Skip to main content
MCP (Model Context Protocol) is an open protocol for AI Agents to invoke external tools. The Python SDK has built-in MCP client capabilities — the host application only needs to describe “which MCP servers exist,” and the SDK automatically handles connection, tool discovery, message routing, OAuth, and state synchronization.

Architecture Overview

  • In-Process: The tool is a Python async function running in your own process. The product of create_sdk_mcp_server communicates with the CLI via the SDK’s control channel without spawning a child process.
  • External: You declare a child process or remote URL in the configuration; the CLI handles connection, discovery, and invocation.

Three Integration Methods

All three methods can be mixed — register multiple servers of different types in the same query() / QoderSDKClient.
💡 mcp_servers can also accept a str / pathlib.Path pointing to a JSON configuration file path; the SDK will pass it through to the CLI as --mcp-config <path>.

In-process tools are the most straightforward extension method: define a regular async function, declare a schema with the decorator, and it becomes callable by the Agent. For detailed @tool() / schema / handler behavior, see tools.md; this section only covers parts related to MCP server assembly.

30-Second Getting Started

@tool() / create_sdk_mcp_server() Full Signatures

The return value McpSdkServerConfig looks like {"type": "sdk", "name": ..., "instance": ...} and can be placed directly into options.mcp_servers.

Annotations Actually Consumed

The three fields below are consumed by the SDK and returned to the host via get_mcp_status().mcpServers[i].tools[i].annotations:
Note that host-side field names drop the Hint suffix: readOnlyHintannotations.readOnly, and so on. The annotations object only contains fields that were explicitly set. ⚠️ These fields do NOT affect auto-mode permission decisions. The CLI treats server-declared annotations as unverifiable advisory metadata (servers can freely under- or over-declare) and intentionally keeps them out of the permission pipeline — admitting them would launder authority for the server’s self-assessment. To hard-block specific tools, use the allowed_tools allowlist or hooks — annotations are for host-side identification (get_mcp_status) and TUI display only.
idempotentHint and title are not currently consumed by the SDK — passing them won’t error, but they neither affect CLI behavior nor appear in get_mcp_status(). If your application needs this information, maintain the mapping yourself on the host side.
💡 About maxResultSizeChars: The Python SDK writes anthropic/maxResultSizeChars into the tool’s _meta via ToolAnnotations(maxResultSizeChars=...), and the CLI uses this to relax the default 50K return length limit. This field is a Python-side incremental capability (TS exposes it via the same-named annotation; wire format is identical).

Handler Return Value

For business failures, use is_error: True instead of throwing an exception. The full content type description, along with several behavior differences between Python and TS (resource_link degrades to text, top-level _meta is not passed through, binary embedded resources are skipped), is in Tools Reference - CallToolResult.

Handler Cancellation Signal

The handler can optionally accept a second parameter ToolInvocationContext to cooperatively exit when the CLI cancels the current call via extra.signal:
⚠️ 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 also won’t get “cross-query shared state” — for shared state, place it in module scope outside the handler closure.

Stdio Server

Communicates with MCP servers via a child process’s stdin/stdout. The @modelcontextprotocol/server-* packages on NPM are all stdio implementations.
When command is unreachable or fails to start, it does not bring down the entire query — that server’s status stays non-'connected', and other servers are unaffected.

SSE / HTTP Server

