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
convertMcpTooltransparently converts MCP tools to AI SDKdynamicTool, 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 Path | Lines | Responsibility |
|---|---|---|
src/mcp/index.ts | ~922 | Module main entry: MCP namespace, Effect Service pattern, client creation, tool discovery, OAuth flow, process cleanup |
src/mcp/auth.ts | ~174 | McpAuth namespace, OAuth token Zod schema definitions and persistent storage (mcp-auth.json, permissions 0o600) |
src/mcp/oauth-callback.ts | ~217 | McpOAuthCallback namespace, local HTTP callback server (port 19876), handles OAuth redirects and extracts authorization codes |
src/mcp/oauth-provider.ts | ~186 | McpOAuthProvider 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:
| State | Meaning | Source |
|---|---|---|
connected | Successfully connected, tools available | create() succeeded, finishAuth() succeeded |
disabled | User disabled this server in configuration | start() checks mcp.disabled |
failed | Connection failed | create() threw an exception, OAuth failed |
needs_auth | OAuth authentication required, includes authorization URL | Remote server returned 401 |
needs_client_registration | Need to register OAuth client first | Remote 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 Type | Class Name | Communication Method | Use Case |
|---|---|---|---|
| stdio | StdioClientTransport | Standard input/output pipes | Local MCP server processes (most common) |
| StreamableHTTP | StreamableHTTPClientTransport | HTTP POST + streaming response | Newer remote MCP servers |
| SSE | SSEClientTransport | HTTP Server-Sent Events | Legacy 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:
- The
defs()function callsclient.listTools()to get the server’s declared tool list - Timeout protection is set to prevent unresponsive servers from blocking the entire startup flow
convertMcpTool(mcpTool, client, timeout)converts each MCP tool to an AI SDKdynamicTool- Tool naming convention:
sanitizedClientName + "_" + sanitizedToolName(e.g.,github_search_repositories) - Register a
tools/list_changednotification 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
inputSchemais 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 fieldsresetTimeoutOnProgress: Long-running tools (e.g., large-scale searches) automatically reset their timeout when progress updates are received, avoiding being mistakenly killed- Naming convention:
sanitizedClientName + "_" + sanitizedToolNameensures 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:
- Extract
stateandcodeparameters from the URL - Verify that
stateexists inpendingAuths(CSRF protection) - Call the corresponding
resolve(code)to wake up the waitingauthenticate()call - 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, checksclientSecretExpiresAt)"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)verifiesserverUrlmatches when returning credentials, preventing credential cross-use - Token expiry check:
isTokenExpired()checks theexpiresAtfield; 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
| Decision | Rationale |
|---|---|
| Effect Service pattern | Provides 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 delegation | The 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: false | Exclusive 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-through | No 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 fallback | The 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 19876 | OAuth 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 timeout | OAuth 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 0o600 | OAuth tokens are equivalent to passwords. 0o600 (owner read/write only) is a basic security measure on multi-user systems |
concurrency: "unbounded" parallel initialization | MCP 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 SDKdynamicTool, and tool descriptions are automatically injected into the Agent context. The Agent’sresolveTools()registers both built-in tools and MCP tools simultaneously - Config:
Config.mcpdefines the MCP server list, where each item includestype(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.addFinalizerensures child processes are cleaned up when the instance is destroyed - Bus:
ToolsChangedBusEvent (BusEvent.define("mcp.tools.changed")) notifies of tool list changes, which the Agent and UI layers subscribe to for updates.BrowserOpenFailedevent handles degradation when OAuth browser launch fails - Global:
Global.Path.data/mcp-auth.jsonstores OAuth tokens. File permissions0o600ensure 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