herdr-link 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/src/mcp.ts ADDED
@@ -0,0 +1,582 @@
1
+ /**
2
+ * Shared stdio MCP server for Runtimes without a native custom-tool surface
3
+ * (Claude Code, Codex, AGY) — ADR-013/ADR-014.
4
+ *
5
+ * Hand-written, line-delimited JSON-RPC 2.0 over stdin/stdout (zero
6
+ * dependencies, no @modelcontextprotocol/sdk). Tool execution reuses the
7
+ * herdr.ts control layer. Tool gating and error semantics follow
8
+ * PROTOCOL.md §7; tool-name presentation follows PROTOCOL.md §4.4.
9
+ *
10
+ * Lazy presentation (blueprint v2): the tool surface is session-local and
11
+ * dormant until activated. Outside Herdr, `tools/list` is empty. Inside
12
+ * Herdr, a dormant session lists only the Tier 0 `herdr_link` gateway;
13
+ * calling the gateway with `{}` activates THIS server session (per stdio
14
+ * connection memory), emits `notifications/tools/list_changed`, and from
15
+ * then on `tools/list` additionally offers the canonical Tier 1 tools.
16
+ * Hosts that never refresh can keep dispatching through explicit gateway
17
+ * actions (`{"action":"send","arguments":{...}}`). No daemon, no global
18
+ * state: activation lives and dies with the connection.
19
+ */
20
+ import { realpathSync } from "node:fs";
21
+ import { pathToFileURL } from "node:url";
22
+
23
+ import { closeAgentPane, ensureSelfName, listPeers, sendMessage } from "./herdr.ts";
24
+ import {
25
+ COMMUNICATION_CONTRACT,
26
+ HERDR_LINK_GATEWAY,
27
+ HERDR_LINK_TOOLS,
28
+ HerdrLinkError,
29
+ TOOL_CLOSE,
30
+ TOOL_PEERS,
31
+ TOOL_SEND,
32
+ formatAgentFacingError,
33
+ type LinkErrorCode,
34
+ } from "./protocol.ts";
35
+
36
+ export const MCP_SERVER_NAME = "herdr-link";
37
+ /** Keep in sync with package.json "version" (serverInfo is informational). */
38
+ export const MCP_SERVER_VERSION = "0.2.0";
39
+ /** Fallback protocol version advertised when the client sends none. */
40
+ export const MCP_PROTOCOL_VERSION = "2025-06-18";
41
+
42
+ /**
43
+ * Emitted once when the gateway activates the session (MCP 2025-06-18
44
+ * `tools.listChanged`): the host is expected to refetch `tools/list`.
45
+ */
46
+ export const TOOLS_LIST_CHANGED = "notifications/tools/list_changed";
47
+
48
+ /**
49
+ * JSON-RPC reserved error codes — transport/protocol-level failures only.
50
+ * The five Link error codes (PROTOCOL.md §7) are never mapped onto these;
51
+ * they travel inside CallToolResult as isError:true + "CODE: detail" text.
52
+ */
53
+ export const PARSE_ERROR = -32700;
54
+ export const INVALID_REQUEST = -32600;
55
+ export const METHOD_NOT_FOUND = -32601;
56
+ export const INVALID_PARAMS = -32602;
57
+
58
+ export interface JsonRpcResponse {
59
+ jsonrpc: "2.0";
60
+ id: unknown;
61
+ result?: unknown;
62
+ error?: { code: number; message: string };
63
+ }
64
+
65
+ /**
66
+ * Server-to-host notification outlet. Unit tests inject a capturing sink;
67
+ * the default sink writes the notification as one more single-line JSON
68
+ * record on stdout — notifications never produce a response and stdout
69
+ * never carries diagnostics.
70
+ */
71
+ export type NotificationSink = (notification: Record<string, unknown>) => void;
72
+
73
+ type CanonicalToolName = typeof TOOL_PEERS | typeof TOOL_SEND | typeof TOOL_CLOSE;
74
+
75
+ const NORMAL_MESSAGING_RULE = "Use Herdr Link, not raw Herdr CLI, pane ids, or terminal input, for normal inter-agent messaging.";
76
+ const TOOL_DESCRIPTIONS: Record<CanonicalToolName, string> = {
77
+ [TOOL_PEERS]: `Discover live named peers in the same Herdr workspace; each state is advisory and Agent Names are the only addresses. ${NORMAL_MESSAGING_RULE}`,
78
+ [TOOL_SEND]:
79
+ `Send a herdr-link/1 message to a live named peer in your own workspace. When replying, set reply_to to the received envelope id; status "sent" means Herdr accepted delivery. ${NORMAL_MESSAGING_RULE}`,
80
+ [TOOL_CLOSE]:
81
+ `Close the pane currently hosting a named same-workspace agent. If you need to send a final message before closing, complete the send first and call close in a later tool step. ${NORMAL_MESSAGING_RULE}`,
82
+ };
83
+
84
+ const TOOL_INPUT_SCHEMAS: Record<CanonicalToolName, Record<string, unknown>> = {
85
+ [TOOL_PEERS]: { type: "object", properties: {} },
86
+ [TOOL_SEND]: {
87
+ type: "object",
88
+ properties: {
89
+ to: { type: "string", description: "Target Herdr agent name" },
90
+ message: { type: "string", description: "Message payload" },
91
+ reply_to: { type: "string", description: "Message id being replied to" },
92
+ },
93
+ required: ["to", "message"],
94
+ },
95
+ [TOOL_CLOSE]: {
96
+ type: "object",
97
+ properties: {
98
+ agent: { type: "string", description: "Target Herdr agent name" },
99
+ },
100
+ required: ["agent"],
101
+ },
102
+ };
103
+
104
+ /** Unexpected exceptions fall back to the operation's own failure code so the §7 vocabulary stays closed. */
105
+ const FALLBACK_ERROR_CODE: Record<CanonicalToolName, LinkErrorCode> = {
106
+ [TOOL_PEERS]: "NOT_IN_HERDR",
107
+ [TOOL_SEND]: "SEND_FAILED",
108
+ [TOOL_CLOSE]: "CLOSE_FAILED",
109
+ };
110
+
111
+ /**
112
+ * Tier 0 gateway tool (blueprint v2): the single always-present registration
113
+ * surface while dormant, and the explicit action-dispatch fallback for hosts
114
+ * that do not react to `notifications/tools/list_changed`.
115
+ */
116
+ const GATEWAY_TOOL: { name: typeof HERDR_LINK_GATEWAY; description: string; inputSchema: Record<string, unknown> } = {
117
+ name: HERDR_LINK_GATEWAY,
118
+ description:
119
+ "Herdr Link gateway. Activate only when the user explicitly asks to use Herdr or when handling an inbound Herdr Link message. Cross-agent messaging starts dormant: call this tool once with no arguments " +
120
+ "({}) to activate it for this session — the host is notified via notifications/tools/list_changed " +
121
+ "and herdr_link_peers / herdr_link_send / herdr_link_close become available as regular tools. " +
122
+ 'If your host did not refresh its tool list, keep dispatching through the gateway: {"action":"peers"}, ' +
123
+ '{"action":"send","arguments":{"to":...,"message":...,"reply_to":...}}, or ' +
124
+ '{"action":"close","arguments":{"agent":...}}.',
125
+ inputSchema: {
126
+ type: "object",
127
+ properties: {
128
+ action: {
129
+ type: "string",
130
+ enum: ["activate", "peers", "send", "close"],
131
+ description:
132
+ 'Omit or use "activate" to turn the session on; other values dispatch the corresponding peers, send, or close capability.',
133
+ },
134
+ arguments: {
135
+ type: "object",
136
+ description: "Canonical input object of the dispatched tool (ignored for activation).",
137
+ },
138
+ },
139
+ },
140
+ };
141
+
142
+ function gatewayToolForState(active: boolean): typeof GATEWAY_TOOL {
143
+ if (!active) return GATEWAY_TOOL;
144
+ return { ...GATEWAY_TOOL, description: `${GATEWAY_TOOL.description} ${NORMAL_MESSAGING_RULE}` };
145
+ }
146
+
147
+ /** Same triple gate as the other adapters (PROTOCOL.md §7 NOT_IN_HERDR condition + pane identity). */
148
+ export function isHerdrEnvironment(): boolean {
149
+ return (
150
+ process.env.HERDR_ENV === "1" &&
151
+ Boolean(process.env.HERDR_BIN_PATH) &&
152
+ Boolean(process.env.HERDR_PANE_ID)
153
+ );
154
+ }
155
+
156
+ function toolDefinitions(): Array<{
157
+ name: CanonicalToolName;
158
+ description: string;
159
+ inputSchema: Record<string, unknown>;
160
+ }> {
161
+ return HERDR_LINK_TOOLS.map((name) => ({
162
+ name,
163
+ description: TOOL_DESCRIPTIONS[name],
164
+ inputSchema: TOOL_INPUT_SCHEMAS[name],
165
+ }));
166
+ }
167
+
168
+ function isRecord(value: unknown): value is Record<string, unknown> {
169
+ return typeof value === "object" && value !== null && !Array.isArray(value);
170
+ }
171
+
172
+ function describeError(error: unknown): string {
173
+ if (error instanceof Error) return error.message;
174
+ if (typeof error === "string") return error;
175
+ try {
176
+ return JSON.stringify(error);
177
+ } catch {
178
+ return String(error);
179
+ }
180
+ }
181
+
182
+ function requireStringArg(args: Record<string, unknown>, key: string, code: LinkErrorCode): string {
183
+ const value = args[key];
184
+ if (typeof value !== "string") {
185
+ throw new HerdrLinkError(code, `"${key}" must be a string`);
186
+ }
187
+ return value;
188
+ }
189
+
190
+ function optionalStringArg(
191
+ args: Record<string, unknown>,
192
+ key: string,
193
+ code: LinkErrorCode,
194
+ ): string | undefined {
195
+ const value = args[key];
196
+ if (value === undefined || value === null) return undefined;
197
+ if (typeof value !== "string") {
198
+ throw new HerdrLinkError(code, `"${key}" must be a string when present`);
199
+ }
200
+ return value;
201
+ }
202
+
203
+ /* ------------------------------------------------------------------ *
204
+ * stdout plumbing
205
+ *
206
+ * Every stdout line — responses and notifications alike — goes through
207
+ * one serialized writer, so interleaving stays FIFO regardless of
208
+ * backpressure and stdout never carries anything but complete JSON lines.
209
+ * ------------------------------------------------------------------ */
210
+
211
+ type LineWriter = (line: string) => Promise<void>;
212
+
213
+ function createSerializedLineWriter(stream: NodeJS.WriteStream): LineWriter {
214
+ let tail: Promise<void> = Promise.resolve();
215
+ return (line: string): Promise<void> => {
216
+ const queued = new Promise<void>((done) => {
217
+ tail = tail.then(() => {
218
+ if (stream.write(`${line}\n`)) {
219
+ done();
220
+ return;
221
+ }
222
+ const flushed = (): void => {
223
+ stream.off("drain", flushed);
224
+ stream.off("error", flushed);
225
+ done();
226
+ };
227
+ stream.on("drain", flushed);
228
+ stream.on("error", flushed);
229
+ });
230
+ });
231
+ return queued;
232
+ };
233
+ }
234
+
235
+ const writeStdoutLine: LineWriter = createSerializedLineWriter(process.stdout);
236
+
237
+ /** Default notification sink: one more single-line JSON record on stdout. */
238
+ function stdoutNotificationSink(notification: Record<string, unknown>): void {
239
+ void writeStdoutLine(JSON.stringify(notification));
240
+ }
241
+
242
+ export interface McpServerDeps {
243
+ environmentOk?: typeof isHerdrEnvironment;
244
+ listPeers?: typeof listPeers;
245
+ sendMessage?: typeof sendMessage;
246
+ closeAgentPane?: typeof closeAgentPane;
247
+ /**
248
+ * Receives server-to-host notifications (currently only
249
+ * `notifications/tools/list_changed`). Defaults to stdout.
250
+ */
251
+ notify?: NotificationSink;
252
+ }
253
+
254
+ /**
255
+ * Creates the JSON-RPC request handler. All Herdr IO goes through `deps`
256
+ * (real control layer by default), keeping the handler unit-testable.
257
+ * Activation state is held in this closure — one handler instance per stdio
258
+ * connection, so sessions never leak across connections.
259
+ */
260
+ export function createRequestHandler(
261
+ deps: McpServerDeps = {},
262
+ ): (message: unknown) => Promise<JsonRpcResponse | null> {
263
+ const environmentOk = deps.environmentOk ?? isHerdrEnvironment;
264
+ const runPeers = deps.listPeers ?? listPeers;
265
+ const runSend = deps.sendMessage ?? sendMessage;
266
+ const runClose = deps.closeAgentPane ?? closeAgentPane;
267
+ const notify = deps.notify ?? stdoutNotificationSink;
268
+
269
+ /** Session-local lazy activation (blueprint v2). True ⇒ Tier 1 tools are listed. */
270
+ let activated = false;
271
+
272
+ /**
273
+ * Idempotent activation. Emits `notifications/tools/list_changed` exactly
274
+ * once, on the dormant → active transition.
275
+ */
276
+ function activateSession(): void {
277
+ if (activated) return;
278
+ activated = true;
279
+ notify({ jsonrpc: "2.0", method: TOOLS_LIST_CHANGED });
280
+ }
281
+
282
+ function respond(id: unknown, result: unknown): JsonRpcResponse {
283
+ return { jsonrpc: "2.0", id, result };
284
+ }
285
+
286
+ function fail(id: unknown, code: number, message: string): JsonRpcResponse {
287
+ return { jsonrpc: "2.0", id, error: { code, message } };
288
+ }
289
+
290
+ function callSuccess(id: unknown, value: object): JsonRpcResponse {
291
+ return respond(id, { content: [{ type: "text", text: JSON.stringify(value) }] });
292
+ }
293
+
294
+ // PROTOCOL.md §7: every Link failure is a local tool failure returned as
295
+ // isError:true with "CODE: detail" text — never a crash, never an envelope.
296
+ function callFailure(id: unknown, error: unknown, fallbackCode: LinkErrorCode): JsonRpcResponse {
297
+ const linkError =
298
+ error instanceof HerdrLinkError
299
+ ? error
300
+ : new HerdrLinkError(fallbackCode, describeError(error));
301
+ return respond(id, {
302
+ content: [{ type: "text", text: formatAgentFacingError(linkError, linkError.code) }],
303
+ isError: true,
304
+ });
305
+ }
306
+
307
+ async function executeCanonical(
308
+ canonicalName: CanonicalToolName,
309
+ args: Record<string, unknown>,
310
+ ): Promise<object> {
311
+ switch (canonicalName) {
312
+ case TOOL_PEERS:
313
+ return await runPeers();
314
+ case TOOL_SEND: {
315
+ const to = requireStringArg(args, "to", "PEER_NOT_FOUND");
316
+ const message = requireStringArg(args, "message", "SEND_FAILED");
317
+ const reply_to = optionalStringArg(args, "reply_to", "SEND_FAILED");
318
+ const sent = await runSend(to, message, reply_to);
319
+ return { status: sent.status, id: sent.id, to: sent.to };
320
+ }
321
+ case TOOL_CLOSE: {
322
+ const agent = requireStringArg(args, "agent", "PEER_NOT_FOUND");
323
+ return await runClose(agent);
324
+ }
325
+ }
326
+ }
327
+
328
+ /** Runs one canonical Tier 1 tool and renders its CallToolResult. */
329
+ async function callCanonicalTool(
330
+ id: unknown,
331
+ canonicalName: CanonicalToolName,
332
+ args: Record<string, unknown>,
333
+ ): Promise<JsonRpcResponse> {
334
+ try {
335
+ return callSuccess(id, await executeCanonical(canonicalName, args));
336
+ } catch (error) {
337
+ return callFailure(id, error, FALLBACK_ERROR_CODE[canonicalName]);
338
+ }
339
+ }
340
+
341
+ /**
342
+ * Tier 0 gateway. Activation (`{}` / `{"action":"activate"}`) is idempotent
343
+ * and reports the canonical surface; explicit actions dispatch onto the
344
+ * canonical executor so hosts that never refetch `tools/list` keep full
345
+ * functionality through this single tool.
346
+ */
347
+ async function callGateway(id: unknown, args: Record<string, unknown>): Promise<JsonRpcResponse> {
348
+ if (!environmentOk()) {
349
+ return callFailure(id, new HerdrLinkError("NOT_IN_HERDR"), "NOT_IN_HERDR");
350
+ }
351
+ const action = args.action;
352
+ if (action === undefined || action === "activate") {
353
+ activateSession();
354
+ return callSuccess(id, {
355
+ status: "active",
356
+ capabilities: ["peers", "send", "close"],
357
+ });
358
+ }
359
+ if (
360
+ typeof action !== "string" ||
361
+ !(["peers", "send", "close"] as readonly string[]).includes(action)
362
+ ) {
363
+ return fail(id, INVALID_PARAMS, `Unknown gateway action: ${String(action)}`);
364
+ }
365
+ const canonicalName = (
366
+ action === "peers" ? TOOL_PEERS : action === "send" ? TOOL_SEND : TOOL_CLOSE
367
+ ) as CanonicalToolName;
368
+ activateSession();
369
+ // Prefer the nested canonical arguments object; otherwise accept the
370
+ // remaining top-level fields directly (deterministic either way).
371
+ const dispatchArgs = isRecord(args.arguments) ? args.arguments : args;
372
+ return await callCanonicalTool(id, canonicalName, dispatchArgs);
373
+ }
374
+
375
+ async function callTool(
376
+ id: unknown,
377
+ params: Record<string, unknown>,
378
+ ): Promise<JsonRpcResponse> {
379
+ const name = params.name;
380
+ if (typeof name !== "string") {
381
+ return fail(id, INVALID_PARAMS, `Unknown tool: ${String(name ?? "")}`);
382
+ }
383
+ const rawArguments = params.arguments;
384
+ const args = isRecord(rawArguments) ? rawArguments : {};
385
+
386
+ if (name === HERDR_LINK_GATEWAY) {
387
+ return await callGateway(id, args);
388
+ }
389
+ if (!(HERDR_LINK_TOOLS as readonly string[]).includes(name)) {
390
+ return fail(id, INVALID_PARAMS, `Unknown tool: ${name}`);
391
+ }
392
+
393
+ // PROTOCOL.md §6.1: outside Herdr nothing executes, regardless of what a
394
+ // stale host-side tool registry still thinks exists.
395
+ if (!environmentOk()) {
396
+ return callFailure(id, new HerdrLinkError("NOT_IN_HERDR"), "NOT_IN_HERDR");
397
+ }
398
+ // A direct Tier 1 call implies the caller already knows the canonical
399
+ // surface; activation keeps list/notification state consistent.
400
+ activateSession();
401
+ return await callCanonicalTool(id, name as CanonicalToolName, args);
402
+ }
403
+
404
+ return async (message: unknown): Promise<JsonRpcResponse | null> => {
405
+ if (!isRecord(message)) {
406
+ return fail(null, INVALID_REQUEST, "Invalid Request");
407
+ }
408
+
409
+ const hasId = "id" in message && message.id !== undefined;
410
+ const id = hasId ? message.id : null;
411
+ const method = message.method;
412
+
413
+ // Notifications never get a response, whatever they carry.
414
+ if (!hasId) return null;
415
+
416
+ if (message.jsonrpc !== "2.0" || typeof method !== "string") {
417
+ return fail(id, INVALID_REQUEST, "Invalid Request");
418
+ }
419
+
420
+ switch (method) {
421
+ case "initialize": {
422
+ const params = isRecord(message.params) ? message.params : {};
423
+ const requested = params.protocolVersion;
424
+ return respond(id, {
425
+ // Echoing the client's version maximizes compatibility; clients that
426
+ // do not support our default would disconnect on a mismatch anyway.
427
+ protocolVersion: typeof requested === "string" ? requested : MCP_PROTOCOL_VERSION,
428
+ capabilities: { tools: { listChanged: true } },
429
+ serverInfo: { name: MCP_SERVER_NAME, version: MCP_SERVER_VERSION },
430
+ });
431
+ }
432
+ case "ping":
433
+ return respond(id, {});
434
+ case "tools/list": {
435
+ // Zero side-effect gate (ADR-013) + lazy presentation (blueprint v2):
436
+ // outside Herdr nothing; dormant only the Tier 0 gateway; active the
437
+ // gateway plus the canonical Tier 1 tools.
438
+ if (!environmentOk()) return respond(id, { tools: [] });
439
+ return respond(id, {
440
+ tools: activated ? [gatewayToolForState(true), ...toolDefinitions()] : [gatewayToolForState(false)],
441
+ });
442
+ }
443
+ case "tools/call": {
444
+ const params = message.params;
445
+ if (!isRecord(params)) {
446
+ return fail(id, INVALID_PARAMS, "tools/call requires an object params");
447
+ }
448
+ return await callTool(id, params);
449
+ }
450
+ default:
451
+ return fail(id, METHOD_NOT_FOUND, `Method not found: ${method}`);
452
+ }
453
+ };
454
+ }
455
+
456
+ /**
457
+ * Serves one JSON-RPC exchange per stdin line until EOF. Responses are written
458
+ * to stdout as single-line JSON (JSON.stringify escapes embedded newlines);
459
+ * notifications emitted through the default sink interleave in FIFO order on
460
+ * the same serialized writer. Nothing else ever touches stdout.
461
+ */
462
+ export async function runStdioServer(
463
+ handler: (message: unknown) => Promise<JsonRpcResponse | null>,
464
+ ): Promise<void> {
465
+ let buffer = "";
466
+
467
+ try {
468
+ for await (const chunk of process.stdin) {
469
+ buffer += chunk.toString("utf8");
470
+ let newlineIndex = buffer.indexOf("\n");
471
+ while (newlineIndex !== -1) {
472
+ const line = buffer.slice(0, newlineIndex).trim();
473
+ buffer = buffer.slice(newlineIndex + 1);
474
+ newlineIndex = buffer.indexOf("\n");
475
+ if (line === "") continue;
476
+
477
+ let response: JsonRpcResponse | null;
478
+ try {
479
+ response = await handler(JSON.parse(line));
480
+ } catch {
481
+ response = failParse();
482
+ }
483
+ if (response) await writeStdoutLine(JSON.stringify(response));
484
+ }
485
+ }
486
+ } catch {
487
+ // stdin ended unexpectedly or stdout failed — exit quietly, hosts treat a
488
+ // dead connection as their own transport error.
489
+ }
490
+
491
+ function failParse(): JsonRpcResponse {
492
+ return { jsonrpc: "2.0", id: null, error: { code: PARSE_ERROR, message: "Parse error" } };
493
+ }
494
+ }
495
+
496
+ /**
497
+ * Host-facing presented name for a canonical tool on prefix-style hosts (PROTOCOL.md
498
+ * §4.4): the full canonical name is always the suffix. The namespace is
499
+ * host-specific ("herdr_link" for the Codex wiring) and deliberately NOT
500
+ * defaulted — serverInfo.name and the host tool namespace are different
501
+ * concerns, so callers must state explicitly which namespace a contract declares.
502
+ */
503
+ export function mcpPresentedToolName(
504
+ canonicalName: CanonicalToolName | typeof HERDR_LINK_GATEWAY,
505
+ serverName: string,
506
+ ): string {
507
+ return `mcp__${serverName}__${canonicalName}`;
508
+ }
509
+
510
+ /** Shared canonical part of every Communication Contract variant (§3). */
511
+ function contractWithAppendix(appendix: string): string {
512
+ return `${COMMUNICATION_CONTRACT}\n\n${appendix}`;
513
+ }
514
+
515
+ /**
516
+ * Contract text for prefix-style MCP hosts (e.g. Codex): tools are exposed as
517
+ * independent `mcp__<namespace>__<canonical>` functions. `namespace` is the
518
+ * host tool namespace and must be explicit. Presentation is lazy: only the
519
+ * gateway is listed until the model activates it (blueprint v2).
520
+ */
521
+ export function buildMcpPrefixedCommunicationContract(namespace: string): string {
522
+ const [peers, send, close] = HERDR_LINK_TOOLS.map((name) =>
523
+ mcpPresentedToolName(name, namespace),
524
+ );
525
+ const gateway = mcpPresentedToolName(HERDR_LINK_GATEWAY, namespace);
526
+ return contractWithAppendix(
527
+ `In this runtime Herdr Link starts dormant: only the ${gateway} gateway tool is listed until it is activated.\n` +
528
+ `- Call ${gateway} once with no arguments ({}); the host then receives notifications/tools/list_changed and the cross-agent tools become available.\n` +
529
+ `- If the host did not refresh its tool list, keep dispatching through the gateway: {"action":"peers"}, {"action":"send","arguments":{...}}, {"action":"close","arguments":{...}}.\n` +
530
+ `The tools are presented under MCP-prefixed names (the canonical name is always the suffix):\n` +
531
+ `- herdr_link_peers -> ${peers}\n` +
532
+ `- herdr_link_send -> ${send}\n` +
533
+ `- herdr_link_close -> ${close}`,
534
+ );
535
+ }
536
+
537
+ /**
538
+ * Contract text for wrapper-style MCP hosts (e.g. AGY's call_mcp_tool): the
539
+ * model invokes one native wrapper carrying ServerName/ToolName/Arguments
540
+ * instead of per-tool functions (PROTOCOL.md §4.4 wrapper form). Both values
541
+ * must be explicit. Presentation is lazy (blueprint v2): activate the gateway
542
+ * first, then address the canonical tools through the same wrapper.
543
+ */
544
+ export function buildMcpWrapperCommunicationContract(
545
+ wrapperName: string,
546
+ serverName: string,
547
+ ): string {
548
+ return contractWithAppendix(
549
+ `In this runtime Herdr Link starts dormant: only the Tier 0 gateway (${HERDR_LINK_GATEWAY}) is listed until it is activated.\n` +
550
+ `- Invoke the gateway once with empty Arguments {} (ToolName "${HERDR_LINK_GATEWAY}"); the host then receives notifications/tools/list_changed and the cross-agent tools become available.\n` +
551
+ `- If the host did not refresh its tool list, keep dispatching through the gateway with ToolName "${HERDR_LINK_GATEWAY}" and an Arguments object carrying {"action":"peers"|"send"|"close", ...}.\n\n` +
552
+ `After activation, Herdr Link MCP tools are invoked through ${wrapperName}.\n\n` +
553
+ `Use:\n` +
554
+ `- ServerName: "${serverName}"\n` +
555
+ `- ToolName: "herdr_link_peers", "herdr_link_send", or "herdr_link_close"\n` +
556
+ `- Arguments: the canonical input object for that Herdr Link tool`,
557
+ );
558
+ }
559
+
560
+ /** True when this file is the process entry point (spawned by an MCP host), false when imported (tests). */
561
+ function invokedDirectly(): boolean {
562
+ const entry = process.argv[1];
563
+ if (!entry) return false;
564
+ try {
565
+ // npm installs bins as symlinks while Node resolves module URLs to their
566
+ // real paths; argv[1] must be resolved identically before comparing or
567
+ // every spawn through an installed bin silently no-ops.
568
+ return import.meta.url === pathToFileURL(realpathSync(entry)).href;
569
+ } catch {
570
+ return false;
571
+ }
572
+ }
573
+
574
+ if (invokedDirectly()) {
575
+ // Self identity bootstrap (PROTOCOL.md §6.3): fire-and-forget before serving
576
+ // requests so a manually launched unnamed agent becomes discoverable ASAP;
577
+ // communication calls still fall back through getSelfContext(). Nothing may
578
+ // ever write to stdout here except JSON-RPC frames, so failures stay silent
579
+ // and surface later as SELF_UNNAMED.
580
+ void ensureSelfName().catch(() => {});
581
+ await runStdioServer(createRequestHandler());
582
+ }