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 SDK lets you define MCP Servers and equip your Agent with tools; the underlying CLI handles connection management, tool discovery, and other runtime work.

Architecture Overview

┌────────────────────────────────────────────────────────────┐
│  Your Node.js application (SDK Host)                       │
│                                                            │
│   ┌──────────────────────────┐                             │
│   │ createSdkMcpServer(...)  │  ← In-Process tools         │
│   │  + tool(...)             │     defined inline, no extra process │
│   └──────────────────────────┘                             │
│                  │                                         │
│                  ▼                                         │
│   ┌──────────────────────────┐                             │
│   │  query({ mcpServers })   │── stdio ─▶ qodercli child  │
│   └──────────────────────────┘                             │
│                                          │                 │
│                                          ├── stdio ──▶ MCP server (process)
│                                          ├── sse   ──▶ MCP server (HTTP/SSE)
│                                          └── http  ──▶ MCP server (Streamable HTTP)
└────────────────────────────────────────────────────────────┘
  • In-Process: The tool is a JS function running in your own process. The McpServer instance communicates with the CLI via the SDK’s control channel without spawning an additional 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 createSdkMcpServer)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(). In-process tools are the most straightforward extension method: define a regular async function, add a Zod schema, and it becomes callable by the Agent.

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);
}

tool() Full Signature

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
};
ParameterDescription
nameTool 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
inputSchemaZod raw shape (not z.object(...), just pass the field object)
handlerActual logic, returns CallToolResult
extras.annotationsMCP tool annotations, see table below

Annotations Actually Consumed

The three fields below are consumed by the SDK and returned to the host via mcpServerStatus().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. 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 allowedTools allowlist or hooks — annotations are for host-side identification (mcpServerStatus) and TUI display only.
idempotentHint and title are not currently supported — passing them won’t error, but the SDK won’t consume them or return them to the host. If your application needs this information, maintain the mapping yourself on the host side.

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 isError: true for operational failures instead of throwing exceptions — exceptions will terminate the entire tool call and the AI won’t get information; isError lets the AI know “this call failed, please try another approach.”
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 } },
);

createSdkMcpServer() Full Signature

function createSdkMcpServer(options: {
  name: string;       // server name (determines tool prefix mcp__<name>__)
  version?: string;   // defaults to '1.0.0'
  tools?: Array<SdkMcpToolDefinition<any>>;
}): McpSdkServerConfigWithInstance;
The return value is shaped like { type: 'sdk', name, instance } and can be directly placed into options.mcpServers.
⚠️ 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.

Multi-tool Example

const searchDocs = tool(
  'search_docs',
  'Search internal docs and return relevant snippets.',
  {
    query: z.string().describe('Search keywords'),
    maxResults: z.number().int().min(1).max(20).optional()
      .describe('Maximum number of results, defaults to 5'),
  },
  async ({ query, maxResults = 5 }) => {
    const hits = await docs.search(query, maxResults);
    return { content: [{ type: 'text', text: JSON.stringify(hits) }] };
  },
  { annotations: { readOnlyHint: true } },
);

