Files
wehub-resource-sync f99010fae1
CI / lint (push) Failing after 1s
CI / frontend (push) Failing after 1s
CI / scripts (push) Failing after 1s
CI / Go Test (ubuntu-latest) (push) Failing after 0s
CI / frontend-node-25 (push) Failing after 1s
CI / docs (push) Failing after 0s
CI / coverage (push) Failing after 0s
CI / e2e (push) Failing after 0s
Docker / build-and-push (push) Failing after 1s
CI / integration (push) Failing after 4m43s
CI / Go Test (windows-latest) (push) Has been cancelled
CI / Desktop Unit Tests (Windows) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Linux (arm64)) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Linux) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Windows) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (aarch64)) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (x86_64)) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:30:36 +08:00

459 lines
13 KiB
Go

// ABOUTME: `agentsview mcp` subcommand — serves the read-only MCP tools
// ABOUTME: over the SessionService seam (stdio by default, or HTTP).
package main
import (
"context"
"errors"
"fmt"
"io"
"net"
"os"
"os/signal"
"strconv"
"strings"
"sync"
"syscall"
"github.com/spf13/cobra"
"go.kenn.io/agentsview/internal/config"
"go.kenn.io/agentsview/internal/db"
mcpserver "go.kenn.io/agentsview/internal/mcp"
"go.kenn.io/agentsview/internal/service"
)
func newMCPCommand() *cobra.Command {
var httpAddr string
var httpAllowInsecure bool
cmd := &cobra.Command{
Use: "mcp",
Short: "Run an MCP server exposing read-only session retrieval tools",
Long: `Start an MCP (Model Context Protocol) server over stdio (default) or
StreamableHTTP, exposing read-only tools for searching and reading
recorded agent sessions: search_sessions, list_sessions,
get_session_overview, get_messages, search_content, and
get_usage_summary.
The server reads through the daemon path. By default each tool call talks to
the local agentsview daemon, starting it when needed so a long-lived MCP server
can recover after the daemon exits due to idleness. Use --server to target an
explicit daemon URL.
Add to your MCP client config (e.g. Claude Desktop):
{
"mcpServers": {
"agentsview": {
"command": "agentsview",
"args": ["mcp"]
}
}
}`,
GroupID: groupData,
SilenceUsage: true,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
svc, cleanup, err := resolveMCPService(cmd)
if err != nil {
return err
}
defer cleanup()
// The CLI runs commands with a plain context (no signal
// handling), so install our own: a long-lived MCP server must
// shut down cleanly on SIGINT/SIGTERM.
ctx, stop := signal.NotifyContext(
cmd.Context(), os.Interrupt, syscall.SIGTERM)
defer stop()
opts := mcpserver.ServeOptions{Service: svc, Version: version}
var serveErr error
if httpAddr != "" {
addr, err := normalizeMCPHTTPAddr(httpAddr, httpAllowInsecure)
if err != nil {
return err
}
// A non-loopback listener must be authenticated, or it is
// an unauthenticated remote read surface over the session
// archive. Loopback binds stay local-trust (no listener
// auth), matching the daemon.
cfg, err := config.LoadPFlags(cmd.Flags())
if err != nil {
return fmt.Errorf("loading config: %w", err)
}
// Provision a token when auth is required but none exists
// yet, matching serve/pg/duckdb so enabling require_auth is
// sufficient for first-time authenticated startup.
if cfg.RequireAuth && cfg.AuthToken == "" {
if err := cfg.EnsureAuthToken(); err != nil {
return fmt.Errorf("provisioning auth token: %w", err)
}
}
token, err := mcpListenerAuth(addr, cfg.AuthToken, cfg.RequireAuth)
if err != nil {
return err
}
opts.Token = token
serveErr = mcpserver.ServeHTTP(ctx, opts, addr)
} else {
serveErr = mcpserver.ServeStdio(ctx, opts)
}
// A SIGINT/SIGTERM-triggered shutdown cancels ctx; that is a
// clean stop, not a failure, so it should not exit non-zero.
if errors.Is(serveErr, context.Canceled) {
return nil
}
return serveErr
},
}
cmd.Flags().StringVar(&httpAddr, "http", "",
"Serve over StreamableHTTP on this address (e.g. 127.0.0.1:8085) "+
"instead of stdio. Bare port forms (':8085', '8085') bind to "+
"loopback only; non-loopback hosts require --http-allow-insecure.")
cmd.Flags().BoolVar(&httpAllowInsecure, "http-allow-insecure", false,
"Allow --http to bind a non-loopback address. A non-loopback bind "+
"requires a configured auth token (auth_token in config.toml, or "+
"enable require_auth) and then enforces Authorization: Bearer on "+
"every request. Only expose it on trusted networks (Tailscale, "+
"VPN-only) or behind an authenticating reverse proxy.")
// Transport-selection flags, mirroring the `session` command.
// Implicit local SQLite reads are daemon-backed; explicit --server
// and --pg select their requested remote/read-store backends.
cmd.Flags().String("server", "", "Remote daemon URL")
cmd.Flags().String("server-token-file", "",
"File containing bearer token for explicit --server requests")
cmd.Flags().Bool("pg", false,
"Read session data from configured PostgreSQL")
return cmd
}
// resolveMCPService constructs the SessionService used by the long-lived
// MCP server. The implicit local path is intentionally daemon-only and
// lazy: every operation re-resolves the daemon transport so a tool call can
// wake the daemon after it exits due to idleness.
func resolveMCPService(
cmd *cobra.Command,
) (service.SessionService, func(), error) {
remote, _ := cmd.Flags().GetString("server")
if remote != "" {
if pgReadRequested(cmd) {
return nil, nil, errors.New(
"--server and --pg are mutually exclusive",
)
}
token, err := explicitServerToken(cmd)
if err != nil {
return nil, nil, err
}
return service.NewHTTPBackend(remote, token, false),
func() {}, nil
}
cfg, err := config.LoadPFlags(cmd.Flags())
if err != nil {
return nil, nil, fmt.Errorf("loading config: %w", err)
}
pgCfg, usePG, err := resolvePGReadConfig(cmd, cfg)
if err != nil {
return nil, nil, err
}
if usePG {
return newPGReadService(cfg, pgCfg)
}
return newMCPDaemonService(cfg), func() {}, nil
}
type mcpDaemonService struct {
mu sync.Mutex
cfg config.Config
}
func newMCPDaemonService(cfg config.Config) service.SessionService {
return &mcpDaemonService{cfg: cfg}
}
func (s *mcpDaemonService) daemonService(
ctx context.Context,
) (service.SessionService, error) {
s.mu.Lock()
defer s.mu.Unlock()
cfg := s.cfg
tr, err := ensureTransportContext(
ctx, &cfg, transportIntentArchiveWrite, 0,
)
if err != nil {
return nil, err
}
if tr.Mode != transportHTTP {
return nil, errors.New(
"agentsview mcp requires a daemon; refusing direct archive access",
)
}
s.cfg.AuthToken = cfg.AuthToken
return service.NewHTTPBackend(tr.URL, cfg.AuthToken, tr.ReadOnly), nil
}
func (s *mcpDaemonService) Get(
ctx context.Context, id string,
) (*service.SessionDetail, error) {
svc, err := s.daemonService(ctx)
if err != nil {
return nil, err
}
return svc.Get(ctx, id)
}
func (s *mcpDaemonService) FindSessionIDsByPartial(
ctx context.Context, partial string, limit int,
) ([]string, error) {
svc, err := s.daemonService(ctx)
if err != nil {
return nil, err
}
return svc.FindSessionIDsByPartial(ctx, partial, limit)
}
func (s *mcpDaemonService) List(
ctx context.Context, f service.ListFilter,
) (*service.SessionList, error) {
svc, err := s.daemonService(ctx)
if err != nil {
return nil, err
}
return svc.List(ctx, f)
}
func (s *mcpDaemonService) Messages(
ctx context.Context, id string, f service.MessageFilter,
) (*service.MessageList, error) {
svc, err := s.daemonService(ctx)
if err != nil {
return nil, err
}
return svc.Messages(ctx, id, f)
}
func (s *mcpDaemonService) ToolCalls(
ctx context.Context, id string,
) (*service.ToolCallList, error) {
svc, err := s.daemonService(ctx)
if err != nil {
return nil, err
}
return svc.ToolCalls(ctx, id)
}
func (s *mcpDaemonService) Sync(
ctx context.Context, in service.SyncInput,
) (*service.SessionDetail, error) {
svc, err := s.daemonService(ctx)
if err != nil {
return nil, err
}
return svc.Sync(ctx, in)
}
func (s *mcpDaemonService) Watch(
ctx context.Context, id string,
) (<-chan service.Event, error) {
svc, err := s.daemonService(ctx)
if err != nil {
return nil, err
}
return svc.Watch(ctx, id)
}
func (s *mcpDaemonService) Stats(
ctx context.Context, f service.StatsFilter,
) (*service.SessionStats, error) {
svc, err := s.daemonService(ctx)
if err != nil {
return nil, err
}
return svc.Stats(ctx, f)
}
func (s *mcpDaemonService) Search(
ctx context.Context, req service.SearchRequest,
) (*service.SessionSearchResult, error) {
svc, err := s.daemonService(ctx)
if err != nil {
return nil, err
}
return svc.Search(ctx, req)
}
func (s *mcpDaemonService) SearchContent(
ctx context.Context, req service.ContentSearchRequest,
) (*service.ContentSearchResult, error) {
svc, err := s.daemonService(ctx)
if err != nil {
return nil, err
}
return svc.SearchContent(ctx, req)
}
func (s *mcpDaemonService) UsageSummary(
ctx context.Context, req service.UsageRequest,
) (*service.UsageSummaryResult, error) {
svc, err := s.daemonService(ctx)
if err != nil {
return nil, err
}
return svc.UsageSummary(ctx, req)
}
func (s *mcpDaemonService) UsagePairwiseComparison(
ctx context.Context, req service.UsagePairwiseComparisonRequest,
) (*service.UsagePairwiseComparisonResponse, error) {
svc, err := s.daemonService(ctx)
if err != nil {
return nil, err
}
return svc.UsagePairwiseComparison(ctx, req)
}
func (s *mcpDaemonService) ListRecallEntries(
ctx context.Context, f service.RecallFilter,
) (*service.RecallList, error) {
svc, err := s.daemonService(ctx)
if err != nil {
return nil, err
}
return svc.ListRecallEntries(ctx, f)
}
func (s *mcpDaemonService) GetRecallEntry(
ctx context.Context, id string,
) (*db.RecallEntry, error) {
svc, err := s.daemonService(ctx)
if err != nil {
return nil, err
}
return svc.GetRecallEntry(ctx, id)
}
func (s *mcpDaemonService) QueryRecallEntries(
ctx context.Context, req service.RecallQuery,
) (*service.RecallQueryResult, error) {
svc, err := s.daemonService(ctx)
if err != nil {
return nil, err
}
return svc.QueryRecallEntries(ctx, req)
}
func (s *mcpDaemonService) ImportRecallEntries(
ctx context.Context, r io.Reader, opts db.RecallImportOptions,
) (*db.RecallImportResult, error) {
svc, err := s.daemonService(ctx)
if err != nil {
return nil, err
}
return svc.ImportRecallEntries(ctx, r, opts)
}
func (s *mcpDaemonService) ListSecrets(
ctx context.Context, f service.SecretListFilter,
) (*service.SecretFindingList, error) {
svc, err := s.daemonService(ctx)
if err != nil {
return nil, err
}
return svc.ListSecrets(ctx, f)
}
func (s *mcpDaemonService) ScanSecrets(
ctx context.Context, in service.SecretScanInput,
progress func(service.SecretScanProgress),
) (*service.SecretScanSummary, error) {
svc, err := s.daemonService(ctx)
if err != nil {
return nil, err
}
return svc.ScanSecrets(ctx, in, progress)
}
// mcpListenerAuth decides the bearer token the MCP HTTP listener must
// enforce for the given (already-normalized) bind address. A loopback bind
// is local-trust and runs without listener auth (empty token) UNLESS
// require_auth is set, which forces authentication on every bind so a
// forwarded loopback port (reverse proxy, SSH tunnel) is never an
// unauthenticated surface. A non-loopback bind, or any bind under
// require_auth, must be authenticated: it returns the configured token, or
// an error when none is set, so the network-reachable surface is never
// unauthenticated.
func mcpListenerAuth(addr, configuredToken string, requireAuth bool) (string, error) {
host, _, err := net.SplitHostPort(addr)
if err != nil {
return "", fmt.Errorf("parsing --http address %q: %w", addr, err)
}
if isLoopbackHost(host) && !requireAuth {
return "", nil
}
if configuredToken == "" {
return "", fmt.Errorf(
"--http %q requires an auth token but none is configured; set "+
"auth_token in config.toml (or enable require_auth) so the MCP "+
"server enforces Authorization: Bearer, or bind a loopback "+
"address without require_auth",
addr)
}
return configuredToken, nil
}
// normalizeMCPHTTPAddr canonicalises a --http argument and rejects values
// that would expose the unauthenticated MCP server on a non-loopback
// interface unless the user has explicitly opted in.
//
// Forms accepted:
// - "8085" -> "127.0.0.1:8085" (loopback)
// - ":8085" -> "127.0.0.1:8085" (loopback; Go's default would be
// all-interfaces, which is the footgun this guards against)
// - "127.0.0.1:8085" -> unchanged (loopback, allowed)
// - "[::1]:8085" -> unchanged (loopback, allowed)
// - "192.168.1.5:8085", "0.0.0.0:8085", "host.local:8085" -> rejected
// unless allowInsecure is set
func normalizeMCPHTTPAddr(addr string, allowInsecure bool) (string, error) {
trimmed := strings.TrimSpace(addr)
if trimmed == "" {
return "", errors.New("--http requires an address")
}
// Bare port: "8085" or ":8085".
if !strings.Contains(trimmed, ":") {
if _, convErr := strconv.Atoi(trimmed); convErr == nil {
return "127.0.0.1:" + trimmed, nil
}
return "", fmt.Errorf("--http %q: not a port and not host:port", trimmed)
}
if strings.HasPrefix(trimmed, ":") {
return "127.0.0.1" + trimmed, nil
}
host, _, splitErr := net.SplitHostPort(trimmed)
if splitErr != nil {
return "", fmt.Errorf("--http %q: %w", trimmed, splitErr)
}
// isLoopbackHost (shared with managed_caddy.go) treats an empty host as
// NOT loopback, which guards the "[]:8085" footgun where an empty host
// passes net.SplitHostPort yet binds to all interfaces.
if isLoopbackHost(host) {
return trimmed, nil
}
if !allowInsecure {
return "", fmt.Errorf(
"--http %q: refusing to bind a non-loopback address without "+
"--http-allow-insecure (the MCP server has no built-in "+
"authentication; only opt in on trusted networks or behind "+
"an authenticating reverse proxy)", trimmed)
}
return trimmed, nil
}