@agentmesh-p2p/sdk 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/README.md +182 -2
  2. package/dist/auth.d.ts +66 -0
  3. package/dist/auth.js +164 -0
  4. package/dist/authorizer.d.ts +41 -0
  5. package/dist/authorizer.js +201 -0
  6. package/dist/base58.d.ts +2 -0
  7. package/dist/base58.js +57 -0
  8. package/dist/biscuit.d.ts +75 -0
  9. package/dist/biscuit.js +250 -0
  10. package/dist/bytes.d.ts +10 -0
  11. package/dist/bytes.js +51 -0
  12. package/dist/challenges.d.ts +16 -0
  13. package/dist/challenges.js +55 -0
  14. package/dist/conformance-join.d.ts +1 -0
  15. package/dist/conformance-join.js +257 -0
  16. package/dist/conformance.d.ts +1 -0
  17. package/dist/conformance.js +60 -0
  18. package/dist/controlplane.d.ts +123 -0
  19. package/dist/controlplane.js +368 -0
  20. package/dist/credential.d.ts +66 -0
  21. package/dist/credential.js +124 -0
  22. package/dist/discovery.d.ts +50 -0
  23. package/dist/discovery.js +150 -0
  24. package/dist/gen/agentmesh_pb.d.ts +2740 -0
  25. package/dist/gen/agentmesh_pb.js +591 -0
  26. package/dist/gen/datalog.d.ts +39 -0
  27. package/dist/gen/datalog.js +84 -0
  28. package/dist/host.d.ts +47 -0
  29. package/dist/host.js +109 -0
  30. package/dist/http1.d.ts +68 -0
  31. package/dist/http1.js +371 -0
  32. package/dist/identity.d.ts +31 -0
  33. package/dist/identity.js +125 -0
  34. package/dist/index.d.ts +18 -0
  35. package/dist/index.js +30 -0
  36. package/dist/libp2p-http-node.d.ts +17 -0
  37. package/dist/libp2p-http-node.js +116 -0
  38. package/dist/libp2p-http.d.ts +165 -0
  39. package/dist/libp2p-http.js +581 -0
  40. package/dist/mcp.d.ts +67 -0
  41. package/dist/mcp.js +157 -0
  42. package/dist/mesh.d.ts +151 -0
  43. package/dist/mesh.js +370 -0
  44. package/dist/platform/ingress.browser.d.ts +1 -0
  45. package/dist/platform/ingress.browser.js +16 -0
  46. package/dist/platform/ingress.d.ts +1 -0
  47. package/dist/platform/ingress.js +15 -0
  48. package/dist/platform/state.browser.d.ts +5 -0
  49. package/dist/platform/state.browser.js +47 -0
  50. package/dist/platform/state.d.ts +5 -0
  51. package/dist/platform/state.js +44 -0
  52. package/dist/platform/transports.browser.d.ts +3 -0
  53. package/dist/platform/transports.browser.js +27 -0
  54. package/dist/platform/transports.d.ts +3 -0
  55. package/dist/platform/transports.js +27 -0
  56. package/dist/platform/types.d.ts +7 -0
  57. package/dist/platform/types.js +14 -0
  58. package/dist/platform/wasm.browser.d.ts +2 -0
  59. package/dist/platform/wasm.browser.js +16 -0
  60. package/dist/platform/wasm.d.ts +2 -0
  61. package/dist/platform/wasm.js +56 -0
  62. package/dist/session.d.ts +316 -0
  63. package/dist/session.js +1269 -0
  64. package/dist/sync.d.ts +41 -0
  65. package/dist/sync.js +111 -0
  66. package/dist/tar.d.ts +25 -0
  67. package/dist/tar.js +406 -0
  68. package/package.json +74 -4
