MCP (Model Context Protocol) is the open protocol AI agents use to call external tools. With the SDK you define MCP servers and configure tools for agents; connection management, tool discovery, OAuth, and status sync are handled by the underlying CLI.
The three approaches can be mixed—register multiple servers of different types in the same session.
In-process tools are the most direct extension path: define an ordinary async function with a schema declaration and the agent can call it. For tool creation / schema / handler details see Tools; this section only covers MCP server assembly.
The return value has the shape
The following three fields are actually consumed by the SDK and echoed back to the host via MCP status queries (TypeScript's
Use the error flag (
Python handlers may accept a second parameter,
Communicates with MCP servers via a child process's stdin/stdout. The
An unreachable or failing
Likewise, an unreachable remote URL won't hang the query; the server status stays non-
The CLI uniformly prefixes MCP tools when exposing them to the model:
For example, server
Use
Omitting the pre-approval list only means no pre-approval rules—the model can still see/call all tools; write operations just go through approval per the permission mode. Full semantics in the Permissions docs.
In TypeScript, runtime management goes through the
To keep the prompt prefix cache stable, prefer finalizing server-set changes at startup:
On timeout the SDK automatically writes a
Remote MCP servers (HTTP/SSE) often require OAuth. The CLI has a complete built-in OAuth 2.0 + PKCE + Dynamic Client Registration (RFC 7591) implementation.
The host controls OAuth timing, completing it before sending the first user message:
The Python SDK also supports an inbound path: when the CLI detects during handshake that a server needs OAuth, it pushes an
MCP
URL mode completes asynchronously: once the server's own callback receives the user's authorization it sends
Python notes:
Hosts can also attach hooks to observe / intercept elicitation:
Architecture Overview
- In-Process: the tool is just an ordinary async function (JS / Python) running in your own process. The server instance talks to the CLI over the SDK control channel—no extra process is spawned.
- External: You declare a child process or remote URL in the configuration; the CLI handles connection, discovery, and invocation.
Three Integration Methods
| Method | Config type | Process Boundary | Use Case |
|---|---|---|---|
| In-Process | 'sdk' (created by createSdkMcpServer / create_sdk_mcp_server) | Same process | Custom business tools that need direct access to host state |
| Stdio | 'stdio' (can be omitted) | Child process | Existing MCP toolkits (@modelcontextprotocol/server-*) |
| SSE / HTTP | 'sse' / 'http' | Remote | Remote services, SaaS tools, services requiring OAuth |
💡 In Python,mcp_serversalso accepts astr/pathlib.Pathpointing to a JSON config file; the SDK passes it through to the CLI as--mcp-config <path>.
In-Process Server (Recommended)
In-process tools are the most direct extension path: define an ordinary async function with a schema declaration and the agent can call it. For tool creation / schema / handler details see Tools; this section only covers MCP server assembly.
30-Second Getting Started
Full signatures
| Parameter | Description |
|---|---|
name (tool) | Tool name; the fully-qualified name will be mcp__<server>__<name> |
description | Description for the model, determining when the AI invokes it — clearly state what the tool does and when to use it |
inputSchema / input_schema | TypeScript takes a Zod raw shape (not z.object(...)); Python supports a simple dict / TypedDict / full JSON Schema dict |
handler | Actual logic, returns CallToolResult |
annotations | MCP tool annotations; see table below |
name (server) | Server name (determines tool prefix mcp__<name>__) |
version | Defaults to '1.0.0' |
tools | List of tools |
{ type: 'sdk', name, instance }—drop it straight into the MCP servers config.
⚠️ Do not reuse the same server config across multiple query() calls: Each query binds an independent transport. Reusing the same config has no side effects, but you won't get "cross-query shared state" capability either — for shared state, place it in module scope outside the handler closure.
Annotations Actually Consumed
The following three fields are actually consumed by the SDK and echoed back to the host via MCP status queries (TypeScript's mcpServerStatus().tools[i].annotations, Python's get_mcp_status().mcpServers[i].tools[i].annotations):
| Field | What it does | Host-side reads as |
|---|---|---|
readOnlyHint | Declares the tool read-only. Read-only tools can run concurrently (no mutual blocking within a batch); the TUI tool details render a [read-only] badge | annotations.readOnly |
destructiveHint | Declares the tool performs destructive operations. The TUI renders a [destructive] badge in tool details | annotations.destructive |
openWorldHint | Declares the tool reaches the outside world (web search, third-party APIs). The TUI tool details render an [open-world] badge | annotations.openWorld |
Note the host-side field names drop theHintsuffix:readOnlyHint→annotations.readOnly, and so on. Theannotationsobject only contains explicitly set fields. ⚠️ These three fields do not affect auto-mode permission decisions. The CLI treats server-declared annotations as unverifiable hints (servers can freely under-/over-declare) and keeps them out of the permission pipeline to avoid endorsing self-description. To hard-deny tools, use tool allowlists or hooks—annotations are only for host-side identification and TUI display.
idempotentHint and title are currently not consumed by the SDK—passing them won't error, but the SDK neither consumes nor echoes them to the host. Maintain your own mapping if your app needs them.
💡 AboutmaxResultSizeChars: the Python SDK writesanthropic/maxResultSizeCharsinto the tool's_metaviaToolAnnotations(maxResultSizeChars=...), letting the CLI relax the default 50K output limit (TS exposes the same annotation; the wire format is identical).
CallToolResult Structure
isError: true / is_error: True) for business failures instead of throwing—an exception kills the whole tool call and the AI gets nothing, while the error flag tells the AI "this call failed, try something else". For the Python/TS behavioral differences (resource_link degraded to text, top-level _meta not passed through, etc.) see Tools.
Handler cancellation signal (Python)
Python handlers may accept a second parameter, ToolInvocationContext, and exit cooperatively via extra.signal when the CLI cancels the in-flight call:
Stdio Server
Communicates with MCP servers via a child process's stdin/stdout. The @modelcontextprotocol/server-* packages on NPM are all stdio implementations.
command doesn't drag down the whole query—that server's status stays non-'connected' and other servers are unaffected.
SSE / HTTP Server
'connected', and other servers are unaffected. For remote services requiring OAuth, see OAuth Authentication.
Tool Naming and Allowlists
The CLI uniformly prefixes MCP tools when exposing them to the model:
my_tools with tool greet appears to the model as mcp__my_tools__greet. Server names may contain hyphens and other special characters (my-tools → mcp__my-tools__<tool>).
tools: Restrict Which Tools the Model Can See
Use tools when you want the model to only see a subset of tools. The CLI adds every built-in tool not in the list to the disallow set — effectively a visibility allowlist:
⚠️ Omitting tools means everything is exposed: all built-in tools plus every tool from connected MCP servers reach the model. For production, list them explicitly to tighten scope.
Pre-approval list (not a visibility allowlist)
allowedTools / allowed_tools adds the listed tools to the auto-approve rules—invocations skip the permission prompt, but unlisted tools are not hidden. Commonly used to exempt low-risk MCP tools from approval:
Process-type server allowlist
allowedMcpServerNames / allowed_mcp_server_names filters process-type (stdio/sse/http) servers only and does not affect in-process servers. Combine with strictMcpConfig: true / strict_mcp_config=True to stop the CLI from loading extra local configs:
⚠️ Omitting the allowlist opens everything up: all declared process-type servers connect; list names explicitly to narrow. In-process servers are never affected by this field.
Runtime management
In TypeScript, runtime management goes through the Query object returned by query(); in Python, the one-shot query() iterator cannot change servers or auth mid-flight—use QoderSDKClient. All methods talk to the CLI over the control channel and are async and idempotent.
⚠️ Caching principle: MCP server config / auth changes rebuild the tools list, and mid-session changes break the prompt prefix cache. The SDK provides "query status + finish auth before the first message" methods; configure the server set once at startup via options and restart the session when it must change.
Querying Status
💡 The MCP handshake happens after the CLI completesinitializeand before the first user message. Query status only after the initialization result returns—handshake IO can take hundreds of milliseconds, so poll untilconnectedbefore relying on it.
Subscribing to Status Changes
- TypeScript: MCP status is pull, not push—call
await q.mcpServerStatus(), polling in your own code as needed. - Python: besides pulling, you can attach an
on_mcp_status_changecallback in options, invoked once per status change; or consume the message stream filteringsystem/mcp_status_change. Callback and stream carry the same payload.
Changing the server set
To keep the prompt prefix cache stable, prefer finalizing server-set changes at startup:
| Goal | TypeScript | Python |
|---|---|---|
| Add / remove / replace servers | Configure in options.mcpServers; restart query() to change the set | Configure mcp_servers at startup; at runtime client.set_mcp_servers(servers) replaces wholesale (returns {added, removed, errors}) |
| Enable only some process-type servers | allowedMcpServerNames allowlist | allowed_mcp_server_names allowlist |
| Reconnect a server | Restart query() | client.reconnect_mcp_server(name), typically to recover from 'failed' |
| Enable / disable a server | Restart query() | client.toggle_mcp_server(name, enabled); disabling disconnects and delists its tools |
| Sign out of a server | Restart query() without the token | client.mcp_clear_auth(name) |
⚠️ These Python runtime methods all rebuild the tools list and therefore break the prompt prefix cache. In production, prefer configuring everything at startup and reserve these APIs for debugging and local development.
Controlling Request Timeout
control_cancel_request and rejects the pending request.
OAuth Authentication
Remote MCP servers (HTTP/SSE) often require OAuth. The CLI has a complete built-in OAuth 2.0 + PKCE + Dynamic Client Registration (RFC 7591) implementation.
⚠️ Caching principle: after OAuth completes, the CLI reconnects the server and rediscovers tools—finishing auth mid-session inevitably breaks the prompt prefix cache. Complete auth before the first user message and start chatting once the tools list is stable.
💡 This section covers CLI-driven OAuth only: the CLI does metadata discovery, PKCE, token exchange, and token persistence itself. There is a separate server-driven auth path—the server uses MCP elicitation/create to send the client to a URL to authorize (typical example: GitHub MCP). The two paths are independent and never trigger together. See Elicitation: server requests user input.
Host-driven authentication (outbound)
The host controls OAuth timing, completing it before sending the first user message:
| Method (TypeScript / Python) | Purpose | When to call |
|---|---|---|
mcpAuthenticate(name, redirectUri?) / mcp_authenticate(name, redirect_uri=None) | Starts OAuth; returns { authUrl?, requiresUserAction }. On silent renewal requiresUserAction: false—no UI needed | Before the first user message |
mcpSubmitOAuthCallbackUrl(name, url) / mcp_submit_oauth_callback_url(name, callback_url) | Submits the full callback URL (with code/state) | Before the first user message |
inject_mcp_token(name, token) (Python only) | Host runs the whole OAuth itself and injects the OAuthToken into the CLI | Before the first user message |
mcp_clear_auth(name) (Python only) | Deletes the CLI-stored OAuth credentials—"sign out" | Anytime; the next tool call triggers re-auth |
redirectUri / redirect_uri is optional and overrides the default OAuth callback target (Electron custom protocols, intranet callback addresses, etc.).
The CLI stores tokens in the system Keychain by default (macOS / Linux Secret Service), falling back to ~/.qoder/mcp-oauth-tokens.json (0o600 permissions + cross-process locking).
Inbound: the on_mcp_oauth_required callback (Python)
The Python SDK also supports an inbound path: when the CLI detects during handshake that a server needs OAuth, it pushes an McpOAuthRequest to the SDK via control_request, and the SDK invokes the host's on_mcp_oauth_required callback. The host returns one of these resolutions:
| Return type | Meaning |
|---|---|
OAuthToken or {"token": OAuthToken} | The host runs the entire OAuth flow itself and injects the token directly into the CLI |
{"callbackUrl": "..."} | The host returns the full callback URL (including code / state); the CLI parses it and exchanges for a token |
{"code": "...", "state": "..."} | The host extracts the code itself and returns it to the CLI |
None | Reject; the CLI marks that server as failed |
Elicitation: Server Requests User Input
MCP elicitation/create is a server → client request for showing the user an interaction. The SDK surfaces it to the host via onElicitation (TypeScript) / on_elicitation (Python).
Two Modes
| Mode | Trigger Scenario | Typical Use |
|---|---|---|
'form' | The server wants structured input; the request carries requestedSchema (a restricted-subset MCP JSON Schema) | API key entry, config forms, confirmations |
'url' | The server sends the user to a URL; the request carries url + elicitationId | Server-side OAuth, device-code activation, account linking |
notifications/elicitation/complete—the SDK projects it as an elicitation-complete message in the stream.
⚠️ qodercli currently advertises onlyelicitation: {}in MCP capabilities (equivalent to{ form: {} }), so only form mode actually reaches the client from remote servers today. URL mode is protocol-complete but requires the CLI to declare theelicitation.urlcapability—coming with future CLI versions, at which point the path lights up automatically.
Callback Signature
- Field names follow the TS SDK's camelCase (
serverName / elicitationId / requestedSchema / displayName); the CLI's snake_case payload is converted automatically by the SDK. - Returning
Noneequals{"action": "cancel"}; with no callback registered the SDK auto-answers cancel per the default contract. - You can also return a
mcp.types.ElicitResultPydantic model (the SDK callsmodel_dump).
signal aborts on q.close() / interruption—check it in long flows.
Form Mode Example
URL mode example (with elicitation_complete)
💡 Do not await the browser round-trip inside the elicitation callback. URL mode is designed for the callback toacceptimmediately (= the user has started the flow) so the CLI doesn't block the control channel; the real completion signal is the subsequentelicitation_completemessage. Awaiting the whole OAuth redirect triggers the control request timeout.
Boundary with the OAuth Path
- CLI-driven OAuth (
mcpAuthenticate/mcp_authenticate, etc.): tokens land in the qodercli Keychain; driven by MCP status showingneeds-auth; does not trigger the elicitation callback. - Server-driven elicit URL: tokens stay inside the server; MCP status never shows
needs-auth; handled by the elicitation callback and finalized by theelicitation_completemessage.
elicitation/create during handshake—if it does, it's server-driven.
Hook Channel
Hosts can also attach hooks to observe / intercept elicitation:
| Hook event | Timing | Notes |
|---|---|---|
Elicitation | When the server request arrives | In TypeScript it takes precedence over onElicitation and can auto accept / decline / cancel (short-circuiting the UI) or pass through; in Python it is observe-only—decisions go to on_elicitation |
ElicitationResult | After the user responds | TypeScript can rewrite action / content or block; Python observes only |
Notification (type=elicitation_complete) | When the URL-mode completion notification arrives | Trigger IDE / system notifications |
Options Reference
| Field (TypeScript / Python) | Default | Description |
|---|---|---|
mcpServers / mcp_servers | – | Server name → config; Python also accepts a JSON config file path |
allowedMcpServerNames / allowed_mcp_server_names | – | Process-type server allowlist (in-process unaffected); omitting opens everything |
strictMcpConfig / strict_mcp_config | false | Stops the CLI from loading extra MCP servers from user config files |
tools / tools | – | Model-visible tool allowlist; omitting exposes all built-in + MCP tools |
allowedTools / allowed_tools | – | Pre-approval list (skips the permission prompt, does not control visibility); omitting means no pre-approval rules |
disallowedTools / disallowed_tools | – | Explicitly denied tools; takes precedence over allow |
controlRequestTimeoutMs / control_request_timeout_ms | 60_000 | Control request timeout (incl. the mcp series); 0 disables |
onElicitation / on_elicitation | – | Fires when an MCP server requests user input (form / url modes) |
on_mcp_oauth_required (Python only) | – | Fires when the CLI detects a server needs OAuth |
on_mcp_status_change (Python only) | – | Fires on every server status change; equivalent to filtering the system/mcp_status_change stream |
Runtime method quick reference
| Method (TypeScript / Python) | Description | When to call |
|---|---|---|
mcpServerStatus() / get_mcp_status() | Get all current MCP server statuses | Anytime |
mcpAuthenticate(...) / mcp_authenticate(...) | Start OAuth; returns { authUrl?, requiresUserAction } | Before the first user message |
mcpSubmitOAuthCallbackUrl(...) / mcp_submit_oauth_callback_url(...) | Submit the OAuth callback | Before the first user message |
set_mcp_servers(servers) (Python only) | Replace the MCP server config wholesale; returns {added, removed, errors} | Anytime (breaks the prefix cache) |
reconnect_mcp_server(name) (Python only) | Reconnect a server | Anytime |
toggle_mcp_server(name, enabled) (Python only) | Enable / disable a server | Anytime |
inject_mcp_token(name, token) (Python only) | Inject a token after host-run OAuth | Before the first user message |
mcp_clear_auth(name) (Python only) | Delete stored OAuth credentials | Anytime |
In TypeScript, change the server set viaoptions.mcpServers(configured at startup) plus aquery()restart.
Type Reference
McpServerStatus.status enum:
| Value | Meaning |
|---|---|
'pending' | Registered, connection not yet started |
'connecting' | Handshaking |
'connected' | Connected, tools are callable |
'failed' | Connection failed (check the error field) |
'needs-auth' | Requires OAuth, proceed with auth flow |
'disabled' | Disabled (determined by CLI internal config or external state) |
Best Practices
- Write descriptions for the AI: a tool's
descriptiondecides when the AI picks it. Spell out "what it does, when to use it, what not to use it for". - Describe every field: always add
.describe(...)to Zod fields in TypeScript andAnnotated[type, "..."]in Python—the AI uses these to construct call arguments. - Fail with the error flag, don't throw: let the AI see the result. Exceptions leave the model confused and may trigger retries.
- Prefer read-only +
readOnlyHint: be careful with writes; pair them with the permission callback or hooks for double confirmation. - Keep server names short: They appear in tool prefixes; overly long names waste tokens.
- Place in-process shared state in module scope: Handlers are closures, but each query still reuses the same server instance.
- Finish OAuth before the first user message: mid-session auth inevitably breaks the prompt prefix cache.
- Pull MCP status on demand: poll
mcpServerStatus()in TypeScript; in Python chooseget_mcp_status()or theon_mcp_status_changecallback. - Set a sensible control request timeout: remote server handshakes can take seconds—the default 60s usually suffices; raise it while waiting for OAuth user actions; set it explicitly in CI.
- Use strict MCP config for isolation: keep MCP servers declared in the user's local
settings.json/.mcp.jsonfrom interfering with your app.