Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions cmd/agentsview/mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"net"
"os"
"os/signal"
"path/filepath"
"strconv"
"strings"
"sync"
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
}

Expand Down
46 changes: 46 additions & 0 deletions cmd/agentsview/mcp_status.go
Original file line number Diff line number Diff line change
@@ -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
}
30 changes: 30 additions & 0 deletions cmd/agentsview/mcp_status_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
17 changes: 17 additions & 0 deletions docs/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
27 changes: 22 additions & 5 deletions internal/mcp/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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 <Token>". It has no effect on stdio.
// The command layer sets it for non-loopback HTTP binds so the
Expand Down Expand Up @@ -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
Expand Down
102 changes: 102 additions & 0 deletions internal/mcpdiscovery/discovery.go
Original file line number Diff line number Diff line change
@@ -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
}
91 changes: 91 additions & 0 deletions internal/mcpdiscovery/discovery_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
Loading