diff --git a/packages/cli/src/serve/routes/a2ui-action.test.ts b/packages/cli/src/serve/routes/a2ui-action.test.ts index c141a053736..1d014452952 100644 --- a/packages/cli/src/serve/routes/a2ui-action.test.ts +++ b/packages/cli/src/serve/routes/a2ui-action.test.ts @@ -177,6 +177,50 @@ describe('POST /session/:id/a2ui-action', () => { expect(configs[0]).toEqual(STDIO_SERVER.config); }); + it('skips unusable runtime configs and uses a later valid server', async () => { + const badConnected: McpServerCell = { + name: 'a2ui-bad', + mcpStatus: 'connected', + config: { httpUrl: 'not a url' }, + }; + const goodListed: McpServerCell = { + name: 'a2ui-good', + config: { command: 'node', args: ['good.mjs'] }, + }; + const { app, configs } = makeApp({ + servers: [badConnected, goodListed], + }); + + await request(app) + .post('/session/s1/a2ui-action') + .send({ name: 'go' }) + .expect(200); + + expect(configs[0]).toEqual(goodListed.config); + }); + + it('skips mixed runtime configs with invalid httpUrl before stdio fallback', async () => { + const badMixed: McpServerCell = { + name: 'a2ui-bad-mixed', + mcpStatus: 'connected', + config: { command: 'node', httpUrl: 'not a url' }, + }; + const goodListed: McpServerCell = { + name: 'a2ui-good', + config: { command: 'node', args: ['good.mjs'] }, + }; + const { app, configs } = makeApp({ + servers: [badMixed, goodListed], + }); + + await request(app) + .post('/session/s1/a2ui-action') + .send({ name: 'go' }) + .expect(200); + + expect(configs[0]).toEqual(goodListed.config); + }); + it('falls back to workspace settings when daemon status is unavailable', async () => { const ws = await fsp.mkdtemp(path.join(os.tmpdir(), 'a2ui-action-test-')); await fsp.mkdir(path.join(ws, '.qwen'), { recursive: true }); @@ -198,6 +242,30 @@ describe('POST /session/:id/a2ui-action', () => { } }); + it('skips unusable workspace settings configs during fallback', async () => { + const ws = await fsp.mkdtemp(path.join(os.tmpdir(), 'a2ui-action-test-')); + await fsp.mkdir(path.join(ws, '.qwen'), { recursive: true }); + await fsp.writeFile( + path.join(ws, '.qwen', 'settings.json'), + JSON.stringify({ + mcpServers: { + 'bad-a2ui': { command: '' }, + 'good-a2ui': { httpUrl: 'https://example.com/mcp' }, + }, + }), + ); + try { + const { app, configs } = makeApp({ serversError: true, workspace: ws }); + await request(app) + .post('/session/s1/a2ui-action') + .send({ name: 'go' }) + .expect(200); + expect(configs[0]).toEqual({ httpUrl: 'https://example.com/mcp' }); + } finally { + await fsp.rm(ws, { recursive: true, force: true }); + } + }); + it('maps proxy failures to a generic 502 without leaking details', async () => { const errSpy = vi .spyOn(process.stderr, 'write') @@ -322,7 +390,15 @@ describe('helpers', () => { it('usableServerConfig accepts stdio or streamable-http shapes only', () => { expect(usableServerConfig({ command: 'node' })).toBe(true); + expect(usableServerConfig({ command: '' })).toBe(false); + expect(usableServerConfig({ command: ' ' })).toBe(false); + expect(usableServerConfig({ httpUrl: 'http://x/mcp' })).toBe(true); expect(usableServerConfig({ httpUrl: 'https://x/mcp' })).toBe(true); + expect(usableServerConfig({ httpUrl: 'not a url' })).toBe(false); + expect(usableServerConfig({ httpUrl: 'ftp://x/mcp' })).toBe(false); + expect(usableServerConfig({ command: 'node', httpUrl: 'not a url' })).toBe( + false, + ); expect(usableServerConfig({})).toBe(false); expect(usableServerConfig(undefined)).toBe(false); }); diff --git a/packages/cli/src/serve/routes/a2ui-action.ts b/packages/cli/src/serve/routes/a2ui-action.ts index 8b5e369c6d9..09ecd8a199c 100644 --- a/packages/cli/src/serve/routes/a2ui-action.ts +++ b/packages/cli/src/serve/routes/a2ui-action.ts @@ -90,10 +90,16 @@ interface RegisterA2uiActionRoutesOptions { /** Exported for unit testing. */ export function usableServerConfig(cfg?: McpServerConfigLike): boolean { - return ( - !!cfg && - (typeof cfg.command === 'string' || typeof cfg.httpUrl === 'string') - ); + if (!cfg) return false; + if (typeof cfg.httpUrl === 'string') { + try { + const parsed = new URL(cfg.httpUrl); + return parsed.protocol === 'http:' || parsed.protocol === 'https:'; + } catch { + return false; + } + } + return typeof cfg.command === 'string' && cfg.command.trim().length > 0; } /**