const server = createSdkMcpServer({
  name: 'kb',
  tools: [searchDocs, queryDb /* ... */],
});

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! },
      },
    },
  },
});

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}` },
      },
    },
  },
});
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.

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.

allowedTools: Pre-approval (Not a Visibility Allowlist)

allowedTools 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: {
  mcpServers: { my_tools: server },
  allowedTools: [
    'mcp__my_tools__greet',          // pre-approved, no prompt
    'mcp__my_tools__search_docs',
  ],
}
Omitting allowedTools just means no pre-approval rules — the model still sees and can call every tool, write operations simply route through the regular permissionMode approval flow. See Permissions docs for full semantics.

allowedMcpServerNames: Process-Server Allowlist

Only filters process-based (stdio/sse/http) servers; does not affect in-process servers. Combined with strictMcpConfig: true, it can prevent the CLI from loading additional local configurations:
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 allowedMcpServerNames means all process servers connect; to tighten, list them explicitly. In-process servers are never filtered by this field.

Runtime Management (Query API)

The Query object returned by query() exposes several MCP-related methods. All methods communicate with the CLI via the control channel and are asynchronous and idempotent.
⚠️ 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.mcpServers, restarting query() when necessary.

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 occurs after the CLI completes initialize but before the first user message. Query status only after initializationResult() has returned to get real results — handshake IO may take a few hundred milliseconds; consider using pollUntil to wait for connected before proceeding.

Subscribing to Status Changes

MCP status uses pull rather than push: call await q.mcpServerStatus(). Implement polling in your own code when needed.

Changing the Server Set? Use Process-level Configuration

To keep the prompt prefix cache stable, server set changes are completed once at startup:
What You Want to DoHow to Do It
Add / remove / replace serversConfigure in options.mcpServers; restart query() when the set needs to change
Enable only some process-based serversoptions.allowedMcpServerNames allowlist
Reconnect a serverRestart query() (reconnection rediscovers tools, affecting cache)
Log out of a serverRestart query() without that token; or clear via external credential store then restart

Controlling Request Timeout

options: {
  controlRequestTimeoutMs: 20_000,  // default 60_000; pass 0 to disable
}
After timeout, the SDK automatically writes a control_cancel_request and rejects the current Promise.

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 to the server and rediscovers tools, which inevitably breaks the prompt prefix cache mid-session. Therefore, only “actively-driven” auth mode is supported — complete all auth before the first streamInput so the tools list stabilizes before sending the first user message.
💡 This section only covers CLI-driven OAuth: The CLI performs metadata discovery, PKCE, token exchange, and token persistence. There is another server-driven auth path — where the server uses MCP elicitation/create to have the client redirect to a URL for authorization (typical example: GitHub MCP). The two paths are independent and won’t trigger simultaneously: with server-driven auth, mcpServerStatus() won’t show needs-auth, and mcpAuthenticate shouldn’t be called; instead, the host uses onElicitation to handle the request. See Elicitation: Server Requests User Input.
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) { /* ... */ }
Pull MethodPurposeWhen to Call
mcpAuthenticate(name, redirectUri?)Initiate OAuth; returns { authUrl?, requiresUserAction }. When silent renewal succeeds, requiresUserAction: false — no UI neededBefore the first streamInput
mcpSubmitOAuthCallbackUrl(name, url)Submit the complete callback URL (with code/state)Before the first streamInput
redirectUri 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. The SDK exposes these requests to the host via Options.onElicitation.

Two Modes

ModeTrigger ScenarioTypical Use
'form'Server requests structured input; request carries requestedSchema (MCP restricted subset of JSON Schema)API key entry, configuration input, secondary confirmation
'url'Server asks user to visit a URL to complete an action; request carries url + elicitationIdServer-side OAuth, device code activation, account linking
URL mode completes asynchronously: after the server receives user authorization in its own callback, it sends notifications/elicitation/complete — the SDK projects this as an SDKElicitationCompleteMessage pushed into the Query message stream.

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
};
signal will abort on q.close() / interrupt; long-running processes should check it.

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 redirect inside onElicitation. The URL mode design is: the callback immediately returns accept (= user has started the flow), and the CLI does not block the control channel; the real “completion” signal comes from the subsequent elicitation_complete message. If you await the entire OAuth redirect, you’ll trigger the control request timeout (controlRequestTimeoutMs).

Boundary with the OAuth Path

  • CLI-driven OAuth (mcpAuthenticate / mcpSubmitOAuthCallbackUrl): Token stored in qodercli Keychain; driven when mcpServerStatus() shows needs-auth; does NOT trigger onElicitation.
  • Server-driven elicit URL: Token stays internal to the server; mcpServerStatus() won’t show needs-auth; handled via onElicitation; completed via system/elicitation_complete.
The two paths are not mutually exclusive but don’t overlap: the same server typically uses only one. If unsure which path a server uses: check whether it sends elicitation/create to the client during handshake — if it does, it’s server-driven.

Hook Channel

Hosts can also attach hooks in settings.json to intercept elicitation, with behavior taking priority over onElicitation:
Hook EventTimingCapability
ElicitationWhen server request arrives, before onElicitationAuto accept / decline / cancel (short-circuit UI), or pass through
ElicitationResultAfter user respondsRewrite action / content, or block (force decline)
Notification (type=elicitation_complete)When URL mode completion notification arrivesTrigger IDE / system notification
⚠️ qodercli 0.2.x only sends elicitation: {} (empty object, compatible with Spring AI Java MCP SDK) in the MCP capability declaration. The MCP SDK server-side interprets this as equivalent to { form: {} }, so currently only form mode actually arrives at the client from remote servers. The URL mode protocol layer is complete, but requires the CLI to explicitly declare elicitation.url for the server-side elicitInput({ mode: 'url' }) to pass validation — this will evolve with CLI version updates.

Options Reference

FieldTypeDefaultDescription
mcpServersRecord<string, McpServerConfig>Server name → config
allowedMcpServerNamesstring[]Process-based server allowlist (does not affect in-process); omitting means all are open
strictMcpConfigbooleanfalsePrevent CLI from loading additional MCP from user config files
toolsstring[]Model-visible tool allowlist; omitting means every built-in + MCP tool is visible
allowedToolsstring[]Pre-approval list (skip permission prompts; does not control visibility); omitting means no pre-approval rules
disallowedToolsstring[]Explicit deny list; takes precedence over allow
controlRequestTimeoutMsnumber60_000Control request timeout (including mcp series), 0 to disable
onElicitationOnElicitationTriggered when an MCP server actively requests user input (form / url modes), see Elicitation

Methods on Query

MethodDescriptionWhen to Call
mcpServerStatus()Get current status of all MCP serversAny time
mcpAuthenticate(name, redirectUri?)Actively initiate OAuth; returns { authUrl?, requiresUserAction }Before the first streamInput
mcpSubmitOAuthCallbackUrl(name, url)Submit OAuth callbackBefore the first streamInput
For adding/removing/modifying the server set, use options.mcpServers (configured at startup) + restart query(); see Changing the Server Set? Use Process-level Configuration.

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,
  // OAuth
  // (OAuthToken / McpOAuthRequest / McpOAuthResolution are exposed via coreTypes)
  // 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: 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. Use .describe() on fields: Always add .describe(...) to Zod fields; the AI uses this information to construct call parameters.
  3. Use isError for failures, don’t throw exceptions: Let the AI see the result. Exceptions confuse the model and may trigger retries.
  4. Prefer read-only + readOnlyHint: Be cautious with write operations; pair with canUseTool 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 streamInput: Use mcpAuthenticate + mcpSubmitOAuthCallbackUrl. Completing auth mid-session inevitably breaks the prompt prefix cache.
  8. Pull MCP status with mcpServerStatus(): The push channel has been retired; poll as needed.
  9. Set a reasonable controlRequestTimeoutMs: Remote server handshakes may take seconds; the default 60s is usually sufficient, but set it explicitly in CI environments.
  10. Use strictMcpConfig for isolation: Prevent MCP servers declared in the user’s local settings.json / .mcp.json from interfering with your application.

Complete Example

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

// 1. Define application 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?.();