Skip to main content

Documentation Index

Fetch the complete documentation index at: https://docs.qoder.com/llms.txt

Use this file to discover all available pages before exploring further.

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

┌────────────────────────────────────────────────────────────┐
│  Your Python application (SDK Host)                        │
│                                                            │
│   ┌──────────────────────────┐                             │
│   │ create_sdk_mcp_server(...) │ ← In-Process tools        │
│   │  + @tool(...)             │     defined inline, no extra process │
│   └──────────────────────────┘                             │
│                  │                                         │
│                  ▼                                         │
│   ┌──────────────────────────┐                             │
│   │  query({mcp_servers})    │── stdio ─▶ qodercli child   │
│   │  / QoderSDKClient(...)   │                             │
│   └──────────────────────────┘                             │
│                                          │                 │
│                                          ├── stdio ──▶ MCP server (process)
│                                          ├── sse   ──▶ MCP server (HTTP/SSE)
│                                          └── http  ──▶ MCP server (Streamable HTTP)
└────────────────────────────────────────────────────────────┘
  • 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

MethodConfig typeProcess BoundaryUse Case
In-Process'sdk' (created by create_sdk_mcp_server)Same processCustom application tools that need direct access to host state
Stdio'stdio' (can be omitted)Child processExisting MCP toolkits (@modelcontextprotocol/server-*)
SSE / HTTP'sse' / 'http'RemoteRemote services, SaaS tools, services requiring OAuth
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

import asyncio
from typing import Annotated

from qoder_agent_sdk import (
    QoderAgentOptions,
    create_sdk_mcp_server,
    query,
    tool,
)


@tool("greet", "Greet someone.", {"name": Annotated[str, "Recipient name"]})
async def greet(args):
    return {"content": [{"type": "text", "text": f"Hello, {args['name']}!"}]}


server = create_sdk_mcp_server(name="my_tools", tools=[greet])


async def main():
    options = QoderAgentOptions(
        mcp_servers={"my_tools": server},
        allowed_tools=["mcp__my_tools__greet"],
    )
    async for msg in query(prompt="Use the greet tool to greet Alice", options=options):
        print(msg)


asyncio.run(main())

@tool() / create_sdk_mcp_server() Full Signatures

def tool(
    name: str,
    description: str,
    input_schema: type | dict[str, Any],
    annotations: ToolAnnotations | None = None,
) -> Callable[[Handler], SdkMcpTool[Any]]: ...


def create_sdk_mcp_server(
    name: str,
    version: str = "1.0.0",
    tools: list[SdkMcpTool[Any]] | None = None,
) -> McpSdkServerConfig: ...
ParameterDescription
name (tool)Tool name; the fully-qualified name will be mcp__<server>__<name>
descriptionDescription for the model, determining when the AI invokes it — clearly state What/When
input_schemaThree forms: simple dict / TypedDict / full JSON Schema dict; see Tools Reference - input_schema
annotationsMCP tool annotations; see table below
name (server)Server name (determines tool prefix mcp__<name>__)
versionDefaults to '1.0.0'
toolsList of SdkMcpTool
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:
FieldWhat it doesHost-side reads as
readOnlyHintDeclares the tool is read-only. Read-only tools may run concurrently (they don’t block each other in the same batch); the TUI renders a [read-only] badge in tool detailsannotations.readOnly
destructiveHintDeclares the tool performs destructive operations. The TUI renders a [destructive] badge in tool detailsannotations.destructive
openWorldHintDeclares the tool interacts with the outside world (e.g., web search, third-party API calls). The TUI renders an [open-world] badge in tool detailsannotations.openWorld
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

# Success return
{"content": [{"type": "text", "text": "result"}]}

# Business failure: use is_error instead of throwing an exception
{"content": [{"type": "text", "text": "error description"}], "is_error": True}
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.
@tool(
    "query_db",
    "Read-only SQL query.",
    {"sql": Annotated[str, "SQL query statement"]},
    annotations=ToolAnnotations(readOnlyHint=True),
)
async def query_db(args):
    sql = args["sql"]
    if not sql.lstrip().upper().startswith("SELECT"):
        return {
            "is_error": True,
            "content": [{"type": "text", "text": "Only SELECT statements are allowed"}],
        }
    rows = await db.query(sql)
    return {"content": [{"type": "text", "text": json.dumps(rows)}]}

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:
@tool("watch", "Watch a counter", {"max": int})
async def watch(args, extra):
    for i in range(args["max"]):
        if extra.signal.is_set():
            return {"content": [{"type": "text", "text": f"aborted at {i}"}]}
        await asyncio.sleep(0.01)
    return {"content": [{"type": "text", "text": "done"}]}
