Skip to content

MCP Source Code Analysis

Module Overview

The MCP (Model Context Protocol) module is OpenCode’s external tool integration channel, located at packages/opencode/src/mcp/. Through the MCP protocol, OpenCode can connect to various external tool servers — database clients, browser automation, search engines, code analysis tools, etc. — exposing their capabilities uniformly as Agent-callable tools. MCP allows OpenCode’s capabilities to extend beyond the built-in toolset.

The module’s core entry point index.ts (~922 lines) exports the MCP namespace, adopting the Effect Service architecture pattern. It exposes functions such as start(), stop(), client(), status(), and tools(). Internally, the module manages global state via Instance.state(), containing clients: Record<string, Client> and status: Record<string, Status>. Each MCP server connection is managed independently — one server crashing does not affect others.

Core design choices:

  • Effect Service pattern provides type-safe dependency injection and resource lifecycle management
  • Discriminated union types precisely express the five client states
  • convertMcpTool transparently converts MCP tools to AI SDK dynamicTool, so the Agent is unaware of underlying protocol differences
  • OAuth 2.0 + PKCE full implementation supports remote MCP servers requiring authentication
  • Recursive process cleanup ensures no orphan processes remain in stdio mode

Key Files

File PathLinesResponsibility
src/mcp/index.ts~922Module main entry: MCP namespace, Effect Service pattern, client creation, tool discovery, OAuth flow, process cleanup
src/mcp/auth.ts~174McpAuth namespace, OAuth token Zod schema definitions and persistent storage (mcp-auth.json, permissions 0o600)
src/mcp/oauth-callback.ts~217McpOAuthCallback namespace, local HTTP callback server (port 19876), handles OAuth redirects and extracts authorization codes
src/mcp/oauth-provider.ts~186McpOAuthProvider class, implements OAuthClientProvider interface, manages OAuth client metadata, tokens, and PKCE flow

Note: The MCP module does not have separate client.ts or transport.ts files. Transport layer implementations come from the @modelcontextprotocol/sdk npm package, and client logic is entirely concentrated in index.ts. This design delegates protocol implementation to the SDK, while OpenCode focuses on connection management, tool conversion, and authentication orchestration.

Type System

Status — Client State Discriminated Union

const Status = z.discriminatedUnion("status", [
  z.object({ status: z.literal("connected") }),
  z.object({ status: z.literal("disabled") }),
  z.object({ status: z.literal("failed"), error: z.string() }),
  z.object({ status: z.literal("needs_auth"), url: z.string() }),
  z.object({ status: z.literal("needs_client_registration") }),
])

Meaning and transition paths of the five states:

StateMeaningSource
connectedSuccessfully connected, tools availablecreate() succeeded, finishAuth() succeeded
disabledUser disabled this server in configurationstart() checks mcp.disabled
failedConnection failedcreate() threw an exception, OAuth failed
needs_authOAuth authentication required, includes authorization URLRemote server returned 401
needs_client_registrationNeed to register OAuth client firstRemote server did not discover a registered client

State transition chain:

disabled ──────────────────────────────────── (configuration-controlled, not involved in connections)
needs_client_registration → needs_auth → connected
                                ↑               ↓
                             failed  ←──── (connection/OAuth failure)

McpAuth Types (auth.ts)

// OAuth token structure
const Tokens = z.object({
  access_token: z.string(),
  token_type: z.string(),
  scope: z.string().optional(),
  expires_at: z.number().optional(),   // Unix timestamp (seconds)
  refresh_token: z.string().optional(),
})

// OAuth client information
const ClientInfo = z.object({
  client_id: z.string(),
  client_secret: z.string().optional(),
  client_secret_expires_at: z.number().optional(),
  redirect_uris: z.array(z.string()).optional(),
  grant_types: z.array(z.string()).optional(),
})

// Persistent entry: indexed by serverUrl
const Entry = z.object({
  serverUrl: z.string(),
  tokens: Tokens.optional(),
  clientInfo: ClientInfo.optional(),
  codeVerifier: z.string().optional(),
  state: z.string().optional(),
})

Core Flow

Transport Layer and Client Creation

MCP.create(key, mcp) is the core function for client creation. It selects the transport method based on configuration:

