mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-07-09 17:19:02 +00:00
* fix(cli): load browser MCP tools by default * fix(cli): cover browser MCP env flags * fix: address browser MCP review follow-ups * fix(cli): add browser MCP diagnostics * fix(cli): tighten browser MCP auto-wiring * fix(cli): address browser MCP diagnostics * revert(cli): drop browser MCP diagnostic churn * revert(cli): drop optional CDP startup diagnostic * refactor(cli): load browser MCP dynamically * fix(cli): lazily attach CDP tunnel * test(cli): use repo deps for CDP tunnel acceptance * fix(cli): scope browser MCP defaults to extension origins * fix(cli): recover from lazy CDP attach failures * fix(chrome-extension): bind CDP replies to source socket * ci: allow slower actionlint runs * fix(cli): harden chrome devtools runtime MCP registration * test(cli): satisfy lint in CDP registration race test * test(cli): cover chrome devtools MCP retry loop * test(cli): cover chrome devtools skip paths * ci: restore actionlint timeout
2418 lines
86 KiB
TypeScript
2418 lines
86 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright 2025 Google LLC
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
import * as GenAiLib from '@google/genai';
|
|
import * as ClientLib from '@modelcontextprotocol/sdk/client/index.js';
|
|
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
|
|
import * as SdkClientStdioLib from '@modelcontextprotocol/sdk/client/stdio.js';
|
|
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import {
|
|
AuthProviderType,
|
|
MCPServerConfig,
|
|
type Config,
|
|
} from '../config/config.js';
|
|
import { GoogleCredentialProvider } from '../mcp/google-auth-provider.js';
|
|
import { MCPOAuthProvider } from '../mcp/oauth-provider.js';
|
|
import { MCPOAuthTokenStorage } from '../mcp/oauth-token-storage.js';
|
|
import { OAuthUtils } from '../mcp/oauth-utils.js';
|
|
import type { PromptRegistry } from '../prompts/prompt-registry.js';
|
|
import type { ResourceRegistry } from '../resources/resource-registry.js';
|
|
import type { WorkspaceContext } from '../utils/workspaceContext.js';
|
|
import {
|
|
addMCPStatusChangeListener,
|
|
connectToMcpServer,
|
|
createStreamableHttpCompatibilityFetch,
|
|
createTransport,
|
|
discoverPrompts,
|
|
discoverResources,
|
|
getAllMCPServerStatuses,
|
|
getMcpOAuthDialogInstruction,
|
|
getMCPServerStatus,
|
|
hasNetworkTransport,
|
|
isEnabled,
|
|
listMcpPrompts,
|
|
listMcpResources,
|
|
MCPServerStatus,
|
|
McpClient,
|
|
discoverTools,
|
|
populateMcpServerCommand,
|
|
removeMCPServerStatus,
|
|
removeMCPStatusChangeListener,
|
|
updateMCPServerStatus,
|
|
} from './mcp-client.js';
|
|
import type { ToolRegistry } from './tool-registry.js';
|
|
|
|
const mockExistsSync = vi.hoisted(() => vi.fn(() => true));
|
|
const mockDebugLogger = vi.hoisted(() => ({
|
|
debug: vi.fn(),
|
|
error: vi.fn(),
|
|
info: vi.fn(),
|
|
warn: vi.fn(),
|
|
}));
|
|
const ORIGINAL_ENV = process.env;
|
|
|
|
vi.mock('node:fs', () => ({
|
|
existsSync: mockExistsSync,
|
|
}));
|
|
vi.mock('@modelcontextprotocol/sdk/client/stdio.js');
|
|
vi.mock('@modelcontextprotocol/sdk/client/index.js');
|
|
vi.mock('@google/genai');
|
|
vi.mock('../mcp/oauth-provider.js');
|
|
vi.mock('../mcp/oauth-token-storage.js');
|
|
vi.mock('../utils/debugLogger.js', () => ({
|
|
createDebugLogger: vi.fn(() => mockDebugLogger),
|
|
}));
|
|
|
|
/**
|
|
* Minimal Config stub for the non-pool `discover()` path, which now reads
|
|
* `cliConfig.getResourceRegistry()` to register discovered resources. The
|
|
* returned registry just needs to accept `registerResource` without
|
|
* throwing — these tests assert on the constructor-injected tool/prompt
|
|
* registries, not the resource one.
|
|
*/
|
|
function cfgWithResources(): Config {
|
|
return {
|
|
getResourceRegistry: () => ({
|
|
registerResource: vi.fn(),
|
|
removeResourcesByServer: vi.fn(),
|
|
}),
|
|
} as unknown as Config;
|
|
}
|
|
|
|
describe('mcp-client', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
process.env = ORIGINAL_ENV;
|
|
});
|
|
|
|
describe('getMcpOAuthDialogInstruction', () => {
|
|
it('builds an authenticate instruction for the named MCP server', () => {
|
|
expect(getMcpOAuthDialogInstruction('authenticate', 'foo')).toBe(
|
|
"In interactive Qwen Code sessions, open the /mcp dialog to authenticate with MCP server 'foo'. For headless or SDK usage, configure MCP OAuth with qwen mcp add --oauth-* or settings.json, then authenticate once in an interactive session before connecting.",
|
|
);
|
|
});
|
|
|
|
it('builds a re-authenticate instruction for the named MCP server', () => {
|
|
expect(getMcpOAuthDialogInstruction('re-authenticate', 'foo')).toBe(
|
|
"In interactive Qwen Code sessions, open the /mcp dialog to re-authenticate with MCP server 'foo'. For headless or SDK usage, configure MCP OAuth with qwen mcp add --oauth-* or settings.json, then re-authenticate once in an interactive session before connecting.",
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('connectToMcpServer', () => {
|
|
afterEach(() => {
|
|
vi.mocked(MCPOAuthProvider).mockReset();
|
|
vi.mocked(MCPOAuthTokenStorage).mockReset();
|
|
});
|
|
|
|
function setupHttpOAuthRetry(connectError: Error) {
|
|
const connect = vi
|
|
.fn()
|
|
.mockRejectedValueOnce(connectError)
|
|
.mockResolvedValueOnce(undefined);
|
|
vi.mocked(ClientLib.Client).mockReturnValue({
|
|
connect,
|
|
registerCapabilities: vi.fn(),
|
|
setRequestHandler: vi.fn(),
|
|
notification: vi.fn(),
|
|
} as unknown as ClientLib.Client);
|
|
const getCredentials = vi
|
|
.fn()
|
|
.mockResolvedValueOnce(null)
|
|
.mockResolvedValueOnce({ clientId: 'client-id' });
|
|
vi.mocked(MCPOAuthTokenStorage).mockImplementation(
|
|
() =>
|
|
({
|
|
getCredentials,
|
|
}) as unknown as MCPOAuthTokenStorage,
|
|
);
|
|
const authenticate = vi.fn().mockResolvedValue(undefined);
|
|
const getValidToken = vi.fn().mockResolvedValue('access-token');
|
|
vi.mocked(MCPOAuthProvider).mockImplementation(
|
|
() =>
|
|
({
|
|
authenticate,
|
|
getValidToken,
|
|
}) as unknown as MCPOAuthProvider,
|
|
);
|
|
const discoverOAuthConfig = vi
|
|
.spyOn(OAuthUtils, 'discoverOAuthConfig')
|
|
.mockResolvedValue({
|
|
authorizationUrl: 'https://auth.example/authorize',
|
|
tokenUrl: 'https://auth.example/token',
|
|
scopes: ['mcp.read'],
|
|
});
|
|
const workspaceContext = {
|
|
getDirectories: vi.fn().mockReturnValue([]),
|
|
onDirectoriesChanged: vi.fn().mockReturnValue(vi.fn()),
|
|
} as unknown as WorkspaceContext;
|
|
|
|
return {
|
|
authenticate,
|
|
connect,
|
|
discoverOAuthConfig,
|
|
getCredentials,
|
|
getValidToken,
|
|
workspaceContext,
|
|
};
|
|
}
|
|
|
|
it('reports rejected stored OAuth tokens for SSE servers', async () => {
|
|
vi.mocked(ClientLib.Client).mockReturnValue({
|
|
connect: vi.fn().mockRejectedValue(new Error('HTTP 401 Unauthorized')),
|
|
registerCapabilities: vi.fn(),
|
|
setRequestHandler: vi.fn(),
|
|
notification: vi.fn(),
|
|
} as unknown as ClientLib.Client);
|
|
vi.mocked(MCPOAuthTokenStorage).mockImplementation(
|
|
() =>
|
|
({
|
|
getCredentials: vi.fn().mockResolvedValue({
|
|
clientId: 'client-id',
|
|
}),
|
|
}) as unknown as MCPOAuthTokenStorage,
|
|
);
|
|
vi.mocked(MCPOAuthProvider).mockImplementation(
|
|
() =>
|
|
({
|
|
getValidToken: vi.fn().mockResolvedValue('stored-token'),
|
|
}) as unknown as MCPOAuthProvider,
|
|
);
|
|
const workspaceContext = {
|
|
getDirectories: vi.fn().mockReturnValue([]),
|
|
onDirectoriesChanged: vi.fn().mockReturnValue(vi.fn()),
|
|
} as unknown as WorkspaceContext;
|
|
|
|
await expect(
|
|
connectToMcpServer(
|
|
'sse-server',
|
|
{ url: 'http://test-server/sse' },
|
|
false,
|
|
workspaceContext,
|
|
),
|
|
).rejects.toThrow(
|
|
"Stored OAuth token for SSE server 'sse-server' was rejected. " +
|
|
getMcpOAuthDialogInstruction('re-authenticate', 'sse-server'),
|
|
);
|
|
});
|
|
|
|
it('reports unusable stored OAuth tokens for SSE servers', async () => {
|
|
const getCredentials = vi
|
|
.fn()
|
|
.mockResolvedValueOnce({ clientId: 'client-id' })
|
|
.mockResolvedValueOnce({ clientId: 'client-id' })
|
|
.mockResolvedValueOnce(null);
|
|
vi.mocked(ClientLib.Client).mockReturnValue({
|
|
connect: vi.fn().mockRejectedValue(new Error('HTTP 401 Unauthorized')),
|
|
registerCapabilities: vi.fn(),
|
|
setRequestHandler: vi.fn(),
|
|
notification: vi.fn(),
|
|
} as unknown as ClientLib.Client);
|
|
vi.mocked(MCPOAuthTokenStorage).mockImplementation(
|
|
() =>
|
|
({
|
|
getCredentials,
|
|
}) as unknown as MCPOAuthTokenStorage,
|
|
);
|
|
vi.mocked(MCPOAuthProvider).mockImplementation(
|
|
() =>
|
|
({
|
|
getValidToken: vi.fn().mockResolvedValue(null),
|
|
}) as unknown as MCPOAuthProvider,
|
|
);
|
|
const workspaceContext = {
|
|
getDirectories: vi.fn().mockReturnValue([]),
|
|
onDirectoriesChanged: vi.fn().mockReturnValue(vi.fn()),
|
|
} as unknown as WorkspaceContext;
|
|
|
|
await expect(
|
|
connectToMcpServer(
|
|
'sse-server',
|
|
{ url: 'http://test-server/sse' },
|
|
false,
|
|
workspaceContext,
|
|
),
|
|
).rejects.toThrow(
|
|
"Stored OAuth tokens for SSE server 'sse-server' are expired or could not be refreshed. " +
|
|
getMcpOAuthDialogInstruction('re-authenticate', 'sse-server'),
|
|
);
|
|
});
|
|
|
|
it('reports unusable SSE OAuth tokens when token validation fails', async () => {
|
|
const getCredentials = vi
|
|
.fn()
|
|
.mockResolvedValueOnce({ clientId: 'client-id' })
|
|
.mockResolvedValueOnce({ clientId: 'client-id' })
|
|
.mockResolvedValueOnce({ clientId: 'client-id' });
|
|
vi.mocked(ClientLib.Client).mockReturnValue({
|
|
connect: vi.fn().mockRejectedValue(new Error('HTTP 401 Unauthorized')),
|
|
registerCapabilities: vi.fn(),
|
|
setRequestHandler: vi.fn(),
|
|
notification: vi.fn(),
|
|
} as unknown as ClientLib.Client);
|
|
vi.mocked(MCPOAuthTokenStorage).mockImplementation(
|
|
() =>
|
|
({
|
|
getCredentials,
|
|
}) as unknown as MCPOAuthTokenStorage,
|
|
);
|
|
const getValidToken = vi
|
|
.fn()
|
|
.mockResolvedValueOnce(null)
|
|
.mockRejectedValue(new Error('Token store unavailable'));
|
|
vi.mocked(MCPOAuthProvider).mockImplementation(
|
|
() =>
|
|
({
|
|
getValidToken,
|
|
}) as unknown as MCPOAuthProvider,
|
|
);
|
|
const workspaceContext = {
|
|
getDirectories: vi.fn().mockReturnValue([]),
|
|
onDirectoriesChanged: vi.fn().mockReturnValue(vi.fn()),
|
|
} as unknown as WorkspaceContext;
|
|
const oauthMessage =
|
|
"Stored OAuth tokens for SSE server 'sse-server' are expired or could not be refreshed. " +
|
|
getMcpOAuthDialogInstruction('re-authenticate', 'sse-server');
|
|
|
|
await expect(
|
|
connectToMcpServer(
|
|
'sse-server',
|
|
{ url: 'http://test-server/sse' },
|
|
false,
|
|
workspaceContext,
|
|
),
|
|
).rejects.toThrow(oauthMessage);
|
|
expect(mockDebugLogger.error).toHaveBeenCalledWith(
|
|
"Failed to validate stored OAuth token for SSE server 'sse-server': Token store unavailable",
|
|
);
|
|
expect(mockDebugLogger.warn).toHaveBeenCalledWith(oauthMessage);
|
|
});
|
|
|
|
it('logs unusable SSE OAuth tokens when stored credentials disappear', async () => {
|
|
const getCredentials = vi
|
|
.fn()
|
|
.mockResolvedValueOnce({ clientId: 'client-id' })
|
|
.mockResolvedValueOnce(null);
|
|
vi.mocked(ClientLib.Client).mockReturnValue({
|
|
connect: vi.fn().mockRejectedValue(new Error('HTTP 401 Unauthorized')),
|
|
registerCapabilities: vi.fn(),
|
|
setRequestHandler: vi.fn(),
|
|
notification: vi.fn(),
|
|
} as unknown as ClientLib.Client);
|
|
vi.mocked(MCPOAuthTokenStorage).mockImplementation(
|
|
() =>
|
|
({
|
|
getCredentials,
|
|
}) as unknown as MCPOAuthTokenStorage,
|
|
);
|
|
const workspaceContext = {
|
|
getDirectories: vi.fn().mockReturnValue([]),
|
|
onDirectoriesChanged: vi.fn().mockReturnValue(vi.fn()),
|
|
} as unknown as WorkspaceContext;
|
|
|
|
await expect(
|
|
connectToMcpServer(
|
|
'sse-server',
|
|
{ url: 'http://test-server/sse' },
|
|
false,
|
|
workspaceContext,
|
|
),
|
|
).rejects.toThrow(
|
|
"Stored OAuth tokens for SSE server 'sse-server' are expired or could not be refreshed. " +
|
|
getMcpOAuthDialogInstruction('re-authenticate', 'sse-server'),
|
|
);
|
|
expect(mockDebugLogger.warn).toHaveBeenCalledWith(
|
|
"Stored OAuth tokens for SSE server 'sse-server' are expired or could not be refreshed. " +
|
|
getMcpOAuthDialogInstruction('re-authenticate', 'sse-server'),
|
|
);
|
|
});
|
|
|
|
it('reports SSE OAuth guidance when credentials fail to re-read after 401', async () => {
|
|
const getCredentials = vi
|
|
.fn()
|
|
.mockResolvedValueOnce({ clientId: 'client-id' })
|
|
.mockResolvedValueOnce({ clientId: 'client-id' })
|
|
.mockRejectedValueOnce(new Error('Corrupt token file'));
|
|
vi.mocked(ClientLib.Client).mockReturnValue({
|
|
connect: vi.fn().mockRejectedValue(new Error('HTTP 401 Unauthorized')),
|
|
registerCapabilities: vi.fn(),
|
|
setRequestHandler: vi.fn(),
|
|
notification: vi.fn(),
|
|
} as unknown as ClientLib.Client);
|
|
vi.mocked(MCPOAuthTokenStorage).mockImplementation(
|
|
() =>
|
|
({
|
|
getCredentials,
|
|
}) as unknown as MCPOAuthTokenStorage,
|
|
);
|
|
const workspaceContext = {
|
|
getDirectories: vi.fn().mockReturnValue([]),
|
|
onDirectoriesChanged: vi.fn().mockReturnValue(vi.fn()),
|
|
} as unknown as WorkspaceContext;
|
|
const oauthMessage =
|
|
"Stored OAuth tokens for SSE server 'sse-server' are expired or could not be refreshed. " +
|
|
getMcpOAuthDialogInstruction('re-authenticate', 'sse-server');
|
|
|
|
await expect(
|
|
connectToMcpServer(
|
|
'sse-server',
|
|
{ url: 'http://test-server/sse' },
|
|
false,
|
|
workspaceContext,
|
|
),
|
|
).rejects.toThrow(oauthMessage);
|
|
expect(mockDebugLogger.error).toHaveBeenCalledWith(
|
|
"Failed to re-read stored OAuth credentials for SSE server 'sse-server' after 401: Corrupt token file",
|
|
);
|
|
expect(mockDebugLogger.warn).toHaveBeenCalledWith(oauthMessage);
|
|
});
|
|
|
|
it('reports missing OAuth configuration for SSE servers without stored credentials', async () => {
|
|
vi.mocked(ClientLib.Client).mockReturnValue({
|
|
connect: vi.fn().mockRejectedValue(new Error('HTTP 401 Unauthorized')),
|
|
registerCapabilities: vi.fn(),
|
|
setRequestHandler: vi.fn(),
|
|
notification: vi.fn(),
|
|
} as unknown as ClientLib.Client);
|
|
vi.mocked(MCPOAuthTokenStorage).mockImplementation(
|
|
() =>
|
|
({
|
|
getCredentials: vi.fn().mockResolvedValue(null),
|
|
}) as unknown as MCPOAuthTokenStorage,
|
|
);
|
|
const workspaceContext = {
|
|
getDirectories: vi.fn().mockReturnValue([]),
|
|
onDirectoriesChanged: vi.fn().mockReturnValue(vi.fn()),
|
|
} as unknown as WorkspaceContext;
|
|
|
|
await expect(
|
|
connectToMcpServer(
|
|
'sse-server',
|
|
{ url: 'http://test-server/sse' },
|
|
false,
|
|
workspaceContext,
|
|
),
|
|
).rejects.toThrow(
|
|
"401 error received for SSE server 'sse-server' without OAuth configuration. " +
|
|
getMcpOAuthDialogInstruction('authenticate', 'sse-server'),
|
|
);
|
|
});
|
|
|
|
it('continues connecting when the SSE OAuth credential pre-read fails', async () => {
|
|
const connect = vi.fn();
|
|
const getCredentials = vi
|
|
.fn()
|
|
.mockRejectedValueOnce(new Error('Corrupt token file'))
|
|
.mockResolvedValue(null);
|
|
vi.mocked(ClientLib.Client).mockReturnValue({
|
|
connect,
|
|
registerCapabilities: vi.fn(),
|
|
setRequestHandler: vi.fn(),
|
|
notification: vi.fn(),
|
|
} as unknown as ClientLib.Client);
|
|
vi.mocked(MCPOAuthTokenStorage).mockImplementation(
|
|
() =>
|
|
({
|
|
getCredentials,
|
|
}) as unknown as MCPOAuthTokenStorage,
|
|
);
|
|
const workspaceContext = {
|
|
getDirectories: vi.fn().mockReturnValue([]),
|
|
onDirectoriesChanged: vi.fn().mockReturnValue(vi.fn()),
|
|
} as unknown as WorkspaceContext;
|
|
|
|
await expect(
|
|
connectToMcpServer(
|
|
'sse-server',
|
|
{ url: 'http://test-server/sse' },
|
|
false,
|
|
workspaceContext,
|
|
),
|
|
).resolves.toBeDefined();
|
|
expect(connect).toHaveBeenCalledOnce();
|
|
expect(mockDebugLogger.warn).toHaveBeenCalledWith(
|
|
"Failed to pre-read stored OAuth credentials for SSE server 'sse-server': Corrupt token file",
|
|
);
|
|
});
|
|
|
|
it('reports OAuth guidance when automatic OAuth handling fails', async () => {
|
|
vi.mocked(ClientLib.Client).mockReturnValue({
|
|
connect: vi
|
|
.fn()
|
|
.mockRejectedValue(
|
|
new Error(
|
|
'HTTP 401 Unauthorized\nwww-authenticate: Bearer realm="example", resource_metadata="https://example.com/.well-known/oauth-protected-resource"',
|
|
),
|
|
),
|
|
registerCapabilities: vi.fn(),
|
|
setRequestHandler: vi.fn(),
|
|
notification: vi.fn(),
|
|
} as unknown as ClientLib.Client);
|
|
vi.mocked(MCPOAuthTokenStorage).mockImplementation(
|
|
() =>
|
|
({
|
|
getCredentials: vi.fn().mockResolvedValue(null),
|
|
}) as unknown as MCPOAuthTokenStorage,
|
|
);
|
|
vi.spyOn(OAuthUtils, 'discoverOAuthConfig').mockResolvedValue(null);
|
|
const workspaceContext = {
|
|
getDirectories: vi.fn().mockReturnValue([]),
|
|
onDirectoriesChanged: vi.fn().mockReturnValue(vi.fn()),
|
|
} as unknown as WorkspaceContext;
|
|
const oauthMessage =
|
|
"Failed to handle automatic OAuth for server 'http-server'. " +
|
|
getMcpOAuthDialogInstruction('authenticate', 'http-server');
|
|
|
|
await expect(
|
|
connectToMcpServer(
|
|
'http-server',
|
|
{ httpUrl: 'http://test-server/mcp' },
|
|
false,
|
|
workspaceContext,
|
|
),
|
|
).rejects.toThrow(oauthMessage);
|
|
expect(mockDebugLogger.error).toHaveBeenCalledWith(oauthMessage);
|
|
});
|
|
|
|
it('retries HTTP connections after base-url OAuth discovery without www-authenticate', async () => {
|
|
const {
|
|
authenticate,
|
|
connect,
|
|
discoverOAuthConfig,
|
|
getValidToken,
|
|
workspaceContext,
|
|
} = setupHttpOAuthRetry(new Error('HTTP 401 Unauthorized'));
|
|
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
|
new Response(null, { status: 401 }),
|
|
);
|
|
|
|
await expect(
|
|
connectToMcpServer(
|
|
'http-server',
|
|
{ httpUrl: 'http://test-server/mcp' },
|
|
false,
|
|
workspaceContext,
|
|
),
|
|
).resolves.toBeDefined();
|
|
|
|
expect(discoverOAuthConfig).toHaveBeenCalledWith('http://test-server');
|
|
expect(authenticate).toHaveBeenCalledWith(
|
|
'http-server',
|
|
{
|
|
enabled: true,
|
|
authorizationUrl: 'https://auth.example/authorize',
|
|
tokenUrl: 'https://auth.example/token',
|
|
scopes: ['mcp.read'],
|
|
},
|
|
'http://test-server/mcp',
|
|
);
|
|
expect(getValidToken).toHaveBeenCalledWith('http-server', {
|
|
clientId: 'client-id',
|
|
});
|
|
expect(connect).toHaveBeenCalledTimes(2);
|
|
const oauthTransport = connect.mock.calls[1][0] as {
|
|
_requestInit?: { headers?: Record<string, string> };
|
|
};
|
|
expect(oauthTransport._requestInit?.headers).toMatchObject({
|
|
Authorization: 'Bearer access-token',
|
|
});
|
|
});
|
|
|
|
it('falls back to base-url OAuth discovery when www-authenticate lacks resource metadata', async () => {
|
|
const { authenticate, connect, discoverOAuthConfig, workspaceContext } =
|
|
setupHttpOAuthRetry(
|
|
new Error(
|
|
'HTTP 401 Unauthorized\nwww-authenticate: Bearer realm="example"',
|
|
),
|
|
);
|
|
|
|
await expect(
|
|
connectToMcpServer(
|
|
'http-server',
|
|
{ httpUrl: 'http://test-server/mcp' },
|
|
false,
|
|
workspaceContext,
|
|
),
|
|
).resolves.toBeDefined();
|
|
|
|
expect(discoverOAuthConfig).toHaveBeenCalledWith('http://test-server');
|
|
expect(authenticate).toHaveBeenCalledWith(
|
|
'http-server',
|
|
{
|
|
enabled: true,
|
|
authorizationUrl: 'https://auth.example/authorize',
|
|
tokenUrl: 'https://auth.example/token',
|
|
scopes: ['mcp.read'],
|
|
},
|
|
'http://test-server/mcp',
|
|
);
|
|
expect(connect).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('reports OAuth guidance when post-discovery transport creation fails', async () => {
|
|
const config = { httpUrl: 'http://test-server/mcp' };
|
|
const { connect, discoverOAuthConfig, getValidToken, workspaceContext } =
|
|
setupHttpOAuthRetry(new Error('HTTP 401 Unauthorized'));
|
|
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
|
new Response(null, { status: 401 }),
|
|
);
|
|
getValidToken.mockImplementation(async () => {
|
|
config.httpUrl = 'not a valid URL';
|
|
return 'access-token';
|
|
});
|
|
const oauthMessage =
|
|
"Failed to create OAuth transport for server 'http-server'. " +
|
|
getMcpOAuthDialogInstruction('authenticate', 'http-server');
|
|
|
|
await expect(
|
|
connectToMcpServer('http-server', config, false, workspaceContext),
|
|
).rejects.toThrow(oauthMessage);
|
|
|
|
expect(discoverOAuthConfig).toHaveBeenCalledWith('http://test-server');
|
|
expect(connect).toHaveBeenCalledTimes(1);
|
|
expect(mockDebugLogger.error).toHaveBeenCalledWith(oauthMessage);
|
|
});
|
|
|
|
it('reports OAuth guidance when post-discovery token lookup fails', async () => {
|
|
const { getValidToken, workspaceContext } = setupHttpOAuthRetry(
|
|
new Error('HTTP 401 Unauthorized'),
|
|
);
|
|
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
|
new Response(null, { status: 401 }),
|
|
);
|
|
getValidToken.mockResolvedValue(null);
|
|
const oauthMessage =
|
|
"Failed to get OAuth token for server 'http-server'. " +
|
|
getMcpOAuthDialogInstruction('authenticate', 'http-server');
|
|
|
|
await expect(
|
|
connectToMcpServer(
|
|
'http-server',
|
|
{ httpUrl: 'http://test-server/mcp' },
|
|
false,
|
|
workspaceContext,
|
|
),
|
|
).rejects.toThrow(oauthMessage);
|
|
|
|
expect(mockDebugLogger.error).toHaveBeenCalledWith(oauthMessage);
|
|
});
|
|
|
|
it('reports OAuth guidance when post-discovery credentials are unavailable', async () => {
|
|
const { getCredentials, workspaceContext } = setupHttpOAuthRetry(
|
|
new Error('HTTP 401 Unauthorized'),
|
|
);
|
|
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
|
new Response(null, { status: 401 }),
|
|
);
|
|
getCredentials.mockReset();
|
|
getCredentials.mockResolvedValue(null);
|
|
const oauthMessage =
|
|
"Failed to get stored credentials for server 'http-server'. " +
|
|
getMcpOAuthDialogInstruction('authenticate', 'http-server');
|
|
|
|
await expect(
|
|
connectToMcpServer(
|
|
'http-server',
|
|
{ httpUrl: 'http://test-server/mcp' },
|
|
false,
|
|
workspaceContext,
|
|
),
|
|
).rejects.toThrow(oauthMessage);
|
|
|
|
expect(mockDebugLogger.error).toHaveBeenCalledWith(oauthMessage);
|
|
});
|
|
|
|
it('wraps OAuth discovery errors with remediation guidance', async () => {
|
|
vi.mocked(ClientLib.Client).mockReturnValue({
|
|
connect: vi.fn().mockRejectedValue(new Error('HTTP 401 Unauthorized')),
|
|
registerCapabilities: vi.fn(),
|
|
setRequestHandler: vi.fn(),
|
|
notification: vi.fn(),
|
|
} as unknown as ClientLib.Client);
|
|
vi.mocked(MCPOAuthTokenStorage).mockImplementation(
|
|
() =>
|
|
({
|
|
getCredentials: vi.fn().mockResolvedValue(null),
|
|
}) as unknown as MCPOAuthTokenStorage,
|
|
);
|
|
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
|
new Response(null, { status: 404 }),
|
|
);
|
|
vi.spyOn(OAuthUtils, 'discoverOAuthConfig').mockRejectedValue(
|
|
new Error('Discovery timed out'),
|
|
);
|
|
const workspaceContext = {
|
|
getDirectories: vi.fn().mockReturnValue([]),
|
|
onDirectoriesChanged: vi.fn().mockReturnValue(vi.fn()),
|
|
} as unknown as WorkspaceContext;
|
|
const oauthMessage =
|
|
"OAuth discovery failed for server 'http-server'. " +
|
|
getMcpOAuthDialogInstruction('authenticate', 'http-server') +
|
|
' Original error: Discovery timed out';
|
|
|
|
await expect(
|
|
connectToMcpServer(
|
|
'http-server',
|
|
{ httpUrl: 'http://test-server/mcp' },
|
|
false,
|
|
workspaceContext,
|
|
),
|
|
).rejects.toThrow(oauthMessage);
|
|
expect(mockDebugLogger.error).toHaveBeenCalledWith(oauthMessage);
|
|
});
|
|
});
|
|
|
|
describe('McpClient', () => {
|
|
it('should discover tools', async () => {
|
|
const mockedClient = {
|
|
connect: vi.fn(),
|
|
discover: vi.fn(),
|
|
disconnect: vi.fn(),
|
|
getStatus: vi.fn(),
|
|
registerCapabilities: vi.fn(),
|
|
setRequestHandler: vi.fn(),
|
|
getInstructions: vi.fn(),
|
|
};
|
|
vi.mocked(ClientLib.Client).mockReturnValue(
|
|
mockedClient as unknown as ClientLib.Client,
|
|
);
|
|
vi.spyOn(SdkClientStdioLib, 'StdioClientTransport').mockReturnValue(
|
|
{} as SdkClientStdioLib.StdioClientTransport,
|
|
);
|
|
const mockedMcpToTool = vi.mocked(GenAiLib.mcpToTool).mockReturnValue({
|
|
tool: () => ({
|
|
functionDeclarations: [
|
|
{
|
|
name: 'testFunction',
|
|
},
|
|
],
|
|
}),
|
|
} as unknown as GenAiLib.CallableTool);
|
|
const mockedToolRegistry = {
|
|
registerTool: vi.fn(),
|
|
} as unknown as ToolRegistry;
|
|
const client = new McpClient(
|
|
'test-server',
|
|
{
|
|
command: 'test-command',
|
|
},
|
|
mockedToolRegistry,
|
|
{} as PromptRegistry,
|
|
{} as WorkspaceContext,
|
|
false,
|
|
);
|
|
await client.connect();
|
|
await client.discover(cfgWithResources());
|
|
expect(mockedMcpToTool).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it('stores server instructions returned during initialization', async () => {
|
|
const mockedClient = {
|
|
connect: vi.fn(),
|
|
registerCapabilities: vi.fn(),
|
|
setRequestHandler: vi.fn(),
|
|
getInstructions: vi.fn().mockReturnValue('Use concise replies.'),
|
|
};
|
|
vi.mocked(ClientLib.Client).mockReturnValue(
|
|
mockedClient as unknown as ClientLib.Client,
|
|
);
|
|
vi.spyOn(SdkClientStdioLib, 'StdioClientTransport').mockReturnValue(
|
|
{} as SdkClientStdioLib.StdioClientTransport,
|
|
);
|
|
|
|
const client = new McpClient(
|
|
'test-server',
|
|
{
|
|
command: 'test-command',
|
|
},
|
|
{ registerTool: vi.fn() } as unknown as ToolRegistry,
|
|
{} as PromptRegistry,
|
|
{
|
|
getDirectories: vi.fn().mockReturnValue([]),
|
|
} as unknown as WorkspaceContext,
|
|
false,
|
|
);
|
|
|
|
await client.connect();
|
|
|
|
expect(client.getInstructions()).toBe('Use concise replies.');
|
|
});
|
|
|
|
it('readResource does not gate on the resources capability (lenient read)', async () => {
|
|
// Regression guard for the discover/read parity bug: a server that
|
|
// answers resources/read but under-declares the `resources` capability
|
|
// must still be readable (matching the lenient `listMcpResources`).
|
|
const mockedClient = {
|
|
connect: vi.fn(),
|
|
registerCapabilities: vi.fn(),
|
|
setRequestHandler: vi.fn(),
|
|
getServerCapabilities: vi.fn().mockReturnValue({}), // not declared
|
|
request: vi.fn().mockResolvedValue({
|
|
contents: [{ uri: 'res://doc', text: 'BODY' }],
|
|
}),
|
|
getInstructions: vi.fn(),
|
|
};
|
|
vi.mocked(ClientLib.Client).mockReturnValue(
|
|
mockedClient as unknown as ClientLib.Client,
|
|
);
|
|
vi.spyOn(SdkClientStdioLib, 'StdioClientTransport').mockReturnValue(
|
|
{} as SdkClientStdioLib.StdioClientTransport,
|
|
);
|
|
const client = new McpClient(
|
|
'srv',
|
|
{ command: 'test-command' },
|
|
{} as ToolRegistry,
|
|
{} as PromptRegistry,
|
|
{} as WorkspaceContext,
|
|
false,
|
|
);
|
|
await client.connect();
|
|
|
|
const result = await client.readResource('res://doc');
|
|
expect(mockedClient.request).toHaveBeenCalledWith(
|
|
{ method: 'resources/read', params: { uri: 'res://doc' } },
|
|
expect.anything(),
|
|
undefined,
|
|
);
|
|
expect((result.contents[0] as { text: string }).text).toBe('BODY');
|
|
});
|
|
|
|
it('should not skip tools even if a parameter is missing a type', async () => {
|
|
const mockedClient = {
|
|
connect: vi.fn(),
|
|
discover: vi.fn(),
|
|
disconnect: vi.fn(),
|
|
getStatus: vi.fn(),
|
|
registerCapabilities: vi.fn(),
|
|
setRequestHandler: vi.fn(),
|
|
tool: vi.fn(),
|
|
getInstructions: vi.fn(),
|
|
};
|
|
vi.mocked(ClientLib.Client).mockReturnValue(
|
|
mockedClient as unknown as ClientLib.Client,
|
|
);
|
|
vi.spyOn(SdkClientStdioLib, 'StdioClientTransport').mockReturnValue(
|
|
{} as SdkClientStdioLib.StdioClientTransport,
|
|
);
|
|
vi.mocked(GenAiLib.mcpToTool).mockReturnValue({
|
|
tool: () =>
|
|
Promise.resolve({
|
|
functionDeclarations: [
|
|
{
|
|
name: 'validTool',
|
|
parametersJsonSchema: {
|
|
type: 'object',
|
|
properties: {
|
|
param1: { type: 'string' },
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: 'invalidTool',
|
|
parametersJsonSchema: {
|
|
type: 'object',
|
|
properties: {
|
|
param1: { description: 'a param with no type' },
|
|
},
|
|
},
|
|
},
|
|
],
|
|
}),
|
|
} as unknown as GenAiLib.CallableTool);
|
|
const mockedToolRegistry = {
|
|
registerTool: vi.fn(),
|
|
} as unknown as ToolRegistry;
|
|
const client = new McpClient(
|
|
'test-server',
|
|
{
|
|
command: 'test-command',
|
|
},
|
|
mockedToolRegistry,
|
|
{} as PromptRegistry,
|
|
{} as WorkspaceContext,
|
|
false,
|
|
);
|
|
await client.connect();
|
|
await client.discover(cfgWithResources());
|
|
expect(mockedToolRegistry.registerTool).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('should handle errors when discovering prompts', async () => {
|
|
const mockedClient = {
|
|
connect: vi.fn(),
|
|
discover: vi.fn(),
|
|
disconnect: vi.fn(),
|
|
getStatus: vi.fn(),
|
|
registerCapabilities: vi.fn(),
|
|
setRequestHandler: vi.fn(),
|
|
getServerCapabilities: vi.fn().mockReturnValue({ prompts: {} }),
|
|
request: vi.fn().mockRejectedValue(new Error('Test error')),
|
|
getInstructions: vi.fn(),
|
|
};
|
|
vi.mocked(ClientLib.Client).mockReturnValue(
|
|
mockedClient as unknown as ClientLib.Client,
|
|
);
|
|
vi.spyOn(SdkClientStdioLib, 'StdioClientTransport').mockReturnValue(
|
|
{} as SdkClientStdioLib.StdioClientTransport,
|
|
);
|
|
vi.mocked(GenAiLib.mcpToTool).mockReturnValue({
|
|
tool: () => Promise.resolve({ functionDeclarations: [] }),
|
|
} as unknown as GenAiLib.CallableTool);
|
|
const client = new McpClient(
|
|
'test-server',
|
|
{
|
|
command: 'test-command',
|
|
},
|
|
{} as ToolRegistry,
|
|
{} as PromptRegistry,
|
|
{} as WorkspaceContext,
|
|
false,
|
|
);
|
|
await client.connect();
|
|
await expect(client.discover(cfgWithResources())).rejects.toThrow(
|
|
'No prompts, tools, or resources found on the server.',
|
|
);
|
|
});
|
|
|
|
it('flips status to DISCONNECTED when discover() throws', async () => {
|
|
// `Config.getFailedMcpServerNames()` filters by
|
|
// `status !== CONNECTED`, so a server that connects successfully
|
|
// but whose `discover()` then crashes (e.g. tools/list rejects, or
|
|
// the "no prompts or tools found" guard fires) must be marked
|
|
// DISCONNECTED before the error propagates. Without this, the
|
|
// server stays CONNECTED in the global registry, the non-interactive
|
|
// failure banner silently omits it, and the Footer's MCP health
|
|
// pill keeps counting it as healthy.
|
|
const mockedClient = {
|
|
connect: vi.fn(),
|
|
discover: vi.fn(),
|
|
disconnect: vi.fn(),
|
|
getStatus: vi.fn(),
|
|
registerCapabilities: vi.fn(),
|
|
setRequestHandler: vi.fn(),
|
|
getServerCapabilities: vi.fn().mockReturnValue({ prompts: {} }),
|
|
request: vi.fn().mockRejectedValue(new Error('tools/list crashed')),
|
|
close: vi.fn(),
|
|
getInstructions: vi.fn(),
|
|
};
|
|
vi.mocked(ClientLib.Client).mockReturnValue(
|
|
mockedClient as unknown as ClientLib.Client,
|
|
);
|
|
vi.spyOn(SdkClientStdioLib, 'StdioClientTransport').mockReturnValue(
|
|
{} as SdkClientStdioLib.StdioClientTransport,
|
|
);
|
|
vi.mocked(GenAiLib.mcpToTool).mockReturnValue({
|
|
tool: () => Promise.resolve({ functionDeclarations: [] }),
|
|
} as unknown as GenAiLib.CallableTool);
|
|
const serverName = `discover-error-${Date.now()}`;
|
|
const client = new McpClient(
|
|
serverName,
|
|
{
|
|
command: 'test-command',
|
|
},
|
|
{} as ToolRegistry,
|
|
{} as PromptRegistry,
|
|
{} as WorkspaceContext,
|
|
false,
|
|
);
|
|
await client.connect();
|
|
// Sanity: connect succeeded so the status is CONNECTED before the
|
|
// discover failure we're about to assert against.
|
|
expect(client.getStatus()).toBe(MCPServerStatus.CONNECTED);
|
|
|
|
await expect(client.discover(cfgWithResources())).rejects.toThrow();
|
|
|
|
expect(client.getStatus()).toBe(MCPServerStatus.DISCONNECTED);
|
|
expect(getMCPServerStatus(serverName)).toBe(MCPServerStatus.DISCONNECTED);
|
|
});
|
|
|
|
it('discoverAndReturn returns tools and prompts WITHOUT registering them', async () => {
|
|
// F2 (#4175) pool path: a single shared McpClient produces this
|
|
// snapshot once; per-session SessionMcpView instances each register
|
|
// a filtered copy. The bare method must therefore NOT touch any
|
|
// registry — otherwise sharing a snapshot across sessions would
|
|
// double-register or cross-contaminate.
|
|
const mockedClient = {
|
|
connect: vi.fn(),
|
|
registerCapabilities: vi.fn(),
|
|
setRequestHandler: vi.fn(),
|
|
getServerCapabilities: vi.fn().mockReturnValue({ prompts: {} }),
|
|
request: vi.fn().mockResolvedValue({
|
|
prompts: [{ name: 'pure-prompt', description: 'p' }],
|
|
}),
|
|
listTools: vi.fn().mockResolvedValue({ tools: [] }),
|
|
getInstructions: vi.fn(),
|
|
};
|
|
vi.mocked(ClientLib.Client).mockReturnValue(
|
|
mockedClient as unknown as ClientLib.Client,
|
|
);
|
|
vi.spyOn(SdkClientStdioLib, 'StdioClientTransport').mockReturnValue(
|
|
{} as SdkClientStdioLib.StdioClientTransport,
|
|
);
|
|
vi.mocked(GenAiLib.mcpToTool).mockReturnValue({
|
|
tool: () =>
|
|
Promise.resolve({
|
|
functionDeclarations: [{ name: 'pure-tool' }],
|
|
}),
|
|
} as unknown as GenAiLib.CallableTool);
|
|
const toolRegistry = {
|
|
registerTool: vi.fn(),
|
|
} as unknown as ToolRegistry;
|
|
const promptRegistry = {
|
|
registerPrompt: vi.fn(),
|
|
} as unknown as PromptRegistry;
|
|
const client = new McpClient(
|
|
'pure-server',
|
|
{ command: 'test-command' },
|
|
toolRegistry,
|
|
promptRegistry,
|
|
{} as WorkspaceContext,
|
|
false,
|
|
);
|
|
await client.connect();
|
|
|
|
const snapshot = await client.discoverAndReturn(cfgWithResources());
|
|
|
|
expect(snapshot.tools).toHaveLength(1);
|
|
expect(snapshot.tools[0].serverToolName).toBe('pure-tool');
|
|
expect(snapshot.prompts).toHaveLength(1);
|
|
expect(snapshot.prompts[0].name).toBe('pure-prompt');
|
|
expect(snapshot.prompts[0].serverName).toBe('pure-server');
|
|
expect(typeof snapshot.prompts[0].invoke).toBe('function');
|
|
// The critical assertion: registries untouched.
|
|
expect(toolRegistry.registerTool).not.toHaveBeenCalled();
|
|
expect(promptRegistry.registerPrompt).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('marks discovered tools alwaysLoad when the MCP server config requests it', async () => {
|
|
const mockedClient = {
|
|
listTools: vi.fn().mockResolvedValue({ tools: [] }),
|
|
} as unknown as ClientLib.Client;
|
|
vi.mocked(GenAiLib.mcpToTool).mockReturnValue({
|
|
tool: () =>
|
|
Promise.resolve({
|
|
functionDeclarations: [{ name: 'chrome_tool' }],
|
|
}),
|
|
} as unknown as GenAiLib.CallableTool);
|
|
|
|
const tools = await discoverTools(
|
|
'chrome-devtools',
|
|
{
|
|
command: 'test-command',
|
|
alwaysLoadTools: true,
|
|
},
|
|
mockedClient,
|
|
cfgWithResources(),
|
|
);
|
|
|
|
expect(tools).toHaveLength(1);
|
|
expect(tools[0].alwaysLoad).toBe(true);
|
|
});
|
|
|
|
it('discoverAndReturn with { applyConfigFilters: false } ignores config filters and trust for shared pool snapshots', async () => {
|
|
const mockedClient = {
|
|
connect: vi.fn(),
|
|
registerCapabilities: vi.fn(),
|
|
setRequestHandler: vi.fn(),
|
|
getServerCapabilities: vi.fn().mockReturnValue({}),
|
|
listTools: vi.fn().mockResolvedValue({ tools: [] }),
|
|
getInstructions: vi.fn(),
|
|
};
|
|
vi.mocked(ClientLib.Client).mockReturnValue(
|
|
mockedClient as unknown as ClientLib.Client,
|
|
);
|
|
vi.spyOn(SdkClientStdioLib, 'StdioClientTransport').mockReturnValue(
|
|
{} as SdkClientStdioLib.StdioClientTransport,
|
|
);
|
|
vi.mocked(GenAiLib.mcpToTool).mockReturnValue({
|
|
tool: () =>
|
|
Promise.resolve({
|
|
functionDeclarations: [
|
|
{ name: 'allowed' },
|
|
{ name: 'filtered_out' },
|
|
],
|
|
}),
|
|
} as unknown as GenAiLib.CallableTool);
|
|
const toolRegistry = {
|
|
registerTool: vi.fn(),
|
|
} as unknown as ToolRegistry;
|
|
const client = new McpClient(
|
|
'pure-filter-server',
|
|
new MCPServerConfig(
|
|
'test-command',
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
true,
|
|
undefined,
|
|
['allowed'],
|
|
['filtered_out'],
|
|
),
|
|
toolRegistry,
|
|
{} as PromptRegistry,
|
|
{} as WorkspaceContext,
|
|
false,
|
|
);
|
|
await client.connect();
|
|
|
|
// R23 T1: pool callers explicitly opt out of filters via the
|
|
// `applyConfigFilters: false` opts argument. Default
|
|
// `discoverAndReturn(cliConfig)` (no opts) applies filters,
|
|
// matching the legacy `discover()` semantics expected by
|
|
// non-pool consumers.
|
|
const snapshot = await client.discoverAndReturn(cfgWithResources(), {
|
|
applyConfigFilters: false,
|
|
});
|
|
|
|
expect(snapshot.tools.map((tool) => tool.serverToolName)).toEqual([
|
|
'allowed',
|
|
'filtered_out',
|
|
]);
|
|
expect(snapshot.tools.every((tool) => tool.trust === undefined)).toBe(
|
|
true,
|
|
);
|
|
expect(toolRegistry.registerTool).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('discoverAndReturn (default) applies config filters and trust — legacy non-pool callers (R23 T1)', async () => {
|
|
// R23 T1 regression: pre-fix `discoverAndReturn` hardcoded
|
|
// `{ applyConfigFilters: false }`, so legacy `discover()`
|
|
// (which delegates here) silently lost filtering and trust.
|
|
// Operators with `trust: true` saw unexpected permission
|
|
// prompts, and `excludeTools` was ignored.
|
|
const mockedClient = {
|
|
connect: vi.fn(),
|
|
registerCapabilities: vi.fn(),
|
|
setRequestHandler: vi.fn(),
|
|
getServerCapabilities: vi.fn().mockReturnValue({}),
|
|
listTools: vi.fn().mockResolvedValue({ tools: [] }),
|
|
getInstructions: vi.fn(),
|
|
};
|
|
vi.mocked(ClientLib.Client).mockReturnValue(
|
|
mockedClient as unknown as ClientLib.Client,
|
|
);
|
|
vi.spyOn(SdkClientStdioLib, 'StdioClientTransport').mockReturnValue(
|
|
{} as SdkClientStdioLib.StdioClientTransport,
|
|
);
|
|
vi.mocked(GenAiLib.mcpToTool).mockReturnValue({
|
|
tool: () =>
|
|
Promise.resolve({
|
|
functionDeclarations: [
|
|
{ name: 'allowed' },
|
|
{ name: 'filtered_out' },
|
|
],
|
|
}),
|
|
} as unknown as GenAiLib.CallableTool);
|
|
const toolRegistry = {
|
|
registerTool: vi.fn(),
|
|
} as unknown as ToolRegistry;
|
|
const client = new McpClient(
|
|
'legacy-filter-server',
|
|
new MCPServerConfig(
|
|
'test-command',
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
true,
|
|
undefined,
|
|
['allowed'],
|
|
['filtered_out'],
|
|
),
|
|
toolRegistry,
|
|
{} as PromptRegistry,
|
|
{} as WorkspaceContext,
|
|
false,
|
|
);
|
|
await client.connect();
|
|
|
|
// No opts → default applyConfigFilters=true → filters applied.
|
|
const snapshot = await client.discoverAndReturn(cfgWithResources());
|
|
|
|
// `excludeTools: ['filtered_out']` excludes the second tool;
|
|
// `includeTools: ['allowed']` keeps the first.
|
|
expect(snapshot.tools.map((tool) => tool.serverToolName)).toEqual([
|
|
'allowed',
|
|
]);
|
|
// `trust: true` config → tools carry trust=true (pre-fix this
|
|
// was always undefined because discoverAndReturn forced
|
|
// applyConfigFilters=false).
|
|
expect(snapshot.tools[0].trust).toBe(true);
|
|
});
|
|
|
|
it('discoverAndReturn throws "No prompts or tools found" when both empty', async () => {
|
|
// Preserves the discover() pre-F2 invariant — the wrapping
|
|
// McpClientManager / pool entry uses this signal to mark the
|
|
// entry as failed (vs "server up, just empty").
|
|
const mockedClient = {
|
|
connect: vi.fn(),
|
|
registerCapabilities: vi.fn(),
|
|
setRequestHandler: vi.fn(),
|
|
getServerCapabilities: vi.fn().mockReturnValue({ prompts: {} }),
|
|
request: vi.fn().mockResolvedValue({ prompts: [] }),
|
|
listTools: vi.fn().mockResolvedValue({ tools: [] }),
|
|
getInstructions: vi.fn(),
|
|
};
|
|
vi.mocked(ClientLib.Client).mockReturnValue(
|
|
mockedClient as unknown as ClientLib.Client,
|
|
);
|
|
vi.spyOn(SdkClientStdioLib, 'StdioClientTransport').mockReturnValue(
|
|
{} as SdkClientStdioLib.StdioClientTransport,
|
|
);
|
|
vi.mocked(GenAiLib.mcpToTool).mockReturnValue({
|
|
tool: () => Promise.resolve({ functionDeclarations: [] }),
|
|
} as unknown as GenAiLib.CallableTool);
|
|
// Forward defense (per code-reviewer P2.4): also assert registries
|
|
// were NOT touched even on the throw path. Catches a future
|
|
// refactor in commits 2-6 that accidentally registers a partial
|
|
// batch before the "no prompts or tools" guard fires.
|
|
const toolRegistry = {
|
|
registerTool: vi.fn(),
|
|
} as unknown as ToolRegistry;
|
|
const promptRegistry = {
|
|
registerPrompt: vi.fn(),
|
|
} as unknown as PromptRegistry;
|
|
const client = new McpClient(
|
|
'empty-server',
|
|
{ command: 'test-command' },
|
|
toolRegistry,
|
|
promptRegistry,
|
|
{} as WorkspaceContext,
|
|
false,
|
|
);
|
|
await client.connect();
|
|
await expect(
|
|
client.discoverAndReturn(cfgWithResources()),
|
|
).rejects.toThrow('No prompts, tools, or resources found on the server.');
|
|
// Status flipped to DISCONNECTED (same as discover() path).
|
|
expect(client.getStatus()).toBe(MCPServerStatus.DISCONNECTED);
|
|
// Registries strictly untouched even on throw — pure method invariant.
|
|
expect(toolRegistry.registerTool).not.toHaveBeenCalled();
|
|
expect(promptRegistry.registerPrompt).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('discoverAndReturn throws when called before connect()', async () => {
|
|
const client = new McpClient(
|
|
'unconnected-server',
|
|
{ command: 'test-command' },
|
|
{} as ToolRegistry,
|
|
{} as PromptRegistry,
|
|
{} as WorkspaceContext,
|
|
false,
|
|
);
|
|
// Status starts DISCONNECTED; discoverAndReturn must reject without
|
|
// hitting the network.
|
|
await expect(
|
|
client.discoverAndReturn(cfgWithResources()),
|
|
).rejects.toThrow('Client is not connected.');
|
|
});
|
|
|
|
it('discover() delegates to discoverAndReturn and registers both tools and prompts', async () => {
|
|
// Backward-compat sanity: the historical discover() entry point
|
|
// still produces side effects in BOTH registries. Previously
|
|
// prompt registration was a side effect inside discoverPrompts;
|
|
// post-F2-1 it is an explicit step in discover() after
|
|
// discoverAndReturn returns.
|
|
const mockedClient = {
|
|
connect: vi.fn(),
|
|
registerCapabilities: vi.fn(),
|
|
setRequestHandler: vi.fn(),
|
|
getServerCapabilities: vi.fn().mockReturnValue({ prompts: {} }),
|
|
request: vi.fn().mockResolvedValue({
|
|
prompts: [{ name: 'p1' }, { name: 'p2' }],
|
|
}),
|
|
listTools: vi.fn().mockResolvedValue({ tools: [] }),
|
|
getInstructions: vi.fn(),
|
|
};
|
|
vi.mocked(ClientLib.Client).mockReturnValue(
|
|
mockedClient as unknown as ClientLib.Client,
|
|
);
|
|
vi.spyOn(SdkClientStdioLib, 'StdioClientTransport').mockReturnValue(
|
|
{} as SdkClientStdioLib.StdioClientTransport,
|
|
);
|
|
vi.mocked(GenAiLib.mcpToTool).mockReturnValue({
|
|
tool: () =>
|
|
Promise.resolve({
|
|
functionDeclarations: [{ name: 't1' }],
|
|
}),
|
|
} as unknown as GenAiLib.CallableTool);
|
|
const toolRegistry = {
|
|
registerTool: vi.fn(),
|
|
} as unknown as ToolRegistry;
|
|
const promptRegistry = {
|
|
registerPrompt: vi.fn(),
|
|
} as unknown as PromptRegistry;
|
|
const client = new McpClient(
|
|
'compat-server',
|
|
{ command: 'test-command' },
|
|
toolRegistry,
|
|
promptRegistry,
|
|
{} as WorkspaceContext,
|
|
false,
|
|
);
|
|
await client.connect();
|
|
await client.discover(cfgWithResources());
|
|
expect(toolRegistry.registerTool).toHaveBeenCalledTimes(1);
|
|
expect(promptRegistry.registerPrompt).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('discover() registers discovered resources into the Config ResourceRegistry', async () => {
|
|
const registerResource = vi.fn();
|
|
const removeResourcesByServer = vi.fn();
|
|
const cfg = {
|
|
getResourceRegistry: () => ({
|
|
registerResource,
|
|
removeResourcesByServer,
|
|
}),
|
|
} as unknown as Config;
|
|
const mockedClient = {
|
|
connect: vi.fn(),
|
|
registerCapabilities: vi.fn(),
|
|
setRequestHandler: vi.fn(),
|
|
getServerCapabilities: vi.fn().mockReturnValue({ resources: {} }),
|
|
request: vi
|
|
.fn()
|
|
.mockImplementation((req: { method?: string }) =>
|
|
req.method === 'resources/list'
|
|
? Promise.resolve({ resources: [{ uri: 'res://a', name: 'a' }] })
|
|
: Promise.resolve({ prompts: [] }),
|
|
),
|
|
getInstructions: vi.fn(),
|
|
};
|
|
vi.mocked(ClientLib.Client).mockReturnValue(
|
|
mockedClient as unknown as ClientLib.Client,
|
|
);
|
|
vi.spyOn(SdkClientStdioLib, 'StdioClientTransport').mockReturnValue(
|
|
{} as SdkClientStdioLib.StdioClientTransport,
|
|
);
|
|
vi.mocked(GenAiLib.mcpToTool).mockReturnValue({
|
|
tool: () => Promise.resolve({ functionDeclarations: [] }),
|
|
} as unknown as GenAiLib.CallableTool);
|
|
const client = new McpClient(
|
|
'srv',
|
|
{ command: 'test-command' },
|
|
{ registerTool: vi.fn() } as unknown as ToolRegistry,
|
|
{ registerPrompt: vi.fn() } as unknown as PromptRegistry,
|
|
{} as WorkspaceContext,
|
|
false,
|
|
);
|
|
await client.connect();
|
|
await client.discover(cfg);
|
|
// Clear-then-register so re-discovery (reconnect) is idempotent and a
|
|
// dropped resource doesn't linger.
|
|
expect(removeResourcesByServer).toHaveBeenCalledWith('srv');
|
|
expect(registerResource).toHaveBeenCalledWith(
|
|
expect.objectContaining({ uri: 'res://a', serverName: 'srv' }),
|
|
);
|
|
});
|
|
|
|
it('discover() does NOT clear resources when the list returns empty (transient-failure guard)', async () => {
|
|
// listMcpResources swallows a transient resources/list failure to [].
|
|
// With tools present (discovery still succeeds), an empty resource list
|
|
// must NOT wipe the registry.
|
|
const registerResource = vi.fn();
|
|
const removeResourcesByServer = vi.fn();
|
|
const cfg = {
|
|
getResourceRegistry: () => ({
|
|
registerResource,
|
|
removeResourcesByServer,
|
|
}),
|
|
} as unknown as Config;
|
|
const mockedClient = {
|
|
connect: vi.fn(),
|
|
registerCapabilities: vi.fn(),
|
|
setRequestHandler: vi.fn(),
|
|
getServerCapabilities: vi.fn().mockReturnValue({ resources: {} }),
|
|
request: vi.fn().mockResolvedValue({ resources: [], prompts: [] }),
|
|
getInstructions: vi.fn(),
|
|
};
|
|
vi.mocked(ClientLib.Client).mockReturnValue(
|
|
mockedClient as unknown as ClientLib.Client,
|
|
);
|
|
vi.spyOn(SdkClientStdioLib, 'StdioClientTransport').mockReturnValue(
|
|
{} as SdkClientStdioLib.StdioClientTransport,
|
|
);
|
|
// A tool exists so discovery does not fail with "no prompts/tools/resources".
|
|
vi.mocked(GenAiLib.mcpToTool).mockReturnValue({
|
|
tool: () => Promise.resolve({ functionDeclarations: [{ name: 't1' }] }),
|
|
} as unknown as GenAiLib.CallableTool);
|
|
const client = new McpClient(
|
|
'srv',
|
|
{ command: 'test-command' },
|
|
{ registerTool: vi.fn() } as unknown as ToolRegistry,
|
|
{ registerPrompt: vi.fn() } as unknown as PromptRegistry,
|
|
{} as WorkspaceContext,
|
|
false,
|
|
);
|
|
await client.connect();
|
|
await client.discover(cfg);
|
|
expect(removeResourcesByServer).not.toHaveBeenCalled();
|
|
expect(registerResource).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('discoverAndReturn connects a resource-only server (does not throw)', async () => {
|
|
// The failed-discovery guard now counts resources: a server exposing
|
|
// only resources (no tools/prompts) is a successful discovery.
|
|
const mockedClient = {
|
|
connect: vi.fn(),
|
|
registerCapabilities: vi.fn(),
|
|
setRequestHandler: vi.fn(),
|
|
getServerCapabilities: vi.fn().mockReturnValue({ resources: {} }),
|
|
request: vi
|
|
.fn()
|
|
.mockImplementation((req: { method?: string }) =>
|
|
req.method === 'resources/list'
|
|
? Promise.resolve({ resources: [{ uri: 'res://a', name: 'a' }] })
|
|
: Promise.resolve({ prompts: [] }),
|
|
),
|
|
listTools: vi.fn().mockResolvedValue({ tools: [] }),
|
|
getInstructions: vi.fn(),
|
|
};
|
|
vi.mocked(ClientLib.Client).mockReturnValue(
|
|
mockedClient as unknown as ClientLib.Client,
|
|
);
|
|
vi.spyOn(SdkClientStdioLib, 'StdioClientTransport').mockReturnValue(
|
|
{} as SdkClientStdioLib.StdioClientTransport,
|
|
);
|
|
vi.mocked(GenAiLib.mcpToTool).mockReturnValue({
|
|
tool: () => Promise.resolve({ functionDeclarations: [] }),
|
|
} as unknown as GenAiLib.CallableTool);
|
|
const client = new McpClient(
|
|
'srv',
|
|
{ command: 'test-command' },
|
|
{} as ToolRegistry,
|
|
{} as PromptRegistry,
|
|
{} as WorkspaceContext,
|
|
false,
|
|
);
|
|
await client.connect();
|
|
const snap = await client.discoverAndReturn(cfgWithResources());
|
|
expect(snap.resources).toHaveLength(1);
|
|
expect(snap.tools).toHaveLength(0);
|
|
expect(snap.prompts).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe('listMcpPrompts (F2 pure helper)', () => {
|
|
it('returns enriched DiscoveredMCPPrompt[] with serverName + bound invoke', async () => {
|
|
const mockClient = {
|
|
getServerCapabilities: vi.fn().mockReturnValue({ prompts: {} }),
|
|
request: vi.fn().mockResolvedValue({
|
|
prompts: [
|
|
{ name: 'greet', description: 'Greet a user' },
|
|
{ name: 'farewell' },
|
|
],
|
|
}),
|
|
} as unknown as ClientLib.Client;
|
|
|
|
const result = await listMcpPrompts('my-server', mockClient);
|
|
expect(result).toHaveLength(2);
|
|
expect(result[0].serverName).toBe('my-server');
|
|
expect(result[0].name).toBe('greet');
|
|
expect(typeof result[0].invoke).toBe('function');
|
|
expect(result[1].name).toBe('farewell');
|
|
});
|
|
|
|
it('attempts prompts/list even when the prompts capability is undeclared (lenient)', async () => {
|
|
// Regression guard: pre-fix this returned [] WITHOUT a request when
|
|
// `capabilities.prompts` was absent, hiding prompts from servers that
|
|
// under-declare the capability. We now always attempt the call.
|
|
const mockClient = {
|
|
getServerCapabilities: vi.fn().mockReturnValue({}),
|
|
request: vi
|
|
.fn()
|
|
.mockRejectedValue(new Error('MCP error -32601: Method not found')),
|
|
} as unknown as ClientLib.Client;
|
|
const result = await listMcpPrompts('no-prompts', mockClient);
|
|
expect(result).toEqual([]);
|
|
expect(vi.mocked(mockClient.request)).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('lists prompts from a server that omits the prompts capability but still answers', async () => {
|
|
const mockClient = {
|
|
getServerCapabilities: vi.fn().mockReturnValue({}),
|
|
request: vi.fn().mockResolvedValue({
|
|
prompts: [{ name: 'greet' }],
|
|
}),
|
|
} as unknown as ClientLib.Client;
|
|
const result = await listMcpPrompts('under-declared', mockClient);
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0].name).toBe('greet');
|
|
expect(result[0].serverName).toBe('under-declared');
|
|
});
|
|
|
|
it('returns [] on protocol error (server up but list call rejects)', async () => {
|
|
const mockClient = {
|
|
getServerCapabilities: vi.fn().mockReturnValue({ prompts: {} }),
|
|
request: vi.fn().mockRejectedValue(new Error('boom')),
|
|
} as unknown as ClientLib.Client;
|
|
const result = await listMcpPrompts('flaky', mockClient);
|
|
expect(result).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('listMcpResources', () => {
|
|
it('returns enriched DiscoveredMCPResource[] with serverName', async () => {
|
|
const mockClient = {
|
|
getServerCapabilities: vi.fn().mockReturnValue({ resources: {} }),
|
|
request: vi.fn().mockResolvedValue({
|
|
resources: [
|
|
{ uri: 'file:///a.txt', name: 'a' },
|
|
{ uri: 'file:///b.txt', name: 'b', mimeType: 'text/plain' },
|
|
],
|
|
}),
|
|
} as unknown as ClientLib.Client;
|
|
|
|
const result = await listMcpResources('my-server', mockClient);
|
|
expect(result).toHaveLength(2);
|
|
expect(result[0].serverName).toBe('my-server');
|
|
expect(result[0].uri).toBe('file:///a.txt');
|
|
expect(result[1].mimeType).toBe('text/plain');
|
|
// Resources carry no bound invoke (unlike prompts).
|
|
expect((result[0] as Record<string, unknown>)['invoke']).toBeUndefined();
|
|
});
|
|
|
|
it('attempts resources/list even when the resources capability is undeclared (lenient)', async () => {
|
|
const mockClient = {
|
|
getServerCapabilities: vi.fn().mockReturnValue({}),
|
|
request: vi
|
|
.fn()
|
|
.mockRejectedValue(new Error('MCP error -32601: Method not found')),
|
|
} as unknown as ClientLib.Client;
|
|
const result = await listMcpResources('no-resources', mockClient);
|
|
expect(result).toEqual([]);
|
|
expect(vi.mocked(mockClient.request)).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('returns [] on protocol error (server up but list call rejects)', async () => {
|
|
const mockClient = {
|
|
getServerCapabilities: vi.fn().mockReturnValue({ resources: {} }),
|
|
request: vi.fn().mockRejectedValue(new Error('boom')),
|
|
} as unknown as ClientLib.Client;
|
|
const result = await listMcpResources('flaky', mockClient);
|
|
expect(result).toEqual([]);
|
|
});
|
|
|
|
it('treats JSON-RPC code -32601 as method-absent even when the message differs (no error logged)', async () => {
|
|
// A spec-compliant server signals an unimplemented method by the
|
|
// numeric code; the message text is not guaranteed (it may be
|
|
// localized or worded "Method not implemented"). It must not be
|
|
// logged as an error.
|
|
const err = Object.assign(new Error('Método no implementado'), {
|
|
code: -32601,
|
|
});
|
|
const mockClient = {
|
|
getServerCapabilities: vi.fn().mockReturnValue({}),
|
|
request: vi.fn().mockRejectedValue(err),
|
|
} as unknown as ClientLib.Client;
|
|
const result = await listMcpResources('localized', mockClient);
|
|
expect(result).toEqual([]);
|
|
expect(mockDebugLogger.error).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('logs genuinely unexpected errors (not method-absent)', async () => {
|
|
const mockClient = {
|
|
getServerCapabilities: vi.fn().mockReturnValue({ resources: {} }),
|
|
request: vi.fn().mockRejectedValue(new Error('connection reset')),
|
|
} as unknown as ClientLib.Client;
|
|
const result = await listMcpResources('broken', mockClient);
|
|
expect(result).toEqual([]);
|
|
expect(mockDebugLogger.error).toHaveBeenCalled();
|
|
});
|
|
|
|
it('swallows the exact "Method not found" message even without a code', async () => {
|
|
const mockClient = {
|
|
getServerCapabilities: vi.fn().mockReturnValue({ resources: {} }),
|
|
request: vi.fn().mockRejectedValue(new Error('Method not found')),
|
|
} as unknown as ClientLib.Client;
|
|
const result = await listMcpResources('no-method', mockClient);
|
|
expect(result).toEqual([]);
|
|
expect(mockDebugLogger.error).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('does NOT swallow an unrelated error that merely contains "method not found" (case-sensitive)', async () => {
|
|
// Regression guard for the message-substring narrowing: the old broad
|
|
// /method not found/i would have hidden this genuine failure.
|
|
const mockClient = {
|
|
getServerCapabilities: vi.fn().mockReturnValue({ resources: {} }),
|
|
request: vi
|
|
.fn()
|
|
.mockRejectedValue(
|
|
new Error('Error in method not found handler: null pointer'),
|
|
),
|
|
} as unknown as ClientLib.Client;
|
|
const result = await listMcpResources('weird', mockClient);
|
|
expect(result).toEqual([]);
|
|
expect(mockDebugLogger.error).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('discoverResources wrapper', () => {
|
|
it('registers discovered resources into the supplied ResourceRegistry', async () => {
|
|
const mockClient = {
|
|
getServerCapabilities: vi.fn().mockReturnValue({ resources: {} }),
|
|
request: vi.fn().mockResolvedValue({
|
|
resources: [{ uri: 'file:///x.txt', name: 'x' }],
|
|
}),
|
|
} as unknown as ClientLib.Client;
|
|
const resourceRegistry = {
|
|
registerResource: vi.fn(),
|
|
} as unknown as ResourceRegistry;
|
|
const out = await discoverResources(
|
|
'wrapper-server',
|
|
mockClient,
|
|
resourceRegistry,
|
|
);
|
|
expect(resourceRegistry.registerResource).toHaveBeenCalledTimes(1);
|
|
expect(out).toHaveLength(1);
|
|
expect(out[0].uri).toBe('file:///x.txt');
|
|
expect(out[0].serverName).toBe('wrapper-server');
|
|
});
|
|
});
|
|
|
|
describe('discoverPrompts wrapper (backward compat after F2-1 split)', () => {
|
|
it('still registers prompts into the supplied PromptRegistry', async () => {
|
|
const mockClient = {
|
|
getServerCapabilities: vi.fn().mockReturnValue({ prompts: {} }),
|
|
request: vi.fn().mockResolvedValue({
|
|
prompts: [{ name: 'register-me' }],
|
|
}),
|
|
} as unknown as ClientLib.Client;
|
|
const promptRegistry = {
|
|
registerPrompt: vi.fn(),
|
|
} as unknown as PromptRegistry;
|
|
const out = await discoverPrompts(
|
|
'wrapper-server',
|
|
mockClient,
|
|
promptRegistry,
|
|
);
|
|
expect(promptRegistry.registerPrompt).toHaveBeenCalledTimes(1);
|
|
expect(out).toHaveLength(1);
|
|
expect(out[0].name).toBe('register-me');
|
|
// Historical contract: return type strips serverName/invoke.
|
|
expect((out[0] as Record<string, unknown>)['serverName']).toBeUndefined();
|
|
expect((out[0] as Record<string, unknown>)['invoke']).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('appendMcpServerCommand', () => {
|
|
it('should do nothing if no MCP servers or command are configured', () => {
|
|
const out = populateMcpServerCommand({}, undefined);
|
|
expect(out).toEqual({});
|
|
});
|
|
|
|
it('should discover tools via mcpServerCommand', () => {
|
|
const commandString = 'command --arg1 value1';
|
|
const out = populateMcpServerCommand({}, commandString);
|
|
expect(out).toEqual({
|
|
mcp: {
|
|
command: 'command',
|
|
args: ['--arg1', 'value1'],
|
|
},
|
|
});
|
|
});
|
|
|
|
it('should handle error if mcpServerCommand parsing fails', () => {
|
|
expect(() => populateMcpServerCommand({}, 'derp && herp')).toThrowError();
|
|
});
|
|
});
|
|
|
|
describe('createTransport', () => {
|
|
describe('should connect via httpUrl', () => {
|
|
it('without headers', async () => {
|
|
const transport = await createTransport(
|
|
'test-server',
|
|
{
|
|
httpUrl: 'http://test-server',
|
|
},
|
|
false,
|
|
);
|
|
|
|
expect(transport).toBeInstanceOf(StreamableHTTPClientTransport);
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
expect((transport as any)._url).toEqual(new URL('http://test-server'));
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
expect((transport as any)._fetch).toEqual(expect.any(Function));
|
|
});
|
|
|
|
it('with headers', async () => {
|
|
const transport = await createTransport(
|
|
'test-server',
|
|
{
|
|
httpUrl: 'http://test-server',
|
|
headers: { Authorization: 'derp' },
|
|
},
|
|
false,
|
|
);
|
|
|
|
expect(transport).toBeInstanceOf(StreamableHTTPClientTransport);
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
expect((transport as any)._url).toEqual(new URL('http://test-server'));
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
expect((transport as any)._requestInit?.headers).toEqual({
|
|
Authorization: 'derp',
|
|
});
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
expect((transport as any)._fetch).toEqual(expect.any(Function));
|
|
});
|
|
|
|
it('treats 400 from optional GET SSE stream as unsupported', async () => {
|
|
const fetchFn = vi
|
|
.fn<typeof fetch>()
|
|
.mockResolvedValue(new Response('bad method', { status: 400 }));
|
|
const fetchWithFallback = createStreamableHttpCompatibilityFetch(
|
|
'spring-ai',
|
|
fetchFn,
|
|
);
|
|
|
|
const response = await fetchWithFallback('http://test-server/mcp', {
|
|
method: 'GET',
|
|
headers: { Accept: 'text/event-stream' },
|
|
});
|
|
|
|
expect(fetchFn).toHaveBeenCalledTimes(1);
|
|
expect(response.status).toBe(405);
|
|
expect(response.statusText).toBe('Method Not Allowed');
|
|
expect(await response.text()).toBe('');
|
|
});
|
|
|
|
it('omits response body diagnostics when the fallback body is empty', async () => {
|
|
const fetchFn = vi
|
|
.fn<typeof fetch>()
|
|
.mockResolvedValue(new Response(null, { status: 400 }));
|
|
const fetchWithFallback = createStreamableHttpCompatibilityFetch(
|
|
'empty-body',
|
|
fetchFn,
|
|
);
|
|
|
|
const response = await fetchWithFallback('http://test-server/mcp', {
|
|
method: 'GET',
|
|
headers: { Accept: 'text/event-stream' },
|
|
});
|
|
|
|
expect(response.status).toBe(405);
|
|
expect(mockDebugLogger.warn).toHaveBeenCalledWith(
|
|
expect.not.stringContaining('Response body:'),
|
|
);
|
|
});
|
|
|
|
it('truncates Streamable HTTP GET SSE error body diagnostics', async () => {
|
|
const fetchFn = vi
|
|
.fn<typeof fetch>()
|
|
.mockResolvedValue(new Response('x'.repeat(1024), { status: 400 }));
|
|
const fetchWithFallback = createStreamableHttpCompatibilityFetch(
|
|
'large-error-body',
|
|
fetchFn,
|
|
);
|
|
|
|
const response = await fetchWithFallback('http://test-server/mcp', {
|
|
method: 'GET',
|
|
headers: { Accept: 'text/event-stream' },
|
|
});
|
|
|
|
expect(response.status).toBe(405);
|
|
expect(mockDebugLogger.warn).toHaveBeenCalledWith(
|
|
expect.stringContaining(
|
|
`Response body: ${JSON.stringify(`${'x'.repeat(512)}...`)}`,
|
|
),
|
|
);
|
|
expect(mockDebugLogger.warn).not.toHaveBeenCalledWith(
|
|
expect.stringContaining('x'.repeat(600)),
|
|
);
|
|
});
|
|
|
|
it('treats parameterized GET SSE Accept headers as unsupported', async () => {
|
|
const fetchFn = vi
|
|
.fn<typeof fetch>()
|
|
.mockResolvedValue(new Response('bad method', { status: 400 }));
|
|
const fetchWithFallback = createStreamableHttpCompatibilityFetch(
|
|
'spring-ai',
|
|
fetchFn,
|
|
);
|
|
|
|
const response = await fetchWithFallback('http://test-server/mcp', {
|
|
method: 'GET',
|
|
headers: {
|
|
Accept: 'application/json, text/event-stream; charset=utf-8',
|
|
},
|
|
});
|
|
|
|
expect(response.status).toBe(405);
|
|
});
|
|
|
|
it('does not hide Streamable HTTP GET SSE server errors', async () => {
|
|
const fetchFn = vi
|
|
.fn<typeof fetch>()
|
|
.mockResolvedValue(new Response('server exploded', { status: 502 }));
|
|
const fetchWithFallback = createStreamableHttpCompatibilityFetch(
|
|
'server-error',
|
|
fetchFn,
|
|
);
|
|
|
|
const response = await fetchWithFallback('http://test-server/mcp', {
|
|
method: 'GET',
|
|
headers: { Accept: 'text/event-stream' },
|
|
});
|
|
|
|
expect(response.status).toBe(502);
|
|
});
|
|
|
|
it('does not rewrite the SDK-native GET SSE unsupported sentinel', async () => {
|
|
const fetchFn = vi
|
|
.fn<typeof fetch>()
|
|
.mockResolvedValue(
|
|
new Response('method not allowed', { status: 405 }),
|
|
);
|
|
const fetchWithFallback = createStreamableHttpCompatibilityFetch(
|
|
'native-unsupported',
|
|
fetchFn,
|
|
);
|
|
|
|
const response = await fetchWithFallback('http://test-server/mcp', {
|
|
method: 'GET',
|
|
headers: { Accept: 'text/event-stream' },
|
|
});
|
|
|
|
expect(response.status).toBe(405);
|
|
expect(await response.text()).toBe('method not allowed');
|
|
});
|
|
|
|
it('does not rewrite resumable GET SSE errors with Last-Event-ID', async () => {
|
|
const fetchFn = vi
|
|
.fn<typeof fetch>()
|
|
.mockResolvedValue(
|
|
new Response('{"error":"invalid cursor"}', { status: 400 }),
|
|
);
|
|
const fetchWithFallback = createStreamableHttpCompatibilityFetch(
|
|
'resume-error',
|
|
fetchFn,
|
|
);
|
|
|
|
const response = await fetchWithFallback('http://test-server/mcp', {
|
|
method: 'GET',
|
|
headers: {
|
|
Accept: 'text/event-stream',
|
|
'Last-Event-ID': 'event-123',
|
|
},
|
|
});
|
|
|
|
expect(response.status).toBe(400);
|
|
expect(await response.text()).toBe('{"error":"invalid cursor"}');
|
|
});
|
|
|
|
it('does not rewrite non-SSE GET responses', async () => {
|
|
const fetchFn = vi
|
|
.fn<typeof fetch>()
|
|
.mockResolvedValue(new Response('bad request', { status: 400 }));
|
|
const fetchWithFallback = createStreamableHttpCompatibilityFetch(
|
|
'plain-get',
|
|
fetchFn,
|
|
);
|
|
|
|
const response = await fetchWithFallback('http://test-server/mcp', {
|
|
method: 'GET',
|
|
headers: { Accept: 'application/json' },
|
|
});
|
|
|
|
expect(response.status).toBe(400);
|
|
});
|
|
|
|
it('does not rewrite POST responses', async () => {
|
|
const fetchFn = vi
|
|
.fn<typeof fetch>()
|
|
.mockResolvedValue(new Response('bad request', { status: 400 }));
|
|
const fetchWithFallback = createStreamableHttpCompatibilityFetch(
|
|
'post-test',
|
|
fetchFn,
|
|
);
|
|
|
|
const response = await fetchWithFallback('http://test-server/mcp', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
|
|
expect(response.status).toBe(400);
|
|
});
|
|
});
|
|
|
|
describe('should connect via url', () => {
|
|
it('without headers', async () => {
|
|
const transport = await createTransport(
|
|
'test-server',
|
|
{
|
|
url: 'http://test-server',
|
|
},
|
|
false,
|
|
);
|
|
expect(transport).toBeInstanceOf(SSEClientTransport);
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
expect((transport as any)._url).toEqual(new URL('http://test-server'));
|
|
});
|
|
|
|
it('with headers', async () => {
|
|
const transport = await createTransport(
|
|
'test-server',
|
|
{
|
|
url: 'http://test-server',
|
|
headers: { Authorization: 'derp' },
|
|
},
|
|
false,
|
|
);
|
|
|
|
expect(transport).toBeInstanceOf(SSEClientTransport);
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
expect((transport as any)._url).toEqual(new URL('http://test-server'));
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
expect((transport as any)._requestInit?.headers).toEqual({
|
|
Authorization: 'derp',
|
|
});
|
|
});
|
|
});
|
|
|
|
it('should connect via command', async () => {
|
|
const mockedTransport = vi
|
|
.spyOn(SdkClientStdioLib, 'StdioClientTransport')
|
|
.mockReturnValue({} as SdkClientStdioLib.StdioClientTransport);
|
|
|
|
await createTransport(
|
|
'test-server',
|
|
{
|
|
command: 'test-command',
|
|
args: ['--foo', 'bar'],
|
|
env: { FOO: 'bar' },
|
|
cwd: 'test/cwd',
|
|
},
|
|
false,
|
|
);
|
|
|
|
expect(mockedTransport).toHaveBeenCalledWith({
|
|
command: 'test-command',
|
|
args: ['--foo', 'bar'],
|
|
cwd: 'test/cwd',
|
|
// Use objectContaining because normalizePathEnvForWindows deduplicates
|
|
// PATH entries on Windows, so the env won't be an exact spread match.
|
|
env: expect.objectContaining({ FOO: 'bar' }),
|
|
stderr: 'pipe',
|
|
});
|
|
});
|
|
|
|
it('should normalize PATH-like env keys on Windows for stdio transport', async () => {
|
|
vi.spyOn(process, 'platform', 'get').mockReturnValue('win32');
|
|
process.env = {
|
|
...ORIGINAL_ENV,
|
|
PATH: 'C:\\Windows\\System32;C:\\Shared\\Tools',
|
|
Path: 'C:\\Users\\tester\\bin;C:\\Shared\\Tools',
|
|
};
|
|
const mockedTransport = vi
|
|
.spyOn(SdkClientStdioLib, 'StdioClientTransport')
|
|
.mockReturnValue({} as SdkClientStdioLib.StdioClientTransport);
|
|
|
|
await createTransport(
|
|
'test-server',
|
|
{
|
|
command: 'test-command',
|
|
env: { FOO: 'bar' },
|
|
},
|
|
false,
|
|
);
|
|
|
|
expect(mockedTransport).toHaveBeenCalledWith({
|
|
command: 'test-command',
|
|
args: [],
|
|
cwd: undefined,
|
|
env: expect.objectContaining({
|
|
PATH: 'C:\\Windows\\System32;C:\\Shared\\Tools;C:\\Users\\tester\\bin',
|
|
FOO: 'bar',
|
|
}),
|
|
stderr: 'pipe',
|
|
});
|
|
const transportOptions = mockedTransport.mock.calls[0]?.[0];
|
|
expect(transportOptions?.env?.['Path']).toBeUndefined();
|
|
});
|
|
|
|
it('should let server config PATH override parent PATH on Windows', async () => {
|
|
vi.spyOn(process, 'platform', 'get').mockReturnValue('win32');
|
|
process.env = {
|
|
...ORIGINAL_ENV,
|
|
PATH: 'C:\\Windows\\System32;C:\\Shared\\Tools',
|
|
Path: 'C:\\Users\\tester\\bin;C:\\Shared\\Tools',
|
|
};
|
|
const mockedTransport = vi
|
|
.spyOn(SdkClientStdioLib, 'StdioClientTransport')
|
|
.mockReturnValue({} as SdkClientStdioLib.StdioClientTransport);
|
|
|
|
await createTransport(
|
|
'test-server',
|
|
{
|
|
command: 'test-command',
|
|
env: { PATH: 'C:\\ServerToolchain\\bin' },
|
|
},
|
|
false,
|
|
);
|
|
|
|
const transportOptions = mockedTransport.mock.calls[0]?.[0];
|
|
// Server-provided PATH should fully replace the parent PATH, not merge
|
|
expect(transportOptions?.env?.['PATH']).toBe('C:\\ServerToolchain\\bin');
|
|
expect(transportOptions?.env?.['Path']).toBeUndefined();
|
|
});
|
|
|
|
it('should connect via command without cwd', async () => {
|
|
const mockedTransport = vi
|
|
.spyOn(SdkClientStdioLib, 'StdioClientTransport')
|
|
.mockReturnValue({} as SdkClientStdioLib.StdioClientTransport);
|
|
|
|
await createTransport(
|
|
'test-server',
|
|
{
|
|
command: 'test-command',
|
|
args: ['--foo', 'bar'],
|
|
},
|
|
false,
|
|
);
|
|
|
|
expect(mockedTransport).toHaveBeenCalledWith({
|
|
command: 'test-command',
|
|
args: ['--foo', 'bar'],
|
|
cwd: undefined,
|
|
env: expect.any(Object),
|
|
stderr: 'pipe',
|
|
});
|
|
});
|
|
|
|
it('should throw if cwd does not exist', async () => {
|
|
mockExistsSync.mockReturnValueOnce(false);
|
|
|
|
await expect(
|
|
createTransport(
|
|
'test-server',
|
|
{
|
|
command: 'test-command',
|
|
cwd: '/nonexistent/path',
|
|
},
|
|
false,
|
|
),
|
|
).rejects.toThrow(
|
|
"MCP server 'test-server': configured cwd does not exist: /nonexistent/path",
|
|
);
|
|
});
|
|
|
|
describe('useGoogleCredentialProvider', () => {
|
|
it('should use GoogleCredentialProvider when specified', async () => {
|
|
const transport = await createTransport(
|
|
'test-server',
|
|
{
|
|
httpUrl: 'http://test.googleapis.com',
|
|
authProviderType: AuthProviderType.GOOGLE_CREDENTIALS,
|
|
oauth: {
|
|
scopes: ['scope1'],
|
|
},
|
|
},
|
|
false,
|
|
);
|
|
|
|
expect(transport).toBeInstanceOf(StreamableHTTPClientTransport);
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const authProvider = (transport as any)._authProvider;
|
|
expect(authProvider).toBeInstanceOf(GoogleCredentialProvider);
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
expect((transport as any)._fetch).toEqual(expect.any(Function));
|
|
});
|
|
|
|
it('should use GoogleCredentialProvider with SSE transport', async () => {
|
|
const transport = await createTransport(
|
|
'test-server',
|
|
{
|
|
url: 'http://test.googleapis.com',
|
|
authProviderType: AuthProviderType.GOOGLE_CREDENTIALS,
|
|
oauth: {
|
|
scopes: ['scope1'],
|
|
},
|
|
},
|
|
false,
|
|
);
|
|
|
|
expect(transport).toBeInstanceOf(SSEClientTransport);
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const authProvider = (transport as any)._authProvider;
|
|
expect(authProvider).toBeInstanceOf(GoogleCredentialProvider);
|
|
});
|
|
|
|
it('should throw an error if no URL is provided with GoogleCredentialProvider', async () => {
|
|
await expect(
|
|
createTransport(
|
|
'test-server',
|
|
{
|
|
authProviderType: AuthProviderType.GOOGLE_CREDENTIALS,
|
|
oauth: {
|
|
scopes: ['scope1'],
|
|
},
|
|
},
|
|
false,
|
|
),
|
|
).rejects.toThrow(
|
|
'URL must be provided in the config for Google Credentials provider',
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('authenticated Streamable HTTP compatibility fetch', () => {
|
|
afterEach(() => {
|
|
vi.mocked(MCPOAuthProvider).mockReset();
|
|
vi.mocked(MCPOAuthTokenStorage).mockReset();
|
|
});
|
|
|
|
it('throws the /mcp instruction when OAuth has no valid token', async () => {
|
|
const getValidToken = vi.fn().mockResolvedValue(null);
|
|
vi.mocked(MCPOAuthProvider).mockImplementation(
|
|
() =>
|
|
({
|
|
getValidToken,
|
|
}) as unknown as MCPOAuthProvider,
|
|
);
|
|
|
|
await expect(
|
|
createTransport(
|
|
'oauth-test-server',
|
|
{
|
|
httpUrl: 'http://test-server',
|
|
oauth: {
|
|
enabled: true,
|
|
clientId: 'client-id',
|
|
},
|
|
},
|
|
false,
|
|
),
|
|
).rejects.toThrow(
|
|
getMcpOAuthDialogInstruction('authenticate', 'oauth-test-server'),
|
|
);
|
|
expect(getValidToken).toHaveBeenCalledWith('oauth-test-server', {
|
|
enabled: true,
|
|
clientId: 'client-id',
|
|
});
|
|
});
|
|
|
|
it('warns when stored OAuth credentials cannot produce a token', async () => {
|
|
const getCredentials = vi.fn().mockResolvedValue({
|
|
clientId: 'client-id',
|
|
});
|
|
const getValidToken = vi.fn().mockResolvedValue(null);
|
|
vi.mocked(MCPOAuthTokenStorage).mockImplementation(
|
|
() =>
|
|
({
|
|
getCredentials,
|
|
}) as unknown as MCPOAuthTokenStorage,
|
|
);
|
|
vi.mocked(MCPOAuthProvider).mockImplementation(
|
|
() =>
|
|
({
|
|
getValidToken,
|
|
}) as unknown as MCPOAuthProvider,
|
|
);
|
|
|
|
const transport = await createTransport(
|
|
'oauth-test-server',
|
|
{
|
|
httpUrl: 'http://test-server',
|
|
},
|
|
false,
|
|
);
|
|
|
|
expect(transport).toBeInstanceOf(StreamableHTTPClientTransport);
|
|
expect(getCredentials).toHaveBeenCalledWith('oauth-test-server');
|
|
expect(getValidToken).toHaveBeenCalledWith('oauth-test-server', {
|
|
clientId: 'client-id',
|
|
});
|
|
expect(mockDebugLogger.warn).toHaveBeenCalledWith(
|
|
"Stored OAuth credentials exist for server 'oauth-test-server' but no valid token could be obtained. Transport will be created without authentication; expect a 401. " +
|
|
getMcpOAuthDialogInstruction(
|
|
're-authenticate',
|
|
'oauth-test-server',
|
|
),
|
|
);
|
|
});
|
|
|
|
it('wires the compatibility fetch for OAuth httpUrl transports', async () => {
|
|
const getValidToken = vi.fn().mockResolvedValue('oauth-token');
|
|
vi.mocked(MCPOAuthProvider).mockImplementation(
|
|
() =>
|
|
({
|
|
getValidToken,
|
|
}) as unknown as MCPOAuthProvider,
|
|
);
|
|
|
|
const transport = await createTransport(
|
|
'oauth-test-server',
|
|
{
|
|
httpUrl: 'http://test-server',
|
|
oauth: {
|
|
enabled: true,
|
|
clientId: 'client-id',
|
|
},
|
|
},
|
|
false,
|
|
);
|
|
|
|
expect(transport).toBeInstanceOf(StreamableHTTPClientTransport);
|
|
expect(getValidToken).toHaveBeenCalledWith('oauth-test-server', {
|
|
enabled: true,
|
|
clientId: 'client-id',
|
|
});
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
expect((transport as any)._fetch).toEqual(expect.any(Function));
|
|
});
|
|
|
|
it('wires the compatibility fetch for service account httpUrl transports', async () => {
|
|
const transport = await createTransport(
|
|
'service-account-test-server',
|
|
{
|
|
httpUrl: 'http://test-server',
|
|
authProviderType: AuthProviderType.SERVICE_ACCOUNT_IMPERSONATION,
|
|
targetAudience: 'client.apps.googleusercontent.com',
|
|
targetServiceAccount:
|
|
'service-account@example-project.iam.gserviceaccount.com',
|
|
},
|
|
false,
|
|
);
|
|
|
|
expect(transport).toBeInstanceOf(StreamableHTTPClientTransport);
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
expect((transport as any)._fetch).toEqual(expect.any(Function));
|
|
});
|
|
});
|
|
});
|
|
describe('isEnabled', () => {
|
|
const funcDecl = { name: 'myTool' };
|
|
const serverName = 'myServer';
|
|
|
|
it('should return true if no include or exclude lists are provided', () => {
|
|
const mcpServerConfig = {};
|
|
expect(isEnabled(funcDecl, serverName, mcpServerConfig)).toBe(true);
|
|
});
|
|
|
|
it('should return false if the tool is in the exclude list', () => {
|
|
const mcpServerConfig = { excludeTools: ['myTool'] };
|
|
expect(isEnabled(funcDecl, serverName, mcpServerConfig)).toBe(false);
|
|
});
|
|
|
|
it('should return true if the tool is in the include list', () => {
|
|
const mcpServerConfig = { includeTools: ['myTool'] };
|
|
expect(isEnabled(funcDecl, serverName, mcpServerConfig)).toBe(true);
|
|
});
|
|
|
|
it('should return true if the tool is in the include list with parentheses', () => {
|
|
const mcpServerConfig = { includeTools: ['myTool()'] };
|
|
expect(isEnabled(funcDecl, serverName, mcpServerConfig)).toBe(true);
|
|
});
|
|
|
|
it('should return false if the include list exists but does not contain the tool', () => {
|
|
const mcpServerConfig = { includeTools: ['anotherTool'] };
|
|
expect(isEnabled(funcDecl, serverName, mcpServerConfig)).toBe(false);
|
|
});
|
|
|
|
it('should return false if the tool is in both the include and exclude lists', () => {
|
|
const mcpServerConfig = {
|
|
includeTools: ['myTool'],
|
|
excludeTools: ['myTool'],
|
|
};
|
|
expect(isEnabled(funcDecl, serverName, mcpServerConfig)).toBe(false);
|
|
});
|
|
|
|
it('should return false if the function declaration has no name', () => {
|
|
const namelessFuncDecl = {};
|
|
const mcpServerConfig = {};
|
|
expect(isEnabled(namelessFuncDecl, serverName, mcpServerConfig)).toBe(
|
|
false,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('removeMCPServerStatus', () => {
|
|
afterEach(() => {
|
|
// Clean up any state left in the module-level registry between tests.
|
|
for (const name of getAllMCPServerStatuses().keys()) {
|
|
removeMCPServerStatus(name);
|
|
}
|
|
});
|
|
|
|
it('removes the entry from the global status map', () => {
|
|
updateMCPServerStatus('srv-a', MCPServerStatus.DISCONNECTED);
|
|
expect(getAllMCPServerStatuses().has('srv-a')).toBe(true);
|
|
|
|
removeMCPServerStatus('srv-a');
|
|
|
|
expect(getAllMCPServerStatuses().has('srv-a')).toBe(false);
|
|
// getMCPServerStatus falls back to DISCONNECTED for unknown servers,
|
|
// but the snapshot map should no longer include the entry.
|
|
expect(getMCPServerStatus('srv-a')).toBe(MCPServerStatus.DISCONNECTED);
|
|
});
|
|
|
|
it('notifies listeners with undefined to signal removal', () => {
|
|
const events: Array<[string, MCPServerStatus | undefined]> = [];
|
|
const listener = (name: string, status: MCPServerStatus | undefined) => {
|
|
events.push([name, status]);
|
|
};
|
|
addMCPStatusChangeListener(listener);
|
|
|
|
updateMCPServerStatus('srv-b', MCPServerStatus.CONNECTED);
|
|
removeMCPServerStatus('srv-b');
|
|
|
|
removeMCPStatusChangeListener(listener);
|
|
|
|
expect(events).toEqual([
|
|
['srv-b', MCPServerStatus.CONNECTED],
|
|
['srv-b', undefined],
|
|
]);
|
|
});
|
|
|
|
it('is a no-op (no listener fired) when the server is not tracked', () => {
|
|
const listener = vi.fn();
|
|
addMCPStatusChangeListener(listener);
|
|
|
|
removeMCPServerStatus('never-registered');
|
|
|
|
removeMCPStatusChangeListener(listener);
|
|
expect(listener).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('a stale status update from an in-flight connect cannot resurrect a removed server', async () => {
|
|
// Race scenario from PR review: `disableMcpServer` removes the entry,
|
|
// but `McpClient.connect()`'s catch block could still fire afterwards
|
|
// and call `updateStatus(DISCONNECTED)`. The `isDisconnecting` guard
|
|
// inside `McpClient.updateStatus` must prevent that resurrection.
|
|
const mockedClient = {
|
|
connect: vi.fn().mockRejectedValue(new Error('connect failed')),
|
|
registerCapabilities: vi.fn(),
|
|
setRequestHandler: vi.fn(),
|
|
close: vi.fn(),
|
|
getInstructions: vi.fn(),
|
|
};
|
|
vi.mocked(ClientLib.Client).mockReturnValue(
|
|
mockedClient as unknown as ClientLib.Client,
|
|
);
|
|
vi.spyOn(SdkClientStdioLib, 'StdioClientTransport').mockReturnValue({
|
|
close: vi.fn(),
|
|
} as unknown as SdkClientStdioLib.StdioClientTransport);
|
|
|
|
const client = new McpClient(
|
|
'racy-server',
|
|
{ command: 'test-command' },
|
|
{} as ToolRegistry,
|
|
{} as PromptRegistry,
|
|
{} as WorkspaceContext,
|
|
false,
|
|
);
|
|
|
|
// Kick off connect() but don't await it; it will reject and run its
|
|
// catch block which calls updateStatus(DISCONNECTED).
|
|
const connectPromise = client.connect();
|
|
|
|
// Simulate the disable path running before connect's catch fires.
|
|
await client.disconnect();
|
|
removeMCPServerStatus('racy-server');
|
|
|
|
// Now let the rejected connect propagate.
|
|
await expect(connectPromise).rejects.toThrow('connect failed');
|
|
|
|
// The entry must remain absent — no resurrection.
|
|
expect(getAllMCPServerStatuses().has('racy-server')).toBe(false);
|
|
});
|
|
|
|
it('disconnect() propagates DISCONNECTED to the global registry', async () => {
|
|
// Regression: a previous version set `isDisconnecting = true` BEFORE
|
|
// calling `updateStatus(DISCONNECTED)`, and `updateStatus`'s guard
|
|
// (designed to block stale `connect()` catch updates) silently
|
|
// swallowed the write. The global registry stayed CONNECTED forever,
|
|
// so `Config.getFailedMcpServerNames()` (which filters
|
|
// `status !== CONNECTED`) omitted timeout-disconnected servers from
|
|
// the non-interactive failure banner and the Footer's MCP health
|
|
// pill kept counting them as healthy.
|
|
const mockedClient = {
|
|
connect: vi.fn(),
|
|
registerCapabilities: vi.fn(),
|
|
setRequestHandler: vi.fn(),
|
|
close: vi.fn(),
|
|
getInstructions: vi.fn(),
|
|
};
|
|
vi.mocked(ClientLib.Client).mockReturnValue(
|
|
mockedClient as unknown as ClientLib.Client,
|
|
);
|
|
const mockedTransport = { close: vi.fn().mockResolvedValue(undefined) };
|
|
vi.spyOn(SdkClientStdioLib, 'StdioClientTransport').mockReturnValue(
|
|
mockedTransport as unknown as SdkClientStdioLib.StdioClientTransport,
|
|
);
|
|
|
|
const client = new McpClient(
|
|
'healthy-server',
|
|
{ command: 'test-command' },
|
|
{} as ToolRegistry,
|
|
{} as PromptRegistry,
|
|
{ getDirectories: () => [] } as unknown as WorkspaceContext,
|
|
false,
|
|
);
|
|
|
|
await client.connect();
|
|
// After connect, the registry should show CONNECTED.
|
|
expect(getMCPServerStatus('healthy-server')).toBe(
|
|
MCPServerStatus.CONNECTED,
|
|
);
|
|
|
|
await client.disconnect();
|
|
// After an intentional disconnect, the global registry MUST reflect
|
|
// DISCONNECTED — otherwise downstream code (failure banner, health
|
|
// pill) treats the server as still healthy.
|
|
expect(getMCPServerStatus('healthy-server')).toBe(
|
|
MCPServerStatus.DISCONNECTED,
|
|
);
|
|
|
|
// Cleanup the registry entry so this test doesn't leak.
|
|
removeMCPServerStatus('healthy-server');
|
|
});
|
|
});
|
|
|
|
describe('hasNetworkTransport', () => {
|
|
it('should return true if only url is provided', () => {
|
|
const config = { url: 'http://example.com' };
|
|
expect(hasNetworkTransport(config)).toBe(true);
|
|
});
|
|
|
|
it('should return true if only httpUrl is provided', () => {
|
|
const config = { httpUrl: 'http://example.com' };
|
|
expect(hasNetworkTransport(config)).toBe(true);
|
|
});
|
|
|
|
it('should return true if both url and httpUrl are provided', () => {
|
|
const config = {
|
|
url: 'http://example.com/sse',
|
|
httpUrl: 'http://example.com/http',
|
|
};
|
|
expect(hasNetworkTransport(config)).toBe(true);
|
|
});
|
|
|
|
it('should return false if neither url nor httpUrl is provided', () => {
|
|
const config = { command: 'do-something' };
|
|
expect(hasNetworkTransport(config)).toBe(false);
|
|
});
|
|
|
|
it('should return false for an empty config object', () => {
|
|
const config = {};
|
|
expect(hasNetworkTransport(config)).toBe(false);
|
|
});
|
|
});
|
|
});
|