mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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
78 lines
2.2 KiB
Go
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
|
|
}
|