feat(coderd/chatd): connect to external MCP servers for chat tool invocation#23333
feat(coderd/chatd): connect to external MCP servers for chat tool invocation#23333
Conversation
mafredri
left a comment
There was a problem hiding this comment.
🤖 mafredri pointed me at this new MCP client package. I reviewed all 4 changed files (~1,250 new lines) and brought in seven reviewers.
Clean package boundary, good separation of concerns. mcpclient has no business logic leakage from chatd. Error handling in connectOne closes the client on every failure path. The test suite covers real MCP servers (not mocks), exercising discovery, invocation, all auth types, filtering, parallel connections, and failure modes.
1 P1, 3 P2, 2 P3 across 6 inline comments. A couple observations inline too.
🤖 Seven reviewers walked into a diff. Six agreed on the token expiry. The seventh was checking goroutine leaks.
…ocation Adds a new `mcpclient` package that connects to admin-configured MCP servers and wraps their tools as `fantasy.AgentTool` values. When a chat has `mcp_server_ids` set, the chat loop now: 1. Loads the MCP server configs from the database 2. Resolves the user's auth tokens (OAuth2/API key/custom headers) 3. Connects to each server via the MCP protocol (streamable HTTP or SSE) 4. Discovers tools via `tools/list` 5. Wraps each tool as an AgentTool with a namespaced name (slug__tool) 6. Appends them to the chat's tool set The chat loop (`chatloop.Run`) already handles tools generically, so MCP-backed tools are invoked identically to built-in workspace tools. Connection failures are logged and skipped — one broken MCP server does not block the chat. Tool allow/deny lists from the server config are applied during discovery. Includes 10 tests covering tool discovery, invocation, filtering, auth headers, multi-server, connection failure, and edge cases.
Move the MCP server config and user token DB queries into the existing errgroup that already parallelizes model resolution and message loading. All four queries hit different tables with no dependencies on each other. Add TestMCPServerToolInvocation — a full round-trip integration test that: 1. Starts a real in-process MCP server with an "echo" tool 2. Seeds the MCP server config in the database 3. Creates a chat with mcp_server_ids pointing to it 4. Mocks the LLM to call the MCP tool (test-mcp__echo) 5. Verifies the tool result flows back to the LLM 6. Verifies the tool result is persisted in the database
- Fix transport leak on Start failure in connectOne (close client) - Add default case in convertCallResult for unsupported content types - Log ConnectAll error instead of discarding with _ - Fix import ordering (mcpclient after chattool) - Remove dead secretTool() test helper - Add require.NotNil guard before findTool().Info() - Add comment explaining silent token degradation behavior
mcpclient.go: - Parallelize ConnectAll with sync.WaitGroup + sync.Mutex instead of serial for loop. O(max-latency) instead of O(sum-of-latencies). - Remove always-nil error return from ConnectAll signature. - Pass logger to buildAuthHeaders; log warning on custom_headers JSON parse failure instead of silently swallowing. - Handle StructuredContent in convertCallResult — marshal to JSON and append as text when present. - Fix convertCallResult comment — binary items take priority, not text. Preceding text is dropped because fantasy supports a single content type per response. - Add TODO comment about toolNameSep ambiguity when tool names contain double underscores. - Add comment about shared timeout covering the entire connect+init+list sequence. chatd.go: - Move ConnectAll into g2 errgroup to overlap with instruction resolution and user prompt resolution. Free parallelism. - Remove dead nil-check on ConnectAll error (no longer returns one). - Update call site for 2-value ConnectAll return. chatd_test.go: - Add explicit MCPServerIDs round-trip assertion on the DB record. - Add comment noting test uses raw DB without dbauthz wrapper; the chatd RBAC path is tested separately. mcpclient_test.go: - Add TestConnectAll_APIKeyAuth (api_key auth type). - Add TestConnectAll_CustomHeadersAuth (custom_headers auth type). - Add TestConnectAll_CustomHeadersInvalidJSON (silent degradation). - Add TestConnectAll_ParallelConnections (3 servers, concurrent). - Update all call sites for 2-value ConnectAll return.
mcpclient.go: - Handle StructuredContent marshal errors consistently with image/audio error handling (append error marker to textParts). - Pass context.Context through to buildAuthHeaders instead of using context.Background() — preserves trace IDs and metadata. - Make cleanup function idempotent by nilling the clients slice after closing, preventing double-close issues. - Document isToolAllowed precedence: allow list wins over deny list when both are set. chatd.go: - Remove stale "Here are all the tools" comment (described what, not why). - Add clarifying comment on g2.Wait() error discard.
da1520f to
7d7e331
Compare
Documentation CheckUpdates Needed
Automated review via Coder Tasks |
P1: Fix gofmt formatting issues in chatd.go and chatd_test.go — over-indented g2.Wait() block, merged line in var block, and misaligned closing brace. P2: Add OAuth2 token expiry warning — log when token is expired and when no token exists for a server/user combo. Still sends the expired token (refresh is a follow-up), but operators now have visibility into auth failures. P2: Fix misleading comment that said oauth2 servers 'will be skipped during connection' — they actually attempt to connect without credentials. Updated to describe actual behavior. P2: Add 60-second per-invocation timeout on MCP tool calls. External servers may stall indefinitely; this prevents one hung call from blocking the chat loop forever. P3: Redact URL userinfo in connection failure logs to avoid leaking embedded credentials (url.Parse + nil User). P3: Use map[uuid.UUID] instead of map[string] for token lookup — avoids string allocations since uuid.UUID is comparable.
7d7e331 to
25a5c32
Compare
mafredri
left a comment
There was a problem hiding this comment.
🤖 mafredri sent me back for round two. All six original findings are addressed cleanly: gofmt fixed, token expiry/missing warnings added, URL redacted, tool call timeout added, comment corrected, uuid.UUID map keys.
Good fixes. The package is in solid shape. A few remaining items from the re-review panel, nothing blocking.
1 P2, 2 P3, plus a note on test coverage.
🤖 A Finland Swede reviews the diff twice: first for bugs, second to make sure the fixes didn't bring friends.
coderd/chatd/mcpclient/mcpclient.go
Outdated
| "skipping MCP server due to connection failure", | ||
| slog.F("server_slug", cfg.Slug), | ||
| slog.F("server_url", redactURL(cfg.Url)), | ||
| slog.Error(connectErr), |
There was a problem hiding this comment.
P2 connectErr wraps Go's *url.Error which includes the full request URL, bypassing the redactURL on the server_url field. (Kurapika)
Go's net/http formats *url.Error as Method "URL": Err. If cfg.Url contains userinfo, redactURL redacts the server_url field correctly, but the same URL leaks through slog.Error(connectErr). The fix would be to redact URLs in the error string too, or to wrap connectErr with a redacted message before logging.
Low urgency since it requires an admin to configure a URL with embedded credentials, and the server_url field (which they'd look at first) is redacted.
There was a problem hiding this comment.
Fixed — added redactErrorURL() that unwraps *url.Error via errors.As and redacts the embedded URL before converting to string. Also extended redactURL to strip query params (u.RawQuery = "") since API keys show up there too.
coderd/chatd/mcpclient/mcpclient.go
Outdated
| slog.F("expired_at", tok.Expiry.Time), | ||
| ) | ||
| } | ||
| if tok.AccessToken != "" { |
There was a problem hiding this comment.
P3 When a token record exists but AccessToken is empty (e.g. incomplete OAuth flow), no warning is logged. (Hisoka, Kurapika)
The !ok case (missing token) now warns. The expired case warns. But ok && AccessToken=="" silently returns empty headers. If the token is also expired, the operator sees "oauth2 token is expired" which is misleading when the real issue is the empty access token. A parallel logger.Warn here would clarify.
Minor diagnostic improvement, not blocking.
There was a problem hiding this comment.
Added warning for ok && AccessToken=="" — now logs "oauth2 token record has empty access token" and breaks out, matching the !ok pattern.
coderd/chatd/mcpclient/mcpclient.go
Outdated
|
|
||
| // redactURL strips userinfo from a URL to avoid logging | ||
| // embedded credentials. | ||
| func redactURL(rawURL string) string { |
There was a problem hiding this comment.
P3 redactURL strips userinfo but passes query params through. (Kurapika, Hisoka)
URLs like https://mcp.example.com/v1?api_key=sk-... would leak the key in logs. Uncommon for MCP server URLs, and the admin who configured it controls the logs. Could add u.RawQuery = "" or a comment noting the limitation.
There was a problem hiding this comment.
Added u.RawQuery = "" and u.Fragment = "" to redactURL. Also exported it as RedactURL and added a table-driven test covering: plain URL, userinfo, query params, both, and invalid URL.
| @@ -0,0 +1,529 @@ | |||
| package mcpclient_test | |||
There was a problem hiding this comment.
[obs] Test coverage was not updated for the new code paths added in the review fixes. (Bisky)
Notable gaps:
convertCallResulthas 7+ branches (image, audio, structured, error flag, multi-text, nil); only single-text is tested.redactURLis untested (pure function, easy table test).Run()whenCallToolreturns a Go error (the primary production failure mode) is untested.buildAuthHeadersmissing-token and expired-token branches are untested.
The first three are the most valuable additions. convertCallResult is the most complex untested function in the file.
There was a problem hiding this comment.
Added tests for the gaps called out:
TestRedactURL— table-driven, 5 cases.TestConnectAll_ExpiredToken— exercises the expiry warning path.TestConnectAll_EmptyAccessToken— exercises the empty-token warning path.TestConnectAll_CallToolError— exercisesIsError=truefrom MCP server propagating throughRun().
convertCallResult is still only tested transitively through Run(). Exporting it for direct unit testing would be a reasonable follow-up — the function has enough branches to warrant its own table test.
P2: Redact URLs in error strings — Go's *url.Error embeds the full request URL, bypassing the server_url field redaction. Added redactErrorURL() that unwraps *url.Error and redacts the embedded URL before logging. P3: Warn when token record exists but AccessToken is empty (e.g. incomplete OAuth flow). Previously this silently returned empty headers. P3: Strip query params in redactURL in addition to userinfo — API keys are sometimes passed as ?api_key=sk-... in server URLs. P3: Add missing test coverage: - TestRedactURL: table-driven test for URL redaction (userinfo, query params, both, invalid URL). - TestConnectAll_ExpiredToken: verifies expired token warning. - TestConnectAll_EmptyAccessToken: verifies empty token warning. - TestConnectAll_CallToolError: verifies MCP-level tool errors (IsError=true) are returned as ToolResponse, not Go errors. Export RedactURL for testability.
mafredri
left a comment
There was a problem hiding this comment.
Small stuff, only one potential blocker depending on intent (text parts discarding).
| ) | ||
| } | ||
|
|
||
| return tools, mcpClient, nil |
There was a problem hiding this comment.
Could check tools length, zero tools means we don't need to keep this client open, right?
There was a problem hiding this comment.
Good call — now closes the client and returns nil when zero tools pass filtering.
There was a problem hiding this comment.
Done in the previous push — closes client and returns nil when zero tools pass filtering.
coderd/chatd/mcpclient/mcpclient.go
Outdated
| cfg.Url, | ||
| transport.WithHeaders(headers), | ||
| ) | ||
| case "streamable_http", "": |
There was a problem hiding this comment.
| case "streamable_http", "": | |
| case "", "streamable_http": |
Minor: Easier to spot the empty "" before rather than after.
| cfg database.MCPServerConfig, | ||
| tokensByConfigID map[uuid.UUID]database.MCPServerUserToken, | ||
| ) map[string]string { | ||
| headers := make(map[string]string) |
There was a problem hiding this comment.
Do we need to support repeated headers/multiple values. Consider using http.Headers with Set/Add depending on.
There was a problem hiding this comment.
Added a comment explaining the constraint — mcp-go transport options accept map[string]string, not http.Header. MCP servers typically don't need multi-valued headers.
- Close client early when zero tools pass filtering (no point holding an idle connection). - Swap empty string to front of case for transport type default so the fallback is immediately visible. - Add comment explaining why buildAuthHeaders uses map[string]string rather than http.Header (mcp-go transport options require it). - Rework convertCallResult to collect all content first, then prefer text over binary. Previously binary items short-circuited the loop and discarded accumulated text. Now text is always returned when present; binary is only used as fallback.
…field Switch ConnectAll from sync.WaitGroup to errgroup.Group per review feedback. Cleaner API — goroutines return nil explicitly rather than using defer wg.Done(). ProviderOptions/SetProviderOptions are required by the fantasy.AgentTool interface but never set by our code. The framework calls SetProviderOptions to inject provider-specific config. Field is kept to satisfy the interface contract.
Summary
Adds a new
coderd/chatd/mcpclientpackage that connects to admin-configured MCP servers and wraps their tools asfantasy.AgentToolvalues that the chat loop can invoke.What changed
New:
coderd/chatd/mcpclient/mcpclient.goThe core package with a single entry point:
This:
mark3labs/mcp-go(streamable HTTP or SSE transport)tools/listmethodfantasy.AgentToolwith namespaced name (serverslug__toolname)Modified:
coderd/chatd/chatd.goIn
runChat(), after loading the model/messages but before assembling the tool list:chat.MCPServerIDsfrom the chat recordmcpclient.ConnectAll()to connect and discover toolsThe chat loop (
chatloop.Run) already handles tools generically — MCP-backed tools are invoked identically to built-in workspace tools. No changes needed inchatloop/.New:
coderd/chatd/mcpclient/mcpclient_test.go10 tests covering:
Design decisions
slug__toolnamewith double underscore separator. Avoids collisions with tools containing single underscores. Stripped when forwarding totools/call.defer. Matches theturnWorkspaceContextpattern.[]fantasy.AgentToolinterface is already fully generic.What's NOT in this PR (follow-ups)
aibridgedMCP proxy cleanup