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
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:
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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({});
|
||||
});
|
||||
});
|
||||
@@ -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/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user