// Local process: stdio transport
if (mcp.type === "stdio") {
  transport = new StdioClientTransport({
    command: mcp.command,
    args: mcp.args,
    env: { ...process.env, ...mcp.env },
    stderr: "pipe",
  })
}

// Remote service: prefer StreamableHTTP, fallback to SSE
if (mcp.url) {
  const url = new URL(mcp.url)
  transport = new StreamableHTTPClientTransport(url, {
    // If OAuth credentials are stored for this URL, inject OAuthProvider
    authProvider: hasStoredCredentials ? oAuthProvider : undefined,
  })
}

Technical characteristics of the three transport types:

Transport TypeClass NameCommunication MethodUse Case
stdioStdioClientTransportStandard input/output pipesLocal MCP server processes (most common)
StreamableHTTPStreamableHTTPClientTransportHTTP POST + streaming responseNewer remote MCP servers
SSESSEClientTransportHTTP Server-Sent EventsLegacy remote server compatibility

Remote transports support automatic fallback: it tries StreamableHTTPClientTransport first, and falls back to SSEClientTransport if the server does not support it. Remote transports can also inject McpOAuthProvider for transparent authentication.

Effect Resource Management — acquireUseRelease

The create() function uses Effect’s acquireUseRelease pattern to manage the transport connection lifecycle:

yield* Effect.acquireUseRelease(
  // acquire: establish connection
  Effect.tryPromise(() => client.connect(transport)),

  // use: operations after successful connection (get tool list, etc.)
  async (connected) => {
    // ... tool discovery, state update ...
  },

  // release: ensure transport is closed regardless of success or failure
  (connected, exit) => {
    if (exit._tag === "Failure") {
      // Connection failed → close transport to release resources
      return Effect.tryPromise(() => transport.close())
    }
  },
)

This pattern ensures that even if an error occurs during connection, the transport is properly closed, preventing file descriptor or child process leaks.

Parallel Initialization of All MCP Servers

The start() function uses Effect’s forEach to initialize all configured MCP servers in parallel:

// Parallel initialization, no concurrency limit
yield* Effect.forEach(
  Object.entries(config.mcp),
  ([key, mcp]) => create(key, mcp),
  { concurrency: "unbounded" },
)

concurrency: "unbounded" means all servers start simultaneously; one slow server does not block others. If a server fails to connect, it is marked as failed but does not affect the initialization of other servers.

Tool Discovery and Registration

Tool discovery flow after a successful MCP server connection:

  1. The defs() function calls client.listTools() to get the server’s declared tool list
  2. Timeout protection is set to prevent unresponsive servers from blocking the entire startup flow
  3. convertMcpTool(mcpTool, client, timeout) converts each MCP tool to an AI SDK dynamicTool
  4. Tool naming convention: sanitizedClientName + "_" + sanitizedToolName (e.g., github_search_repositories)
  5. Register a tools/list_changed notification handler so tools are automatically re-discovered when the server dynamically adds or removes them

convertMcpTool — Bridging MCP Tools to AI SDK Tools

This is the most critical conversion function in the MCP module, transforming MCP protocol tool definitions into AI SDK dynamicTool:

function convertMcpTool(
  mcpTool: MCPToolDef,
  client: MCPClient,
  timeout?: number,
): Tool {
  // 1. Parameter schema conversion: pass through MCP's inputSchema directly
  const inputSchema = mcpTool.inputSchema
  const schema: JSONSchema7 = {
    ...(inputSchema as JSONSchema7),
    type: "object",
    properties: (inputSchema.properties ?? {}) as JSONSchema7["properties"],
    // Strict validation: no additional properties allowed
    additionalProperties: false,
  }

  // 2. Construct dynamicTool
  return dynamicTool({
    description: mcpTool.description ?? "",
    inputSchema: jsonSchema(schema),
    // 3. Executor: call MCP client's callTool
    execute: async (args: unknown) => {
      return client.callTool(
        {
          name: mcpTool.name,
          arguments: (args || {}) as Record<string, unknown>,
        },
        CallToolResultSchema,
        {
          resetTimeoutOnProgress: true,  // Auto-reset timeout on progress updates
          timeout,
        },
      )
    },
  })
}

