fix(acp): support SSE and HTTP MCP servers in ACP mode (#3574)

In ACP mode, the Mcp server list sent by the IDE client can include
SSE (type: "sse") and HTTP (type: "http") transports, but the previous
implementation only handled stdio servers via toStdioServer(). Non-stdio
servers were silently skipped (continue), so any SSE/HTTP-configured
MCP server would never be registered.

Changes:
- Add toSseServer() helper: detects type=="sse" servers and maps them
  to MCPServerConfig(url=..., headers=...)
- Add toHttpServer() helper: detects type=="http" servers and maps them
  to MCPServerConfig(httpUrl=..., headers=...)
- Refactor newSessionConfig() loop to handle all three transport types
- Declare mcpCapabilities: { sse: true, http: true } in agentCapabilities
  so IDE clients know this agent supports these transports without needing
  a transparent proxy
- Export the three helper functions for unit testing

Tests:
- Unit tests for toStdioServer / toSseServer / toHttpServer helpers
  (type discrimination, mutual exclusion)
- Integration-style tests for QwenAgent.initialize() mcpCapabilities
- Integration-style tests for newSession() with SSE/HTTP MCP servers,
  verifying MCPServerConfig is constructed with the correct arguments
  (url vs httpUrl, headers passthrough, empty-headers → undefined)

Fixes #3472
This commit is contained in:
顾盼 2026-04-24 14:53:01 +08:00 committed by GitHub
parent 5c1e636dbe
commit 97926a07fe
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 492 additions and 17 deletions

View file

@ -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<typeof process.exit>;
@ -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<void> };
type AgentLike = {
initialize: (args: Record<string, unknown>) => Promise<unknown>;
newSession: (args: Record<string, unknown>) => Promise<unknown>;
};
let mockConfig: Config;
let processExitSpy: MockInstance<typeof process.exit>;
let stdinDestroySpy: MockInstance<typeof process.stdin.destroy>;
let stdoutDestroySpy: MockInstance<typeof process.stdout.destroy>;
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<typeof AgentSideConnection>;
});
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<typeof Session>,
);
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;
});
});

View file

@ -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<string, Session> = 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<string, string> = {};
for (const { name: envName, value } of stdioServer.env) {
env[envName] = value;
if (stdioServer) {
const env: Record<string, string> = {};
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<string, string> = {};
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<string, string> = {};
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 };