@agentmesh-p2p/sdk 0.1.0 → 0.2.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.
package/dist/index.js CHANGED
@@ -16,7 +16,7 @@ export { Identity, canonicalPeerId, peerIdFromPublicKey, libp2pPublicKey, verify
16
16
  export { ControlPlaneClient, ControlPlaneError, EnrollmentRejectedError, InsecureControlPlaneURLError, KeysNotTrustedError, ROLE_NODE, validateControlPlaneURL, verifyKeysResponse, } from "./controlplane.js";
17
17
  export { attenuateCredential, credentialFromJSON, credentialTimeToLiveSeconds, credentialToJSON, decodeAuthResponse, encodeAuthFrame, sealCredential, withCredentialMethods, } from "./credential.js";
18
18
  export { egressChallenge, enrollChallenge, enrollStatusChallenge, nodesCatalogChallenge, policiesChallenge, refreshChallenge, registerChallenge, revocationsChallenge, } from "./challenges.js";
19
- export { DEFAULT_ROUTERS, MeshSession, ROUTER_REDIAL_BACKOFFS_MS, candidatesFromRouterInfos, selectRouters, } from "./session.js";
19
+ export { DEFAULT_ROUTERS, JOIN_TIMEOUT_MS, MeshSession, ROUTER_REDIAL_BACKOFFS_MS, candidatesFromRouterInfos, selectRouters, } from "./session.js";
20
20
  export { BiscuitVerificationError, ROLE_ROUTER, attenuateBiscuit, requireRole, sealBiscuit, verifyPeerBiscuit, } from "./biscuit.js";
21
21
  export { decodeTARBlockPayload, effectiveTARExpiration, encodeTARBlockFact, encodeTARBlockPayload, evaluateTaskRules, matchHTTPPath, matchServicePattern, matchTaskRule, parseTARBlockSource, validateHTTPGrantPath, validateServicePattern, validateTaskAuthorizationRule, validateTaskRule, } from "./tar.js";
22
22
  export { AUTH_HANDLER_OPTIONS, AUTH_PROTOCOL, GOAWAY_MAX_RETRY_AFTER_MS, GOAWAY_PROTOCOL, GOAWAY_STREAM_TIMEOUT_MS, MAX_GOAWAY_MESSAGE_BYTES, MCP_PROTOCOL, ROUTER_SHUN_DURATION_MS, AuthRejectedError, authenticateWithPeer, authStreamHandler, clampGoAwayRetryAfterMs, goAwayReasonName, goAwayStreamHandler, } from "./auth.js";
@@ -0,0 +1,8 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import type { MeshSession } from "./session.ts";
3
+ type Session = Pick<MeshSession, "peerId" | "routers" | "isHeldRouter" | "discover" | "openMCP"> & {
4
+ node: Pick<MeshSession["node"], "getPeers">;
5
+ };
6
+ /** The same five caller tools as agentmesh-node; an SDK member hosts no services. */
7
+ export declare function createMeshMCPServer(ready: Session | Promise<Session>, version: string): McpServer;
8
+ export {};
@@ -0,0 +1,305 @@
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
+ // The local MCP surface for SDK members. All remote operations use the
15
+ // session's authenticated streams and the member's own credential.
16
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
17
+ import { CallToolResultSchema } from "@modelcontextprotocol/sdk/types.js";
18
+ import { z } from "zod";
19
+ import { BASELINE_DATALOG } from "./gen/datalog.js";
20
+ import { AuthRejectedError } from "./auth.js";
21
+ import { canonicalPeerId } from "./identity.js";
22
+ const MAX_PROVIDERS = 64;
23
+ const MAX_SERVICES = 256;
24
+ const MAX_TOOLS = 256;
25
+ const MAX_RESULTS = 1024;
26
+ // Bounds enrollment, directory lookups and single-peer queries.
27
+ const REQUEST_TIMEOUT_MS = 10_000;
28
+ // Match agentmesh-node: each provider in a mesh-wide fan-out gets its own
29
+ // deadline, at most 8 at a time, and a slow one only costs its own row.
30
+ const PEER_TIMEOUT_MS = 5_000;
31
+ const FANOUT = 8;
32
+ // Opening a call stream is bounded like the node's dial; the call itself is
33
+ // bounded by the MCP client's cancellation, as tools may run for minutes.
34
+ const OPEN_TIMEOUT_MS = 15_000;
35
+ const CALL_TIMEOUT_MS = 30 * 60_000;
36
+ const serviceSchema = z.object({
37
+ type: z.number().int(), name: z.string().min(1), description: z.string().optional(),
38
+ });
39
+ const types = { mcp: 1, inference: 2, a2a: 3 };
40
+ function text(value) {
41
+ return { content: [{ type: "text", text: JSON.stringify(value) }] };
42
+ }
43
+ function serviceTarget(name, type = "mcp") {
44
+ const target = name.includes("://") ? name : `${type}://${name}`;
45
+ if (!target.startsWith(`${type}://`) || target.slice(type.length + 3).length === 0) {
46
+ throw new Error(`expected a ${type} service name`);
47
+ }
48
+ return target;
49
+ }
50
+ function toolTarget(name) {
51
+ // Split at the first path slash: MCP tool names themselves can contain '/',
52
+ // '?' or '#'. Only the service name is a path segment on the wire.
53
+ const match = /^mcp:\/\/([^/?#]+)\/(.+)$/s.exec(name);
54
+ if (!match)
55
+ throw new Error("tool_name must be mcp://<service>/<tool>");
56
+ return { service: `mcp://${match[1]}`, tool: match[2] };
57
+ }
58
+ function requiredLabels(value) {
59
+ const labels = Object.create(null);
60
+ if (value.trim() === "")
61
+ return labels;
62
+ for (const pair of value.split(",")) {
63
+ if (pair.trim() === "")
64
+ continue;
65
+ const parts = pair.trim().split("=");
66
+ if (parts.length !== 2 || !parts[0]?.trim() || !parts[1]?.trim()) {
67
+ throw new Error("required_labels must contain comma-separated key=value pairs");
68
+ }
69
+ const key = parts[0].trim(), val = parts[1].trim();
70
+ // Match api.ValidateLabelKey / ValidateLabelValue and the node parser.
71
+ if (!/^[a-zA-Z0-9_.-]{1,63}$/.test(key) || new TextEncoder().encode(val).length > 255 || /[\p{Cc}"\\]/u.test(val)) {
72
+ throw new Error("invalid required label key or value");
73
+ }
74
+ if (Object.hasOwn(labels, key))
75
+ throw new Error(`duplicate required label: ${key}`);
76
+ labels[key] = val;
77
+ }
78
+ if (Object.keys(labels).length === 0)
79
+ throw new Error("expected at least one required label");
80
+ return labels;
81
+ }
82
+ async function call(session, peer, service, name, args, options, request = { signal: options.signal }) {
83
+ const remote = await session.openMCP(peer, service, options);
84
+ try {
85
+ return CallToolResultSchema.parse(await remote.client.callTool({ name, arguments: args }, undefined, request));
86
+ }
87
+ finally {
88
+ await remote.close();
89
+ }
90
+ }
91
+ async function catalog(session, peer, type, signal) {
92
+ const result = await call(session, peer, `system://${BASELINE_DATALOG.catalog_target}`, "list_local_services", { type }, { signal });
93
+ const content = result.content[0];
94
+ if (result.isError || content?.type !== "text")
95
+ throw new Error("provider refused its service catalog");
96
+ return z.array(serviceSchema).max(MAX_SERVICES).parse(JSON.parse(content.text)).filter((s) => s.type === types[type]);
97
+ }
98
+ // Follow MCP cursors, but never accept an unbounded or looping catalog.
99
+ async function tools(session, peer, service, signal) {
100
+ const remote = await session.openMCP(peer, service, { signal });
101
+ try {
102
+ const result = [];
103
+ const cursors = new Set();
104
+ let cursor;
105
+ do {
106
+ signal.throwIfAborted();
107
+ const page = await remote.client.listTools(cursor === undefined ? {} : { cursor }, { signal });
108
+ result.push(...page.tools);
109
+ if (result.length > MAX_TOOLS)
110
+ throw new Error(`provider exceeds ${MAX_TOOLS} tools per service`);
111
+ cursor = page.nextCursor;
112
+ if (cursor !== undefined) {
113
+ if (cursors.has(cursor) || cursors.size >= MAX_TOOLS)
114
+ throw new Error("provider returned an unbounded tool catalog");
115
+ cursors.add(cursor);
116
+ }
117
+ } while (cursor !== undefined);
118
+ return result;
119
+ }
120
+ finally {
121
+ await remote.close();
122
+ }
123
+ }
124
+ // Do not keep a cancelled MCP request waiting for enrollment or directory lookup.
125
+ async function withSignal(value, signal) {
126
+ signal.throwIfAborted();
127
+ return new Promise((resolve, reject) => {
128
+ const abort = () => { reject(signal.reason); };
129
+ signal.addEventListener("abort", abort, { once: true });
130
+ Promise.resolve(value).then(resolve, reject).finally(() => { signal.removeEventListener("abort", abort); });
131
+ });
132
+ }
133
+ class ResultLimitError extends Error {
134
+ constructor() { super("too many tools; narrow the search by peer or service"); }
135
+ }
136
+ // Runs fn for each peer, FANOUT at a time, each under its own deadline. Only
137
+ // the caller's cancellation aborts the whole fan-out.
138
+ async function fanOut(peers, signal, fn) {
139
+ const results = [];
140
+ let next = 0;
141
+ const worker = async () => {
142
+ for (let i = next++; i < peers.length; i = next++) {
143
+ signal.throwIfAborted();
144
+ try {
145
+ results[i] = { status: "fulfilled", value: await fn(peers[i], AbortSignal.any([signal, AbortSignal.timeout(PEER_TIMEOUT_MS)])) };
146
+ }
147
+ catch (reason) {
148
+ results[i] = { status: "rejected", reason };
149
+ }
150
+ }
151
+ };
152
+ await Promise.all(Array.from({ length: Math.min(FANOUT, peers.length) }, worker));
153
+ signal.throwIfAborted();
154
+ return results;
155
+ }
156
+ /** The same five caller tools as agentmesh-node; an SDK member hosts no services. */
157
+ export function createMeshMCPServer(ready, version) {
158
+ const server = new McpServer({ name: "agentmesh", version }, { instructions: "Use find_remote_tools, then describe_remote_tool to learn the input schema, then call_remote_tool. " +
159
+ "Calls use this SDK member's identity and mesh policy. This stdio server exposes no local HTTP proxy, inference endpoint or hosted services.",
160
+ });
161
+ server.registerTool("get_mesh_info", { description: "Get information about the mesh network", inputSchema: {} }, async (_args, extra) => {
162
+ const signal = AbortSignal.any([extra.signal, AbortSignal.timeout(REQUEST_TIMEOUT_MS)]);
163
+ const s = await withSignal(ready, signal);
164
+ const routers = s.routers.filter((r) => s.isHeldRouter(r.peerId)).map((r) => r.peerId).sort();
165
+ return text({ peer_id: s.peerId, connected_peers: s.node.getPeers().map((p) => p.toString()).sort(), attached_routers: routers, router_peer_id: routers[0] ?? "" });
166
+ });
167
+ server.registerTool("discover_remote_services", {
168
+ description: "Discover remote services by type and optional name. Returns peer_id, srv_name and srv_description. This SDK member has no local_proxy_url; inference and A2A services require an SDK client or an agentmesh-node HTTP proxy.",
169
+ inputSchema: {
170
+ type: z.string().describe("Required. One of: mcp, inference, a2a."),
171
+ name: z.string().optional().describe("Optional service name. Omit to list all services of the given type."),
172
+ limit: z.number().int().optional().describe("Optional limit for pagination. Defaults to 20."),
173
+ offset: z.number().int().optional().describe("Optional offset for pagination. Defaults to 0."),
174
+ },
175
+ }, async (args, extra) => {
176
+ const setup = AbortSignal.any([extra.signal, AbortSignal.timeout(REQUEST_TIMEOUT_MS)]);
177
+ const s = await withSignal(ready, setup);
178
+ const type = args.type.toLowerCase();
179
+ if (types[type] === undefined)
180
+ throw new Error("type must be mcp, inference or a2a");
181
+ const name = args.name ? serviceTarget(args.name, type).slice(type.length + 3) : undefined;
182
+ const peers = await withSignal(s.discover(type, name, { limit: MAX_PROVIDERS }), setup);
183
+ const rows = [];
184
+ if (name !== undefined) {
185
+ // As in agentmesh-node, a named lookup is answered by the directory
186
+ // alone; no provider is contacted, so none can fail or stall it.
187
+ for (const peer of peers)
188
+ rows.push({ peer_id: peer.peerId, srv_name: `${type}://${name}`, srv_description: "" });
189
+ }
190
+ else {
191
+ // A provider that fails or stalls is dropped, as in agentmesh-node.
192
+ const catalogs = await fanOut(peers, extra.signal, (peer, signal) => catalog(s, peer, type, signal));
193
+ catalogs.forEach((result, i) => {
194
+ if (result.status === "rejected") {
195
+ console.error(`agentmesh: catalog from ${peers[i].peerId} failed: ${JSON.stringify(String(result.reason))}`);
196
+ return;
197
+ }
198
+ for (const svc of result.value) {
199
+ rows.push({ peer_id: peers[i].peerId, srv_name: serviceTarget(svc.name, type), srv_description: svc.description ?? "" });
200
+ if (rows.length > MAX_RESULTS)
201
+ throw new Error("too many services; narrow the discovery by name");
202
+ }
203
+ });
204
+ }
205
+ rows.sort((a, b) => a.peer_id.localeCompare(b.peer_id) || a.srv_name.localeCompare(b.srv_name));
206
+ const offset = Math.max(0, args.offset ?? 0), limit = (args.limit ?? 0) > 0 ? args.limit : 20;
207
+ return text(rows.slice(offset, offset + limit));
208
+ });
209
+ server.registerTool("find_remote_tools", {
210
+ description: "Discover MCP tools available on hosted services across the mesh. Optionally narrow by peer_id, service_name, or exact tool_name. intent is accepted but ignored, as in agentmesh-node.",
211
+ inputSchema: {
212
+ intent: z.string().optional().describe("Natural-language description of what the caller is looking for. Reserved for future semantic ranking; currently accepted but ignored."),
213
+ peer_id: z.string().optional().describe("Restrict the search to a single peer. Empty means search the whole mesh."),
214
+ service_name: z.string().optional().describe("Restrict results to tools whose name starts with this service prefix (e.g. 'code-reviewer'). Empty means no service filter."),
215
+ tool_name: z.string().optional().describe("Exact (non-namespaced) tool name to locate across the mesh, e.g. 'review_pr'. Empty means list all tools."),
216
+ },
217
+ }, async (args, extra) => {
218
+ const setup = AbortSignal.any([extra.signal, AbortSignal.timeout(REQUEST_TIMEOUT_MS)]);
219
+ const s = await withSignal(ready, setup);
220
+ const filter = args.service_name ? serviceTarget(args.service_name) : undefined;
221
+ const peerId = args.peer_id ? canonicalPeerId(args.peer_id) : undefined;
222
+ if (peerId === s.peerId)
223
+ throw new Error("cross-mesh discovery cannot target self");
224
+ const peers = peerId ? [{ peerId, addrs: [] }] : await withSignal(s.discover("mcp", undefined, { limit: MAX_PROVIDERS }), setup);
225
+ const rows = [];
226
+ const append = (row) => {
227
+ if (rows.length >= MAX_RESULTS)
228
+ throw new ResultLimitError();
229
+ rows.push(row);
230
+ };
231
+ const results = await fanOut(peers, extra.signal, async (peer, signal) => {
232
+ const found = [];
233
+ for (const svc of await catalog(s, peer, "mcp", signal)) {
234
+ const target = serviceTarget(svc.name);
235
+ if (filter !== undefined && target !== filter)
236
+ continue;
237
+ try {
238
+ for (const tool of await tools(s, peer, target, signal)) {
239
+ if (!args.tool_name || tool.name === args.tool_name) {
240
+ found.push({ peer_id: peer.peerId, tool_name: `${target}/${tool.name}`, description: tool.description ?? "" });
241
+ if (found.length > MAX_RESULTS)
242
+ throw new ResultLimitError();
243
+ }
244
+ }
245
+ }
246
+ catch (err) {
247
+ // The peer's deadline ends the peer, not just this service.
248
+ signal.throwIfAborted();
249
+ if (err instanceof ResultLimitError)
250
+ throw err;
251
+ if (err instanceof AuthRejectedError)
252
+ console.error(`agentmesh: discovery refused: ${JSON.stringify(err.message)}`);
253
+ else
254
+ found.push({ peer_id: peer.peerId, tool_name: target, error: String(err) });
255
+ }
256
+ }
257
+ return found;
258
+ });
259
+ results.forEach((result, i) => {
260
+ if (result.status === "fulfilled") {
261
+ result.value.forEach(append);
262
+ return;
263
+ }
264
+ const err = result.reason;
265
+ if (err instanceof ResultLimitError)
266
+ throw err;
267
+ if (peerId !== undefined)
268
+ throw err;
269
+ if (err instanceof AuthRejectedError)
270
+ console.error(`agentmesh: discovery refused: ${JSON.stringify(err.message)}`);
271
+ else
272
+ append({ peer_id: peers[i].peerId, error: String(err) });
273
+ });
274
+ return text(rows.filter((row) => !args.tool_name || !row.error).sort((a, b) => a.peer_id.localeCompare(b.peer_id) || (a.tool_name ?? "").localeCompare(b.tool_name ?? "")));
275
+ });
276
+ server.registerTool("describe_remote_tool", {
277
+ description: "Return the description, input schema, and output schema for a specific MCP tool on a specific peer.",
278
+ inputSchema: {
279
+ peer_id: z.string().describe("Peer ID of the node hosting the server. Required."),
280
+ tool_name: z.string().describe("Namespaced server name as returned by find_remote_tools (e.g. 'mcp://code-reviewer/review_pr'). Required."),
281
+ },
282
+ }, async (args, extra) => {
283
+ const signal = AbortSignal.any([extra.signal, AbortSignal.timeout(REQUEST_TIMEOUT_MS)]);
284
+ const s = await withSignal(ready, signal), peer = canonicalPeerId(args.peer_id), target = toolTarget(args.tool_name);
285
+ const tool = (await tools(s, peer, target.service, signal)).find((t) => t.name === target.tool);
286
+ if (tool === undefined)
287
+ throw new Error("tool not found on peer");
288
+ return text({ peer_id: peer, tool_name: args.tool_name, description: tool.description ?? "", input_schema: tool.inputSchema, ...(tool.outputSchema ? { output_schema: tool.outputSchema } : {}) });
289
+ });
290
+ server.registerTool("call_remote_tool", {
291
+ description: "Call an MCP tool on a remote agent",
292
+ inputSchema: {
293
+ peer_id: z.string().describe("The Peer ID of the target agent"),
294
+ tool_name: z.string().describe("The name of the server to call"),
295
+ arguments: z.record(z.string(), z.unknown()).optional().describe("Server arguments as a JSON object whose keys match the target server's input_schema. Call describe_remote_tool first to learn the schema."),
296
+ required_labels: z.string().optional().describe("Comma-separated key=value pairs (e.g. 'region=us-east-1,team=platform'). Fails closed: the call is rejected unless the peer attests every one of them. Empty means no requirement."),
297
+ },
298
+ }, async (args, extra) => {
299
+ const s = await withSignal(ready, AbortSignal.any([extra.signal, AbortSignal.timeout(REQUEST_TIMEOUT_MS)]));
300
+ const peer = canonicalPeerId(args.peer_id), target = toolTarget(args.tool_name);
301
+ const open = AbortSignal.any([extra.signal, AbortSignal.timeout(OPEN_TIMEOUT_MS)]);
302
+ return call(s, peer, target.service, target.tool, args.arguments ?? {}, { signal: open, requiredLabels: requiredLabels(args.required_labels ?? "") }, { signal: extra.signal, timeout: CALL_TIMEOUT_MS, resetTimeoutOnProgress: true });
303
+ });
304
+ return server;
305
+ }
package/dist/mcp.js CHANGED
@@ -21,7 +21,7 @@ import { decodeAuthFrame, decodeAuthResponse } from "./credential.js";
21
21
  /** go-msgio's default message cap, which agentmesh-node's StreamTransport uses. */
22
22
  export const MAX_MCP_MESSAGE_BYTES = 8 * 1024 * 1024;
23
23
  /** The MCP client the SDK identifies itself as. */
24
- export const MCP_CLIENT_INFO = { name: "agent-mesh-sdk", version: "0.1.0" };
24
+ export const MCP_CLIENT_INFO = { name: "agent-mesh-sdk", version: "0.2.0" };
25
25
  /**
26
26
  * A Transport for @modelcontextprotocol/sdk over a libp2p stream that has
27
27
  * already passed the auth handshake.
package/dist/mesh.d.ts CHANGED
@@ -117,9 +117,10 @@ export declare class AgentMesh {
117
117
  * Trades the current biscuit for a fresh one and persists it. The control
118
118
  * plane redeems only the last biscuit it issued, so a lost refresh result
119
119
  * means re-enrolling; persisting before returning keeps that rare.
120
- * Concurrent calls wait for each other.
120
+ * Concurrent calls wait for each other. A refresh whose signal has
121
+ * aborted by the time its turn comes does not start.
121
122
  */
122
- refresh(): Promise<MeshCredential>;
123
+ refresh(signal?: AbortSignal): Promise<MeshCredential>;
123
124
  /**
124
125
  * Adopts a signing key announced by a KEY_ROTATION event, so peers whose
125
126
  * credentials the new key signs verify before the next pull confirms it.
@@ -133,8 +134,11 @@ export declare class AgentMesh {
133
134
  * addresses and the ban set. Each part is attempted even when another
134
135
  * fails; the errors are reported together. Gossip events only bring this
135
136
  * forward; they are never the only way state arrives.
137
+ *
138
+ * An aborted signal cancels the request in flight and throws its reason:
139
+ * nothing more is sent and nothing is written to the state directory.
136
140
  */
137
- syncControlPlane(): Promise<ControlPlaneSync>;
141
+ syncControlPlane(signal?: AbortSignal): Promise<ControlPlaneSync>;
138
142
  /**
139
143
  * The frame that opens every stream to a peer: this member's biscuit plus
140
144
  * the service it wants (e.g. "mcp://calculator").
package/dist/mesh.js CHANGED
@@ -194,14 +194,16 @@ export class AgentMesh {
194
194
  * Trades the current biscuit for a fresh one and persists it. The control
195
195
  * plane redeems only the last biscuit it issued, so a lost refresh result
196
196
  * means re-enrolling; persisting before returning keeps that rare.
197
- * Concurrent calls wait for each other.
197
+ * Concurrent calls wait for each other. A refresh whose signal has
198
+ * aborted by the time its turn comes does not start.
198
199
  */
199
- async refresh() {
200
- const run = this.#refreshChain.then(() => this.#refreshOnce(), () => this.#refreshOnce());
200
+ async refresh(signal) {
201
+ const run = this.#refreshChain.then(() => this.#refreshOnce(signal), () => this.#refreshOnce(signal));
201
202
  this.#refreshChain = run.then(() => undefined, () => undefined);
202
203
  return run;
203
204
  }
204
- async #refreshOnce() {
205
+ async #refreshOnce(signal) {
206
+ signal?.throwIfAborted();
205
207
  let jwt;
206
208
  if (this.#jwtSource !== undefined) {
207
209
  try {
@@ -214,6 +216,10 @@ export class AgentMesh {
214
216
  // Best effort: fall back to session-only refresh if the token source is temporarily unavailable.
215
217
  }
216
218
  }
219
+ // The last point to stop. The /refresh request itself is not cut short:
220
+ // the control plane spends the old biscuit when it answers, so only a
221
+ // refresh that runs to the end can save the new one.
222
+ signal?.throwIfAborted();
217
223
  const result = await this.controlPlane.refresh({
218
224
  identity: this.identity,
219
225
  biscuit: this.#credential.biscuit,
@@ -256,33 +262,37 @@ export class AgentMesh {
256
262
  * addresses and the ban set. Each part is attempted even when another
257
263
  * fails; the errors are reported together. Gossip events only bring this
258
264
  * forward; they are never the only way state arrives.
265
+ *
266
+ * An aborted signal cancels the request in flight and throws its reason:
267
+ * nothing more is sent and nothing is written to the state directory.
259
268
  */
260
- async syncControlPlane() {
269
+ async syncControlPlane(signal) {
261
270
  const errors = [];
262
271
  let keysChanged = false;
263
272
  let refreshed = false;
264
273
  try {
265
- const keys = await this.controlPlane.keys(this.#credential.controlPlaneKeys);
274
+ const keys = await this.controlPlane.keys(this.#credential.controlPlaneKeys, signal);
266
275
  if (keys.length === 0) {
267
276
  throw new Error("/keys returned no keys");
268
277
  }
269
278
  keysChanged = !sameKeySet(keys, this.#credential.controlPlaneKeys);
270
279
  this.#credential = withCredentialMethods({ ...this.#credential, controlPlaneKeys: keys });
271
280
  if (credentialPredatesRotation(this.#credential)) {
272
- await this.refresh();
281
+ await this.refresh(signal);
273
282
  refreshed = true;
274
283
  }
275
284
  }
276
285
  catch (err) {
277
286
  errors.push(`keys: ${err instanceof Error ? err.message : String(err)}`);
278
287
  }
288
+ signal?.throwIfAborted();
279
289
  let bannedPeerIds;
280
290
  let routers;
281
291
  // Taken before the request: a ban recorded after this instant cannot be
282
292
  // in the answer, so its absence must not be read as an unban.
283
293
  const fetchedAt = new Date();
284
294
  try {
285
- const info = await this.controlPlane.info();
295
+ const info = await this.controlPlane.info(signal);
286
296
  routers = info.routers;
287
297
  this.#routers = info.routers;
288
298
  const discovered = info.routerAddresses.length > 0
@@ -296,6 +306,7 @@ export class AgentMesh {
296
306
  catch (err) {
297
307
  errors.push(`info: ${err instanceof Error ? err.message : String(err)}`);
298
308
  }
309
+ signal?.throwIfAborted();
299
310
  if (keysChanged || bannedPeerIds !== undefined) {
300
311
  await this.save();
301
312
  }
package/dist/session.d.ts CHANGED
@@ -78,7 +78,12 @@ export interface JoinOptions extends MeshHostOptions {
78
78
  * requiredLabels. Held on every call, MCP and HTTP alike; no call waives it.
79
79
  */
80
80
  egressRequireLabels?: Record<string, string>;
81
- /** Bounds the whole join. */
81
+ /**
82
+ * Bounds the whole join: the control plane sync, and the dial, auth
83
+ * handshake and relay reservation with every router tried. Without one the
84
+ * join is bounded by JOIN_TIMEOUT_MS (60s). A join that stops throws an
85
+ * error naming each router tried and the step it was in.
86
+ */
82
87
  signal?: AbortSignal;
83
88
  }
84
89
  export interface AdmittedRouter {
@@ -124,6 +129,17 @@ export interface ToolCallResult {
124
129
  /** The raw MCP result. */
125
130
  raw: unknown;
126
131
  }
132
+ /**
133
+ * Bounds a join whose caller passed no signal: each step has a timeout of
134
+ * its own, but a join tries them in turn over every router it is offered.
135
+ */
136
+ export declare const JOIN_TIMEOUT_MS = 60000;
137
+ /**
138
+ * Bounds the control plane sync a join starts with. The sync is best effort,
139
+ * so a control plane that does not answer in time leaves the join to go on
140
+ * with the stored credential instead of using up the whole join.
141
+ */
142
+ export declare const JOIN_SYNC_TIMEOUT_MS = 5000;
127
143
  /**
128
144
  * A member that is on the mesh: a libp2p host authenticated with at least
129
145
  * one router, answering the auth handshake for peers that dial it, and
@@ -184,7 +200,7 @@ export declare class MeshSession {
184
200
  * the mesh's record TTL. A member with no relayed address announces
185
201
  * nothing: there is nothing to reach it at.
186
202
  */
187
- announce(): Promise<void>;
203
+ announce(signal?: AbortSignal): Promise<void>;
188
204
  /** Whether peerId is currently one of the routers this member holds. */
189
205
  isHeldRouter(peerId: string): boolean;
190
206
  /** Handles a /mesh/goaway/1.0.0 message from a held router: shuns it, detaches it, and tops up from the catalog. */
@@ -312,5 +328,8 @@ export declare function candidatesFromRouterInfos(routers: readonly RouterInfo[]
312
328
  * and orders by matching prefer labels (descending) then load + jitter (ascending).
313
329
  */
314
330
  export declare function selectRouters(candidates: readonly RouterCandidate[], k: number, options?: SelectRoutersOptions): RouterCandidate[];
315
- /** Implements AgentMesh.join(); lives here to keep mesh.ts free of libp2p. */
316
- export declare function joinMesh(mesh: AgentMesh, options?: JoinOptions): Promise<MeshSession>;
331
+ /**
332
+ * Implements AgentMesh.join(); lives here to keep mesh.ts free of libp2p.
333
+ * timeoutMs bounds a join whose caller passed no signal.
334
+ */
335
+ export declare function joinMesh(mesh: AgentMesh, options?: JoinOptions, timeoutMs?: number): Promise<MeshSession>;