Skip to content

fix(mcp): decouple MCP server discovery from connection to eliminate spurious OAuth redirects#2914

Draft
BnjmnZmmrmn-parafin wants to merge 1 commit intotailcallhq:mainfrom
BnjmnZmmrmn-parafin:benjaminzimmerman/mon-mcp-lazy-connect
Draft

fix(mcp): decouple MCP server discovery from connection to eliminate spurious OAuth redirects#2914
BnjmnZmmrmn-parafin wants to merge 1 commit intotailcallhq:mainfrom
BnjmnZmmrmn-parafin:benjaminzimmerman/mon-mcp-lazy-connect

Conversation

@BnjmnZmmrmn-parafin
Copy link
Copy Markdown

Summary

MCP tool calls currently connect to every configured MCP server at chat startup — even servers whose tools aren't needed for the current request. This means invoking the GitHub MCP tool also silently attempts to connect to Notion, Datadog, and every other configured server. If any of those servers requires OAuth and has no cached credential, a browser window opens mid-session without the user asking for it.

This PR decouples discovery (which tools exist, used to build the system prompt) from connection (the live network handshake), so only the server that owns a requested tool is ever connected to.

Changes

crates/forge_services/src/mcp/lazy_client.rs (new)

  • LazyMcpClient<I> — holds server config but defers all network I/O via Arc<OnceCell<I::Client>> until the first tool invocation. Clones share the same OnceCell so the handshake fires exactly once across concurrent callers.

crates/forge_services/src/mcp/service.rs

Rewrote ForgeMcpService with a two-stage lifecycle:

  • register_servers() — pure config parsing, zero network I/O. Builds LazyMcpClient entries. Servers that declare their tools statically in config (tools: [...] field) get lightweight ToolDefinition stubs immediately so the LLM sees them in the system prompt without a connection being made.
  • connect_server() / connect_all_pending() — fires only when a tool is actually called. pending_servers.remove() is the mutual-exclusion point: exactly one concurrent caller receives the LazyMcpClient, all others return Ok(()).
  • insert_lazy_client() — after populating tools from the live connection, prunes stale declared stubs for that server from declared_tools so contains_tool_in_memory doesn't return true for tools that were declared in config but absent from the server's actual tool list.
  • get_mcp_servers() — KV cache stores stubs only (never live schemas). Uses had_live_before snapshot for the cache-read fast path; re-reads has_live_now after list() for the cache-write gate so live schemas never bleed into subsequent cold starts.

crates/forge_app/src/services.rs

  • Added contains_mcp_tool(&ToolName) -> anyhow::Result<bool> to the McpService trait — pure in-memory check, no network activity.

crates/forge_app/src/mcp_executor.rs

  • contains_tool() now delegates to contains_mcp_tool() instead of calling get_mcp_servers(), eliminating the second eager connection sweep that fired on every tool dispatch.

crates/forge_domain/src/mcp.rs

  • Added tools: Vec<String> field to both McpStdioServer and McpHttpServer. Skip-serialized when empty so existing configs are unaffected. Added declared_tools() helper on McpServerConfig.

Behavior

  • Before: every chat start connects to all configured MCP servers concurrently. Any server requiring OAuth with no cached token opens a browser window.
  • After: chat start is zero-network. GitHub MCP invoked → only GitHub connects. Notion stays pending. No browser window opens unless a Notion tool is actually called.

Configuration (optional)

Users can pre-declare tool names in .mcp.json to make them visible in the system prompt without waiting for first use:

{
  "mcpServers": {
    "github": {
      "url": "https://api.githubcopilot.com/mcp/",
      "headers": { "Authorization": "Bearer {{.env.GITHUB_TOKEN}}" },
      "tools": ["get_file_contents", "create_pull_request", "list_commits"]
    }
  }
}

Without tools, the server's tools are hidden from the system prompt until a tool from that server is first invoked (at which point full schemas are fetched and used for all subsequent calls in that session).

Verification

1. Configure two MCP servers — one requiring OAuth (e.g. Notion), one with a token header (e.g. GitHub)
2. Start a chat session and invoke a GitHub tool
3. Observe: only GitHub connects; no browser window opens for Notion
4. Invoke a Notion tool — Notion connects on demand

@github-actions github-actions bot added the type: fix Iterations on existing features or infrastructure. label Apr 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

type: fix Iterations on existing features or infrastructure.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant