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