5.6 KiB
External MCP Endpoint — Implementation Plan
Spec: ./spec.md
Architecture
The existing MCP machinery already separates cleanly along a thin seam:
internal/agentctl/server/mcp/—Serverwrapsmark3labs/mcp-go, registers tools, exposes routes.- The tools talk to the backend through one interface:
In
type BackendClient interface { RequestPayload(ctx context.Context, action string, payload, result interface{}) error }agentctlthis isChannelBackendClient, which tunnels over the agent-stream WebSocket and lands on handlers registered againstinternal/mcp/handlers.Handlerson the backend'sws.Dispatcher.
Reuse path: register a second mcp.Server instance inside the backend, pointed at a BackendClient that calls the same ws.Dispatcher directly — no WS round-trip, no tunneling. The dispatcher is just a map[string]Handler, so direct dispatch is a one-line call.
File map
Move
internal/agentctl/server/mcp/→internal/mcp/server/(package renamedmcpserver).- Two import sites to update:
cmd/agentctl/main.go,internal/agentctl/server/api/server.go. - Test files come along.
- Two import sites to update:
Modify (internal/mcp/server/server.go)
New(...)keeps current signature — sessionID/taskID stay (used in log fields), pass empty strings for the backend instance.- Add
RegisterRoutesAt(router gin.IRouter, basePath string)that registers<basePath>,<basePath>/sse,<basePath>/message. ExistingRegisterRouteskeeps registering at root for agentctl. - Pass
basePaththrough toWithBaseURLso the SSE event embeds the rightmessageEndpointURL.
New (internal/mcp/external/)
direct_backend.go—DirectBackendClientwraps a*ws.Dispatcher, implementsBackendClient.RequestPayloadby building a*ws.Message, callingDispatch, returning the unmarshalled payload.provider.go—Provide(dispatcher *ws.Dispatcher, baseURL string, log *logger.Logger) (*mcpserver.Server, error)constructs the MCP server inModeConfigwithdisableAskQuestion=true.direct_backend_test.go,provider_test.go— unit + integration tests.
Modify (cmd/kandev/helpers.go)
- After
mcpHandlers.RegisterHandlers(p.gateway.Dispatcher)inregisterMCPAndDebugRoutes, build the external MCP server withexternal.Provide(p.gateway.Dispatcher, "http://localhost:<port>", p.log)and callsrv.RegisterRoutesAt(p.router, "/mcp"). - Wire its
Close()into the existing cleanup chain.
Modify (frontend)
- New "External MCP" panel in Settings. Static — shows the URL, copy button, and ready-to-paste snippets for Claude Code, Cursor, Codex. No backend calls.
- File:
apps/web/components/settings/external-mcp/external-mcp-panel.tsx+ add to settings index.
Cleanup (deferred to its own commit)
- Remove
ports.MCP = 40429(internal/common/ports/ports.go). - Remove
McpServerEnabled,McpServerPortfrominternal/agentctl/server/config/config.go. - Remove the unused
40429constant inapps/cli/src/constants.ts.
Tool surface (v1)
Config-mode tools as currently registered in (*Server).registerConfigWorkflowTools / Agent / Mcp / Executor / Task plus create_task_kandev from kanban tools. Excluded:
ask_user_question_kandev(no UI loop for external invocations).create_task_plan_kandev,get/update/delete_task_plan_kandev(require a live session).- Internal-only:
clarification_timeout.
Implementation: add a ModeExternal constant or a WithoutAskQuestion + WithoutPlanTools option. Simplest: introduce ModeExternal that registers exactly the tools above. Reuses every existing handler.
Phases
- Refactor (no behavior change). Move the package, rename imports, add
RegisterRoutesAt. AddModeExternalconstant + branch inregisterTools(). All existing tests pass. - Backend wiring. New
internal/mcp/external/package withDirectBackendClientandProvide. Wire intohelpers.go. - Frontend Settings panel. Static React panel with copy-to-clipboard for the three snippets.
- Tests. Unit tests for
DirectBackendClient(round-trips through dispatcher). Integration test that boots a Gin router with the provider and runstools/list+ a representativetools/call(list_workspaces_kandev) over real HTTP usingmcp-goclient. - Cleanup. Delete dead
40429-related code. - Verify.
make fmtthenmake typecheck test lint(backend);pnpm typecheck test lint(web).
Risks / open points
- mcp-go path prefix support. Need to confirm
WithBaseURL("http://localhost:38429/mcp")correctly emits the/mcp/message?sessionId=…URL in the SSE handshake. If not, register at root with a GinGroup("/mcp")and have the SSE handler be unaware of the prefix — Gin strips it before the wrapped handler sees it. Will validate during Phase 1. - Route conflicts. Backend Gin router currently has
/ws,/health,/api/v1/*, plus debug & pprof routes./mcpis unused. Verified. - Direct dispatch concurrency.
ws.Dispatcheris read-only after registration; safe to call from many goroutines. No locking needed inDirectBackendClient. - Session-scoped tool handlers expecting non-empty session/task IDs. Config-mode handlers don't inspect
sessionID/taskID(they take params from the payload). Confirmed ininternal/mcp/handlers/config_*_handlers.go. Thecreate_task_kandevhandler also takes everything from payload. Safe. - Logging noise. The MCP server logs every tool call with
session_id. With backend-mode it'll be empty — acceptable, but worth filtering or noting.
Out of scope (per spec)
Auth, remote binding, session-scoped tools, per-workspace scoping. Token-based auth is the most likely follow-up.