Likewise, an unreachable remote URL won’t hang the query; the server status stays non-'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:
For example, server name my_tools with tool name greet gives the model the tool name mcp__my_tools__greet. Server names may contain hyphens and other special characters (my-toolsmcp__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.

allowed_tools: Pre-approval (Not a Visibility Allowlist)

allowed_tools adds listed tools to the “always-allow” rule set — calls skip the permission prompt, but unlisted tools are not hidden. Use it to whitelist low-risk MCP tools for unattended use:
Omitting allowed_tools just means no pre-approval rules — the model still sees and can call every tool; write operations simply route through the regular permission_mode approval flow. See Permissions docs for full semantics.

allowed_mcp_server_names: Process-Server Allowlist

Only filters process-based (stdio/sse/http) servers; does not affect in-process servers. Combined with strict_mcp_config=True, it can prevent the CLI from loading additional local configurations:
⚠️ Omitting allowed_mcp_server_names means all process servers connect; to tighten, list them explicitly. In-process servers are never filtered by this field.

Runtime Management (QoderSDKClient)

query() is a single-shot iterator and cannot change servers or auth mid-stream. For runtime management of MCP, use QoderSDKClient, which exposes status queries, OAuth, server add/remove, reconnect / toggle, etc., as public methods.
⚠️ Caching Principle: MCP server config / auth state changes rebuild the tools list, which breaks the prompt prefix cache mid-session. The SDK provides methods for “querying status + completing auth before the first message”; the server set itself should be configured once at startup via options.mcp_servers, creating a new QoderSDKClient when necessary.

Querying Status

💡 The MCP handshake occurs after the CLI completes initialize but before the first user message. QoderSDKClient.connect() already waits until initialize returns; handshake IO may take a few hundred milliseconds, so poll get_mcp_status() until connected if needed.

Subscribing to Status Changes

Pick one of two ways: Method 1 (recommended): Attach an on_mcp_status_change callback on options; it is called every time status changes.
Method 2: Consume the message stream and filter system/mcp_status_change. The callback and the message stream share the same payload; the callback simply removes the need for filtering.

Runtime Add/Remove Server / Reconnect / Toggle

⚠️ All three methods trigger a tools-list rebuild and therefore break the prompt prefix cache. In production, prefer to configure mcp_servers fully at startup; reserve these APIs for debugging and local development.

Controlling Request Timeout

After timeout, the SDK automatically writes a control_cancel_request and rejects the current Future.

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. The Python SDK exposes both inbound (CLI proactively asks the host to complete OAuth) and outbound (host actively triggers OAuth) paths; choose based on your host shape.
⚠️ Caching Principle: After OAuth completes, the CLI reconnects to the server and rediscovers tools, which inevitably breaks the prompt prefix cache mid-session. Complete authentication before sending the first user message so the tools list stabilizes before the conversation starts.
💡 This section only covers CLI-driven OAuth: The CLI performs metadata discovery, PKCE, token exchange, and token persistence itself. There is another server-driven auth path — where the server uses MCP elicitation/create to have the client redirect to a URL for authorization. The two paths are independent and won’t trigger simultaneously. See Elicitation: Server Requests User Input.

Inbound: on_mcp_oauth_required Callback

When the CLI detects during handshake that a server requires 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 the following resolutions:

Outbound: Host Actively Drives Authentication

When the host’s own UI has a “Sign in” entry, it can actively invoke:
redirect_uri is optional, overriding the default OAuth callback target (Electron custom protocol, enterprise 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).

Elicitation: Server Requests User Input

MCP elicitation/create is a server → client request used to have the client display an interaction to the user (form mode collects structured input; url mode asks the user to visit a URL to complete an action).
The Python SDK is now aligned with the TS SDK: QoderAgentOptions.on_elicitation accepts an async callback that returns ElicitationResult, with the same signature as the TS version. When the callback is not set, the SDK still defaults to answering {"action": "cancel"}. The Elicitation / ElicitationResult hook events still fire in parallel as a read-only observation channel.
⚠️ The current CLI does not advertise the elicitation.url capability. A server’s elicit({mode: 'url'}) is rejected directly by the CLI (MCP error -32602: Client does not support URL-mode elicitation requests), so URL-mode elicit will not reach the SDK, and the system/elicitation_complete notification will not fire on the current CLI either. Once the CLI enables the URL capability, this path will automatically become operational.

Responding to elicit with on_elicitation

  • 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 None is equivalent to {"action": "cancel"}, making it convenient for the host to bail out from a fallback path.
  • You can also return a mcp.types.ElicitResult Pydantic model (the SDK calls model_dump).

Observing elicitation (hook channel)

After on_elicitation lands, the Elicitation / ElicitationResult hooks still fire in parallel — they are a read-only observation channel and do not make decisions.

Boundary with the OAuth Path

  • CLI-driven OAuth (mcp_authenticate / inject_mcp_token / on_mcp_oauth_required): Token stored in qodercli Keychain; driven when get_mcp_status() shows needs-auth; does NOT trigger the Elicitation hook.
  • Server-driven elicit: Token stays internal to the server; get_mcp_status() does not show needs-auth; decisions are made via the on_elicitation callback (the SDK auto-cancels when not registered).
The two paths are not mutually exclusive but don’t overlap: the same server typically uses only one.

Options Reference

Methods on QoderSDKClient


Type Reference

McpServerStatus.status enum (McpServerConnectionStatus):

Best Practices

  1. Write descriptions for the AI: The @tool description determines when the AI selects it. Clearly state “what it does, when to use it, what it should NOT be used for.”
  2. Add Annotated to parameters: In simple dict / TypedDict, write Annotated[type, "..."] on fields; the AI uses this information to construct call arguments.
  3. Use is_error: True for failures, don’t throw exceptions: Let the AI see the result. For a complete comparison, see Tools Guide - How the SDK handles tool errors.
  4. Prefer read-only + readOnlyHint: Be cautious with write operations; pair with can_use_tool or hooks for secondary confirmation.
  5. Keep server names short: They appear in tool prefixes; overly long names waste tokens.
  6. Place in-process shared state in module scope: Handlers are closures, but each query still reuses the same server instance.
  7. Complete OAuth before the first user message: Use mcp_authenticate + mcp_submit_oauth_callback_url, the inbound on_mcp_oauth_required callback, or inject_mcp_token. Completing auth mid-session inevitably breaks the prompt prefix cache.
  8. Pull MCP status with get_mcp_status() or on_mcp_status_change: The push channel (status change message) is retained; pick one as needed.
  9. Set a reasonable control_request_timeout_ms: Remote server handshakes may take seconds; the default 60s is usually sufficient. Increase it when waiting for user OAuth actions, and set it explicitly in CI environments.
  10. Use strict_mcp_config for isolation: Prevent MCP servers declared in the user’s local ~/.qoder/settings.json / .mcp.json from interfering with your application.

Complete Example