refactor: consolidate agent MCP onto a single persistent engine (#26599)

Two MCP code paths both spawned the servers declared in a workspace's
`.mcp.json`: the persistent engine in `agent/x/agentmcp` (which owns
tool-call execution via `CallTool`) and an ephemeral one-shot runner in
`agent/agentcontext` (`mcprunner.go`) that connected, listed tools, and
immediately closed each server purely for discovery. Every declared
server was launched twice, and the discovery path duplicated the
engine's `.mcp.json` parse, transport-build, env-resolve, and connect
logic.

This makes `agent/x/agentmcp` the single persistent MCP engine. The
`agentcontext` manager now reads that engine's per-server catalog
in-process through an injected `MCPCatalog` option and surfaces each
server as a `KindMCPServer` resource. The engine wires `SetOnReload` to
the manager's `Trigger`, so a reload (startup connect or `.mcp.json`
edit) re-resolves and re-pushes the pinned resources. Tool-call
execution is unchanged: it still flows through the engine's `CallTool`
over `POST /api/v0/mcp/call-tool`.

The now-dead HTTP discovery surface is removed: the agent `GET
/api/v0/mcp/tools` route with `agentmcp.API.handleListTools`, and
`workspacesdk.AgentConn.ListMCPTools` with `ListMCPToolsResponse` (mock
regenerated). The change nets roughly `-1370` lines, mostly the deleted
duplicate runner and its tests.

<details>
<summary>Decision log</summary>

The merge of #26585 made pinned `chat_context_resources` the sole source
of workspace context, which surfaced the duplicate spawning. Two options
were considered:

- **Option A + dependency injection (chosen):** keep `agent/x/agentmcp`
as the single persistent engine; `agentcontext` consumes its catalog
in-process and stays the orchestrator/owner at the API boundary (it
still pushes `KindMCPServer` resources). This is low-risk because
`agentcontext` already exposed the `resolver.MCPResources` seam, so the
change just rebinds it from the ephemeral runner to the shared engine.
- **Option B (rejected):** reimplement persistent pooling, reconnect,
singleflight, and race handling inside `agentcontext` and delete
`agentmcp`. Too broad, and it discards the engine's tested lifecycle for
no behavioral gain.

`agentcontext`'s discovery was never what kept servers alive; its runner
closed each server immediately after listing tools. The component
holding persistent connections was always `agentmcp`, which is why
execution already lived there. Consolidating onto it removes the
duplicated stack rather than a whole package: both packages survive with
distinct roles (`agentmcp` is the engine, `agentcontext` is the
orchestrator/owner).

</details>

