From fc24a3e9df074ddf534e71aef6f0fde9f6d8b2b0 Mon Sep 17 00:00:00 2001 From: OlaCryto Date: Fri, 13 Mar 2026 05:44:22 -0700 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20native=20A2A=20protocol=20support?= =?UTF-8?q?=20=E2=80=94=20Phase=208?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A2A client for agent card discovery and full task lifecycle (submit, poll, stream, cancel). A2A server to expose evalanche skills as A2A-compliant endpoints. Economy adapters bridging A2A concepts to discovery, negotiation, and settlement. 6 new MCP tools: fetch_agent_card, a2a_list_skills, a2a_submit_task, a2a_get_task, a2a_cancel_task, a2a_serve. 49 new tests across 3 test files (421 total passing). --- src/interop/a2a-adapters.ts | 191 ++++++++++++++ src/interop/a2a-server.ts | 337 +++++++++++++++++++++++ src/interop/a2a.ts | 426 ++++++++++++++++++++++++++++++ src/interop/index.ts | 34 ++- src/interop/schemas.ts | 100 +++++++ src/mcp/server.ts | 172 ++++++++++++ src/utils/errors.ts | 2 + test/interop/a2a-adapters.test.ts | 255 ++++++++++++++++++ test/interop/a2a-server.test.ts | 272 +++++++++++++++++++ test/interop/a2a.test.ts | 261 ++++++++++++++++++ 10 files changed, 2047 insertions(+), 3 deletions(-) create mode 100644 src/interop/a2a-adapters.ts create mode 100644 src/interop/a2a-server.ts create mode 100644 src/interop/a2a.ts create mode 100644 test/interop/a2a-adapters.test.ts create mode 100644 test/interop/a2a-server.test.ts create mode 100644 test/interop/a2a.test.ts diff --git a/src/interop/a2a-adapters.ts b/src/interop/a2a-adapters.ts new file mode 100644 index 0000000..4b662c4 --- /dev/null +++ b/src/interop/a2a-adapters.ts @@ -0,0 +1,191 @@ +/** + * A2A ↔ Evalanche Economy Adapters + * + * Maps A2A protocol concepts to evalanche economy primitives: + * - Agent Card skills → DiscoveryClient AgentService shape + * - A2A task submission → NegotiationClient.propose() + * - A2A task completion → settlement trigger + * - A2A task failure → negotiation rejection + escrow refund + * - AgentCard → AgentRegistration bridge + */ +import type { AgentService, DiscoveryQuery } from '../economy/types'; +import type { NegotiationClient } from '../economy/negotiation'; +import type { EscrowClient } from '../economy/escrow'; +import type { + AgentCard, + A2ASkill, + A2ATask, + AgentRegistration, + AgentServiceEntry, +} from './schemas'; + +// ── Agent Card → Discovery Mapping ── + +/** + * Convert an A2A Agent Card skill into an evalanche AgentService shape. + * This allows A2A-discovered agents to appear in evalanche's DiscoveryClient. + */ +export function skillToAgentService( + skill: A2ASkill, + card: AgentCard, + agentId: string, +): AgentService { + return { + agentId, + capability: skill.id, + description: `${skill.name}: ${skill.description}`, + endpoint: card.url, + pricePerCall: '0', + chainId: 1, + registeredAt: Date.now(), + tags: skill.tags ?? [], + }; +} + +/** + * Convert all skills from an Agent Card into evalanche AgentService entries. + */ +export function cardToAgentServices(card: AgentCard, agentId: string): AgentService[] { + return card.skills.map((skill) => skillToAgentService(skill, card, agentId)); +} + +/** + * Bridge an A2A AgentCard to an ERC-8004 AgentRegistration shape. + * Useful for treating A2A and ERC-8004 as interchangeable discovery sources. + */ +export function cardToRegistration(card: AgentCard, walletAddress?: string): AgentRegistration { + const services: AgentServiceEntry[] = [ + { name: 'A2A', endpoint: card.url, version: card.version }, + ]; + + return { + name: card.name, + description: card.description ?? '', + agentWallet: walletAddress ?? '', + active: true, + services, + x402Support: card.authentication?.type === 'x402', + supportedTrust: [], + registrations: [], + }; +} + +// ── Task → Negotiation Mapping ── + +/** Parameters for creating a negotiation proposal from an A2A task */ +export interface A2ATaskProposalParams { + /** The agent card of the target agent */ + card: AgentCard; + /** The skill to invoke */ + skillId: string; + /** Task input text */ + input: string; + /** Proposed price in wei */ + price: string; + /** Chain ID for payment */ + chainId: number; + /** ID of the proposing agent */ + fromAgentId: string; + /** ID of the target agent */ + toAgentId: string; + /** TTL for the proposal in ms */ + ttlMs?: number; +} + +/** + * Create a negotiation proposal backed by an A2A task intent. + * Returns the proposal ID — use it to track the proposal lifecycle. + */ +export function createA2AProposal( + negotiation: NegotiationClient, + params: A2ATaskProposalParams, +): string { + return negotiation.propose({ + fromAgentId: params.fromAgentId, + toAgentId: params.toAgentId, + task: `a2a:${params.skillId}`, + price: params.price, + chainId: params.chainId, + ttlMs: params.ttlMs, + }); +} + +// ── Task Completion → Settlement ── + +/** + * Handle A2A task completion — triggers settlement if proposal exists. + * + * When an A2A task completes successfully with artifacts, + * this maps it to the evalanche settlement flow. + */ +export function mapTaskCompletion(task: A2ATask): { + completed: boolean; + failed: boolean; + artifacts: Array<{ name?: string; mimeType?: string; text?: string; uri?: string }>; + error?: string; +} { + const completed = task.status === 'completed'; + const failed = task.status === 'failed' || task.status === 'canceled'; + + return { + completed, + failed, + artifacts: task.artifacts.map((a) => ({ + name: a.name, + mimeType: a.mimeType, + text: a.text, + uri: a.uri, + })), + error: task.error?.message, + }; +} + +/** + * Handle A2A task failure — reject negotiation and refund escrow if funded. + */ +export async function handleTaskFailure( + task: A2ATask, + proposalId: string, + negotiation: NegotiationClient, + escrow?: EscrowClient, + jobId?: string, +): Promise { + // Reject the negotiation + try { + negotiation.reject(proposalId); + } catch { + // May already be in a terminal state — that's fine + } + + // Refund escrow if it was funded + if (escrow && jobId) { + try { + await escrow.refund(jobId); + } catch { + // Escrow may not exist or already be released + } + } +} + +// ── Discovery Query Helpers ── + +/** + * Build a DiscoveryQuery that matches A2A-sourced services. + */ +export function buildA2ADiscoveryQuery(options?: { + capability?: string; + tag?: string; + supportsStreaming?: boolean; +}): DiscoveryQuery { + const query: DiscoveryQuery = {}; + + if (options?.capability) { + query.capability = options.capability; + } + + if (options?.tag) { + query.tags = [options.tag]; + } + + return query; +} diff --git a/src/interop/a2a-server.ts b/src/interop/a2a-server.ts new file mode 100644 index 0000000..3103c2b --- /dev/null +++ b/src/interop/a2a-server.ts @@ -0,0 +1,337 @@ +/** + * A2A Server — Wrap evalanche capabilities as an A2A-compliant agent. + * + * Generates `agent-card.json` from registered skills, + * serves `/.well-known/agent-card.json`, and routes incoming + * A2A task requests to registered handlers. + * + * Usage: + * ```ts + * const server = new A2AServer({ name: 'MyAgent', url: 'https://my-agent.com' }); + * + * server.registerSkill({ + * id: 'audit', + * name: 'Smart Contract Audit', + * description: 'Security audit for Solidity contracts', + * handler: async (input) => ({ text: 'Audit complete. No vulnerabilities found.' }), + * }); + * + * const httpServer = server.listen(3000); + * ``` + */ +import { createServer, type Server, type IncomingMessage, type ServerResponse } from 'http'; +import { randomBytes } from 'crypto'; +import { EvalancheError, EvalancheErrorCode } from '../utils/errors'; +import type { + AgentCard, + A2ASkill, + A2ATask, + A2ATaskStatus, + A2AMessage, + A2AArtifact, + A2AAuthentication, +} from './schemas'; + +/** Handler function for an A2A skill */ +export type SkillHandler = (input: string, metadata?: Record) => Promise; + +/** Result from a skill handler */ +export interface SkillResult { + /** Text output */ + text?: string; + /** Binary artifact */ + data?: { name: string; mimeType: string; content: string }; + /** External URI to result */ + uri?: string; + /** Additional metadata */ + metadata?: Record; +} + +/** Skill registration with handler */ +export interface RegisteredSkill extends A2ASkill { + /** The function that handles this skill */ + handler: SkillHandler; +} + +/** Options for creating an A2A server */ +export interface A2AServerOptions { + /** Agent display name */ + name: string; + /** Base URL where this agent is hosted */ + url: string; + /** Agent description */ + description?: string; + /** Agent version */ + version?: string; + /** Provider info */ + provider?: { name: string; url?: string }; + /** Authentication config */ + authentication?: A2AAuthentication; + /** Whether to support streaming */ + supportsStreaming?: boolean; +} + +/** + * A2AServer wraps evalanche agent capabilities as an A2A-compliant endpoint. + */ +export class A2AServer { + private readonly _options: A2AServerOptions; + private readonly _skills: Map = new Map(); + private readonly _tasks: Map = new Map(); + private _server: Server | null = null; + + constructor(options: A2AServerOptions) { + this._options = options; + } + + /** + * Register a skill that this agent can perform. + */ + registerSkill(skill: Omit & { id?: string; handler: SkillHandler }): string { + const id = skill.id ?? `skill_${randomBytes(4).toString('hex')}`; + + this._skills.set(id, { + id, + name: skill.name, + description: skill.description, + tags: skill.tags, + inputModes: skill.inputModes, + outputModes: skill.outputModes, + examples: skill.examples, + handler: skill.handler, + }); + + return id; + } + + /** + * Remove a registered skill. + */ + unregisterSkill(skillId: string): boolean { + return this._skills.delete(skillId); + } + + /** + * Generate the agent card for this server. + */ + getAgentCard(): AgentCard { + const skills: A2ASkill[] = Array.from(this._skills.values()).map( + ({ handler: _, ...skill }) => skill, + ); + + return { + name: this._options.name, + description: this._options.description ?? '', + url: this._options.url, + version: this._options.version, + provider: this._options.provider, + authentication: this._options.authentication, + skills, + defaultInputModes: ['text'], + defaultOutputModes: ['text'], + supportsStreaming: this._options.supportsStreaming ?? false, + }; + } + + /** + * Start an HTTP server that serves the A2A protocol. + */ + listen(port: number): Server { + this._server = createServer((req, res) => { + this._handleRequest(req, res).catch(() => { + if (!res.headersSent) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Internal server error' })); + } + }); + }); + + this._server.listen(port); + return this._server; + } + + /** + * Stop the server. + */ + close(): Promise { + return new Promise((resolve) => { + if (this._server) { + this._server.close(() => resolve()); + } else { + resolve(); + } + }); + } + + /** Get a task by ID (for testing/inspection) */ + getTask(taskId: string): A2ATask | undefined { + return this._tasks.get(taskId); + } + + // ── HTTP Request Routing ── + + private async _handleRequest(req: IncomingMessage, res: ServerResponse): Promise { + const url = req.url ?? '/'; + const method = req.method ?? 'GET'; + + // CORS preflight + if (method === 'OPTIONS') { + res.writeHead(204, { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type, Authorization', + }); + res.end(); + return; + } + + const cors = { 'Access-Control-Allow-Origin': '*' }; + + // Agent card endpoint + if (url === '/.well-known/agent-card.json' && method === 'GET') { + const card = this.getAgentCard(); + res.writeHead(200, { ...cors, 'Content-Type': 'application/json' }); + res.end(JSON.stringify(card)); + return; + } + + // Submit task + if (url === '/tasks' && method === 'POST') { + const body = await this._parseBody(req); + const task = await this._handleSubmitTask(body); + res.writeHead(201, { ...cors, 'Content-Type': 'application/json' }); + res.end(JSON.stringify(task)); + return; + } + + // Get task + const taskMatch = url.match(/^\/tasks\/([^/]+)$/); + if (taskMatch && method === 'GET') { + const task = this._tasks.get(taskMatch[1]); + if (!task) { + res.writeHead(404, { ...cors, 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Task not found' })); + return; + } + res.writeHead(200, { ...cors, 'Content-Type': 'application/json' }); + res.end(JSON.stringify(task)); + return; + } + + // Cancel task + const cancelMatch = url.match(/^\/tasks\/([^/]+)\/cancel$/); + if (cancelMatch && method === 'POST') { + const task = this._tasks.get(cancelMatch[1]); + if (!task) { + res.writeHead(404, { ...cors, 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Task not found' })); + return; + } + if (task.status === 'completed' || task.status === 'failed' || task.status === 'canceled') { + res.writeHead(400, { ...cors, 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: `Cannot cancel task in '${task.status}' state` })); + return; + } + task.status = 'canceled'; + res.writeHead(200, { ...cors, 'Content-Type': 'application/json' }); + res.end(JSON.stringify(task)); + return; + } + + // 404 + res.writeHead(404, { ...cors, 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Not found' })); + } + + /** Handle task submission: validate, create task, execute handler */ + private async _handleSubmitTask(body: Record): Promise { + const skillId = body.skill_id as string; + if (!skillId) { + throw new EvalancheError('Missing skill_id in task submission', EvalancheErrorCode.A2A_ERROR); + } + + const skill = this._skills.get(skillId); + if (!skill) { + throw new EvalancheError(`Unknown skill: ${skillId}`, EvalancheErrorCode.A2A_ERROR); + } + + // Extract input from messages + const messages = body.messages as A2AMessage[] | undefined; + let input = ''; + if (messages && messages.length > 0) { + const firstMsg = messages[0]; + if (firstMsg.parts && firstMsg.parts.length > 0) { + const textPart = firstMsg.parts.find((p) => p.type === 'text'); + if (textPart && 'text' in textPart) { + input = textPart.text; + } + } + } + + const taskId = `task_${randomBytes(8).toString('hex')}`; + const task: A2ATask = { + id: taskId, + status: 'submitted', + messages: messages ?? [], + artifacts: [], + metadata: body.metadata as Record | undefined, + }; + this._tasks.set(taskId, task); + + // Execute handler asynchronously + task.status = 'working'; + this._executeSkill(task, skill, input).catch(() => { + // Error already handled inside _executeSkill + }); + + return task; + } + + /** Execute a skill handler and update task state */ + private async _executeSkill(task: A2ATask, skill: RegisteredSkill, input: string): Promise { + try { + const result = await skill.handler(input, task.metadata); + + const artifacts: A2AArtifact[] = []; + if (result.text) { + artifacts.push({ name: 'response', mimeType: 'text/plain', text: result.text }); + } + if (result.data) { + artifacts.push({ name: result.data.name, mimeType: result.data.mimeType, data: result.data.content }); + } + if (result.uri) { + artifacts.push({ name: 'result', uri: result.uri }); + } + + task.artifacts = artifacts; + task.messages.push({ + role: 'agent', + parts: [{ type: 'text', text: result.text ?? 'Task completed' }], + }); + task.status = 'completed'; + } catch (error) { + task.status = 'failed'; + task.error = { + code: 'HANDLER_ERROR', + message: error instanceof Error ? error.message : String(error), + }; + } + } + + /** Parse JSON body from request */ + private _parseBody(req: IncomingMessage): Promise> { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + req.on('data', (chunk: Buffer) => chunks.push(chunk)); + req.on('end', () => { + try { + const raw = Buffer.concat(chunks).toString('utf-8'); + resolve(raw ? JSON.parse(raw) : {}); + } catch { + reject(new Error('Invalid JSON body')); + } + }); + req.on('error', reject); + }); + } +} diff --git a/src/interop/a2a.ts b/src/interop/a2a.ts new file mode 100644 index 0000000..3d7527b --- /dev/null +++ b/src/interop/a2a.ts @@ -0,0 +1,426 @@ +/** + * A2A Protocol Client — Agent-to-Agent interaction following the A2A spec. + * + * Supports: + * - Fetching and parsing Agent Cards from `.well-known/agent-card.json` + * - Resolving agent cards via ERC-8004 identity + * - Full task lifecycle: submit, poll, stream, cancel + * + * Usage: + * ```ts + * const a2a = new A2AClient(); + * + * // Fetch an agent's card + * const card = await a2a.fetchAgentCard('https://agent.example.com'); + * + * // Submit a task + * const task = await a2a.submitTask('https://agent.example.com', 'audit', 'Audit this contract: 0x...'); + * + * // Poll for completion + * const result = await a2a.getTask('https://agent.example.com', task.id); + * ``` + */ +import { EvalancheError, EvalancheErrorCode } from '../utils/errors'; +import type { InteropIdentityResolver } from './identity'; +import type { + AgentCard, + A2ASkill, + A2ATask, + A2ATaskStatus, + A2AMessage, + A2AArtifact, +} from './schemas'; + +/** Options for submitting a task */ +export interface SubmitTaskOptions { + /** Skill ID to invoke */ + skillId: string; + /** Input text or prompt */ + input: string; + /** Optional metadata to attach */ + metadata?: Record; + /** Authorization header value (e.g., 'Bearer xxx') */ + auth?: string; +} + +/** Callback for streaming task updates */ +export type TaskUpdateCallback = (event: { + status: A2ATaskStatus; + message?: A2AMessage; + artifact?: A2AArtifact; +}) => void; + +/** + * A2AClient handles all A2A protocol interactions. + * + * Can optionally use an `InteropIdentityResolver` to resolve + * agent cards from ERC-8004 identities (agentId → A2A endpoint → card). + */ +export class A2AClient { + private readonly _identity?: InteropIdentityResolver; + private readonly _fetchFn: typeof globalThis.fetch; + + constructor(options?: { + identity?: InteropIdentityResolver; + fetch?: typeof globalThis.fetch; + }) { + this._identity = options?.identity; + this._fetchFn = options?.fetch ?? globalThis.fetch.bind(globalThis); + } + + // ── Agent Card (Step 8.1) ── + + /** + * Fetch an agent card from a base URL. + * Looks up `{baseUrl}/.well-known/agent-card.json`. + */ + async fetchAgentCard(baseUrl: string): Promise { + const url = baseUrl.replace(/\/+$/, '') + '/.well-known/agent-card.json'; + + let response: Response; + try { + response = await this._fetchFn(url, { + headers: { Accept: 'application/json' }, + }); + } catch (error) { + throw new EvalancheError( + `Failed to fetch agent card from ${url}: ${error instanceof Error ? error.message : String(error)}`, + EvalancheErrorCode.A2A_ERROR, + error instanceof Error ? error : undefined, + ); + } + + if (!response.ok) { + throw new EvalancheError( + `Agent card not found at ${url} (HTTP ${response.status})`, + EvalancheErrorCode.A2A_ERROR, + ); + } + + let data: unknown; + try { + data = await response.json(); + } catch { + throw new EvalancheError( + `Invalid JSON in agent card at ${url}`, + EvalancheErrorCode.A2A_ERROR, + ); + } + + return this._validateAgentCard(data, url); + } + + /** + * Resolve an agent card from an ERC-8004 agent ID. + * Chains: agentId → identity resolution → A2A endpoint → agent card. + * + * Requires an `InteropIdentityResolver` to be configured. + */ + async resolveAgentCardFromERC8004(agentId: string): Promise { + if (!this._identity) { + throw new EvalancheError( + 'InteropIdentityResolver required to resolve agent cards from ERC-8004 IDs', + EvalancheErrorCode.A2A_ERROR, + ); + } + + const registration = await this._identity.resolveAgent(agentId); + const a2aService = registration.services.find((s) => s.name === 'A2A'); + + if (!a2aService) { + throw new EvalancheError( + `Agent ${agentId} has no A2A service endpoint registered`, + EvalancheErrorCode.A2A_ERROR, + ); + } + + return this.fetchAgentCard(a2aService.endpoint); + } + + /** + * List skills from an agent card. + * Convenience method that returns just the skills array. + */ + listSkills(card: AgentCard): A2ASkill[] { + return card.skills; + } + + /** + * Find a skill by ID or tag match. + */ + findSkill(card: AgentCard, query: { id?: string; tag?: string }): A2ASkill | undefined { + if (query.id) { + return card.skills.find((s) => s.id === query.id); + } + if (query.tag) { + const tag = query.tag.toLowerCase(); + return card.skills.find((s) => s.tags?.some((t) => t.toLowerCase() === tag)); + } + return undefined; + } + + // ── Task Lifecycle (Step 8.2) ── + + /** + * Submit a task to an A2A agent. + * Returns the created task with its ID and initial status. + */ + async submitTask(baseUrl: string, options: SubmitTaskOptions): Promise { + const url = baseUrl.replace(/\/+$/, '') + '/tasks'; + + const body = { + skill_id: options.skillId, + messages: [ + { + role: 'user', + parts: [{ type: 'text', text: options.input }], + }, + ], + metadata: options.metadata, + }; + + const headers: Record = { + 'Content-Type': 'application/json', + Accept: 'application/json', + }; + if (options.auth) { + headers['Authorization'] = options.auth; + } + + let response: Response; + try { + response = await this._fetchFn(url, { + method: 'POST', + headers, + body: JSON.stringify(body), + }); + } catch (error) { + throw new EvalancheError( + `Failed to submit A2A task to ${url}: ${error instanceof Error ? error.message : String(error)}`, + EvalancheErrorCode.A2A_ERROR, + error instanceof Error ? error : undefined, + ); + } + + if (!response.ok) { + const errorText = await response.text().catch(() => 'Unknown error'); + throw new EvalancheError( + `A2A task submission failed (HTTP ${response.status}): ${errorText}`, + EvalancheErrorCode.A2A_TASK_FAILED, + ); + } + + const data = await response.json() as Record; + return this._parseTask(data); + } + + /** + * Get the current status and artifacts of a task. + */ + async getTask(baseUrl: string, taskId: string, auth?: string): Promise { + const url = baseUrl.replace(/\/+$/, '') + `/tasks/${encodeURIComponent(taskId)}`; + + const headers: Record = { Accept: 'application/json' }; + if (auth) headers['Authorization'] = auth; + + let response: Response; + try { + response = await this._fetchFn(url, { headers }); + } catch (error) { + throw new EvalancheError( + `Failed to get A2A task ${taskId}: ${error instanceof Error ? error.message : String(error)}`, + EvalancheErrorCode.A2A_ERROR, + error instanceof Error ? error : undefined, + ); + } + + if (!response.ok) { + throw new EvalancheError( + `A2A task ${taskId} not found (HTTP ${response.status})`, + EvalancheErrorCode.A2A_ERROR, + ); + } + + const data = await response.json() as Record; + return this._parseTask(data); + } + + /** + * Stream task updates via SSE. + * Calls `onUpdate` for each status change, message, or artifact. + * Returns an abort function to stop streaming. + */ + async streamTask( + baseUrl: string, + taskId: string, + onUpdate: TaskUpdateCallback, + auth?: string, + ): Promise<{ abort: () => void }> { + const url = baseUrl.replace(/\/+$/, '') + `/tasks/${encodeURIComponent(taskId)}/stream`; + + const headers: Record = { Accept: 'text/event-stream' }; + if (auth) headers['Authorization'] = auth; + + const controller = new AbortController(); + + let response: Response; + try { + response = await this._fetchFn(url, { + headers, + signal: controller.signal, + }); + } catch (error) { + if ((error as Error).name === 'AbortError') { + return { abort: () => {} }; + } + throw new EvalancheError( + `Failed to stream A2A task ${taskId}: ${error instanceof Error ? error.message : String(error)}`, + EvalancheErrorCode.A2A_ERROR, + error instanceof Error ? error : undefined, + ); + } + + if (!response.ok || !response.body) { + throw new EvalancheError( + `A2A task stream failed (HTTP ${response.status})`, + EvalancheErrorCode.A2A_ERROR, + ); + } + + // Process SSE stream in background + this._processSSEStream(response.body, onUpdate).catch(() => { + // Stream ended or errored — silently stop + }); + + return { abort: () => controller.abort() }; + } + + /** + * Cancel an in-progress task. + */ + async cancelTask(baseUrl: string, taskId: string, auth?: string): Promise { + const url = baseUrl.replace(/\/+$/, '') + `/tasks/${encodeURIComponent(taskId)}/cancel`; + + const headers: Record = { + 'Content-Type': 'application/json', + Accept: 'application/json', + }; + if (auth) headers['Authorization'] = auth; + + let response: Response; + try { + response = await this._fetchFn(url, { method: 'POST', headers }); + } catch (error) { + throw new EvalancheError( + `Failed to cancel A2A task ${taskId}: ${error instanceof Error ? error.message : String(error)}`, + EvalancheErrorCode.A2A_ERROR, + error instanceof Error ? error : undefined, + ); + } + + if (!response.ok) { + throw new EvalancheError( + `A2A task cancellation failed (HTTP ${response.status})`, + EvalancheErrorCode.A2A_TASK_FAILED, + ); + } + + const data = await response.json() as Record; + return this._parseTask(data); + } + + // ── Internal Helpers ── + + /** Validate and type an agent card response */ + private _validateAgentCard(data: unknown, source: string): AgentCard { + const card = data as Record; + + if (!card || typeof card !== 'object') { + throw new EvalancheError(`Invalid agent card from ${source}: not an object`, EvalancheErrorCode.A2A_ERROR); + } + + if (typeof card.name !== 'string' || !card.name) { + throw new EvalancheError(`Invalid agent card from ${source}: missing name`, EvalancheErrorCode.A2A_ERROR); + } + + if (typeof card.url !== 'string' || !card.url) { + throw new EvalancheError(`Invalid agent card from ${source}: missing url`, EvalancheErrorCode.A2A_ERROR); + } + + if (!Array.isArray(card.skills)) { + throw new EvalancheError(`Invalid agent card from ${source}: missing skills array`, EvalancheErrorCode.A2A_ERROR); + } + + // Validate each skill has at minimum id and name + for (const skill of card.skills) { + const s = skill as Record; + if (typeof s.id !== 'string' || typeof s.name !== 'string') { + throw new EvalancheError(`Invalid skill in agent card from ${source}: missing id or name`, EvalancheErrorCode.A2A_ERROR); + } + } + + return { + name: card.name as string, + description: (card.description as string) ?? '', + url: card.url as string, + version: card.version as string | undefined, + provider: card.provider as AgentCard['provider'], + authentication: card.authentication as AgentCard['authentication'], + skills: card.skills as A2ASkill[], + defaultInputModes: card.defaultInputModes as AgentCard['defaultInputModes'], + defaultOutputModes: card.defaultOutputModes as AgentCard['defaultOutputModes'], + supportsStreaming: card.supportsStreaming as boolean | undefined, + supportsPushNotifications: card.supportsPushNotifications as boolean | undefined, + }; + } + + /** Parse a task response into typed A2ATask */ + private _parseTask(data: Record): A2ATask { + return { + id: (data.id as string) ?? '', + status: (data.status as A2ATaskStatus) ?? 'submitted', + messages: (data.messages as A2AMessage[]) ?? [], + artifacts: (data.artifacts as A2AArtifact[]) ?? [], + error: data.error as A2ATask['error'], + metadata: data.metadata as Record | undefined, + }; + } + + /** Process an SSE stream body */ + private async _processSSEStream(body: ReadableStream, onUpdate: TaskUpdateCallback): Promise { + const reader = body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop() ?? ''; + + for (const line of lines) { + if (line.startsWith('data: ')) { + const jsonStr = line.slice(6).trim(); + if (!jsonStr || jsonStr === '[DONE]') continue; + + try { + const event = JSON.parse(jsonStr) as Record; + onUpdate({ + status: (event.status as A2ATaskStatus) ?? 'working', + message: event.message as A2AMessage | undefined, + artifact: event.artifact as A2AArtifact | undefined, + }); + } catch { + // Skip malformed SSE events + } + } + } + } + } finally { + reader.releaseLock(); + } + } +} diff --git a/src/interop/index.ts b/src/interop/index.ts index 4351cc5..44a68c4 100644 --- a/src/interop/index.ts +++ b/src/interop/index.ts @@ -1,17 +1,37 @@ /** * Interop Layer — barrel exports. * - * ERC-8004 identity resolution, service endpoint discovery, - * and cross-protocol agent interoperability. + * ERC-8004 identity resolution, A2A protocol support, + * service endpoint discovery, and cross-protocol agent interoperability. * * ```ts - * import { InteropIdentityResolver } from 'evalanche/interop'; + * import { InteropIdentityResolver, A2AClient, A2AServer } from 'evalanche/interop'; * ``` */ // Identity resolver export { InteropIdentityResolver } from './identity'; +// A2A client +export { A2AClient } from './a2a'; +export type { SubmitTaskOptions, TaskUpdateCallback } from './a2a'; + +// A2A server +export { A2AServer } from './a2a-server'; +export type { SkillHandler, SkillResult, RegisteredSkill, A2AServerOptions } from './a2a-server'; + +// A2A ↔ Economy adapters +export { + skillToAgentService, + cardToAgentServices, + cardToRegistration, + createA2AProposal, + mapTaskCompletion, + handleTaskFailure, + buildA2ADiscoveryQuery, +} from './a2a-adapters'; +export type { A2ATaskProposalParams } from './a2a-adapters'; + // Shared types export type { AgentRegistration, @@ -21,4 +41,12 @@ export type { ServiceEndpoints, TransportType, TrustMode, + AgentCard, + A2ASkill, + A2ATask, + A2ATaskStatus, + A2AMessage, + A2AArtifact, + A2AModality, + A2AAuthentication, } from './schemas'; diff --git a/src/interop/schemas.ts b/src/interop/schemas.ts index 306fe6e..b1a1e31 100644 --- a/src/interop/schemas.ts +++ b/src/interop/schemas.ts @@ -57,3 +57,103 @@ export interface EndpointVerification { /** Reason for failure, if not verified */ reason?: string; } + +// ── A2A Protocol Types (v0.3+) ── + +/** Supported input/output modalities for an A2A skill */ +export type A2AModality = 'text' | 'image' | 'audio' | 'video' | 'file'; + +/** Authentication schemes supported by an A2A agent */ +export interface A2AAuthentication { + /** Auth scheme type (e.g., 'bearer', 'apiKey', 'x402') */ + type: string; + /** Where to place the credential */ + in?: 'header' | 'query'; + /** Header or query parameter name */ + name?: string; +} + +/** A single skill advertised in an A2A agent card */ +export interface A2ASkill { + /** Unique skill identifier */ + id: string; + /** Human-readable skill name */ + name: string; + /** Description of what this skill does */ + description: string; + /** Tags for categorization/search */ + tags?: string[]; + /** Input modalities accepted */ + inputModes?: A2AModality[]; + /** Output modalities produced */ + outputModes?: A2AModality[]; + /** Example prompts or inputs */ + examples?: string[]; +} + +/** A2A Agent Card — the identity card for an A2A-compliant agent */ +export interface AgentCard { + /** Agent display name */ + name: string; + /** Agent description */ + description: string; + /** Base URL of the agent's A2A endpoint */ + url: string; + /** Agent version */ + version?: string; + /** Provider/organization info */ + provider?: { name: string; url?: string }; + /** Authentication requirements */ + authentication?: A2AAuthentication; + /** Skills this agent offers */ + skills: A2ASkill[]; + /** Default input modality */ + defaultInputModes?: A2AModality[]; + /** Default output modality */ + defaultOutputModes?: A2AModality[]; + /** Whether this agent supports streaming */ + supportsStreaming?: boolean; + /** Whether this agent supports push notifications */ + supportsPushNotifications?: boolean; +} + +/** A2A task status lifecycle */ +export type A2ATaskStatus = 'submitted' | 'working' | 'input-required' | 'completed' | 'failed' | 'canceled'; + +/** An artifact produced by a task */ +export interface A2AArtifact { + /** Artifact name */ + name?: string; + /** MIME type of the artifact */ + mimeType?: string; + /** Text content (for text artifacts) */ + text?: string; + /** Base64 data (for binary artifacts) */ + data?: string; + /** CID or URL for externally stored artifacts */ + uri?: string; +} + +/** A message in a task conversation */ +export interface A2AMessage { + /** Role: user (requester) or agent (worker) */ + role: 'user' | 'agent'; + /** Message parts */ + parts: Array<{ type: 'text'; text: string } | { type: 'file'; mimeType: string; data: string }>; +} + +/** Full A2A task object */ +export interface A2ATask { + /** Unique task ID */ + id: string; + /** Current status */ + status: A2ATaskStatus; + /** Conversation history */ + messages: A2AMessage[]; + /** Artifacts produced */ + artifacts: A2AArtifact[]; + /** Error info if failed */ + error?: { code: string; message: string }; + /** Metadata */ + metadata?: Record; +} diff --git a/src/mcp/server.ts b/src/mcp/server.ts index a0873c7..0553c51 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -13,6 +13,8 @@ import { NegotiationClient } from '../economy/negotiation'; import { SettlementClient } from '../economy/settlement'; import { AgentMemory } from '../economy/memory'; import { InteropIdentityResolver } from '../interop/identity'; +import { A2AClient } from '../interop/a2a'; +import { A2AServer } from '../interop/a2a-server'; import { createServer, type IncomingMessage, type ServerResponse } from 'http'; /** MCP tool definition */ @@ -932,6 +934,83 @@ const TOOLS: MCPTool[] = [ required: ['address'], }, }, + // ── Phase 8: A2A Protocol ── + { + name: 'fetch_agent_card', + description: 'Fetch an A2A agent card from a URL or resolve one from an ERC-8004 agent ID. Returns agent name, skills, capabilities, and authentication requirements.', + inputSchema: { + type: 'object', + properties: { + url: { type: 'string', description: 'Base URL of the A2A agent (fetches .well-known/agent-card.json)' }, + agentId: { type: 'string', description: 'ERC-8004 agent ID to resolve (alternative to url)' }, + }, + }, + }, + { + name: 'a2a_list_skills', + description: 'List skills available from an A2A agent card. Returns skill IDs, names, descriptions, tags, and supported modalities.', + inputSchema: { + type: 'object', + properties: { + url: { type: 'string', description: 'Base URL of the A2A agent' }, + agentId: { type: 'string', description: 'ERC-8004 agent ID (alternative to url)' }, + }, + }, + }, + { + name: 'a2a_submit_task', + description: 'Submit a task to an A2A-compliant agent. Invokes a specific skill with input text and returns the task ID and initial status.', + inputSchema: { + type: 'object', + properties: { + url: { type: 'string', description: 'Base URL of the A2A agent' }, + skillId: { type: 'string', description: 'Skill ID to invoke' }, + input: { type: 'string', description: 'Input text or prompt for the task' }, + auth: { type: 'string', description: 'Optional authorization header value (e.g., Bearer token)' }, + }, + required: ['url', 'skillId', 'input'], + }, + }, + { + name: 'a2a_get_task', + description: 'Get the current status, messages, and artifacts of an A2A task.', + inputSchema: { + type: 'object', + properties: { + url: { type: 'string', description: 'Base URL of the A2A agent' }, + taskId: { type: 'string', description: 'Task ID to check' }, + auth: { type: 'string', description: 'Optional authorization header value' }, + }, + required: ['url', 'taskId'], + }, + }, + { + name: 'a2a_cancel_task', + description: 'Cancel an in-progress A2A task.', + inputSchema: { + type: 'object', + properties: { + url: { type: 'string', description: 'Base URL of the A2A agent' }, + taskId: { type: 'string', description: 'Task ID to cancel' }, + auth: { type: 'string', description: 'Optional authorization header value' }, + }, + required: ['url', 'taskId'], + }, + }, + { + name: 'a2a_serve', + description: 'Register a local skill as an A2A-compatible endpoint. The skill will be listed in the agent card and can receive tasks from other agents.', + inputSchema: { + type: 'object', + properties: { + skillId: { type: 'string', description: 'Unique skill identifier' }, + name: { type: 'string', description: 'Human-readable skill name' }, + description: { type: 'string', description: 'What this skill does' }, + tags: { type: 'array', items: { type: 'string' }, description: 'Tags for categorization' }, + }, + required: ['name', 'description'], + }, + }, ]; /** @@ -948,6 +1027,8 @@ export class EvalancheMCPServer { private settlement: SettlementClient; private memory: AgentMemory; private interopResolver: InteropIdentityResolver; + private a2aClient: A2AClient; + private a2aServer: A2AServer | null = null; constructor(config: EvalancheConfig) { this.config = config; @@ -958,6 +1039,7 @@ export class EvalancheMCPServer { this.settlement = new SettlementClient(this.agent.wallet, this.negotiation); this.memory = new AgentMemory(); // in-memory by default; can be swapped for file-backed this.interopResolver = new InteropIdentityResolver(this.agent.provider); + this.a2aClient = new A2AClient({ identity: this.interopResolver }); } /** Handle a JSON-RPC request and return a response */ @@ -1839,6 +1921,96 @@ export class EvalancheMCPServer { break; } + // ── Phase 8: A2A Protocol ── + + case 'fetch_agent_card': { + let card; + if (args.url) { + card = await this.a2aClient.fetchAgentCard(args.url as string); + } else if (args.agentId) { + card = await this.a2aClient.resolveAgentCardFromERC8004(args.agentId as string); + } else { + throw new EvalancheError('Provide either url or agentId', EvalancheErrorCode.A2A_ERROR); + } + result = card; + break; + } + + case 'a2a_list_skills': { + let card; + if (args.url) { + card = await this.a2aClient.fetchAgentCard(args.url as string); + } else if (args.agentId) { + card = await this.a2aClient.resolveAgentCardFromERC8004(args.agentId as string); + } else { + throw new EvalancheError('Provide either url or agentId', EvalancheErrorCode.A2A_ERROR); + } + const skills = this.a2aClient.listSkills(card); + result = { agentName: card.name, skills, count: skills.length }; + break; + } + + case 'a2a_submit_task': { + const task = await this.a2aClient.submitTask(args.url as string, { + skillId: args.skillId as string, + input: args.input as string, + auth: args.auth as string | undefined, + }); + result = { taskId: task.id, status: task.status }; + break; + } + + case 'a2a_get_task': { + const task = await this.a2aClient.getTask( + args.url as string, + args.taskId as string, + args.auth as string | undefined, + ); + result = { + taskId: task.id, + status: task.status, + messages: task.messages, + artifacts: task.artifacts, + error: task.error, + }; + break; + } + + case 'a2a_cancel_task': { + const task = await this.a2aClient.cancelTask( + args.url as string, + args.taskId as string, + args.auth as string | undefined, + ); + result = { taskId: task.id, status: task.status }; + break; + } + + case 'a2a_serve': { + if (!this.a2aServer) { + this.a2aServer = new A2AServer({ + name: 'evalanche-agent', + url: `http://localhost:3100`, + description: 'Evalanche A2A agent', + }); + this.a2aServer.listen(3100); + } + const skillId = this.a2aServer.registerSkill({ + id: args.skillId as string | undefined, + name: args.name as string, + description: args.description as string, + tags: args.tags as string[] | undefined, + handler: async (input) => ({ text: `Processed: ${input}` }), + }); + const card = this.a2aServer.getAgentCard(); + result = { + skillId, + agentCard: card, + message: `Skill registered. Agent card at http://localhost:3100/.well-known/agent-card.json`, + }; + break; + } + default: return this.error(id, -32602, `Unknown tool: ${name}`); } diff --git a/src/utils/errors.ts b/src/utils/errors.ts index 8329df9..4241e61 100644 --- a/src/utils/errors.ts +++ b/src/utils/errors.ts @@ -44,6 +44,8 @@ export enum EvalancheErrorCode { ENDPOINT_VERIFICATION_ERROR = 'ENDPOINT_VERIFICATION_ERROR', UNSUPPORTED_URI_SCHEME = 'UNSUPPORTED_URI_SCHEME', AGENT_NOT_FOUND = 'AGENT_NOT_FOUND', + A2A_ERROR = 'A2A_ERROR', + A2A_TASK_FAILED = 'A2A_TASK_FAILED', } /** Custom error class for all Evalanche SDK errors */ diff --git a/test/interop/a2a-adapters.test.ts b/test/interop/a2a-adapters.test.ts new file mode 100644 index 0000000..54ff490 --- /dev/null +++ b/test/interop/a2a-adapters.test.ts @@ -0,0 +1,255 @@ +import { describe, it, expect, vi } from 'vitest'; +import { + skillToAgentService, + cardToAgentServices, + cardToRegistration, + createA2AProposal, + mapTaskCompletion, + handleTaskFailure, + buildA2ADiscoveryQuery, +} from '../../src/interop/a2a-adapters'; +import { NegotiationClient } from '../../src/economy/negotiation'; +import type { AgentCard, A2ASkill, A2ATask } from '../../src/interop/schemas'; + +const SAMPLE_SKILL: A2ASkill = { + id: 'audit', + name: 'Smart Contract Audit', + description: 'Audit Solidity contracts', + tags: ['security', 'solidity'], + inputModes: ['text'], + outputModes: ['text'], +}; + +const SAMPLE_CARD: AgentCard = { + name: 'TestAgent', + description: 'Test agent for adapters', + url: 'https://agent.example.com', + version: '1.0.0', + skills: [SAMPLE_SKILL], + defaultInputModes: ['text'], + defaultOutputModes: ['text'], + supportsStreaming: false, +}; + +describe('A2A Adapters', () => { + // ── Skill → AgentService ── + + describe('skillToAgentService', () => { + it('should map skill to AgentService', () => { + const service = skillToAgentService(SAMPLE_SKILL, SAMPLE_CARD, 'agent_1'); + + expect(service.agentId).toBe('agent_1'); + expect(service.capability).toBe('audit'); + expect(service.description).toContain('Smart Contract Audit'); + expect(service.endpoint).toBe('https://agent.example.com'); + expect(service.tags).toEqual(['security', 'solidity']); + }); + }); + + describe('cardToAgentServices', () => { + it('should map all skills from a card', () => { + const services = cardToAgentServices(SAMPLE_CARD, 'agent_1'); + + expect(services).toHaveLength(1); + expect(services[0].capability).toBe('audit'); + }); + }); + + // ── Card → Registration ── + + describe('cardToRegistration', () => { + it('should create registration from card', () => { + const reg = cardToRegistration(SAMPLE_CARD, '0xWallet123'); + + expect(reg.name).toBe('TestAgent'); + expect(reg.description).toBe('Test agent for adapters'); + expect(reg.agentWallet).toBe('0xWallet123'); + expect(reg.active).toBe(true); + expect(reg.services).toHaveLength(1); + expect(reg.services[0].name).toBe('A2A'); + expect(reg.services[0].endpoint).toBe('https://agent.example.com'); + }); + + it('should use empty string for wallet when not provided', () => { + const reg = cardToRegistration(SAMPLE_CARD); + expect(reg.agentWallet).toBe(''); + }); + + it('should detect x402 auth support', () => { + const cardWithX402: AgentCard = { + ...SAMPLE_CARD, + authentication: { type: 'x402' }, + }; + const reg = cardToRegistration(cardWithX402); + expect(reg.x402Support).toBe(true); + }); + }); + + // ── Task → Proposal ── + + describe('createA2AProposal', () => { + it('should create a proposal via negotiation client', () => { + const negotiation = new NegotiationClient(); + const proposalId = createA2AProposal(negotiation, { + card: SAMPLE_CARD, + skillId: 'audit', + input: 'Audit this contract', + price: '1000000000000000000', + chainId: 8453, + fromAgentId: 'buyer_1', + toAgentId: 'seller_1', + }); + + expect(typeof proposalId).toBe('string'); + expect(proposalId).toMatch(/^prop_/); + + const proposal = negotiation.get(proposalId); + expect(proposal?.task).toBe('a2a:audit'); + expect(proposal?.price).toBe('1000000000000000000'); + expect(proposal?.fromAgentId).toBe('buyer_1'); + expect(proposal?.toAgentId).toBe('seller_1'); + }); + }); + + // ── Task Completion Mapping ── + + describe('mapTaskCompletion', () => { + it('should map completed task', () => { + const task: A2ATask = { + id: 'task_1', + status: 'completed', + messages: [], + artifacts: [{ name: 'report', mimeType: 'text/plain', text: 'All clear' }], + }; + + const result = mapTaskCompletion(task); + expect(result.completed).toBe(true); + expect(result.failed).toBe(false); + expect(result.artifacts).toHaveLength(1); + expect(result.artifacts[0].text).toBe('All clear'); + }); + + it('should map failed task', () => { + const task: A2ATask = { + id: 'task_1', + status: 'failed', + messages: [], + artifacts: [], + error: { code: 'HANDLER_ERROR', message: 'Something went wrong' }, + }; + + const result = mapTaskCompletion(task); + expect(result.completed).toBe(false); + expect(result.failed).toBe(true); + expect(result.error).toBe('Something went wrong'); + }); + + it('should map canceled task as failed', () => { + const task: A2ATask = { + id: 'task_1', + status: 'canceled', + messages: [], + artifacts: [], + }; + + const result = mapTaskCompletion(task); + expect(result.completed).toBe(false); + expect(result.failed).toBe(true); + }); + + it('should map in-progress task', () => { + const task: A2ATask = { + id: 'task_1', + status: 'working', + messages: [], + artifacts: [], + }; + + const result = mapTaskCompletion(task); + expect(result.completed).toBe(false); + expect(result.failed).toBe(false); + }); + }); + + // ── Task Failure Handling ── + + describe('handleTaskFailure', () => { + it('should reject proposal on failure', async () => { + const negotiation = new NegotiationClient(); + const proposalId = negotiation.propose({ + fromAgentId: 'A', + toAgentId: 'B', + task: 'a2a:audit', + price: '100', + chainId: 1, + }); + + const task: A2ATask = { + id: 'task_1', + status: 'failed', + messages: [], + artifacts: [], + error: { code: 'ERR', message: 'Failed' }, + }; + + await handleTaskFailure(task, proposalId, negotiation); + + const proposal = negotiation.get(proposalId); + expect(proposal?.status).toBe('rejected'); + }); + + it('should not throw if proposal already rejected', async () => { + const negotiation = new NegotiationClient(); + const proposalId = negotiation.propose({ + fromAgentId: 'A', + toAgentId: 'B', + task: 'test', + price: '100', + chainId: 1, + }); + negotiation.reject(proposalId); + + const task: A2ATask = { id: 't', status: 'failed', messages: [], artifacts: [] }; + + // Should not throw + await handleTaskFailure(task, proposalId, negotiation); + }); + + it('should attempt escrow refund if provided', async () => { + const negotiation = new NegotiationClient(); + const proposalId = negotiation.propose({ + fromAgentId: 'A', + toAgentId: 'B', + task: 'test', + price: '100', + chainId: 1, + }); + + const mockEscrow = { refund: vi.fn().mockResolvedValue(undefined) }; + const task: A2ATask = { id: 't', status: 'failed', messages: [], artifacts: [] }; + + await handleTaskFailure(task, proposalId, negotiation, mockEscrow as any, 'job_1'); + + expect(mockEscrow.refund).toHaveBeenCalledWith('job_1'); + }); + }); + + // ── Discovery Query ── + + describe('buildA2ADiscoveryQuery', () => { + it('should build empty query with no options', () => { + const query = buildA2ADiscoveryQuery(); + expect(query).toEqual({}); + }); + + it('should build query with capability', () => { + const query = buildA2ADiscoveryQuery({ capability: 'audit' }); + expect(query.capability).toBe('audit'); + }); + + it('should build query with tag', () => { + const query = buildA2ADiscoveryQuery({ tag: 'security' }); + expect(query.tags).toEqual(['security']); + }); + }); +}); diff --git a/test/interop/a2a-server.test.ts b/test/interop/a2a-server.test.ts new file mode 100644 index 0000000..cde4e75 --- /dev/null +++ b/test/interop/a2a-server.test.ts @@ -0,0 +1,272 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { A2AServer } from '../../src/interop/a2a-server'; + +describe('A2AServer', () => { + let server: A2AServer; + + afterEach(async () => { + if (server) await server.close(); + }); + + describe('skill registration', () => { + it('should register a skill and return an ID', () => { + server = new A2AServer({ name: 'TestAgent', url: 'http://localhost:3200' }); + const id = server.registerSkill({ + name: 'Audit', + description: 'Audit contracts', + handler: async () => ({ text: 'done' }), + }); + + expect(typeof id).toBe('string'); + }); + + it('should use provided skill ID', () => { + server = new A2AServer({ name: 'TestAgent', url: 'http://localhost:3200' }); + const id = server.registerSkill({ + id: 'my-audit', + name: 'Audit', + description: 'Audit contracts', + handler: async () => ({ text: 'done' }), + }); + + expect(id).toBe('my-audit'); + }); + + it('should unregister a skill', () => { + server = new A2AServer({ name: 'TestAgent', url: 'http://localhost:3200' }); + const id = server.registerSkill({ + name: 'Audit', + description: 'Audit contracts', + handler: async () => ({ text: 'done' }), + }); + + expect(server.unregisterSkill(id)).toBe(true); + expect(server.unregisterSkill(id)).toBe(false); + }); + }); + + describe('agent card generation', () => { + it('should generate a valid agent card', () => { + server = new A2AServer({ + name: 'TestAgent', + url: 'http://localhost:3200', + description: 'A test agent', + version: '1.0.0', + }); + + server.registerSkill({ + id: 'audit', + name: 'Audit', + description: 'Audit contracts', + tags: ['security'], + handler: async () => ({ text: 'done' }), + }); + + const card = server.getAgentCard(); + + expect(card.name).toBe('TestAgent'); + expect(card.description).toBe('A test agent'); + expect(card.url).toBe('http://localhost:3200'); + expect(card.version).toBe('1.0.0'); + expect(card.skills).toHaveLength(1); + expect(card.skills[0].id).toBe('audit'); + expect(card.skills[0].tags).toEqual(['security']); + expect(card.defaultInputModes).toEqual(['text']); + expect(card.defaultOutputModes).toEqual(['text']); + expect(card.supportsStreaming).toBe(false); + }); + + it('should not include handler in agent card', () => { + server = new A2AServer({ name: 'TestAgent', url: 'http://localhost:3200' }); + server.registerSkill({ + name: 'Audit', + description: 'Audit contracts', + handler: async () => ({ text: 'done' }), + }); + + const card = server.getAgentCard(); + const serialized = JSON.stringify(card); + expect(serialized).not.toContain('handler'); + }); + }); + + describe('HTTP server', () => { + it('should serve agent card at /.well-known/agent-card.json', async () => { + server = new A2AServer({ name: 'TestAgent', url: 'http://localhost:3201' }); + server.registerSkill({ + id: 'test', + name: 'Test', + description: 'Test skill', + handler: async () => ({ text: 'ok' }), + }); + server.listen(3201); + + // Wait briefly for server to start + await new Promise((r) => setTimeout(r, 100)); + + const res = await fetch('http://localhost:3201/.well-known/agent-card.json'); + expect(res.ok).toBe(true); + + const card = await res.json(); + expect(card.name).toBe('TestAgent'); + expect(card.skills).toHaveLength(1); + }); + + it('should accept task submissions', async () => { + server = new A2AServer({ name: 'TestAgent', url: 'http://localhost:3202' }); + server.registerSkill({ + id: 'echo', + name: 'Echo', + description: 'Echo back input', + handler: async (input) => ({ text: `Echo: ${input}` }), + }); + server.listen(3202); + + await new Promise((r) => setTimeout(r, 100)); + + const res = await fetch('http://localhost:3202/tasks', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + skill_id: 'echo', + messages: [{ role: 'user', parts: [{ type: 'text', text: 'Hello' }] }], + }), + }); + + expect(res.status).toBe(201); + const task = await res.json(); + expect(task.id).toBeTruthy(); + expect(['submitted', 'working', 'completed']).toContain(task.status); + }); + + it('should return 404 for unknown skill', async () => { + server = new A2AServer({ name: 'TestAgent', url: 'http://localhost:3203' }); + server.listen(3203); + + await new Promise((r) => setTimeout(r, 100)); + + const res = await fetch('http://localhost:3203/tasks', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ skill_id: 'nonexistent' }), + }); + + // Server returns 500 because the handler throws + expect(res.status).toBe(500); + }); + + it('should get task status', async () => { + server = new A2AServer({ name: 'TestAgent', url: 'http://localhost:3204' }); + server.registerSkill({ + id: 'slow', + name: 'Slow', + description: 'Slow task', + handler: async () => { + await new Promise((r) => setTimeout(r, 200)); + return { text: 'done' }; + }, + }); + server.listen(3204); + + await new Promise((r) => setTimeout(r, 100)); + + // Submit + const submitRes = await fetch('http://localhost:3204/tasks', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + skill_id: 'slow', + messages: [{ role: 'user', parts: [{ type: 'text', text: 'go' }] }], + }), + }); + const task = await submitRes.json(); + + // Get status immediately (should be working) + const getRes = await fetch(`http://localhost:3204/tasks/${task.id}`); + expect(getRes.ok).toBe(true); + const status = await getRes.json(); + expect(status.id).toBe(task.id); + + // Wait for completion and check again + await new Promise((r) => setTimeout(r, 300)); + const finalRes = await fetch(`http://localhost:3204/tasks/${task.id}`); + const finalTask = await finalRes.json(); + expect(finalTask.status).toBe('completed'); + expect(finalTask.artifacts.length).toBeGreaterThan(0); + }); + + it('should cancel a task', async () => { + server = new A2AServer({ name: 'TestAgent', url: 'http://localhost:3205' }); + server.registerSkill({ + id: 'long', + name: 'Long', + description: 'Long task', + handler: async () => { + await new Promise((r) => setTimeout(r, 5000)); + return { text: 'done' }; + }, + }); + server.listen(3205); + + await new Promise((r) => setTimeout(r, 100)); + + // Submit + const submitRes = await fetch('http://localhost:3205/tasks', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + skill_id: 'long', + messages: [{ role: 'user', parts: [{ type: 'text', text: 'go' }] }], + }), + }); + const task = await submitRes.json(); + + // Cancel + const cancelRes = await fetch(`http://localhost:3205/tasks/${task.id}/cancel`, { + method: 'POST', + }); + expect(cancelRes.ok).toBe(true); + const canceledTask = await cancelRes.json(); + expect(canceledTask.status).toBe('canceled'); + }); + + it('should return 404 for unknown task', async () => { + server = new A2AServer({ name: 'TestAgent', url: 'http://localhost:3206' }); + server.listen(3206); + + await new Promise((r) => setTimeout(r, 100)); + + const res = await fetch('http://localhost:3206/tasks/nonexistent'); + expect(res.status).toBe(404); + }); + + it('should return 404 for unknown routes', async () => { + server = new A2AServer({ name: 'TestAgent', url: 'http://localhost:3207' }); + server.listen(3207); + + await new Promise((r) => setTimeout(r, 100)); + + const res = await fetch('http://localhost:3207/unknown'); + expect(res.status).toBe(404); + }); + + it('should handle CORS preflight', async () => { + server = new A2AServer({ name: 'TestAgent', url: 'http://localhost:3208' }); + server.listen(3208); + + await new Promise((r) => setTimeout(r, 100)); + + const res = await fetch('http://localhost:3208/.well-known/agent-card.json', { + method: 'OPTIONS', + }); + expect(res.status).toBe(204); + }); + }); + + describe('close', () => { + it('should resolve immediately if no server started', async () => { + server = new A2AServer({ name: 'TestAgent', url: 'http://localhost:3200' }); + await server.close(); // Should not throw + }); + }); +}); diff --git a/test/interop/a2a.test.ts b/test/interop/a2a.test.ts new file mode 100644 index 0000000..15d5a81 --- /dev/null +++ b/test/interop/a2a.test.ts @@ -0,0 +1,261 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { A2AClient } from '../../src/interop/a2a'; +import type { AgentCard } from '../../src/interop/schemas'; + +const SAMPLE_CARD: AgentCard = { + name: 'TestAgent', + description: 'A test A2A agent', + url: 'https://agent.example.com', + version: '1.0.0', + skills: [ + { + id: 'audit', + name: 'Smart Contract Audit', + description: 'Audit Solidity contracts for vulnerabilities', + tags: ['security', 'solidity'], + inputModes: ['text'], + outputModes: ['text'], + }, + { + id: 'summarize', + name: 'Summarize', + description: 'Summarize text documents', + tags: ['nlp'], + }, + ], + defaultInputModes: ['text'], + defaultOutputModes: ['text'], + supportsStreaming: false, +}; + +const SAMPLE_TASK = { + id: 'task_abc123', + status: 'working' as const, + messages: [{ role: 'user' as const, parts: [{ type: 'text' as const, text: 'Audit this contract' }] }], + artifacts: [], +}; + +describe('A2AClient', () => { + let client: A2AClient; + let mockFetch: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + mockFetch = vi.fn(); + client = new A2AClient({ fetch: mockFetch as unknown as typeof fetch }); + }); + + // ── Agent Card ── + + describe('fetchAgentCard', () => { + it('should fetch and validate an agent card', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => SAMPLE_CARD, + }); + + const card = await client.fetchAgentCard('https://agent.example.com'); + + expect(mockFetch).toHaveBeenCalledWith( + 'https://agent.example.com/.well-known/agent-card.json', + expect.objectContaining({ headers: { Accept: 'application/json' } }), + ); + expect(card.name).toBe('TestAgent'); + expect(card.skills).toHaveLength(2); + expect(card.skills[0].id).toBe('audit'); + }); + + it('should strip trailing slashes from base URL', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => SAMPLE_CARD, + }); + + await client.fetchAgentCard('https://agent.example.com///'); + + expect(mockFetch).toHaveBeenCalledWith( + 'https://agent.example.com/.well-known/agent-card.json', + expect.any(Object), + ); + }); + + it('should throw on HTTP error', async () => { + mockFetch.mockResolvedValueOnce({ ok: false, status: 404 }); + + await expect(client.fetchAgentCard('https://bad.example.com')) + .rejects.toThrow('Agent card not found'); + }); + + it('should throw on invalid JSON', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => { throw new Error('bad json'); }, + }); + + await expect(client.fetchAgentCard('https://agent.example.com')) + .rejects.toThrow('Invalid JSON'); + }); + + it('should throw on missing name', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ url: 'http://x', skills: [] }), + }); + + await expect(client.fetchAgentCard('https://agent.example.com')) + .rejects.toThrow('missing name'); + }); + + it('should throw on missing skills array', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ name: 'X', url: 'http://x' }), + }); + + await expect(client.fetchAgentCard('https://agent.example.com')) + .rejects.toThrow('missing skills'); + }); + }); + + describe('resolveAgentCardFromERC8004', () => { + it('should throw without identity resolver', async () => { + await expect(client.resolveAgentCardFromERC8004('123')) + .rejects.toThrow('InteropIdentityResolver required'); + }); + }); + + // ── Skills ── + + describe('listSkills', () => { + it('should return skills from a card', () => { + const skills = client.listSkills(SAMPLE_CARD); + expect(skills).toHaveLength(2); + expect(skills[0].name).toBe('Smart Contract Audit'); + }); + }); + + describe('findSkill', () => { + it('should find skill by id', () => { + const skill = client.findSkill(SAMPLE_CARD, { id: 'audit' }); + expect(skill?.name).toBe('Smart Contract Audit'); + }); + + it('should find skill by tag', () => { + const skill = client.findSkill(SAMPLE_CARD, { tag: 'nlp' }); + expect(skill?.id).toBe('summarize'); + }); + + it('should return undefined for no match', () => { + expect(client.findSkill(SAMPLE_CARD, { id: 'nonexistent' })).toBeUndefined(); + expect(client.findSkill(SAMPLE_CARD, { tag: 'nonexistent' })).toBeUndefined(); + }); + + it('should return undefined with no query', () => { + expect(client.findSkill(SAMPLE_CARD, {})).toBeUndefined(); + }); + }); + + // ── Task Lifecycle ── + + describe('submitTask', () => { + it('should submit a task and return task object', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => SAMPLE_TASK, + }); + + const task = await client.submitTask('https://agent.example.com', { + skillId: 'audit', + input: 'Audit this contract', + }); + + expect(mockFetch).toHaveBeenCalledWith( + 'https://agent.example.com/tasks', + expect.objectContaining({ + method: 'POST', + body: expect.stringContaining('"skill_id":"audit"'), + }), + ); + expect(task.id).toBe('task_abc123'); + expect(task.status).toBe('working'); + }); + + it('should include auth header when provided', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => SAMPLE_TASK, + }); + + await client.submitTask('https://agent.example.com', { + skillId: 'audit', + input: 'test', + auth: 'Bearer token123', + }); + + const callArgs = mockFetch.mock.calls[0]; + expect(callArgs[1].headers.Authorization).toBe('Bearer token123'); + }); + + it('should throw on submission failure', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 400, + text: async () => 'Bad request', + }); + + await expect( + client.submitTask('https://agent.example.com', { skillId: 'x', input: 'y' }), + ).rejects.toThrow('task submission failed'); + }); + }); + + describe('getTask', () => { + it('should fetch task by ID', async () => { + const completedTask = { ...SAMPLE_TASK, status: 'completed', artifacts: [{ name: 'report', text: 'All good' }] }; + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => completedTask, + }); + + const task = await client.getTask('https://agent.example.com', 'task_abc123'); + + expect(mockFetch).toHaveBeenCalledWith( + 'https://agent.example.com/tasks/task_abc123', + expect.any(Object), + ); + expect(task.status).toBe('completed'); + expect(task.artifacts).toHaveLength(1); + }); + + it('should throw on 404', async () => { + mockFetch.mockResolvedValueOnce({ ok: false, status: 404 }); + + await expect(client.getTask('https://agent.example.com', 'bad_id')) + .rejects.toThrow('not found'); + }); + }); + + describe('cancelTask', () => { + it('should cancel a task', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ ...SAMPLE_TASK, status: 'canceled' }), + }); + + const task = await client.cancelTask('https://agent.example.com', 'task_abc123'); + + expect(mockFetch).toHaveBeenCalledWith( + 'https://agent.example.com/tasks/task_abc123/cancel', + expect.objectContaining({ method: 'POST' }), + ); + expect(task.status).toBe('canceled'); + }); + + it('should throw on cancel failure', async () => { + mockFetch.mockResolvedValueOnce({ ok: false, status: 400 }); + + await expect(client.cancelTask('https://agent.example.com', 'task_abc123')) + .rejects.toThrow('cancellation failed'); + }); + }); +}); From 94309c45b839f662f607bea9ecea6f63e2f72342 Mon Sep 17 00:00:00 2001 From: OlaCryto Date: Fri, 13 Mar 2026 07:03:03 -0700 Subject: [PATCH 2/5] fix: preserve canceled status after handler completes, return 400 for validation errors Address Codex review: - P1: _executeSkill now checks for terminal state before overwriting status - P2: _handleSubmitTask validation errors (missing/unknown skill_id) return 400 not 500 --- src/interop/a2a-server.ts | 17 ++++++--- test/interop/a2a-server.test.ts | 62 +++++++++++++++++++++++++++++++-- 2 files changed, 72 insertions(+), 7 deletions(-) diff --git a/src/interop/a2a-server.ts b/src/interop/a2a-server.ts index 3103c2b..5a1a76f 100644 --- a/src/interop/a2a-server.ts +++ b/src/interop/a2a-server.ts @@ -26,7 +26,6 @@ import type { AgentCard, A2ASkill, A2ATask, - A2ATaskStatus, A2AMessage, A2AArtifact, A2AAuthentication, @@ -138,10 +137,16 @@ export class A2AServer { */ listen(port: number): Server { this._server = createServer((req, res) => { - this._handleRequest(req, res).catch(() => { + this._handleRequest(req, res).catch((err) => { if (!res.headersSent) { - res.writeHead(500, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: 'Internal server error' })); + const isValidationError = err instanceof EvalancheError && + err.code === EvalancheErrorCode.A2A_ERROR; + const status = isValidationError ? 400 : 500; + const message = isValidationError + ? (err as Error).message + : 'Internal server error'; + res.writeHead(status, { 'Access-Control-Allow-Origin': '*', 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: message })); } }); }); @@ -303,6 +308,9 @@ export class A2AServer { artifacts.push({ name: 'result', uri: result.uri }); } + // Don't overwrite terminal states (e.g. task was canceled while handler ran) + if (task.status === 'canceled' || task.status === 'failed') return; + task.artifacts = artifacts; task.messages.push({ role: 'agent', @@ -310,6 +318,7 @@ export class A2AServer { }); task.status = 'completed'; } catch (error) { + if (task.status === 'canceled') return; task.status = 'failed'; task.error = { code: 'HANDLER_ERROR', diff --git a/test/interop/a2a-server.test.ts b/test/interop/a2a-server.test.ts index cde4e75..bb6a790 100644 --- a/test/interop/a2a-server.test.ts +++ b/test/interop/a2a-server.test.ts @@ -139,7 +139,7 @@ describe('A2AServer', () => { expect(['submitted', 'working', 'completed']).toContain(task.status); }); - it('should return 404 for unknown skill', async () => { + it('should return 400 for unknown skill', async () => { server = new A2AServer({ name: 'TestAgent', url: 'http://localhost:3203' }); server.listen(3203); @@ -151,8 +151,26 @@ describe('A2AServer', () => { body: JSON.stringify({ skill_id: 'nonexistent' }), }); - // Server returns 500 because the handler throws - expect(res.status).toBe(500); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toContain('Unknown skill'); + }); + + it('should return 400 for missing skill_id', async () => { + server = new A2AServer({ name: 'TestAgent', url: 'http://localhost:3209' }); + server.listen(3209); + + await new Promise((r) => setTimeout(r, 100)); + + const res = await fetch('http://localhost:3209/tasks', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }); + + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toContain('skill_id'); }); it('should get task status', async () => { @@ -230,6 +248,44 @@ describe('A2AServer', () => { expect(canceledTask.status).toBe('canceled'); }); + it('should preserve canceled status after handler finishes', async () => { + server = new A2AServer({ name: 'TestAgent', url: 'http://localhost:3210' }); + server.registerSkill({ + id: 'medium', + name: 'Medium', + description: 'Medium task', + handler: async () => { + await new Promise((r) => setTimeout(r, 200)); + return { text: 'done' }; + }, + }); + server.listen(3210); + + await new Promise((r) => setTimeout(r, 100)); + + // Submit + const submitRes = await fetch('http://localhost:3210/tasks', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + skill_id: 'medium', + messages: [{ role: 'user', parts: [{ type: 'text', text: 'go' }] }], + }), + }); + const task = await submitRes.json(); + + // Cancel immediately + await fetch(`http://localhost:3210/tasks/${task.id}/cancel`, { method: 'POST' }); + + // Wait for handler to finish + await new Promise((r) => setTimeout(r, 400)); + + // Status should still be canceled, not overwritten to completed + const getRes = await fetch(`http://localhost:3210/tasks/${task.id}`); + const finalTask = await getRes.json(); + expect(finalTask.status).toBe('canceled'); + }); + it('should return 404 for unknown task', async () => { server = new A2AServer({ name: 'TestAgent', url: 'http://localhost:3206' }); server.listen(3206); From 1f912186ba6df7d54d237621adc892deef867ae1 Mon Sep 17 00:00:00 2001 From: OlaCryto Date: Fri, 13 Mar 2026 07:22:32 -0700 Subject: [PATCH 3/5] fix: address remaining Codex review comments on A2A server - P1: never advertise supportsStreaming (no stream endpoint implemented) - P2: malformed JSON bodies now return 400 instead of 500 - P2: add error listener on server.listen() for EADDRINUSE/EACCES - Remove misleading supportsStreaming option from A2AServerOptions --- src/interop/a2a-server.ts | 15 +++++++++++---- test/interop/a2a-server.test.ts | 29 +++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 4 deletions(-) diff --git a/src/interop/a2a-server.ts b/src/interop/a2a-server.ts index 5a1a76f..0265556 100644 --- a/src/interop/a2a-server.ts +++ b/src/interop/a2a-server.ts @@ -66,8 +66,6 @@ export interface A2AServerOptions { provider?: { name: string; url?: string }; /** Authentication config */ authentication?: A2AAuthentication; - /** Whether to support streaming */ - supportsStreaming?: boolean; } /** @@ -128,7 +126,8 @@ export class A2AServer { skills, defaultInputModes: ['text'], defaultOutputModes: ['text'], - supportsStreaming: this._options.supportsStreaming ?? false, + // Streaming not implemented — always advertise false to avoid misleading clients + supportsStreaming: false, }; } @@ -151,6 +150,14 @@ export class A2AServer { }); }); + this._server.on('error', (err) => { + throw new EvalancheError( + `A2A server failed to start: ${err.message}`, + EvalancheErrorCode.A2A_ERROR, + err, + ); + }); + this._server.listen(port); return this._server; } @@ -337,7 +344,7 @@ export class A2AServer { const raw = Buffer.concat(chunks).toString('utf-8'); resolve(raw ? JSON.parse(raw) : {}); } catch { - reject(new Error('Invalid JSON body')); + reject(new EvalancheError('Invalid JSON body', EvalancheErrorCode.A2A_ERROR)); } }); req.on('error', reject); diff --git a/test/interop/a2a-server.test.ts b/test/interop/a2a-server.test.ts index bb6a790..5c7e3fb 100644 --- a/test/interop/a2a-server.test.ts +++ b/test/interop/a2a-server.test.ts @@ -317,6 +317,35 @@ describe('A2AServer', () => { }); expect(res.status).toBe(204); }); + + it('should return 400 for malformed JSON body', async () => { + server = new A2AServer({ name: 'TestAgent', url: 'http://localhost:3211' }); + server.registerSkill({ + id: 'test', + name: 'Test', + description: 'Test', + handler: async () => ({ text: 'ok' }), + }); + server.listen(3211); + + await new Promise((r) => setTimeout(r, 100)); + + const res = await fetch('http://localhost:3211/tasks', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: 'not valid json {{{', + }); + + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toContain('Invalid JSON'); + }); + + it('should never advertise supportsStreaming as true', () => { + server = new A2AServer({ name: 'TestAgent', url: 'http://localhost:3200' }); + const card = server.getAgentCard(); + expect(card.supportsStreaming).toBe(false); + }); }); describe('close', () => { From d1e56c7426e1be7c1e8d82d5f1c9ef80c706b0f4 Mon Sep 17 00:00:00 2001 From: OlaCryto Date: Sat, 14 Mar 2026 02:19:01 -0700 Subject: [PATCH 4/5] fix: enforce auth on task endpoints, preserve binary artifacts, safe listen() Address Codex review round 3: - P1: reject unauthenticated requests on /tasks when authentication configured - P1: listen() returns Promise, rejects on EADDRINUSE/EACCES (no uncaught throw) - P2: mapTaskCompletion now preserves binary artifact data field - Agent card still served without auth (public discovery) --- src/interop/a2a-adapters.ts | 3 +- src/interop/a2a-server.ts | 52 +++++++++++++---- src/mcp/server.ts | 2 +- test/interop/a2a-adapters.test.ts | 13 +++++ test/interop/a2a-server.test.ts | 95 ++++++++++++++++++++----------- 5 files changed, 119 insertions(+), 46 deletions(-) diff --git a/src/interop/a2a-adapters.ts b/src/interop/a2a-adapters.ts index 4b662c4..0a2bbf6 100644 --- a/src/interop/a2a-adapters.ts +++ b/src/interop/a2a-adapters.ts @@ -121,7 +121,7 @@ export function createA2AProposal( export function mapTaskCompletion(task: A2ATask): { completed: boolean; failed: boolean; - artifacts: Array<{ name?: string; mimeType?: string; text?: string; uri?: string }>; + artifacts: Array<{ name?: string; mimeType?: string; text?: string; data?: string; uri?: string }>; error?: string; } { const completed = task.status === 'completed'; @@ -134,6 +134,7 @@ export function mapTaskCompletion(task: A2ATask): { name: a.name, mimeType: a.mimeType, text: a.text, + data: a.data, uri: a.uri, })), error: task.error?.message, diff --git a/src/interop/a2a-server.ts b/src/interop/a2a-server.ts index 0265556..86daf11 100644 --- a/src/interop/a2a-server.ts +++ b/src/interop/a2a-server.ts @@ -133,8 +133,10 @@ export class A2AServer { /** * Start an HTTP server that serves the A2A protocol. + * Returns a promise that resolves with the Server once it's listening, + * or rejects on startup errors (EADDRINUSE, EACCES, etc.). */ - listen(port: number): Server { + listen(port: number): Promise { this._server = createServer((req, res) => { this._handleRequest(req, res).catch((err) => { if (!res.headersSent) { @@ -150,16 +152,16 @@ export class A2AServer { }); }); - this._server.on('error', (err) => { - throw new EvalancheError( - `A2A server failed to start: ${err.message}`, - EvalancheErrorCode.A2A_ERROR, - err, - ); + return new Promise((resolve, reject) => { + this._server!.once('error', (err) => { + reject(new EvalancheError( + `A2A server failed to start: ${err.message}`, + EvalancheErrorCode.A2A_ERROR, + err, + )); + }); + this._server!.listen(port, () => resolve(this._server!)); }); - - this._server.listen(port); - return this._server; } /** @@ -207,6 +209,15 @@ export class A2AServer { return; } + // Enforce authentication on task endpoints if configured + if (this._options.authentication && url.startsWith('/tasks')) { + if (!this._checkAuth(req)) { + res.writeHead(401, { ...cors, 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Authentication required' })); + return; + } + } + // Submit task if (url === '/tasks' && method === 'POST') { const body = await this._parseBody(req); @@ -334,6 +345,27 @@ export class A2AServer { } } + /** Check request credentials against configured authentication */ + private _checkAuth(req: IncomingMessage): boolean { + const auth = this._options.authentication; + if (!auth) return true; + + const location = auth.in ?? 'header'; + const paramName = auth.name ?? 'Authorization'; + + if (location === 'header') { + const value = req.headers[paramName.toLowerCase()]; + return typeof value === 'string' && value.length > 0; + } + + if (location === 'query') { + const url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`); + return url.searchParams.has(paramName); + } + + return false; + } + /** Parse JSON body from request */ private _parseBody(req: IncomingMessage): Promise> { return new Promise((resolve, reject) => { diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 0553c51..f347b4a 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -1993,7 +1993,7 @@ export class EvalancheMCPServer { url: `http://localhost:3100`, description: 'Evalanche A2A agent', }); - this.a2aServer.listen(3100); + await this.a2aServer.listen(3100); } const skillId = this.a2aServer.registerSkill({ id: args.skillId as string | undefined, diff --git a/test/interop/a2a-adapters.test.ts b/test/interop/a2a-adapters.test.ts index 54ff490..e889c08 100644 --- a/test/interop/a2a-adapters.test.ts +++ b/test/interop/a2a-adapters.test.ts @@ -114,6 +114,19 @@ describe('A2A Adapters', () => { // ── Task Completion Mapping ── describe('mapTaskCompletion', () => { + it('should preserve binary artifact data', () => { + const task: A2ATask = { + id: 'task_1', + status: 'completed', + messages: [], + artifacts: [{ name: 'binary', mimeType: 'application/pdf', data: 'base64content==' }], + }; + + const result = mapTaskCompletion(task); + expect(result.artifacts[0].data).toBe('base64content=='); + expect(result.artifacts[0].mimeType).toBe('application/pdf'); + }); + it('should map completed task', () => { const task: A2ATask = { id: 'task_1', diff --git a/test/interop/a2a-server.test.ts b/test/interop/a2a-server.test.ts index 5c7e3fb..0e68b6b 100644 --- a/test/interop/a2a-server.test.ts +++ b/test/interop/a2a-server.test.ts @@ -99,10 +99,7 @@ describe('A2AServer', () => { description: 'Test skill', handler: async () => ({ text: 'ok' }), }); - server.listen(3201); - - // Wait briefly for server to start - await new Promise((r) => setTimeout(r, 100)); + await server.listen(3201); const res = await fetch('http://localhost:3201/.well-known/agent-card.json'); expect(res.ok).toBe(true); @@ -120,9 +117,7 @@ describe('A2AServer', () => { description: 'Echo back input', handler: async (input) => ({ text: `Echo: ${input}` }), }); - server.listen(3202); - - await new Promise((r) => setTimeout(r, 100)); + await server.listen(3202); const res = await fetch('http://localhost:3202/tasks', { method: 'POST', @@ -141,9 +136,7 @@ describe('A2AServer', () => { it('should return 400 for unknown skill', async () => { server = new A2AServer({ name: 'TestAgent', url: 'http://localhost:3203' }); - server.listen(3203); - - await new Promise((r) => setTimeout(r, 100)); + await server.listen(3203); const res = await fetch('http://localhost:3203/tasks', { method: 'POST', @@ -158,9 +151,7 @@ describe('A2AServer', () => { it('should return 400 for missing skill_id', async () => { server = new A2AServer({ name: 'TestAgent', url: 'http://localhost:3209' }); - server.listen(3209); - - await new Promise((r) => setTimeout(r, 100)); + await server.listen(3209); const res = await fetch('http://localhost:3209/tasks', { method: 'POST', @@ -184,9 +175,7 @@ describe('A2AServer', () => { return { text: 'done' }; }, }); - server.listen(3204); - - await new Promise((r) => setTimeout(r, 100)); + await server.listen(3204); // Submit const submitRes = await fetch('http://localhost:3204/tasks', { @@ -224,9 +213,7 @@ describe('A2AServer', () => { return { text: 'done' }; }, }); - server.listen(3205); - - await new Promise((r) => setTimeout(r, 100)); + await server.listen(3205); // Submit const submitRes = await fetch('http://localhost:3205/tasks', { @@ -259,9 +246,7 @@ describe('A2AServer', () => { return { text: 'done' }; }, }); - server.listen(3210); - - await new Promise((r) => setTimeout(r, 100)); + await server.listen(3210); // Submit const submitRes = await fetch('http://localhost:3210/tasks', { @@ -288,9 +273,7 @@ describe('A2AServer', () => { it('should return 404 for unknown task', async () => { server = new A2AServer({ name: 'TestAgent', url: 'http://localhost:3206' }); - server.listen(3206); - - await new Promise((r) => setTimeout(r, 100)); + await server.listen(3206); const res = await fetch('http://localhost:3206/tasks/nonexistent'); expect(res.status).toBe(404); @@ -298,9 +281,7 @@ describe('A2AServer', () => { it('should return 404 for unknown routes', async () => { server = new A2AServer({ name: 'TestAgent', url: 'http://localhost:3207' }); - server.listen(3207); - - await new Promise((r) => setTimeout(r, 100)); + await server.listen(3207); const res = await fetch('http://localhost:3207/unknown'); expect(res.status).toBe(404); @@ -308,9 +289,7 @@ describe('A2AServer', () => { it('should handle CORS preflight', async () => { server = new A2AServer({ name: 'TestAgent', url: 'http://localhost:3208' }); - server.listen(3208); - - await new Promise((r) => setTimeout(r, 100)); + await server.listen(3208); const res = await fetch('http://localhost:3208/.well-known/agent-card.json', { method: 'OPTIONS', @@ -326,9 +305,7 @@ describe('A2AServer', () => { description: 'Test', handler: async () => ({ text: 'ok' }), }); - server.listen(3211); - - await new Promise((r) => setTimeout(r, 100)); + await server.listen(3211); const res = await fetch('http://localhost:3211/tasks', { method: 'POST', @@ -346,6 +323,56 @@ describe('A2AServer', () => { const card = server.getAgentCard(); expect(card.supportsStreaming).toBe(false); }); + + it('should reject unauthenticated requests when auth is configured', async () => { + server = new A2AServer({ + name: 'AuthAgent', + url: 'http://localhost:3212', + authentication: { type: 'bearer', in: 'header', name: 'Authorization' }, + }); + server.registerSkill({ + id: 'secret', + name: 'Secret', + description: 'Requires auth', + handler: async () => ({ text: 'ok' }), + }); + await server.listen(3212); + + // No auth header — should get 401 + const res = await fetch('http://localhost:3212/tasks', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ skill_id: 'secret', messages: [] }), + }); + expect(res.status).toBe(401); + + // With auth header — should get through + const authedRes = await fetch('http://localhost:3212/tasks', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: 'Bearer test-token', + }, + body: JSON.stringify({ + skill_id: 'secret', + messages: [{ role: 'user', parts: [{ type: 'text', text: 'hi' }] }], + }), + }); + expect(authedRes.status).toBe(201); + }); + + it('should still serve agent card without auth', async () => { + server = new A2AServer({ + name: 'AuthAgent', + url: 'http://localhost:3213', + authentication: { type: 'bearer' }, + }); + await server.listen(3213); + + // Agent card should be public + const res = await fetch('http://localhost:3213/.well-known/agent-card.json'); + expect(res.ok).toBe(true); + }); }); describe('close', () => { From e3086d69a81af437cdd1276df5887a36e725da13 Mon Sep 17 00:00:00 2001 From: OlaCryto Date: Sat, 14 Mar 2026 05:46:27 -0700 Subject: [PATCH 5/5] fix: route on pathname not raw URL, honor auth placement, parse SSE data: without space Address Codex review round 4: - P1: strip query string from req.url before route matching (fixes query-based auth) - P2: client _applyAuth honors authPlacement (header name, query param) from agent card - P2: SSE parser accepts both 'data: {...}' and 'data:{...}' formats --- src/interop/a2a-server.ts | 4 ++- src/interop/a2a.ts | 60 +++++++++++++++++++++++++++++---------- src/interop/index.ts | 2 +- 3 files changed, 49 insertions(+), 17 deletions(-) diff --git a/src/interop/a2a-server.ts b/src/interop/a2a-server.ts index 86daf11..92e74ab 100644 --- a/src/interop/a2a-server.ts +++ b/src/interop/a2a-server.ts @@ -185,7 +185,9 @@ export class A2AServer { // ── HTTP Request Routing ── private async _handleRequest(req: IncomingMessage, res: ServerResponse): Promise { - const url = req.url ?? '/'; + // Parse pathname to strip query strings (e.g. /tasks?api_key=... → /tasks) + const rawUrl = req.url ?? '/'; + const url = rawUrl.split('?')[0]; const method = req.method ?? 'GET'; // CORS preflight diff --git a/src/interop/a2a.ts b/src/interop/a2a.ts index 3d7527b..d5b7c72 100644 --- a/src/interop/a2a.ts +++ b/src/interop/a2a.ts @@ -31,6 +31,14 @@ import type { A2AArtifact, } from './schemas'; +/** Auth placement config matching A2AAuthentication from agent card */ +export interface AuthPlacement { + /** Where to send the credential */ + in?: 'header' | 'query'; + /** Header or query parameter name (defaults to 'Authorization') */ + name?: string; +} + /** Options for submitting a task */ export interface SubmitTaskOptions { /** Skill ID to invoke */ @@ -39,8 +47,10 @@ export interface SubmitTaskOptions { input: string; /** Optional metadata to attach */ metadata?: Record; - /** Authorization header value (e.g., 'Bearer xxx') */ + /** Auth credential value (e.g., 'Bearer xxx' or an API key) */ auth?: string; + /** Where to place the auth credential (from agent card authentication config) */ + authPlacement?: AuthPlacement; } /** Callback for streaming task updates */ @@ -183,13 +193,11 @@ export class A2AClient { 'Content-Type': 'application/json', Accept: 'application/json', }; - if (options.auth) { - headers['Authorization'] = options.auth; - } + const finalUrl = this._applyAuth(url, headers, options.auth, options.authPlacement); let response: Response; try { - response = await this._fetchFn(url, { + response = await this._fetchFn(finalUrl, { method: 'POST', headers, body: JSON.stringify(body), @@ -217,15 +225,15 @@ export class A2AClient { /** * Get the current status and artifacts of a task. */ - async getTask(baseUrl: string, taskId: string, auth?: string): Promise { + async getTask(baseUrl: string, taskId: string, auth?: string, authPlacement?: AuthPlacement): Promise { const url = baseUrl.replace(/\/+$/, '') + `/tasks/${encodeURIComponent(taskId)}`; const headers: Record = { Accept: 'application/json' }; - if (auth) headers['Authorization'] = auth; + const finalUrl = this._applyAuth(url, headers, auth, authPlacement); let response: Response; try { - response = await this._fetchFn(url, { headers }); + response = await this._fetchFn(finalUrl, { headers }); } catch (error) { throw new EvalancheError( `Failed to get A2A task ${taskId}: ${error instanceof Error ? error.message : String(error)}`, @@ -255,17 +263,18 @@ export class A2AClient { taskId: string, onUpdate: TaskUpdateCallback, auth?: string, + authPlacement?: AuthPlacement, ): Promise<{ abort: () => void }> { const url = baseUrl.replace(/\/+$/, '') + `/tasks/${encodeURIComponent(taskId)}/stream`; const headers: Record = { Accept: 'text/event-stream' }; - if (auth) headers['Authorization'] = auth; + const finalUrl = this._applyAuth(url, headers, auth, authPlacement); const controller = new AbortController(); let response: Response; try { - response = await this._fetchFn(url, { + response = await this._fetchFn(finalUrl, { headers, signal: controller.signal, }); @@ -298,18 +307,18 @@ export class A2AClient { /** * Cancel an in-progress task. */ - async cancelTask(baseUrl: string, taskId: string, auth?: string): Promise { + async cancelTask(baseUrl: string, taskId: string, auth?: string, authPlacement?: AuthPlacement): Promise { const url = baseUrl.replace(/\/+$/, '') + `/tasks/${encodeURIComponent(taskId)}/cancel`; const headers: Record = { 'Content-Type': 'application/json', Accept: 'application/json', }; - if (auth) headers['Authorization'] = auth; + const finalUrl = this._applyAuth(url, headers, auth, authPlacement); let response: Response; try { - response = await this._fetchFn(url, { method: 'POST', headers }); + response = await this._fetchFn(finalUrl, { method: 'POST', headers }); } catch (error) { throw new EvalancheError( `Failed to cancel A2A task ${taskId}: ${error instanceof Error ? error.message : String(error)}`, @@ -331,6 +340,27 @@ export class A2AClient { // ── Internal Helpers ── + /** Apply auth credential to a URL and headers based on placement config */ + private _applyAuth( + url: string, + headers: Record, + auth?: string, + placement?: AuthPlacement, + ): string { + if (!auth) return url; + + const location = placement?.in ?? 'header'; + const name = placement?.name ?? 'Authorization'; + + if (location === 'query') { + const separator = url.includes('?') ? '&' : '?'; + return `${url}${separator}${encodeURIComponent(name)}=${encodeURIComponent(auth)}`; + } + + headers[name] = auth; + return url; + } + /** Validate and type an agent card response */ private _validateAgentCard(data: unknown, source: string): AgentCard { const card = data as Record; @@ -402,8 +432,8 @@ export class A2AClient { buffer = lines.pop() ?? ''; for (const line of lines) { - if (line.startsWith('data: ')) { - const jsonStr = line.slice(6).trim(); + if (line.startsWith('data:')) { + const jsonStr = line.slice(5).trim(); if (!jsonStr || jsonStr === '[DONE]') continue; try { diff --git a/src/interop/index.ts b/src/interop/index.ts index 44a68c4..ef31771 100644 --- a/src/interop/index.ts +++ b/src/interop/index.ts @@ -14,7 +14,7 @@ export { InteropIdentityResolver } from './identity'; // A2A client export { A2AClient } from './a2a'; -export type { SubmitTaskOptions, TaskUpdateCallback } from './a2a'; +export type { SubmitTaskOptions, TaskUpdateCallback, AuthPlacement } from './a2a'; // A2A server export { A2AServer } from './a2a-server';