Skip to content

feat(coderd/chatd): connect to external MCP servers for chat tool invocation#23333

Merged
kylecarbs merged 9 commits intomainfrom
kylecarbs/mcp-chat-tools
Mar 20, 2026
Merged

feat(coderd/chatd): connect to external MCP servers for chat tool invocation#23333
kylecarbs merged 9 commits intomainfrom
kylecarbs/mcp-chat-tools

Conversation

@kylecarbs
Copy link
Copy Markdown
Member

Summary

Adds a new coderd/chatd/mcpclient package that connects to admin-configured MCP servers and wraps their tools as fantasy.AgentTool values that the chat loop can invoke.

What changed

New: coderd/chatd/mcpclient/mcpclient.go

The core package with a single entry point:

func ConnectAll(
    ctx context.Context,
    logger slog.Logger,
    configs []database.MCPServerConfig,
    tokens []database.MCPServerUserToken,
) (tools []fantasy.AgentTool, cleanup func(), err error)

This:

  1. Connects to each enabled MCP server using mark3labs/mcp-go (streamable HTTP or SSE transport)
  2. Discovers tools via the MCP tools/list method
  3. Wraps each tool as a fantasy.AgentTool with namespaced name (serverslug__toolname)
  4. Applies tool allow/deny list filtering from the server config
  5. Handles auth: OAuth2 bearer tokens, API keys, and custom headers
  6. Skips broken servers with a warning (10s connect timeout per server)
  7. Returns a cleanup function to close all MCP connections

Modified: coderd/chatd/chatd.go

In runChat(), after loading the model/messages but before assembling the tool list:

  • Reads chat.MCPServerIDs from the chat record
  • Loads the MCP server configs from the database
  • Resolves the user's auth tokens
  • Calls mcpclient.ConnectAll() to connect and discover tools
  • Appends the MCP tools to the chat's tool set
  • Defers cleanup to close connections when the chat turn ends

The chat loop (chatloop.Run) already handles tools generically — MCP-backed tools are invoked identically to built-in workspace tools. No changes needed in chatloop/.

New: coderd/chatd/mcpclient/mcpclient_test.go

10 tests covering:

  • Tool discovery and namespacing
  • Tool call forwarding and result conversion
  • Allow/deny list filtering
  • Connection failure handling (graceful skip)
  • Multi-server support with correct prefixes
  • OAuth2 auth header injection
  • Disabled server skipping
  • Invalid input handling
  • Tool info parameter propagation

Design decisions

  • Tool namespacing: slug__toolname with double underscore separator. Avoids collisions with tools containing single underscores. Stripped when forwarding to tools/call.
  • Connection lifecycle: Fresh connections per chat turn, closed via defer. Matches the turnWorkspaceContext pattern.
  • Failure isolation: Each server connects independently. A broken server doesn't fail the chat — its tools are simply unavailable.
  • No chatloop changes: The existing []fantasy.AgentTool interface is already fully generic.

What's NOT in this PR (follow-ups)

  • Frontend MCP server picker UI (selecting servers for a chat)
  • System prompt additions describing available MCP tools
  • Token refresh on expiry mid-chat
  • The deprecated aibridged MCP proxy cleanup

Copy link
Copy Markdown
Member

@mafredri mafredri left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 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.
@kylecarbs kylecarbs requested a review from mafredri March 20, 2026 12:21
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.
@kylecarbs kylecarbs force-pushed the kylecarbs/mcp-chat-tools branch from da1520f to 7d7e331 Compare March 20, 2026 12:22
@coder-tasks
Copy link
Copy Markdown
Contributor

coder-tasks bot commented Mar 20, 2026

Documentation Check

Updates Needed

  • docs/ai-coder/agents/architecture.md — The "Tool execution" section enumerates workspace, platform, orchestration, and provider tools and states the agent is "restricted to the tool set defined in this section." This PR adds a fifth category — external MCP server tools — that is not reflected. The section should document that admin-configured MCP servers can extend the tool set, how namespacing works (serverslug__toolname), and the allow/deny filtering behavior.

  • docs/ai-coder/agents/chats-api.md — The chat creation example omits mcp_server_ids, which is now a functional field in CreateChatRequest. A note or example showing how to attach MCP servers to a chat via the API would be useful, since this is accessible today even without the forthcoming frontend picker.


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.
@kylecarbs kylecarbs force-pushed the kylecarbs/mcp-chat-tools branch from 7d7e331 to 25a5c32 Compare March 20, 2026 12:34
Copy link
Copy Markdown
Member

@mafredri mafredri left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 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.

"skipping MCP server due to connection failure",
slog.F("server_slug", cfg.Slug),
slog.F("server_url", redactURL(cfg.Url)),
slog.Error(connectErr),
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

slog.F("expired_at", tok.Expiry.Time),
)
}
if tok.AccessToken != "" {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added warning for ok && AccessToken=="" — now logs "oauth2 token record has empty access token" and breaks out, matching the !ok pattern.


// redactURL strips userinfo from a URL to avoid logging
// embedded credentials.
func redactURL(rawURL string) string {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[obs] Test coverage was not updated for the new code paths added in the review fixes. (Bisky)

Notable gaps:

  • convertCallResult has 7+ branches (image, audio, structured, error flag, multi-text, nil); only single-text is tested.
  • redactURL is untested (pure function, easy table test).
  • Run() when CallTool returns a Go error (the primary production failure mode) is untested.
  • buildAuthHeaders missing-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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 — exercises IsError=true from MCP server propagating through Run().

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.
Copy link
Copy Markdown
Member

@mafredri mafredri left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Small stuff, only one potential blocker depending on intent (text parts discarding).

)
}

return tools, mcpClient, nil
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could check tools length, zero tools means we don't need to keep this client open, right?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good call — now closes the client and returns nil when zero tools pass filtering.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in the previous push — closes client and returns nil when zero tools pass filtering.

cfg.Url,
transport.WithHeaders(headers),
)
case "streamable_http", "":
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
case "streamable_http", "":
case "", "streamable_http":

Minor: Easier to spot the empty "" before rather than after.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

cfg database.MCPServerConfig,
tokensByConfigID map[uuid.UUID]database.MCPServerUserToken,
) map[string]string {
headers := make(map[string]string)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need to support repeated headers/multiple values. Consider using http.Headers with Set/Add depending on.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
@kylecarbs kylecarbs enabled auto-merge (squash) March 20, 2026 16:43
@kylecarbs kylecarbs merged commit e388a88 into main Mar 20, 2026
26 checks passed
@kylecarbs kylecarbs deleted the kylecarbs/mcp-chat-tools branch March 20, 2026 16:49
@github-actions github-actions bot locked and limited conversation to collaborators Mar 20, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants