MCP Integration
Feature Definition
Section titled “Feature Definition”MCP (Model Context Protocol) servers are external tool providers — code search engines, database interfaces, documentation providers, deployment platforms. The agent connects to them as an MCP client. But managing those connections (starting, stopping, authenticating, listing tools, handling failures) is a separate concern from the MCP protocol itself. The protocol-level details are covered on the MCP Client page.
This page covers the management layer: how the agent’s architecture exposes MCP lifecycle operations to its UI and how different architectures (single-process vs client-server) handle this differently. For the broader process-boundary tradeoffs in the same stacks, see Client-Server Architecture. For the inverse mode where the agent is exposed as an MCP tool provider, see MCP Server.
The management challenges are:
- Dynamic server addition. Users want to add new MCP servers at runtime without restarting the agent. In a client-server architecture, this means an HTTP API for server CRUD.
- Status reporting. MCP servers have complex status states — starting, connected, failed, needs authentication, needs client registration. The UI needs real-time updates as these states change.
- OAuth orchestration. Remote MCP servers often require OAuth 2.0 with PKCE. In a client-server architecture, the OAuth redirect flow must be coordinated between the server (which holds the MCP connection) and the client (which may need to open a browser).
- Instance isolation. In a multi-directory server, each project directory may have different MCP servers configured. The management layer must scope MCP connections per directory.
- Startup ordering. MCP servers must be initialized before the agent can list available tools. Startup can be slow (process spawn, network handshake, OAuth) and must not block the UI.
Aider Implementation
Section titled “Aider Implementation”Pinned at: b9050e1d (aider)
Aider has no MCP support. There are no MCP dependencies in requirements.in, no server management code, and no extension mechanism for external tools. Aider’s tool system is entirely built-in (file editing, shell commands, repo map). This eliminates the management problem entirely.
Codex Implementation
Section titled “Codex Implementation”Pinned at: 4ab44e2c (codex)
Codex is a single-process application with no HTTP server, so MCP management is internal. There is no management API — MCP servers are configured statically and initialized at startup. The TUI receives progress events via async channels.
Configuration-Based Initialization
Section titled “Configuration-Based Initialization”MCP servers are defined in the Codex config file (TOML). The function effective_mcp_servers() in codex-rs/core/src/mcp/mod.rs:162 resolves the final set of enabled servers from config, optionally injecting a built-in codex_apps server if the feature is enabled.
There is no way to add or remove MCP servers at runtime. Changing the MCP server list requires restarting Codex.
McpConnectionManager
Section titled “McpConnectionManager”The McpConnectionManager struct (mcp_connection_manager.rs:350) owns one RmcpClient per configured server, keyed by server name. It is created during session initialization and lives for the duration of the session.
Initialization flow (initialize(), mcp_connection_manager.rs:360-462):
- Iterates over enabled servers from config (line 376)
- For each server, emits
McpStartupUpdateEvent { server, status: Starting }via the event channel (lines 378-385) - Creates an
AsyncManagedClientwrapper that lazily starts the actual MCP connection (line 386) - Spawns all clients into a
JoinSetfor parallel startup (line 398) - Each client startup task:
- Awaits the client connection
- On success: sends sandbox state notification (
codex/sandbox-state/updatecustom method), then emitsMcpStartupStatus::Ready - On failure: formats a descriptive error message (with auth context if relevant), emits
McpStartupStatus::Failed { error }
- After all tasks complete, emits
McpStartupCompleteEventwith a summary:ready,cancelled, andfailedserver lists (lines 442-461)
Event-Driven Progress
Section titled “Event-Driven Progress”The TUI never queries MCP status — it receives three event types via async_channel:
| Event | When | Payload |
|---|---|---|
McpStartupUpdateEvent | Per-server status change | { server: String, status: Starting | Ready | Failed { error } } |
McpStartupCompleteEvent | All servers finished | { ready: [names], cancelled: [names], failed: [{ server, error }] } |
McpToolCallBeginEvent / McpToolCallEndEvent | Tool call lifecycle | { call_id, server, tool_name, arguments, result, duration } |
The TUI displays a startup progress indicator based on McpStartupUpdateEvent messages and shows a summary toast when McpStartupCompleteEvent arrives. There is no interactive management — the user cannot connect, disconnect, or authenticate servers from the TUI.
Auth Status Pre-Computation
Section titled “Auth Status Pre-Computation”Before initializing connections, Codex pre-computes auth status for all servers via compute_auth_statuses() (mcp/auth.rs:60). This parallelized check determines whether each server needs OAuth before attempting connection, so the startup event can include auth-related error messages.
Tool Aggregation
Section titled “Tool Aggregation”McpConnectionManager::list_all_tools() (mcp_connection_manager.rs:522) aggregates tools from all connected servers. Tool names are fully qualified as mcp__<server>__<tool> using __ as the delimiter (line 82). Names are sanitized to match the OpenAI Responses API pattern ^[a-zA-Z0-9_-]+$ (line 99). If sanitization causes collisions, a SHA1 hash suffix is appended (line 148). Maximum tool name length is 64 characters (line 83).
Sandbox State Propagation
Section titled “Sandbox State Propagation”A unique Codex feature: after MCP connection is established, Codex notifies each server of the current sandbox policy via a custom MCP method codex/sandbox-state/update (mcp_connection_manager.rs:406-413). Servers that declare the codex/sandbox-state capability receive a SandboxState object, allowing them to enforce matching restrictions. This is a Codex-specific extension to MCP, not part of the standard protocol.
What Codex Lacks
Section titled “What Codex Lacks”- No runtime add/remove of MCP servers
- No HTTP management API (single-process)
- No interactive OAuth flow from the TUI (auth must be pre-configured or handled externally)
- No reconnection after failure (requires restart)
- No per-directory MCP scoping (single session = single set of servers)
OpenCode Implementation
Section titled “OpenCode Implementation”Pinned at: 7ed44997 (opencode)
OpenCode is the only reference implementation with a full HTTP management API for MCP servers. The TUI client manages MCP entirely through REST calls, and status updates arrive via SSE events.
HTTP Route Module
Section titled “HTTP Route Module”The MCP management API is defined in packages/opencode/src/server/routes/mcp.ts (226 lines). It is mounted at /mcp in the server’s route tree (server.ts:151).
| Method | Path | Operation ID | Purpose |
|---|---|---|---|
| GET | /mcp/ | mcp.status | Get status of all configured MCP servers |
| POST | /mcp/ | mcp.add | Dynamically add a new MCP server |
| POST | /mcp/:name/auth | mcp.auth.start | Start OAuth flow, returns authorization URL |
| POST | /mcp/:name/auth/callback | mcp.auth.callback | Complete OAuth with authorization code |
| POST | /mcp/:name/auth/authenticate | mcp.auth.authenticate | Full OAuth flow (opens browser, waits for callback) |
| DELETE | /mcp/:name/auth | mcp.auth.remove | Remove stored OAuth credentials |
| POST | /mcp/:name/connect | mcp.connect | Re-connect a disconnected server |
| POST | /mcp/:name/disconnect | mcp.disconnect | Disconnect a connected server |
All routes use hono-openapi for automatic OpenAPI documentation generation. Request bodies are validated with Zod schemas. The generated SDK (packages/sdk/) provides typed client methods for each endpoint.
Status Model
Section titled “Status Model”MCP server status is a discriminated union defined in mcp/index.ts:66-109:
Status = discriminatedUnion("status", [ { status: "connected" }, { status: "disabled" }, { status: "failed", error: string }, { status: "needs_auth" }, { status: "needs_client_registration", error: string },])Five states cover the full lifecycle:
- connected — handshake complete, tools available
- disabled — user explicitly disconnected (via
POST /mcp/:name/disconnect) - failed — connection attempt failed (error string describes why)
- needs_auth — server returned
UnauthorizedError, OAuth required - needs_client_registration — OAuth dynamic client registration failed (server doesn’t accept this client)
The GET /mcp/ endpoint returns Record<string, Status> — a map of server names to their current status. This is the TUI’s primary way to render the MCP panel.
Dynamic Server Addition
Section titled “Dynamic Server Addition”POST /mcp/ accepts { name: string, config: Config.Mcp } where Config.Mcp is a Zod union of McpLocal (stdio transport: command + args + env) and McpRemote (HTTP transport: url + headers). The handler calls MCP.add(name, config) (mcp/index.ts:257-289), which:
- Closes the existing client for that name if one exists
- Stores the new config in the instance state
- Calls
MCP.create(key, mcp)to establish the connection - Returns the updated status
This is the mechanism by which the TUI’s “add MCP server” dialog works — it makes a POST request to the server, which handles the actual process spawning or HTTP connection.
Connection Lifecycle
Section titled “Connection Lifecycle”MCP.create() (mcp/index.ts:291-494) handles the actual client creation with transport-specific logic:
Remote servers (lines 304-405):
- Creates
McpOAuthProviderwith server-specific auth storage - Attempts
StreamableHTTPClientTransportfirst - Falls back to
SSEClientTransportif StreamableHTTP fails - On
UnauthorizedError→ sets status toneeds_auth - On client registration error → sets status to
needs_client_registration - Publishes toast notifications for auth-related states via the bus
Local servers (lines 408-450):
- Spawns child process via
StdioClientTransportwith configured command, args, and env - Connects the MCP client
- On failure → sets status to
failedwith error message
After successful connection, registerNotificationHandlers() (mcp/index.ts:112-117) subscribes to ToolListChangedNotification from the MCP SDK. When a server’s tool list changes, it publishes MCP.ToolsChanged on the bus, which the TUI receives via SSE.
OAuth Through the Server
Section titled “OAuth Through the Server”The OAuth flow is the most complex part of the management API. It must coordinate between the server (which holds the MCP transport), a local callback server (which receives the OAuth redirect), and potentially the client (which may need to open a browser).
Three-endpoint OAuth flow:
-
POST /mcp/:name/auth— Start OAuth- Validates server supports OAuth (
MCP.supportsOAuth()) - Starts the OAuth callback server on port 19876 (
McpOAuthCallback.ensureRunning()) - Generates cryptographic state:
crypto.getRandomValues(new Uint8Array(32))(line 734) - Creates
StreamableHTTPClientTransportwith the OAuth provider - Connects to capture the authorization URL (the MCP SDK’s auth flow produces this)
- Stores the pending transport for later (
pendingOAuthTransports.set(), line 775) - Returns
{ authorizationUrl: string }to the client
- Validates server supports OAuth (
-
POST /mcp/:name/auth/authenticate— Full automatic flow- Calls
startAuth()internally to get the authorization URL - Registers a callback waiter BEFORE opening the browser (line 807) — prevents race condition
- Opens the system browser via the
openpackage (line 810) - Blocks waiting for
McpOAuthCallback.waitForCallback(oauthState)(line 837) - Validates the returned state matches (CSRF protection, lines 840-844)
- Calls
finishAuth()with the authorization code - Returns the final
Statusafter authentication
- Calls
-
POST /mcp/:name/auth/callback— Complete with code- Accepts
{ code: string }(the authorization code) - Retrieves the pending transport from the map
- Calls
transport.finishAuth(authorizationCode)(line 864) - Clears the PKCE code verifier
- Re-adds the server to establish an authenticated connection (
MCP.add(), line 883) - Returns the new status
- Accepts
The authenticate endpoint is the all-in-one option for interactive use — the TUI calls it, the server opens the browser, waits for the callback, and returns the result. The auth + callback endpoints provide manual control for headless or remote scenarios where the client must handle the browser separately.
OAuth Callback Server
Section titled “OAuth Callback Server”McpOAuthCallback (mcp/oauth-callback.ts) runs a Bun HTTP server on port 19876, listening at /mcp/oauth/callback. Key details:
- Lazy startup:
ensureRunning()checks if port 19876 is already in use (supports multiple OpenCode instances) before binding - CSRF protection: validates the
stateparameter against pending auth requests (lines 115-122) - Timeout: pending auth requests expire after 5 minutes (line 57)
- Browser UX: returns an HTML page that auto-closes the browser tab after 2 seconds (line 22)
- Pending auth map:
Map<string, { resolve, reject, timeout }>keyed by OAuth state string
Auth Storage
Section titled “Auth Storage”McpAuth (mcp/auth.ts) persists OAuth credentials to ~/.opencode/data/mcp-auth.json. The schema per server:
Entry = { tokens?: { accessToken, refreshToken?, expiresAt?, scope? }, clientInfo?: { clientId, clientSecret?, clientIdIssuedAt?, clientSecretExpiresAt? }, codeVerifier?: string, oauthState?: string, serverUrl?: string,}URL validation (lines 42-53): credentials are invalidated if the server URL changes, preventing credential reuse across different servers. Token expiry is checked via isTokenExpired() (lines 126-131).
Event System Integration
Section titled “Event System Integration”MCP status changes propagate to the TUI via the instance-scoped event bus (covered on the Architecture page). Two MCP-specific bus events:
mcp.tools.changed— published when an MCP server sends aToolListChangedNotification. Payload:{ server: string }. The TUI uses this to refresh the tool list display.mcp.browser.open.failed— published when theopenpackage fails to launch a browser (e.g., in SSH sessions). Payload:{ mcpName, url }. The TUI can display the URL for manual copy.
Both events flow through Bus.publish() → subscribeAll() → SSE stream (GET /event), arriving at the client as part of the unified event stream.
Instance-Scoped MCP
Section titled “Instance-Scoped MCP”Because OpenCode’s server uses directory-based instance isolation (covered on the Architecture page), each project directory gets its own set of MCP connections. The MCP namespace operations (add, connect, status, etc.) all operate on Instance.state(), which is scoped per request via the directory resolver middleware. Two projects can have completely different MCP servers configured and connected.
Tool Aggregation
Section titled “Tool Aggregation”MCP.tools() (mcp/index.ts:566-606) aggregates tools from all connected servers in the current instance. Tool naming uses clientName_toolName format (underscore, not double-underscore like Codex). Each tool is converted from the MCP Tool type to the Vercel AI SDK Tool type using convertMcpTool() (line 120), which wraps the MCP inputSchema in jsonSchema() and creates a dynamicTool() with the per-server timeout applied.
Pitfalls & Hard Lessons
Section titled “Pitfalls & Hard Lessons”Config-Only vs Dynamic Management
Section titled “Config-Only vs Dynamic Management”Codex requires a restart to change MCP servers. OpenCode allows runtime changes via HTTP. The config-only approach is simpler and eliminates an entire class of bugs (state synchronization between config file and runtime state), but it makes MCP server exploration painful — the user must edit a config file and restart for every change. Dynamic management is worth the complexity for any tool that expects frequent MCP server iteration.
OAuth in Client-Server Is Harder Than Single-Process
Section titled “OAuth in Client-Server Is Harder Than Single-Process”In a single-process tool, OAuth is straightforward: open browser, receive redirect on localhost, done. In a client-server architecture, the server holds the MCP connection but the client may be on a different machine. OpenCode’s solution (the server opens the browser via the open package) works when the server runs locally, but fails for remote servers. The three-endpoint split (auth, authenticate, callback) is the right pattern — it lets the client decide whether to use the automatic flow or handle the browser manually.
Port 19876 Is a Coordination Problem
Section titled “Port 19876 Is a Coordination Problem”OpenCode’s OAuth callback server binds to port 19876. If multiple OpenCode instances run simultaneously, only the first gets the port. The ensureRunning() check handles this gracefully (it skips binding if the port is taken), but the auth flow silently depends on whichever instance owns the port actually forwarding the callback correctly. There is no instance-to-instance coordination.
Status State Machine Has No Reconnection
Section titled “Status State Machine Has No Reconnection”OpenCode’s status model has five states but no automatic reconnection. A failed server stays failed until the user manually calls POST /mcp/:name/connect. There is no retry backoff, no health check loop, and no automatic recovery from transient network failures. For local stdio servers this is acceptable (if the process dies, it probably has a real problem). For remote HTTP servers behind flaky networks, it means the user must manually reconnect.
Sandbox State Propagation Is Codex-Only
Section titled “Sandbox State Propagation Is Codex-Only”Codex’s codex/sandbox-state/update custom method is a good idea — it lets MCP servers enforce matching sandbox restrictions. But it is a Codex-specific extension, not part of the MCP specification. No other MCP host sends this. MCP servers that rely on it will silently ignore it when connected to other hosts, potentially running in a less restricted mode than expected.
Event-Driven vs Polling for Status
Section titled “Event-Driven vs Polling for Status”Codex uses push events (McpStartupUpdateEvent); OpenCode uses push events via SSE (mcp.tools.changed). Neither provides a polling fallback for the status query. OpenCode does have GET /mcp/ for point-in-time status, but status transitions between polls can be missed. The combination (SSE for real-time + HTTP for catch-up) is the correct pattern.
Tool Name Collisions Are Real
Section titled “Tool Name Collisions Are Real”Both Codex and OpenCode namespace MCP tools, but with different delimiters (__ vs _). If a server name contains the delimiter, tool name parsing becomes ambiguous. Codex handles this via SHA1 hash fallback for collisions. OpenCode does not appear to have collision detection — if two servers expose tools with names that produce the same clientName_toolName, the later one wins.
OpenOxide Blueprint
Section titled “OpenOxide Blueprint”Architecture Decision: Two-Mode MCP Management
Section titled “Architecture Decision: Two-Mode MCP Management”Following the dual-mode pattern from the Architecture page:
-
Single-process mode — MCP servers are initialized at startup from config. Progress events are sent via async channels to the TUI. No runtime add/remove (matches Codex). This is the MVP target.
-
Server mode — Full HTTP management API for MCP lifecycle. Dynamic add/remove, OAuth flow coordination, per-directory scoping. Mirrors OpenCode’s approach but with Rust type safety.
The MCP client protocol implementation lives in openoxide-mcp (covered on the MCP Client page). The management layer lives in openoxide-core (single-process) and openoxide-server (HTTP API).
Management Trait
Section titled “Management Trait”#[async_trait]pub trait McpManager: Send + Sync { /// List all configured MCP servers and their statuses. async fn status(&self) -> HashMap<String, McpStatus>;
/// Add a new MCP server at runtime. async fn add(&self, name: String, config: McpServerConfig) -> Result<McpStatus>;
/// Remove an MCP server. async fn remove(&self, name: &str) -> Result<()>;
/// Connect a disconnected server. async fn connect(&self, name: &str) -> Result<McpStatus>;
/// Disconnect a connected server. async fn disconnect(&self, name: &str) -> Result<McpStatus>;
/// Start OAuth flow, returning the authorization URL. async fn start_auth(&self, name: &str) -> Result<AuthorizationUrl>;
/// Complete OAuth with the authorization code. async fn finish_auth(&self, name: &str, code: &str) -> Result<McpStatus>;
/// List all tools from all connected servers. async fn tools(&self) -> Vec<McpToolDefinition>;}In single-process mode, a LocalMcpManager implements this trait, backing add/remove with in-memory state and emitting events to the TUI via tokio::sync::broadcast. In server mode, the HTTP routes call the same trait methods — the business logic is shared.
Status Enum
Section titled “Status Enum”#[derive(Debug, Clone, Serialize, Deserialize)]#[serde(tag = "status")]pub enum McpStatus { Connected, Disabled, Failed { error: String }, NeedsAuth, NeedsClientRegistration { error: String },}Matches OpenCode’s five-state model. This is the right level of granularity — fewer states lose information (the auth distinction matters for UI), more states add complexity without value.
Startup Events
Section titled “Startup Events”Follow Codex’s event pattern for progress reporting:
pub enum McpEvent { /// Per-server status change during startup or at runtime. StatusUpdate { server: String, status: McpStatus }, /// All initial servers have finished startup. StartupComplete { ready: Vec<String>, failed: Vec<(String, String)>, }, /// A server's tool list changed. ToolsChanged { server: String },}These events are emitted via the EventSink trait (from the architecture page). The TUI receives them as typed events; the HTTP server serializes them into the SSE stream.
OAuth Callback Server
Section titled “OAuth Callback Server”For server mode, run an OAuth callback listener on a configurable port (default 19876, matching OpenCode’s convention). Key improvements over OpenCode:
- Instance-aware routing: include an instance identifier in the OAuth state parameter so the callback server can route to the correct
McpManagerinstance in multi-directory mode - Configurable port:
OPENOXIDE_OAUTH_CALLBACK_PORTenv var (OpenCode hardcodes 19876) - Timeout with cleanup: 5-minute expiry on pending auth requests, matching OpenCode
For single-process mode, reuse the same callback server but bind lazily (only when an OAuth flow is initiated).
HTTP Routes (Server Mode)
Section titled “HTTP Routes (Server Mode)”pub fn routes() -> Router<AppState> { Router::new() .route("/", get(status).post(add)) .route("/:name/connect", post(connect)) .route("/:name/disconnect", post(disconnect)) .route("/:name/auth", post(start_auth).delete(remove_auth)) .route("/:name/auth/callback", post(auth_callback)) .route("/:name/auth/authenticate", post(authenticate))}Each handler extracts the McpManager from axum state and calls the corresponding trait method. Request/response types are derived from utoipa for OpenAPI generation.
Auth Storage
Section titled “Auth Storage”Persist OAuth credentials in $XDG_DATA_HOME/openoxide/mcp-auth.json. Schema matches OpenCode’s format: tokens, client info, code verifier, server URL. URL validation on read (invalidate if server URL changed).
Tool Naming
Section titled “Tool Naming”Use mcp__<server>__<tool> (double-underscore delimiter, matching Codex). Sanitize to ^[a-zA-Z0-9_-]+$. Handle collisions with SHA1 hash suffix (Codex’s approach). Maximum length: 64 characters.
Key Design Decisions
Section titled “Key Design Decisions”- Shared trait for both modes.
McpManageris the single abstraction. Config-only initialization in single-process mode is justadd()called in a loop at startup with runtime adds disabled. - No automatic reconnection in v1. Match OpenCode’s manual reconnect model. Add exponential backoff reconnection for remote servers in a later iteration — the failure modes are complex and getting reconnection wrong (infinite retry loops, stale connections) is worse than no reconnection.
- Instance-aware OAuth state. Encode the directory/instance ID in the OAuth state parameter to prevent cross-instance auth confusion.
- Sandbox state as optional capability. Support Codex’s
codex/sandbox-state/updatepattern for interoperability, but make it opt-in. Don’t require MCP servers to understand it.
Crates
Section titled “Crates”| Crate | Purpose |
|---|---|
openoxide-mcp | MCP client protocol (transport, handshake, tool calls) |
openoxide-core | McpManager trait + LocalMcpManager implementation |
openoxide-server | HTTP routes for MCP management |
axum | HTTP framework for management API |
tokio::sync::broadcast | Event distribution for status updates |
serde_json | Auth storage serialization |
sha1 | Tool name collision resolution |