diff --git a/packages/cli/src/acp-integration/acpAgent.test.ts b/packages/cli/src/acp-integration/acpAgent.test.ts index 261035970..a5d92a303 100644 --- a/packages/cli/src/acp-integration/acpAgent.test.ts +++ b/packages/cli/src/acp-integration/acpAgent.test.ts @@ -45,7 +45,22 @@ vi.mock('@agentclientprotocol/sdk', () => ({ }, })), ndJsonStream: vi.fn().mockReturnValue({}), - RequestError: class RequestError extends Error {}, + RequestError: class RequestError extends Error { + static authRequired = vi + .fn() + .mockImplementation((data: unknown, msg: string) => { + const err = new Error(msg); + Object.assign(err, data); + return err; + }); + static invalidParams = vi + .fn() + .mockImplementation((data: unknown, msg: string) => { + const err = new Error(msg); + Object.assign(err, data); + return err; + }); + }, PROTOCOL_VERSION: '1.0.0', })); @@ -73,7 +88,9 @@ vi.mock('@qwen-code/qwen-code-core', () => ({ clearCachedCredentialFile: vi.fn(), QwenOAuth2Event: {}, qwenOAuth2Events: { on: vi.fn(), off: vi.fn() }, - MCPServerConfig: {}, + MCPServerConfig: vi.fn().mockImplementation((...args: unknown[]) => ({ + _args: args, + })), SessionService: vi.fn(), tokenLimit: vi.fn(), SessionStartSource: { @@ -90,18 +107,31 @@ vi.mock('./authMethods.js', () => ({ buildAuthMethods: vi.fn() })); vi.mock('./service/filesystem.js', () => ({ AcpFileSystemService: vi.fn(), })); -vi.mock('../config/settings.js', () => ({ SettingScope: {} })); +vi.mock('../config/settings.js', () => ({ + SettingScope: {}, + loadSettings: vi.fn(), +})); vi.mock('../config/config.js', () => ({ loadCliConfig: vi.fn() })); vi.mock('./session/Session.js', () => ({ Session: vi.fn() })); vi.mock('../utils/acpModelUtils.js', () => ({ formatAcpModelId: vi.fn(), })); -import { runAcpAgent } from './acpAgent.js'; +import { + runAcpAgent, + toStdioServer, + toSseServer, + toHttpServer, +} from './acpAgent.js'; import type { Config } from '@qwen-code/qwen-code-core'; import type { LoadedSettings } from '../config/settings.js'; import type { CliArgs } from '../config/config.js'; -import { SessionEndReason } from '@qwen-code/qwen-code-core'; +import { SessionEndReason, MCPServerConfig } from '@qwen-code/qwen-code-core'; +import type { McpServer } from '@agentclientprotocol/sdk'; +import { AgentSideConnection } from '@agentclientprotocol/sdk'; +import { loadSettings } from '../config/settings.js'; +import { loadCliConfig } from '../config/config.js'; +import { Session } from './session/Session.js'; describe('runAcpAgent shutdown cleanup', () => { let processExitSpy: MockInstance; @@ -480,3 +510,387 @@ describe('runAcpAgent SessionEnd hooks', () => { expect(mockHookSystem.fireSessionEndEvent).toHaveBeenCalledTimes(1); }); }); + +// --------------------------------------------------------------------------- +// Unit tests for toStdioServer / toSseServer / toHttpServer helpers +// --------------------------------------------------------------------------- + +describe('toStdioServer', () => { + const stdioServer = { + name: 'my-stdio', + command: 'node', + args: ['server.js'], + env: [], + } as unknown as McpServer; + + const sseServer = { + type: 'sse', + name: 'my-sse', + url: 'http://localhost:3000/sse', + headers: [], + } as unknown as McpServer; + + it('returns the server when it is a stdio server', () => { + expect(toStdioServer(stdioServer)).toBe(stdioServer); + }); + + it('returns undefined for SSE server', () => { + expect(toStdioServer(sseServer)).toBeUndefined(); + }); + + it('returns undefined for HTTP server', () => { + const httpServer = { + type: 'http', + name: 'my-http', + url: 'http://localhost:3000/mcp', + headers: [], + } as unknown as McpServer; + expect(toStdioServer(httpServer)).toBeUndefined(); + }); +}); + +describe('toSseServer', () => { + it('returns the server when type is sse', () => { + const sseServer = { + type: 'sse', + name: 'my-sse', + url: 'http://localhost:3000/sse', + headers: [], + } as unknown as McpServer; + const result = toSseServer(sseServer); + expect(result).toBe(sseServer); + expect(result?.type).toBe('sse'); + }); + + it('returns undefined for stdio server', () => { + const stdioServer = { + name: 'my-stdio', + command: 'node', + args: [], + env: [], + } as unknown as McpServer; + expect(toSseServer(stdioServer)).toBeUndefined(); + }); + + it('returns undefined for http server', () => { + const httpServer = { + type: 'http', + name: 'my-http', + url: 'http://localhost:3000/mcp', + headers: [], + } as unknown as McpServer; + expect(toSseServer(httpServer)).toBeUndefined(); + }); +}); + +describe('toHttpServer', () => { + it('returns the server when type is http', () => { + const httpServer = { + type: 'http', + name: 'my-http', + url: 'http://localhost:3000/mcp', + headers: [], + } as unknown as McpServer; + const result = toHttpServer(httpServer); + expect(result).toBe(httpServer); + expect(result?.type).toBe('http'); + }); + + it('returns undefined for stdio server', () => { + const stdioServer = { + name: 'my-stdio', + command: 'node', + args: [], + env: [], + } as unknown as McpServer; + expect(toHttpServer(stdioServer)).toBeUndefined(); + }); + + it('returns undefined for sse server', () => { + const sseServer = { + type: 'sse', + name: 'my-sse', + url: 'http://localhost:3000/sse', + headers: [], + } as unknown as McpServer; + expect(toHttpServer(sseServer)).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// Tests for QwenAgent.initialize() mcpCapabilities + newSession SSE/HTTP +// --------------------------------------------------------------------------- + +describe('QwenAgent MCP SSE/HTTP support', () => { + // We need to capture the agent factory from AgentSideConnection constructor + let capturedAgentFactory: + | ((conn: AgentSideConnectionLike) => AgentLike) + | undefined; + + type AgentSideConnectionLike = { closed: Promise }; + type AgentLike = { + initialize: (args: Record) => Promise; + newSession: (args: Record) => Promise; + }; + + let mockConfig: Config; + let processExitSpy: MockInstance; + let stdinDestroySpy: MockInstance; + let stdoutDestroySpy: MockInstance; + + const mockArgv = {} as CliArgs; + + beforeEach(() => { + vi.clearAllMocks(); + mockConnectionState.reset(); + capturedAgentFactory = undefined; + + // Override AgentSideConnection mock to capture factory + vi.mocked(AgentSideConnection).mockImplementation((factory: unknown) => { + capturedAgentFactory = factory as typeof capturedAgentFactory; + return { + get closed() { + return mockConnectionState.promise; + }, + } as unknown as InstanceType; + }); + + mockConfig = { + initialize: vi.fn().mockResolvedValue(undefined), + getHookSystem: vi.fn().mockReturnValue(undefined), + getDisableAllHooks: vi.fn().mockReturnValue(false), + hasHooksForEvent: vi.fn().mockReturnValue(false), + getModel: vi.fn().mockReturnValue('test-model'), + getModelsConfig: vi.fn().mockReturnValue({ + getCurrentAuthType: vi.fn().mockReturnValue('api-key'), + }), + refreshAuth: vi.fn().mockResolvedValue(undefined), + } as unknown as Config; + + processExitSpy = vi + .spyOn(process, 'exit') + .mockImplementation((() => undefined) as unknown as typeof process.exit); + stdinDestroySpy = vi + .spyOn(process.stdin, 'destroy') + .mockImplementation(() => process.stdin); + stdoutDestroySpy = vi + .spyOn(process.stdout, 'destroy') + .mockImplementation(() => process.stdout); + }); + + afterEach(() => { + processExitSpy.mockRestore(); + stdinDestroySpy.mockRestore(); + stdoutDestroySpy.mockRestore(); + }); + + it('initialize response includes mcpCapabilities with sse and http', async () => { + const mockSettings = { + merged: { mcpServers: {} }, + } as unknown as LoadedSettings; + const agentPromise = runAcpAgent(mockConfig, mockSettings, mockArgv); + + await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined()); + + const fakeConn = { + get closed() { + return mockConnectionState.promise; + }, + } as AgentSideConnectionLike; + + const agent = capturedAgentFactory!(fakeConn) as AgentLike; + const response = await agent.initialize({ clientCapabilities: {} }); + + expect(response).toMatchObject({ + agentCapabilities: { + mcpCapabilities: { + sse: true, + http: true, + }, + }, + }); + + mockConnectionState.resolve(); + await agentPromise; + }); + + function makeInnerConfig() { + return { + initialize: vi.fn().mockResolvedValue(undefined), + getModelsConfig: vi.fn().mockReturnValue({ + getCurrentAuthType: vi.fn().mockReturnValue('api-key'), + }), + refreshAuth: vi.fn().mockResolvedValue(undefined), + getModel: vi.fn().mockReturnValue('m'), + getContentGeneratorConfig: vi.fn().mockReturnValue({}), + getAvailableModels: vi.fn().mockReturnValue([]), + getModes: vi.fn().mockReturnValue([]), + getApprovalMode: vi.fn().mockReturnValue('default'), + getSessionId: vi.fn().mockReturnValue('test-session-id'), + getAuthType: vi.fn().mockReturnValue('api-key'), + getAllConfiguredModels: vi.fn().mockReturnValue([]), + getGeminiClient: vi.fn().mockReturnValue({ + isInitialized: vi.fn().mockReturnValue(true), + initialize: vi.fn().mockResolvedValue(undefined), + }), + getFileSystemService: vi.fn().mockReturnValue(undefined), + setFileSystemService: vi.fn(), + getHookSystem: vi.fn().mockReturnValue(undefined), + getDisableAllHooks: vi.fn().mockReturnValue(true), + hasHooksForEvent: vi.fn().mockReturnValue(false), + }; + } + + function makeSessionSettings() { + return { + merged: { mcpServers: {} }, + getUserHooks: vi.fn().mockReturnValue({}), + getProjectHooks: vi.fn().mockReturnValue({}), + } as unknown as LoadedSettings; + } + + async function setupSessionMocks(sessionId: string) { + const innerConfig = makeInnerConfig(); + vi.mocked(loadSettings).mockReturnValue(makeSessionSettings()); + vi.mocked(loadCliConfig).mockResolvedValue( + innerConfig as unknown as Config, + ); + vi.mocked(Session).mockImplementation( + () => + ({ + getId: vi.fn().mockReturnValue(sessionId), + getConfig: vi.fn().mockReturnValue(innerConfig), + sendAvailableCommandsUpdate: vi.fn().mockResolvedValue(undefined), + replayHistory: vi.fn().mockResolvedValue(undefined), + installRewriter: vi.fn(), + }) as unknown as InstanceType, + ); + return innerConfig; + } + + it('newSession with SSE MCP server creates MCPServerConfig with url', async () => { + await setupSessionMocks('session-sse'); + + const agentPromise = runAcpAgent( + mockConfig, + makeSessionSettings(), + mockArgv, + ); + await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined()); + + const agent = capturedAgentFactory!({ + get closed() { + return mockConnectionState.promise; + }, + }) as AgentLike; + + await agent.newSession({ + cwd: '/tmp', + mcpServers: [ + { + type: 'sse', + name: 'my-sse-server', + url: 'http://localhost:3001/sse', + headers: [{ name: 'Authorization', value: 'Bearer token123' }], + }, + ], + }); + + expect(MCPServerConfig).toHaveBeenCalledWith( + undefined, + undefined, + undefined, + undefined, + 'http://localhost:3001/sse', + undefined, + { Authorization: 'Bearer token123' }, + ); + + mockConnectionState.resolve(); + await agentPromise; + }); + + it('newSession with HTTP MCP server creates MCPServerConfig with httpUrl', async () => { + await setupSessionMocks('session-http'); + + const agentPromise = runAcpAgent( + mockConfig, + makeSessionSettings(), + mockArgv, + ); + await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined()); + + const agent = capturedAgentFactory!({ + get closed() { + return mockConnectionState.promise; + }, + }) as AgentLike; + + await agent.newSession({ + cwd: '/tmp', + mcpServers: [ + { + type: 'http', + name: 'my-http-server', + url: 'http://localhost:3002/mcp', + headers: [], + }, + ], + }); + + expect(MCPServerConfig).toHaveBeenCalledWith( + undefined, + undefined, + undefined, + undefined, + undefined, + 'http://localhost:3002/mcp', + undefined, + ); + + mockConnectionState.resolve(); + await agentPromise; + }); + + it('newSession with SSE MCP server and empty headers passes undefined for headers', async () => { + await setupSessionMocks('session-sse-noheaders'); + + const agentPromise = runAcpAgent( + mockConfig, + makeSessionSettings(), + mockArgv, + ); + await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined()); + + const agent = capturedAgentFactory!({ + get closed() { + return mockConnectionState.promise; + }, + }) as AgentLike; + + await agent.newSession({ + cwd: '/tmp', + mcpServers: [ + { + type: 'sse', + name: 'no-header-sse', + url: 'http://localhost:3003/sse', + headers: [], + }, + ], + }); + + expect(MCPServerConfig).toHaveBeenCalledWith( + undefined, + undefined, + undefined, + undefined, + 'http://localhost:3003/sse', + undefined, + undefined, + ); + + mockConnectionState.resolve(); + await agentPromise; + }); +}); diff --git a/packages/cli/src/acp-integration/acpAgent.ts b/packages/cli/src/acp-integration/acpAgent.ts index e40378bf9..41905996f 100644 --- a/packages/cli/src/acp-integration/acpAgent.ts +++ b/packages/cli/src/acp-integration/acpAgent.ts @@ -42,6 +42,8 @@ import type { LoadSessionRequest, LoadSessionResponse, McpServer, + McpServerHttp, + McpServerSse, McpServerStdio, NewSessionRequest, NewSessionResponse, @@ -162,13 +164,31 @@ export async function runAcpAgent( process.off('SIGINT', shutdownHandler); } -function toStdioServer(server: McpServer): McpServerStdio | undefined { +export function toStdioServer(server: McpServer): McpServerStdio | undefined { if ('command' in server && 'args' in server && 'env' in server) { return server as McpServerStdio; } return undefined; } +export function toSseServer( + server: McpServer, +): (McpServerSse & { type: 'sse' }) | undefined { + if ('type' in server && server.type === 'sse') { + return server as McpServerSse & { type: 'sse' }; + } + return undefined; +} + +export function toHttpServer( + server: McpServer, +): (McpServerHttp & { type: 'http' }) | undefined { + if ('type' in server && server.type === 'http') { + return server as McpServerHttp & { type: 'http' }; + } + return undefined; +} + class QwenAgent implements Agent { private sessions: Map = new Map(); private clientCapabilities: ClientCapabilities | undefined; @@ -204,6 +224,10 @@ class QwenAgent implements Agent { list: {}, resume: {}, }, + mcpCapabilities: { + sse: true, + http: true, + }, }, }; } @@ -499,18 +523,55 @@ class QwenAgent implements Agent { for (const server of mcpServers) { const stdioServer = toStdioServer(server); - if (!stdioServer) continue; - - const env: Record = {}; - for (const { name: envName, value } of stdioServer.env) { - env[envName] = value; + if (stdioServer) { + const env: Record = {}; + for (const { name: envName, value } of stdioServer.env) { + env[envName] = value; + } + mergedMcpServers[stdioServer.name] = new MCPServerConfig( + stdioServer.command, + stdioServer.args, + env, + cwd, + ); + continue; + } + + const sseServer = toSseServer(server); + if (sseServer) { + const headers: Record = {}; + for (const { name: headerName, value } of sseServer.headers) { + headers[headerName] = value; + } + mergedMcpServers[sseServer.name] = new MCPServerConfig( + undefined, + undefined, + undefined, + undefined, + sseServer.url, + undefined, + Object.keys(headers).length > 0 ? headers : undefined, + ); + continue; + } + + const httpServer = toHttpServer(server); + if (httpServer) { + const headers: Record = {}; + for (const { name: headerName, value } of httpServer.headers) { + headers[headerName] = value; + } + mergedMcpServers[httpServer.name] = new MCPServerConfig( + undefined, + undefined, + undefined, + undefined, + undefined, + httpServer.url, + Object.keys(headers).length > 0 ? headers : undefined, + ); + continue; } - mergedMcpServers[stdioServer.name] = new MCPServerConfig( - stdioServer.command, - stdioServer.args, - env, - cwd, - ); } const settings = { ...this.settings.merged, mcpServers: mergedMcpServers };