⚠️ 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.
class McpStdioServerConfig(TypedDict):
    type: NotRequired[Literal["stdio"]]    # optional; stdio is the default
    command: str                           # executable command
    args: NotRequired[list[str]]           # command arguments
    env: NotRequired[dict[str, str]]       # environment variables
    tools: NotRequired[list[McpServerToolPolicy]]
options = QoderAgentOptions(
    mcp_servers={
        "fs": {
            "command": "npx",
            "args": ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/project"],
        },
        "gh": {
            "command": "npx",
            "args": ["-y", "@modelcontextprotocol/server-github"],
            "env": {"GITHUB_TOKEN": os.environ["GITHUB_TOKEN"]},
        },
    },
)
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

class McpSSEServerConfig(TypedDict):
    type: Literal["sse"]
    url: str
    headers: NotRequired[dict[str, str]]
    tools: NotRequired[list[McpServerToolPolicy]]


class McpHttpServerConfig(TypedDict):
    type: Literal["http"]                   # Streamable HTTP
    url: str
    headers: NotRequired[dict[str, str]]
    tools: NotRequired[list[McpServerToolPolicy]]
options = QoderAgentOptions(
    mcp_servers={
        "analytics": {
            "type": "http",
            "url": "https://analytics.example.com/mcp",
            "headers": {"Authorization": f"Bearer {os.environ['ANALYTICS_TOKEN']}"},
        },
    },
)
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:
mcp__<server_name>__<tool_name>
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”:
options = QoderAgentOptions(
    mcp_servers={"my_tools": server},
    tools=[
        "Read", "Grep",                  # built-in tools you still want
        "mcp__my_tools__greet",
        "mcp__my_tools__search_docs",
    ],
)
⚠️ 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:
options = QoderAgentOptions(
    mcp_servers={"my_tools": server},
    allowed_tools=[
        "mcp__my_tools__greet",          # pre-approved, no prompt
        "mcp__my_tools__search_docs",
    ],
)
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:
options = QoderAgentOptions(
    mcp_servers={
        "keep": {"command": "..."},
        "drop": {"command": "..."},
    },
    allowed_mcp_server_names=["keep"],   # 'drop' still appears in status but does not connect
    strict_mcp_config=True,              # do not load MCP servers from settings.json / .mcp.json
)
⚠️ 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

async with QoderSDKClient(options) as client:
    status = await client.get_mcp_status()
    # Returns McpStatusResponse: {"mcpServers": [McpServerStatus, ...]}
    # Each McpServerStatus contains:
    #   name, status, serverInfo?, error?, config?, scope?, tools?

    for server in status["mcpServers"]:
        print(f"{server['name']}: {server['status']}")
        if server["status"] == "connected":
            print("  tools:", [t["name"] for t in server.get("tools", [])])
💡 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.
async def on_status(msg):
    print(f"{msg['server_name']} -> {msg['status']}")
    if msg.get("error"):
        print("  error:", msg["error"])


options = QoderAgentOptions(
    mcp_servers={...},
    on_mcp_status_change=on_status,
)
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

MethodPurpose
client.set_mcp_servers(servers)Replace the current MCP configuration with the full desired mapping; returns {added, removed, errors}
client.reconnect_mcp_server(name)Reconnect a specific server, typically to recover from a 'failed' state
client.toggle_mcp_server(name, enabled)Enable / disable a server; disabling disconnects it and removes its tools
⚠️ 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

options = QoderAgentOptions(
    control_request_timeout_ms=20_000,   # default 60_000; pass 0 to disable
)
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:
Return typeMeaning
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
NoneReject; the CLI marks that server as failed
async def handle_oauth(request: McpOAuthRequest) -> McpOAuthResolution | None:
    # Open request['auth_url'] in an Electron BrowserWindow / system browser
    callback_url = await open_browser_and_wait_for_callback(request["auth_url"])
    return {"callbackUrl": callback_url}


options = QoderAgentOptions(
    mcp_servers={"analytics": {"type": "http", "url": "https://analytics.example.com/mcp"}},
    on_mcp_oauth_required=handle_oauth,
    control_request_timeout_ms=120_000,   # user authorization may take a while
)

