Architecture Overview
- 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
| Method | Config type | Process Boundary | Use Case |
|---|---|---|---|
| In-Process | 'sdk' (created by createSdkMcpServer) | Same process | Custom application tools that need direct access to host state |
| Stdio | 'stdio' (can be omitted) | Child process | Existing MCP toolkits (@modelcontextprotocol/server-*) |
| SSE / HTTP | 'sse' / 'http' | Remote | Remote services, SaaS tools, services requiring OAuth |
query().
In-Process Server (Recommended)
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
tool() Full Signature
| Parameter | Description |
|---|---|
name | Tool name; the fully-qualified name will be mcp__<server>__<name> |
description | Description for the model, determining when the AI invokes it — clearly state what the tool does and when to use it |
inputSchema | Zod raw shape (not z.object(...), just pass the field object) |
handler | Actual logic, returns CallToolResult |
extras.annotations | MCP tool annotations, see table below |
Annotations Actually Consumed
The three fields below are consumed by the SDK and returned to the host viamcpServerStatus().tools[i].annotations:
| Field | What it does | Host-side reads as |
|---|---|---|
readOnlyHint | Declares 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 details | annotations.readOnly |
destructiveHint | Declares the tool performs destructive operations. The TUI renders a [destructive] badge in tool details | annotations.destructive |
openWorldHint | Declares the tool interacts with the outside world (e.g., web search, third-party API calls). The TUI renders an [open-world] badge in tool details | annotations.openWorld |
Note that host-side field names drop theHintsuffix:readOnlyHint→annotations.readOnly, and so on. Theannotationsobject 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 theallowedToolsallowlist 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
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.”
createSdkMcpServer() Full Signature
{ 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
Stdio Server
Communicates with MCP servers via a child process’s stdin/stdout. The@modelcontextprotocol/server-* packages on NPM are all stdio implementations.
SSE / HTTP Server
Tool Naming and Allowlists
The CLI uniformly prefixes MCP tools when exposing them to the model: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:
⚠️ 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:
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:
⚠️ 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)
TheQuery 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 viaoptions.mcpServers, restartingquery()when necessary.
Querying Status
💡 The MCP handshake occurs after the CLI completesinitializebut before the first user message. Query status only afterinitializationResult()has returned to get real results — handshake IO may take a few hundred milliseconds; consider usingpollUntilto wait forconnectedbefore proceeding.
Subscribing to Status Changes
MCP status uses pull rather than push: callawait 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 Do | How to Do It |
|---|---|
| Add / remove / replace servers | Configure in options.mcpServers; restart query() when the set needs to change |
| Enable only some process-based servers | options.allowedMcpServerNames allowlist |
| Reconnect a server | Restart query() (reconnection rediscovers tools, affecting cache) |
| Log out of a server | Restart query() without that token; or clear via external credential store then restart |
Controlling Request Timeout
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 MCPThe host controls OAuth timing, completing it before sending the first user message:elicitation/createto 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 showneeds-auth, andmcpAuthenticateshouldn’t be called; instead, the host usesonElicitationto handle the request. See Elicitation: Server Requests User Input.
| Pull Method | Purpose | When to Call |
|---|---|---|
mcpAuthenticate(name, redirectUri?) | Initiate OAuth; returns { authUrl?, requiresUserAction }. When silent renewal succeeds, requiresUserAction: false — no UI needed | Before 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
MCPelicitation/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
| Mode | Trigger Scenario | Typical 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 + elicitationId | Server-side OAuth, device code activation, account linking |
notifications/elicitation/complete — the SDK projects this as an SDKElicitationCompleteMessage pushed into the Query message stream.
Callback Signature
signal will abort on q.close() / interrupt; long-running processes should check it.
Form Mode Example
URL Mode Example (with elicitation_complete)
💡 Do not await the browser redirect insideonElicitation. The URL mode design is: the callback immediately returnsaccept(= user has started the flow), and the CLI does not block the control channel; the real “completion” signal comes from the subsequentelicitation_completemessage. 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 whenmcpServerStatus()showsneeds-auth; does NOT triggeronElicitation. - Server-driven elicit URL: Token stays internal to the server;
mcpServerStatus()won’t showneeds-auth; handled viaonElicitation; completed viasystem/elicitation_complete.
elicitation/create to the client during handshake — if it does, it’s server-driven.
Hook Channel
Hosts can also attach hooks insettings.json to intercept elicitation, with behavior taking priority over onElicitation:
| Hook Event | Timing | Capability |
|---|---|---|
Elicitation | When server request arrives, before onElicitation | Auto accept / decline / cancel (short-circuit UI), or pass through |
ElicitationResult | After user responds | Rewrite action / content, or block (force decline) |
Notification (type=elicitation_complete) | When URL mode completion notification arrives | Trigger IDE / system notification |
⚠️ qodercli 0.2.x only sendselicitation: {}(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 declareelicitation.urlfor the server-sideelicitInput({ mode: 'url' })to pass validation — this will evolve with CLI version updates.
Options Reference
| Field | Type | Default | Description |
|---|---|---|---|
mcpServers | Record<string, McpServerConfig> | – | Server name → config |
allowedMcpServerNames | string[] | – | Process-based server allowlist (does not affect in-process); omitting means all are open |
strictMcpConfig | boolean | false | Prevent CLI from loading additional MCP from user config files |
tools | string[] | – | Model-visible tool allowlist; omitting means every built-in + MCP tool is visible |
allowedTools | string[] | – | Pre-approval list (skip permission prompts; does not control visibility); omitting means no pre-approval rules |
disallowedTools | string[] | – | Explicit deny list; takes precedence over allow |
controlRequestTimeoutMs | number | 60_000 | Control request timeout (including mcp series), 0 to disable |
onElicitation | OnElicitation | – | Triggered when an MCP server actively requests user input (form / url modes), see Elicitation |
Methods on Query
| Method | Description | When to Call |
|---|---|---|
mcpServerStatus() | Get current status of all MCP servers | Any time |
mcpAuthenticate(name, redirectUri?) | Actively initiate OAuth; returns { authUrl?, requiresUserAction } | Before the first streamInput |
mcpSubmitOAuthCallbackUrl(name, url) | Submit OAuth callback | Before the first streamInput |
For adding/removing/modifying the server set, useoptions.mcpServers(configured at startup) + restartquery(); see Changing the Server Set? Use Process-level Configuration.
Type Reference
status enum:
| Value | Meaning |
|---|---|
'pending' | Registered, connection not yet started |
'connecting' | Handshaking |
'connected' | Connected, tools are callable |
'failed' | Connection failed (check the error field) |
'needs-auth' | Requires OAuth, proceed with auth flow |
'disabled' | Disabled (determined by CLI internal config or external state) |
Best Practices
- Write descriptions for the AI: The
tool()descriptiondetermines when the AI selects it. Clearly state “what it does, when to use it, what it should NOT be used for.” - Use
.describe()on fields: Always add.describe(...)to Zod fields; the AI uses this information to construct call parameters. - Use
isErrorfor failures, don’t throw exceptions: Let the AI see the result. Exceptions confuse the model and may trigger retries. - Prefer read-only +
readOnlyHint: Be cautious with write operations; pair withcanUseToolor hooks for secondary confirmation. - Keep server names short: They appear in tool prefixes; overly long names waste tokens.
- Place in-process shared state in module scope: Handlers are closures, but each query still reuses the same server instance.
- Complete OAuth before the first
streamInput: UsemcpAuthenticate+mcpSubmitOAuthCallbackUrl. Completing auth mid-session inevitably breaks the prompt prefix cache. - Pull MCP status with
mcpServerStatus(): The push channel has been retired; poll as needed. - Set a reasonable
controlRequestTimeoutMs: Remote server handshakes may take seconds; the default 60s is usually sufficient, but set it explicitly in CI environments. - Use
strictMcpConfigfor isolation: Prevent MCP servers declared in the user’s localsettings.json/.mcp.jsonfrom interfering with your application.