mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-19 09:33:53 +00:00
Merge pull request #1147 from QwenLM/mingholy/feat/cli-sdk-stage-2
Custom tools support via SDK controlled MCP servers
This commit is contained in:
@@ -63,6 +63,7 @@ vi.mock('../tools/tool-registry', () => {
|
||||
ToolRegistryMock.prototype.registerTool = vi.fn();
|
||||
ToolRegistryMock.prototype.discoverAllTools = vi.fn();
|
||||
ToolRegistryMock.prototype.getAllTools = vi.fn(() => []); // Mock methods if needed
|
||||
ToolRegistryMock.prototype.getAllToolNames = vi.fn(() => []);
|
||||
ToolRegistryMock.prototype.getTool = vi.fn();
|
||||
ToolRegistryMock.prototype.getFunctionDeclarations = vi.fn(() => []);
|
||||
return { ToolRegistry: ToolRegistryMock };
|
||||
|
||||
@@ -46,6 +46,7 @@ import { ExitPlanModeTool } from '../tools/exitPlanMode.js';
|
||||
import { GlobTool } from '../tools/glob.js';
|
||||
import { GrepTool } from '../tools/grep.js';
|
||||
import { LSTool } from '../tools/ls.js';
|
||||
import type { SendSdkMcpMessage } from '../tools/mcp-client.js';
|
||||
import { MemoryTool, setGeminiMdFilename } from '../tools/memoryTool.js';
|
||||
import { ReadFileTool } from '../tools/read-file.js';
|
||||
import { ReadManyFilesTool } from '../tools/read-many-files.js';
|
||||
@@ -239,9 +240,18 @@ export class MCPServerConfig {
|
||||
readonly targetAudience?: string,
|
||||
/* targetServiceAccount format: <service-account-name>@<project-num>.iam.gserviceaccount.com */
|
||||
readonly targetServiceAccount?: string,
|
||||
// SDK MCP server type - 'sdk' indicates server runs in SDK process
|
||||
readonly type?: 'sdk',
|
||||
) {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an MCP server config represents an SDK server
|
||||
*/
|
||||
export function isSdkMcpServerConfig(config: MCPServerConfig): boolean {
|
||||
return config.type === 'sdk';
|
||||
}
|
||||
|
||||
export enum AuthProviderType {
|
||||
DYNAMIC_DISCOVERY = 'dynamic_discovery',
|
||||
GOOGLE_CREDENTIALS = 'google_credentials',
|
||||
@@ -360,6 +370,17 @@ function normalizeConfigOutputFormat(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for Config.initialize()
|
||||
*/
|
||||
export interface ConfigInitializeOptions {
|
||||
/**
|
||||
* Callback for sending MCP messages to SDK servers via control plane.
|
||||
* Required for SDK MCP server support in SDK mode.
|
||||
*/
|
||||
sendSdkMcpMessage?: SendSdkMcpMessage;
|
||||
}
|
||||
|
||||
export class Config {
|
||||
private sessionId: string;
|
||||
private sessionData?: ResumedSessionData;
|
||||
@@ -599,8 +620,9 @@ export class Config {
|
||||
|
||||
/**
|
||||
* Must only be called once, throws if called again.
|
||||
* @param options Optional initialization options including sendSdkMcpMessage callback
|
||||
*/
|
||||
async initialize(): Promise<void> {
|
||||
async initialize(options?: ConfigInitializeOptions): Promise<void> {
|
||||
if (this.initialized) {
|
||||
throw Error('Config was already initialized');
|
||||
}
|
||||
@@ -619,7 +641,9 @@ export class Config {
|
||||
this.subagentManager.loadSessionSubagents(this.sessionSubagents);
|
||||
}
|
||||
|
||||
this.toolRegistry = await this.createToolRegistry();
|
||||
this.toolRegistry = await this.createToolRegistry(
|
||||
options?.sendSdkMcpMessage,
|
||||
);
|
||||
|
||||
await this.geminiClient.initialize();
|
||||
|
||||
@@ -1261,8 +1285,14 @@ export class Config {
|
||||
return this.subagentManager;
|
||||
}
|
||||
|
||||
async createToolRegistry(): Promise<ToolRegistry> {
|
||||
const registry = new ToolRegistry(this, this.eventEmitter);
|
||||
async createToolRegistry(
|
||||
sendSdkMcpMessage?: SendSdkMcpMessage,
|
||||
): Promise<ToolRegistry> {
|
||||
const registry = new ToolRegistry(
|
||||
this,
|
||||
this.eventEmitter,
|
||||
sendSdkMcpMessage,
|
||||
);
|
||||
|
||||
const coreToolsConfig = this.getCoreTools();
|
||||
const excludeToolsConfig = this.getExcludeTools();
|
||||
@@ -1347,6 +1377,7 @@ export class Config {
|
||||
}
|
||||
|
||||
await registry.discoverAllTools();
|
||||
console.debug('ToolRegistry created', registry.getAllToolNames());
|
||||
return registry;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,7 +102,9 @@ export * from './tools/shell.js';
|
||||
export * from './tools/web-search/index.js';
|
||||
export * from './tools/read-many-files.js';
|
||||
export * from './tools/mcp-client.js';
|
||||
export * from './tools/mcp-client-manager.js';
|
||||
export * from './tools/mcp-tool.js';
|
||||
export * from './tools/sdk-control-client-transport.js';
|
||||
export * from './tools/task.js';
|
||||
export * from './tools/todoWrite.js';
|
||||
export * from './tools/exitPlanMode.js';
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
*/
|
||||
|
||||
import type { Config, MCPServerConfig } from '../config/config.js';
|
||||
import { isSdkMcpServerConfig } from '../config/config.js';
|
||||
import type { ToolRegistry } from './tool-registry.js';
|
||||
import type { PromptRegistry } from '../prompts/prompt-registry.js';
|
||||
import {
|
||||
@@ -12,6 +13,7 @@ import {
|
||||
MCPDiscoveryState,
|
||||
populateMcpServerCommand,
|
||||
} from './mcp-client.js';
|
||||
import type { SendSdkMcpMessage } from './mcp-client.js';
|
||||
import { getErrorMessage } from '../utils/errors.js';
|
||||
import type { EventEmitter } from 'node:events';
|
||||
import type { WorkspaceContext } from '../utils/workspaceContext.js';
|
||||
@@ -31,6 +33,7 @@ export class McpClientManager {
|
||||
private readonly workspaceContext: WorkspaceContext;
|
||||
private discoveryState: MCPDiscoveryState = MCPDiscoveryState.NOT_STARTED;
|
||||
private readonly eventEmitter?: EventEmitter;
|
||||
private readonly sendSdkMcpMessage?: SendSdkMcpMessage;
|
||||
|
||||
constructor(
|
||||
mcpServers: Record<string, MCPServerConfig>,
|
||||
@@ -40,6 +43,7 @@ export class McpClientManager {
|
||||
debugMode: boolean,
|
||||
workspaceContext: WorkspaceContext,
|
||||
eventEmitter?: EventEmitter,
|
||||
sendSdkMcpMessage?: SendSdkMcpMessage,
|
||||
) {
|
||||
this.mcpServers = mcpServers;
|
||||
this.mcpServerCommand = mcpServerCommand;
|
||||
@@ -48,6 +52,7 @@ export class McpClientManager {
|
||||
this.debugMode = debugMode;
|
||||
this.workspaceContext = workspaceContext;
|
||||
this.eventEmitter = eventEmitter;
|
||||
this.sendSdkMcpMessage = sendSdkMcpMessage;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -71,6 +76,11 @@ export class McpClientManager {
|
||||
this.eventEmitter?.emit('mcp-client-update', this.clients);
|
||||
const discoveryPromises = Object.entries(servers).map(
|
||||
async ([name, config]) => {
|
||||
// For SDK MCP servers, pass the sendSdkMcpMessage callback
|
||||
const sdkCallback = isSdkMcpServerConfig(config)
|
||||
? this.sendSdkMcpMessage
|
||||
: undefined;
|
||||
|
||||
const client = new McpClient(
|
||||
name,
|
||||
config,
|
||||
@@ -78,6 +88,7 @@ export class McpClientManager {
|
||||
this.promptRegistry,
|
||||
this.workspaceContext,
|
||||
this.debugMode,
|
||||
sdkCallback,
|
||||
);
|
||||
this.clients.set(name, client);
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/
|
||||
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
|
||||
import type {
|
||||
GetPromptResult,
|
||||
JSONRPCMessage,
|
||||
Prompt,
|
||||
} from '@modelcontextprotocol/sdk/types.js';
|
||||
import {
|
||||
@@ -22,10 +23,11 @@ import {
|
||||
} from '@modelcontextprotocol/sdk/types.js';
|
||||
import { parse } from 'shell-quote';
|
||||
import type { Config, MCPServerConfig } from '../config/config.js';
|
||||
import { AuthProviderType } from '../config/config.js';
|
||||
import { AuthProviderType, isSdkMcpServerConfig } from '../config/config.js';
|
||||
import { GoogleCredentialProvider } from '../mcp/google-auth-provider.js';
|
||||
import { ServiceAccountImpersonationProvider } from '../mcp/sa-impersonation-provider.js';
|
||||
import { DiscoveredMCPTool } from './mcp-tool.js';
|
||||
import { SdkControlClientTransport } from './sdk-control-client-transport.js';
|
||||
|
||||
import type { FunctionDeclaration } from '@google/genai';
|
||||
import { mcpToTool } from '@google/genai';
|
||||
@@ -42,6 +44,14 @@ import type {
|
||||
} from '../utils/workspaceContext.js';
|
||||
import type { ToolRegistry } from './tool-registry.js';
|
||||
|
||||
/**
|
||||
* Callback type for sending MCP messages to SDK servers via control plane
|
||||
*/
|
||||
export type SendSdkMcpMessage = (
|
||||
serverName: string,
|
||||
message: JSONRPCMessage,
|
||||
) => Promise<JSONRPCMessage>;
|
||||
|
||||
export const MCP_DEFAULT_TIMEOUT_MSEC = 10 * 60 * 1000; // default to 10 minutes
|
||||
|
||||
export type DiscoveredMCPPrompt = Prompt & {
|
||||
@@ -92,6 +102,7 @@ export class McpClient {
|
||||
private readonly promptRegistry: PromptRegistry,
|
||||
private readonly workspaceContext: WorkspaceContext,
|
||||
private readonly debugMode: boolean,
|
||||
private readonly sendSdkMcpMessage?: SendSdkMcpMessage,
|
||||
) {
|
||||
this.client = new Client({
|
||||
name: `qwen-cli-mcp-client-${this.serverName}`,
|
||||
@@ -189,7 +200,12 @@ export class McpClient {
|
||||
}
|
||||
|
||||
private async createTransport(): Promise<Transport> {
|
||||
return createTransport(this.serverName, this.serverConfig, this.debugMode);
|
||||
return createTransport(
|
||||
this.serverName,
|
||||
this.serverConfig,
|
||||
this.debugMode,
|
||||
this.sendSdkMcpMessage,
|
||||
);
|
||||
}
|
||||
|
||||
private async discoverTools(cliConfig: Config): Promise<DiscoveredMCPTool[]> {
|
||||
@@ -501,6 +517,7 @@ export function populateMcpServerCommand(
|
||||
* @param mcpServerName The name identifier for this MCP server
|
||||
* @param mcpServerConfig Configuration object containing connection details
|
||||
* @param toolRegistry The registry to register discovered tools with
|
||||
* @param sendSdkMcpMessage Optional callback for SDK MCP servers to route messages via control plane.
|
||||
* @returns Promise that resolves when discovery is complete
|
||||
*/
|
||||
export async function connectAndDiscover(
|
||||
@@ -511,6 +528,7 @@ export async function connectAndDiscover(
|
||||
debugMode: boolean,
|
||||
workspaceContext: WorkspaceContext,
|
||||
cliConfig: Config,
|
||||
sendSdkMcpMessage?: SendSdkMcpMessage,
|
||||
): Promise<void> {
|
||||
updateMCPServerStatus(mcpServerName, MCPServerStatus.CONNECTING);
|
||||
|
||||
@@ -521,6 +539,7 @@ export async function connectAndDiscover(
|
||||
mcpServerConfig,
|
||||
debugMode,
|
||||
workspaceContext,
|
||||
sendSdkMcpMessage,
|
||||
);
|
||||
|
||||
mcpClient.onerror = (error) => {
|
||||
@@ -744,6 +763,7 @@ export function hasNetworkTransport(config: MCPServerConfig): boolean {
|
||||
*
|
||||
* @param mcpServerName The name of the MCP server, used for logging and identification.
|
||||
* @param mcpServerConfig The configuration specifying how to connect to the server.
|
||||
* @param sendSdkMcpMessage Optional callback for SDK MCP servers to route messages via control plane.
|
||||
* @returns A promise that resolves to a connected MCP `Client` instance.
|
||||
* @throws An error if the connection fails or the configuration is invalid.
|
||||
*/
|
||||
@@ -752,6 +772,7 @@ export async function connectToMcpServer(
|
||||
mcpServerConfig: MCPServerConfig,
|
||||
debugMode: boolean,
|
||||
workspaceContext: WorkspaceContext,
|
||||
sendSdkMcpMessage?: SendSdkMcpMessage,
|
||||
): Promise<Client> {
|
||||
const mcpClient = new Client({
|
||||
name: 'qwen-code-mcp-client',
|
||||
@@ -808,6 +829,7 @@ export async function connectToMcpServer(
|
||||
mcpServerName,
|
||||
mcpServerConfig,
|
||||
debugMode,
|
||||
sendSdkMcpMessage,
|
||||
);
|
||||
try {
|
||||
await mcpClient.connect(transport, {
|
||||
@@ -1172,7 +1194,21 @@ export async function createTransport(
|
||||
mcpServerName: string,
|
||||
mcpServerConfig: MCPServerConfig,
|
||||
debugMode: boolean,
|
||||
sendSdkMcpMessage?: SendSdkMcpMessage,
|
||||
): Promise<Transport> {
|
||||
if (isSdkMcpServerConfig(mcpServerConfig)) {
|
||||
if (!sendSdkMcpMessage) {
|
||||
throw new Error(
|
||||
`SDK MCP server '${mcpServerName}' requires sendSdkMcpMessage callback`,
|
||||
);
|
||||
}
|
||||
return new SdkControlClientTransport({
|
||||
serverName: mcpServerName,
|
||||
sendMcpMessage: sendSdkMcpMessage,
|
||||
debugMode,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
mcpServerConfig.authProviderType ===
|
||||
AuthProviderType.SERVICE_ACCOUNT_IMPERSONATION
|
||||
|
||||
163
packages/core/src/tools/sdk-control-client-transport.ts
Normal file
163
packages/core/src/tools/sdk-control-client-transport.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* SdkControlClientTransport - MCP Client transport for SDK MCP servers
|
||||
*
|
||||
* This transport enables CLI's MCP client to connect to SDK MCP servers
|
||||
* through the control plane. Messages are routed:
|
||||
*
|
||||
* CLI MCP Client → SdkControlClientTransport → sendMcpMessage() →
|
||||
* control_request (mcp_message) → SDK → control_response → onmessage → CLI
|
||||
*
|
||||
* Unlike StdioClientTransport which spawns a subprocess, this transport
|
||||
* communicates with SDK MCP servers running in the SDK process.
|
||||
*/
|
||||
|
||||
import type { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js';
|
||||
|
||||
/**
|
||||
* Callback to send MCP messages to SDK via control plane
|
||||
* Returns the MCP response from the SDK
|
||||
*/
|
||||
export type SendMcpMessageCallback = (
|
||||
serverName: string,
|
||||
message: JSONRPCMessage,
|
||||
) => Promise<JSONRPCMessage>;
|
||||
|
||||
export interface SdkControlClientTransportOptions {
|
||||
serverName: string;
|
||||
sendMcpMessage: SendMcpMessageCallback;
|
||||
debugMode?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* MCP Client Transport for SDK MCP servers
|
||||
*
|
||||
* Implements the @modelcontextprotocol/sdk Transport interface to enable
|
||||
* CLI's MCP client to connect to SDK MCP servers via the control plane.
|
||||
*/
|
||||
export class SdkControlClientTransport {
|
||||
private serverName: string;
|
||||
private sendMcpMessage: SendMcpMessageCallback;
|
||||
private debugMode: boolean;
|
||||
private started = false;
|
||||
|
||||
// Transport interface callbacks
|
||||
onmessage?: (message: JSONRPCMessage) => void;
|
||||
onerror?: (error: Error) => void;
|
||||
onclose?: () => void;
|
||||
|
||||
constructor(options: SdkControlClientTransportOptions) {
|
||||
this.serverName = options.serverName;
|
||||
this.sendMcpMessage = options.sendMcpMessage;
|
||||
this.debugMode = options.debugMode ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the transport
|
||||
* For SDK transport, this just marks it as ready - no subprocess to spawn
|
||||
*/
|
||||
async start(): Promise<void> {
|
||||
if (this.started) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.started = true;
|
||||
|
||||
if (this.debugMode) {
|
||||
console.error(
|
||||
`[SdkControlClientTransport] Started for server '${this.serverName}'`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a message to the SDK MCP server via control plane
|
||||
*
|
||||
* Routes the message through the control plane and delivers
|
||||
* the response via onmessage callback.
|
||||
*/
|
||||
async send(message: JSONRPCMessage): Promise<void> {
|
||||
if (!this.started) {
|
||||
throw new Error(
|
||||
`SdkControlClientTransport (${this.serverName}) not started. Call start() first.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (this.debugMode) {
|
||||
console.error(
|
||||
`[SdkControlClientTransport] Sending message to '${this.serverName}':`,
|
||||
JSON.stringify(message),
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
// Send message to SDK and wait for response
|
||||
const response = await this.sendMcpMessage(this.serverName, message);
|
||||
|
||||
if (this.debugMode) {
|
||||
console.error(
|
||||
`[SdkControlClientTransport] Received response from '${this.serverName}':`,
|
||||
JSON.stringify(response),
|
||||
);
|
||||
}
|
||||
|
||||
// Deliver response via onmessage callback
|
||||
if (this.onmessage) {
|
||||
this.onmessage(response);
|
||||
}
|
||||
} catch (error) {
|
||||
if (this.debugMode) {
|
||||
console.error(
|
||||
`[SdkControlClientTransport] Error sending to '${this.serverName}':`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
|
||||
if (this.onerror) {
|
||||
this.onerror(error instanceof Error ? error : new Error(String(error)));
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the transport
|
||||
*/
|
||||
async close(): Promise<void> {
|
||||
if (!this.started) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.started = false;
|
||||
|
||||
if (this.debugMode) {
|
||||
console.error(
|
||||
`[SdkControlClientTransport] Closed for server '${this.serverName}'`,
|
||||
);
|
||||
}
|
||||
|
||||
if (this.onclose) {
|
||||
this.onclose();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if transport is started
|
||||
*/
|
||||
isStarted(): boolean {
|
||||
return this.started;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get server name
|
||||
*/
|
||||
getServerName(): string {
|
||||
return this.serverName;
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import type { Config } from '../config/config.js';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { StringDecoder } from 'node:string_decoder';
|
||||
import { connectAndDiscover } from './mcp-client.js';
|
||||
import type { SendSdkMcpMessage } from './mcp-client.js';
|
||||
import { McpClientManager } from './mcp-client-manager.js';
|
||||
import { DiscoveredMCPTool } from './mcp-tool.js';
|
||||
import { parse } from 'shell-quote';
|
||||
@@ -173,7 +174,11 @@ export class ToolRegistry {
|
||||
private config: Config;
|
||||
private mcpClientManager: McpClientManager;
|
||||
|
||||
constructor(config: Config, eventEmitter?: EventEmitter) {
|
||||
constructor(
|
||||
config: Config,
|
||||
eventEmitter?: EventEmitter,
|
||||
sendSdkMcpMessage?: SendSdkMcpMessage,
|
||||
) {
|
||||
this.config = config;
|
||||
this.mcpClientManager = new McpClientManager(
|
||||
this.config.getMcpServers() ?? {},
|
||||
@@ -183,6 +188,7 @@ export class ToolRegistry {
|
||||
this.config.getDebugMode(),
|
||||
this.config.getWorkspaceContext(),
|
||||
eventEmitter,
|
||||
sendSdkMcpMessage,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user