Outbound: Host Actively Drives Authentication

When the host’s own UI has a “Sign in” entry, it can actively invoke:
async with QoderSDKClient(options) as client:
    status = await client.get_mcp_status()
    for server in status["mcpServers"]:
        if server["status"] != "needs-auth":
            continue
        result = await client.mcp_authenticate(server["name"])
        if result.get("requiresUserAction"):
            await open_in_browser(result["authUrl"])
            callback_url = await wait_for_user_paste_callback()
            await client.mcp_submit_oauth_callback_url(server["name"], callback_url)
        # Silent path (cached client + valid refresh token): requiresUserAction=False
        # No UI needed; the server transitions directly to connected

    # The tools list is now stable; safe to send user messages
    await client.query("first user message")
MethodPurposeWhen to Call
client.mcp_authenticate(name, redirect_uri=None)Initiate OAuth; returns {authUrl?, requiresUserAction}; on silent renewal, requiresUserAction=False and no UI is neededBefore the first user message
client.mcp_submit_oauth_callback_url(name, callback_url)Submit the complete callback URL (with code/state)Before the first user message
client.inject_mcp_token(name, token)The host runs the entire OAuth flow itself and injects the OAuthToken into the CLIBefore the first user message
client.mcp_clear_auth(name)Delete the OAuth credentials stored by the CLI — equivalent to “sign out”Any time; the next tool call will trigger re-authentication
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

from qoder_agent_sdk import ElicitationRequest, ElicitationResult, QoderAgentOptions


async def on_elicitation(req: ElicitationRequest) -> ElicitationResult:
    # Form mode: req["requestedSchema"] is a JSON Schema; the returned content must match.
    if req.get("mode") == "form":
        return {"action": "accept", "content": {"token": "xxx"}}
    # Return decline / cancel for any case you cannot handle; the CLI relays the result back to the MCP server.
    return {"action": "decline"}


