feat: implement SDK MCP server support and enhance control request handling

- Added new `SdkMcpController` to manage communication between CLI MCP clients and SDK MCP servers.
- Introduced `createSdkMcpServer` function for creating SDK-embedded MCP servers.
- Updated configuration options to support both external and SDK MCP servers.
- Enhanced timeout settings for various SDK operations, including MCP requests.
- Refactored existing control request handling to accommodate new SDK MCP server functionality.
- Updated tests to cover new SDK MCP server features and ensure proper integration.
This commit is contained in:
mingholy.lmh
2025-12-04 17:01:13 +08:00
parent a58d3f7aaf
commit 322ce80e2c
29 changed files with 2473 additions and 837 deletions

View File

@@ -3,6 +3,17 @@ export { AbortError, isAbortError } from './types/errors.js';
export { Query } from './query/Query.js';
export { SdkLogger } from './utils/logger.js';
// SDK MCP Server exports
export { tool } from './mcp/tool.js';
export { createSdkMcpServer } from './mcp/createSdkMcpServer.js';
export type { SdkMcpToolDefinition } from './mcp/tool.js';
export type {
CreateSdkMcpServerOptions,
McpSdkServerConfigWithInstance,
} from './mcp/createSdkMcpServer.js';
export type { QueryOptions } from './query/createQuery.js';
export type { LogLevel, LoggerConfig, ScopedLogger } from './utils/logger.js';
@@ -18,6 +29,7 @@ export type {
SDKResultMessage,
SDKPartialAssistantMessage,
SDKMessage,
SDKMcpServerConfig,
ControlMessage,
CLIControlRequest,
CLIControlResponse,
@@ -43,6 +55,10 @@ export type {
PermissionMode,
CanUseTool,
PermissionResult,
ExternalMcpServerConfig,
SdkMcpServerConfig,
CLIMcpServerConfig,
McpServerConfig,
McpOAuthConfig,
McpAuthProviderType,
} from './types/types.js';
export { isSdkMcpServerConfig } from './types/types.js';

View File

@@ -103,9 +103,3 @@ export class SdkControlServerTransport {
return this.serverName;
}
}
export function createSdkControlServerTransport(
options: SdkControlServerTransportOptions,
): SdkControlServerTransport {
return new SdkControlServerTransport(options);
}

View File

@@ -1,29 +1,63 @@
/**
* Factory function to create SDK-embedded MCP servers
*
* Creates MCP Server instances that run in the user's Node.js process
* and are proxied to the CLI via the control plane.
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import {
ListToolsRequestSchema,
CallToolRequestSchema,
type CallToolResultSchema,
} from '@modelcontextprotocol/sdk/types.js';
import type { ToolDefinition } from '../types/types.js';
import { formatToolResult, formatToolError } from './formatters.js';
/**
* Factory function to create SDK-embedded MCP servers
*/
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import type { SdkMcpToolDefinition } from './tool.js';
import { validateToolName } from './tool.js';
import type { z } from 'zod';
type CallToolResult = z.infer<typeof CallToolResultSchema>;
/**
* Options for creating an SDK MCP server
*/
export type CreateSdkMcpServerOptions = {
name: string;
version?: string;
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
tools?: Array<SdkMcpToolDefinition<any>>;
};
/**
* SDK MCP Server configuration with instance
*/
export type McpSdkServerConfigWithInstance = {
type: 'sdk';
name: string;
instance: McpServer;
};
/**
* Creates an MCP server instance that can be used with the SDK transport.
*
* @example
* ```typescript
* import { z } from 'zod';
* import { tool, createSdkMcpServer } from '@qwen-code/sdk-typescript';
*
* const calculatorTool = tool(
* 'calculate_sum',
* 'Add two numbers',
* { a: z.number(), b: z.number() },
* async (args) => ({ content: [{ type: 'text', text: String(args.a + args.b) }] })
* );
*
* const server = createSdkMcpServer({
* name: 'calculator',
* version: '1.0.0',
* tools: [calculatorTool],
* });
* ```
*/
export function createSdkMcpServer(
name: string,
version: string,
tools: ToolDefinition[],
): Server {
// Validate server name
options: CreateSdkMcpServerOptions,
): McpSdkServerConfigWithInstance {
const { name, version = '1.0.0', tools } = options;
if (!name || typeof name !== 'string') {
throw new Error('MCP server name must be a non-empty string');
}
@@ -32,78 +66,42 @@ export function createSdkMcpServer(
throw new Error('MCP server version must be a non-empty string');
}
if (!Array.isArray(tools)) {
if (tools !== undefined && !Array.isArray(tools)) {
throw new Error('Tools must be an array');
}
// Validate tool names are unique
const toolNames = new Set<string>();
for (const tool of tools) {
validateToolName(tool.name);
if (toolNames.has(tool.name)) {
throw new Error(
`Duplicate tool name '${tool.name}' in MCP server '${name}'`,
);
if (tools) {
for (const t of tools) {
validateToolName(t.name);
if (toolNames.has(t.name)) {
throw new Error(
`Duplicate tool name '${t.name}' in MCP server '${name}'`,
);
}
toolNames.add(t.name);
}
toolNames.add(tool.name);
}
// Create MCP Server instance
const server = new Server(
{
name,
version,
},
const server = new McpServer(
{ name, version },
{
capabilities: {
tools: {},
tools: tools ? {} : undefined,
},
},
);
// Create tool map for fast lookup
const toolMap = new Map<string, ToolDefinition>();
for (const tool of tools) {
toolMap.set(tool.name, tool);
if (tools) {
tools.forEach((toolDef) => {
server.tool(
toolDef.name,
toolDef.description,
toolDef.inputSchema,
toolDef.handler,
);
});
}
// Register list_tools handler
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: tools.map((tool) => ({
name: tool.name,
description: tool.description,
inputSchema: tool.inputSchema,
})),
}));
// Register call_tool handler
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name: toolName, arguments: toolArgs } = request.params;
// Find tool
const tool = toolMap.get(toolName);
if (!tool) {
return formatToolError(
new Error(`Tool '${toolName}' not found in server '${name}'`),
) as CallToolResult;
}
try {
// Invoke tool handler
const result = await tool.handler(toolArgs);
// Format result
return formatToolResult(result) as CallToolResult;
} catch (error) {
// Handle tool execution error
return formatToolError(
error instanceof Error
? error
: new Error(`Tool '${toolName}' failed: ${String(error)}`),
) as CallToolResult;
}
});
return server;
return { type: 'sdk', name, instance: server };
}

