Skip to main content
Extensions

MCP Integration

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.

Architecture Overview

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

MethodConfig typeProcess BoundaryUse Case
In-Process'sdk' (created by createSdkMcpServer / create_sdk_mcp_server)Same processCustom business 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
The three approaches can be mixed—register multiple servers of different types in the same session.
💡 In Python, mcp_servers also accepts a str / pathlib.Path pointing to a JSON config file; the SDK passes it through to the CLI as --mcp-config <path>.

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

import { query, createSdkMcpServer, tool } from '@qoder-ai/qoder-agent-sdk';
import { z } from 'zod';

const greet = tool(
  'greet',
  'Greet someone.',
  { name: z.string().describe('Recipient name') },
  async ({ name }) => ({
    content: [{ type: 'text', text: `Hello, ${name}!` }],
  }),
);

const server = createSdkMcpServer({
  name: 'my_tools',
  tools: [greet],
});

const q = query({
  prompt: 'Use the greet tool to greet Alice',
  options: {
    mcpServers: { my_tools: server },
    allowedTools: ['mcp__my_tools__greet'],
  },
});

for await (const msg of q) {
  if (msg.type === 'result') console.log(msg.result);
}

Full signatures

function tool<Schema extends ZodRawShape>(
  name: string,
  description: string,
  inputSchema: Schema,
  handler: (args: z.infer<ZodObject<Schema>>, extra: unknown) => Promise<CallToolResult>,
  extras?: ToolExtras,
): SdkMcpToolDefinition<Schema>;

type ToolExtras = {
  annotations?: ToolAnnotations;  // see "What annotations are actually consumed" below
};

function createSdkMcpServer(options: {
  name: string;       // server name (determines tool prefix mcp__<name>__)
  version?: string;   // defaults to '1.0.0'
  tools?: Array<SdkMcpToolDefinition<any>>;
}): McpSdkServerConfigWithInstance;
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 the tool does and when to use it
inputSchema / input_schemaTypeScript takes a Zod raw shape (not z.object(...)); Python supports a simple dict / TypedDict / full JSON Schema dict
handlerActual logic, returns CallToolResult
annotationsMCP tool annotations; see table below
name (server)Server name (determines tool prefix mcp__<name>__)
versionDefaults to '1.0.0'
toolsList of tools
The return value has the shape { 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):
FieldWhat it doesHost-side reads as
readOnlyHintDeclares the tool read-only. Read-only tools can run concurrently (no mutual blocking within a batch); the TUI tool details render a [read-only] badgeannotations.readOnly
destructiveHintDeclares the tool performs destructive operations. The TUI renders a [destructive] badge in tool detailsannotations.destructive
openWorldHintDeclares the tool reaches the outside world (web search, third-party APIs). The TUI tool details render an [open-world] badgeannotations.openWorld
Note the host-side field names drop the Hint suffix: readOnlyHintannotations.readOnly, and so on. The annotations object 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.
💡 About maxResultSizeChars: the Python SDK writes anthropic/maxResultSizeChars into the tool's _meta via ToolAnnotations(maxResultSizeChars=...), letting the CLI relax the default 50K output limit (TS exposes the same annotation; the wire format is identical).

CallToolResult Structure

