mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-19 09:33:53 +00:00
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:
@@ -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';
|
||||
|
||||
@@ -103,9 +103,3 @@ export class SdkControlServerTransport {
|
||||
return this.serverName;
|
||||
}
|
||||
}
|
||||
|
||||
export function createSdkControlServerTransport(
|
||||
options: SdkControlServerTransportOptions,
|
||||
): SdkControlServerTransport {
|
||||
return new SdkControlServerTransport(options);
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user