Key design decisions:

  • Parameter schema pass-through: The MCP tool’s inputSchema is used directly as the AI SDK tool’s parameter definition, requiring no manual mapping
  • additionalProperties: false: Exclusive strict validation — LLM-generated parameters must exactly match the schema, preventing unexpected fields
  • resetTimeoutOnProgress: Long-running tools (e.g., large-scale searches) automatically reset their timeout when progress updates are received, avoiding being mistakenly killed
  • Naming convention: sanitizedClientName + "_" + sanitizedToolName ensures tool names from different MCP servers do not conflict

tools/list_changed Notification Handling

The MCP protocol supports servers dynamically adding or removing tools at runtime. The watch() function listens for tools/list_changed notifications:

client.setNotificationHandler(ToolsListChangedNotificationSchema, async () => {
  // 1. Re-fetch tool list
  const newTools = await defs(client, key, mcp)
  // 2. Update tool set in state
  // 3. Broadcast ToolsChanged event via Bus
  Bus.publish(Event.ToolsChanged, { client: key })
})

This enables the Agent to access tools dynamically added by MCP servers without restarting.

OAuth 2.0 + PKCE Authentication Flow

Remote MCP servers may require OAuth authentication. OpenCode implements a complete OAuth 2.0 + PKCE flow involving collaboration across three files:

Complete Flow

1. startAuth(key, mcp)
   ├─ Generate random state parameter (CSRF protection)
   ├─ Create McpOAuthProvider instance
   ├─ Attempt to connect to remote server
   └─ Capture authorizationUrl (extracted from Provider redirect)

2. authenticate(key, mcp)
   ├─ Call startAuth() to get authorization URL
   ├─ Open user browser (Open.browser)
   └─ waitForCallback(oauthState, mcpName) ← blocking wait, 5-minute timeout

3. Browser callback → McpOAuthCallback handles
   ├─ After user authorizes, redirect to http://localhost:19876/mcp/oauth/callback
   ├─ Verify state parameter (CSRF protection)
   └─ Parse authorization code

4. finishAuth(key, mcp, code)
   ├─ transport.finishAuth(code) exchanges for access token
   └─ createAndStore() persists credentials

5. McpAuth.set() → write to mcp-auth.json (permissions 0o600)

McpOAuthCallback — Local Callback Server

oauth-callback.ts implements a lightweight local HTTP server for receiving OAuth callbacks:

// Constant definitions
const OAUTH_CALLBACK_PORT = 19876           // Fixed callback port
const OAUTH_CALLBACK_PATH = "/mcp/oauth/callback"  // Callback path
const CALLBACK_TIMEOUT_MS = 5 * 60 * 1000  // 5-minute timeout

Core data structures:

// Pending authorization mapping: state → Promise resolve function
const pendingAuths: Map<string, (code: string) => void> = new Map()

// Reverse index: mcpName → oauthState (for canceling a specific MCP's authorization)
const mcpNameToState: Map<string, string> = new Map()

The ensureRunning() method checks if the port is already in use:

  • If the port is free, it starts an HTTP server listening on port 19876
  • If the port is already in use (meaning another OpenCode instance is running), it reuses the existing server

The handleRequest() method processes callback requests:

  1. Extract state and code parameters from the URL
  2. Verify that state exists in pendingAuths (CSRF protection)
  3. Call the corresponding resolve(code) to wake up the waiting authenticate() call
  4. Return a custom HTML page: success page (green) or error page (red)

McpOAuthProvider — OAuth Client Provider

oauth-provider.ts implements the OAuthClientProvider interface, which is the standard interface required by @modelcontextprotocol/sdk:

class McpOAuthProvider implements OAuthClientProvider {
  // Client metadata: tells the authorization server who we are
  get clientMetadata(): OAuthClientMetadata {
    return {
      redirect_uris: [`http://localhost:${OAUTH_CALLBACK_PORT}${OAUTH_CALLBACK_PATH}`],
      client_name: "OpenCode",
      grant_types: ["authorization_code", "refresh_token"],
    }
  }