View File

@@ -1,39 +1,76 @@
/**
* Tool definition helper for SDK-embedded MCP servers
*
* Provides type-safe tool definitions with generic input/output types.
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import type { ToolDefinition } from '../types/types.js';
/**
* Tool definition helper for SDK-embedded MCP servers
*/
export function tool<TInput = unknown, TOutput = unknown>(
def: ToolDefinition<TInput, TOutput>,
): ToolDefinition<TInput, TOutput> {
// Validate tool definition
if (!def.name || typeof def.name !== 'string') {
throw new Error('Tool definition must have a name (string)');
import type { CallToolResultSchema } from '@modelcontextprotocol/sdk/types.js';
import type { z, ZodRawShape, ZodObject, ZodTypeAny } from 'zod';
type CallToolResult = z.infer<typeof CallToolResultSchema>;
/**
* SDK MCP Tool Definition with Zod schema type inference
*/
export type SdkMcpToolDefinition<Schema extends ZodRawShape = ZodRawShape> = {
name: string;
description: string;
inputSchema: Schema;
handler: (
args: z.infer<ZodObject<Schema, 'strip', ZodTypeAny>>,
extra: unknown,
) => Promise<CallToolResult>;
};
/**
* Create an SDK MCP tool definition with Zod schema inference
*
* @example
* ```typescript
* import { z } from 'zod';
* import { tool } from '@qwen-code/sdk-typescript';
*
* const calculatorTool = tool(
* 'calculate_sum',
* 'Calculate the sum of two numbers',
* { a: z.number(), b: z.number() },
* async (args) => {
* // args is inferred as { a: number, b: number }
* return { content: [{ type: 'text', text: String(args.a + args.b) }] };
* }
* );
* ```
*/
export function tool<Schema extends ZodRawShape>(
name: string,
description: string,
inputSchema: Schema,
handler: (
args: z.infer<ZodObject<Schema, 'strip', ZodTypeAny>>,
extra: unknown,
) => Promise<CallToolResult>,
): SdkMcpToolDefinition<Schema> {
if (!name || typeof name !== 'string') {
throw new Error('Tool name must be a non-empty string');
}
if (!def.description || typeof def.description !== 'string') {
throw new Error(
`Tool definition for '${def.name}' must have a description (string)`,
);
if (!description || typeof description !== 'string') {
throw new Error(`Tool '${name}' must have a description (string)`);
}
if (!def.inputSchema || typeof def.inputSchema !== 'object') {
throw new Error(
`Tool definition for '${def.name}' must have an inputSchema (object)`,
);
if (!inputSchema || typeof inputSchema !== 'object') {
throw new Error(`Tool '${name}' must have an inputSchema (object)`);
}
if (!def.handler || typeof def.handler !== 'function') {
throw new Error(
`Tool definition for '${def.name}' must have a handler (function)`,
);
if (!handler || typeof handler !== 'function') {
throw new Error(`Tool '${name}' must have a handler (function)`);
}
// Return definition (pass-through for type safety)
return def;
return { name, description, inputSchema, handler };
}
export function validateToolName(name: string): void {
@@ -53,39 +90,3 @@ export function validateToolName(name: string): void {
);
}
}
export function validateInputSchema(schema: unknown): void {
if (!schema || typeof schema !== 'object') {
throw new Error('Input schema must be an object');
}
const schemaObj = schema as Record<string, unknown>;
if (!schemaObj.type) {
throw new Error('Input schema must have a type field');
}
// For object schemas, validate properties
if (schemaObj.type === 'object') {
if (schemaObj.properties && typeof schemaObj.properties !== 'object') {
throw new Error('Input schema properties must be an object');
}
if (schemaObj.required && !Array.isArray(schemaObj.required)) {
throw new Error('Input schema required must be an array');
}
}
}
export function createTool<TInput = unknown, TOutput = unknown>(
def: ToolDefinition<TInput, TOutput>,
): ToolDefinition<TInput, TOutput> {
// Validate via tool() function
const validated = tool(def);
// Additional validation
validateToolName(validated.name);
validateInputSchema(validated.inputSchema);
return validated;
}

View File

@@ -5,10 +5,10 @@
* Implements AsyncIterator protocol for message consumption.
*/
const PERMISSION_CALLBACK_TIMEOUT = 30000;
const MCP_REQUEST_TIMEOUT = 30000;
const CONTROL_REQUEST_TIMEOUT = 30000;
const STREAM_CLOSE_TIMEOUT = 10000;
const DEFAULT_CAN_USE_TOOL_TIMEOUT = 30_000;
const DEFAULT_MCP_REQUEST_TIMEOUT = 60_000;
const DEFAULT_CONTROL_REQUEST_TIMEOUT = 30_000;
const DEFAULT_STREAM_CLOSE_TIMEOUT = 60_000;
import { randomUUID } from 'node:crypto';
import { SdkLogger } from '../utils/logger.js';
@@ -19,6 +19,7 @@ import type {
CLIControlResponse,
ControlCancelRequest,
PermissionSuggestion,
WireSDKMcpServerConfig,
} from '../types/protocol.js';
import {
isSDKUserMessage,
@@ -31,12 +32,17 @@ import {
isControlCancel,
} from '../types/protocol.js';
import type { Transport } from '../transport/Transport.js';
import type { QueryOptions } from '../types/types.js';
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import type { QueryOptions, CLIMcpServerConfig } from '../types/types.js';
import { isSdkMcpServerConfig } from '../types/types.js';
import { Stream } from '../utils/Stream.js';
import { serializeJsonLine } from '../utils/jsonLines.js';
import { AbortError } from '../types/errors.js';
import type { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js';
import type { SdkControlServerTransport } from '../mcp/SdkControlServerTransport.js';
import {
SdkControlServerTransport,
type SdkControlServerTransportOptions,
} from '../mcp/SdkControlServerTransport.js';
import { ControlRequestType } from '../types/protocol.js';
interface PendingControlRequest {
@@ -46,6 +52,11 @@ interface PendingControlRequest {
abortController: AbortController;
}
interface PendingMcpResponse {
resolve: (response: JSONRPCMessage) => void;
reject: (error: Error) => void;
}
interface TransportWithEndInput extends Transport {
endInput(): void;
}
@@ -61,7 +72,9 @@ export class Query implements AsyncIterable<SDKMessage> {
private abortController: AbortController;
private pendingControlRequests: Map<string, PendingControlRequest> =
new Map();
private pendingMcpResponses: Map<string, PendingMcpResponse> = new Map();
private sdkMcpTransports: Map<string, SdkControlServerTransport> = new Map();
private sdkMcpServers: Map<string, McpServer> = new Map();
readonly initialized: Promise<void>;
private closed = false;
private messageRouterStarted = false;
@@ -92,6 +105,11 @@ export class Query implements AsyncIterable<SDKMessage> {
*/
this.sdkMessages = this.readSdkMessages();
/**
* Promise that resolves when the first SDKResultMessage is received.
* Used to coordinate endInput() timing - ensures all initialization
* (SDK MCP servers, control responses) is complete before closing CLI stdin.
*/
this.firstResultReceivedPromise = new Promise((resolve) => {
this.firstResultReceivedResolve = resolve;
});
@@ -121,17 +139,152 @@ export class Query implements AsyncIterable<SDKMessage> {
this.startMessageRouter();
}
private async initializeSdkMcpServers(): Promise<void> {
if (!this.options.mcpServers) {
return;
}
const connectionPromises: Array<Promise<void>> = [];
// Extract SDK MCP servers from the unified mcpServers config
for (const [key, config] of Object.entries(this.options.mcpServers)) {
if (!isSdkMcpServerConfig(config)) {
continue; // Skip external MCP servers
}
// Use the name from SDKMcpServerConfig, fallback to key for backwards compatibility
const serverName = config.name || key;
const server = config.instance;
// Create transport options with callback to route MCP server responses
const transportOptions: SdkControlServerTransportOptions = {
sendToQuery: async (message: JSONRPCMessage) => {
this.handleMcpServerResponse(serverName, message);
},
serverName,
};
const sdkTransport = new SdkControlServerTransport(transportOptions);
// Connect server to transport and only register on success
const connectionPromise = server
.connect(sdkTransport)
.then(() => {
// Only add to maps after successful connection
this.sdkMcpServers.set(serverName, server);
this.sdkMcpTransports.set(serverName, sdkTransport);
logger.debug(`SDK MCP server '${serverName}' connected to transport`);
})
.catch((error) => {
logger.error(
`Failed to connect SDK MCP server '${serverName}' to transport:`,
error,
);
// Don't throw - one failed server shouldn't prevent others
});
connectionPromises.push(connectionPromise);
}
// Wait for all connection attempts to complete
await Promise.all(connectionPromises);
if (this.sdkMcpServers.size > 0) {
logger.info(
`Initialized ${this.sdkMcpServers.size} SDK MCP server(s): ${Array.from(this.sdkMcpServers.keys()).join(', ')}`,
);
}
}
/**
* Handle response messages from SDK MCP servers
*
* When an MCP server sends a response via transport.send(), this callback
* routes it back to the pending request that's waiting for it.
*/
private handleMcpServerResponse(
serverName: string,
message: JSONRPCMessage,
): void {
// Check if this is a response with an id
if ('id' in message && message.id !== null && message.id !== undefined) {
const key = `${serverName}:${message.id}`;
const pending = this.pendingMcpResponses.get(key);
if (pending) {
logger.debug(
`Routing MCP response for server '${serverName}', id: ${message.id}`,
);
pending.resolve(message);
this.pendingMcpResponses.delete(key);
return;
}
}
// If no pending request found, log a warning (this shouldn't happen normally)
logger.warn(
`Received MCP server response with no pending request: server='${serverName}'`,
message,
);
}
/**
* Get SDK MCP servers config for CLI initialization
*
* Only SDK servers are sent in the initialize request.
*/
private getSdkMcpServersForCli(): Record<string, WireSDKMcpServerConfig> {
const sdkServers: Record<string, WireSDKMcpServerConfig> = {};
for (const [name] of this.sdkMcpServers.entries()) {
sdkServers[name] = { type: 'sdk', name };
}
return sdkServers;
}
/**
* Get external MCP servers (non-SDK) that should be managed by the CLI
*/
private getMcpServersForCli(): Record<string, CLIMcpServerConfig> {
if (!this.options.mcpServers) {
return {};
}
const externalServers: Record<string, CLIMcpServerConfig> = {};
for (const [name, config] of Object.entries(this.options.mcpServers)) {
if (isSdkMcpServerConfig(config)) {
continue;
}
externalServers[name] = config as CLIMcpServerConfig;
}
return externalServers;
}
private async initialize(): Promise<void> {
try {
logger.debug('Initializing Query');
const sdkMcpServerNames = Array.from(this.sdkMcpTransports.keys());
// Initialize SDK MCP servers and wait for connections
await this.initializeSdkMcpServers();
// Get only successfully connected SDK servers for CLI
const sdkMcpServersForCli = this.getSdkMcpServersForCli();
const mcpServersForCli = this.getMcpServersForCli();
logger.debug('SDK MCP servers for CLI:', sdkMcpServersForCli);
logger.debug('External MCP servers for CLI:', mcpServersForCli);
await this.sendControlRequest(ControlRequestType.INITIALIZE, {
hooks: null,
sdkMcpServers:
sdkMcpServerNames.length > 0 ? sdkMcpServerNames : undefined,
mcpServers: this.options.mcpServers,
Object.keys(sdkMcpServersForCli).length > 0
? sdkMcpServersForCli
: undefined,
mcpServers:
Object.keys(mcpServersForCli).length > 0
? mcpServersForCli
: undefined,
agents: this.options.agents,
});
logger.info('Query initialized successfully');
@@ -279,10 +432,12 @@ export class Query implements AsyncIterable<SDKMessage> {
}
try {
const canUseToolTimeout =
this.options.timeout?.canUseTool ?? DEFAULT_CAN_USE_TOOL_TIMEOUT;
const timeoutPromise = new Promise<never>((_, reject) => {
setTimeout(
() => reject(new Error('Permission callback timeout')),
PERMISSION_CALLBACK_TIMEOUT,
canUseToolTimeout,
);
});
@@ -361,32 +516,45 @@ export class Query implements AsyncIterable<SDKMessage> {
}
private handleMcpRequest(
_serverName: string,
serverName: string,
message: JSONRPCMessage,
transport: SdkControlServerTransport,
): Promise<JSONRPCMessage> {
const messageId = 'id' in message ? message.id : null;
const key = `${serverName}:${messageId}`;
return new Promise((resolve, reject) => {
const mcpRequestTimeout =
this.options.timeout?.mcpRequest ?? DEFAULT_MCP_REQUEST_TIMEOUT;
const timeout = setTimeout(() => {
this.pendingMcpResponses.delete(key);
reject(new Error('MCP request timeout'));
}, MCP_REQUEST_TIMEOUT);
}, mcpRequestTimeout);
const messageId = 'id' in message ? message.id : null;
/**
* Hook into transport to capture response.
* Temporarily replace sendToQuery to intercept the response message
* matching this request's ID, then restore the original handler.
*/
const originalSend = transport.sendToQuery;
transport.sendToQuery = async (responseMessage: JSONRPCMessage) => {
if ('id' in responseMessage && responseMessage.id === messageId) {
clearTimeout(timeout);
transport.sendToQuery = originalSend;
resolve(responseMessage);
}
return originalSend(responseMessage);
const cleanup = () => {
clearTimeout(timeout);
this.pendingMcpResponses.delete(key);
};
const resolveAndCleanup = (response: JSONRPCMessage) => {
cleanup();
resolve(response);
};
const rejectAndCleanup = (error: Error) => {
cleanup();
reject(error);
};
// Register pending response handler
this.pendingMcpResponses.set(key, {
resolve: resolveAndCleanup,
reject: rejectAndCleanup,
});
// Deliver message to MCP server via transport.onmessage
// The server will process it and call transport.send() with the response,
// which triggers handleMcpServerResponse to resolve our pending promise
transport.handleMessage(message);
});
}
@@ -466,10 +634,13 @@ export class Query implements AsyncIterable<SDKMessage> {
const responsePromise = new Promise<Record<string, unknown> | null>(
(resolve, reject) => {
const abortController = new AbortController();
const controlRequestTimeout =
this.options.timeout?.controlRequest ??
DEFAULT_CONTROL_REQUEST_TIMEOUT;
const timeout = setTimeout(() => {
this.pendingControlRequests.delete(requestId);
reject(new Error(`Control request timeout: ${subtype}`));
}, CONTROL_REQUEST_TIMEOUT);
}, controlRequestTimeout);
this.pendingControlRequests.set(requestId, {
resolve,
@@ -520,6 +691,12 @@ export class Query implements AsyncIterable<SDKMessage> {
}
this.pendingControlRequests.clear();
// Clean up pending MCP responses
for (const pending of this.pendingMcpResponses.values()) {
pending.reject(new Error('Query closed'));
}
this.pendingMcpResponses.clear();
await this.transport.close();
/**
@@ -588,22 +765,31 @@ export class Query implements AsyncIterable<SDKMessage> {
}
/**
* In multi-turn mode with MCP servers, wait for first result
* to ensure MCP servers have time to process before next input.
* This prevents race conditions where the next input arrives before
* MCP servers have finished processing the current request.
* After all user messages are sent (for-await loop ended), determine when to
* close the CLI's stdin via endInput().
*
* - If a result message was already received: All initialization (SDK MCP servers,
* control responses, etc.) is complete, safe to close stdin immediately.
* - If no result yet: Wait for either the result to arrive, or the timeout to expire.
* This gives pending control_responses from SDK MCP servers or other modules
* time to complete their initialization before we close the input stream.
*
* The timeout ensures we don't hang indefinitely - either the turn proceeds
* normally, or it fails with a timeout, but Promise.race will always resolve.
*/
if (
!this.isSingleTurn &&
this.sdkMcpTransports.size > 0 &&
this.firstResultReceivedPromise
) {
const streamCloseTimeout =
this.options.timeout?.streamClose ?? DEFAULT_STREAM_CLOSE_TIMEOUT;
await Promise.race([
this.firstResultReceivedPromise,
new Promise<void>((resolve) => {
setTimeout(() => {
resolve();
}, STREAM_CLOSE_TIMEOUT);
}, streamCloseTimeout);
}),
]);
}

View File

@@ -1,5 +1,6 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
export interface Annotation {
type: string;
value: string;
@@ -293,10 +294,44 @@ export interface MCPServerConfig {
targetServiceAccount?: string;
}
/**
* SDK MCP Server configuration
*
* SDK MCP servers run in the SDK process and are connected via in-memory transport.
* Tool calls are routed through the control plane between SDK and CLI.
*/
export interface SDKMcpServerConfig {
/**
* Type identifier for SDK MCP servers
*/
type: 'sdk';
/**
* Server name for identification and routing
*/
name: string;
/**
* The MCP Server instance created by createSdkMcpServer()
*/
instance: McpServer;
}
/**
* Wire format for SDK MCP servers sent to the CLI
*/
export type WireSDKMcpServerConfig = Omit<SDKMcpServerConfig, 'instance'>;
export interface CLIControlInitializeRequest {
subtype: 'initialize';
hooks?: HookRegistration[] | null;
sdkMcpServers?: Record<string, MCPServerConfig>;
/**
* SDK MCP servers config
* These are MCP servers running in the SDK process, connected via control plane.
* External MCP servers are configured separately in settings, not via initialization.
*/
sdkMcpServers?: Record<string, WireSDKMcpServerConfig>;
/**
* External MCP servers that should be managed by the CLI.
*/
mcpServers?: Record<string, MCPServerConfig>;
agents?: SubagentConfig[];
}

View File

@@ -2,19 +2,98 @@ import { z } from 'zod';
import type { CanUseTool } from './types.js';
import type { SubagentConfig } from './protocol.js';
export const ExternalMcpServerConfigSchema = z.object({
command: z.string().min(1, 'Command must be a non-empty string'),
/**
* OAuth configuration for MCP servers
*/
export const McpOAuthConfigSchema = z
.object({
enabled: z.boolean().optional(),
clientId: z
.string()
.min(1, 'clientId must be a non-empty string')
.optional(),
clientSecret: z.string().optional(),
scopes: z.array(z.string()).optional(),
redirectUri: z.string().optional(),
authorizationUrl: z.string().optional(),
tokenUrl: z.string().optional(),
audiences: z.array(z.string()).optional(),
tokenParamName: z.string().optional(),
registrationUrl: z.string().optional(),
})
.strict();
/**
* CLI MCP Server configuration schema
*
* Supports multiple transport types:
* - stdio: command, args, env, cwd
* - SSE: url
* - Streamable HTTP: httpUrl, headers
* - WebSocket: tcp
*/
export const CLIMcpServerConfigSchema = z.object({
// For stdio transport
command: z.string().optional(),
args: z.array(z.string()).optional(),
env: z.record(z.string(), z.string()).optional(),
cwd: z.string().optional(),
// For SSE transport
url: z.string().optional(),
// For streamable HTTP transport
httpUrl: z.string().optional(),
headers: z.record(z.string(), z.string()).optional(),
// For WebSocket transport
tcp: z.string().optional(),
// Common
timeout: z.number().optional(),
trust: z.boolean().optional(),
// Metadata
description: z.string().optional(),
includeTools: z.array(z.string()).optional(),
excludeTools: z.array(z.string()).optional(),
extensionName: z.string().optional(),
// OAuth configuration
oauth: McpOAuthConfigSchema.optional(),
authProviderType: z
.enum([
'dynamic_discovery',
'google_credentials',
'service_account_impersonation',
])
.optional(),
// Service Account Configuration
targetAudience: z.string().optional(),
targetServiceAccount: z.string().optional(),
});
/**
* SDK MCP Server configuration schema
*/
export const SdkMcpServerConfigSchema = z.object({
connect: z.custom<(transport: unknown) => Promise<void>>(
(val) => typeof val === 'function',
{ message: 'connect must be a function' },
type: z.literal('sdk'),
name: z.string().min(1, 'name must be a non-empty string'),
instance: z.custom<{
connect(transport: unknown): Promise<void>;
close(): Promise<void>;
}>(
(val) =>
val &&
typeof val === 'object' &&
'connect' in val &&
typeof val.connect === 'function',
{ message: 'instance must be an MCP Server with connect method' },
),
});
/**
* Unified MCP Server configuration schema
*/
export const McpServerConfigSchema = z.union([
CLIMcpServerConfigSchema,
SdkMcpServerConfigSchema,
]);
export const ModelConfigSchema = z.object({
model: z.string().optional(),
temp: z.number().optional(),
@@ -37,6 +116,13 @@ export const SubagentConfigSchema = z.object({
isBuiltin: z.boolean().optional(),
});
export const TimeoutConfigSchema = z.object({
canUseTool: z.number().positive().optional(),
mcpRequest: z.number().positive().optional(),
controlRequest: z.number().positive().optional(),
streamClose: z.number().positive().optional(),
});
export const QueryOptionsSchema = z
.object({
cwd: z.string().optional(),
@@ -49,7 +135,7 @@ export const QueryOptionsSchema = z
message: 'canUseTool must be a function',
})
.optional(),
mcpServers: z.record(z.string(), ExternalMcpServerConfigSchema).optional(),
mcpServers: z.record(z.string(), McpServerConfigSchema).optional(),
abortController: z.instanceof(AbortController).optional(),
debug: z.boolean().optional(),
stderr: z
@@ -78,5 +164,6 @@ export const QueryOptionsSchema = z
)
.optional(),
includePartialMessages: z.boolean().optional(),
timeout: TimeoutConfigSchema.optional(),
})
.strict();

View File

@@ -2,25 +2,11 @@ import type {
PermissionMode,
PermissionSuggestion,
SubagentConfig,
SDKMcpServerConfig,
} from './protocol.js';
export type { PermissionMode };
type JSONSchema = {
type: string;
properties?: Record<string, unknown>;
required?: string[];
description?: string;
[key: string]: unknown;
};
export type ToolDefinition<TInput = unknown, TOutput = unknown> = {
name: string;
description: string;
inputSchema: JSONSchema;
handler: (input: TInput) => Promise<TOutput>;
};
export type TransportOptions = {
pathToQwenExecutable: string;
cwd?: string;
@@ -61,14 +47,115 @@ export type PermissionResult =
interrupt?: boolean;
};
export interface ExternalMcpServerConfig {
command: string;
args?: string[];
env?: Record<string, string>;
/**
* OAuth configuration for MCP servers
*/
export interface McpOAuthConfig {
enabled?: boolean;
clientId?: string;
clientSecret?: string;
scopes?: string[];
redirectUri?: string;
authorizationUrl?: string;
tokenUrl?: string;
audiences?: string[];
tokenParamName?: string;
registrationUrl?: string;
}
export interface SdkMcpServerConfig {
connect: (transport: unknown) => Promise<void>;
/**
* Auth provider type for MCP servers
*/
export type McpAuthProviderType =
| 'dynamic_discovery'
| 'google_credentials'
| 'service_account_impersonation';
/**
* CLI MCP Server configuration
*
* Supports multiple transport types:
* - stdio: command, args, env, cwd
* - SSE: url
* - Streamable HTTP: httpUrl, headers
* - WebSocket: tcp
*
* This interface aligns with MCPServerConfig in @qwen-code/qwen-code-core.
*/
export interface CLIMcpServerConfig {
// For stdio transport
command?: string;
args?: string[];
env?: Record<string, string>;
cwd?: string;
// For SSE transport
url?: string;
// For streamable HTTP transport
httpUrl?: string;
headers?: Record<string, string>;
// For WebSocket transport
tcp?: string;
// Common
timeout?: number;
trust?: boolean;
// Metadata
description?: string;
includeTools?: string[];
excludeTools?: string[];
extensionName?: string;
// OAuth configuration
oauth?: McpOAuthConfig;
authProviderType?: McpAuthProviderType;
// Service Account Configuration
/** targetAudience format: CLIENT_ID.apps.googleusercontent.com */
targetAudience?: string;
/** targetServiceAccount format: <service-account-name>@<project-num>.iam.gserviceaccount.com */
targetServiceAccount?: string;
}
/**
* Unified MCP Server configuration
*
* Supports both external MCP servers (stdio/SSE/HTTP/WebSocket) and SDK-embedded MCP servers.
*
* @example External MCP server (stdio)
* ```typescript
* mcpServers: {
* 'my-server': { command: 'node', args: ['server.js'] }
* }
* ```
*
* @example External MCP server (SSE)
* ```typescript
* mcpServers: {
* 'remote-server': { url: 'http://localhost:3000/sse' }
* }
* ```
*
* @example External MCP server (Streamable HTTP)
* ```typescript
* mcpServers: {
* 'http-server': { httpUrl: 'http://localhost:3000/mcp', headers: { 'Authorization': 'Bearer token' } }
* }
* ```
*
* @example SDK MCP server
* ```typescript
* const server = createSdkMcpServer('weather', '1.0.0', [weatherTool]);
* mcpServers: {
* 'weather': { type: 'sdk', name: 'weather', instance: server }
* }
* ```
*/
export type McpServerConfig = CLIMcpServerConfig | SDKMcpServerConfig;
/**
* Type guard to check if a config is an SDK MCP server
*/
export function isSdkMcpServerConfig(
config: McpServerConfig,
): config is SDKMcpServerConfig {
return 'type' in config && config.type === 'sdk';
}
/**
@@ -174,11 +261,36 @@ export interface QueryOptions {
canUseTool?: CanUseTool;
/**
* External MCP (Model Context Protocol) servers to connect to.
* Each server is identified by a unique name and configured with command, args, and environment.
* @example { 'my-server': { command: 'node', args: ['server.js'], env: { PORT: '3000' } } }
* MCP (Model Context Protocol) servers to connect to.
*
* Supports both external MCP servers and SDK-embedded MCP servers:
*
* **External MCP servers** - Run in separate processes, connected via stdio/SSE/HTTP:
* ```typescript
* mcpServers: {
* 'stdio-server': { command: 'node', args: ['server.js'], env: { PORT: '3000' } },
* 'sse-server': { url: 'http://localhost:3000/sse' },
* 'http-server': { httpUrl: 'http://localhost:3000/mcp' }
* }
* ```
*
* **SDK MCP servers** - Run in the SDK process, connected via in-memory transport:
* ```typescript
* const myTool = tool({
* name: 'my_tool',
* description: 'My custom tool',
* inputSchema: { type: 'object', properties: { input: { type: 'string' } } },
* handler: async (input) => ({ result: input.input.toUpperCase() }),
* });
*
* const server = createSdkMcpServer('my-server', '1.0.0', [myTool]);
*
* mcpServers: {
* 'my-server': { type: 'sdk', name: 'my-server', instance: server }
* }
* ```
*/
mcpServers?: Record<string, ExternalMcpServerConfig>;
mcpServers?: Record<string, McpServerConfig>;
/**
* AbortController to cancel the query session.
@@ -294,4 +406,43 @@ export interface QueryOptions {
* @default false
*/
includePartialMessages?: boolean;
/**
* Timeout configuration for various SDK operations.
* All values are in milliseconds.
*/
timeout?: {
/**
* Timeout for the `canUseTool` callback.
* If the callback doesn't resolve within this time, the permission request
* will be denied with a timeout error (fail-safe behavior).
* @default 60000 (1 minute)
*/
canUseTool?: number;
/**
* Timeout for SDK MCP tool calls.
* This applies to tool calls made to SDK-embedded MCP servers.
* @default 60000 (1 minute)
*/
mcpRequest?: number;
/**
* Timeout for SDK→CLI control requests.
* This applies to internal control operations like initialize, interrupt,
* setPermissionMode, setModel, etc.
* @default 60000 (1 minute)
*/
controlRequest?: number;
/**
* Timeout for waiting before closing CLI's stdin after user messages are sent.
* In multi-turn mode with SDK MCP servers, after all user messages are processed,
* the SDK waits for the first result message to ensure all initialization
* (control responses, MCP server setup, etc.) is complete before closing stdin.
* This timeout is a fallback to avoid hanging indefinitely.
* @default 60000 (1 minute)
*/
streamClose?: number;
};
}