Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .changeset/afraid-times-cover.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
184 changes: 125 additions & 59 deletions server/src/adagents-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<AgentCardValidationResult> {
const result: AgentCardValidationResult = {
Expand All @@ -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<AgentCardValidationResult> {
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<AgentCardValidationResult> {
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<never>((_, 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;
Expand Down
72 changes: 72 additions & 0 deletions server/tests/unit/adagents-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [
Expand Down