Files
coder/agent/x/agentmcp/api_internal_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

56 lines
1.5 KiB
Go

package agentmcp
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/require"
"cdr.dev/slog/v3/sloggers/slogtest"
"github.com/coder/coder/v2/agent/agentexec"
"github.com/coder/coder/v2/codersdk/workspacesdk"
"github.com/coder/coder/v2/testutil"
)
// TestHandleCallTool_ErrorMapping verifies the call-tool handler maps
// Manager errors to the right HTTP status codes. Tool discovery is no
// longer served over HTTP (the agentcontext manager reads the catalog
// in-process), so only the execution endpoint is exercised here.
func TestHandleCallTool_ErrorMapping(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
logger := slogtest.Make(t, nil)
m := NewManager(ctx, logger, agentexec.DefaultExecer, nil)
t.Cleanup(func() { _ = m.Close() })
api := NewAPI(m)
cases := []struct {
name string
toolName string
wantCode int
}{
{name: "InvalidToolName", toolName: "noseparator", wantCode: http.StatusBadRequest},
{name: "UnknownServer", toolName: "ghost__echo", wantCode: http.StatusNotFound},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
body, err := json.Marshal(workspacesdk.CallMCPToolRequest{ToolName: tc.toolName})
require.NoError(t, err)
req := httptest.NewRequest(http.MethodPost, "/call-tool", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
api.Routes().ServeHTTP(rec, req)
require.Equal(t, tc.wantCode, rec.Code)
})
}
}