From a93fe46ad2c8152347ca52407b0e6ba6708fb258 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Thu, 15 Jan 2026 14:27:18 -0500 Subject: [PATCH] fix: Add MCP protocol support to agent health checks Addie's probe_adcp_agent was reporting MCP agents as "unreachable" because validateAgentCards only checked for A2A-style agent-card.json endpoints. MCP agents use streamable HTTP instead. Changes: - Refactored validateSingleAgentCard to try A2A first, then MCP as fallback - Added 5s timeout to MCP validation using Promise.race - Returns combined errors when both protocols fail - Added tests for MCP protocol support Co-Authored-By: Claude Opus 4.5 --- .changeset/afraid-times-cover.md | 2 + server/src/adagents-manager.ts | 184 ++++++++++++++------- server/tests/unit/adagents-manager.test.ts | 72 ++++++++ 3 files changed, 199 insertions(+), 59 deletions(-) create mode 100644 .changeset/afraid-times-cover.md diff --git a/.changeset/afraid-times-cover.md b/.changeset/afraid-times-cover.md new file mode 100644 index 0000000000..a845151cc8 --- /dev/null +++ b/.changeset/afraid-times-cover.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/server/src/adagents-manager.ts b/server/src/adagents-manager.ts index 2b338a9e51..c95ae157c1 100644 --- a/server/src/adagents-manager.ts +++ b/server/src/adagents-manager.ts @@ -492,7 +492,7 @@ export class AdAgentsManager { } /** - * Validates a single agent's card endpoint + * Validates a single agent's card endpoint (supports both A2A and MCP protocols) */ private async validateSingleAgentCard(agentUrl: string): Promise { const result: AgentCardValidationResult = { @@ -501,73 +501,139 @@ export class AdAgentsManager { errors: [] }; - try { - const startTime = Date.now(); - - // Try to fetch agent card (A2A standard and root fallback) - const cardEndpoints = [ - `${agentUrl}/.well-known/agent-card.json`, // A2A protocol standard - agentUrl // Sometimes the main URL returns the card - ]; - - let cardFound = false; - - for (const endpoint of cardEndpoints) { - try { - const response = await axios.get(endpoint, { - timeout: 3000, // Keep short for responsive UX - headers: { - 'Accept': 'application/json', - 'User-Agent': 'AdCP-Testing-Framework/1.0' - }, - validateStatus: () => true - }); + const startTime = Date.now(); + + // Try A2A first (agent-card.json endpoints) + const a2aResult = await this.tryA2AValidation(agentUrl, startTime); + if (a2aResult.valid) { + return a2aResult; + } + + // If A2A failed, try MCP protocol + const mcpResult = await this.tryMCPValidation(agentUrl, startTime); + if (mcpResult.valid) { + return mcpResult; + } + + // Both failed - return combined errors + result.response_time_ms = Date.now() - startTime; + result.errors = [ + 'Agent not reachable via A2A or MCP protocols', + ...a2aResult.errors.map(e => `A2A: ${e}`), + ...mcpResult.errors.map(e => `MCP: ${e}`) + ]; + + return result; + } + + /** + * Try A2A protocol validation (agent-card.json) + */ + private async tryA2AValidation(agentUrl: string, startTime: number): Promise { + const result: AgentCardValidationResult = { + agent_url: agentUrl, + valid: false, + errors: [] + }; + + // Try to fetch agent card (A2A standard and root fallback) + const cardEndpoints = [ + `${agentUrl}/.well-known/agent-card.json`, // A2A protocol standard + agentUrl // Sometimes the main URL returns the card + ]; + + for (const endpoint of cardEndpoints) { + try { + const response = await axios.get(endpoint, { + timeout: 3000, // Keep short for responsive UX + headers: { + 'Accept': 'application/json', + 'User-Agent': 'AdCP-Testing-Framework/1.0' + }, + validateStatus: () => true + }); + + result.response_time_ms = Date.now() - startTime; + result.status_code = response.status; - result.response_time_ms = Date.now() - startTime; - result.status_code = response.status; - - if (response.status === 200) { - result.card_data = response.data; - result.card_endpoint = endpoint; - cardFound = true; - - // Check content-type header - const contentType = response.headers['content-type'] || ''; - const isJsonContentType = contentType.includes('application/json'); - - // Basic validation of card structure - if (typeof response.data === 'object' && response.data !== null) { - if (!isJsonContentType) { - result.errors.push(`Endpoint returned JSON data but with content-type: ${contentType}. Should be application/json`); - result.valid = false; - } else { - result.valid = true; - } + if (response.status === 200) { + result.card_data = response.data; + result.card_endpoint = endpoint; + + // Check content-type header + const contentType = response.headers['content-type'] || ''; + const isJsonContentType = contentType.includes('application/json'); + + // Basic validation of card structure + if (typeof response.data === 'object' && response.data !== null) { + if (!isJsonContentType) { + result.errors.push(`Endpoint returned JSON data but with content-type: ${contentType}. Should be application/json`); + result.valid = false; + } else { + result.valid = true; + } + } else { + if (contentType.includes('text/html')) { + result.errors.push('Agent card endpoint returned HTML instead of JSON. This appears to be a website, not an agent card endpoint.'); } else { - if (contentType.includes('text/html')) { - result.errors.push('Agent card endpoint returned HTML instead of JSON. This appears to be a website, not an agent card endpoint.'); - } else { - result.errors.push(`Agent card is not a valid JSON object (content-type: ${contentType})`); - } + result.errors.push(`Agent card is not a valid JSON object (content-type: ${contentType})`); } - break; } - } catch (endpointError) { - // Try next endpoint - continue; + return result; } + } catch (endpointError) { + // Try next endpoint + continue; } + } - if (!cardFound) { - result.errors.push('No agent card found at /.well-known/agent-card.json or root URL'); - } + result.errors.push('No agent card found at /.well-known/agent-card.json or root URL'); + return result; + } + + /** + * Try MCP protocol validation (streamable HTTP) + */ + private async tryMCPValidation(agentUrl: string, startTime: number): Promise { + const result: AgentCardValidationResult = { + agent_url: agentUrl, + valid: false, + errors: [] + }; + const MCP_TIMEOUT_MS = 5000; // Match timeout used in health.ts + + try { + const { AdCPClient } = await import('@adcp/client'); + const multiClient = new AdCPClient([{ + id: 'health-check', + name: 'Health Checker', + agent_uri: agentUrl, + protocol: 'mcp', + }]); + const client = multiClient.agent('health-check'); + + // Add timeout to prevent hanging on slow/unresponsive agents + const agentInfo = await Promise.race([ + client.getAgentInfo(), + new Promise((_, reject) => + setTimeout(() => reject(new Error('MCP connection timed out')), MCP_TIMEOUT_MS) + ), + ]); + + result.response_time_ms = Date.now() - startTime; + result.valid = true; + result.card_endpoint = agentUrl; + result.card_data = { + name: agentInfo.name, + protocol: 'mcp', + tools: agentInfo.tools?.map(t => t.name) || [], + tools_count: agentInfo.tools?.length || 0, + }; } catch (error) { - if (axios.isAxiosError(error)) { - result.errors.push(`Network error: ${error.message}`); - } else { - result.errors.push('Unknown error occurred while validating agent card'); - } + result.response_time_ms = Date.now() - startTime; + const message = error instanceof Error ? error.message : 'Unknown error'; + result.errors.push(`MCP connection failed: ${message}`); } return result; diff --git a/server/tests/unit/adagents-manager.test.ts b/server/tests/unit/adagents-manager.test.ts index 5fc92534bf..c6375ecd03 100644 --- a/server/tests/unit/adagents-manager.test.ts +++ b/server/tests/unit/adagents-manager.test.ts @@ -730,6 +730,78 @@ describe('AdAgentsManager', () => { }); }); + describe('MCP Protocol Support', () => { + it('falls back to MCP when A2A endpoints return 404', async () => { + const agents: AuthorizedAgent[] = [ + { + url: 'https://mcp-agent.example.com/mcp', + authorized_for: 'Test', + }, + ]; + + // A2A endpoints return 404 + mockedAxios.get.mockResolvedValue({ + status: 404, + data: {}, + headers: {}, + }); + + // Mock MCP client + vi.doMock('@adcp/client', () => ({ + AdCPClient: class { + agent() { + return { + getAgentInfo: () => + Promise.resolve({ + name: 'MCP Test Agent', + tools: [{ name: 'tool1' }, { name: 'tool2' }], + }), + }; + } + }, + })); + + const results = await manager.validateAgentCards(agents); + + expect(results[0].valid).toBe(true); + expect(results[0].card_data?.protocol).toBe('mcp'); + expect(results[0].card_data?.tools_count).toBe(2); + }); + + it('returns combined errors when both A2A and MCP fail', async () => { + const agents: AuthorizedAgent[] = [ + { + url: 'https://broken-agent.example.com', + authorized_for: 'Test', + }, + ]; + + // A2A endpoints return 404 + mockedAxios.get.mockResolvedValue({ + status: 404, + data: {}, + headers: {}, + }); + + // Mock MCP client to fail + vi.doMock('@adcp/client', () => ({ + AdCPClient: class { + agent() { + return { + getAgentInfo: () => Promise.reject(new Error('Connection refused')), + }; + } + }, + })); + + const results = await manager.validateAgentCards(agents); + + expect(results[0].valid).toBe(false); + expect(results[0].errors.some((e) => e.includes('A2A'))).toBe(true); + expect(results[0].errors.some((e) => e.includes('MCP'))).toBe(true); + }); + }); + describe('validateProposed', () => { it('validates proposed agents without making HTTP requests', () => { const agents: AuthorizedAgent[] = [