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
13 changes: 13 additions & 0 deletions .changeset/bright-badgers-confess.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
"@voltagent/a2a-server": patch
"@voltagent/server-core": patch
"@voltagent/server-hono": patch
"@voltagent/server-elysia": patch
---

fix: point A2A agent cards at the JSON-RPC endpoint

A2A agent cards now advertise `/a2a/{serverId}` instead of the internal
`/.well-known/{serverId}/agent-card.json` discovery document. When the card is
served through the Hono or Elysia integrations, VoltAgent also resolves that
endpoint to an absolute URL based on the incoming request.
14 changes: 11 additions & 3 deletions examples/with-a2a-server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,13 +79,21 @@ pnpm --filter voltagent-example-with-a2a-server dev
The Hono server listens on `http://localhost:3141`. Check the discovery document:

```bash
curl http://localhost:3141/.well-known/support/agent-card.json | jq
curl http://localhost:3141/.well-known/supportagent/agent-card.json | jq
```

The returned card advertises the JSON-RPC endpoint via its `url` field:

```json
{
"url": "http://localhost:3141/a2a/supportagent"
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
```

Send a JSON-RPC request to the agent:

```bash
curl -X POST http://localhost:3141/a2a/support \
curl -X POST http://localhost:3141/a2a/supportagent \
-H "Content-Type: application/json" \
-d '{
"jsonrpc": "2.0",
Expand All @@ -110,7 +118,7 @@ There is a helper that exercises the example end-to-end. Start the dev server in
pnpm --filter voltagent-example-with-a2a-server test:smoke
```

The script fetches the agent card, sends a message via `/a2a`, and asserts that the resulting task transitions to `completed`.
The script fetches the agent card, asserts that it points to the absolute `/a2a` endpoint, sends a message via `/a2a`, and asserts that the resulting task transitions to `completed`.

## Next steps

Expand Down
1 change: 1 addition & 0 deletions examples/with-a2a-server/scripts/smoke-test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,7 @@ async function run() {
console.log("🔎 Fetching agent card...");
const card = await getAgentCard();
assert.equal(card.name, "supportagent");
assert.equal(card.url, new URL(`/a2a/${AGENT_ID}`, BASE_URL).toString());
assert.equal(Array.isArray(card.skills), true);
console.log("✅ Agent card retrieved");

Expand Down
37 changes: 37 additions & 0 deletions packages/a2a-server/src/server.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,43 @@ describe("A2AServer", () => {
});
});

it("uses the A2A endpoint for agent card URLs", () => {
const agent: StubAgent = {
id: "support-agent",
purpose: "Answer support questions",
generateText: vi.fn(),
streamText: vi.fn(),
};

const server = createServer(agent);

expect(server.getAgentCard("support-agent").url).toBe("/a2a/support-agent");
expect(
server.getAgentCard("support-agent", {
requestUrl: "https://agents.example/.well-known/support-agent/agent-card.json",
}).url,
).toBe("https://agents.example/a2a/support-agent");
});

it("encodes reserved characters in A2A endpoint URLs without removing spaces", () => {
const agentId = "support agent/ops?";
const agent: StubAgent = {
id: agentId,
purpose: "Answer support questions",
generateText: vi.fn(),
streamText: vi.fn(),
};

const server = createServer(agent);

expect(server.getAgentCard(agentId).url).toBe("/a2a/support%20agent%2Fops%3F");
expect(
server.getAgentCard(agentId, {
requestUrl: "https://agents.example/.well-known/support%20agent%2Fops%3F/agent-card.json",
}).url,
).toBe("https://agents.example/a2a/support%20agent%2Fops%3F");
});

