Skip to content

Commit 5ad9e97

Browse files
kaisozaojea
authored andcommitted
sdk: rewrite the card of an agent behind a node for the mesh
A stock A2A client on session.fetch() or MeshTransport follows the URL in the agent card. An agent behind a sam-node serves a card naming its own address, so the client left the mesh and the SDK refused the URL. sam-node's egress proxy impersonates that card endpoint for its own callers. The SDKs now do the same at their /libp2p-http client: a GET of /a2a/<name>/.well-known/agent-card.json or of the bare service root is held, the SDK fetches the card itself at the well-known path with identity encoding, and answers with it regenerated. The rewrite points every HTTP interface at the mesh URL, drops gRPC interfaces and signatures, and answers 502 for a card that is not JSON or has no interface left; the agent's own non-200 is relayed as it is. Streaming stays as the agent declares it, unlike the node's rewrite, since this transport streams and SDK-hosted agents advertise it.
1 parent b4e5f0d commit 5ad9e97

10 files changed

Lines changed: 410 additions & 22 deletions

File tree

‎sdk/README.md‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,7 @@ pinned by a test:
141141
ID is case-sensitive. The URL an HTTP client uses for a peer's service
142142
therefore carries the peer ID in the path,
143143
`http://mesh/sam/<peer-id>/<type>/<name>/<path>`, the shape of `sam-node`'s
144-
egress proxy and of an agent card it rewrote; the host is ignored.
144+
egress proxy and of an agent card rewritten for the mesh; the host is ignored.
145145
- A member that publishes nothing has announced no address, so nothing in
146146
the DHT or a router's peerstore names one. `sam-node` dials
147147
`/p2p/<router>/p2p-circuit` for every router it authenticated with when
@@ -520,7 +520,13 @@ holds against the control plane's records.
520520
`open_http_request` / `fetchOverStream` return once the headers are in
521521
and stream the body; `MeshTransport` (httpx) and `session.fetch()`
522522
(fetch) carry a client's requests to the peer a mesh URL names. The A2A
523-
SDK's client takes either without changes.
523+
SDK's client takes either without changes. The card of an agent behind a
524+
`sam-node` names the agent's own address; a GET of the well-known card
525+
path or of the service root is answered the way the node's egress proxy
526+
answers it: the SDK fetches the card itself, with identity encoding, and
527+
serves it rewritten (`rewriteAgentCard` / `rewrite_agent_card`): HTTP
528+
interfaces point at the mesh URL, gRPC ones are dropped, signatures go.
529+
Streaming stays as the agent declares it, since the transport streams.
524530
- `sam-node`: a peer it knows no address for is dialed through every
525531
router it authenticated with, so its egress proxy reaches an agent by
526532
peer ID (`preparePeerAddrs`).