options = QoderAgentOptions(
    mcp_servers={"my_server": {"type": "http", "url": "..."}},
    on_elicitation=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.
Hook EventTimingPayload TypedDict
ElicitationWhen server request arrivesElicitationHookInputmcp_server_name / message / mode / elicitation_id? / requested_schema? / url? / title?
ElicitationResultAfter SDK / host completes its responseElicitationResultHookInputmcp_server_name / action / mode / elicitation_id? / content?
from qoder_agent_sdk import HookMatcher, QoderAgentOptions


async def on_elicit(input, tool_use_id, context):
    print(
        "elicit from",
        input["mcp_server_name"],
        "mode=",
        input["mode"],
        "schema=",
        input.get("requested_schema"),
    )
    return {"continue_": True}


options = QoderAgentOptions(
    mcp_servers={"my_server": {"type": "http", "url": "..."}},
    hooks={
        "Elicitation": [HookMatcher(hooks=[on_elicit])],
    },
)

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

FieldTypeDefaultDescription
mcp_serversdict[str, McpServerConfig] | str | Path{}Server name → config; or a JSON config file path
allowed_mcp_server_nameslist[str][]Process-based server allowlist (does not affect in-process); empty list means all are open
strict_mcp_configboolFalsePrevent the CLI from loading additional MCP from user config files
toolslist[str] | ToolsPreset | NoneNoneModel-visible tool allowlist; omitting means every built-in + MCP tool is visible
allowed_toolslist[str][]Pre-approval list (skip permission prompts; does not control visibility); empty list means no pre-approval rules
disallowed_toolslist[str][]Explicit deny list; takes precedence over allow
control_request_timeout_msint60_000Control request timeout (including mcp series), 0 to disable
on_mcp_oauth_requiredOnMcpOAuthRequired | NoneNoneTriggered when the CLI detects that a server requires OAuth
on_mcp_status_changeOnMcpStatusChange | NoneNoneTriggered on every server status change; equivalent to filtering the system/mcp_status_change stream
on_elicitationOnElicitation | NoneNoneHost decision when a server requests user input via MCP elicitation/create; the SDK auto-cancels if not set
hooks['Elicitation']list[HookMatcher]Read-only observation hook when a server requests user input (decisions go through on_elicitation)

Methods on QoderSDKClient

MethodDescriptionWhen to Call
get_mcp_status()Get current status of all MCP serversAny time
set_mcp_servers(servers)Replace the entire MCP server configuration; returns {added, removed, errors}Any time (breaks the prefix cache)
reconnect_mcp_server(name)Reconnect a specific serverAny time
toggle_mcp_server(name, enabled)Enable / disable a serverAny time
mcp_authenticate(name, redirect_uri=None)Actively initiate OAuthBefore the first user message
mcp_submit_oauth_callback_url(name, callback_url)Submit OAuth callback URLBefore the first user message
inject_mcp_token(name, token)Inject a token after running the full OAuth flow on the hostBefore the first user message
mcp_clear_auth(name)Delete stored OAuth credentialsAny time

Type Reference

from qoder_agent_sdk import (
    # Factories
    create_sdk_mcp_server,
    tool,
    SdkMcpTool,
    # Configs
    McpServerConfig,
    McpStdioServerConfig,
    McpSSEServerConfig,
    McpHttpServerConfig,
    McpSdkServerConfig,
    McpServerToolPolicy,
    # Status
    McpServerStatus,
    McpServerStatusConfig,
    McpServerConnectionStatus,
    McpServerInfo,
    McpToolInfo,
    McpToolAnnotations,
    McpStatusResponse,
    McpStatusChangeMessage,
    # Runtime changes
    McpSetServersResult,
    # OAuth
    OAuthToken,
    McpOAuthRequest,
    McpOAuthResolution,
    McpOAuthTokenResolution,
    McpOAuthCallbackUrlResolution,
    McpOAuthCodeResolution,
    OnMcpOAuthRequired,
    OnMcpStatusChange,
)
McpServerStatus.status enum (McpServerConnectionStatus):
ValueMeaning
'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 toggle_mcp_server)

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

import asyncio
import json
import os
from typing import Annotated

from mcp.types import ToolAnnotations

from qoder_agent_sdk import (
    AssistantMessage,
    McpOAuthRequest,
    McpOAuthResolution,
    QoderAgentOptions,
    QoderSDKClient,
    ResultMessage,
    TextBlock,
    create_sdk_mcp_server,
    tool,
)


# 1. Define application tools
@tool(
    "get_user_orders",
    "Query a user's orders, optionally filtered by status.",
    {
        "user_id": Annotated[str, "User UUID"],
        "status": Annotated[str, "Filter by order status: pending/paid/shipped/cancelled"],
    },
    annotations=ToolAnnotations(readOnlyHint=True),
)
async def get_user_orders(args):
    try:
        orders = await db.get_orders(args["user_id"], args.get("status"))
        return {"content": [{"type": "text", "text": json.dumps(orders)}]}
    except Exception as e:
        return {
            "is_error": True,
            "content": [{"type": "text", "text": f"Query failed: {e}"}],
        }


# 2. Assemble the server
crm = create_sdk_mcp_server(name="crm", tools=[get_user_orders])


# 3. Prepare the inbound OAuth callback (called by the CLI when a remote server requires auth)
async def handle_oauth(request: McpOAuthRequest) -> McpOAuthResolution | None:
    callback_url = await open_browser_and_wait_for_callback(request["auth_url"])
    return {"callbackUrl": callback_url}


# 4. Start the client; complete authentication before the first message
async def main():
    options = QoderAgentOptions(
        mcp_servers={
            "crm": crm,
            "analytics": {"type": "http", "url": "https://analytics.example.com/mcp"},
        },
        allowed_tools=["mcp__crm__get_user_orders"],
        on_mcp_oauth_required=handle_oauth,
        control_request_timeout_ms=120_000,
    )

    async with QoderSDKClient(options) as client:
        # Outbound fallback: even when the CLI did not actively go inbound, pull status and drive auth ourselves
        status = await client.get_mcp_status()
        for server in status["mcpServers"]:
            if server["status"] != "needs-auth":
                continue
            result = await client.mcp_authenticate(server["name"])
            if result.get("requiresUserAction"):
                callback_url = await open_browser_and_wait_for_callback(result["authUrl"])
                await client.mcp_submit_oauth_callback_url(server["name"], callback_url)

        # 5. Consume messages (the tools list is now stable; the prefix cache will be established correctly)
        await client.query("Find user-123's recent paid orders")
        async for msg in client.receive_response():
            if isinstance(msg, AssistantMessage):
                for block in msg.content:
                    if isinstance(block, TextBlock):
                        print(block.text)
            elif isinstance(msg, ResultMessage):
                if msg.subtype == "success":
                    print("done, cost=", msg.total_cost_usd)
                else:
                    print("failed:", msg.subtype)


asyncio.run(main())