@@ -0,0 +1,581 @@
1
+ // Copyright 2026 Google LLC
2
+ //
3
+ // Licensed under the Apache License, Version 2.0 (the "License");
4
+ // you may not use this file except in compliance with the License.
5
+ // You may obtain a copy of the License at
6
+ //
7
+ // http://www.apache.org/licenses/LICENSE-2.0
8
+ //
9
+ // Unless required by applicable law or agreed to in writing, software
10
+ // distributed under the License is distributed on an "AS IS" BASIS,
11
+ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ // See the License for the specific language governing permissions and
13
+ // limitations under the License.
14
+ import { AUTH_HANDSHAKE_TIMEOUT_MS } from "./auth.js";
15
+ import { AuthorizationError, authorizeCaller } from "./authorizer.js";
16
+ import { fromBase64, toBase64 } from "./bytes.js";
17
+ import { ByteReader, HTTPParseError, LAST_CHUNK, bodyStream, encodeChunk, encodeRequestHead, encodeResponseHead, readBody, readRequestHead, readResponseHead, requestBodyFraming, responseBodyFraming, sendAll, streamSource, } from "./http1.js";
18
+ import { canonicalPeerId } from "./identity.js";
19
+ /** go-libp2p-http's protocol: plain HTTP/1.1 on a stream, one request per stream. */
20
+ export const HTTP_PROTOCOL = "/libp2p-http";
21
+ /** Headers of the mesh HTTP datapath (api/network.go). */
22
+ export const HEADER_MESH_BISCUIT = "x-mesh-biscuit";
23
+ export const HEADER_PEER_ID = "x-peer-id";
24
+ export const HEADER_MESH_NO_TRAILING_SLASH = "x-mesh-no-trailing-slash";
25
+ /** Options for libp2p.handle() so the agent is reachable over relayed connections. */
26
+ export const HTTP_HANDLER_OPTIONS = { runOnLimitedConnection: true };
27
+ /** The service name an agent answers under unless it picks another. */
28
+ export const DEFAULT_A2A_NAME = "agent";
29
+ /**
30
+ * The path prefix of a mesh URL, http://mesh/mesh/<peer-id>/<type>/<name>/<path>:
31
+ * the shape of agentmesh-node's egress proxy and of an agent card rewritten for the
32
+ * mesh, by agentmesh-node or by this SDK. The host is ignored; the peer ID is in the
33
+ * path because URL parsers lowercase the host and a peer ID is case-sensitive.
34
+ */
35
+ export const MESH_PATH_PREFIX = "/mesh/";
36
+ /** The well-known agent card location (A2A spec / RFC 8615). */
37
+ export const AGENT_CARD_PATH = ".well-known/agent-card.json";
38
+ const MAX_AGENT_CARD_BYTES = 1 << 20;
39
+ /** Largest request body the ingress reads whole for a handler or url target. */
40
+ export const MAX_INGRESS_BODY_BYTES = 8 * 1024 * 1024;
41
+ const REQUEST_TIMEOUT_MS = 60_000;
42
+ export function a2aEndpoint(spec) {
43
+ const name = spec.name ?? DEFAULT_A2A_NAME;
44
+ const given = [spec.url, spec.handler, spec.listener].filter((t) => t !== undefined).length;
45
+ if (given !== 1) {
46
+ throw new Error("an A2A endpoint is exactly one of url, handler or listener");
47
+ }
48
+ const target = spec.url !== undefined ? { url: spec.url } : spec.handler !== undefined ? { handler: spec.handler } : { listener: spec.listener };
49
+ return { name, service: `a2a://${name}`, target };
50
+ }
51
+ // A URL parser reads %2e as a dot too (WHATWG URL, path state), and %2f/%5c/\
52
+ // as path separators on some backends, so the check rejects those and dot segments.
53
+ function hasDotSegment(path) {
54
+ if (!path.startsWith("/") || path.includes("#")) {
55
+ return true;
56
+ }
57
+ for (let i = 0; i < path.length; i++) {
58
+ const code = path.charCodeAt(i);
59
+ if (code < 0x20 || code === 0x7f || path[i] === "\\" || path[i] === '"') {
60
+ return true;
61
+ }
62
+ if (path[i] === "%" && i + 2 < path.length) {
63
+ const hex = path.slice(i + 1, i + 3).toLowerCase();
64
+ if (hex === "2f" || hex === "5c" || hex === "00") {
65
+ return true;
66
+ }
67
+ }
68
+ }
69
+ return path.split("/").some((seg) => {
70
+ const s = seg.replace(/%2e/gi, ".");
71
+ return s === "." || s === "..";
72
+ });
73
+ }
74
+ /**
75
+ * Server-side admission of /libp2p-http, as agentmesh-node's StartIngressServer:
76
+ * the path is /<type>/<name>[/<upstream>], the caller's biscuit is
77
+ * X-Mesh-Biscuit, and the request is authorized for <type>://<name> before
78
+ * anything is forwarded. Only the agent's own endpoint is answered; anything
79
+ * else is 404 after authorization, so an unauthorized caller learns nothing
80
+ * about it.
81
+ */
82
+ export async function admitIngress(req, endpoint, options) {
83
+ // Policy is decided on the /<type>/<name> prefix; a dot segment in what
84
+ // follows could resolve to a sibling path on the backend. Checked on the
85
+ // raw path, before URL parsing normalizes it away.
86
+ if (hasDotSegment(req.target.split("?")[0])) {
87
+ return { status: 400, text: "Invalid path" };
88
+ }
89
+ const url = new URL(req.target, "http://mesh.invalid");
90
+ const parts = url.pathname.replace(/^\//, "").split("/");
91
+ if (parts.length < 2 || parts[0] === "" || parts[1] === "") {
92
+ return { status: 400, text: "Invalid path" };
93
+ }
94
+ const [serviceType, serviceName, ...rest] = parts;
95
+ if (serviceType !== "inference" && serviceType !== "a2a" && serviceType !== "mcp" && serviceType !== "egress") {
96
+ return { status: 400, text: "Invalid service type" };
97
+ }
98
+ const upstreamPath = rest.join("/");
99
+ const biscuitB64 = req.headers.get(HEADER_MESH_BISCUIT);
100
+ if (biscuitB64 === null || biscuitB64 === "") {
101
+ return { status: 401, text: "Missing X-Mesh-Biscuit header" };
102
+ }
103
+ let biscuit;
104
+ try {
105
+ biscuit = fromBase64(biscuitB64);
106
+ if (biscuit.length === 0) {
107
+ throw new Error("empty");
108
+ }
109
+ }
110
+ catch {
111
+ return { status: 400, text: "Invalid X-Mesh-Biscuit encoding" };
112
+ }
113
+ const targetService = `${serviceType}://${serviceName}`;
114
+ if (options.isBanned?.(req.remotePeer) === true) {
115
+ return { status: 403, text: "Authorization failed" };
116
+ }
117
+ let verified;
118
+ try {
119
+ verified = await authorizeCaller({
120
+ biscuit,
121
+ peerId: req.remotePeer,
122
+ targetService,
123
+ protocol: HTTP_PROTOCOL,
124
+ // The path as the backend sees it, decided before authorization so
125
+ // path() is what policy meant, never the routing prefix.
126
+ method: req.method,
127
+ path: "/" + upstreamPath,
128
+ }, options);
129
+ }
130
+ catch (err) {
131
+ if (err instanceof AuthorizationError) {
132
+ return { status: 403, text: "Authorization failed" };
133
+ }
134
+ throw err;
135
+ }
136
+ options.onAuthorized?.(req.remotePeer, verified, targetService);
137
+ // Under the type the policy was evaluated on.
138
+ if (targetService !== endpoint.service) {
139
+ return { status: 404, text: "Service not found" };
140
+ }
141
+ return { verified, path: "/" + upstreamPath + url.search, noTrailingSlash: upstreamPath === "" && rest.length === 0 };
142
+ }
143
+ /** Headers of the mesh datapath and of the hop itself, not passed on to the agent. */
144
+ const HOP_HEADERS = new Set([HEADER_MESH_BISCUIT, HEADER_MESH_NO_TRAILING_SLASH, HEADER_PEER_ID, "host", "connection", "transfer-encoding", "content-length", "keep-alive"]);
145
+ /**
146
+ * The headers the agent sees: the request's own, less the datapath's, with
147
+ * X-Peer-Id set (not added) to the verified caller so an inbound value
148
+ * cannot pose as the peer.
149
+ */
150
+ export function agentHeaders(inbound, remotePeer, noTrailingSlash) {
151
+ const headers = new Headers();
152
+ inbound.forEach((value, name) => {
153
+ if (!HOP_HEADERS.has(name)) {
154
+ headers.append(name, value);
155
+ }
156
+ });
157
+ headers.set(HEADER_PEER_ID, remotePeer);
158
+ if (noTrailingSlash) {
159
+ headers.set(HEADER_MESH_NO_TRAILING_SLASH, "true");
160
+ }
161
+ return headers;
162
+ }
163
+ /** A plain-text refusal, as agentmesh-node's ingress writes one. */
164
+ export function refusalResponse(refusal) {
165
+ return new Response(refusal.text + "\n", { status: refusal.status, headers: { "content-type": "text/plain; charset=utf-8" } });
166
+ }
167
+ async function writeResponse(stream, response, headOnly) {
168
+ const headers = new Headers();
169
+ response.headers.forEach((value, name) => {
170
+ if (name !== "content-length" && name !== "transfer-encoding" && name !== "connection") {
171
+ headers.set(name, value);
172
+ }
173
+ });
174
+ headers.set("connection", "close");
175
+ if (headOnly) {
176
+ // The same head a GET would get (RFC 9110 section 9.3.2): the length the
177
+ // agent declared, if any; the body it may have built is not sent.
178
+ const declared = response.headers.get("content-length");
179
+ if (declared !== null) {
180
+ headers.set("content-length", declared);
181
+ }
182
+ void response.body?.cancel().catch(() => { });
183
+ await sendAll(stream, encodeResponseHead(response.status, response.statusText, headers));
184
+ return;
185
+ }
186
+ const body = response.body;
187
+ if (body === null) {
188
+ headers.set("content-length", "0");
189
+ await sendAll(stream, encodeResponseHead(response.status, response.statusText, headers));
190
+ return;
191
+ }
192
+ // The length is not known up front, so each piece goes as a chunk as soon
193
+ // as the agent writes it; an SSE event reaches the caller before the next.
194
+ headers.set("transfer-encoding", "chunked");
195
+ await sendAll(stream, encodeResponseHead(response.status, response.statusText, headers));
196
+ const reader = body.getReader();
197
+ try {
198
+ for (let next = await reader.read(); !next.done; next = await reader.read()) {
199
+ if (next.value.length > 0) {
200
+ await sendAll(stream, encodeChunk(next.value));
201
+ }
202
+ }
203
+ }
204
+ finally {
205
+ reader.releaseLock();
206
+ }
207
+ await sendAll(stream, LAST_CHUNK);
208
+ }
209
+ async function serveIngress(stream, remotePeer, endpoint, options) {
210
+ const reader = new ByteReader(streamSource(stream));
211
+ const headTimer = setTimeout(() => stream.abort(new Error(`no request head from ${remotePeer} within ${AUTH_HANDSHAKE_TIMEOUT_MS}ms`)), AUTH_HANDSHAKE_TIMEOUT_MS);
212
+ let response;
213
+ let headOnly = false;
214
+ try {
215
+ let head;
216
+ try {
217
+ head = await readRequestHead(reader);
218
+ }
219
+ finally {
220
+ clearTimeout(headTimer);
221
+ }
222
+ if (head === null) {
223
+ return;
224
+ }
225
+ headOnly = head.method === "HEAD";
226
+ const admission = await admitIngress({ method: head.method, target: head.target, headers: head.headers, remotePeer }, endpoint, options);
227
+ if ("status" in admission) {
228
+ response = refusalResponse(admission);
229
+ }
230
+ else if ("listener" in endpoint.target) {
231
+ response = refusalResponse({ status: 501, text: "A request listener is not served in this runtime" });
232
+ }
233
+ else {
234
+ const headers = agentHeaders(head.headers, remotePeer, admission.noTrailingSlash);
235
+ const init = { method: head.method, headers };
236
+ if (head.method !== "GET" && head.method !== "HEAD") {
237
+ init.body = await readBody(reader, requestBodyFraming(head), MAX_INGRESS_BODY_BYTES);
238
+ }
239
+ if ("url" in endpoint.target) {
240
+ const base = endpoint.target.url.replace(/\/$/, "");
241
+ response = await fetch(base + admission.path, { ...init, redirect: "manual" });
242
+ }
243
+ else {
244
+ response = await endpoint.target.handler(new Request("http://" + endpoint.name + admission.path, init), admission.verified);
245
+ }
246
+ }
247
+ }
248
+ catch (err) {
249
+ if (err instanceof HTTPParseError) {
250
+ response = refusalResponse({ status: 400, text: `Bad request: ${err.message}` });
251
+ }
252
+ else {
253
+ response = refusalResponse({ status: 500, text: `ingress error: ${err instanceof Error ? err.message : String(err)}` });
254
+ }
255
+ }
256
+ await writeResponse(stream, response, headOnly);
257
+ }
258
+ /**
259
+ * Server side of /libp2p-http for an endpoint answered by a handler in this
260
+ * process or an A2A server at a URL. One request per stream; the stream is
261
+ * closed once the response has been written.
262
+ */
263
+ export function httpIngressHandler(endpoint, options) {
264
+ return (stream, connection) => {
265
+ void serveIngress(stream, connection.remotePeer.toString(), endpoint, options)
266
+ .then(() => stream.close())
267
+ .catch((err) => stream.abort(err instanceof Error ? err : new Error(String(err))));
268
+ };
269
+ }
270
+ /** The request target for a service on a peer: /<type>/<name>/<path>. */
271
+ export function meshHTTPTarget(targetService, path = "") {
272
+ const m = /^(inference|a2a|mcp|egress):\/\/(.+)$/.exec(targetService);
273
+ if (m === null) {
274
+ throw new Error(`service target must look like inference://<name>, got ${JSON.stringify(targetService)}`);
275
+ }
276
+ if (path === "") {
277
+ return `/${m[1]}/${m[2]}`;
278
+ }
279
+ return `/${m[1]}/${m[2]}${path.startsWith("/") ? path : "/" + path}`;
280
+ }
281
+ /**
282
+ * The URL a fetch bound to a session (session.fetch()) takes for a service
283
+ * on a peer: http://mesh/mesh/<peer-id>/<type>/<name>/<path>.
284
+ */
285
+ export function meshURL(peerId, targetService, path = "") {
286
+ return "http://mesh" + MESH_PATH_PREFIX + canonicalPeerId(peerId) + meshHTTPTarget(targetService, path);
287
+ }
288
+ /** (peer ID, request target) of a mesh URL; the target is what the peer's ingress takes. */
289
+ export function splitMeshURL(url) {
290
+ if (!url.pathname.startsWith(MESH_PATH_PREFIX)) {
291
+ throw new TypeError(`a mesh URL looks like http://mesh${MESH_PATH_PREFIX}<peer-id>/<type>/<name>/..., got ${url.toString()}`);
292
+ }
293
+ const [peerId = "", ...rest] = url.pathname.slice(MESH_PATH_PREFIX.length).split("/");
294
+ if (peerId === "" || rest.length < 2 || rest[0] === "" || rest[1] === "") {
295
+ throw new TypeError(`a mesh URL names a peer, a service type and a name, got ${url.toString()}`);
296
+ }
297
+ return { peerId, target: "/" + rest.join("/") + url.search };
298
+ }
299
+ /** The bare service root counts too: a2a-go treats a pathful base URL as the card location. */
300
+ function agentCardService(method, target) {
301
+ const m = /^\/a2a\/([^/?]+)(?:\/(?:\.well-known\/agent-card\.json)?)?(?:\?.*)?$/.exec(target);
302
+ return method === "GET" && m !== null ? `a2a://${m[1]}` : undefined;
303
+ }
304
+ /**
305
+ * An agent card rebuilt for the mesh, as agentmesh-node's egress proxy serves it:
306
+ * HTTP interfaces resolve under base (meshInterfaceURL), gRPC ones go, signatures no longer match.
307
+ * Streaming stays as declared, this transport streams. Throws when no interface remains.
308
+ */
309
+ export function rewriteAgentCard(card, base) {
310
+ if (typeof card !== "object" || card === null || Array.isArray(card)) {
311
+ throw new Error("agent card is not a JSON object");
312
+ }
313
+ const out = { ...card };
314
+ delete out.signatures;
315
+ const interfaces = Array.isArray(out.supportedInterfaces) ? out.supportedInterfaces : [];
316
+ const kept = interfaces.filter(carriedOverHTTP).map((iface) => {
317
+ const url = iface.url;
318
+ return { ...iface, url: meshInterfaceURL(base, typeof url === "string" ? url : "") };
319
+ });
320
+ if (kept.length === 0) {
321
+ 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?");
322
+ }
323
+ return { ...out, supportedInterfaces: kept };
324
+ }
325
+ /**
326
+ * The mesh URL of one agent interface, as agentmesh-node resolves it. The
327
+ * provider's node names an interface it can reach by a reference relative to
328
+ * the service root ("invoke", "a2a/rest", "" for the root), which resolves
329
+ * under base; anything else maps to base itself. A reference that could leave
330
+ * the service (a dot segment, a rooted path, a scheme or a host) also maps to
331
+ * base: a provider must not be able to point the caller at another path on
332
+ * this node. Mirrors Go's url.Parse and URL.EscapedPath for the parts it reads.
333
+ */
334
+ export function meshInterfaceURL(base, raw) {
335
+ const hash = raw.indexOf("#");
336
+ const beforeFragment = hash < 0 ? raw : raw.slice(0, hash);
337
+ if (hash >= 0 && percentDecode(utf8.encode(raw.slice(hash + 1))) === undefined) {
338
+ return base;
339
+ }
340
+ if (/[\x00-\x1f\x7f]/.test(beforeFragment)) {
341
+ return base;
342
+ }
343
+ const query = beforeFragment.indexOf("?");
344
+ let rawPath = query < 0 ? beforeFragment : beforeFragment.slice(0, query);
345
+ const rawQuery = query < 0 ? "" : beforeFragment.slice(query + 1);
346
+ // A scheme, or for Go a parse error: a colon in the first segment.
347
+ if (rawPath.split("/", 1)[0].includes(":")) {
348
+ return base;
349
+ }
350
+ // "//" alone is an empty authority and an empty path; any other authority
351
+ // names a host, a user or a rooted path.
352
+ if (rawPath === "//") {
353
+ rawPath = "";
354
+ }
355
+ const encoded = utf8.encode(rawPath);
356
+ const decoded = percentDecode(encoded);
357
+ if (decoded === undefined) {
358
+ return base;
359
+ }
360
+ const path = latin1(decoded);
361
+ if (path.startsWith("/") || path.split("/").some((seg) => seg === "." || seg === "..")) {
362
+ return base;
363
+ }
364
+ let out = base;
365
+ if (path !== "") {
366
+ out += "/" + escapedPath(rawPath, encoded, decoded);
367
+ }
368
+ if (rawQuery !== "") {
369
+ out += "?" + rawQuery;
370
+ }
371
+ return out;
372
+ }
373
+ const utf8 = new TextEncoder();
374
+ // Bytes Go's url package leaves alone in a path (shouldEscape(c, encodePath) is false).
375
+ const PATH_SAFE = new Set(Array.from("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~$&+,/:;=@", (c) => c.charCodeAt(0)));
376
+ // Bytes Go's validEncoded also accepts in an already escaped path.
377
+ const PATH_SUB_DELIMS = new Set(Array.from("!'()*[]%", (c) => c.charCodeAt(0)));
378
+ function hexValue(c) {
379
+ if (c >= 0x30 && c <= 0x39)
380
+ return c - 0x30;
381
+ if (c >= 0x41 && c <= 0x46)
382
+ return c - 0x37;
383
+ if (c >= 0x61 && c <= 0x66)
384
+ return c - 0x57;
385
+ return -1;
386
+ }
387
+ /** Percent-decodes bytes, or undefined when a "%" is not followed by two hex digits. */
388
+ function percentDecode(bytes) {
389
+ const out = [];
390
+ for (let i = 0; i < bytes.length; i++) {
391
+ if (bytes[i] !== 0x25) {
392
+ out.push(bytes[i]);
393
+ continue;
394
+ }
395
+ const hi = hexValue(bytes[i + 1] ?? -1);
396
+ const lo = hexValue(bytes[i + 2] ?? -1);
397
+ if (hi < 0 || lo < 0) {
398
+ return undefined;
399
+ }
400
+ out.push((hi << 4) | lo);
401
+ i += 2;
402
+ }
403
+ return Uint8Array.from(out);
404
+ }
405
+ /** Go's URL.EscapedPath: the path as written when that is a valid escaping of it, else the default escaping. */
406
+ function escapedPath(rawPath, encoded, decoded) {
407
+ const escaped = percentEncodePath(decoded);
408
+ if (escaped !== rawPath && encoded.every((c) => PATH_SAFE.has(c) || PATH_SUB_DELIMS.has(c))) {
409
+ return rawPath;
410
+ }
411
+ return latin1(decoded) === "*" ? "*" : escaped;
412
+ }
413
+ function percentEncodePath(bytes) {
414
+ let out = "";
415
+ for (const c of bytes) {
416
+ out += PATH_SAFE.has(c) ? String.fromCharCode(c) : "%" + c.toString(16).toUpperCase().padStart(2, "0");
417
+ }
418
+ return out;
419
+ }
420
+ /** One char per byte, so that segment checks see the decoded bytes as Go does. */
421
+ function latin1(bytes) {
422
+ return Array.from(bytes, (c) => String.fromCharCode(c)).join("");
423
+ }
424
+ /** Whether an interface's binding can traverse the mesh; gRPC needs its own connection. */
425
+ function carriedOverHTTP(iface) {
426
+ const binding = typeof iface === "object" && iface !== null ? iface.protocolBinding : undefined;
427
+ return typeof binding === "string" && ["JSONRPC", "HTTP+JSON"].includes(binding.toUpperCase());
428
+ }
429
+ /**
430
+ * Impersonates the agent's card endpoint as agentmesh-node's egress proxy does: holds
431
+ * the client's request, fetches the card itself with identity encoding, and
432
+ * answers with it regenerated; the agent's own non-200 is relayed as it is.
433
+ */
434
+ async function serveAgentCard(conn, biscuit, request, options, service) {
435
+ const base = meshURL(conn.remotePeer.toString(), service);
436
+ const headers = new Headers(request.headers);
437
+ headers.delete("accept-encoding");
438
+ let response;
439
+ try {
440
+ response = await sendOverStream(conn, biscuit, new Request(`${base}/${AGENT_CARD_PATH}`, { headers, signal: request.signal }), options);
441
+ }
442
+ catch (err) {
443
+ return badGateway(`agent card fetch failed: ${err instanceof Error ? err.message : String(err)}`);
444
+ }
445
+ if (response.status !== 200) {
446
+ return response;
447
+ }
448
+ let card;
449
+ try {
450
+ card = JSON.parse(new TextDecoder().decode(await readLimited(response.body, MAX_AGENT_CARD_BYTES)));
451
+ }
452
+ catch {
453
+ return badGateway("agent card is not valid JSON");
454
+ }
455
+ try {
456
+ return new Response(JSON.stringify(rewriteAgentCard(card, base)), { status: 200, headers: { "content-type": "application/json" } });
457
+ }
458
+ catch (err) {
459
+ return badGateway(err instanceof Error ? err.message : String(err));
460
+ }
461
+ }
462
+ function badGateway(reason) {
463
+ return new Response(`Bad Gateway: ${reason}`, { status: 502, headers: { "content-type": "text/plain" } });
464
+ }
465
+ /** The first limit bytes of a body, the rest dropped, as io.LimitReader bounds the node. */
466
+ async function readLimited(body, limit) {
467
+ const chunks = [];
468
+ let n = 0;
469
+ if (body !== null) {
470
+ const reader = body.getReader();
471
+ for (let next = await reader.read(); !next.done && n < limit; next = await reader.read()) {
472
+ const chunk = next.value.subarray(0, limit - n);
473
+ chunks.push(chunk);
474
+ n += chunk.length;
475
+ }
476
+ await reader.cancel();
477
+ }
478
+ const out = new Uint8Array(n);
479
+ let at = 0;
480
+ for (const chunk of chunks) {
481
+ out.set(chunk, at);
482
+ at += chunk.length;
483
+ }
484
+ return out;
485
+ }
486
+ /**
487
+ * Client side of /libp2p-http, as go-libp2p-http's RoundTripper: one stream
488
+ * per request, plain HTTP/1.1 with Host set to the peer ID and the biscuit in
489
+ * X-Mesh-Biscuit. Resolves once the response headers are in; the body streams
490
+ * after, so an SSE response is consumed as the peer sends it. An agent card
491
+ * is served rewritten for the mesh (rewriteAgentCard), as agentmesh-node serves one.
492
+ */
493
+ export async function fetchOverStream(conn, biscuit, request, options = {}) {
494
+ const { target } = splitMeshURL(new URL(request.url));
495
+ const service = agentCardService(request.method, target);
496
+ return service === undefined ? sendOverStream(conn, biscuit, request, options) : serveAgentCard(conn, biscuit, request, options, service);
497
+ }
498
+ async function sendOverStream(conn, biscuit, request, options) {
499
+ const { target } = splitMeshURL(new URL(request.url));
500
+ const peerId = conn.remotePeer.toString();
501
+ // One controller ends the exchange: the caller's signal at any time, the
502
+ // headers timeout only until the response headers are in.
503
+ const ctl = new AbortController();
504
+ let headersIn = false;
505
+ const forward = (s) => s?.addEventListener("abort", () => ctl.abort(s.reason), { once: true });
506
+ forward(options.signal);
507
+ forward(request.signal);
508
+ if (options.signal === undefined) {
509
+ const timer = setTimeout(() => {
510
+ if (!headersIn) {
511
+ ctl.abort(new DOMException(`no response headers from ${peerId} within ${REQUEST_TIMEOUT_MS}ms`, "TimeoutError"));
512
+ }
513
+ }, REQUEST_TIMEOUT_MS);
514
+ timer.unref?.();
515
+ }
516
+ const signal = ctl.signal;
517
+ const stream = await conn.newStream(HTTP_PROTOCOL, { signal, runOnLimitedConnection: true });
518
+ const asError = (reason) => (reason instanceof Error ? reason : new Error(String(reason)));
519
+ const abortStream = () => stream.abort(asError(signal.reason));
520
+ signal.addEventListener("abort", abortStream, { once: true });
521
+ const headers = new Headers();
522
+ request.headers.forEach((value, name) => {
523
+ if (name !== "host" && name !== "content-length" && name !== "transfer-encoding" && name !== HEADER_MESH_BISCUIT && name !== HEADER_PEER_ID) {
524
+ headers.set(name, value);
525
+ }
526
+ });
527
+ headers.set("host", peerId);
528
+ headers.set(HEADER_MESH_BISCUIT, toBase64(biscuit));
529
+ const body = request.body === null ? new Uint8Array(0) : new Uint8Array(await request.arrayBuffer());
530
+ headers.set("content-length", String(body.length));
531
+ try {
532
+ await sendAll(stream, encodeRequestHead(request.method, target, headers));
533
+ await sendAll(stream, body);
534
+ const reader = new ByteReader(streamSource(stream));
535
+ const head = await readResponseHead(reader);
536
+ headersIn = true;
537
+ signal.throwIfAborted();
538
+ const framing = responseBodyFraming(request.method, head);
539
+ const done = (err) => {
540
+ signal.removeEventListener("abort", abortStream);
541
+ if (err !== undefined) {
542
+ stream.abort(err);
543
+ }
544
+ else {
545
+ void stream.close().catch(() => { });
546
+ }
547
+ };
548
+ let responseBody = null;
549
+ if (framing.kind === "none") {
550
+ done();
551
+ }
552
+ else {
553
+ responseBody = bodyStream(reader, framing, done);
554
+ }
555
+ return new Response(responseBody, { status: head.status, statusText: head.statusText, headers: head.headers });
556
+ }
557
+ catch (err) {
558
+ stream.abort(asError(err));
559
+ throw signal.aborted ? (signal.reason ?? err) : err;
560
+ }
561
+ }
562
+ /** One request to /<type>/<name>/<path> on a peer, body read whole. */
563
+ export async function httpRequestOverStream(conn, biscuit, targetService, path, options = {}) {
564
+ const signal = options.signal ?? AbortSignal.timeout(REQUEST_TIMEOUT_MS);
565
+ const init = { method: options.method ?? "GET", headers: options.headers ?? {}, signal };
566
+ if (options.body !== undefined) {
567
+ init.body = options.body;
568
+ }
569
+ const request = new Request(meshURL(conn.remotePeer.toString(), targetService, path), init);
570
+ const streamOptions = { signal };
571
+ const response = await fetchOverStream(conn, biscuit, request, streamOptions);
572
+ const buf = new Uint8Array(await response.arrayBuffer());
573
+ if (buf.length > MAX_INGRESS_BODY_BYTES) {
574
+ throw new Error("response body too large");
575
+ }
576
+ const flat = {};
577
+ response.headers.forEach((value, key) => {
578
+ flat[key] = value;
579
+ });
580
+ return { status: response.status, headers: flat, body: buf, text: () => new TextDecoder().decode(buf) };
581
+ }
package/dist/mcp.d.ts ADDED
@@ -0,0 +1,67 @@
1
+ import type { Connection, Stream } from "@libp2p/interface";
2
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
3
+ import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
4
+ import { type JSONRPCMessage } from "@modelcontextprotocol/sdk/types.js";
5
+ import { type VerifiedBiscuit } from "./biscuit.ts";
6
+ /** go-msgio's default message cap, which agentmesh-node's StreamTransport uses. */
7
+ export declare const MAX_MCP_MESSAGE_BYTES: number;
8
+ /** The MCP client the SDK identifies itself as. */
9
+ export declare const MCP_CLIENT_INFO: {
10
+ name: string;
11
+ version: string;
12
+ };
13
+ /**
14
+ * A Transport for @modelcontextprotocol/sdk over a libp2p stream that has
15
+ * already passed the auth handshake.
16
+ */
17
+ export declare class StreamTransport implements Transport {
18
+ #private;
19
+ onclose?: () => void;
20
+ onerror?: (error: Error) => void;
21
+ onmessage?: (message: JSONRPCMessage) => void;
22
+ constructor(stream: Stream);
23
+ start(): Promise<void>;
24
+ send(message: JSONRPCMessage): Promise<void>;
25
+ close(): Promise<void>;
26
+ }
27
+ export interface MCPSessionOptions {
28
+ /** Labels the provider's credential must all carry, e.g. { region: "eu", compliance: "gdpr" }. */
29
+ requiredLabels?: Record<string, string>;
30
+ signal?: AbortSignal;
31
+ /** The mesh policy as the session synced it (Session.policyRules); set by the session, not the caller. */
32
+ policyRules?: readonly string[];
33
+ }
34
+ /** An open MCP session with a provider on the mesh. */
35
+ export interface MCPSession {
36
+ client: Client;
37
+ /** The provider's verified credential. */
38
+ provider: VerifiedBiscuit;
39
+ close(): Promise<void>;
40
+ }
41
+ /**
42
+ * The provider's credential lacks a label the caller requires or a label of
43
+ * the session's egress floor, as checkPeerLabels refuses.
44
+ */
45
+ export declare class LabelsNotSatisfiedError extends Error {
46
+ constructor(peerId: string, required: string[], what: string);
47
+ }
48
+ /**
49
+ * A requirement is satisfied only when the provider attests every pair, as
50
+ * agentmesh-node's api.LabelCheck (`check if label(k1, v1), label(k2, v2)`), the
51
+ * same rule as the egress floor. A map holds one value per key, so listing
52
+ * several pairs narrows the acceptable providers. Empty is no requirement.
53
+ */
54
+ export declare function requireLabels(provider: VerifiedBiscuit, required: Record<string, string> | undefined): void;
55
+ /**
56
+ * The session's egress floor, agentmesh-node's egress.require_labels: the same
57
+ * rule as requireLabels, refused with a message that names the floor. Empty
58
+ * is no floor.
59
+ */
60
+ export declare function requireEgressLabels(provider: VerifiedBiscuit, required: Record<string, string> | undefined): void;
61
+ /**
62
+ * Opens /mesh/mcp/1.0.0 to a connected provider for targetService ("" is the
63
+ * provider's own catalog), verifies the provider, and returns a connected
64
+ * MCP client. frame is this member's AuthFrame for that service; egressRequireLabels
65
+ * is the session's, not the caller's (requireEgressLabels).
66
+ */
67
+ export declare function openMCPSession(conn: Connection, frame: Uint8Array, trustedKeys: Uint8Array[], options?: MCPSessionOptions, egressRequireLabels?: Record<string, string>): Promise<MCPSession>;