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/CHANGELOG.md +28 -0
- package/LICENSE +21 -0
- package/PROTOCOL.md +244 -0
- package/README.md +159 -0
- package/README.zh-CN.md +159 -0
- package/dist/herdr-link.mcp.js +796 -0
- package/dist/herdr-link.opencode.js +535 -0
- package/docs/mcp-wiring.md +275 -0
- package/package.json +65 -0
- package/scripts/mcp-probe.mjs +41 -0
- package/src/herdr.ts +535 -0
- package/src/mcp.ts +582 -0
- package/src/opencode.ts +180 -0
- package/src/pi.ts +170 -0
- package/src/protocol.ts +284 -0
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
|
+
}
|