Files
coder/agent/agentcontext/mcpcatalog_test.go
T
Kyle Carberry 27ecd17991 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
2026-06-22 22:21:58 -06:00

78 lines
2.2 KiB
Go

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
}