Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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.
12 changes: 10 additions & 2 deletions examples/with-a2a-server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,15 @@ 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:
Expand Down Expand Up @@ -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
18 changes: 18 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,24 @@ 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("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 segment.replace(/^\/+|\/+$|\s+/g, "");
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
}

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
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
73 changes: 73 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,73 @@
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,
},
})),
};
});

describe("A2A Routes", () => {
let app: OpenAPIHono;
const mockDeps = {
a2a: {
registry: {
list: vi.fn().mockReturnValue([{ id: "server1" }]),
},
},
} as any;
const mockLogger = {
trace: vi.fn(),
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
} as any;

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

it("passes the request URL when resolving the agent card", async () => {
vi.mocked(serverCore.resolveAgentCard).mockReturnValue({
name: "agent",
description: "desc",
} as any);

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({
name: "agent",
description: "desc",
});
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
14 changes: 7 additions & 7 deletions website/docs/agents/a2a/a2a-server.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ export const a2aServer = new A2AServer({
});
```

The server metadata feeds the discovery card served from `/.well-known/{agentId}/agent-card.json`.
The server metadata feeds the discovery card served from `/.well-known/{serverId}/agent-card.json`. The card's `url` field points to `/a2a/{serverId}`, and when the card is fetched over HTTP VoltAgent returns that URL as an absolute address.

## Register The Server With VoltAgent

Expand All @@ -71,17 +71,17 @@ export const voltAgent = new VoltAgent({

With this in place, VoltAgent automatically exposes:

- `GET /.well-known/{agentId}/agent-card.json`
- `POST /a2a/{agentId}`
- `GET /.well-known/{serverId}/agent-card.json`
- `POST /a2a/{serverId}`
Comment thread
coderabbitai[bot] marked this conversation as resolved.

The JSON-RPC handler accepts `message/send`, `message/stream`, `tasks/get`, and `tasks/cancel` requests.

## Available Endpoints

| Method | Path | Description |
| ------ | ---------------------------------------- | ---------------------------------------------------------------------------------------------------------------- |
| `GET` | `/.well-known/{agentId}/agent-card.json` | Returns the discovery card for the specified agent. |
| `POST` | `/a2a/{agentId}` | Accepts JSON-RPC 2.0 requests. Supported methods: `message/send`, `message/stream`, `tasks/get`, `tasks/cancel`. |
| Method | Path | Description |
| ------ | ----------------------------------------- | ---------------------------------------------------------------------------------------------------------------- |
| `GET` | `/.well-known/{serverId}/agent-card.json` | Returns the discovery card for the specified A2A server. The card's `url` points to `/a2a/{serverId}`. |
| `POST` | `/a2a/{serverId}` | Accepts JSON-RPC 2.0 requests. Supported methods: `message/send`, `message/stream`, `tasks/get`, `tasks/cancel`. |

Example JSON-RPC payload for `message/send`:

Expand Down
Loading