chore: import upstream snapshot with attribution
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

This commit is contained in:
wehub-resource-sync
2026-07-13 13:24:08 +08:00
commit 0d3cb498a3
5438 changed files with 1316560 additions and 0 deletions
+366
View File
@@ -0,0 +1,366 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
clearOAuthTokenCache,
PromptfooOAuthClientProvider,
} from '../../../src/providers/mcp/authProvider';
// Mock the util module to avoid actual HTTP requests
vi.mock('../../../src/providers/mcp/util', () => ({
getOAuthToken: vi.fn().mockResolvedValue('mock-access-token'),
}));
describe('PromptfooOAuthClientProvider', () => {
beforeEach(() => {
vi.clearAllMocks();
clearOAuthTokenCache();
});
afterEach(() => {
clearOAuthTokenCache();
});
describe('client_credentials grant', () => {
it('should create provider with correct metadata', () => {
const provider = new PromptfooOAuthClientProvider({
type: 'oauth',
grantType: 'client_credentials',
clientId: 'test-client',
clientSecret: 'test-secret',
tokenUrl: 'https://auth.example.com/token',
});
expect(provider.clientMetadata.grant_types).toContain('client_credentials');
expect(provider.redirectUrl).toBeUndefined();
const clientInfo = provider.clientInformation();
expect(clientInfo.client_id).toBe('test-client');
expect(clientInfo.client_secret).toBe('test-secret');
});
it('should prepare token request with correct grant type', () => {
const provider = new PromptfooOAuthClientProvider({
type: 'oauth',
grantType: 'client_credentials',
clientId: 'test-client',
clientSecret: 'test-secret',
tokenUrl: 'https://auth.example.com/token',
scopes: ['read', 'write'],
});
const params = provider.prepareTokenRequest();
expect(params.get('grant_type')).toBe('client_credentials');
expect(params.get('scope')).toBe('read write');
expect(params.has('username')).toBe(false);
expect(params.has('password')).toBe(false);
});
it('should use scope parameter when provided', () => {
const provider = new PromptfooOAuthClientProvider({
type: 'oauth',
grantType: 'client_credentials',
clientId: 'test-client',
clientSecret: 'test-secret',
tokenUrl: 'https://auth.example.com/token',
scopes: ['read'], // config scope
});
// Scope parameter should override config scope
const params = provider.prepareTokenRequest('custom-scope');
expect(params.get('scope')).toBe('custom-scope');
});
});
describe('password grant', () => {
it('should prepare token request with username and password', () => {
const provider = new PromptfooOAuthClientProvider({
type: 'oauth',
grantType: 'password',
tokenUrl: 'https://auth.example.com/token',
username: 'user@example.com',
password: 'secret123',
});
const params = provider.prepareTokenRequest();
expect(params.get('grant_type')).toBe('password');
expect(params.get('username')).toBe('user@example.com');
expect(params.get('password')).toBe('secret123');
});
it('should include optional clientId and clientSecret', () => {
const provider = new PromptfooOAuthClientProvider({
type: 'oauth',
grantType: 'password',
clientId: 'optional-client',
clientSecret: 'optional-secret',
tokenUrl: 'https://auth.example.com/token',
username: 'user@example.com',
password: 'secret123',
});
const clientInfo = provider.clientInformation();
expect(clientInfo.client_id).toBe('optional-client');
expect(clientInfo.client_secret).toBe('optional-secret');
});
});
describe('token caching', () => {
it('should save and retrieve tokens', async () => {
const provider = new PromptfooOAuthClientProvider({
type: 'oauth',
grantType: 'client_credentials',
clientId: 'test-client',
clientSecret: 'test-secret',
tokenUrl: 'https://auth.example.com/token',
});
const tokens = {
access_token: 'test-token',
token_type: 'Bearer',
expires_in: 3600,
};
provider.saveTokens(tokens);
expect(await provider.tokens()).toEqual(tokens);
});
it('should share tokens between provider instances with same config', async () => {
const config = {
type: 'oauth' as const,
grantType: 'client_credentials' as const,
clientId: 'test-client',
clientSecret: 'test-secret',
tokenUrl: 'https://auth.example.com/token',
};
const provider1 = new PromptfooOAuthClientProvider(config);
const provider2 = new PromptfooOAuthClientProvider(config);
const tokens = {
access_token: 'shared-token',
token_type: 'Bearer',
expires_in: 3600,
};
provider1.saveTokens(tokens);
// Second provider should see the cached tokens
expect(await provider2.tokens()).toEqual(tokens);
});
it('should fetch fresh tokens when cache is empty', async () => {
const { getOAuthToken } = await import('../../../src/providers/mcp/util');
vi.mocked(getOAuthToken).mockResolvedValueOnce('fresh-token');
const provider = new PromptfooOAuthClientProvider({
type: 'oauth',
grantType: 'client_credentials',
clientId: 'client-1',
clientSecret: 'secret-1',
tokenUrl: 'https://auth.example.com/token',
});
const tokens = await provider.tokens();
expect(tokens).toBeDefined();
expect(tokens?.access_token).toBe('fresh-token');
expect(getOAuthToken).toHaveBeenCalled();
});
it('should invalidate tokens when requested', async () => {
const provider = new PromptfooOAuthClientProvider({
type: 'oauth',
grantType: 'client_credentials',
clientId: 'test-client',
clientSecret: 'test-secret',
tokenUrl: 'https://auth.example.com/token',
});
provider.saveTokens({
access_token: 'test-token',
token_type: 'Bearer',
expires_in: 3600,
});
expect(await provider.tokens()).toBeDefined();
provider.invalidateCredentials('tokens');
// After invalidation, it will try to fetch fresh tokens
const { getOAuthToken } = await import('../../../src/providers/mcp/util');
vi.mocked(getOAuthToken).mockResolvedValueOnce('new-token-after-invalidation');
const newTokens = await provider.tokens();
expect(newTokens?.access_token).toBe('new-token-after-invalidation');
});
it('should invalidate all credentials when scope is "all"', async () => {
const provider = new PromptfooOAuthClientProvider({
type: 'oauth',
grantType: 'client_credentials',
clientId: 'test-client',
clientSecret: 'test-secret',
tokenUrl: 'https://auth.example.com/token',
});
provider.saveTokens({
access_token: 'test-token',
token_type: 'Bearer',
expires_in: 3600,
});
provider.invalidateCredentials('all');
// After invalidation, it will try to fetch fresh tokens
const { getOAuthToken } = await import('../../../src/providers/mcp/util');
vi.mocked(getOAuthToken).mockResolvedValueOnce('new-token-after-all-invalidation');
const newTokens = await provider.tokens();
expect(newTokens?.access_token).toBe('new-token-after-all-invalidation');
});
});
describe('client information', () => {
it('should save and retrieve client information', () => {
const provider = new PromptfooOAuthClientProvider({
type: 'oauth',
grantType: 'client_credentials',
clientId: 'original-client',
clientSecret: 'original-secret',
tokenUrl: 'https://auth.example.com/token',
});
const newClientInfo = {
client_id: 'new-client',
client_secret: 'new-secret',
};
provider.saveClientInformation(newClientInfo);
expect(provider.clientInformation()).toEqual(newClientInfo);
});
});
describe('non-interactive flow methods', () => {
it('should throw on redirectToAuthorization', () => {
const provider = new PromptfooOAuthClientProvider({
type: 'oauth',
grantType: 'client_credentials',
clientId: 'test-client',
clientSecret: 'test-secret',
tokenUrl: 'https://auth.example.com/token',
});
expect(() => provider.redirectToAuthorization(new URL('https://example.com'))).toThrow(
'not supported',
);
});
it('should throw on codeVerifier', () => {
const provider = new PromptfooOAuthClientProvider({
type: 'oauth',
grantType: 'client_credentials',
clientId: 'test-client',
clientSecret: 'test-secret',
tokenUrl: 'https://auth.example.com/token',
});
expect(() => provider.codeVerifier()).toThrow('not supported');
});
it('should not throw on saveCodeVerifier (no-op)', () => {
const provider = new PromptfooOAuthClientProvider({
type: 'oauth',
grantType: 'client_credentials',
clientId: 'test-client',
clientSecret: 'test-secret',
tokenUrl: 'https://auth.example.com/token',
});
// Should not throw
expect(() => provider.saveCodeVerifier('some-verifier')).not.toThrow();
});
});
describe('client metadata', () => {
it('should have correct token_endpoint_auth_method when client secret is present', () => {
const provider = new PromptfooOAuthClientProvider({
type: 'oauth',
grantType: 'client_credentials',
clientId: 'test-client',
clientSecret: 'test-secret',
tokenUrl: 'https://auth.example.com/token',
});
expect(provider.clientMetadata.token_endpoint_auth_method).toBe('client_secret_basic');
});
it('should have token_endpoint_auth_method as "none" when no client secret', () => {
const provider = new PromptfooOAuthClientProvider({
type: 'oauth',
grantType: 'password',
tokenUrl: 'https://auth.example.com/token',
username: 'user',
password: 'pass',
});
expect(provider.clientMetadata.token_endpoint_auth_method).toBe('none');
});
it('should have empty redirect_uris for non-interactive flows', () => {
const provider = new PromptfooOAuthClientProvider({
type: 'oauth',
grantType: 'client_credentials',
clientId: 'test-client',
clientSecret: 'test-secret',
tokenUrl: 'https://auth.example.com/token',
});
expect(provider.clientMetadata.redirect_uris).toEqual([]);
});
});
});
describe('clearOAuthTokenCache', () => {
beforeEach(() => {
clearOAuthTokenCache();
});
it('should clear all cached tokens', async () => {
const provider = new PromptfooOAuthClientProvider({
type: 'oauth',
grantType: 'client_credentials',
clientId: 'test-client',
clientSecret: 'test-secret',
tokenUrl: 'https://auth.example.com/token',
});
provider.saveTokens({
access_token: 'test-token',
token_type: 'Bearer',
expires_in: 3600,
});
expect(await provider.tokens()).toBeDefined();
clearOAuthTokenCache();
// Create a new provider with same config - should fetch fresh tokens
const { getOAuthToken } = await import('../../../src/providers/mcp/util');
vi.mocked(getOAuthToken).mockResolvedValueOnce('fresh-token-after-clear');
const newProvider = new PromptfooOAuthClientProvider({
type: 'oauth',
grantType: 'client_credentials',
clientId: 'test-client',
clientSecret: 'test-secret',
tokenUrl: 'https://auth.example.com/token',
});
const tokens = await newProvider.tokens();
expect(tokens?.access_token).toBe('fresh-token-after-clear');
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,187 @@
/**
* End-to-end test for GitHub issue #6902:
* "Gemini provider fails with MCP tools due to unsupported additionalProperties in JSON Schema"
*
* This test verifies the complete transformation pipeline from MCP SDK Zod schemas
* to Gemini-compatible tool schemas.
*/
import { describe, expect, it } from 'vitest';
import { transformMCPToolsToGoogle } from '../../../src/providers/mcp/transform';
import type { MCPTool } from '../../../src/providers/mcp/types';
describe('GitHub Issue #6902 - Gemini MCP Schema Compatibility (E2E)', () => {
it('should transform MCP SDK Zod schema to valid Gemini schema', () => {
// This is exactly what MCP SDK generates when using Zod with z.object({...}).strict()
const mcpZodGeneratedTools: MCPTool[] = [
{
name: 'analyze-prompt',
description: 'Analyzes a prompt for potential security vulnerabilities',
inputSchema: {
type: 'object',
properties: {
prompt: {
type: 'string',
description: 'The prompt text to analyze',
},
severity: {
type: 'string',
enum: ['low', 'medium', 'high', 'critical'],
description: 'Minimum severity threshold',
},
options: {
type: 'object',
properties: {
maxLength: {
type: 'number',
default: 4096,
},
includeRecommendations: {
type: 'boolean',
default: true,
},
},
additionalProperties: false,
},
},
required: ['prompt'],
additionalProperties: false,
$schema: 'http://json-schema.org/draft-07/schema#',
},
},
];
// Transform to Google/Gemini format
const googleTools = transformMCPToolsToGoogle(mcpZodGeneratedTools);
// Validate structure
expect(googleTools).toHaveLength(1);
expect(googleTools[0].functionDeclarations).toHaveLength(1);
const funcDecl = googleTools[0].functionDeclarations![0];
expect(funcDecl.name).toBe('analyze-prompt');
expect(funcDecl.description).toBe('Analyzes a prompt for potential security vulnerabilities');
// Validate the schema is Gemini-compliant
const params = funcDecl.parameters!;
// 1. No unsupported properties at root level
expect(params).not.toHaveProperty('$schema');
expect(params).not.toHaveProperty('additionalProperties');
// 2. Types are uppercase (Gemini requirement)
expect(params.type).toBe('OBJECT');
expect(params.properties!.prompt.type).toBe('STRING');
expect(params.properties!.severity.type).toBe('STRING');
expect(params.properties!.options!.type).toBe('OBJECT');
// 3. Nested schemas are also sanitized
const optionsSchema = params.properties!.options as Record<string, any>;
expect(optionsSchema).not.toHaveProperty('additionalProperties');
expect(optionsSchema.properties!.maxLength).not.toHaveProperty('default');
expect(optionsSchema.properties!.includeRecommendations).not.toHaveProperty('default');
expect(optionsSchema.properties!.maxLength.type).toBe('NUMBER');
expect(optionsSchema.properties!.includeRecommendations.type).toBe('BOOLEAN');
// 4. Supported properties are preserved
expect(params.required).toEqual(['prompt']);
expect(params.properties!.prompt.description).toBe('The prompt text to analyze');
expect(params.properties!.severity.enum).toEqual(['low', 'medium', 'high', 'critical']);
});
it('should produce schema that matches Gemini API expectations', () => {
// Minimal reproduction of the issue
const problematicTool: MCPTool[] = [
{
name: 'test',
description: 'Test',
inputSchema: {
type: 'object',
properties: {
query: { type: 'string' },
},
additionalProperties: false, // This was causing the error!
},
},
];
const googleTools = transformMCPToolsToGoogle(problematicTool);
const params = googleTools[0].functionDeclarations![0].parameters!;
// The resulting schema should be valid for Gemini:
// - type: OBJECT (uppercase)
// - properties with STRING type (uppercase)
// - NO additionalProperties
expect(params).toEqual({
type: 'OBJECT',
properties: {
query: { type: 'STRING' },
},
});
// This schema should now work with Gemini without:
// "GenerateContentRequest.tools[0].function_declarations[0].parameters.properties[query].additionalProperties:
// should not be set for TYPE_OBJECT"
});
it('should handle complex nested array and object schemas', () => {
const complexTool: MCPTool[] = [
{
name: 'process-data',
description: 'Process complex data structures',
inputSchema: {
type: 'object',
properties: {
items: {
type: 'array',
items: {
type: 'object',
properties: {
id: { type: 'string' },
metadata: {
type: 'object',
properties: {
tags: {
type: 'array',
items: { type: 'string' },
minItems: 1,
maxItems: 10,
},
},
additionalProperties: false,
},
},
additionalProperties: false,
},
},
},
additionalProperties: false,
$schema: 'http://json-schema.org/draft-07/schema#',
},
},
];
const googleTools = transformMCPToolsToGoogle(complexTool);
const params = googleTools[0].functionDeclarations![0].parameters!;
// Verify deep nesting is properly sanitized
expect(params).not.toHaveProperty('$schema');
expect(params).not.toHaveProperty('additionalProperties');
const itemsSchema = params.properties!.items as Record<string, any>;
expect(itemsSchema.type).toBe('ARRAY');
const itemSchema = itemsSchema.items as Record<string, any>;
expect(itemSchema.type).toBe('OBJECT');
expect(itemSchema).not.toHaveProperty('additionalProperties');
const metadataSchema = itemSchema.properties.metadata as Record<string, any>;
expect(metadataSchema.type).toBe('OBJECT');
expect(metadataSchema).not.toHaveProperty('additionalProperties');
// minItems and maxItems should be preserved
const tagsSchema = metadataSchema.properties.tags as Record<string, any>;
expect(tagsSchema.minItems).toBe(1);
expect(tagsSchema.maxItems).toBe(10);
});
});
@@ -0,0 +1,67 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
describe('MCP client optional dependency', () => {
afterEach(() => {
vi.doUnmock('@modelcontextprotocol/sdk/client/index.js');
vi.doUnmock('../../../src/util/packageImportErrors');
vi.resetModules();
});
it('explains how to install the MCP SDK when provider mode is requested', async () => {
vi.doMock('@modelcontextprotocol/sdk/client/index.js', () => {
throw new Error('Cannot find package @modelcontextprotocol/sdk');
});
vi.doMock('../../../src/util/packageImportErrors', () => ({
isMissingPackageImportError: () => true,
}));
const { MCPClient } = await import('../../../src/providers/mcp/client');
const client = new MCPClient({
enabled: true,
server: {
command: 'node',
args: ['server.js'],
},
});
const initializePromise = client.initialize();
await expect(initializePromise).rejects.toThrow(
'The @modelcontextprotocol/sdk package is required for MCP provider support.',
);
await expect(initializePromise).rejects.toThrow('npm install @modelcontextprotocol/sdk');
});
it('keeps eager provider initialization from becoming an unhandled rejection', async () => {
vi.doMock('@modelcontextprotocol/sdk/client/index.js', () => {
throw new Error('Cannot find package @modelcontextprotocol/sdk');
});
vi.doMock('../../../src/util/packageImportErrors', () => ({
isMissingPackageImportError: () => true,
}));
const unhandledRejection = vi.fn();
process.once('unhandledRejection', unhandledRejection);
try {
const { MCPProvider } = await import('../../../src/providers/mcp');
const provider = new MCPProvider({
config: {
enabled: true,
server: {
command: 'node',
args: ['server.js'],
},
},
});
await new Promise<void>((resolve) => setImmediate(resolve));
expect(unhandledRejection).not.toHaveBeenCalled();
await expect(provider.callApi('{"tool":"lookup_user"}')).resolves.toEqual({
error:
'MCP Provider error: The @modelcontextprotocol/sdk package is required for MCP provider support. Install it with: npm install @modelcontextprotocol/sdk',
});
} finally {
process.off('unhandledRejection', unhandledRejection);
}
});
});
+280
View File
@@ -0,0 +1,280 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const mcpClientMock = vi.hoisted(() => ({
initialize: vi.fn().mockResolvedValue(undefined),
getAllTools: vi.fn().mockReturnValue([]),
callTool: vi.fn(),
cleanup: vi.fn().mockResolvedValue(undefined),
connectedServers: ['test-server'],
}));
vi.mock('../../../src/providers/mcp/client', () => ({
MCPClient: vi.fn(function MockMCPClient() {
return mcpClientMock;
}),
}));
import { MCPProvider } from '../../../src/providers/mcp';
function createContext(payload: Record<string, unknown>) {
return {
vars: { prompt: JSON.stringify(payload) },
} as any;
}
describe('MCPProvider', () => {
beforeEach(() => {
vi.clearAllMocks();
mcpClientMock.initialize.mockResolvedValue(undefined);
mcpClientMock.getAllTools.mockReturnValue([]);
mcpClientMock.callTool.mockReset();
mcpClientMock.cleanup.mockResolvedValue(undefined);
});
it('should preserve existing output behavior without a response transform', async () => {
const rawResult = {
content: [{ type: 'text', text: 'raw response' }],
structuredContent: { answer: 'structured response' },
};
mcpClientMock.callTool.mockResolvedValue({
content: 'normalized response',
raw: rawResult,
});
const provider = new MCPProvider({ config: { enabled: true } });
const payload = { tool: 'lookup_user', args: { id: '123' } };
await expect(provider.callApi('', createContext(payload))).resolves.toEqual({
output: 'normalized response',
raw: rawResult,
metadata: {
toolName: 'lookup_user',
toolArgs: { id: '123' },
originalPayload: payload,
},
});
});
it('should preserve MCP tool error results as direct provider output', async () => {
const rawResult = {
content: [{ type: 'text', text: 'Path traversal not allowed' }],
isError: true,
};
mcpClientMock.callTool.mockResolvedValue({
content: 'Path traversal not allowed',
isError: true,
raw: rawResult,
});
const provider = new MCPProvider({ config: { enabled: true } });
await expect(provider.callTool('read_file', { path: '../../../etc/passwd' })).resolves.toEqual({
output: 'Path traversal not allowed',
raw: rawResult,
metadata: {
toolName: 'read_file',
toolArgs: { path: '../../../etc/passwd' },
},
});
});
it('should preserve MCP tool error results as direct provider output via callApi', async () => {
const rawResult = {
content: [{ type: 'text', text: 'Path traversal not allowed' }],
isError: true,
};
mcpClientMock.callTool.mockResolvedValue({
content: 'Path traversal not allowed',
isError: true,
raw: rawResult,
});
const provider = new MCPProvider({ config: { enabled: true } });
const payload = { tool: 'read_file', args: { path: '../../../etc/passwd' } };
await expect(provider.callApi('', createContext(payload))).resolves.toEqual({
output: 'Path traversal not allowed',
raw: rawResult,
metadata: {
toolName: 'read_file',
toolArgs: { path: '../../../etc/passwd' },
originalPayload: payload,
},
});
});
it('should surface MCP client failures as a provider error', async () => {
const failure = { content: '', error: 'connection lost' };
mcpClientMock.callTool.mockResolvedValue(failure);
const provider = new MCPProvider({ config: { enabled: true } });
const payload = { tool: 'lookup_user', args: { id: '123' } };
await expect(provider.callApi('', createContext(payload))).resolves.toEqual({
error: 'MCP tool error: connection lost',
raw: failure,
});
});
it('should transform raw MCP results and merge provider metadata', async () => {
const rawResult = {
content: [{ type: 'text', text: 'raw response' }],
structuredContent: { answer: 'structured response' },
};
mcpClientMock.callTool.mockResolvedValue({
content: 'normalized response',
raw: rawResult,
});
const provider = new MCPProvider({
config: {
enabled: true,
transformResponse:
'({ output: result.structuredContent.answer, metadata: { parser: "custom", content } })',
},
});
const payload = { tool: 'lookup_user', args: { id: '123' } };
await expect(provider.callApi('', createContext(payload))).resolves.toEqual({
output: 'structured response',
raw: rawResult,
metadata: {
toolName: 'lookup_user',
toolArgs: { id: '123' },
originalPayload: payload,
parser: 'custom',
content: 'normalized response',
},
});
});
it('should use deprecated responseParser when transformResponse is not configured', async () => {
const rawResult = {
structuredContent: { answer: 'legacy response' },
};
mcpClientMock.callTool.mockResolvedValue({
content: 'normalized response',
raw: rawResult,
});
const provider = new MCPProvider({
config: {
enabled: true,
responseParser:
'({ output: result.structuredContent.answer, metadata: { parser: "legacy" } })',
},
});
await expect(provider.callTool('lookup_user', { id: '123' })).resolves.toEqual({
output: 'legacy response',
raw: rawResult,
metadata: {
parser: 'legacy',
toolName: 'lookup_user',
toolArgs: { id: '123' },
},
});
});
it('should prefer transformResponse over deprecated responseParser when both are configured', async () => {
const rawResult = {
structuredContent: { answer: 'structured response' },
};
mcpClientMock.callTool.mockResolvedValue({
content: 'normalized response',
raw: rawResult,
});
const provider = new MCPProvider({
config: {
enabled: true,
responseParser: '({ output: "legacy response", metadata: { parser: "legacy" } })',
transformResponse: '({ output: "new response", metadata: { parser: "transform" } })',
},
});
await expect(provider.callTool('lookup_user', { id: '123' })).resolves.toEqual({
output: 'new response',
raw: rawResult,
metadata: {
parser: 'transform',
toolName: 'lookup_user',
toolArgs: { id: '123' },
},
});
});
it('should keep provider metadata authoritative when transforms return conflicting keys', async () => {
const rawResult = {
structuredContent: { answer: 'structured response' },
};
mcpClientMock.callTool.mockResolvedValue({
content: 'normalized response',
raw: rawResult,
});
const provider = new MCPProvider({
config: {
enabled: true,
transformResponse: `({
output: result.structuredContent.answer,
metadata: {
toolName: 'spoofed',
toolArgs: { id: 'spoofed' },
originalPayload: { tool: 'spoofed' },
parser: 'custom'
}
})`,
},
});
const payload = { tool: 'lookup_user', args: { id: '123' } };
await expect(provider.callApi('', createContext(payload))).resolves.toEqual({
output: 'structured response',
raw: rawResult,
metadata: {
parser: 'custom',
toolName: 'lookup_user',
toolArgs: { id: '123' },
originalPayload: payload,
},
});
});
it('should apply response transforms to direct tool calls', async () => {
const rawResult = {
structuredContent: { answer: 'direct response' },
};
mcpClientMock.callTool.mockResolvedValue({
content: 'normalized response',
raw: rawResult,
});
const provider = new MCPProvider({
config: {
enabled: true,
transformResponse:
'(result, _content, context) => ({ output: `${context.toolName}:${result.structuredContent.answer}` })',
},
});
await expect(provider.callTool('lookup_user', { id: '123' })).resolves.toEqual({
output: 'lookup_user:direct response',
raw: rawResult,
metadata: {
toolName: 'lookup_user',
toolArgs: { id: '123' },
},
});
});
it('should return the existing invalid prompt contract before calling tools', async () => {
const provider = new MCPProvider({ config: { enabled: true } });
await expect(provider.callApi('', { vars: { prompt: 'not-json' } } as any)).resolves.toEqual({
error:
'Invalid JSON in prompt. MCP provider expects a JSON payload with tool call information.',
});
expect(mcpClientMock.callTool).not.toHaveBeenCalled();
});
});
+584
View File
@@ -0,0 +1,584 @@
import { describe, expect, it } from 'vitest';
import {
transformMCPToolsToAnthropic,
transformMCPToolsToGoogle,
transformMCPToolsToOpenAi,
} from '../../../src/providers/mcp/transform';
import type Anthropic from '@anthropic-ai/sdk';
import type { MCPTool } from '../../../src/providers/mcp/types';
import type { OpenAiTool } from '../../../src/providers/openai/util';
describe('transformMCPToolsToOpenAi', () => {
it('should transform MCP tools to OpenAI format', () => {
const mcpTools: MCPTool[] = [
{
name: 'test_tool',
description: 'A test tool',
inputSchema: {
properties: {
param1: { type: 'string' },
param2: { type: 'number' },
},
required: ['param1'],
},
},
];
const expected: OpenAiTool[] = [
{
type: 'function',
function: {
name: 'test_tool',
description: 'A test tool',
parameters: {
type: 'object',
properties: {
param1: { type: 'string' },
param2: { type: 'number' },
},
required: ['param1'],
},
},
},
];
const result = transformMCPToolsToOpenAi(mcpTools);
expect(result).toEqual(expected);
});
it('should handle tools without properties in schema', () => {
const mcpTools: MCPTool[] = [
{
name: 'simple_tool',
description: 'A simple tool',
inputSchema: {
type: 'string',
},
},
];
// When inputSchema has type but no properties field, we should return empty properties
// This is a malformed schema - MCP tools should have proper JSON Schema format
const expected: OpenAiTool[] = [
{
type: 'function',
function: {
name: 'simple_tool',
description: 'A simple tool',
parameters: {
type: 'object',
properties: {},
},
},
},
];
const result = transformMCPToolsToOpenAi(mcpTools);
expect(result).toEqual(expected);
});
it('should handle empty input schema', () => {
const mcpTools: MCPTool[] = [
{
name: 'empty_tool',
description: 'A tool with empty schema',
inputSchema: {},
},
];
const expected: OpenAiTool[] = [
{
type: 'function',
function: {
name: 'empty_tool',
description: 'A tool with empty schema',
parameters: {
type: 'object',
properties: {},
},
},
},
];
const result = transformMCPToolsToOpenAi(mcpTools);
expect(result).toEqual(expected);
});
it('should handle multiple tools', () => {
const mcpTools: MCPTool[] = [
{
name: 'tool1',
description: 'First tool',
inputSchema: {
properties: {
param1: { type: 'string' },
},
},
},
{
name: 'tool2',
description: 'Second tool',
inputSchema: {
properties: {
param2: { type: 'number' },
},
},
},
];
const result = transformMCPToolsToOpenAi(mcpTools);
expect(result).toHaveLength(2);
expect(result[0].function.name).toBe('tool1');
expect(result[1].function.name).toBe('tool2');
});
it('should remove $schema field from inputSchema', () => {
const mcpTools: MCPTool[] = [
{
name: 'tool_with_schema',
description: 'Tool with $schema field',
inputSchema: {
$schema: 'http://json-schema.org/draft-07/schema#',
type: 'object',
properties: {
param: { type: 'string' },
},
},
},
];
const result = transformMCPToolsToOpenAi(mcpTools);
expect(result[0].function.parameters).not.toHaveProperty('$schema');
expect(result[0].function.parameters).toEqual({
type: 'object',
properties: {
param: { type: 'string' },
},
});
});
it('should preserve additionalProperties when present', () => {
const mcpTools: MCPTool[] = [
{
name: 'tool_with_additional_props',
description: 'Tool with additionalProperties',
inputSchema: {
type: 'object',
properties: {},
additionalProperties: false,
},
},
];
const result = transformMCPToolsToOpenAi(mcpTools);
expect(result[0].function.parameters).toEqual({
type: 'object',
properties: {},
additionalProperties: false,
});
});
it('should handle schema from MCP SDK with all metadata fields', () => {
const mcpTools: MCPTool[] = [
{
name: 'mcp_sdk_tool',
description: 'Tool with MCP SDK generated schema',
inputSchema: {
type: 'object',
properties: {},
additionalProperties: false,
$schema: 'http://json-schema.org/draft-07/schema#',
},
},
];
const result = transformMCPToolsToOpenAi(mcpTools);
// Should not include $schema but should include additionalProperties
expect(result[0].function.parameters).toEqual({
type: 'object',
properties: {},
additionalProperties: false,
});
expect(result[0].function.parameters).not.toHaveProperty('$schema');
});
it('should not include required field when empty', () => {
const mcpTools: MCPTool[] = [
{
name: 'tool_empty_required',
description: 'Tool with empty required array',
inputSchema: {
properties: {
param: { type: 'string' },
},
required: [],
},
},
];
const result = transformMCPToolsToOpenAi(mcpTools);
expect(result[0].function.parameters).not.toHaveProperty('required');
expect(result[0].function.parameters).toEqual({
type: 'object',
properties: {
param: { type: 'string' },
},
});
});
it('should handle schema without properties field', () => {
const mcpTools: MCPTool[] = [
{
name: 'tool_no_properties',
description: 'Tool without properties field',
inputSchema: {
type: 'object',
},
},
];
const result = transformMCPToolsToOpenAi(mcpTools);
expect(result[0].function.parameters).toEqual({
type: 'object',
properties: {},
});
});
});
describe('transformMCPToolsToAnthropic', () => {
it('should transform MCP tools to Anthropic format', () => {
const mcpTools: MCPTool[] = [
{
name: 'test_tool',
description: 'A test tool',
inputSchema: {
properties: {
param: { type: 'string' },
},
required: ['param'],
},
},
];
const expected: Anthropic.Tool[] = [
{
name: 'test_tool',
description: 'A test tool',
input_schema: {
type: 'object',
properties: {
param: { type: 'string' },
},
required: ['param'],
},
},
];
const result = transformMCPToolsToAnthropic(mcpTools);
expect(result).toEqual(expected);
});
it('should remove $schema field from inputSchema', () => {
const mcpTools: MCPTool[] = [
{
name: 'tool_with_schema',
description: 'Tool with $schema field',
inputSchema: {
$schema: 'http://json-schema.org/draft-07/schema#',
type: 'object',
properties: {
param: { type: 'string' },
},
},
},
];
const result = transformMCPToolsToAnthropic(mcpTools);
expect(result[0].input_schema).not.toHaveProperty('$schema');
expect(result[0].input_schema).toEqual({
type: 'object',
properties: {
param: { type: 'string' },
},
});
});
it('should handle empty input schemas', () => {
const mcpTools: MCPTool[] = [
{
name: 'no_input_tool',
description: 'Tool with no input parameters',
inputSchema: {
type: 'object',
properties: {},
additionalProperties: false,
$schema: 'http://json-schema.org/draft-07/schema#',
},
},
];
const result = transformMCPToolsToAnthropic(mcpTools);
expect(result[0].input_schema).not.toHaveProperty('$schema');
expect(result[0].input_schema).toEqual({
type: 'object',
properties: {},
additionalProperties: false,
});
});
});
describe('transformMCPToolsToGoogle', () => {
it('should transform MCP tools to Google format', () => {
const mcpTools: MCPTool[] = [
{
name: 'test_tool',
description: 'A test tool',
inputSchema: {
properties: {
param: { type: 'string' },
},
required: ['param'],
},
},
];
const result = transformMCPToolsToGoogle(mcpTools);
expect(result).toHaveLength(1);
expect(result[0].functionDeclarations).toHaveLength(1);
expect(result[0].functionDeclarations?.[0].name).toBe('test_tool');
expect(result[0].functionDeclarations?.[0].description).toBe('A test tool');
expect(result[0].functionDeclarations?.[0].parameters?.type).toBe('OBJECT');
});
it('should remove $schema field from tool definitions', () => {
const mcpTools: MCPTool[] = [
{
name: 'test_tool',
description: 'A test tool',
inputSchema: {
$schema: 'http://json-schema.org/draft-07/schema#',
properties: {
param: { type: 'string' },
},
},
},
];
const result = transformMCPToolsToGoogle(mcpTools);
const parameters = result[0].functionDeclarations?.[0].parameters;
expect(parameters).not.toHaveProperty('$schema');
// Types should be converted to uppercase for Gemini
expect(parameters?.properties?.param).toEqual({ type: 'STRING' });
});
it('should remove additionalProperties from schema (GitHub issue #6902)', () => {
// This is the exact issue from GitHub: MCP SDK generates additionalProperties: false
// which Gemini does not support
const mcpTools: MCPTool[] = [
{
name: 'test_tool',
description: 'Tool with additionalProperties',
inputSchema: {
type: 'object',
properties: {
query: { type: 'string' },
},
additionalProperties: false,
},
},
];
const result = transformMCPToolsToGoogle(mcpTools);
const parameters = result[0].functionDeclarations?.[0].parameters;
expect(parameters).not.toHaveProperty('additionalProperties');
expect(parameters).toEqual({
type: 'OBJECT',
properties: {
query: { type: 'STRING' },
},
});
});
it('should convert lowercase types to uppercase for Gemini', () => {
const mcpTools: MCPTool[] = [
{
name: 'type_test_tool',
description: 'Tool testing type conversion',
inputSchema: {
type: 'object',
properties: {
strParam: { type: 'string' },
numParam: { type: 'number' },
intParam: { type: 'integer' },
boolParam: { type: 'boolean' },
arrParam: { type: 'array', items: { type: 'string' } },
objParam: { type: 'object', properties: {} },
},
},
},
];
const result = transformMCPToolsToGoogle(mcpTools);
const parameters = result[0].functionDeclarations?.[0].parameters;
expect(parameters?.type).toBe('OBJECT');
expect(parameters?.properties?.strParam?.type).toBe('STRING');
expect(parameters?.properties?.numParam?.type).toBe('NUMBER');
expect(parameters?.properties?.intParam?.type).toBe('INTEGER');
expect(parameters?.properties?.boolParam?.type).toBe('BOOLEAN');
expect(parameters?.properties?.arrParam?.type).toBe('ARRAY');
expect(parameters?.properties?.arrParam?.items?.type).toBe('STRING');
expect(parameters?.properties?.objParam?.type).toBe('OBJECT');
});
it('should recursively sanitize nested schemas', () => {
const mcpTools: MCPTool[] = [
{
name: 'nested_tool',
description: 'Tool with nested schema',
inputSchema: {
type: 'object',
properties: {
user: {
type: 'object',
additionalProperties: false,
properties: {
name: { type: 'string', default: 'unknown' },
address: {
type: 'object',
additionalProperties: false,
properties: {
city: { type: 'string' },
},
},
},
},
},
additionalProperties: false,
},
},
];
const result = transformMCPToolsToGoogle(mcpTools);
const parameters = result[0].functionDeclarations?.[0].parameters;
// Root level
expect(parameters).not.toHaveProperty('additionalProperties');
// Nested user object
expect(parameters?.properties?.user).not.toHaveProperty('additionalProperties');
expect(parameters?.properties?.user?.properties?.name).not.toHaveProperty('default');
expect(parameters?.properties?.user?.type).toBe('OBJECT');
// Deeply nested address object
expect(parameters?.properties?.user?.properties?.address).not.toHaveProperty(
'additionalProperties',
);
expect(parameters?.properties?.user?.properties?.address?.type).toBe('OBJECT');
expect(parameters?.properties?.user?.properties?.address?.properties?.city?.type).toBe(
'STRING',
);
});
it('should handle MCP SDK Zod-generated schema (real-world scenario from issue #6902)', () => {
// This represents exactly what the MCP SDK generates when using Zod schemas
const mcpTools: MCPTool[] = [
{
name: 'analyze_prompt',
description: 'Analyze a prompt for security issues',
inputSchema: {
type: 'object',
properties: {
prompt: {
type: 'string',
description: 'The prompt to analyze',
},
categories: {
type: 'array',
items: {
type: 'string',
},
description: 'Categories to check',
},
options: {
type: 'object',
properties: {
maxTokens: {
type: 'number',
default: 1000,
},
strict: {
type: 'boolean',
default: true,
},
},
additionalProperties: false,
},
},
required: ['prompt'],
additionalProperties: false,
$schema: 'http://json-schema.org/draft-07/schema#',
},
},
];
const result = transformMCPToolsToGoogle(mcpTools);
const parameters = result[0].functionDeclarations?.[0].parameters;
// Verify all unsupported properties are removed
expect(parameters).not.toHaveProperty('$schema');
expect(parameters).not.toHaveProperty('additionalProperties');
expect(parameters?.properties?.options).not.toHaveProperty('additionalProperties');
expect(parameters?.properties?.options?.properties?.maxTokens).not.toHaveProperty('default');
expect(parameters?.properties?.options?.properties?.strict).not.toHaveProperty('default');
// Verify types are converted to uppercase
expect(parameters?.type).toBe('OBJECT');
expect(parameters?.properties?.prompt?.type).toBe('STRING');
expect(parameters?.properties?.categories?.type).toBe('ARRAY');
expect(parameters?.properties?.categories?.items?.type).toBe('STRING');
expect(parameters?.properties?.options?.type).toBe('OBJECT');
expect(parameters?.properties?.options?.properties?.maxTokens?.type).toBe('NUMBER');
expect(parameters?.properties?.options?.properties?.strict?.type).toBe('BOOLEAN');
// Verify required is preserved
expect(parameters?.required).toEqual(['prompt']);
});
it('should handle empty schema', () => {
const mcpTools: MCPTool[] = [
{
name: 'empty_tool',
description: 'Tool with empty schema',
inputSchema: {},
},
];
const result = transformMCPToolsToGoogle(mcpTools);
const parameters = result[0].functionDeclarations?.[0].parameters;
expect(parameters?.type).toBe('OBJECT');
expect(parameters?.properties).toEqual({});
});
it('should handle schema with only type field', () => {
const mcpTools: MCPTool[] = [
{
name: 'simple_tool',
description: 'Simple tool',
inputSchema: {
type: 'object',
},
},
];
const result = transformMCPToolsToGoogle(mcpTools);
const parameters = result[0].functionDeclarations?.[0].parameters;
expect(parameters?.type).toBe('OBJECT');
expect(parameters?.properties).toEqual({});
});
});
+104
View File
@@ -0,0 +1,104 @@
import { describe, expect, it } from 'vitest';
import { createTransformResponse } from '../../../src/providers/mcp/transforms';
describe('MCP createTransformResponse', () => {
const context = {
toolName: 'lookup_user',
toolArgs: { id: '123' },
originalPayload: { tool: 'lookup_user', args: { id: '123' } },
};
it('should return normalized content when no transform is configured', async () => {
const transform = createTransformResponse(undefined);
await expect(
transform({ structuredContent: { name: 'Ada' } }, 'fallback text', context),
).resolves.toEqual({
output: 'fallback text',
});
});
it('should expose raw result, normalized content, and context to string transforms', async () => {
const transform = createTransformResponse(
'({ output: result.structuredContent.name, metadata: { tool: context.toolName, content } })',
);
await expect(
transform({ structuredContent: { name: 'Ada' } }, 'fallback text', context),
).resolves.toEqual({
output: 'Ada',
metadata: {
tool: 'lookup_user',
content: 'fallback text',
},
});
});
it('should wrap primitive function results as provider output', async () => {
const transform = createTransformResponse((result: any) => result.structuredContent.name);
await expect(
transform({ structuredContent: { name: 'Ada' } }, 'fallback text', context),
).resolves.toEqual({
output: 'Ada',
});
});
it('should wrap primitive string-expression results as provider output', async () => {
const transform = createTransformResponse('42');
await expect(transform({}, 'fallback text', context)).resolves.toEqual({
output: 42,
});
});
it('should await async function results before normalizing them', async () => {
const transform = createTransformResponse(async (result: any) => result.structuredContent.name);
await expect(
transform({ structuredContent: { name: 'Ada' } }, 'fallback text', context),
).resolves.toEqual({
output: 'Ada',
});
});
it('should await async string function expressions before normalizing them', async () => {
const transform = createTransformResponse('async (result) => result.structuredContent.name');
await expect(
transform({ structuredContent: { name: 'Ada' } }, 'fallback text', context),
).resolves.toEqual({
output: 'Ada',
});
});
it('should wrap object results without provider response fields as output', async () => {
const transform = createTransformResponse('({ answer: result.structuredContent.name })');
await expect(
transform({ structuredContent: { name: 'Ada' } }, 'fallback text', context),
).resolves.toEqual({
output: { answer: 'Ada' },
});
});
it('should throw if file transforms are not pre-loaded', () => {
expect(() => createTransformResponse('file://parser.js')).toThrow(
/should be pre-loaded before calling createTransformResponse/,
);
});
it('should reject unsupported transform types', () => {
expect(() => createTransformResponse(123 as any)).toThrow(
"Unsupported response transform type: number. Expected a function, a string starting with 'file://' pointing to a JavaScript file, or a string containing a JavaScript expression.",
);
});
it('should wrap errors from string transforms', async () => {
const transform = createTransformResponse('result.missing.value');
await expect(transform({}, 'fallback text', context)).rejects.toThrow(
/Failed to transform MCP response/,
);
});
});
+424
View File
@@ -0,0 +1,424 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
applyQueryParams,
discoverTokenEndpoint,
getAuthHeaders,
getAuthQueryParams,
getMcpErrorMessage,
getOAuthTokenWithExpiry,
isMcpErrorResult,
isMcpToolNameFilter,
} from '../../../src/providers/mcp/util';
import type {
MCPOAuthClientCredentialsAuth,
MCPServerConfig,
} from '../../../src/providers/mcp/types';
// Mock fetchWithProxy for discovery tests
const mockFetch = vi.fn();
vi.mock('../../../src/util/fetch/index', () => ({
fetchWithProxy: (...args: unknown[]) => mockFetch(...args),
}));
describe('isMcpToolNameFilter', () => {
it('identifies plain tool names as MCP filters', () => {
expect(isMcpToolNameFilter('search_companies')).toBe(true);
expect(isMcpToolNameFilter(['search_companies', 'list_industries'])).toBe(true);
});
it('does not classify file loaders or object tool definitions as MCP filters', () => {
expect(isMcpToolNameFilter('file://tools.json')).toBe(false);
expect(isMcpToolNameFilter(['file://tools.json'])).toBe(false);
expect(isMcpToolNameFilter([{ type: 'function', function: { name: 'lookup' } }])).toBe(false);
});
});
describe('isMcpErrorResult', () => {
it('flags results with a thrown SDK error', () => {
expect(isMcpErrorResult({ content: '', error: 'connection lost' })).toBe(true);
});
it('flags results with a protocol-level isError flag', () => {
expect(isMcpErrorResult({ content: 'Path traversal not allowed', isError: true })).toBe(true);
});
it('does not flag successful results', () => {
expect(isMcpErrorResult({ content: 'ok' })).toBe(false);
});
});
describe('getMcpErrorMessage', () => {
it('prefers the thrown-error message', () => {
expect(getMcpErrorMessage({ content: 'ignored', error: 'connection lost' })).toBe(
'connection lost',
);
});
it('falls back to the tool error content', () => {
expect(getMcpErrorMessage({ content: 'Path traversal not allowed', isError: true })).toBe(
'Path traversal not allowed',
);
});
it('falls back to a generic message when an error result has no content', () => {
expect(getMcpErrorMessage({ content: '', isError: true })).toBe(
'Tool returned an error result',
);
});
});
describe('getAuthHeaders', () => {
it('should return bearer auth header', () => {
const server: MCPServerConfig = {
auth: { type: 'bearer', token: 'abc123' },
};
expect(getAuthHeaders(server)).toEqual({
Authorization: 'Bearer abc123',
});
});
it('should return api_key auth header with default key name', () => {
const server: MCPServerConfig = {
auth: { type: 'api_key', api_key: 'xyz789' },
};
expect(getAuthHeaders(server)).toEqual({
'X-API-Key': 'xyz789',
});
});
it('should return api_key auth header with value field', () => {
const server: MCPServerConfig = {
auth: { type: 'api_key', value: 'xyz789' },
};
expect(getAuthHeaders(server)).toEqual({
'X-API-Key': 'xyz789',
});
});
it('should return api_key auth header with custom key name', () => {
const server: MCPServerConfig = {
auth: { type: 'api_key', value: 'xyz789', keyName: 'X-Custom-Key' },
};
expect(getAuthHeaders(server)).toEqual({
'X-Custom-Key': 'xyz789',
});
});
it('should return empty object for api_key with query placement', () => {
const server: MCPServerConfig = {
auth: { type: 'api_key', value: 'xyz789', placement: 'query' },
};
expect(getAuthHeaders(server)).toEqual({});
});
it('should return basic auth header', () => {
const server: MCPServerConfig = {
auth: { type: 'basic', username: 'user', password: 'pass' },
};
expect(getAuthHeaders(server)).toEqual({
Authorization: 'Basic dXNlcjpwYXNz', // base64 of 'user:pass'
});
});
it('should return oauth bearer token when provided', () => {
const server: MCPServerConfig = {
auth: {
type: 'oauth',
grantType: 'client_credentials',
clientId: 'id',
clientSecret: 'secret',
tokenUrl: 'https://auth.example.com/token',
},
};
expect(getAuthHeaders(server, 'oauth-token-123')).toEqual({
Authorization: 'Bearer oauth-token-123',
});
});
it('should return empty object for oauth without token', () => {
const server: MCPServerConfig = {
auth: {
type: 'oauth',
grantType: 'client_credentials',
clientId: 'id',
clientSecret: 'secret',
tokenUrl: 'https://auth.example.com/token',
},
};
expect(getAuthHeaders(server)).toEqual({});
});
it('should return empty object if no auth', () => {
const server: MCPServerConfig = {};
expect(getAuthHeaders(server)).toEqual({});
});
it('should return empty object for incomplete auth', () => {
// Test handling of invalid/incomplete auth config (type assertion bypasses TS for edge case testing)
const server: MCPServerConfig = { auth: { type: 'bearer' } as MCPServerConfig['auth'] };
expect(getAuthHeaders(server)).toEqual({});
});
});
describe('getAuthQueryParams', () => {
it('should return query params for api_key with query placement', () => {
const server: MCPServerConfig = {
auth: { type: 'api_key', value: 'xyz789', placement: 'query', keyName: 'api_key' },
};
expect(getAuthQueryParams(server)).toEqual({
api_key: 'xyz789',
});
});
it('should return empty object for api_key with header placement', () => {
const server: MCPServerConfig = {
auth: { type: 'api_key', value: 'xyz789', placement: 'header' },
};
expect(getAuthQueryParams(server)).toEqual({});
});
it('should return empty object for non-api_key auth', () => {
const server: MCPServerConfig = {
auth: { type: 'bearer', token: 'abc123' },
};
expect(getAuthQueryParams(server)).toEqual({});
});
it('should use default keyName for query placement', () => {
const server: MCPServerConfig = {
auth: { type: 'api_key', value: 'xyz789', placement: 'query' },
};
expect(getAuthQueryParams(server)).toEqual({
'X-API-Key': 'xyz789',
});
});
});
describe('applyQueryParams', () => {
it('should append query params to URL', () => {
const url = 'https://api.example.com/v1';
const params = { key: 'value', another: 'param' };
expect(applyQueryParams(url, params)).toBe(
'https://api.example.com/v1?key=value&another=param',
);
});
it('should append to existing query params', () => {
const url = 'https://api.example.com/v1?existing=param';
const params = { key: 'value' };
expect(applyQueryParams(url, params)).toBe(
'https://api.example.com/v1?existing=param&key=value',
);
});
it('should return original URL if no params', () => {
const url = 'https://api.example.com/v1';
expect(applyQueryParams(url, {})).toBe(url);
});
});
describe('discoverTokenEndpoint', () => {
beforeEach(() => {
mockFetch.mockReset();
});
afterEach(() => {
vi.clearAllMocks();
});
it('should discover token endpoint from root well-known URL', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({
token_endpoint: 'https://auth.example.com/oauth/token',
authorization_endpoint: 'https://auth.example.com/oauth/authorize',
}),
});
const result = await discoverTokenEndpoint('https://mcp.example.com');
expect(result).toBe('https://auth.example.com/oauth/token');
expect(mockFetch).toHaveBeenCalledWith(
'https://mcp.example.com/.well-known/oauth-authorization-server',
);
});
it('should try path-appended discovery first for URLs with paths', async () => {
// First attempt (path-appended) fails
mockFetch.mockResolvedValueOnce({ ok: false, status: 404 });
// Second attempt (RFC 8414 path-aware) fails
mockFetch.mockResolvedValueOnce({ ok: false, status: 404 });
// Third attempt (root) succeeds
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({ token_endpoint: 'https://auth.example.com/token' }),
});
const result = await discoverTokenEndpoint('https://example.com/realms/test');
expect(result).toBe('https://auth.example.com/token');
// Should have tried path-appended first
expect(mockFetch).toHaveBeenNthCalledWith(
1,
'https://example.com/realms/test/.well-known/oauth-authorization-server',
);
// Then RFC 8414 path-aware
expect(mockFetch).toHaveBeenNthCalledWith(
2,
'https://example.com/.well-known/oauth-authorization-server/realms/test',
);
// Then root
expect(mockFetch).toHaveBeenNthCalledWith(
3,
'https://example.com/.well-known/oauth-authorization-server',
);
});
it('should succeed with path-appended discovery (Keycloak style)', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({
token_endpoint: 'https://keycloak.example.com/realms/test/protocol/openid-connect/token',
}),
});
const result = await discoverTokenEndpoint('https://keycloak.example.com/realms/test');
expect(result).toBe('https://keycloak.example.com/realms/test/protocol/openid-connect/token');
});
it('should throw error if no discovery succeeds', async () => {
mockFetch.mockResolvedValue({ ok: false, status: 404 });
await expect(discoverTokenEndpoint('https://example.com')).rejects.toThrow(
/Failed to discover OAuth token endpoint/,
);
});
it('should throw error if metadata has no token_endpoint', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({
// Missing token_endpoint - only has authorization_endpoint
authorization_endpoint: 'https://auth.example.com/authorize',
}),
});
await expect(discoverTokenEndpoint('https://example.com')).rejects.toThrow(
/Failed to discover OAuth token endpoint/,
);
});
it('should ignore empty token endpoints during discovery', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({ token_endpoint: '' }),
});
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({ token_endpoint: 'https://auth.example.com/token' }),
});
await expect(discoverTokenEndpoint('https://example.com/path')).resolves.toBe(
'https://auth.example.com/token',
);
});
it('should ignore malformed token endpoints during discovery', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({ token_endpoint: 'not a url' }),
});
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({ token_endpoint: 'https://auth.example.com/token' }),
});
await expect(discoverTokenEndpoint('https://example.com/path')).resolves.toBe(
'https://auth.example.com/token',
);
});
it('should ignore token endpoints with unsupported protocols during discovery', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({ token_endpoint: 'ftp://auth.example.com/token' }),
});
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({ token_endpoint: 'https://auth.example.com/token' }),
});
await expect(discoverTokenEndpoint('https://example.com/path')).resolves.toBe(
'https://auth.example.com/token',
);
});
it('should handle network errors gracefully', async () => {
mockFetch.mockRejectedValue(new Error('Network error'));
await expect(discoverTokenEndpoint('https://example.com')).rejects.toThrow(
/Failed to discover OAuth token endpoint/,
);
});
});
describe('getOAuthTokenWithExpiry', () => {
beforeEach(() => {
mockFetch.mockReset();
});
afterEach(() => {
vi.clearAllMocks();
});
it('scopes cached tokens by the discovered token endpoint', async () => {
mockFetch.mockImplementation(async (url: string) => {
const parsedUrl = new URL(url);
if (parsedUrl.pathname.endsWith('/.well-known/oauth-authorization-server')) {
return {
ok: true,
json: async () => ({
token_endpoint:
parsedUrl.hostname === 'agent-a.example.com'
? 'https://auth-a.example.com/oauth/token'
: 'https://auth-b.example.com/oauth/token',
}),
};
}
if (url === 'https://auth-a.example.com/oauth/token') {
return {
ok: true,
json: async () => ({ access_token: 'token-a', expires_in: 3600 }),
};
}
if (url === 'https://auth-b.example.com/oauth/token') {
return {
ok: true,
json: async () => ({ access_token: 'token-b', expires_in: 3600 }),
};
}
throw new Error(`Unexpected URL: ${url}`);
});
const auth: MCPOAuthClientCredentialsAuth = {
type: 'oauth',
grantType: 'client_credentials',
clientId: 'shared-client',
clientSecret: 'secret',
};
const firstToken = await getOAuthTokenWithExpiry(auth, 'https://agent-a.example.com/a2a');
const secondToken = await getOAuthTokenWithExpiry(auth, 'https://agent-b.example.com/a2a');
expect(firstToken.accessToken).toBe('token-a');
expect(secondToken.accessToken).toBe('token-b');
expect(
mockFetch.mock.calls
.map(([url]) => String(url))
.filter((url) => url.includes('/oauth/token')),
).toEqual(['https://auth-a.example.com/oauth/token', 'https://auth-b.example.com/oauth/token']);
});
});