type CallToolResult = {
  content: Array<
    | { type: 'text'; text: string }
    | { type: 'image'; data: string; mimeType: string }     // base64
    | { type: 'audio'; data: string; mimeType: string }
    | { type: 'resource'; resource: { uri: string; text?: string; blob?: string; mimeType?: string } }
    | { type: 'resource_link'; uri: string; title?: string; name?: string }
  >;
  isError?: boolean;  // when true, the AI sees this as a failed result
};
Use the error flag (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.
const queryDb = tool(
  'query_db',
  'Read-only SQL query.',
  { sql: z.string() },
  async ({ sql }) => {
    if (!/^\s*SELECT/i.test(sql)) {
      return {
        isError: true,
        content: [{ type: 'text', text: 'Only SELECT statements are allowed' }],
      };
    }
    const rows = await db.query(sql);
    return { content: [{ type: 'text', text: JSON.stringify(rows) }] };
  },
  { annotations: { readOnlyHint: true } },
);

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:
@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"}]}

Stdio Server

Communicates with MCP servers via a child process's stdin/stdout. The @modelcontextprotocol/server-* packages on NPM are all stdio implementations.
type McpStdioServerConfig = {
  type?: 'stdio';                       // optional; stdio is the default
  command: string;                      // executable command
  args?: string[];                      // command arguments
  env?: Record<string, string>;         // environment variables
  isProxy?: boolean;                    // proxy flag (aggregates multiple backends)
};

const q = query({
  prompt: 'Read the title from the project README',
  options: {
    mcpServers: {
      fs: {
        command: 'npx',
        args: ['-y', '@modelcontextprotocol/server-filesystem', '/path/to/project'],
      },
      gh: {
        command: 'npx',
        args: ['-y', '@modelcontextprotocol/server-github'],
        env: { GITHUB_TOKEN: process.env.GITHUB_TOKEN! },
      },
    },
  },
});
An unreachable or failing command doesn't drag down the whole query—that server's status stays non-'connected' and other servers are unaffected.

SSE / HTTP Server

type McpSSEServerConfig = {
  type: 'sse';
  url: string;
  headers?: Record<string, string>;
  isProxy?: boolean;
};

type McpHttpServerConfig = {
  type: 'http';                         // Streamable HTTP
  url: string;
  headers?: Record<string, string>;
  isProxy?: boolean;
};

const q = query({
  prompt: 'Query this month\'s sales data',
  options: {
    mcpServers: {
      analytics: {
        type: 'http',
        url: 'https://analytics.example.com/mcp',
        headers: { Authorization: `Bearer ${process.env.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 my_tools with tool greet appears to the model as 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: {
  mcpServers: { 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.

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:
options: {
  mcpServers: { my_tools: server },
  allowedTools: [
    'mcp__my_tools__greet',          // pre-approved, no prompt
    'mcp__my_tools__search_docs',
  ],
}
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.

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:
options: {
  mcpServers: {
    keep: makeStdioConfig('...'),
    drop: makeStdioConfig('...'),
  },
  allowedMcpServerNames: ['keep'],   // 'drop' still appears in status but does not connect
  strictMcpConfig: true,             // skip loading MCP servers from settings.json / .mcp.json
}
⚠️ 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

const status = await q.mcpServerStatus();
// Returns McpServerStatus[], each item includes:
//   { name, status: 'pending' | 'connecting' | 'connected' | 'failed' | 'needs-auth' | 'disabled', tools?, ... }

for (const s of status) {
  console.log(`${s.name}: ${s.status}`);
  if (s.status === 'connected') {
    console.log('  tools:', s.tools?.map((t) => t.name));
  }
}
💡 The MCP handshake happens after the CLI completes initialize and before the first user message. Query status only after the initialization result returns—handshake IO can take hundreds of milliseconds, so poll until connected before 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_change callback in options, invoked once per status change; or consume the message stream filtering system/mcp_status_change. Callback and stream carry the same payload.
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,
)

Changing the server set

To keep the prompt prefix cache stable, prefer finalizing server-set changes at startup:
GoalTypeScriptPython
Add / remove / replace serversConfigure in options.mcpServers; restart query() to change the setConfigure mcp_servers at startup; at runtime client.set_mcp_servers(servers) replaces wholesale (returns {added, removed, errors})
Enable only some process-type serversallowedMcpServerNames allowlistallowed_mcp_server_names allowlist
Reconnect a serverRestart query()client.reconnect_mcp_server(name), typically to recover from 'failed'
Enable / disable a serverRestart query()client.toggle_mcp_server(name, enabled); disabling disconnects and delists its tools
Sign out of a serverRestart query() without the tokenclient.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

options: {
  controlRequestTimeoutMs: 20_000,  // default 60_000; pass 0 to disable
}
On timeout the SDK automatically writes a 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:
const q = query({
  prompt: userMessages(),  // AsyncIterable — no message is sent yet
  options: {
    mcpServers: {
      // Assume this remote server uses the CLI-driven standard OAuth (metadata discovery + PKCE).
      // If you connect to a server like GitHub MCP that implements OAuth on its own side, use onElicitation instead.
      analytics: { type: 'http', url: 'https://analytics.example.com/mcp' },
    },
  },
});

// Wait for handshake to complete
await q.initializationResult();

// Find servers that need authentication
const status = await q.mcpServerStatus();
for (const s of status.filter((x) => x.status === 'needs-auth')) {
  const result = await q.mcpAuthenticate(s.name);
  if (result.requiresUserAction) {
    await openInBrowser(result.authUrl!);
    const callbackUrl = await waitForUserPasteCallback();
    await q.mcpSubmitOAuthCallbackUrl(s.name, callbackUrl);
  }
  // Silent path (cached client + valid refresh token): result.requiresUserAction === false
  // No UI prompt needed; just proceed to the next step.
}

// At this point the tools list is stable; sending the first user message
// will let the prompt prefix cache be established cleanly.
for await (const msg of q) { /* ... */ }
Method (TypeScript / Python)PurposeWhen to call
mcpAuthenticate(name, redirectUri?) / mcp_authenticate(name, redirect_uri=None)Starts OAuth; returns { authUrl?, requiresUserAction }. On silent renewal requiresUserAction: false—no UI neededBefore 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 CLIBefore 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 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
)

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

ModeTrigger ScenarioTypical 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 + elicitationIdServer-side OAuth, device-code activation, account linking
URL mode completes asynchronously: once the server's own callback receives the user's authorization it sends notifications/elicitation/complete—the SDK projects it as an elicitation-complete message in the stream.
⚠️ qodercli currently advertises only elicitation: {} 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 the elicitation.url capability—coming with future CLI versions, at which point the path lights up automatically.

Callback Signature

import type { OnElicitation, ElicitationRequest, ElicitationResult } from '@qoder-ai/qoder-agent-sdk';

type OnElicitation = (
  request: ElicitationRequest,
  options: { signal: AbortSignal },
) => Promise<ElicitationResult>;

type ElicitationRequest = {
  serverName: string;          // name of the MCP server that issued the request
  message: string;             // explanation shown to the user
  mode?: 'form' | 'url';       // defaults to form
  url?: string;                // required when mode='url'
  elicitationId?: string;      // required when mode='url'; used to correlate later completion notifications
  requestedSchema?: Record<string, unknown>;  // field schema carried when mode='form'
  title?: string;
  displayName?: string;
  description?: string;
};

type ElicitationResult = {
  action: 'accept' | 'decline' | 'cancel';
  content?: Record<string, string | number | boolean | string[]>;  // populated when accept + form
};
Python notes:
  • 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 equals {"action": "cancel"}; with no callback registered the SDK auto-answers cancel per the default contract.
  • You can also return a mcp.types.ElicitResult Pydantic model (the SDK calls model_dump).
In TypeScript, signal aborts on q.close() / interruption—check it in long flows.

Form Mode Example

const q = query({
  prompt: userMessages(),
  options: {
    mcpServers: { my_server: { type: 'http', url: '...' } },
    onElicitation: async (request) => {
      if (request.mode !== 'url' && request.requestedSchema) {
        // Show a form in the UI and collect the user's input
        const filled = await showForm(request.message, request.requestedSchema);
        if (!filled) return { action: 'cancel' };
        return { action: 'accept', content: filled };
      }
      return { action: 'decline' };
    },
  },
});

URL mode example (with elicitation_complete)

const q = query({
  prompt: userMessages(),
  options: {
    mcpServers: { gh: { type: 'http', url: 'https://mcp.github.com/mcp' } },
    onElicitation: async (request, { signal }) => {
      if (request.mode !== 'url' || !request.url) {
        return { action: 'cancel' };
      }
      // Open the browser so the user can authorize; we only acknowledge "I have started the flow"
      // Real completion is signaled by notifications/elicitation/complete from the server side
      await openInBrowser(request.url);
      return { action: 'accept' };
    },
  },
});

// Listen for system/elicitation_complete to learn when server-side authorization is done
for await (const msg of q) {
  if (msg.type === 'system' && msg.subtype === 'elicitation_complete') {
    console.log(`server '${msg.mcp_server_name}' finished elicitation ${msg.elicitation_id}`);
    // The server now has its token; subsequent tool calls can succeed directly.
  }
}
💡 Do not await the browser round-trip inside the elicitation callback. URL mode is designed for the callback to accept immediately (= the user has started the flow) so the CLI doesn't block the control channel; the real completion signal is the subsequent elicitation_complete message. 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 showing needs-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 the elicitation_complete message.
The two paths don't conflict but don't overlap either: a given server usually takes exactly one. Not sure which one a server uses? Watch whether it sends elicitation/create during handshake—if it does, it's server-driven.

Hook Channel

Hosts can also attach hooks to observe / intercept elicitation:
Hook eventTimingNotes
ElicitationWhen the server request arrivesIn 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
ElicitationResultAfter the user respondsTypeScript can rewrite action / content or block; Python observes only
Notification (type=elicitation_complete)When the URL-mode completion notification arrivesTrigger IDE / system notifications
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])],
    },
)

Options Reference

Field (TypeScript / Python)DefaultDescription
mcpServers / mcp_serversServer name → config; Python also accepts a JSON config file path
allowedMcpServerNames / allowed_mcp_server_namesProcess-type server allowlist (in-process unaffected); omitting opens everything
strictMcpConfig / strict_mcp_configfalseStops the CLI from loading extra MCP servers from user config files
tools / toolsModel-visible tool allowlist; omitting exposes all built-in + MCP tools
allowedTools / allowed_toolsPre-approval list (skips the permission prompt, does not control visibility); omitting means no pre-approval rules
disallowedTools / disallowed_toolsExplicitly denied tools; takes precedence over allow
controlRequestTimeoutMs / control_request_timeout_ms60_000Control request timeout (incl. the mcp series); 0 disables
onElicitation / on_elicitationFires 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)DescriptionWhen to call
mcpServerStatus() / get_mcp_status()Get all current MCP server statusesAnytime
mcpAuthenticate(...) / mcp_authenticate(...)Start OAuth; returns { authUrl?, requiresUserAction }Before the first user message
mcpSubmitOAuthCallbackUrl(...) / mcp_submit_oauth_callback_url(...)Submit the OAuth callbackBefore 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 serverAnytime
toggle_mcp_server(name, enabled) (Python only)Enable / disable a serverAnytime
inject_mcp_token(name, token) (Python only)Inject a token after host-run OAuthBefore the first user message
mcp_clear_auth(name) (Python only)Delete stored OAuth credentialsAnytime
In TypeScript, change the server set via options.mcpServers (configured at startup) plus a query() restart.

Type Reference

import type {
  // Factory function return value
  McpSdkServerConfigWithInstance,
  // Union type — pass into options.mcpServers
  McpServerConfig,
  // Individual transport types
  McpStdioServerConfig,
  McpSSEServerConfig,
  McpHttpServerConfig,
  McpSdkServerConfig,
  // Status
  McpServerStatus,
  McpServerStatusConfig,
  // Elicitation
  OnElicitation,
  ElicitationRequest,
  ElicitationResult,
  SDKElicitationCompleteMessage,
} from '@qoder-ai/qoder-agent-sdk';

import { tool, createSdkMcpServer } from '@qoder-ai/qoder-agent-sdk';
import type {
  AnyZodRawShape,
  InferShape,
  SdkMcpToolDefinition,
} from '@qoder-ai/qoder-agent-sdk';
McpServerStatus.status enum:
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 external state)

Best Practices

  1. Write descriptions for the AI: a tool's description decides when the AI picks it. Spell out "what it does, when to use it, what not to use it for".
  2. Describe every field: always add .describe(...) to Zod fields in TypeScript and Annotated[type, "..."] in Python—the AI uses these to construct call arguments.
  3. Fail with the error flag, don't throw: let the AI see the result. Exceptions leave the model confused and may trigger retries.
  4. Prefer read-only + readOnlyHint: be careful with writes; pair them with the permission callback or hooks for double 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. Finish OAuth before the first user message: mid-session auth inevitably breaks the prompt prefix cache.
  8. Pull MCP status on demand: poll mcpServerStatus() in TypeScript; in Python choose get_mcp_status() or the on_mcp_status_change callback.
  9. 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.
  10. Use strict MCP config for isolation: keep MCP servers declared in the user's local settings.json / .mcp.json from interfering with your app.

Complete Example

import { query, createSdkMcpServer, tool } from '@qoder-ai/qoder-agent-sdk';
import { z } from 'zod';

// 1. Define business tools
const getUserOrders = tool(
  'get_user_orders',
  'Query a user\'s orders, optionally filtered by status.',
  {
    userId: z.string().describe('User UUID'),
    status: z.enum(['pending', 'paid', 'shipped', 'cancelled']).optional()
      .describe('Filter by order status'),
  },
  async ({ userId, status }) => {
    try {
      const orders = await db.getOrders(userId, status);
      return { content: [{ type: 'text', text: JSON.stringify(orders) }] };
    } catch (err) {
      return {
        isError: true,
        content: [{ type: 'text', text: `Query failed: ${(err as Error).message}` }],
      };
    }
  },
  { annotations: { readOnlyHint: true } },
);

// 2. Assemble the server
const myServer = createSdkMcpServer({
  name: 'crm',
  tools: [getUserOrders /* , ... */],
});

// 3. Start query (use AsyncIterable so no message is sent yet)
async function* userMessages() {
  yield {
    type: 'user' as const,
    message: { role: 'user' as const, content: 'List the recently paid orders for user-123' },
    parent_tool_use_id: null,
  };
}

const q = query({
  prompt: userMessages(),
  options: {
    mcpServers: {
      crm: myServer,
      // Assume a remote server that uses CLI-driven OAuth (GitHub MCP uses elicit-URL, not this path)
      analytics: { type: 'http', url: 'https://analytics.example.com/mcp' },
    },
    allowedTools: ['mcp__crm__get_user_orders'],
    controlRequestTimeoutMs: 30_000,
  },
});

// 4. Wait for handshake; actively drive auth before the first user message
await q.initializationResult();
const status = await q.mcpServerStatus();
for (const s of status.filter((x) => x.status === 'needs-auth')) {
  const result = await q.mcpAuthenticate(s.name);
  if (result.requiresUserAction) {
    const callbackUrl = await openInBrowserAndWaitForCallback(result.authUrl!);
    await q.mcpSubmitOAuthCallbackUrl(s.name, callbackUrl);
  }
  // Silent refresh success: requiresUserAction === false; no UI required
}

// 5. Consume messages (tools list is now stable; prompt prefix cache will be established correctly)
for await (const msg of q) {
  if (msg.type === 'result') {
    console.log(msg.subtype === 'success' ? msg.result : msg);
    break;
  }
}

await q.close?.();