Files
wehub-resource-sync 0d3cb498a3
CI / Shell Format Check (push) Has been cancelled
CI / Check Ruby (3.4) (push) Has been cancelled
CI / CI Config (push) Has been cancelled
CI / Test on Node ${{ matrix.node }} and ${{ matrix.os }}${{ matrix.shard && format(' (shard {0}/3)', matrix.shard) || '' }} (push) Has been cancelled
CI / Build on Node ${{ matrix.node }} (push) Has been cancelled
CI / Style Check (push) Has been cancelled
CI / Generate Assets (push) Has been cancelled
CI / Check Python (3.14) (push) Has been cancelled
CI / Check Python (3.9) (push) Has been cancelled
CI / Build Docs (push) Has been cancelled
CI / Code Scan Action (push) Has been cancelled
CI / Site tests (push) Has been cancelled
CI / webui tests (push) Has been cancelled
CI / Run Integration Tests (push) Has been cancelled
CI / Run Smoke Tests (push) Has been cancelled
CI / Go Tests (push) Has been cancelled
CI / Share Test (push) Has been cancelled
CI / Redteam (Production API) (push) Has been cancelled
CI / Redteam (Staging API) (push) Has been cancelled
CI / GitHub Actions Lint (push) Has been cancelled
CI / Check Ruby (3.0) (push) Has been cancelled
release-please / release-please (push) Has been cancelled
release-please / build (push) Has been cancelled
release-please / publish-npm (push) Has been cancelled
release-please / publish-npm-backfill (push) Has been cancelled
release-please / docker (push) Has been cancelled
release-please / publish-code-scan-action (push) Has been cancelled
release-please / attest-code-scan-action (push) Has been cancelled
Deploy local.promptfoo.app / Deploy to Cloudflare Pages (push) Has been cancelled
Test and Publish Multi-arch Docker Image / test (push) Has been cancelled
Test and Publish Multi-arch Docker Image / build-docker-and-push-digests (map[digest-suffix:linux-amd64 platform:linux/amd64 runner:ubuntu-latest]) (push) Has been cancelled
Test and Publish Multi-arch Docker Image / build-docker-and-push-digests (map[digest-suffix:linux-arm64 platform:linux/arm64 runner:ubuntu-24.04-arm]) (push) Has been cancelled
Test and Publish Multi-arch Docker Image / merge-docker-digests (push) Has been cancelled
Test and Publish Multi-arch Docker Image / Attest Multi-arch Image (push) Has been cancelled
Validate Renovate Config / Validate Renovate Configuration (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:24:08 +08:00

1722 lines
53 KiB
TypeScript

import path from 'path';
import { beforeEach, describe, expect, it, vi } from 'vitest';
const mockGetEnvInt = vi.hoisted(() => vi.fn().mockReturnValue(undefined));
vi.mock('../../../src/envars', async () => ({
...(await vi.importActual<typeof import('../../../src/envars')>('../../../src/envars')),
getEnvInt: (...args: unknown[]) => mockGetEnvInt(...args),
}));
const mockGetOAuthTokenWithExpiry = vi.hoisted(() =>
vi.fn().mockResolvedValue({
accessToken: 'mock-oauth-token',
expiresAt: Date.now() + 3600000, // 1 hour from now
}),
);
vi.mock('../../../src/providers/mcp/util', async () => ({
...(await vi.importActual<typeof import('../../../src/providers/mcp/util')>(
'../../../src/providers/mcp/util',
)),
getOAuthTokenWithExpiry: (...args: unknown[]) => mockGetOAuthTokenWithExpiry(...args),
}));
const mcpMocks = vi.hoisted(() => {
const mockClient = {
_clientInfo: {},
_capabilities: {},
registerCapabilities: vi.fn(),
assertCapability: vi.fn(),
connect: vi.fn(),
ping: vi.fn().mockResolvedValue({}),
listTools: vi.fn().mockResolvedValue({
tools: [{ name: 'tool1', description: 'desc1', inputSchema: {} }],
}),
callTool: vi.fn(),
close: vi.fn().mockResolvedValue(undefined),
};
const mockStdioTransport = {
close: vi.fn().mockResolvedValue(undefined),
connect: vi.fn(),
start: vi.fn(),
send: vi.fn(),
};
const mockStreamableHTTPTransport = {
close: vi.fn().mockResolvedValue(undefined),
connect: vi.fn(),
start: vi.fn(),
send: vi.fn(),
};
const mockSSETransport = {
close: vi.fn().mockResolvedValue(undefined),
connect: vi.fn(),
start: vi.fn(),
send: vi.fn(),
};
const MockClient = vi.fn(function MockClient() {
return mockClient;
});
const MockStdioTransport = vi.fn(function MockStdioTransport() {
return mockStdioTransport;
});
const MockStreamableHTTPTransport = vi.fn(function MockStreamableHTTPTransport() {
return mockStreamableHTTPTransport;
});
const MockSSETransport = vi.fn(function MockSSETransport() {
return mockSSETransport;
});
return {
mockClient,
mockStdioTransport,
mockStreamableHTTPTransport,
mockSSETransport,
MockClient,
MockSSETransport,
MockStdioTransport,
MockStreamableHTTPTransport,
};
});
const { mockClient, mockStdioTransport, mockStreamableHTTPTransport } = mcpMocks;
// Mock the modules before importing them
vi.mock('@modelcontextprotocol/sdk/client/index.js', async (importOriginal) => {
return {
...(await importOriginal()),
Client: mcpMocks.MockClient,
};
});
vi.mock('@modelcontextprotocol/sdk/client/stdio.js', async (importOriginal) => {
return {
...(await importOriginal()),
StdioClientTransport: mcpMocks.MockStdioTransport,
};
});
vi.mock('@modelcontextprotocol/sdk/client/streamableHttp.js', async (importOriginal) => {
return {
...(await importOriginal()),
StreamableHTTPClientTransport: mcpMocks.MockStreamableHTTPTransport,
};
});
vi.mock('@modelcontextprotocol/sdk/client/sse.js', async (importOriginal) => {
return {
...(await importOriginal()),
SSEClientTransport: mcpMocks.MockSSETransport,
};
});
// Import the mocked modules after mocking
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
import cliState from '../../../src/cliState';
import { MCPClient } from '../../../src/providers/mcp/client';
import { createDeferred } from '../../util/utils';
function createMockClient(callTool = vi.fn()) {
return {
_clientInfo: {},
_capabilities: {},
registerCapabilities: vi.fn(),
assertCapability: vi.fn(),
connect: vi.fn().mockResolvedValue(undefined),
ping: vi.fn().mockResolvedValue({}),
listTools: vi.fn().mockResolvedValue({
tools: [{ name: 'tool1', description: 'desc1', inputSchema: {} }],
}),
callTool,
close: vi.fn().mockResolvedValue(undefined),
};
}
describe('MCPClient', () => {
let mcpClient: MCPClient;
beforeEach(() => {
vi.clearAllMocks();
mockGetEnvInt.mockReset();
mockGetEnvInt.mockReturnValue(undefined);
// Reset the OAuth token mock to return a valid token by default
mockGetOAuthTokenWithExpiry.mockReset();
mockGetOAuthTokenWithExpiry.mockResolvedValue({
accessToken: 'mock-oauth-token',
expiresAt: Date.now() + 3600000, // 1 hour from now
});
cliState.basePath = undefined;
});
describe('initialize', () => {
it('should not initialize if disabled', async () => {
mcpClient = new MCPClient({ enabled: false });
await mcpClient.initialize();
expect(mcpClient.hasInitialized).toBe(false);
});
it('should initialize with single server config', async () => {
// Reset mocks for this test
mockClient.connect.mockResolvedValueOnce(undefined);
mockClient.listTools.mockResolvedValueOnce({
tools: [{ name: 'tool1', description: 'desc1', inputSchema: {} }],
});
mcpClient = new MCPClient({
enabled: true,
server: {
command: 'npm',
args: ['start'],
},
});
await mcpClient.initialize();
expect(StdioClientTransport).toHaveBeenCalledWith({
command: 'npm',
args: ['start'],
env: process.env as Record<string, string>,
});
expect(mockClient.connect).toHaveBeenCalledWith(mockStdioTransport, undefined);
await mcpClient.cleanup();
expect(mcpClient.hasInitialized).toBe(false);
});
it('should initialize with multiple servers', async () => {
// Reset mocks for this test
mockClient.connect.mockResolvedValue(undefined);
mockClient.listTools.mockResolvedValue({
tools: [{ name: 'tool1', description: 'desc1', inputSchema: {} }],
});
mcpClient = new MCPClient({
enabled: true,
servers: [
{
name: 'server1',
command: 'npm',
args: ['start'],
},
{
name: 'server2',
path: 'script.js',
},
],
});
await mcpClient.initialize();
expect(StdioClientTransport).toHaveBeenCalledTimes(2);
expect(mockClient.connect).toHaveBeenCalledTimes(2);
});
it('should resolve local server paths relative to the config base path', async () => {
mockClient.connect.mockResolvedValueOnce(undefined);
mockClient.listTools.mockResolvedValueOnce({
tools: [{ name: 'tool1', description: 'desc1', inputSchema: {} }],
});
cliState.basePath = '/tmp/simple-mcp';
mcpClient = new MCPClient({
enabled: true,
server: {
path: './example-server.js',
},
});
await mcpClient.initialize();
expect(StdioClientTransport).toHaveBeenCalledWith({
command: process.execPath,
args: [path.resolve(cliState.basePath, './example-server.js')],
env: process.env as Record<string, string>,
});
});
it('should throw error for unsupported file type', async () => {
mcpClient = new MCPClient({
enabled: true,
server: {
path: 'script.txt',
},
});
await expect(mcpClient.initialize()).rejects.toThrow(
'Local server must be a .js or .py file',
);
});
it('should initialize with remote server using StreamableHTTPClientTransport', async () => {
// Reset mocks for this test
mockClient.connect.mockResolvedValueOnce(undefined);
mockClient.listTools.mockResolvedValueOnce({
tools: [{ name: 'tool1', description: 'desc1', inputSchema: {} }],
});
mcpClient = new MCPClient({
enabled: true,
server: {
url: 'http://localhost:3000',
},
});
await mcpClient.initialize();
expect(StreamableHTTPClientTransport).toHaveBeenCalledWith(expect.any(URL), undefined);
expect(mockClient.connect).toHaveBeenCalledWith(mockStreamableHTTPTransport, undefined);
});
it('should initialize with remote server using StreamableHTTPClientTransport with headers', async () => {
// Reset mocks for this test
mockClient.connect.mockResolvedValueOnce(undefined);
mockClient.listTools.mockResolvedValueOnce({
tools: [{ name: 'tool1', description: 'desc1', inputSchema: {} }],
});
const customHeaders = {
'X-Custom-Header': 'custom-value',
Authorization: 'Bearer test-token',
};
mcpClient = new MCPClient({
enabled: true,
server: {
url: 'http://localhost:3000',
headers: customHeaders,
},
});
await mcpClient.initialize();
expect(StreamableHTTPClientTransport).toHaveBeenCalledWith(
expect.any(URL),
expect.objectContaining({
requestInit: expect.objectContaining({
headers: expect.objectContaining(customHeaders),
}),
}),
);
expect(mockClient.connect).toHaveBeenCalledWith(mockStreamableHTTPTransport, undefined);
});
it('should fall back to SSEClientTransport if StreamableHTTPClientTransport fails', async () => {
// Reset mocks for this test
mockClient.connect
.mockImplementationOnce(function () {
throw new Error('Connection failed');
})
.mockResolvedValueOnce(undefined);
mockClient.listTools.mockResolvedValueOnce({
tools: [{ name: 'tool1', description: 'desc1', inputSchema: {} }],
});
mcpClient = new MCPClient({
enabled: true,
server: {
url: 'http://localhost:3000',
},
});
await mcpClient.initialize();
expect(StreamableHTTPClientTransport).toHaveBeenCalledWith(expect.any(URL), undefined);
expect(SSEClientTransport).toHaveBeenCalledWith(expect.any(URL), undefined);
expect(mockClient.connect).toHaveBeenCalledTimes(2);
});
it('should fall back to SSEClientTransport with headers if StreamableHTTPClientTransport fails', async () => {
// Reset mocks for this test
mockClient.connect
.mockImplementationOnce(function () {
throw new Error('Connection failed');
})
.mockResolvedValueOnce(undefined);
mockClient.listTools.mockResolvedValueOnce({
tools: [{ name: 'tool1', description: 'desc1', inputSchema: {} }],
});
const customHeaders = {
'X-Custom-Header': 'custom-value',
Authorization: 'Bearer test-token',
};
mcpClient = new MCPClient({
enabled: true,
server: {
url: 'http://localhost:3000',
headers: customHeaders,
},
});
await mcpClient.initialize();
expect(StreamableHTTPClientTransport).toHaveBeenCalledWith(
expect.any(URL),
expect.objectContaining({
requestInit: expect.objectContaining({
headers: expect.objectContaining(customHeaders),
}),
}),
);
expect(SSEClientTransport).toHaveBeenCalledWith(
expect.any(URL),
expect.objectContaining({
requestInit: expect.objectContaining({
headers: expect.objectContaining(customHeaders),
}),
}),
);
expect(mockClient.connect).toHaveBeenCalledTimes(2);
});
it('should filter tools according to config.tools', async () => {
// Reset mocks for this test
mockClient.connect.mockResolvedValueOnce(undefined);
mockClient.listTools.mockResolvedValueOnce({
tools: [
{ name: 'tool1', description: 'desc1', inputSchema: {} },
{ name: 'tool2', description: 'desc2', inputSchema: {} },
],
});
mcpClient = new MCPClient({
enabled: true,
server: {
command: 'npm',
args: ['start'],
},
tools: ['tool2'],
});
await mcpClient.initialize();
// getAllTools should return only tool2
expect(mcpClient.getAllTools()).toEqual([
{ name: 'tool2', description: 'desc2', inputSchema: {} },
]);
});
it('should exclude tools according to config.exclude_tools', async () => {
// Reset mocks for this test
mockClient.connect.mockResolvedValueOnce(undefined);
mockClient.listTools.mockResolvedValueOnce({
tools: [
{ name: 'tool1', description: 'desc1', inputSchema: {} },
{ name: 'tool2', description: 'desc2', inputSchema: {} },
],
});
mcpClient = new MCPClient({
enabled: true,
server: {
command: 'npm',
args: ['start'],
},
exclude_tools: ['tool1'],
});
await mcpClient.initialize();
expect(mcpClient.getAllTools()).toEqual([
{ name: 'tool2', description: 'desc2', inputSchema: {} },
]);
});
it('should initialize with correct client metadata including name, version, and description', async () => {
// Reset mocks for this test
mockClient.connect.mockResolvedValueOnce(undefined);
mockClient.listTools.mockResolvedValueOnce({
tools: [{ name: 'tool1', description: 'desc1', inputSchema: {} }],
});
mcpClient = new MCPClient({
enabled: true,
server: {
command: 'npm',
args: ['start'],
},
});
await mcpClient.initialize();
expect(Client).toHaveBeenCalledWith({
name: 'promptfoo-MCP',
version: '1.0.0',
description: 'Promptfoo MCP client for connecting to MCP servers during LLM evaluations',
});
});
it('should provide a descriptive client description for MCP server identification', async () => {
// Reset mocks for this test
mockClient.connect.mockResolvedValueOnce(undefined);
mockClient.listTools.mockResolvedValueOnce({
tools: [],
});
mcpClient = new MCPClient({
enabled: true,
server: {
command: 'npm',
args: ['start'],
},
});
await mcpClient.initialize();
// Verify the description is provided and meaningful
const clientCall = vi.mocked(Client).mock.calls[0][0];
expect(clientCall).toHaveProperty('description');
const description = clientCall.description as string;
expect(typeof description).toBe('string');
expect(description.length).toBeGreaterThan(0);
// Case-insensitive check for key terms
expect(description.toLowerCase()).toContain('promptfoo');
expect(description).toContain('MCP');
});
it('should pass timeout to listTools when configured', async () => {
// Reset mocks for this test
mockClient.connect.mockResolvedValueOnce(undefined);
mockClient.listTools.mockResolvedValueOnce({
tools: [{ name: 'tool1', description: 'desc1', inputSchema: {} }],
});
mcpClient = new MCPClient({
enabled: true,
timeout: 900000, // 15 minutes
server: {
command: 'npm',
args: ['start'],
},
});
await mcpClient.initialize();
expect(mockClient.listTools).toHaveBeenCalledWith(undefined, { timeout: 900000 });
});
it('should pass timeout options to connect()', async () => {
// Reset mocks for this test
mockClient.connect.mockResolvedValueOnce(undefined);
mockClient.listTools.mockResolvedValueOnce({
tools: [{ name: 'tool1', description: 'desc1', inputSchema: {} }],
});
mcpClient = new MCPClient({
enabled: true,
timeout: 300000,
server: {
command: 'npm',
args: ['start'],
},
});
await mcpClient.initialize();
expect(mockClient.connect).toHaveBeenCalledWith(expect.anything(), { timeout: 300000 });
});
it('should ping server when pingOnConnect is true', async () => {
// Reset mocks for this test
mockClient.connect.mockResolvedValueOnce(undefined);
mockClient.ping.mockResolvedValueOnce({});
mockClient.listTools.mockResolvedValueOnce({
tools: [{ name: 'tool1', description: 'desc1', inputSchema: {} }],
});
mcpClient = new MCPClient({
enabled: true,
pingOnConnect: true,
server: {
command: 'npm',
args: ['start'],
},
});
await mcpClient.initialize();
expect(mockClient.ping).toHaveBeenCalled();
});
it('should fail initialization if ping fails', async () => {
// Reset mocks for this test
mockClient.connect.mockResolvedValueOnce(undefined);
mockClient.ping.mockRejectedValueOnce(new Error('Server not responding'));
mcpClient = new MCPClient({
enabled: true,
pingOnConnect: true,
server: {
command: 'npm',
args: ['start'],
},
});
await expect(mcpClient.initialize()).rejects.toThrow('ping failed');
});
it('should pass resetTimeoutOnProgress option', async () => {
// Reset mocks for this test
mockClient.connect.mockResolvedValueOnce(undefined);
mockClient.listTools.mockResolvedValueOnce({
tools: [{ name: 'tool1', description: 'desc1', inputSchema: {} }],
});
mockClient.callTool.mockResolvedValueOnce({ content: 'result' });
mcpClient = new MCPClient({
enabled: true,
timeout: 300000,
resetTimeoutOnProgress: true,
server: {
command: 'npm',
args: ['start'],
},
});
await mcpClient.initialize();
await mcpClient.callTool('tool1', {});
expect(mockClient.callTool).toHaveBeenCalledWith(
{ name: 'tool1', arguments: {} },
undefined,
{ timeout: 300000, resetTimeoutOnProgress: true },
);
});
it('should pass maxTotalTimeout option', async () => {
// Reset mocks for this test
mockClient.connect.mockResolvedValueOnce(undefined);
mockClient.listTools.mockResolvedValueOnce({
tools: [{ name: 'tool1', description: 'desc1', inputSchema: {} }],
});
mockClient.callTool.mockResolvedValueOnce({ content: 'result' });
mcpClient = new MCPClient({
enabled: true,
timeout: 300000,
resetTimeoutOnProgress: true,
maxTotalTimeout: 900000,
server: {
command: 'npm',
args: ['start'],
},
});
await mcpClient.initialize();
await mcpClient.callTool('tool1', {});
expect(mockClient.callTool).toHaveBeenCalledWith(
{ name: 'tool1', arguments: {} },
undefined,
{ timeout: 300000, resetTimeoutOnProgress: true, maxTotalTimeout: 900000 },
);
});
});
describe('callTool', () => {
it('should call tool successfully', async () => {
// Reset mocks for this test
mockClient.connect.mockResolvedValueOnce(undefined);
mockClient.listTools.mockResolvedValueOnce({
tools: [{ name: 'tool1', description: 'desc1', inputSchema: {} }],
});
mockClient.callTool.mockResolvedValueOnce({ content: 'result' });
mcpClient = new MCPClient({
enabled: true,
server: {
command: 'npm',
args: ['start'],
},
});
await mcpClient.initialize();
const result = await mcpClient.callTool('tool1', { arg: 'value' });
expect(result).toEqual({ content: 'result', raw: { content: 'result' } });
expect(mockClient.callTool).toHaveBeenCalledWith(
{
name: 'tool1',
arguments: { arg: 'value' },
},
undefined,
undefined,
);
});
it('should pass timeout option when configured', async () => {
// Reset mocks for this test
mockClient.connect.mockResolvedValueOnce(undefined);
mockClient.listTools.mockResolvedValueOnce({
tools: [{ name: 'tool1', description: 'desc1', inputSchema: {} }],
});
mockClient.callTool.mockResolvedValueOnce({ content: 'result' });
mcpClient = new MCPClient({
enabled: true,
timeout: 900000, // 15 minutes
server: {
command: 'npm',
args: ['start'],
},
});
await mcpClient.initialize();
const result = await mcpClient.callTool('tool1', { arg: 'value' });
expect(result).toEqual({ content: 'result', raw: { content: 'result' } });
expect(mockClient.callTool).toHaveBeenCalledWith(
{
name: 'tool1',
arguments: { arg: 'value' },
},
undefined,
{ timeout: 900000 },
);
});
it('should use MCP_REQUEST_TIMEOUT_MS env var as fallback when no config timeout', async () => {
// Reset mocks for this test
mockClient.connect.mockResolvedValueOnce(undefined);
mockClient.listTools.mockResolvedValueOnce({
tools: [{ name: 'tool1', description: 'desc1', inputSchema: {} }],
});
mockClient.callTool.mockResolvedValueOnce({ content: 'result' });
// Mock env var to return a timeout value
mockGetEnvInt.mockReturnValue(600000); // 10 minutes
mcpClient = new MCPClient({
enabled: true,
// No timeout in config
server: {
command: 'npm',
args: ['start'],
},
});
await mcpClient.initialize();
const result = await mcpClient.callTool('tool1', { arg: 'value' });
expect(result).toEqual({ content: 'result', raw: { content: 'result' } });
expect(mockClient.callTool).toHaveBeenCalledWith(
{
name: 'tool1',
arguments: { arg: 'value' },
},
undefined,
{ timeout: 600000 },
);
// Reset mock
mockGetEnvInt.mockReturnValue(undefined);
});
it('should prefer config timeout over env var', async () => {
// Reset mocks for this test
mockClient.connect.mockResolvedValueOnce(undefined);
mockClient.listTools.mockResolvedValueOnce({
tools: [{ name: 'tool1', description: 'desc1', inputSchema: {} }],
});
mockClient.callTool.mockResolvedValueOnce({ content: 'result' });
// Mock env var to return a different timeout value
mockGetEnvInt.mockReturnValue(600000); // 10 minutes
mcpClient = new MCPClient({
enabled: true,
timeout: 900000, // 15 minutes - should take precedence
server: {
command: 'npm',
args: ['start'],
},
});
await mcpClient.initialize();
await mcpClient.callTool('tool1', { arg: 'value' });
expect(mockClient.callTool).toHaveBeenCalledWith(
{
name: 'tool1',
arguments: { arg: 'value' },
},
undefined,
{ timeout: 900000 }, // Config timeout takes precedence
);
// Reset mock
mockGetEnvInt.mockReturnValue(undefined);
});
it('should handle tool error', async () => {
// Reset mocks for this test
mockClient.connect.mockResolvedValueOnce(undefined);
mockClient.listTools.mockResolvedValueOnce({
tools: [{ name: 'tool1', description: 'desc1', inputSchema: {} }],
});
mockClient.callTool.mockRejectedValueOnce(new Error('Tool error'));
mcpClient = new MCPClient({
enabled: true,
server: {
command: 'npm',
args: ['start'],
},
debug: true,
});
await mcpClient.initialize();
const result = await mcpClient.callTool('tool1', {});
expect(result).toEqual({
content: '',
error: 'Tool error',
});
});
it('should surface MCP tool error results', async () => {
// Reset mocks for this test
const errorContent = [{ type: 'text', text: 'Invalid arguments' }];
mockClient.connect.mockResolvedValueOnce(undefined);
mockClient.listTools.mockResolvedValueOnce({
tools: [{ name: 'tool1', description: 'desc1', inputSchema: {} }],
});
mockClient.callTool.mockResolvedValueOnce({
content: errorContent,
isError: true,
});
mcpClient = new MCPClient({
enabled: true,
server: {
command: 'npm',
args: ['start'],
},
});
await mcpClient.initialize();
const result = await mcpClient.callTool('tool1', {});
expect(result).toEqual({
content: JSON.stringify(errorContent),
isError: true,
raw: {
content: errorContent,
isError: true,
},
});
});
it('should throw error for unknown tool', async () => {
// Reset mocks for this test
mockClient.connect.mockResolvedValueOnce(undefined);
mockClient.listTools.mockResolvedValueOnce({
tools: [{ name: 'tool1', description: 'desc1', inputSchema: {} }],
});
mcpClient = new MCPClient({
enabled: true,
server: {
command: 'npm',
args: ['start'],
},
});
await mcpClient.initialize();
await expect(mcpClient.callTool('unknown', {})).rejects.toThrow('Tool unknown not found');
});
it('should support tool content as Buffer', async () => {
// Reset mocks for this test
const contentBuffer = Buffer.from('buffered-result');
mockClient.connect.mockResolvedValueOnce(undefined);
mockClient.listTools.mockResolvedValueOnce({
tools: [{ name: 'tool1', description: 'desc1', inputSchema: {} }],
});
mockClient.callTool.mockResolvedValueOnce({ content: contentBuffer });
mcpClient = new MCPClient({
enabled: true,
server: {
command: 'npm',
args: ['start'],
},
});
await mcpClient.initialize();
const result = await mcpClient.callTool('tool1', {});
expect(result).toEqual({ content: 'buffered-result', raw: { content: contentBuffer } });
});
it('should return empty string if result content is falsy', async () => {
// Reset mocks for this test
mockClient.connect.mockResolvedValueOnce(undefined);
mockClient.listTools.mockResolvedValueOnce({
tools: [{ name: 'tool1', description: 'desc1', inputSchema: {} }],
});
mockClient.callTool.mockResolvedValueOnce({});
mcpClient = new MCPClient({
enabled: true,
server: {
command: 'npm',
args: ['start'],
},
});
await mcpClient.initialize();
const result = await mcpClient.callTool('tool1', {});
expect(result).toEqual({ content: '', raw: {} });
});
it('should parse JSON-stringified content correctly', async () => {
// Reset mocks for this test
mockClient.connect.mockResolvedValueOnce(undefined);
mockClient.listTools.mockResolvedValueOnce({
tools: [{ name: 'tool1', description: 'desc1', inputSchema: {} }],
});
mockClient.callTool.mockResolvedValueOnce({ content: '"Hello World"' });
mcpClient = new MCPClient({
enabled: true,
server: {
command: 'npm',
args: ['start'],
},
});
await mcpClient.initialize();
const result = await mcpClient.callTool('tool1', {});
expect(result).toEqual({ content: 'Hello World', raw: { content: '"Hello World"' } });
});
it('should handle non-JSON string content correctly', async () => {
// Reset mocks for this test
mockClient.connect.mockResolvedValueOnce(undefined);
mockClient.listTools.mockResolvedValueOnce({
tools: [{ name: 'tool1', description: 'desc1', inputSchema: {} }],
});
mockClient.callTool.mockResolvedValueOnce({ content: 'Plain text response' });
mcpClient = new MCPClient({
enabled: true,
server: {
command: 'npm',
args: ['start'],
},
});
await mcpClient.initialize();
const result = await mcpClient.callTool('tool1', {});
expect(result).toEqual({
content: 'Plain text response',
raw: { content: 'Plain text response' },
});
});
});
describe('cleanup', () => {
it('should cleanup all clients', async () => {
// Reset mocks for this test
mockClient.connect.mockResolvedValueOnce(undefined);
mockClient.listTools.mockResolvedValueOnce({
tools: [{ name: 'tool1', description: 'desc1', inputSchema: {} }],
});
mcpClient = new MCPClient({
enabled: true,
server: {
command: 'npm',
args: ['start'],
},
});
await mcpClient.initialize();
await mcpClient.cleanup();
expect(mockClient.close).toHaveBeenCalledWith();
});
it('should handle cleanup errors', async () => {
// Reset mocks for this test
mockClient.connect.mockResolvedValueOnce(undefined);
mockClient.listTools.mockResolvedValueOnce({
tools: [{ name: 'tool1', description: 'desc1', inputSchema: {} }],
});
mockClient.close.mockRejectedValueOnce(new Error('Cleanup error'));
mcpClient = new MCPClient({
enabled: true,
server: { command: 'npm', args: ['start'] },
debug: true,
});
await mcpClient.initialize();
await mcpClient.cleanup();
expect(mockClient.close).toHaveBeenCalledWith();
});
});
describe('getAllTools', () => {
it('should return all tools from all servers', async () => {
// Reset mocks for this test
mockClient.connect.mockResolvedValue(undefined);
mockClient.listTools.mockResolvedValue({
tools: [
{ name: 'tool1', description: 'desc1', inputSchema: {} },
{ name: 'tool2', description: 'desc2', inputSchema: {} },
],
});
mcpClient = new MCPClient({
enabled: true,
servers: [
{
name: 'server1',
command: 'npm',
args: ['start'],
},
{
name: 'server2',
path: 'script.js',
},
],
});
await mcpClient.initialize();
const allTools = mcpClient.getAllTools();
expect(Array.isArray(allTools)).toBe(true);
expect(allTools.length).toBeGreaterThan(0);
});
it('should return empty array if no tools', () => {
mcpClient = new MCPClient({ enabled: true });
// force tools to be empty
expect(mcpClient.getAllTools()).toEqual([]);
});
it('should skip stale tool entries when another server still has the requested tool', async () => {
mockClient.connect.mockResolvedValue(undefined);
mockClient.listTools.mockResolvedValue({
tools: [{ name: 'tool1', description: 'desc1', inputSchema: {} }],
});
mockClient.callTool.mockResolvedValue({ content: 'result' });
mcpClient = new MCPClient({
enabled: true,
servers: [
{
name: 'server1',
command: 'npm',
args: ['start'],
},
{
name: 'server2',
command: 'node',
args: ['server.js'],
},
],
});
await mcpClient.initialize();
(
mcpClient as unknown as {
clients: Map<string, unknown>;
}
).clients.delete('server1');
await expect(mcpClient.callTool('tool1', {})).resolves.toEqual({
content: 'result',
raw: { content: 'result' },
});
expect(mockClient.callTool).toHaveBeenCalledTimes(1);
});
it('should skip a server whose proactive OAuth refresh fails when another server still has the requested tool', async () => {
const server1Client = createMockClient(vi.fn().mockResolvedValue({ content: 'server1' }));
const server2Client = createMockClient(
vi.fn().mockResolvedValue({ content: 'server2 result' }),
);
vi.mocked(Client)
.mockImplementationOnce(function MockServer1Client() {
return server1Client as unknown as Client;
})
.mockImplementationOnce(function MockServer2Client() {
return server2Client as unknown as Client;
});
mockGetOAuthTokenWithExpiry
.mockResolvedValueOnce({
accessToken: 'stale-token',
expiresAt: Date.now() - 1000,
})
.mockRejectedValueOnce(new Error('refresh failed'));
mcpClient = new MCPClient({
enabled: true,
servers: [
{
name: 'server1',
url: 'http://localhost:3000',
auth: {
type: 'oauth',
grantType: 'client_credentials',
clientId: 'test-client',
clientSecret: 'test-secret',
tokenUrl: 'https://auth.example.com/token',
},
},
{
name: 'server2',
command: 'npm',
args: ['start'],
},
],
});
await mcpClient.initialize();
await expect(mcpClient.callTool('tool1', {})).resolves.toEqual({
content: 'server2 result',
raw: { content: 'server2 result' },
});
expect(server1Client.callTool).not.toHaveBeenCalled();
expect(server2Client.callTool).toHaveBeenCalledTimes(1);
});
it('should report when a known tool only exists on disconnected servers', async () => {
mockClient.connect.mockResolvedValue(undefined);
mockClient.listTools.mockResolvedValue({
tools: [{ name: 'tool1', description: 'desc1', inputSchema: {} }],
});
mcpClient = new MCPClient({
enabled: true,
server: {
name: 'server1',
command: 'npm',
args: ['start'],
},
});
await mcpClient.initialize();
(
mcpClient as unknown as {
clients: Map<string, unknown>;
}
).clients.delete('server1');
await expect(mcpClient.callTool('tool1', {})).rejects.toThrow(
'Tool tool1 is known but MCP server is disconnected: server1',
);
});
});
describe('OAuth authentication', () => {
it('should use static headers for OAuth with tokenUrl configured', async () => {
mockClient.connect.mockResolvedValueOnce(undefined);
mockClient.listTools.mockResolvedValueOnce({
tools: [{ name: 'tool1', description: 'desc1', inputSchema: {} }],
});
mcpClient = new MCPClient({
enabled: true,
server: {
url: 'http://localhost:3000',
auth: {
type: 'oauth',
grantType: 'client_credentials',
clientId: 'test-client',
clientSecret: 'test-secret',
tokenUrl: 'https://auth.example.com/token',
},
},
});
await mcpClient.initialize();
// When tokenUrl is configured, we use static Authorization header
expect(StreamableHTTPClientTransport).toHaveBeenCalledWith(
expect.any(URL),
expect.objectContaining({
requestInit: expect.objectContaining({
headers: expect.objectContaining({
Authorization: 'Bearer mock-oauth-token',
}),
}),
}),
);
// Verify authProvider is NOT used when tokenUrl is configured
const callArgs = vi.mocked(StreamableHTTPClientTransport).mock.calls[0];
expect(callArgs[1]).not.toHaveProperty('authProvider');
});
it('should use static headers for OAuth password grant with tokenUrl', async () => {
mockClient.connect.mockResolvedValueOnce(undefined);
mockClient.listTools.mockResolvedValueOnce({
tools: [{ name: 'tool1', description: 'desc1', inputSchema: {} }],
});
mcpClient = new MCPClient({
enabled: true,
server: {
url: 'http://localhost:3000',
auth: {
type: 'oauth',
grantType: 'password',
tokenUrl: 'https://auth.example.com/token',
username: 'testuser',
password: 'testpass',
},
},
});
await mcpClient.initialize();
// When tokenUrl is configured, we use static Authorization header
expect(StreamableHTTPClientTransport).toHaveBeenCalledWith(
expect.any(URL),
expect.objectContaining({
requestInit: expect.objectContaining({
headers: expect.objectContaining({
Authorization: 'Bearer mock-oauth-token',
}),
}),
}),
);
});
it('should NOT use authProvider for bearer auth type', async () => {
mockClient.connect.mockResolvedValueOnce(undefined);
mockClient.listTools.mockResolvedValueOnce({
tools: [{ name: 'tool1', description: 'desc1', inputSchema: {} }],
});
mcpClient = new MCPClient({
enabled: true,
server: {
url: 'http://localhost:3000',
auth: {
type: 'bearer',
token: 'static-token',
},
},
});
await mcpClient.initialize();
// Should have headers but NOT authProvider
expect(StreamableHTTPClientTransport).toHaveBeenCalledWith(
expect.any(URL),
expect.objectContaining({
requestInit: expect.objectContaining({
headers: expect.objectContaining({
Authorization: 'Bearer static-token',
}),
}),
}),
);
// Verify authProvider is not in the options
const callArgs = vi.mocked(StreamableHTTPClientTransport).mock.calls[0];
expect(callArgs[1]).not.toHaveProperty('authProvider');
});
it('should NOT use authProvider for basic auth type', async () => {
mockClient.connect.mockResolvedValueOnce(undefined);
mockClient.listTools.mockResolvedValueOnce({
tools: [{ name: 'tool1', description: 'desc1', inputSchema: {} }],
});
mcpClient = new MCPClient({
enabled: true,
server: {
url: 'http://localhost:3000',
auth: {
type: 'basic',
username: 'user',
password: 'pass',
},
},
});
await mcpClient.initialize();
// Should have headers but NOT authProvider
const expectedAuth = 'Basic ' + Buffer.from('user:pass').toString('base64');
expect(StreamableHTTPClientTransport).toHaveBeenCalledWith(
expect.any(URL),
expect.objectContaining({
requestInit: expect.objectContaining({
headers: expect.objectContaining({
Authorization: expectedAuth,
}),
}),
}),
);
// Verify authProvider is not in the options
const callArgs = vi.mocked(StreamableHTTPClientTransport).mock.calls[0];
expect(callArgs[1]).not.toHaveProperty('authProvider');
});
it('should combine OAuth static headers with custom headers when tokenUrl configured', async () => {
mockClient.connect.mockResolvedValueOnce(undefined);
mockClient.listTools.mockResolvedValueOnce({
tools: [{ name: 'tool1', description: 'desc1', inputSchema: {} }],
});
const customHeaders = {
'X-Custom-Header': 'custom-value',
};
mcpClient = new MCPClient({
enabled: true,
server: {
url: 'http://localhost:3000',
headers: customHeaders,
auth: {
type: 'oauth',
grantType: 'client_credentials',
clientId: 'test-client',
clientSecret: 'test-secret',
tokenUrl: 'https://auth.example.com/token',
},
},
});
await mcpClient.initialize();
// Should have both Authorization header and custom headers
expect(StreamableHTTPClientTransport).toHaveBeenCalledWith(
expect.any(URL),
expect.objectContaining({
requestInit: expect.objectContaining({
headers: expect.objectContaining({
...customHeaders,
Authorization: 'Bearer mock-oauth-token',
}),
}),
}),
);
// Verify authProvider is NOT used when tokenUrl is configured
const callArgs = vi.mocked(StreamableHTTPClientTransport).mock.calls[0];
expect(callArgs[1]).not.toHaveProperty('authProvider');
});
it('should fall back to SSEClientTransport with static headers if StreamableHTTPClientTransport fails', async () => {
mockClient.connect
.mockImplementationOnce(function () {
throw new Error('Connection failed');
})
.mockResolvedValueOnce(undefined);
mockClient.listTools.mockResolvedValueOnce({
tools: [{ name: 'tool1', description: 'desc1', inputSchema: {} }],
});
mcpClient = new MCPClient({
enabled: true,
server: {
url: 'http://localhost:3000',
auth: {
type: 'oauth',
grantType: 'client_credentials',
clientId: 'test-client',
clientSecret: 'test-secret',
tokenUrl: 'https://auth.example.com/token',
},
},
});
await mcpClient.initialize();
// Both transports should receive static Authorization headers when tokenUrl is configured
expect(StreamableHTTPClientTransport).toHaveBeenCalledWith(
expect.any(URL),
expect.objectContaining({
requestInit: expect.objectContaining({
headers: expect.objectContaining({
Authorization: 'Bearer mock-oauth-token',
}),
}),
}),
);
expect(SSEClientTransport).toHaveBeenCalledWith(
expect.any(URL),
expect.objectContaining({
requestInit: expect.objectContaining({
headers: expect.objectContaining({
Authorization: 'Bearer mock-oauth-token',
}),
}),
}),
);
});
it('should proactively refresh token before callTool if close to expiration', async () => {
// First call with valid token
mockClient.connect.mockResolvedValue(undefined);
mockClient.listTools.mockResolvedValue({
tools: [{ name: 'tool1', description: 'desc1', inputSchema: {} }],
});
mockClient.callTool.mockResolvedValue({ content: 'result' });
// Set token to expire soon (within buffer)
mockGetOAuthTokenWithExpiry.mockResolvedValueOnce({
accessToken: 'initial-token',
expiresAt: Date.now() + 30000, // 30 seconds, within 60s buffer
});
mcpClient = new MCPClient({
enabled: true,
server: {
url: 'http://localhost:3000',
auth: {
type: 'oauth',
grantType: 'client_credentials',
clientId: 'test-client',
clientSecret: 'test-secret',
tokenUrl: 'https://auth.example.com/token',
},
},
});
await mcpClient.initialize();
// Now set up a new token for the refresh
mockGetOAuthTokenWithExpiry.mockResolvedValueOnce({
accessToken: 'refreshed-token',
expiresAt: Date.now() + 3600000, // 1 hour
});
// Call tool - should trigger proactive refresh
await mcpClient.callTool('tool1', {});
// Should have called getOAuthTokenWithExpiry twice (initial + refresh)
expect(mockGetOAuthTokenWithExpiry).toHaveBeenCalledTimes(2);
});
it('should call the reconnected client after a proactive token refresh', async () => {
const oldClient = createMockClient(vi.fn());
const refreshedClient = createMockClient(vi.fn().mockResolvedValue({ content: 'result' }));
vi.mocked(Client)
.mockImplementationOnce(function () {
return oldClient as unknown as Client;
})
.mockImplementationOnce(function () {
return refreshedClient as unknown as Client;
});
mockGetOAuthTokenWithExpiry
.mockResolvedValueOnce({
accessToken: 'initial-token',
expiresAt: Date.now() + 30_000,
})
.mockResolvedValueOnce({
accessToken: 'refreshed-token',
expiresAt: Date.now() + 3_600_000,
});
mcpClient = new MCPClient({
enabled: true,
server: {
url: 'http://localhost:3000',
auth: {
type: 'oauth',
grantType: 'client_credentials',
clientId: 'test-client',
clientSecret: 'test-secret',
tokenUrl: 'https://auth.example.com/token',
},
},
});
await mcpClient.initialize();
await expect(mcpClient.callTool('tool1', {})).resolves.toEqual({
content: 'result',
raw: { content: 'result' },
});
expect(oldClient.callTool).not.toHaveBeenCalled();
expect(refreshedClient.callTool).toHaveBeenCalledTimes(1);
});
it('should wait for an in-progress reactive reconnect when token metadata is already refreshed', async () => {
const reconnectStarted = createDeferred<void>();
const reconnectContinue = createDeferred<void>();
const oldClient = createMockClient(
vi.fn().mockRejectedValueOnce(new Error('401 Unauthorized')),
);
const refreshedClient = createMockClient(
vi.fn().mockResolvedValue({ content: 'refreshed-result' }),
);
refreshedClient.connect.mockImplementationOnce(async () => {
reconnectStarted.resolve(undefined);
await reconnectContinue.promise;
});
vi.mocked(Client)
.mockImplementationOnce(function () {
return oldClient as unknown as Client;
})
.mockImplementationOnce(function () {
return refreshedClient as unknown as Client;
});
mockGetOAuthTokenWithExpiry
.mockResolvedValueOnce({
accessToken: 'initial-token',
expiresAt: Date.now() + 3_600_000,
})
.mockResolvedValueOnce({
accessToken: 'refreshed-token',
expiresAt: Date.now() + 3_600_000,
});
mcpClient = new MCPClient({
enabled: true,
server: {
url: 'http://localhost:3000',
auth: {
type: 'oauth',
grantType: 'client_credentials',
clientId: 'test-client',
clientSecret: 'test-secret',
tokenUrl: 'https://auth.example.com/token',
},
},
});
await mcpClient.initialize();
const call1 = mcpClient.callTool('tool1', {});
await reconnectStarted.promise;
const call2 = mcpClient.callTool('tool1', {});
reconnectContinue.resolve(undefined);
await expect(Promise.all([call1, call2])).resolves.toEqual([
{ content: 'refreshed-result', raw: { content: 'refreshed-result' } },
{ content: 'refreshed-result', raw: { content: 'refreshed-result' } },
]);
expect(oldClient.callTool).toHaveBeenCalledTimes(1);
expect(refreshedClient.callTool).toHaveBeenCalledTimes(2);
});
it('should deduplicate concurrent proactive token refreshes', async () => {
mockClient.connect.mockResolvedValue(undefined);
mockClient.listTools.mockResolvedValue({
tools: [{ name: 'tool1', description: 'desc1', inputSchema: {} }],
});
mockClient.callTool.mockResolvedValue({ content: 'result' });
mockGetOAuthTokenWithExpiry.mockResolvedValueOnce({
accessToken: 'initial-token',
expiresAt: Date.now() + 30_000,
});
const refreshStarted = createDeferred<void>();
const refreshToken = createDeferred<{ accessToken: string; expiresAt: number }>();
mockGetOAuthTokenWithExpiry.mockImplementationOnce(async () => {
refreshStarted.resolve(undefined);
return refreshToken.promise;
});
mcpClient = new MCPClient({
enabled: true,
server: {
url: 'http://localhost:3000',
auth: {
type: 'oauth',
grantType: 'client_credentials',
clientId: 'test-client',
clientSecret: 'test-secret',
tokenUrl: 'https://auth.example.com/token',
},
},
});
await mcpClient.initialize();
const call1 = mcpClient.callTool('tool1', {});
await refreshStarted.promise;
const call2 = mcpClient.callTool('tool1', {});
refreshToken.resolve({
accessToken: 'refreshed-token',
expiresAt: Date.now() + 3_600_000,
});
await Promise.all([call1, call2]);
expect(mockGetOAuthTokenWithExpiry).toHaveBeenCalledTimes(2);
expect(mockClient.callTool).toHaveBeenCalledTimes(2);
});
it('should deduplicate retry refreshes after a failed in-progress proactive refresh', async () => {
mockClient.connect.mockResolvedValue(undefined);
mockClient.listTools.mockResolvedValue({
tools: [{ name: 'tool1', description: 'desc1', inputSchema: {} }],
});
mockClient.callTool.mockResolvedValue({ content: 'result' });
mockGetOAuthTokenWithExpiry.mockResolvedValueOnce({
accessToken: 'initial-token',
expiresAt: Date.now() + 30_000,
});
const failedRefreshStarted = createDeferred<void>();
const failedRefreshToken = createDeferred<{ accessToken: string; expiresAt: number }>();
mockGetOAuthTokenWithExpiry
.mockImplementationOnce(async () => {
failedRefreshStarted.resolve(undefined);
return failedRefreshToken.promise;
})
.mockResolvedValueOnce({
accessToken: 'refreshed-token',
expiresAt: Date.now() + 3_600_000,
});
mcpClient = new MCPClient({
enabled: true,
server: {
url: 'http://localhost:3000',
auth: {
type: 'oauth',
grantType: 'client_credentials',
clientId: 'test-client',
clientSecret: 'test-secret',
tokenUrl: 'https://auth.example.com/token',
},
},
});
await mcpClient.initialize();
const call1 = mcpClient.callTool('tool1', {}).catch(() => {
// Expected to fail on the first refresh attempt.
});
await failedRefreshStarted.promise;
const call2 = mcpClient.callTool('tool1', {});
const call3 = mcpClient.callTool('tool1', {});
failedRefreshToken.reject(new Error('token refresh failed'));
await Promise.allSettled([call1, call2, call3]);
expect(mockGetOAuthTokenWithExpiry).toHaveBeenCalledTimes(3);
expect(mockClient.callTool).toHaveBeenCalledTimes(2);
});
it('should deduplicate concurrent reactive token refreshes after auth errors', async () => {
const oldClient = createMockClient(
vi
.fn()
.mockRejectedValueOnce(new Error('401 Unauthorized'))
.mockRejectedValueOnce(new Error('401 Unauthorized')),
);
const refreshedClient = createMockClient(vi.fn().mockResolvedValue({ content: 'result' }));
vi.mocked(Client)
.mockImplementationOnce(function () {
return oldClient as unknown as Client;
})
.mockImplementationOnce(function () {
return refreshedClient as unknown as Client;
});
mockGetOAuthTokenWithExpiry.mockResolvedValueOnce({
accessToken: 'initial-token',
expiresAt: Date.now() + 3_600_000,
});
const refreshStarted = createDeferred<void>();
const refreshToken = createDeferred<{ accessToken: string; expiresAt: number }>();
mockGetOAuthTokenWithExpiry.mockImplementationOnce(async () => {
refreshStarted.resolve(undefined);
return refreshToken.promise;
});
mcpClient = new MCPClient({
enabled: true,
server: {
url: 'http://localhost:3000',
auth: {
type: 'oauth',
grantType: 'client_credentials',
clientId: 'test-client',
clientSecret: 'test-secret',
tokenUrl: 'https://auth.example.com/token',
},
},
});
await mcpClient.initialize();
const call1 = mcpClient.callTool('tool1', {});
const call2 = mcpClient.callTool('tool1', {});
await refreshStarted.promise;
refreshToken.resolve({
accessToken: 'reactive-refresh-token',
expiresAt: Date.now() + 3_600_000,
});
await Promise.all([call1, call2]);
expect(mockGetOAuthTokenWithExpiry).toHaveBeenCalledTimes(2);
expect(oldClient.callTool).toHaveBeenCalledTimes(2);
expect(refreshedClient.callTool).toHaveBeenCalledTimes(2);
});
it('should not refresh token if still valid', async () => {
mockClient.connect.mockResolvedValue(undefined);
mockClient.listTools.mockResolvedValue({
tools: [{ name: 'tool1', description: 'desc1', inputSchema: {} }],
});
mockClient.callTool.mockResolvedValue({ content: 'result' });
// Token valid for 1 hour (outside buffer)
mockGetOAuthTokenWithExpiry.mockResolvedValueOnce({
accessToken: 'valid-token',
expiresAt: Date.now() + 3600000, // 1 hour
});
mcpClient = new MCPClient({
enabled: true,
server: {
url: 'http://localhost:3000',
auth: {
type: 'oauth',
grantType: 'client_credentials',
clientId: 'test-client',
clientSecret: 'test-secret',
tokenUrl: 'https://auth.example.com/token',
},
},
});
await mcpClient.initialize();
// Call tool - should NOT trigger refresh
await mcpClient.callTool('tool1', {});
// Should have called getOAuthTokenWithExpiry only once (initial)
expect(mockGetOAuthTokenWithExpiry).toHaveBeenCalledTimes(1);
});
});
});