  // Client information: lookup by priority
  // 1. client_id/client_secret from user configuration
  // 2. Stored clientInfo from McpAuth
  // 3. None → trigger Dynamic Client Registration (DCR)
  get clientInformation(): OAuthClientInformation | undefined { ... }

  // Tokens: retrieve from McpAuth and convert format
  get tokens(): OAuthTokens | undefined { ... }

  // Redirect to authorization page: capture URL instead of actually navigating
  async redirectToAuthorization(authorizationUrl: URL): Promise<void> {
    this.capturedAuthorizationUrl = authorizationUrl
  }

  // PKCE code verifier storage
  async saveCodeVerifier(codeVerifier: string): Promise<void> { ... }
  get codeVerifier(): string | undefined { ... }

  // CSRF state parameter storage
  async saveState(state: string): Promise<void> { ... }
  get state(): string | undefined { ... }

  // Clear stored credentials when invalidated
  async invalidateCredentials(kind: "client" | "tokens" | "all"): Promise<void> { ... }
}

invalidateCredentials handles three invalidation scenarios:

  • "client": Clear client information (e.g., clientSecret expired, checks clientSecretExpiresAt)
  • "tokens": Clear tokens (e.g., access_token expired with no refresh_token)
  • "all": Clear all credentials, restart authentication flow

McpAuth — Token Persistence

auth.ts uses the Effect Service pattern to implement secure storage of OAuth credentials:

// File path: Global.Path.data/mcp-auth.json
// File permissions: 0o600 (owner read/write only)

// Core helper function
async function writeJson(data: Entry[]): Promise<void> {
  await FileSystem.writeFile(
    Global.Path.data + "/mcp-auth.json",
    JSON.stringify(data, null, 2),
    { mode: 0o600 },  // Strict file permissions
  )
}

Key security measures:

  • File permissions 0o600: Ensures other users cannot read OAuth tokens
  • URL matching validation: getForUrl(serverUrl) verifies serverUrl matches when returning credentials, preventing credential cross-use
  • Token expiry check: isTokenExpired() checks the expiresAt field; expired tokens are not automatically used

The generic helper functions updateField and clearField provide type-safe field-level updates:

// Update a specific field of a URL entry
async function updateField<T extends keyof Entry>(
  serverUrl: string,
  field: T,
  value: Entry[T],
): Promise<void> { ... }

// Clear a specific field of a URL entry
async function clearField(
  serverUrl: string,
  field: keyof Entry,
): Promise<void> { ... }

Process Cleanup

The MCP module has special process cleanup requirements in stdio transport mode. Local MCP servers are typically launched as child processes, and these child processes may themselves create child processes (e.g., the GitHub MCP server might launch git subprocesses). OpenCode implements recursive process cleanup to ensure no orphan processes are left behind.

descendants — Recursively Getting the Process Tree

// Use BFS (breadth-first search) to recursively get all descendants of a process
const pids: number[] = []
const queue = [pid]  // Start from the MCP server process PID

while (queue.length > 0) {
  const current = queue.shift()!
  // Get direct children of the current process via pgrep -P
  const handle = yield* spawner.spawn(
    ChildProcess.make("pgrep", ["-P", String(current)], ...),
  )
  const text = yield* Stream.mkString(Stream.decodeText(handle.stdout))
  for (const tok of text.split("\n")) {
    const cpid = parseInt(tok, 10)
    if (!isNaN(cpid)) {
      pids.push(cpid)
      queue.push(cpid)  // Continue searching children of children
    }
  }
}

Cleanup Strategy

The complete cleanup flow when closing an MCP client:

1. client.close() closes the MCP client connection
2. Check if there is a stdio transport (has pid property)
   ├─ No pid (remote transport) → cleanup complete
   └─ Has pid (stdio transport) → continue
3. descendants(pid) gets all descendant processes
4. Starting from leaf processes (end of list), send SIGTERM in order
5. Wait 5 seconds
6. Still alive? Send SIGKILL to force terminate
7. Clear pendingOAuthTransports Map

The reason for sending SIGTERM starting from leaf processes: if the parent process is killed first, child processes may be reparented to the init process, becoming orphans. Killing leaf processes first ensures the process tree is cleaned up completely from bottom to top.

Automatic Cleanup on Instance Dispose