‎sdk/js/src/index.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ export { LabelsNotSatisfiedError, StreamTransport, openMCPSession, requireEgress
4747
export { AuthorizationError, authorizeCaller, type AuthorizeRequest, type ProviderAuthorizerOptions } from "./authorizer.ts";
4848
export {
4949
DEFAULT_A2A_NAME,
50+
AGENT_CARD_PATH,
5051
HTTP_HANDLER_OPTIONS,
5152
HTTP_PROTOCOL,
5253
MESH_PATH_PREFIX,
@@ -57,6 +58,7 @@ export {
5758
httpRequestOverStream,
5859
meshHTTPTarget,
5960
meshURL,
61+
rewriteAgentCard,
6062
splitMeshURL,
6163
type A2AEndpoint,
6264
type A2AEndpointSpec,

‎sdk/js/src/libp2p-http.test.ts‎

Lines changed: 73 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import { after, before, test } from "node:test";
2929
import { loadBiscuit } from "./biscuit.ts";
3030
import { ROLE_NODE } from "./controlplane.ts";
3131
import {
32+
AGENT_CARD_PATH,
3233
HTTP_PROTOCOL,
3334
a2aEndpoint,
3435
admitIngress,
@@ -37,6 +38,7 @@ import {
3738
httpRequestOverStream,
3839
meshHTTPTarget,
3940
meshURL,
41+
rewriteAgentCard,
4042
splitMeshURL,
4143
type ProviderOptions,
4244
} from "./libp2p-http.ts";
@@ -55,7 +57,7 @@ let callerBiscuit: Uint8Array;
5557
let guestBiscuit: Uint8Array;
5658
let backend: http.Server;
5759
let backendURL: string;
58-
const backendSeen: { method: string; url: string; peer: string | undefined; body: string }[] = [];
60+
const backendSeen: { method: string; url: string; peer: string | undefined; body: string; encoding: string | undefined }[] = [];
5961
const listenerSeen: { url: string; peer: string | undefined; biscuit: string | undefined }[] = [];
6062
const authorized: string[] = [];
6163

@@ -98,14 +100,40 @@ function providerOptions(biscuit: Uint8Array): ProviderOptions {
98100
};
99101
}
100102

103+
const STOCK_CARD = {
104+
name: "echo-agent",
105+
version: "1.0.0",
106+
capabilities: { streaming: true, pushNotifications: false },
107+
supportedInterfaces: [
108+
{ url: "http://127.0.0.1:7777/", protocolBinding: "JSONRPC", protocolVersion: "1.0" },
109+
{ url: "127.0.0.1:50051", protocolBinding: "GRPC", protocolVersion: "1.0" },
110+
],
111+
signatures: [{ protected: "eyJhbGciOiJFUzI1NiJ9", signature: "c3RhbGU" }],
112+
skills: [],
113+
defaultInputModes: ["text/plain"],
114+
defaultOutputModes: ["text/plain"],
115+
};
116+
101117
// Stands in for an A2A server beside the agent: echoes the request and, on
102118
// /stream, answers with three SSE events as message/stream would.
103119
function fakeA2AServer(req: http.IncomingMessage, res: http.ServerResponse): void {
104120
let body = "";
105121
req.on("data", (c: Buffer) => (body += c.toString()));
106122
req.on("end", () => {
107-
backendSeen.push({ method: req.method ?? "", url: req.url ?? "", peer: req.headers["x-peer-id"] as string | undefined, body });
123+
backendSeen.push({ method: req.method ?? "", url: req.url ?? "", peer: req.headers["x-peer-id"] as string | undefined, body, encoding: req.headers["accept-encoding"] as string | undefined });
108124
assert.equal(req.headers["x-sam-biscuit"], undefined, "biscuit leaked to the backend");
125+
if (req.url === `/${AGENT_CARD_PATH}` && req.headers["x-card"] === "missing") {
126+
res.writeHead(404, { "content-type": "text/plain" });
127+
res.end("no card");
128+
return;
129+
}
130+
if (req.url === `/${AGENT_CARD_PATH}`) {
131+
const grpcOnly = req.headers["x-card"] === "grpc-only";
132+
const supportedInterfaces = STOCK_CARD.supportedInterfaces.filter((i) => !grpcOnly || i.protocolBinding === "GRPC");
133+
res.writeHead(200, { "content-type": "application/json" });
134+
res.end(JSON.stringify({ ...STOCK_CARD, supportedInterfaces }));
135+
return;
136+
}
109137
if (req.url === "/stream") {
110138
res.writeHead(200, { "content-type": "text/event-stream" });
111139
let i = 0;
@@ -242,6 +270,49 @@ test("a fetch over the stream delivers an SSE body event by event", async () =>
242270
);
243271
});
244272

273+
test("the card of an agent behind a node comes back rewritten for the mesh, as sam-node serves it", async () => {
274+
const conn = await dial();
275+
const base = meshURL(agent.peerId.toString(), "a2a://agent");
276+
const response = await fetchOverStream(conn, callerBiscuit, new Request(`${base}/${AGENT_CARD_PATH}`));
277+
assert.equal(response.status, 200);
278+
assert.equal(response.headers.get("content-type"), "application/json");
279+
const card = (await response.json()) as Record<string, unknown>;
280+
assert.deepEqual(card.supportedInterfaces, [{ url: base, protocolBinding: "JSONRPC", protocolVersion: "1.0" }]);
281+
assert.deepEqual(card.capabilities, { streaming: true, pushNotifications: false });
282+
assert.equal("signatures" in card, false);
283+
assert.equal(card.name, "echo-agent");
284+
assert.deepEqual(card.skills, []);
285+
assert.equal(backendSeen[backendSeen.length - 1]?.url, `/${AGENT_CARD_PATH}`);
286+
287+
// The bare service root serves the card too, the way a2a-go resolves a pathful base URL.
288+
assert.deepEqual(await (await fetchOverStream(conn, callerBiscuit, new Request(base))).json(), card);
289+
assert.equal(backendSeen[backendSeen.length - 1]?.url, `/${AGENT_CARD_PATH}`);
290+
await fetchOverStream(conn, callerBiscuit, new Request(`${base}/${AGENT_CARD_PATH}`, { headers: { "accept-encoding": "x-test-only" } }));
291+
assert.notEqual(backendSeen[backendSeen.length - 1]?.encoding, "x-test-only", "the client's accept-encoding reached the agent");
292+
293+
const missing = await fetchOverStream(conn, callerBiscuit, new Request(`${base}/${AGENT_CARD_PATH}`, { headers: { "x-card": "missing" } }));
294+
assert.equal(missing.status, 404);
295+
assert.equal(await missing.text(), "no card");
296+
297+
const raw = await httpRequestOverStream(conn, callerBiscuit, "a2a://agent", `/${AGENT_CARD_PATH}`);
298+
assert.deepEqual(JSON.parse(raw.text()), card);
299+
300+
const refused = await fetchOverStream(conn, callerBiscuit, new Request(`${base}/${AGENT_CARD_PATH}`, { headers: { "x-card": "grpc-only" } }));
301+
assert.equal(refused.status, 502);
302+
assert.match(await refused.text(), /no supported interface the mesh can carry/);
303+
304+
const other = await fetchOverStream(conn, callerBiscuit, new Request(`${base}/card`));
305+
assert.deepEqual(await other.json(), { path: "/card", echo: "" });
306+
});
307+
308+
test("rewriteAgentCard keeps only what the mesh carries", () => {
309+
assert.throws(() => rewriteAgentCard([], "http://mesh/x"), /not a JSON object/);
310+
assert.throws(() => rewriteAgentCard({ supportedInterfaces: [] }, "http://mesh/x"), /no supported interface/);
311+
assert.deepEqual(rewriteAgentCard({ supportedInterfaces: [{ url: "x", protocolBinding: "http+json" }] }, "http://mesh/x"), {
312+
supportedInterfaces: [{ url: "http://mesh/x", protocolBinding: "http+json" }],
313+
});
314+
});
315+
245316
test("a fetch handler sees the caller and the path, and its streaming body goes out as it is written", async () => {
246317
const conn = await dial(handlerAgent);
247318
const res = await httpRequestOverStream(conn, callerBiscuit, "a2a://agent", "/tasks?x=1", { method: "POST", headers: { "x-sam-biscuit": "spoof", "content-type": "text/plain" }, body: "hello" });

‎sdk/js/src/libp2p-http.ts‎

Lines changed: 104 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -61,12 +61,17 @@ export const DEFAULT_A2A_NAME = "agent";
6161

6262
/**
6363
* The path prefix of a mesh URL, http://mesh/sam/<peer-id>/<type>/<name>/<path>:
64-
* the shape of sam-node's egress proxy and of an agent card it rewrote. The
65-
* host is ignored; the peer ID is in the path because URL parsers lowercase
66-
* the host and a peer ID is case-sensitive.
64+
* the shape of sam-node's egress proxy and of an agent card rewritten for the
65+
* mesh, by sam-node or by this SDK. The host is ignored; the peer ID is in the
66+
* path because URL parsers lowercase the host and a peer ID is case-sensitive.
6767
*/
6868
export const MESH_PATH_PREFIX = "/sam/";
6969

70+
/** The well-known agent card location (A2A spec / RFC 8615). */
71+
export const AGENT_CARD_PATH = ".well-known/agent-card.json";
72+
73+
const MAX_AGENT_CARD_BYTES = 1 << 20;
74+
7075
/** Largest request body the ingress reads whole for a handler or url target. */
7176
export const MAX_INGRESS_BODY_BYTES = 8 * 1024 * 1024;
7277
const REQUEST_TIMEOUT_MS = 60_000;
@@ -387,6 +392,94 @@ export function splitMeshURL(url: URL): { peerId: string; target: string } {
387392
return { peerId, target: "/" + rest.join("/") + url.search };
388393
}
389394

395+
/** The bare service root counts too: a2a-go treats a pathful base URL as the card location. */
396+
function agentCardService(method: string, target: string): string | undefined {
397+
const m = /^\/a2a\/([^/?]+)(?:\/(?:\.well-known\/agent-card\.json)?)?(?:\?.*)?$/.exec(target);
398+
return method === "GET" && m !== null ? `a2a://${m[1]}` : undefined;
399+
}
400+
401+
/**
402+
* An agent card rebuilt for the mesh, as sam-node's egress proxy serves it:
403+
* HTTP interfaces point at base, gRPC ones go, signatures no longer match.
404+
* Streaming stays as declared, this transport streams. Throws when no interface remains.
405+
*/
406+
export function rewriteAgentCard(card: unknown, base: string): Record<string, unknown> {
407+
if (typeof card !== "object" || card === null || Array.isArray(card)) {
408+
throw new Error("agent card is not a JSON object");
409+
}
410+
const out = { ...(card as Record<string, unknown>) };
411+
delete out.signatures;
412+
const interfaces = Array.isArray(out.supportedInterfaces) ? (out.supportedInterfaces as unknown[]) : [];
413+
const kept = interfaces.filter(carriedOverHTTP).map((iface) => ({ ...(iface as Record<string, unknown>), url: base }));
414+
if (kept.length === 0) {
415+
throw new Error("agent card advertises no supported interface the mesh can carry (JSONRPC or HTTP+JSON); is the agent serving a pre-1.0 A2A card?");
416+
}
417+
return { ...out, supportedInterfaces: kept };
418+
}
419+
420+
/** Whether an interface's binding can traverse the mesh; gRPC needs its own connection. */
421+
function carriedOverHTTP(iface: unknown): boolean {
422+
const binding = typeof iface === "object" && iface !== null ? (iface as { protocolBinding?: unknown }).protocolBinding : undefined;
423+
return typeof binding === "string" && ["JSONRPC", "HTTP+JSON"].includes(binding.toUpperCase());
424+
}
425+
426+
/**
427+
* Impersonates the agent's card endpoint as sam-node's egress proxy does: holds
428+
* the client's request, fetches the card itself with identity encoding, and
429+
* answers with it regenerated; the agent's own non-200 is relayed as it is.
430+
*/
431+
async function serveAgentCard(conn: Connection, biscuit: Uint8Array, request: Request, options: HTTPStreamOptions, service: string): Promise<Response> {
432+
const base = meshURL(conn.remotePeer.toString(), service);
433+
const headers = new Headers(request.headers);
434+
headers.delete("accept-encoding");
435+
let response: Response;
436+
try {
437+
response = await sendOverStream(conn, biscuit, new Request(`${base}/${AGENT_CARD_PATH}`, { headers, signal: request.signal }), options);
438+
} catch (err) {
439+
return badGateway(`agent card fetch failed: ${err instanceof Error ? err.message : String(err)}`);
440+
}
441+
if (response.status !== 200) {
442+
return response;
443+
}
444+
let card: unknown;
445+
try {
446+
card = JSON.parse(new TextDecoder().decode(await readLimited(response.body, MAX_AGENT_CARD_BYTES)));
447+
} catch {
448+
return badGateway("agent card is not valid JSON");
449+
}
450+
try {
451+
return new Response(JSON.stringify(rewriteAgentCard(card, base)), { status: 200, headers: { "content-type": "application/json" } });
452+
} catch (err) {
453+
return badGateway(err instanceof Error ? err.message : String(err));
454+
}
455+
}
456+
457+
function badGateway(reason: string): Response {
458+
return new Response(`Bad Gateway: ${reason}`, { status: 502, headers: { "content-type": "text/plain" } });
459+
}
460+
461+
/** The first limit bytes of a body, the rest dropped, as io.LimitReader bounds the node. */
462+
async function readLimited(body: ReadableStream<Uint8Array> | null, limit: number): Promise<Uint8Array> {
463+
const chunks: Uint8Array[] = [];
464+
let n = 0;
465+
if (body !== null) {
466+
const reader = body.getReader();
467+
for (let next = await reader.read(); !next.done && n < limit; next = await reader.read()) {
468+
const chunk = next.value.subarray(0, limit - n);
469+
chunks.push(chunk);
470+
n += chunk.length;
471+
}
472+
await reader.cancel();
473+
}
474+
const out = new Uint8Array(n);
475+
let at = 0;
476+
for (const chunk of chunks) {
477+
out.set(chunk, at);
478+
at += chunk.length;
479+
}
480+
return out;
481+
}
482+
390483
export interface HTTPStreamOptions {
391484
/** The agent this request is made for. */
392485
agent?: string;
@@ -398,9 +491,16 @@ export interface HTTPStreamOptions {
398491
* Client side of /libp2p-http, as go-libp2p-http's RoundTripper: one stream
399492
* per request, plain HTTP/1.1 with Host set to the peer ID and the biscuit in
400493
* X-Sam-Biscuit. Resolves once the response headers are in; the body streams
401-
* after, so an SSE response is consumed as the peer sends it.
494+
* after, so an SSE response is consumed as the peer sends it. An agent card
495+
* is served rewritten for the mesh (rewriteAgentCard), as sam-node serves one.
402496
*/
403497
export async function fetchOverStream(conn: Connection, biscuit: Uint8Array, request: Request, options: HTTPStreamOptions = {}): Promise<Response> {
498+
const { target } = splitMeshURL(new URL(request.url));
499+
const service = agentCardService(request.method, target);
500+
return service === undefined ? sendOverStream(conn, biscuit, request, options) : serveAgentCard(conn, biscuit, request, options, service);
501+
}
502+
503+
async function sendOverStream(conn: Connection, biscuit: Uint8Array, request: Request, options: HTTPStreamOptions): Promise<Response> {
404504
const { target } = splitMeshURL(new URL(request.url));
405505
const peerId = conn.remotePeer.toString();
406506
// One controller ends the exchange: the caller's signal at any time, the

‎sdk/python/src/agent_mesh/__init__.py‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
from .httpx_transport import MESH_PATH_PREFIX, MeshTransport, split_mesh_url
3535
from .identity import Identity, canonical_peer_id, libp2p_public_key, peer_id_from_public_key, verify_ed25519
3636
from .libp2p_http import (
37+
AGENT_CARD_PATH,
3738
DEFAULT_A2A_NAME,
3839
HTTP_PROTOCOL,
3940
A2AEndpoint,
@@ -45,7 +46,9 @@
4546
http_ingress_handler,
4647
http_request_over_stream,
4748
mesh_http_target,
49+
mesh_url,
4850
open_http_request,
51+
rewrite_agent_card,
4952
)
5053
from .mcp_client import LabelsNotSatisfiedError, ToolCallResult, ToolInfo, open_mcp_session, require_egress_labels, require_labels
5154
from .mesh import AgentMesh, ControlPlaneSync
@@ -57,6 +60,7 @@
5760

5861
__all__ = [
5962
"A2AEndpoint",
63+
"AGENT_CARD_PATH",
6064
"AUTH_PROTOCOL",
6165
"AdmittedRouter",
6266
"AgentMesh",
@@ -111,6 +115,7 @@
111115
"http_request_over_stream",
112116
"libp2p_public_key",
113117
"mesh_http_target",
118+
"mesh_url",
114119
"open_http_request",
115120
"open_mcp_session",
116121
"parse_service_target",
@@ -122,6 +127,7 @@
122127
"require_role",
123128
"reserve_relay",
124129
"service_key",
130+
"rewrite_agent_card",
125131
"split_mesh_url",
126132
"validate_control_plane_url",
127133
"verify_ed25519",

‎sdk/python/src/agent_mesh/httpx_transport.py‎

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,22 +20,23 @@
2020
await client.post("http://mesh/sam/<peer-id>/a2a/agent", json=...)
2121
2222
The URL has the shape sam-node's egress proxy takes, /sam/<peer-id>/<type>/
23-
<name>/<path>, so an agent card a sam-node rewrote for the mesh works here as
24-
it is. The host is ignored: httpx lowercases it, and a peer ID is not
25-
case-insensitive. Response bodies stream, so `message/stream` works."""
23+
<name>/<path>. The card of an agent behind a sam-node comes back rewritten to
24+
that shape, as the node's egress proxy serves it, so a stock A2A client
25+
bootstraps from it unchanged. The host is ignored: httpx lowercases it, and a
26+
peer ID is not case-insensitive. Response bodies stream, so `message/stream`
27+
works."""
2628

2729
from __future__ import annotations
2830

2931
from typing import TYPE_CHECKING, AsyncIterator
3032

3133
import httpx
3234

33-
from .libp2p_http import StreamedResponse, open_http_request
35+
from .libp2p_http import MESH_PATH_PREFIX, StreamedResponse, open_http_request
3436

3537
if TYPE_CHECKING:
3638
from .session import MeshSession
3739

38-
MESH_PATH_PREFIX = "/sam/"
3940
_REQUEST_TIMEOUT = 60.0
4041

4142

0 commit comments

Comments
 (0)