Skip to content

MCP Integration

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:

  1. 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.
  2. 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.
  3. 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).
  4. 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.
  5. 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.

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.


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.

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.

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):

  1. Iterates over enabled servers from config (line 376)
  2. For each server, emits McpStartupUpdateEvent { server, status: Starting } via the event channel (lines 378-385)
  3. Creates an AsyncManagedClient wrapper that lazily starts the actual MCP connection (line 386)
  4. Spawns all clients into a JoinSet for parallel startup (line 398)
  5. Each client startup task:
    • Awaits the client connection
    • On success: sends sandbox state notification (codex/sandbox-state/update custom method), then emits McpStartupStatus::Ready
    • On failure: formats a descriptive error message (with auth context if relevant), emits McpStartupStatus::Failed { error }
  6. After all tasks complete, emits McpStartupCompleteEvent with a summary: ready, cancelled, and failed server lists (lines 442-461)

The TUI never queries MCP status — it receives three event types via async_channel:

EventWhenPayload
McpStartupUpdateEventPer-server status change{ server: String, status: Starting | Ready | Failed { error } }
McpStartupCompleteEventAll servers finished{ ready: [names], cancelled: [names], failed: [{ server, error }] }
McpToolCallBeginEvent / McpToolCallEndEventTool 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.

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.

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

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.

  • 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)

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.

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

MethodPathOperation IDPurpose
GET/mcp/mcp.statusGet status of all configured MCP servers
POST/mcp/mcp.addDynamically add a new MCP server
POST/mcp/:name/authmcp.auth.startStart OAuth flow, returns authorization URL
POST/mcp/:name/auth/callbackmcp.auth.callbackComplete OAuth with authorization code
POST/mcp/:name/auth/authenticatemcp.auth.authenticateFull OAuth flow (opens browser, waits for callback)
DELETE/mcp/:name/authmcp.auth.removeRemove stored OAuth credentials
POST/mcp/:name/connectmcp.connectRe-connect a disconnected server
POST/mcp/:name/disconnectmcp.disconnectDisconnect 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.

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.

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:

  1. Closes the existing client for that name if one exists
  2. Stores the new config in the instance state
  3. Calls MCP.create(key, mcp) to establish the connection
  4. 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.

MCP.create() (mcp/index.ts:291-494) handles the actual client creation with transport-specific logic:

Remote servers (lines 304-405):

  1. Creates McpOAuthProvider with server-specific auth storage
  2. Attempts StreamableHTTPClientTransport first
  3. Falls back to SSEClientTransport if StreamableHTTP fails
  4. On UnauthorizedError → sets status to needs_auth
  5. On client registration error → sets status to needs_client_registration
  6. Publishes toast notifications for auth-related states via the bus

Local servers (lines 408-450):

  1. Spawns child process via StdioClientTransport with configured command, args, and env
  2. Connects the MCP client
  3. On failure → sets status to failed with 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.

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:

  1. 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 StreamableHTTPClientTransport with 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
  2. 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 open package (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 Status after authentication
  3. 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

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.

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 state parameter 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

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

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 a ToolListChangedNotification. Payload: { server: string }. The TUI uses this to refresh the tool list display.
  • mcp.browser.open.failed — published when the open package 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.

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.

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.


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.

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.

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.

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.

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.

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.


Architecture Decision: Two-Mode MCP Management

Section titled “Architecture Decision: Two-Mode MCP Management”

Following the dual-mode pattern from the Architecture page:

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

  2. 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).

openoxide-core/src/mcp/manager.rs
#[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.

#[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.

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.

For server mode, run an OAuth callback listener on a configurable port (default 19876, matching OpenCode’s convention). Key improvements over OpenCode:

  1. Instance-aware routing: include an instance identifier in the OAuth state parameter so the callback server can route to the correct McpManager instance in multi-directory mode
  2. Configurable port: OPENOXIDE_OAUTH_CALLBACK_PORT env var (OpenCode hardcodes 19876)
  3. 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).

openoxide-server/src/routes/mcp.rs
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.

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

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.

  1. Shared trait for both modes. McpManager is the single abstraction. Config-only initialization in single-process mode is just add() called in a loop at startup with runtime adds disabled.
  2. 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.
  3. Instance-aware OAuth state. Encode the directory/instance ID in the OAuth state parameter to prevent cross-instance auth confusion.
  4. Sandbox state as optional capability. Support Codex’s codex/sandbox-state/update pattern for interoperability, but make it opt-in. Don’t require MCP servers to understand it.
CratePurpose
openoxide-mcpMCP client protocol (transport, handshake, tool calls)
openoxide-coreMcpManager trait + LocalMcpManager implementation
openoxide-serverHTTP routes for MCP management
axumHTTP framework for management API
tokio::sync::broadcastEvent distribution for status updates
serde_jsonAuth storage serialization
sha1Tool name collision resolution