diff --git a/cmd/agentsview/mcp.go b/cmd/agentsview/mcp.go index ef652bc87..f3cb24391 100644 --- a/cmd/agentsview/mcp.go +++ b/cmd/agentsview/mcp.go @@ -10,6 +10,7 @@ import ( "net" "os" "os/signal" + "path/filepath" "strconv" "strings" "sync" @@ -96,6 +97,13 @@ Add to your MCP client config (e.g. Claude Desktop): return err } opts.Token = token + opts.DiscoveryDirectory = filepath.Join(cfg.DataDir, "mcp") + opts.BackendURL, _ = cmd.Flags().GetString("server") + if opts.BackendURL == "" && !pgReadRequested(cmd) { + if runtime := FindDaemonRuntime(cfg.DataDir, cfg.AuthToken); runtime != nil { + opts.BackendURL = runtime.Record.Endpoint().BaseURL() + } + } serveErr = mcpserver.ServeHTTP(ctx, opts, addr) } else { serveErr = mcpserver.ServeStdio(ctx, opts) @@ -129,6 +137,7 @@ Add to your MCP client config (e.g. Claude Desktop): cmd.Flags().Bool("pg", false, "Read session data from configured PostgreSQL") + cmd.AddCommand(newMCPStatusCommand()) return cmd } diff --git a/cmd/agentsview/mcp_status.go b/cmd/agentsview/mcp_status.go new file mode 100644 index 000000000..e3fbec4d0 --- /dev/null +++ b/cmd/agentsview/mcp_status.go @@ -0,0 +1,46 @@ +package main + +import ( + "encoding/json/v2" + "fmt" + "path/filepath" + + "github.com/spf13/cobra" + "go.kenn.io/agentsview/internal/config" + "go.kenn.io/agentsview/internal/mcpdiscovery" +) + +func newMCPStatusCommand() *cobra.Command { + command := &cobra.Command{ + Use: "status", + Short: "List running HTTP MCP listeners", + Args: cobra.NoArgs, + PersistentPreRunE: func(*cobra.Command, []string) error { return nil }, + RunE: func(command *cobra.Command, _ []string) error { + cfg, err := config.LoadReadOnly() + if err != nil { + return err + } + directory := filepath.Join(cfg.DataDir, "mcp") + endpoints, err := mcpdiscovery.List(directory) + if err != nil { + return err + } + if outputFormat(command) == "json" { + return json.MarshalWrite(command.OutOrStdout(), endpoints) + } + if len(endpoints) == 0 { + _, err := fmt.Fprintln(command.OutOrStdout(), "No HTTP MCP listeners are running.") + return err + } + for _, endpoint := range endpoints { + if _, err := fmt.Fprintf(command.OutOrStdout(), "MCP %s (pid %d)\n", endpoint.URL, endpoint.PID); err != nil { + return err + } + } + return nil + }, + } + registerFormatFlags(command.Flags()) + return command +} diff --git a/cmd/agentsview/mcp_status_test.go b/cmd/agentsview/mcp_status_test.go new file mode 100644 index 000000000..beb8bb834 --- /dev/null +++ b/cmd/agentsview/mcp_status_test.go @@ -0,0 +1,30 @@ +package main + +import ( + "bytes" + "encoding/json/v2" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.kenn.io/agentsview/internal/mcpdiscovery" +) + +func TestMCPStatusJSONReportsPublishedListener(t *testing.T) { + home := t.TempDir() + t.Setenv("AGENTSVIEW_DATA_DIR", home) + cleanup, err := mcpdiscovery.Publish(filepath.Join(home, "mcp"), "127.0.0.1:9876", "", "http://127.0.0.1:4321") + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, cleanup()) }) + command := newMCPStatusCommand() + command.SetArgs([]string{"--json"}) + var output bytes.Buffer + command.SetOut(&output) + require.NoError(t, command.Execute()) + var rows []mcpdiscovery.Endpoint + require.NoError(t, json.Unmarshal(output.Bytes(), &rows)) + require.Len(t, rows, 1) + assert.Equal(t, "http://127.0.0.1:9876/mcp", rows[0].URL) + assert.Equal(t, "http://127.0.0.1:4321", rows[0].BackendURL) +} diff --git a/docs/mcp.md b/docs/mcp.md index 6cb908a00..9fb4b5f3d 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -21,6 +21,23 @@ Use the MCP server when you want a coding assistant to answer questions such as: The tools are read-only. They expose session history and usage data, but they do not mutate the archive or resync files directly. +### Discover running HTTP listeners + +Run `agentsview mcp status --json` to list HTTP MCP listeners started by this +version. The command reads local runtime records without starting a server. Each +entry includes `transport`, `url`, `pid`, `backend_url` when known, and +`token_path` when the listener requires a bearer token. Read the token from that +private file; the status output does not print it. + +For wildcard binds, the URL uses loopback (`127.0.0.1` for IPv4 or `::1` for +IPv6) so local clients can connect. Other bound addresses remain unchanged. + +The listener publishes its actual bound port after startup, including when +started with port zero, and removes its record on orderly shutdown. Status omits +records whose process has exited. Stdio sessions are not listening endpoints and +do not appear. An empty JSON list means no HTTP listeners were found in this +application's configured data directory. + ## Quick Start For local desktop-style MCP clients, use stdio: diff --git a/internal/mcp/server.go b/internal/mcp/server.go index 44767d01f..4c347949c 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -8,11 +8,14 @@ import ( "errors" "fmt" "io" + "net" "net/http" "os" "strings" "time" + "go.kenn.io/agentsview/internal/mcpdiscovery" + "github.com/modelcontextprotocol/go-sdk/mcp" "go.kenn.io/agentsview/internal/service" @@ -35,9 +38,11 @@ const ( // tests can control the self-reference exclusion window (defaults to // time.Now). type ServeOptions struct { - Service service.SessionService - Version string - Now func() time.Time + DiscoveryDirectory string + BackendURL string + Service service.SessionService + Version string + Now func() time.Time // Token, when non-empty, requires every StreamableHTTP request to // carry "Authorization: Bearer ". It has no effect on stdio. // The command layer sets it for non-loopback HTTP binds so the @@ -209,13 +214,25 @@ func isCleanStdioShutdown(err error) bool { // cancelled the HTTP server is shut down gracefully so in-flight tool // calls can finish. addr must already be validated as a safe bind // address (see the cmd layer's loopback guard). -func ServeHTTP(ctx context.Context, opts ServeOptions, addr string) error { +func ServeHTTP(ctx context.Context, opts ServeOptions, addr string) (result error) { + listener, err := net.Listen("tcp", addr) + if err != nil { + return err + } + defer func() { _ = listener.Close() }() + if opts.DiscoveryDirectory != "" { + cleanup, err := mcpdiscovery.Publish(opts.DiscoveryDirectory, listener.Addr().String(), opts.Token, opts.BackendURL) + if err != nil { + return err + } + defer func() { result = errors.Join(result, cleanup()) }() + } httpServer := &http.Server{Addr: addr, Handler: newHTTPHandler(opts)} fmt.Fprintf(os.Stderr, "agentsview mcp: serving on %s\n", addr) errCh := make(chan error, 1) go func() { - err := httpServer.ListenAndServe() + err := httpServer.Serve(listener) if err != nil && !errors.Is(err, http.ErrServerClosed) { errCh <- err return diff --git a/internal/mcpdiscovery/discovery.go b/internal/mcpdiscovery/discovery.go new file mode 100644 index 000000000..f7e06d545 --- /dev/null +++ b/internal/mcpdiscovery/discovery.go @@ -0,0 +1,102 @@ +// Package mcpdiscovery publishes the local HTTP MCP listener for CLI clients. +package mcpdiscovery + +import ( + "errors" + "fmt" + "net" + "os" + "path/filepath" + + "go.kenn.io/kit/daemon" + "go.kenn.io/kit/safefileio" +) + +const service = "agentsview-mcp" + +// Endpoint describes an existing listener. TokenPath locates a private file; +// status output never includes the bearer token itself. +type Endpoint struct { + PID int `json:"pid"` + Transport string `json:"transport"` + URL string `json:"url"` + BackendURL string `json:"backend_url,omitempty"` + TokenPath string `json:"token_path,omitempty"` +} + +// Publish runs only after bind succeeds. The caller removes discovery state +// after the listener closes, including shutdown caused by a serving error. +// address is the bound listener address; wildcard hosts use loopback in the URL. +func Publish(directory, address, token, backendURL string) (func() error, error) { + host, port, err := net.SplitHostPort(address) + if err != nil { + return nil, fmt.Errorf("parse MCP listener address: %w", err) + } + if ip := net.ParseIP(host); ip.IsUnspecified() { + if ip.To4() != nil { + host = "127.0.0.1" + } else { + host = "::1" + } + } + if err := os.MkdirAll(directory, 0o700); err != nil { + return nil, err + } + store := daemon.RuntimeStore{Dir: directory, Prefix: "mcp"} + rec := daemon.NewRuntimeRecord(service, "", daemon.Endpoint{Network: "tcp", Address: address}) + rec.Metadata = map[string]string{"url": "http://" + net.JoinHostPort(host, port) + "/mcp", "backend_url": backendURL} + tokenPath := "" + if token != "" { + file, err := os.CreateTemp(directory, "mcp-token-*") + if err != nil { + return nil, err + } + tokenPath = file.Name() + _, writeErr := file.WriteString(token) + if err := errors.Join(writeErr, file.Close()); err != nil { + _ = os.Remove(tokenPath) + return nil, err + } + rec.Metadata["token_path"] = tokenPath + } + recordPath, err := store.Write(rec) + if err != nil { + if tokenPath != "" { + _ = os.Remove(tokenPath) + } + return nil, err + } + return func() error { + recordErr := os.Remove(recordPath) + var tokenErr error + if tokenPath != "" { + tokenErr = os.Remove(tokenPath) + } + return errors.Join(recordErr, tokenErr) + }, nil +} + +// List is observational: it never starts a daemon or prunes another process's +// records. Dead-process records are omitted, including after an unclean exit. +func List(directory string) ([]Endpoint, error) { + endpoints := []Endpoint{} + if _, err := os.Stat(directory); errors.Is(err, os.ErrNotExist) { + return endpoints, nil + } else if err != nil { + return nil, err + } + if err := safefileio.ValidatePrivateDir(directory); err != nil { + return nil, err + } + records, err := (daemon.RuntimeStore{Dir: filepath.Clean(directory), Prefix: "mcp"}).List() + if err != nil { + return nil, fmt.Errorf("read MCP listener status: %w", err) + } + for _, rec := range records { + if rec.Service != service || !daemon.ProcessAlive(rec.PID) { + continue + } + endpoints = append(endpoints, Endpoint{PID: rec.PID, Transport: "http", URL: rec.Metadata["url"], BackendURL: rec.Metadata["backend_url"], TokenPath: rec.Metadata["token_path"]}) + } + return endpoints, nil +} diff --git a/internal/mcpdiscovery/discovery_test.go b/internal/mcpdiscovery/discovery_test.go new file mode 100644 index 000000000..2dc9f5fd1 --- /dev/null +++ b/internal/mcpdiscovery/discovery_test.go @@ -0,0 +1,91 @@ +package mcpdiscovery + +import ( + "io" + "net" + "net/http" + "net/http/httptest" + "net/url" + "os" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPublishedURLConnectsToListener(t *testing.T) { + for _, tc := range []struct { + name string + network string + address string + host string + }{ + {"IPv4 wildcard", "tcp4", "0.0.0.0:0", "127.0.0.1"}, + {"IPv6 wildcard", "tcp6", "[::]:0", "::1"}, + {"IPv4 loopback", "tcp4", "127.0.0.1:0", "127.0.0.1"}, + {"IPv6 loopback", "tcp6", "[::1]:0", "::1"}, + } { + t.Run(tc.name, func(t *testing.T) { + listener, err := net.Listen(tc.network, tc.address) + if err != nil && tc.network == "tcp6" { + t.Skipf("IPv6 listener unavailable: %v", err) + } + require.NoError(t, err) + server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/mcp" { + http.NotFound(w, r) + return + } + _, _ = io.WriteString(w, "discovered listener") + })) + require.NoError(t, server.Listener.Close()) + server.Listener = listener + server.Start() + t.Cleanup(server.Close) + dir := t.TempDir() + require.NoError(t, os.Chmod(dir, 0o700)) + cleanup, err := Publish(dir, listener.Addr().String(), "", "") + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, cleanup()) }) + rows, err := List(dir) + require.NoError(t, err) + require.Len(t, rows, 1) + endpoint, err := url.Parse(rows[0].URL) + require.NoError(t, err) + assert.Equal(t, tc.host, endpoint.Hostname()) + client := server.Client() + client.Timeout = 5 * time.Second + response, err := client.Get(rows[0].URL) + require.NoError(t, err) + defer response.Body.Close() + body, err := io.ReadAll(response.Body) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, response.StatusCode) + assert.Equal(t, "discovered listener", string(body)) + }) + } +} + +// Listener publication, status, and cleanup are the client discovery contract. +func TestPublishedListenerStatusAndCleanup(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.Chmod(dir, 0o700)) + listener, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, listener.Close()) }) + cleanup, err := Publish(dir, listener.Addr().String(), "test-listener-token", "http://127.0.0.1:4321") + require.NoError(t, err) + rows, err := List(dir) + require.NoError(t, err) + require.Len(t, rows, 1) + assert.Equal(t, "http://"+listener.Addr().String()+"/mcp", rows[0].URL) + assert.Equal(t, "http://127.0.0.1:4321", rows[0].BackendURL) + token, err := os.ReadFile(rows[0].TokenPath) + require.NoError(t, err) + assert.Equal(t, "test-listener-token", string(token)) + require.NoError(t, cleanup()) + rows, err = List(dir) + require.NoError(t, err) + assert.Empty(t, rows) +}