Coder Agents generated on behalf of @kylecarbs
This commit is contained in:
Kyle Carberry
2026-06-22 22:21:58 -06:00
committed by GitHub
parent 0f37522e6f
commit 27ecd17991
20 changed files with 433 additions and 1817 deletions
+8 -8
View File
@@ -23,12 +23,12 @@
// to coderd without coupling this package to any particular
// drpc client version.
//
// Live MCP server tool lists are produced by this package's own
// self-contained MCP runner: it connects to the MCP servers declared in
// the .mcp.json files the resolver discovers, lists their tools, and
// surfaces them as KindMCPServer resources so MCP servers and their
// tools are pushed to coderd alongside instruction files and skills.
// This runs independently of agent/x/agentmcp, which owns the agent's
// MCP HTTP proxy; the two MCP paths share no state and both continue to
// operate unchanged during the rollout.
// Live MCP server tool lists come from the shared MCP engine in
// agent/x/agentmcp, which owns the single set of MCP server connections
// used for both tool discovery and tool-call execution. This package
// reads that engine's catalog through the injected MCPCatalog option and
// surfaces the servers and their tools as KindMCPServer resources, so
// MCP servers are pushed to coderd alongside instruction files and
// skills. The engine notifies this package through the Manager's Trigger
// when its catalog changes, driving a re-resolve and re-push.
package agentcontext
+17 -36
View File
@@ -9,7 +9,6 @@ import (
"golang.org/x/xerrors"
"cdr.dev/slog/v3"
"github.com/coder/coder/v2/agent/agentexec"
"github.com/coder/quartz"
)
@@ -39,18 +38,13 @@ type ManagerOptions struct {
// Tests use this to inject MCP resources (via
// Resolver.MCPResources) and tighten caps.
Resolver *Resolver
// MCPExecer, when non-nil, enables the self-contained MCP
// runner: the Manager connects to the MCP servers declared
// in the .mcp.json files it discovers, lists their tools,
// and surfaces them as KindMCPServer resources in every
// snapshot. The runner uses this Execer to launch stdio MCP
// servers. It is ignored when the resolver already has an
// MCP provider (e.g. a test injecting one via Resolver).
MCPExecer agentexec.Execer
// MCPUpdateEnv optionally enriches the environment handed to
// stdio MCP servers (typically the agent's per-command env).
// Used only when MCPExecer is set; may be nil.
MCPUpdateEnv func([]string) ([]string, error)
// MCPCatalog, when non-nil, supplies the per-server MCP snapshot
// the Manager surfaces as KindMCPServer resources on every
// resolve. The agent injects the shared MCP engine's catalog here
// so discovery and execution use one set of server connections.
// It is ignored when the resolver already has an MCP provider
// (e.g. a test injecting one via Resolver).
MCPCatalog func() []MCPServerStatus
// Debounce overrides the watcher's debounce window.
Debounce time.Duration
}
@@ -73,11 +67,7 @@ type Manager struct {
workingDir func() string
allowedRoots []string
resolver *Resolver
// mcpRunner, when non-nil, owns the agent's self-contained
// MCP connection lifecycle and feeds the resolver's MCP
// provider. runMCPSync (started by Run) drives its reloads.
mcpRunner *mcpRunner
debounce time.Duration
debounce time.Duration
mu sync.Mutex
sources []Source
@@ -152,17 +142,16 @@ func NewManager(opts ManagerOptions) *Manager {
runStartedCh: make(chan struct{}),
}
// Enable the self-contained MCP runner unless the resolver
// already has a provider (tests inject one via Resolver). The
// runner connects to the .mcp.json servers the resolver
// discovers and surfaces their tools as KindMCPServer
// resources; runMCPSync (started in Run) drives its reloads.
// The provider must be wired before the eager first resolve
// below so the seam is present from the first snapshot.
if resolver.MCPResources == nil && opts.MCPExecer != nil {
m.mcpRunner = newMCPRunner(m.logger.Named("mcp"), opts.MCPExecer, opts.MCPUpdateEnv, m.Trigger)
// Surface the shared MCP engine's catalog as KindMCPServer
// resources unless the resolver already has a provider (tests
// inject one via Resolver). The engine owns the connection
// lifecycle and notifies this Manager via Trigger when its
// catalog changes (see agent wiring). The provider must be wired
// before the eager first resolve below so the seam is present
// from the first snapshot.
if resolver.MCPResources == nil && opts.MCPCatalog != nil {
resolver.MCPResources = func() []Resource {
return buildMCPServerResources(m.mcpRunner.Servers())
return buildMCPServerResources(opts.MCPCatalog())
}
}
@@ -235,14 +224,6 @@ func (m *Manager) Run(ctx context.Context) error {
defer watcher.Close()
// Drive MCP server reloads from discovered .mcp.json files for
// the lifetime of Run. Started here (not in NewManager) so it
// runs alongside the trigger loop that consumes its re-resolve
// signals.
if m.mcpRunner != nil {
go m.runMCPSync(ctx)
}
for {
select {
case <-ctx.Done():
-7
View File
@@ -25,13 +25,6 @@ import (
// Claude config files into snapshots and breaks every
// Len(Resources, N) assertion.
func TestMain(m *testing.M) {
// The MCP runner re-execs this test binary as a fake stdio MCP
// server (TEST_MCP_FAKE_SERVER=1). Serve and exit before any test
// setup runs.
if maybeServeFakeMCPServer() {
os.Exit(0)
}
home, err := os.MkdirTemp("", "agentcontext-test-home-")
if err != nil {
panic(err)
+77
View File
@@ -0,0 +1,77 @@
package agentcontext_test
import (
"sync"
"testing"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/agent/agentcontext"
"github.com/coder/coder/v2/testutil"
)
// TestManager_MCPCatalogSurfacesResources verifies the injected MCP
// catalog is surfaced as KindMCPServer resources, and that a catalog
// change picked up on the next Trigger re-resolves the snapshot. In
// production the shared MCP engine wires SetOnReload to the Manager's
// Trigger so a reload re-publishes the updated tools.
func TestManager_MCPCatalogSurfacesResources(t *testing.T) {
t.Parallel()
dir := t.TempDir()
var mu sync.Mutex
servers := []agentcontext.MCPServerStatus{{
Name: "srv",
Connected: true,
Tools: []agentcontext.MCPTool{{Name: "echo", Description: "echoes input"}},
}}
m := newTestManager(t, agentcontext.ManagerOptions{
WorkingDir: func() string { return dir },
MCPCatalog: func() []agentcontext.MCPServerStatus {
mu.Lock()
defer mu.Unlock()
return append([]agentcontext.MCPServerStatus(nil), servers...)
},
})
// The eager first snapshot already reflects the injected catalog.
got := findMCPServerResource(m.Snapshot(), "srv")
require.NotNil(t, got)
require.Equal(t, agentcontext.StatusOK, got.Status)
require.Len(t, got.Tools, 1)
require.Equal(t, "echo", got.Tools[0].Name)
ctx := testutil.Context(t, testutil.WaitLong)
go func() { _ = m.Run(ctx) }()
// A catalog change re-resolves on the next Trigger.
mu.Lock()
servers = []agentcontext.MCPServerStatus{{
Name: "srv",
Connected: true,
Tools: []agentcontext.MCPTool{
{Name: "echo"},
{Name: "ping"},
},
}}
mu.Unlock()
m.Trigger()
require.Eventually(t, func() bool {
got := findMCPServerResource(m.Snapshot(), "srv")
return got != nil && len(got.Tools) == 2
}, testutil.WaitShort, testutil.IntervalMedium,
"catalog change should re-resolve into the snapshot")
}
// findMCPServerResource returns the KindMCPServer resource for the named
// server, or nil if absent.
func findMCPServerResource(snap agentcontext.Snapshot, name string) *agentcontext.Resource {
for i := range snap.Resources {
if r := snap.Resources[i]; r.Kind == agentcontext.KindMCPServer && r.Source == name {
return &snap.Resources[i]
}
}
return nil
}
-189
View File
@@ -1,189 +0,0 @@
package agentcontext_test
import (
"bufio"
"encoding/json"
"fmt"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/agent/agentcontext"
"github.com/coder/coder/v2/agent/agentexec"
"github.com/coder/coder/v2/testutil"
)
// TestManager_MCPServerToolsInSnapshot exercises the MCP runner end to
// end against a real subprocess: a .mcp.json in the working directory is
// discovered by the resolver, the runner connects the declared stdio
// server, lists its tools, and they surface as a KindMCPServer resource
// in the manager's snapshot (the same snapshot that is pushed to coderd).
func TestManager_MCPServerToolsInSnapshot(t *testing.T) {
t.Parallel()
dir := t.TempDir()
writeMCPConfig(t, dir, "fake", map[string]string{"TEST_MCP_FAKE_SERVER": "1"})
m := newTestManager(t, agentcontext.ManagerOptions{
WorkingDir: func() string { return dir },
MCPExecer: agentexec.DefaultExecer,
})
ctx := testutil.Context(t, testutil.WaitLong)
go func() { _ = m.Run(ctx) }()
require.Eventually(t, func() bool {
return findMCPServer(m.Snapshot(), "fake") != nil
}, testutil.WaitLong, testutil.IntervalMedium,
"the connected MCP server's tools should surface in the snapshot")
got := findMCPServer(m.Snapshot(), "fake")
require.NotNil(t, got)
require.Equal(t, agentcontext.StatusOK, got.Status)
require.Len(t, got.Tools, 1)
require.Equal(t, "echo", got.Tools[0].Name)
require.Equal(t, "echoes input", got.Tools[0].Description)
}
// TestManager_MCPServerHangingCloseDoesNotStall is a regression test for
// a server that ignores stdin-close. mcp-go's stdio Close() closes stdin
// and then blocks on cmd.Wait(); without a force-kill the runner's
// per-server connect (and thus the whole reload) would hang and the
// tools would never be published. The runner force-kills the subprocess,
// so the tool still surfaces in the snapshot.
func TestManager_MCPServerHangingCloseDoesNotStall(t *testing.T) {
t.Parallel()
dir := t.TempDir()
writeMCPConfig(t, dir, "hang", map[string]string{
"TEST_MCP_FAKE_SERVER": "1",
"TEST_MCP_HANG_AFTER_LIST": "1",
})
m := newTestManager(t, agentcontext.ManagerOptions{
WorkingDir: func() string { return dir },
MCPExecer: agentexec.DefaultExecer,
})
ctx := testutil.Context(t, testutil.WaitLong)
go func() { _ = m.Run(ctx) }()
require.Eventually(t, func() bool {
got := findMCPServer(m.Snapshot(), "hang")
return got != nil && got.Status == agentcontext.StatusOK
}, testutil.WaitLong, testutil.IntervalMedium,
"a hanging MCP server must not stall the reload; its tool should still surface")
}
// findMCPServer returns the KindMCPServer resource for the named server,
// or nil if absent.
func findMCPServer(snap agentcontext.Snapshot, name string) *agentcontext.Resource {
for i := range snap.Resources {
if r := snap.Resources[i]; r.Kind == agentcontext.KindMCPServer && r.Source == name {
return &r
}
}
return nil
}
// writeMCPConfig writes a .mcp.json into dir declaring a single stdio MCP
// server that re-execs this test binary into serveFakeMCPServer (via the
// TEST_MCP_FAKE_SERVER env, which TestMain handles).
func writeMCPConfig(t *testing.T, dir, name string, env map[string]string) {
t.Helper()
testBin, err := os.Executable()
require.NoError(t, err)
cfg := map[string]any{
"mcpServers": map[string]any{
name: map[string]any{
"command": testBin,
"env": env,
},
},
}
data, err := json.Marshal(cfg)
require.NoError(t, err)
require.NoError(t, os.WriteFile(filepath.Join(dir, ".mcp.json"), data, 0o600))
}
// maybeServeFakeMCPServer serves the fake stdio MCP server when
// TEST_MCP_FAKE_SERVER=1 and reports whether it handled the process so
// the caller (TestMain) can exit. The runner re-execs the test binary
// into this, so it must run at the very top of TestMain. When
// TEST_MCP_HANG_AFTER_LIST=1 the server blocks after serving instead of
// returning, simulating a server that ignores stdin-close so a test can
// exercise the runner's force-kill (the process is then killed by the
// parent and never returns here).
func maybeServeFakeMCPServer() (served bool) {
if os.Getenv("TEST_MCP_FAKE_SERVER") != "1" {
return false
}
serveFakeMCPServer()
if os.Getenv("TEST_MCP_HANG_AFTER_LIST") == "1" {
select {}
}
return true
}
// serveFakeMCPServer serves a minimal MCP protocol over stdin/stdout: it
// answers initialize and advertises a single "echo" tool, then returns
// when the client closes stdin (EOF).
func serveFakeMCPServer() {
scanner := bufio.NewScanner(os.Stdin)
for scanner.Scan() {
line := scanner.Bytes()
var req struct {
JSONRPC string `json:"jsonrpc"`
ID json.RawMessage `json:"id"`
Method string `json:"method"`
}
if err := json.Unmarshal(line, &req); err != nil {
continue
}
var resp any
switch req.Method {
case "initialize":
resp = map[string]any{
"jsonrpc": "2.0",
"id": req.ID,
"result": map[string]any{
"protocolVersion": "2025-03-26",
"capabilities": map[string]any{"tools": map[string]any{}},
"serverInfo": map[string]any{"name": "fake-server", "version": "0.0.1"},
},
}
case "notifications/initialized":
// Notifications take no response.
continue
case "tools/list":
resp = map[string]any{
"jsonrpc": "2.0",
"id": req.ID,
"result": map[string]any{
"tools": []map[string]any{
{
"name": "echo",
"description": "echoes input",
"inputSchema": map[string]any{
"type": "object",
"properties": map[string]any{},
},
},
},
},
}
default:
resp = map[string]any{
"jsonrpc": "2.0",
"id": req.ID,
"error": map[string]any{"code": -32601, "message": "method not found"},
}
}
out, err := json.Marshal(resp)
if err != nil {
continue
}
_, _ = fmt.Fprintf(os.Stdout, "%s\n", out)
}
}
-502
View File
@@ -1,502 +0,0 @@
package agentcontext
import (
"context"
"encoding/hex"
"encoding/json"
"errors"
"io/fs"
"os"
"os/exec"
"reflect"
"slices"
"strings"
"sync"
"time"
"github.com/mark3labs/mcp-go/client"
"github.com/mark3labs/mcp-go/client/transport"
"github.com/mark3labs/mcp-go/mcp"
"golang.org/x/sync/errgroup"
"golang.org/x/xerrors"
"cdr.dev/slog/v3"
"github.com/coder/coder/v2/agent/agentexec"
"github.com/coder/coder/v2/agent/usershell"
"github.com/coder/coder/v2/buildinfo"
)
// mcpConnectTimeout bounds how long the runner waits for a single MCP
// server to start its transport, initialize, and report its tools.
const mcpConnectTimeout = 30 * time.Second
// mcpConnectConcurrency bounds how many MCP servers the runner connects
// to at once. .mcp.json files rarely declare many servers, but the cap
// keeps a pathological config from spawning an unbounded number of
// subprocesses simultaneously.
const mcpConnectConcurrency = 8
// mcpServerConfig is a single MCP server declaration parsed from a
// .mcp.json file. It is the runner's self-contained equivalent of the
// agent/x/agentmcp ServerConfig: agentcontext deliberately does not
// import that package so the two MCP paths stay completely separate.
type mcpServerConfig struct {
Name string
Transport string
Command string
Args []string
Env map[string]string
URL string
Headers map[string]string
}
// mcpConfigFile mirrors the on-disk .mcp.json schema.
type mcpConfigFile struct {
MCPServers map[string]json.RawMessage `json:"mcpServers"`
}
// mcpServerEntry is a single server block inside mcpServers.
type mcpServerEntry struct {
Command string `json:"command"`
Args []string `json:"args"`
Env map[string]string `json:"env"`
Type string `json:"type"`
URL string `json:"url"`
Headers map[string]string `json:"headers"`
}
// parseMCPConfig reads a .mcp.json file at path and returns the declared
// MCP servers sorted by name. It returns an empty slice when the
// mcpServers key is missing or empty. It is a self-contained copy of the
// agent/x/agentmcp parser so agentcontext can discover and start its own
// MCP servers without importing that package.
func parseMCPConfig(path string) ([]mcpServerConfig, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, xerrors.Errorf("read mcp config %q: %w", path, err)
}
var cfg mcpConfigFile
if err := json.Unmarshal(data, &cfg); err != nil {
return nil, xerrors.Errorf("parse mcp config %q: %w", path, err)
}
if len(cfg.MCPServers) == 0 {
return []mcpServerConfig{}, nil
}
servers := make([]mcpServerConfig, 0, len(cfg.MCPServers))
for name, raw := range cfg.MCPServers {
var entry mcpServerEntry
if err := json.Unmarshal(raw, &entry); err != nil {
return nil, xerrors.Errorf("parse server %q in %q: %w", name, path, err)
}
tr := inferMCPTransport(entry)
if tr == "" {
return nil, xerrors.Errorf("server %q in %q has no command or url", name, path)
}
resolveMCPEnvVars(entry.Env)
servers = append(servers, mcpServerConfig{
Name: name,
Transport: tr,
Command: entry.Command,
Args: entry.Args,
Env: entry.Env,
URL: entry.URL,
Headers: entry.Headers,
})
}
slices.SortFunc(servers, func(a, b mcpServerConfig) int {
return strings.Compare(a.Name, b.Name)
})
return servers, nil
}
// inferMCPTransport determines the transport type for a server entry.
// An explicit "type" field takes priority; otherwise the presence of
// "command" implies stdio and "url" implies http.
func inferMCPTransport(e mcpServerEntry) string {
if e.Type != "" {
return e.Type
}
if e.Command != "" {
return "stdio"
}
if e.URL != "" {
return "http"
}
return ""
}
// resolveMCPEnvVars expands ${VAR} references in env map values using
// the current process environment.
func resolveMCPEnvVars(env map[string]string) {
for k, v := range env {
env[k] = os.Expand(v, os.Getenv)
}
}
// mcpRunner connects to the MCP servers declared in the .mcp.json files
// the context resolver discovers, lists each server's tools, and caches
// a non-blocking per-server snapshot that buildMCPServerResources turns
// into KindMCPServer resources. It owns its own connection lifecycle and
// does not share state with agent/x/agentmcp: the two MCP paths run
// independently during the rollout. Connections are one-shot
// (connect, initialize, list tools, close) because the runner only needs
// each server's tool list to push to coderd, not a live tool-call proxy.
type mcpRunner struct {
logger slog.Logger
execer agentexec.Execer
updateEnv func([]string) ([]string, error)
onChange func()
// reloadMu serializes Reload so a slow reload cannot interleave
// with a newer one and publish a stale cache. The sole production
// caller (runMCPSync) already calls Reload sequentially; the mutex
// is defensive.
reloadMu sync.Mutex
mu sync.Mutex
cache []MCPServerStatus
}
// newMCPRunner constructs a runner. onChange is invoked (outside the
// cache lock) after a Reload that changes the per-server snapshot, so
// the manager can re-resolve and push the updated KindMCPServer
// resources. updateEnv may be nil.
func newMCPRunner(logger slog.Logger, execer agentexec.Execer, updateEnv func([]string) ([]string, error), onChange func()) *mcpRunner {
return &mcpRunner{
logger: logger,
execer: execer,
updateEnv: updateEnv,
onChange: onChange,
}
}
// Servers returns a deep copy of the current per-server MCP snapshot. It
// never blocks on I/O: the resolver calls it on every re-resolve.
func (r *mcpRunner) Servers() []MCPServerStatus {
r.mu.Lock()
defer r.mu.Unlock()
return cloneMCPServers(r.cache)
}
// Reload reparses the supplied .mcp.json paths, connects to every
// declared server in parallel, lists its tools, and replaces the cached
// snapshot with the fresh result (including per-server failures). It
// fires onChange when the snapshot changed. Reload is best-effort: a
// server that fails to connect or list tools is recorded as a
// disconnected entry rather than aborting the whole reload.
func (r *mcpRunner) Reload(ctx context.Context, paths []string) {
r.reloadMu.Lock()
defer r.reloadMu.Unlock()
configs := r.parseConfigs(ctx, paths)
statuses := r.connectAll(ctx, configs)
r.mu.Lock()
changed := !reflect.DeepEqual(r.cache, statuses)
r.cache = statuses
r.mu.Unlock()
if changed && r.onChange != nil {
r.onChange()
}
}
// parseConfigs parses every path and returns the union of declared
// servers, deduplicated by name (first occurrence wins). Missing files
// are skipped silently; other parse errors are logged and skipped so one
// broken .mcp.json does not drop the servers declared in sibling files.
func (r *mcpRunner) parseConfigs(ctx context.Context, paths []string) []mcpServerConfig {
var all []mcpServerConfig
for _, path := range paths {
configs, err := parseMCPConfig(path)
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
continue
}
r.logger.Warn(ctx, "failed to parse MCP config",
slog.F("path", path),
slog.Error(err),
)
continue
}
all = append(all, configs...)
}
seen := make(map[string]struct{}, len(all))
deduped := make([]mcpServerConfig, 0, len(all))
for _, cfg := range all {
if _, ok := seen[cfg.Name]; ok {
continue
}
seen[cfg.Name] = struct{}{}
deduped = append(deduped, cfg)
}
return deduped
}
// connectAll connects to each server in parallel (bounded) and returns
// one status per server in name order. Per-server failures are isolated:
// a failed connect or list becomes a disconnected status carrying the
// error instead of failing the batch.
func (r *mcpRunner) connectAll(ctx context.Context, configs []mcpServerConfig) []MCPServerStatus {
if len(configs) == 0 {
return nil
}
statuses := make([]MCPServerStatus, len(configs))
var eg errgroup.Group
eg.SetLimit(mcpConnectConcurrency)
for i, cfg := range configs {
eg.Go(func() error {
st := MCPServerStatus{Name: cfg.Name}
tools, err := r.connectAndList(ctx, cfg)
if err != nil {
r.logger.Warn(ctx, "failed to connect MCP server",
slog.F("server", cfg.Name),
slog.Error(err),
)
st.Err = err.Error()
} else {
st.Connected = true
st.Tools = tools
}
statuses[i] = st
return nil
})
}
_ = eg.Wait()
return statuses
}
// connectAndList starts a single MCP server, completes the initialize
// handshake, lists its tools, and closes the connection. Tool names are
// returned exactly as the server reported them; the resource carries the
// server name separately, so any flattening into a single namespace is
// left to the control plane.
func (r *mcpRunner) connectAndList(ctx context.Context, cfg mcpServerConfig) ([]MCPTool, error) {
tr, err := r.createTransport(ctx, cfg)
if err != nil {
return nil, xerrors.Errorf("create transport for %q: %w", cfg.Name, err)
}
c := client.NewClient(tr)
// Tie the subprocess to cmdCtx. mcp-go's stdio Close() closes stdin
// and then blocks on cmd.Wait() with no kill: a server that ignores
// stdin-close would stall this reload indefinitely (the deferred
// Close runs before connectAndList returns, so it would block the
// errgroup and hold the reload lock). Canceling cmdCtx force-kills
// the process via exec.CommandContext, so Close's Wait returns. The
// deferred cleanup cancels before closing, and runs before the
// connectCtx cancel because defers are LIFO.
cmdCtx, cmdCancel := context.WithCancel(ctx)
connectCtx, cancel := context.WithTimeout(cmdCtx, mcpConnectTimeout)
defer cancel()
if err := c.Start(cmdCtx); err != nil {
cmdCancel()
_ = c.Close()
return nil, xerrors.Errorf("start %q: %w", cfg.Name, err)
}
defer func() {
cmdCancel()
_ = c.Close()
}()
if _, err := c.Initialize(connectCtx, mcp.InitializeRequest{
Params: mcp.InitializeParams{
ProtocolVersion: mcp.LATEST_PROTOCOL_VERSION,
ClientInfo: mcp.Implementation{
Name: "coder-agent",
Version: buildinfo.Version(),
},
},
}); err != nil {
return nil, xerrors.Errorf("initialize %q: %w", cfg.Name, err)
}
result, err := c.ListTools(connectCtx, mcp.ListToolsRequest{})
if err != nil {
return nil, xerrors.Errorf("list tools from %q: %w", cfg.Name, err)
}
tools := make([]MCPTool, 0, len(result.Tools))
for _, tool := range result.Tools {
tools = append(tools, MCPTool{
Name: tool.Name,
Description: tool.Description,
InputSchema: toolInputSchema(tool.InputSchema),
})
}
return tools, nil
}
// createTransport builds the mcp-go transport for a server config.
func (r *mcpRunner) createTransport(ctx context.Context, cfg mcpServerConfig) (transport.Interface, error) {
switch cfg.Transport {
case "stdio":
env := r.buildEnv(ctx, cfg.Env)
return transport.NewStdioWithOptions(
cfg.Command,
env,
cfg.Args,
transport.WithCommandFunc(func(ctx context.Context, command string, cmdEnv []string, args []string) (*exec.Cmd, error) {
cmd := r.execer.CommandContext(ctx, command, args...)
cmd.Env = cmdEnv
return cmd, nil
}),
), nil
case "http", "":
return transport.NewStreamableHTTP(cfg.URL, transport.WithHTTPHeaders(cfg.Headers))
case "sse":
return transport.NewSSE(cfg.URL, transport.WithHeaders(cfg.Headers))
default:
return nil, xerrors.Errorf("unsupported transport %q", cfg.Transport)
}
}
// buildEnv enriches the process environment via the agent's updateEnv
// callback, then merges explicit overrides from the server config on
// top. Note: env enrichment is captured per Reload; an env change alone
// (without a .mcp.json change) does not trigger a re-list.
func (r *mcpRunner) buildEnv(ctx context.Context, explicit map[string]string) []string {
env := usershell.SystemEnvInfo{}.Environ()
if r.updateEnv != nil {
updated, err := r.updateEnv(env)
if err != nil {
r.logger.Warn(ctx, "failed to enrich MCP server environment", slog.Error(err))
env = usershell.SystemEnvInfo{}.Environ()
} else {
env = updated
}
}
if len(explicit) == 0 {
return env
}
existing := make(map[string]int, len(env))
for i, kv := range env {
if k, _, ok := strings.Cut(kv, "="); ok {
existing[k] = i
}
}
for k, v := range explicit {
entry := k + "=" + v
if idx, ok := existing[k]; ok {
env[idx] = entry
} else {
env = append(env, entry)
}
}
return env
}
// toolInputSchema converts an mcp-go tool input schema into the
// JSON-Schema-shaped map MCPTool carries. Required is converted to
// []any (not []string) so the downstream structpb encoding accepts it.
// An empty schema yields nil so the tool ships with InputSchema unset.
func toolInputSchema(s mcp.ToolInputSchema) map[string]any {
out := map[string]any{}
if s.Type != "" {
out["type"] = s.Type
}
if len(s.Properties) > 0 {
out["properties"] = s.Properties
}
if len(s.Required) > 0 {
required := make([]any, len(s.Required))
for i, req := range s.Required {
required[i] = req
}
out["required"] = required
}
if len(out) == 0 {
return nil
}
return out
}
// cloneMCPServers deep-copies a per-server snapshot so callers cannot
// mutate the runner's cache. Tool input schemas are treated as immutable
// and shared by reference.
func cloneMCPServers(in []MCPServerStatus) []MCPServerStatus {
if len(in) == 0 {
return nil
}
out := make([]MCPServerStatus, len(in))
for i, s := range in {
s.Tools = slices.Clone(s.Tools)
out[i] = s
}
return out
}
// runMCPSync keeps the runner's connected servers in sync with the
// .mcp.json files the resolver discovers. It subscribes to snapshot
// changes, extracts the set of KindMCPConfig paths (keyed by content
// hash so in-place edits are detected), and reloads the runner only when
// that set changes. A reload fires the runner's onChange, which
// re-resolves and surfaces the updated KindMCPServer resources; that
// re-resolve does not change the config set, so it does not loop.
func (m *Manager) runMCPSync(ctx context.Context) {
changes, unsubscribe := m.SubscribeChanges()
defer unsubscribe()
var lastKey string
reload := func() {
paths, key := mcpConfigSet(m.Snapshot())
if key == lastKey {
return
}
lastKey = key
m.mcpRunner.Reload(ctx, paths)
}
// Pick up any .mcp.json discovered before we subscribed.
reload()
for {
select {
case <-ctx.Done():
return
case <-m.closedCh:
return
case <-changes:
reload()
}
}
}
// mcpConfigSet extracts the .mcp.json config files from a snapshot's
// KindMCPConfig resources. It returns the sorted unique source paths
// plus a key encoding path:contenthash pairs, so callers detect both
// path-set changes and in-place content edits. An empty set yields an
// empty key.
func mcpConfigSet(snap Snapshot) (paths []string, key string) {
hashes := make(map[string]string, len(snap.Resources))
for _, r := range snap.Resources {
if r.Kind != KindMCPConfig || r.Source == "" {
continue
}
hashes[r.Source] = hex.EncodeToString(r.ContentHash[:])
}
if len(hashes) == 0 {
return nil, ""
}
paths = make([]string, 0, len(hashes))
for p := range hashes {
paths = append(paths, p)
}
slices.Sort(paths)
parts := make([]string, len(paths))
for i, p := range paths {
parts[i] = p + ":" + hashes[p]
}
return paths, strings.Join(parts, "\n")
}
@@ -1,148 +0,0 @@
package agentcontext
import (
"os"
"path/filepath"
"testing"
"github.com/mark3labs/mcp-go/mcp"
"github.com/stretchr/testify/require"
)
func TestParseMCPConfig(t *testing.T) {
t.Parallel()
write := func(t *testing.T, body string) string {
t.Helper()
dir := t.TempDir()
path := filepath.Join(dir, ".mcp.json")
require.NoError(t, os.WriteFile(path, []byte(body), 0o600))
return path
}
t.Run("InfersTransportAndSorts", func(t *testing.T) {
t.Parallel()
path := write(t, `{"mcpServers": {
"zebra": {"command": "zebra-bin", "args": ["--flag"]},
"alpha": {"url": "https://example.com/mcp"}
}}`)
got, err := parseMCPConfig(path)
require.NoError(t, err)
require.Len(t, got, 2)
// Sorted by name.
require.Equal(t, "alpha", got[0].Name)
require.Equal(t, "http", got[0].Transport)
require.Equal(t, "https://example.com/mcp", got[0].URL)
require.Equal(t, "zebra", got[1].Name)
require.Equal(t, "stdio", got[1].Transport)
require.Equal(t, "zebra-bin", got[1].Command)
require.Equal(t, []string{"--flag"}, got[1].Args)
})
t.Run("ExplicitTypeWins", func(t *testing.T) {
t.Parallel()
path := write(t, `{"mcpServers": {"s": {"type": "sse", "url": "https://x"}}}`)
got, err := parseMCPConfig(path)
require.NoError(t, err)
require.Len(t, got, 1)
require.Equal(t, "sse", got[0].Transport)
})
t.Run("EmptyServers", func(t *testing.T) {
t.Parallel()
path := write(t, `{"mcpServers": {}}`)
got, err := parseMCPConfig(path)
require.NoError(t, err)
require.Empty(t, got)
})
t.Run("RejectsServerWithoutCommandOrURL", func(t *testing.T) {
t.Parallel()
path := write(t, `{"mcpServers": {"s": {}}}`)
_, err := parseMCPConfig(path)
require.Error(t, err)
})
t.Run("InvalidJSON", func(t *testing.T) {
t.Parallel()
path := write(t, `{not json`)
_, err := parseMCPConfig(path)
require.Error(t, err)
})
}
// TestParseMCPConfig_ExpandsEnv is a standalone (non-parallel) test
// because t.Setenv cannot be used under a parallel parent test.
func TestParseMCPConfig_ExpandsEnv(t *testing.T) {
t.Setenv("AGENTCONTEXT_MCP_TEST_TOKEN", "secret")
dir := t.TempDir()
path := filepath.Join(dir, ".mcp.json")
require.NoError(t, os.WriteFile(path,
[]byte(`{"mcpServers": {"s": {"command": "x", "env": {"TOKEN": "${AGENTCONTEXT_MCP_TEST_TOKEN}"}}}}`), 0o600))
got, err := parseMCPConfig(path)
require.NoError(t, err)
require.Len(t, got, 1)
require.Equal(t, "secret", got[0].Env["TOKEN"])
}
func TestToolInputSchema(t *testing.T) {
t.Parallel()
t.Run("FullSchema", func(t *testing.T) {
t.Parallel()
got := toolInputSchema(mcp.ToolInputSchema{
Type: "object",
Properties: map[string]any{"q": map[string]any{"type": "string"}},
Required: []string{"q"},
})
require.Equal(t, "object", got["type"])
require.Equal(t, map[string]any{"q": map[string]any{"type": "string"}}, got["properties"])
// Required is converted to []any so structpb.NewStruct accepts it.
require.Equal(t, []any{"q"}, got["required"])
})
t.Run("EmptyYieldsNil", func(t *testing.T) {
t.Parallel()
require.Nil(t, toolInputSchema(mcp.ToolInputSchema{}))
})
t.Run("TypeOnly", func(t *testing.T) {
t.Parallel()
got := toolInputSchema(mcp.ToolInputSchema{Type: "object"})
require.Equal(t, map[string]any{"type": "object"}, got)
})
}
func TestMCPConfigSet(t *testing.T) {
t.Parallel()
t.Run("Empty", func(t *testing.T) {
t.Parallel()
paths, key := mcpConfigSet(Snapshot{})
require.Empty(t, paths)
require.Empty(t, key)
})
t.Run("SortedAndKeyedByContentHash", func(t *testing.T) {
t.Parallel()
snap := Snapshot{Resources: []Resource{
{Kind: KindMCPConfig, Source: "/b/.mcp.json", ContentHash: [32]byte{0x01}},
{Kind: KindMCPConfig, Source: "/a/.mcp.json", ContentHash: [32]byte{0x02}},
// Non-config and empty-source resources are ignored.
{Kind: KindInstructionFile, Source: "/a/AGENTS.md"},
{Kind: KindMCPServer, Source: "fs"},
{Kind: KindMCPConfig, Source: ""},
}}
paths, key := mcpConfigSet(snap)
require.Equal(t, []string{"/a/.mcp.json", "/b/.mcp.json"}, paths)
require.NotEmpty(t, key)
// An in-place content edit (same path, new hash) changes the key.
snap2 := Snapshot{Resources: []Resource{
{Kind: KindMCPConfig, Source: "/b/.mcp.json", ContentHash: [32]byte{0x01}},
{Kind: KindMCPConfig, Source: "/a/.mcp.json", ContentHash: [32]byte{0x09}},
}}
_, key2 := mcpConfigSet(snap2)
require.NotEqual(t, key, key2)
})
}