Skip to content

Commit b6813e9

Browse files
authored
fix: correct A2A agent card URLs (#1199)
* fix: correct A2A agent card URLs * fix: address A2A review findings
1 parent 74b76aa commit b6813e9

14 files changed

Lines changed: 288 additions & 23 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
"@voltagent/a2a-server": patch
3+
"@voltagent/server-core": patch
4+
"@voltagent/server-hono": patch
5+
"@voltagent/server-elysia": patch
6+
---
7+
8+
fix: point A2A agent cards at the JSON-RPC endpoint
9+
10+
A2A agent cards now advertise `/a2a/{serverId}` instead of the internal
11+
`/.well-known/{serverId}/agent-card.json` discovery document. When the card is
12+
served through the Hono or Elysia integrations, VoltAgent also resolves that
13+
endpoint to an absolute URL based on the incoming request.

examples/with-a2a-server/README.md

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -79,13 +79,21 @@ pnpm --filter voltagent-example-with-a2a-server dev
7979
The Hono server listens on `http://localhost:3141`. Check the discovery document:
8080

8181
```bash
82-
curl http://localhost:3141/.well-known/support/agent-card.json | jq
82+
curl http://localhost:3141/.well-known/supportagent/agent-card.json | jq
83+
```
84+
85+
The returned card advertises the JSON-RPC endpoint via its `url` field:
86+
87+
```json
88+
{
89+
"url": "http://localhost:3141/a2a/supportagent"
90+
}
8391
```
8492

8593
Send a JSON-RPC request to the agent:
8694

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

113-
The script fetches the agent card, sends a message via `/a2a`, and asserts that the resulting task transitions to `completed`.
121+
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`.
114122

115123
## Next steps
116124

examples/with-a2a-server/scripts/smoke-test.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,7 @@ async function run() {
151151
console.log("🔎 Fetching agent card...");
152152
const card = await getAgentCard();
153153
assert.equal(card.name, "supportagent");
154+
assert.equal(card.url, new URL(`/a2a/${AGENT_ID}`, BASE_URL).toString());
154155
assert.equal(Array.isArray(card.skills), true);
155156
console.log("✅ Agent card retrieved");
156157

packages/a2a-server/src/server.spec.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,43 @@ describe("A2AServer", () => {
176176
});
177177
});
178178

179+
it("uses the A2A endpoint for agent card URLs", () => {
180+
const agent: StubAgent = {
181+
id: "support-agent",
182+
purpose: "Answer support questions",
183+
generateText: vi.fn(),
184+
streamText: vi.fn(),
185+
};
186+
187+
const server = createServer(agent);
188+
189+
expect(server.getAgentCard("support-agent").url).toBe("/a2a/support-agent");
190+
expect(
191+
server.getAgentCard("support-agent", {
192+
requestUrl: "https://agents.example/.well-known/support-agent/agent-card.json",
193+
}).url,
194+
).toBe("https://agents.example/a2a/support-agent");
195+
});
196+
197+
it("encodes reserved characters in A2A endpoint URLs without removing spaces", () => {
198+
const agentId = "support agent/ops?";
199+
const agent: StubAgent = {
200+
id: agentId,
201+
purpose: "Answer support questions",
202+
generateText: vi.fn(),
203+
streamText: vi.fn(),
204+
};
205+
206+
const server = createServer(agent);
207+
208+
expect(server.getAgentCard(agentId).url).toBe("/a2a/support%20agent%2Fops%3F");
209+
expect(
210+
server.getAgentCard(agentId, {
211+
requestUrl: "https://agents.example/.well-known/support%20agent%2Fops%3F/agent-card.json",
212+
}).url,
213+
).toBe("https://agents.example/a2a/support%20agent%2Fops%3F");
214+
});
215+
179216
it("streams incremental updates and completes the task", async () => {
180217
const streamText = vi.fn().mockImplementation(async () => ({
181218
text: Promise.resolve("Final response"),

packages/a2a-server/src/server.ts

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,30 @@ import type {
3131
} from "./types";
3232
import { VoltA2AError } from "./types";
3333

34+
const DEFAULT_A2A_ROUTE_PREFIX = "/a2a";
35+
36+
function sanitizeSegment(segment: string): string {
37+
return encodeURIComponent(segment.replace(/^\/+|\/+$/g, ""));
38+
}
39+
40+
function buildA2AEndpointPath(serverId: string): string {
41+
return `${DEFAULT_A2A_ROUTE_PREFIX}/${sanitizeSegment(serverId)}`;
42+
}
43+
44+
function resolveAgentCardUrl(serverId: string, requestUrl?: string): string {
45+
const endpointPath = buildA2AEndpointPath(serverId);
46+
47+
if (!requestUrl) {
48+
return endpointPath;
49+
}
50+
51+
try {
52+
return new URL(endpointPath, requestUrl).toString();
53+
} catch {
54+
return endpointPath;
55+
}
56+
}
57+
3458
export class A2AServer {
3559
private deps?: Required<A2AServerDeps>;
3660
private readonly config: A2AServerConfig;
@@ -69,9 +93,9 @@ export class A2AServer {
6993
};
7094
}
7195

72-
getAgentCard(agentId: string, _context: A2ARequestContext = {}): AgentCard {
73-
const agent = this.resolveAgent(agentId, _context);
74-
const url = `/.well-known/${agentId}/agent-card.json`;
96+
getAgentCard(agentId: string, context: A2ARequestContext = {}): AgentCard {
97+
const agent = this.resolveAgent(agentId, context);
98+
const url = resolveAgentCardUrl(agentId, context.requestUrl);
7599

76100
return buildAgentCard(agent, {
77101
url,

packages/a2a-server/src/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@ export interface A2ARequestContext {
100100
userId?: string;
101101
sessionId?: string;
102102
metadata?: Record<string, unknown>;
103+
requestUrl?: string;
103104
}
104105

105106
export interface A2AFilterParams<T> {
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { describe, expect, it } from "vitest";
2+
import { buildA2AEndpointPath, buildAgentCardPath } from "./routes";
3+
4+
describe("A2A route helpers", () => {
5+
it("encodes reserved characters without removing internal spaces", () => {
6+
expect(buildA2AEndpointPath("support agent/ops?")).toBe("/a2a/support%20agent%2Fops%3F");
7+
expect(buildAgentCardPath("support agent/ops?")).toBe(
8+
"/.well-known/support%20agent%2Fops%3F/agent-card.json",
9+
);
10+
});
11+
});

packages/server-core/src/a2a/routes.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,5 +10,5 @@ export function buildA2AEndpointPath(serverId: string): string {
1010
}
1111

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

packages/server-core/src/a2a/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ export interface A2ARequestContext {
3636
userId?: string;
3737
sessionId?: string;
3838
metadata?: Record<string, unknown>;
39+
requestUrl?: string;
3940
}
4041

4142
export interface AgentCardSkill {

packages/server-elysia/src/routes/a2a.routes.spec.ts

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -80,14 +80,13 @@ describe("A2A Routes", () => {
8080
});
8181

8282
it("should handle agent card request", async () => {
83-
vi.mocked(serverCore.resolveAgentCard).mockResolvedValue({
83+
vi.mocked(serverCore.resolveAgentCard).mockReturnValue({
8484
name: "agent",
8585
description: "desc",
8686
} as any);
8787

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

9291
expect(response.status).toBe(200);
9392
expect(await response.json()).toEqual({
@@ -98,7 +97,7 @@ describe("A2A Routes", () => {
9897
mockDeps.a2a.registry,
9998
"server1",
10099
"server1",
101-
{},
100+
{ requestUrl },
102101
);
103102
});
104103

0 commit comments

Comments
 (0)