Cleanup logic on instance destruction is registered via Effect.addFinalizer:

Effect.addFinalizer(() =>
  Effect.gen(function* () {
    // Iterate all clients, execute the cleanup flow above
    for (const [key, client] of Object.entries(state.clients)) {
      // ... close + descendants + kill ...
    }
    // Clear pendingOAuthTransports
    pendingOAuthTransports.clear()
  }),
)

This ensures that child processes are properly cleaned up whether the instance shuts down normally or exits abnormally.

Call Chain Examples

Chain 1: Configuration Loading → MCP Startup → Tool Registration

1. Instance.init() triggers → MCP.start() is called


2. Read Config.mcp to get server list
   e.g.: { "github": { type: "stdio", command: "npx", args: ["-y", "@modelcontextprotocol/server-github"] } }


3. Effect.forEach({ concurrency: "unbounded" }) parallel initialization
   Call create(key, mcp) for each configuration:
   ├─ Check mcp.disabled → disabled state
   ├─ Select transport layer:
   │  ├─ stdio → StdioClientTransport → launch child process
   │  └─ url → StreamableHTTPClientTransport (fallback SSE)
   ├─ Effect.acquireUseRelease establishes connection
   │  ├─ acquire: client.connect(transport)
   │  ├─ use: client.listTools() → convertMcpTool() conversion
   │  └─ release: close transport on failure
   └─ Update status[key] state


4. defs(client, key, mcp) tool discovery
   ├─ client.listTools() gets tool list (with timeout protection)
   └─ convertMcpTool() converts each tool to dynamicTool
      e.g.: { name: "search_repositories" } → dynamicTool "github_search_repositories"


5. Bus.publish(ToolsChanged) notifies Agent of tool list update


6. Agent's next prompt construction includes MCP tools in tool descriptions

Chain 2: Agent Calls MCP Tool

1. LLM returns tool_use with tool name "github_search_repositories"


2. Agent tool routing matches this MCP tool
   → dynamicTool.execute({ query: "opencode" })


3. Inside convertMcpTool:
   client.callTool({
     name: "search_repositories",          // Original MCP tool name
     arguments: { query: "opencode" },     // LLM-generated parameters
   }, CallToolResultSchema, {
     resetTimeoutOnProgress: true,         // Reset timeout on progress updates
     timeout: 30000,                       // Default 30-second timeout
   })


4. MCP SDK sends request to MCP server via transport layer
   ├─ stdio: write to child process's stdin
   └─ HTTP: POST request to remote server


5. MCP server processes request, returns result


6. Result is passed to LLM through the Agent tool's return value

Chain 3: Complete OAuth Authentication Flow

1. User starts a remote MCP server requiring authentication
   MCP.create("remote-server", { url: "https://..." })
   ├─ Try StreamableHTTPClientTransport connection
   └─ Server returns 401 Unauthorized


2. Status updated to needs_auth with authorizationUrl


3. MCP.authenticate("remote-server", mcp)
   ├─ startAuth():
   │  ├─ Generate random state (CSRF protection)
   │  ├─ Create McpOAuthProvider (with PKCE code verifier)
   │  ├─ Attempt connection → capture authorizationUrl
   │  └─ Return { authorizationUrl, oauthState }

   ├─ Open.browser(authorizationUrl) → open user's browser

   └─ McpOAuthCallback.waitForCallback(oauthState, "remote-server")
      ├─ ensureRunning() → ensure HTTP server on port 19876 is running
      ├─ Store Promise in pendingAuths[state]
      └─ Wait for resolve, up to 5 minutes


4. User logs in and authorizes in the browser
   → Redirect to http://localhost:19876/mcp/oauth/callback?state=xxx&code=yyy


5. McpOAuthCallback.handleRequest()
   ├─ Verify state parameter matches (CSRF protection)
   ├─ Parse code (authorization code)
   ├─ resolve(code) → wake up waiting authenticate()
   └─ Return success HTML page


6. finishAuth("remote-server", mcp, code)
   ├─ transport.finishAuth(code) → exchange authorization code for access_token
   ├─ McpAuth.set() → persist to mcp-auth.json (permissions 0o600)
   └─ createAndStore() → reconnect with new credentials