it("streams incremental updates and completes the task", async () => {
const streamText = vi.fn().mockImplementation(async () => ({
text: Promise.resolve("Final response"),
Expand Down
30 changes: 27 additions & 3 deletions packages/a2a-server/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,30 @@ import type {
} from "./types";
import { VoltA2AError } from "./types";

const DEFAULT_A2A_ROUTE_PREFIX = "/a2a";

function sanitizeSegment(segment: string): string {
return encodeURIComponent(segment.replace(/^\/+|\/+$/g, ""));
}

function buildA2AEndpointPath(serverId: string): string {
return `${DEFAULT_A2A_ROUTE_PREFIX}/${sanitizeSegment(serverId)}`;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

function resolveAgentCardUrl(serverId: string, requestUrl?: string): string {
const endpointPath = buildA2AEndpointPath(serverId);

if (!requestUrl) {
return endpointPath;
}

try {
return new URL(endpointPath, requestUrl).toString();
} catch {
return endpointPath;
}
}

export class A2AServer {
private deps?: Required<A2AServerDeps>;
private readonly config: A2AServerConfig;
Expand Down Expand Up @@ -69,9 +93,9 @@ export class A2AServer {
};
}

getAgentCard(agentId: string, _context: A2ARequestContext = {}): AgentCard {
const agent = this.resolveAgent(agentId, _context);
const url = `/.well-known/${agentId}/agent-card.json`;
getAgentCard(agentId: string, context: A2ARequestContext = {}): AgentCard {
const agent = this.resolveAgent(agentId, context);
const url = resolveAgentCardUrl(agentId, context.requestUrl);

return buildAgentCard(agent, {
url,
Expand Down
1 change: 1 addition & 0 deletions packages/a2a-server/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ export interface A2ARequestContext {
userId?: string;
sessionId?: string;
metadata?: Record<string, unknown>;
requestUrl?: string;
}

export interface A2AFilterParams<T> {
Expand Down
11 changes: 11 additions & 0 deletions packages/server-core/src/a2a/routes.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { describe, expect, it } from "vitest";
import { buildA2AEndpointPath, buildAgentCardPath } from "./routes";

describe("A2A route helpers", () => {
it("encodes reserved characters without removing internal spaces", () => {
expect(buildA2AEndpointPath("support agent/ops?")).toBe("/a2a/support%20agent%2Fops%3F");
expect(buildAgentCardPath("support agent/ops?")).toBe(
"/.well-known/support%20agent%2Fops%3F/agent-card.json",
);
});
});
2 changes: 1 addition & 1 deletion packages/server-core/src/a2a/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,5 @@ export function buildA2AEndpointPath(serverId: string): string {
}

function sanitizeSegment(segment: string): string {
return segment.replace(/^\/+|\/+$|\s+/g, "");
return encodeURIComponent(segment.replace(/^\/+|\/+$/g, ""));
}
1 change: 1 addition & 0 deletions packages/server-core/src/a2a/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export interface A2ARequestContext {
userId?: string;
sessionId?: string;
metadata?: Record<string, unknown>;
requestUrl?: string;
}

export interface AgentCardSkill {
Expand Down
9 changes: 4 additions & 5 deletions packages/server-elysia/src/routes/a2a.routes.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,14 +80,13 @@ describe("A2A Routes", () => {
});

it("should handle agent card request", async () => {
vi.mocked(serverCore.resolveAgentCard).mockResolvedValue({
vi.mocked(serverCore.resolveAgentCard).mockReturnValue({
name: "agent",
description: "desc",
} as any);

const response = await app.handle(
new Request("http://localhost/.well-known/server1/agent-card.json"),
);
const requestUrl = "http://localhost/.well-known/server1/agent-card.json";
const response = await app.handle(new Request(requestUrl));

expect(response.status).toBe(200);
expect(await response.json()).toEqual({
Expand All @@ -98,7 +97,7 @@ describe("A2A Routes", () => {
mockDeps.a2a.registry,
"server1",
"server1",
{},
{ requestUrl },
);
});

Expand Down
6 changes: 4 additions & 2 deletions packages/server-elysia/src/routes/a2a.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,9 +187,11 @@ export function registerA2ARoutes(app: Elysia, deps: ServerProviderDeps, logger:
// GET /a2a/:serverId/card - Get agent card
app.get(
A2A_ROUTES.agentCard.path,
async ({ params, set }) => {
async ({ params, request, set }) => {
try {
const card = resolveAgentCard(typedRegistry, params.serverId, params.serverId, {});
const card = resolveAgentCard(typedRegistry, params.serverId, params.serverId, {
requestUrl: request.url,
});
return card;
} catch (error) {
const response = normalizeError(null, error);
Expand Down
166 changes: 166 additions & 0 deletions packages/server-hono/src/routes/a2a.routes.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
import { A2AServerRegistry, type ServerProviderDeps, TriggerRegistry } from "@voltagent/core";
import type { Logger } from "@voltagent/internal";
import * as serverCore from "@voltagent/server-core";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { OpenAPIHono } from "../zod-openapi-compat";
import { registerA2ARoutes } from "./a2a.routes";

vi.mock("@voltagent/server-core", async () => {
const actual = await vi.importActual("@voltagent/server-core");
return {
...actual,
executeA2ARequest: vi.fn(),
resolveAgentCard: vi.fn(),
A2A_ROUTES: actual.A2A_ROUTES,
};
});

vi.mock("@voltagent/a2a-server", async () => {
return {
normalizeError: vi.fn().mockImplementation((id, error) => ({
jsonrpc: "2.0",
id,
error: {
code: error.code || -32603,
message: error.message,
},
})),
};
});

function createMockA2ARegistry() {
const registry = new A2AServerRegistry();
registry.register(
{
getMetadata() {
return {
id: "server1",
name: "server1",
version: "1.0.0",
};
},
},
{
agentRegistry: {
getAgent() {
return undefined;
},
getAllAgents() {
return [];
},
},
},
);
return registry;
}

function createMockDeps(): ServerProviderDeps {
return {
agentRegistry: {
getAgent() {
return undefined;
},
getAllAgents() {
return [];
},
getAgentCount() {
return 0;
},
removeAgent() {
return false;
},
registerAgent() {},
getGlobalVoltOpsClient() {
return undefined;
},
getGlobalLogger() {
return undefined;
},
},
workflowRegistry: {
getWorkflow() {
return undefined;
},
getWorkflowsForApi() {
return [];
},
getWorkflowDetailForApi() {
return undefined;
},
getWorkflowCount() {
return 0;
},
getAllWorkflowIds() {
return [];
},
on() {},
off() {},
activeExecutions: new Map(),
async resumeSuspendedWorkflow() {
return undefined;
},
},
triggerRegistry: new TriggerRegistry(),
a2a: {
registry: createMockA2ARegistry(),
},
};
}

function createMockLogger(): Logger {
const logger: Logger = {
trace: vi.fn(),
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
fatal: vi.fn(),
child: vi.fn(() => logger),
};

return logger;
}

describe("A2A Routes", () => {
let app: InstanceType<typeof OpenAPIHono>;
let mockDeps: ServerProviderDeps;
let mockLogger: Logger;

beforeEach(() => {
app = new OpenAPIHono();
mockDeps = createMockDeps();
mockLogger = createMockLogger();
registerA2ARoutes(app, mockDeps, mockLogger);
vi.clearAllMocks();
});

it("passes the request URL when resolving the agent card", async () => {
const card = {
name: "agent",
description: "desc",
url: "https://agents.example/a2a/server1",
version: "1.0.0",
capabilities: {
streaming: true,
pushNotifications: false,
stateTransitionHistory: false,
},
defaultInputModes: ["text"],
defaultOutputModes: ["text"],
skills: [],
};
vi.mocked(serverCore.resolveAgentCard).mockReturnValue(card);

const requestUrl = "http://agents.example/.well-known/server1/agent-card.json";
const response = await app.request(requestUrl);

expect(response.status).toBe(200);
expect(await response.json()).toEqual(card);
expect(serverCore.resolveAgentCard).toHaveBeenCalledWith(
mockDeps.a2a?.registry,
"server1",
"server1",
{ requestUrl },
);
});
});
4 changes: 3 additions & 1 deletion packages/server-hono/src/routes/a2a.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,9 @@ export function registerA2ARoutes(app: OpenAPIHonoType, deps: ServerProviderDeps
app.openapi(agentCardRoute as any, (c) => {
const serverId = requirePathParam(c, "serverId");
try {
const card = resolveAgentCard(typedRegistry, serverId, serverId, {});
const card = resolveAgentCard(typedRegistry, serverId, serverId, {
requestUrl: c.req.url,
});
return c.json(card, 200);
} catch (error) {
const response = normalizeError(null, error);
Expand Down
Loading
Loading