From db8d5ac8598b8a869488dea129bc209f94a9eda3 Mon Sep 17 00:00:00 2001 From: Marius van Niekerk Date: Fri, 11 Sep 2026 17:12:24 -0400 Subject: [PATCH 1/2] feat(mcp): expose running HTTP listeners through status MCP clients need the actual listening port and backend target to connect to an existing HTTP server. Fixed port guesses do not work when several servers run or when a listener binds port zero. Publish local listener records after binding and expose them through mcp status. Keep bearer values in private files so status output can locate credentials without printing them. --- cmd/agentsview/mcp.go | 9 +++ cmd/agentsview/mcp_status.go | 46 +++++++++++++ cmd/agentsview/mcp_status_test.go | 30 +++++++++ docs/mcp.md | 14 ++++ internal/mcp/server.go | 27 ++++++-- internal/mcpdiscovery/discovery.go | 89 +++++++++++++++++++++++++ internal/mcpdiscovery/discovery_test.go | 33 +++++++++ 7 files changed, 243 insertions(+), 5 deletions(-) create mode 100644 cmd/agentsview/mcp_status.go create mode 100644 cmd/agentsview/mcp_status_test.go create mode 100644 internal/mcpdiscovery/discovery.go create mode 100644 internal/mcpdiscovery/discovery_test.go diff --git a/cmd/agentsview/mcp.go b/cmd/agentsview/mcp.go index ef652bc878..f3cb24391f 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 0000000000..e3fbec4d04 --- /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 0000000000..beb8bb8349 --- /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 6cb908a007..ceb006734b 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -21,6 +21,20 @@ 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. + +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 44767d01ff..4c347949c2 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 0000000000..88235a41e5 --- /dev/null +++ b/internal/mcpdiscovery/discovery.go @@ -0,0 +1,89 @@ +// Package mcpdiscovery publishes the local HTTP MCP listener for CLI clients. +package mcpdiscovery + +import ( + "errors" + "fmt" + "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. +func Publish(directory, address, token, backendURL string) (func() error, error) { + 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://" + address + "/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 0000000000..8d56693e9a --- /dev/null +++ b/internal/mcpdiscovery/discovery_test.go @@ -0,0 +1,33 @@ +package mcpdiscovery + +import ( + "net" + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// 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) +} From bfd5407952cd50e8ca4ba8f58e310def97247c1e Mon Sep 17 00:00:00 2001 From: Marius van Niekerk Date: Sat, 12 Sep 2026 12:39:36 -0400 Subject: [PATCH 2/2] fix(mcp): publish loopback URLs for wildcard listeners Local clients need a connectable destination from MCP status. Wildcard bind addresses identify listening interfaces rather than client targets, so advertise loopback while retaining the actual assigned port. --- docs/mcp.md | 3 ++ internal/mcpdiscovery/discovery.go | 15 ++++++- internal/mcpdiscovery/discovery_test.go | 58 +++++++++++++++++++++++++ 3 files changed, 75 insertions(+), 1 deletion(-) diff --git a/docs/mcp.md b/docs/mcp.md index ceb006734b..9fb4b5f3de 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -29,6 +29,9 @@ 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 diff --git a/internal/mcpdiscovery/discovery.go b/internal/mcpdiscovery/discovery.go index 88235a41e5..f7e06d5453 100644 --- a/internal/mcpdiscovery/discovery.go +++ b/internal/mcpdiscovery/discovery.go @@ -4,6 +4,7 @@ package mcpdiscovery import ( "errors" "fmt" + "net" "os" "path/filepath" @@ -25,13 +26,25 @@ type Endpoint struct { // 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://" + address + "/mcp", "backend_url": backendURL} + 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-*") diff --git a/internal/mcpdiscovery/discovery_test.go b/internal/mcpdiscovery/discovery_test.go index 8d56693e9a..2dc9f5fd19 100644 --- a/internal/mcpdiscovery/discovery_test.go +++ b/internal/mcpdiscovery/discovery_test.go @@ -1,14 +1,72 @@ 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()