7. Status updated to connected, tool discovery begins

Chain 4: Server Shutdown and Resource Cleanup

1. Instance.shutdown() → MCP.stop() is called


2. Iterate all clients
   ├─ client.close() close connection
   └─ Execute process cleanup for stdio transport:

      ├─ descendants(pid) recursively get process tree
      │  e.g.: pid=1000 → [1001, 1002, 1003]

      ├─ SIGTERM starting from leaf processes (from end of list)
      │  kill(1003, SIGTERM) → kill(1002, SIGTERM) → kill(1001, SIGTERM)

      ├─ Wait 5 seconds

      ├─ Still alive? → SIGKILL to force terminate

      └─ Cleanup complete


3. Clear pendingOAuthTransports Map
   Terminate all in-progress OAuth flows

Design Tradeoffs

DecisionRationale
Effect Service patternProvides type-safe dependency injection and resource lifecycle management. acquireUseRelease ensures transport connections are properly closed in all cases, and addFinalizer ensures child processes are recursively cleaned up
MCP SDK transport layer delegationThe complex details of transport protocols (stdio pipe management, HTTP SSE parsing, protocol version negotiation) are handled by @modelcontextprotocol/sdk, while OpenCode focuses on connection orchestration and tool conversion
additionalProperties: falseExclusive strict validation. MCP tool parameter schemas may be incomplete, but the AI SDK requires LLM-generated parameters to strictly match. Better to have the LLM retry than to pass unexpected fields to the MCP server
Parameter schema direct pass-throughNo MCP schema → AI SDK schema mapping conversion. MCP’s inputSchema is already JSON Schema, compatible with AI SDK’s jsonSchema(). Fewer conversion layers means fewer bugs
StreamableHTTP → SSE automatic fallbackThe MCP protocol is migrating from SSE to StreamableHTTP, but many servers still only support SSE. Automatic fallback ensures both types of servers can be connected
Fixed callback port 19876OAuth callbacks need a predictable port to pre-register in redirect_uri. ensureRunning() handles port conflicts, allowing multiple OpenCode instances to share the same callback server
Recursive process cleanup (BFS + leaf-first)stdio MCP servers may launch chains of child processes. Killing the parent first would cause children to be reparented to init, becoming orphans. BFS traversal + SIGTERM from leaves ensures complete process tree cleanup
5-minute OAuth timeoutOAuth requires the user to manually interact in the browser. 5 minutes gives enough time to complete login and authorization while preventing indefinite hangs
File permissions 0o600OAuth tokens are equivalent to passwords. 0o600 (owner read/write only) is a basic security measure on multi-user systems
concurrency: "unbounded" parallel initializationMCP servers are completely independent; one being slow does not affect others. No concurrency limit allows all servers to become ready as quickly as possible

Relationships with Other Modules

  • Agent: The Agent calls MCP tools through the unified tool interface, indistinguishable from built-in tools. convertMcpTool() wraps MCP tools as AI SDK dynamicTool, and tool descriptions are automatically injected into the Agent context. The Agent’s resolveTools() registers both built-in tools and MCP tools simultaneously
  • Config: Config.mcp defines the MCP server list, where each item includes type (stdio/url), command, args, env, url, disabled, and other fields. After configuration changes, the MCP module needs to be re-start()ed
  • Instance: MCP state is managed via Instance.state(), following the project instance lifecycle. start()/stop() are automatically called during instance initialization/shutdown. Effect.addFinalizer ensures child processes are cleaned up when the instance is destroyed
  • Bus: ToolsChanged BusEvent (BusEvent.define("mcp.tools.changed")) notifies of tool list changes, which the Agent and UI layers subscribe to for updates. BrowserOpenFailed event handles degradation when OAuth browser launch fails
  • Global: Global.Path.data/mcp-auth.json stores OAuth tokens. File permissions 0o600 ensure security. The path is managed uniformly by the Global module
  • Permission: MCP tool invocations are also governed by the Permission system. Users can approve or deny specific MCP tool executions, using the same permission rules as built-in tools
  • Provider: MCP tool execution results are returned to the Agent through dynamicTool, seamlessly integrating with the Provider module’s LLM call flow