@agent-delivery-harness/mcp 0.7.3

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/LICENSE ADDED
@@ -0,0 +1,105 @@
1
+ # Functional Source License, Version 1.1, ALv2 Future License
2
+
3
+ ## Abbreviation
4
+
5
+ FSL-1.1-ALv2
6
+
7
+ ## Notice
8
+
9
+ Copyright 2026 Kwamina Essuah Mensah
10
+
11
+ ## Terms and Conditions
12
+
13
+ ### Licensor ("We")
14
+
15
+ The party offering the Software under these Terms and Conditions.
16
+
17
+ ### The Software
18
+
19
+ The "Software" is each version of the software that we make available under
20
+ these Terms and Conditions, as indicated by our inclusion of these Terms and
21
+ Conditions with the Software.
22
+
23
+ ### License Grant
24
+
25
+ Subject to your compliance with this License Grant and the Patents,
26
+ Redistribution and Trademark clauses below, we hereby grant you the right to
27
+ use, copy, modify, create derivative works, publicly perform, publicly display
28
+ and redistribute the Software for any Permitted Purpose identified below.
29
+
30
+ ### Permitted Purpose
31
+
32
+ A Permitted Purpose is any purpose other than a Competing Use. A Competing Use
33
+ means making the Software available to others in a commercial product or
34
+ service that:
35
+
36
+ 1. substitutes for the Software;
37
+
38
+ 2. substitutes for any other product or service we offer using the Software
39
+ that exists as of the date we make the Software available; or
40
+
41
+ 3. offers the same or substantially similar functionality as the Software.
42
+
43
+ Permitted Purposes specifically include using the Software:
44
+
45
+ 1. for your internal use and access;
46
+
47
+ 2. for non-commercial education;
48
+
49
+ 3. for non-commercial research; and
50
+
51
+ 4. in connection with professional services that you provide to a licensee
52
+ using the Software in accordance with these Terms and Conditions.
53
+
54
+ ### Patents
55
+
56
+ To the extent your use for a Permitted Purpose would necessarily infringe our
57
+ patents, the license grant above includes a license under our patents. If you
58
+ make a claim against any party that the Software infringes or contributes to
59
+ the infringement of any patent, then your patent license to the Software ends
60
+ immediately.
61
+
62
+ ### Redistribution
63
+
64
+ The Terms and Conditions apply to all copies, modifications and derivatives of
65
+ the Software.
66
+
67
+ If you redistribute any copies, modifications or derivatives of the Software,
68
+ you must include a copy of or a link to these Terms and Conditions and not
69
+ remove any copyright notices provided in or with the Software.
70
+
71
+ ### Disclaimer
72
+
73
+ THE SOFTWARE IS PROVIDED "AS IS" AND WITHOUT WARRANTIES OF ANY KIND, EXPRESS OR
74
+ IMPLIED, INCLUDING WITHOUT LIMITATION WARRANTIES OF FITNESS FOR A PARTICULAR
75
+ PURPOSE, MERCHANTABILITY, TITLE OR NON-INFRINGEMENT.
76
+
77
+ IN NO EVENT WILL WE HAVE ANY LIABILITY TO YOU ARISING OUT OF OR RELATED TO THE
78
+ SOFTWARE, INCLUDING INDIRECT, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES,
79
+ EVEN IF WE HAVE BEEN INFORMED OF THEIR POSSIBILITY IN ADVANCE.
80
+
81
+ ### Trademarks
82
+
83
+ Except for displaying the License Details and identifying us as the origin of
84
+ the Software, you have no right under these Terms and Conditions to use our
85
+ trademarks, trade names, service marks or product names.
86
+
87
+ ## Grant of Future License
88
+
89
+ We hereby irrevocably grant you an additional license to use the Software under
90
+ the Apache License, Version 2.0 that is effective on the second anniversary of
91
+ the date we make the Software available. On or after that date, you may use the
92
+ Software under the Apache License, Version 2.0, in which case the following
93
+ will apply:
94
+
95
+ Licensed under the Apache License, Version 2.0 (the "License"); you may not use
96
+ this file except in compliance with the License.
97
+
98
+ You may obtain a copy of the License at
99
+
100
+ http://www.apache.org/licenses/LICENSE-2.0
101
+
102
+ Unless required by applicable law or agreed to in writing, software distributed
103
+ under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
104
+ CONDITIONS OF ANY KIND, either express or implied. See the License for the
105
+ specific language governing permissions and limitations under the License.
package/NOTICE ADDED
@@ -0,0 +1,5 @@
1
+ delivery-harness
2
+ Copyright Kwamina Essuah Mensah
3
+
4
+ This product includes software developed by the delivery-harness authors
5
+ (https://github.com/kwam1na/agent-delivery-harness).
package/package.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "@agent-delivery-harness/mcp",
3
+ "version": "0.7.3",
4
+ "type": "module",
5
+ "description": "MCP server exposing the delivery harness submission surface",
6
+ "license": "FSL-1.1-ALv2",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/v26-technologies/agent-delivery-harness.git",
10
+ "directory": "packages/mcp"
11
+ },
12
+ "engines": {
13
+ "node": ">=22.6.0"
14
+ },
15
+ "dependencies": {
16
+ "@agent-delivery-harness/cli": "0.7.3",
17
+ "@agent-delivery-harness/kernel": "0.7.3"
18
+ },
19
+ "exports": {
20
+ ".": "./src/index.ts"
21
+ },
22
+ "files": [
23
+ "src",
24
+ "!src/**/*.test.ts",
25
+ "LICENSE",
26
+ "NOTICE"
27
+ ]
28
+ }
package/src/index.ts ADDED
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Delivery harness MCP server.
3
+ *
4
+ * `server.ts` is the tool surface — a wrapper over the CLI's command core at
5
+ * strict parity. `stdio.ts` is the transport that carries it: newline-delimited
6
+ * JSON-RPC 2.0, hand-rolled, no runtime dependency.
7
+ */
8
+
9
+ export const PACKAGE_NAME = "@agent-delivery-harness/mcp";
10
+
11
+ export {
12
+ HANDSHAKE_PROTOCOL_VERSIONS,
13
+ LOG_LEVELS,
14
+ MCP_PROTOCOL_VERSION,
15
+ MCP_SERVER_CAPABILITIES,
16
+ MCP_SERVER_INFO,
17
+ MCP_STATELESS_PROTOCOL_VERSION,
18
+ META_CLIENT_CAPABILITIES,
19
+ META_CLIENT_INFO,
20
+ META_LOG_LEVEL,
21
+ META_PROTOCOL_VERSION,
22
+ META_SERVER_INFO,
23
+ META_STATELESS_KEYS,
24
+ STATELESS_PROTOCOL_VERSIONS,
25
+ SUPPORTED_PROTOCOL_VERSIONS,
26
+ TOOL_LIST_CACHE_SCOPE,
27
+ TOOL_LIST_TTL_MS,
28
+ callTool,
29
+ listTools,
30
+ toolResultFor,
31
+ type McpToolResult,
32
+ type ToolHostRuntime,
33
+ type ToolListing,
34
+ type ToolOutcome,
35
+ } from "./server.ts";
36
+
37
+ export {
38
+ INVALID_PARAMS,
39
+ INVALID_REQUEST,
40
+ JSON_RPC_VERSION,
41
+ METHOD_NOT_FOUND,
42
+ PARSE_ERROR,
43
+ UNSUPPORTED_PROTOCOL_VERSION,
44
+ createSession,
45
+ encodeResponse,
46
+ handleRpcLine,
47
+ handleRpcMessage,
48
+ serveStdio,
49
+ type JsonRpcId,
50
+ type JsonRpcResponse,
51
+ type McpSession,
52
+ } from "./stdio.ts";
package/src/server.ts ADDED
@@ -0,0 +1,576 @@
1
+ /**
2
+ * The MCP tool surface: `review-context`, `submit-evidence`, and the read-only
3
+ * `managed` projection, at strict parity with the CLI.
4
+ *
5
+ * THIS IS A WRAPPER, NOT A SECOND IMPLEMENTATION. Every tool call is turned
6
+ * into the argv the CLI would have been invoked with and handed to
7
+ * `runCliBoundary` — the same boundary, the same command modules, the same
8
+ * config loader, the same exit-code classification, the same renderer. The text
9
+ * an agent reads is literally the bytes the CLI wrote, captured rather than
10
+ * printed. Parity is therefore a property of the construction, and the parity
11
+ * table beside this file is what keeps it one.
12
+ *
13
+ * WHY NO MCP SDK. The stdio transport is newline-delimited JSON-RPC 2.0 and the
14
+ * three methods this server answers are small and fully specified, so the SDK
15
+ * would buy framing this package can write in a page — at the cost of the first
16
+ * runtime dependency in a repo that refused one on its canonicalizer for
17
+ * supply-chain reasons. The protocol layer lives in `stdio.ts`; this module is
18
+ * transport-free so the parity suite drives the same code an agent reaches.
19
+ *
20
+ * A DELIBERATE SUBSET, IN TWO DIMENSIONS. Three of the CLI's eleven commands are
21
+ * exposed, and the third — `managed` — exposes only its read-only operations.
22
+ * The rest are not tools, and a call naming one is an unknown tool: what a tool
23
+ * call may reach is the registry below, never the CLI's. Exposing less than the
24
+ * CLI is within the contract; behaving differently about what is exposed is not.
25
+ *
26
+ * The second dimension is the one that matters most. A tool surface that could
27
+ * submit a stage result or run a sensor would be a second orchestrator sitting
28
+ * beside the host that already owns delegation and sequencing. So the operation
29
+ * enum below is read-class only, and the facade's contract-inventory sensor
30
+ * checks that against the product's own operation inventory rather than against
31
+ * this comment.
32
+ *
33
+ * WHAT AN AGENT NEVER GETS. No TTY, therefore no waiver prompt — an MCP session
34
+ * has no human at the other end to answer one, and a prompt nobody can answer
35
+ * is either a hang or a forged consent. Both TTY flags are hard `false` here.
36
+ */
37
+ import {
38
+ CliInterruption,
39
+ EXIT_OK,
40
+ EXIT_USAGE,
41
+ commandBlocker,
42
+ importHarnessConfig,
43
+ managedCommand,
44
+ reviewContextCommand,
45
+ runCliBoundary,
46
+ submitEvidenceCommand,
47
+ type CliRuntime,
48
+ type CommandDescriptor,
49
+ } from "@agent-delivery-harness/cli";
50
+ import {
51
+ BlockedError,
52
+ createInternalErrorBlocker,
53
+ renderBlockers,
54
+ serializeBlockers,
55
+ type ArtifactsPort,
56
+ type Blocker,
57
+ type EnvSnapshot,
58
+ type HarnessConfig,
59
+ type LiveProviderResult,
60
+ type SerializedBlockers,
61
+ } from "@agent-delivery-harness/kernel";
62
+
63
+ // ── Protocol identity ────────────────────────────────────────────────────────
64
+
65
+ /**
66
+ * The revision the handshake settles on, and the older ones it will still speak
67
+ * if a client asks for one. Newest first: `initialize` echoes the client's
68
+ * version when it is on this list and answers with the newest otherwise, which
69
+ * is the negotiation the spec asks for.
70
+ *
71
+ * 2025-03-26 is missing on purpose. It is the one revision that requires a
72
+ * server to accept JSON-RPC batches, and the transport refuses every array; a
73
+ * client asking for it is answered with the newest revision here, where that
74
+ * refusal is true. 2024-11-05 predates batching and carries no such
75
+ * requirement.
76
+ */
77
+ export const MCP_PROTOCOL_VERSION = "2025-11-25";
78
+ export const HANDSHAKE_PROTOCOL_VERSIONS: readonly string[] = Object.freeze([MCP_PROTOCOL_VERSION, "2025-06-18", "2024-11-05"]);
79
+
80
+ /**
81
+ * The stateless revision, and the list of stateless revisions — one, today.
82
+ *
83
+ * 2026-07-28 removed the handshake rather than extending it. A client no longer
84
+ * negotiates once and remembers; it declares its protocol version and
85
+ * capabilities on every request, and the server answers each request on its own
86
+ * terms. That makes "which revision is this connection speaking" the wrong
87
+ * question, and `HANDSHAKE_PROTOCOL_VERSIONS` and this list the two answers to
88
+ * the right one: which revisions arrive through `initialize`, and which arrive
89
+ * in `_meta`.
90
+ */
91
+ export const MCP_STATELESS_PROTOCOL_VERSION = "2026-07-28";
92
+ export const STATELESS_PROTOCOL_VERSIONS: readonly string[] = Object.freeze([MCP_STATELESS_PROTOCOL_VERSION]);
93
+
94
+ /**
95
+ * Everything this server speaks, newest first — for documentation and for tests
96
+ * that need the whole set.
97
+ *
98
+ * NOT WHAT `server/discover` ANSWERS. That field is `supportedVersions`:
99
+ * "Protocol versions the server supports. The client should choose one of these
100
+ * for subsequent requests" — and on stdio, "The server returns a
101
+ * `DiscoverResult`: the server is modern. Select a mutually supported version
102
+ * from `supportedVersions` and continue." There is no branch back to
103
+ * `initialize` from there; a `DiscoverResult` arriving is itself what tells the
104
+ * client to stay modern. A handshake revision named in that field would
105
+ * therefore be a trap — a conforming client selects 2024-11-05, continues
106
+ * modern, and earns a guaranteed `UnsupportedProtocolVersionError`. So the
107
+ * probe answers `STATELESS_PROTOCOL_VERSIONS`, the same list the error carries,
108
+ * for the same loop-avoidance reason. What a handshake client can reach is
109
+ * discovered the way it always was: by sending `initialize`.
110
+ */
111
+ export const SUPPORTED_PROTOCOL_VERSIONS: readonly string[] = Object.freeze([
112
+ ...STATELESS_PROTOCOL_VERSIONS,
113
+ ...HANDSHAKE_PROTOCOL_VERSIONS,
114
+ ]);
115
+
116
+ /** Version tracks the package; the release mechanics keep the two in step. */
117
+ export const MCP_SERVER_INFO = { name: "delivery-harness", version: "0.0.0" } as const;
118
+
119
+ // ── Per-request protocol metadata ────────────────────────────────────────────
120
+
121
+ /**
122
+ * The `_meta` keys 2026-07-28 reserves. Named rather than spelled inline
123
+ * because a typo in one of these is a request silently read as the wrong era —
124
+ * the failure mode that has no symptom until a client sees the wrong envelope.
125
+ */
126
+ export const META_PROTOCOL_VERSION = "io.modelcontextprotocol/protocolVersion";
127
+ export const META_CLIENT_INFO = "io.modelcontextprotocol/clientInfo";
128
+ export const META_CLIENT_CAPABILITIES = "io.modelcontextprotocol/clientCapabilities";
129
+ export const META_LOG_LEVEL = "io.modelcontextprotocol/logLevel";
130
+ export const META_SERVER_INFO = "io.modelcontextprotocol/serverInfo";
131
+
132
+ /**
133
+ * The `_meta` members whose presence identifies a request as stateless: the
134
+ * four per-request protocol fields 2026-07-28 defines, and nothing else.
135
+ *
136
+ * WHY NOT THE `io.modelcontextprotocol/` PREFIX. Because that prefix marks MCP
137
+ * ownership, not the modern era, and the two are not the same set.
138
+ * 2025-11-25's tasks feature reserves a key under it — "All requests,
139
+ * notifications, and responses related to a task **MUST** include the
140
+ * `io.modelcontextprotocol/related-task` key in their `_meta` field" — so a
141
+ * handshake-era request can carry a prefixed key perfectly legitimately. A
142
+ * prefix test would route that request into the stateless path and reject it
143
+ * for a missing protocol version, which is exactly what the same page forbids:
144
+ * "Receivers that do not declare the task capability for a request type
145
+ * **MUST** process requests of that type normally, ignoring any
146
+ * task-augmentation metadata if present." This server declares no `tasks`
147
+ * capability, so that MUST binds it, and enumerating the four modern members is
148
+ * what keeps it honoured.
149
+ *
150
+ * Keying on the set rather than on `protocolVersion` alone is still what stops
151
+ * a modern request that omitted a required field from being quietly served as a
152
+ * legacy one — a request carrying `clientCapabilities` and no version is
153
+ * unmistakably modern and gets told so.
154
+ *
155
+ * WHY `logLevel` BELONGS HERE. The other three are era-exclusive by
156
+ * construction — no handshake revision has per-request protocol fields at all —
157
+ * but `logLevel` is a member a reader could plausibly imagine predating the
158
+ * rewrite, so the premise is named rather than assumed. It does not: through
159
+ * 2025-11-25 the log level travels as the `logging/setLevel` *method*, and that
160
+ * revision's `_meta` section defines only the key-name grammar and the reserved
161
+ * prefix, naming no protocol-field keys whatever. 2026-07-28 is the revision
162
+ * that moved it: "Log level is now set per-request via
163
+ * `io.modelcontextprotocol/logLevel` in `_meta`."
164
+ */
165
+ export const META_STATELESS_KEYS: readonly string[] = Object.freeze([
166
+ META_PROTOCOL_VERSION,
167
+ META_CLIENT_INFO,
168
+ META_CLIENT_CAPABILITIES,
169
+ META_LOG_LEVEL,
170
+ ]);
171
+
172
+ /** RFC 5424 severities, as the logging utility enumerates them. */
173
+ export const LOG_LEVELS: readonly string[] = Object.freeze([
174
+ "debug",
175
+ "info",
176
+ "notice",
177
+ "warning",
178
+ "error",
179
+ "critical",
180
+ "alert",
181
+ "emergency",
182
+ ]);
183
+
184
+ /**
185
+ * The caching hints on every `CacheableResult` this server returns.
186
+ *
187
+ * `"public"` is the honest scope: the advertised surface is two fixed tools,
188
+ * identical for every caller, carrying nothing user-specific — the spec's own
189
+ * example of when public is appropriate. The TTL is an hour because the list is
190
+ * compiled into the binary and cannot change while the process runs; it is a
191
+ * freshness hint, and this one is generous because there is nothing to go
192
+ * stale.
193
+ */
194
+ export const TOOL_LIST_TTL_MS = 3_600_000;
195
+ export const TOOL_LIST_CACHE_SCOPE = "public" as const;
196
+
197
+ /** What `server/discover` and the handshake both report. */
198
+ export const MCP_SERVER_CAPABILITIES = { tools: { listChanged: false } } as const;
199
+
200
+ const MCP_SOURCE_ID = "delivery-harness.mcp";
201
+
202
+ // ── What a tool call may reach ───────────────────────────────────────────────
203
+
204
+ /** Everything the host provides. No streams: this module never prints. */
205
+ export interface ToolHostRuntime {
206
+ readonly cwd: string;
207
+ readonly env: EnvSnapshot;
208
+ readonly loadConfig?: (rootDir: string) => Promise<HarnessConfig>;
209
+ readonly artifacts?: ArtifactsPort;
210
+ readonly liveResults?: readonly LiveProviderResult[];
211
+ }
212
+
213
+ /**
214
+ * One tool call, classified the way the CLI classifies an invocation.
215
+ *
216
+ * `exitCode` is carried verbatim rather than translated, because the CLI's
217
+ * three classes are the contract: 0 passed, 2 could not be parsed, anything
218
+ * else is a policy failure. `outcome` is that same classification named, so an
219
+ * agent reading structured content does not have to know shell conventions.
220
+ */
221
+ export interface ToolOutcome {
222
+ readonly outcome: "ok" | "blocked" | "usage";
223
+ readonly exitCode: number;
224
+ readonly text: string;
225
+ readonly blockers: SerializedBlockers;
226
+ }
227
+
228
+ export interface ToolListing {
229
+ readonly name: string;
230
+ readonly description: string;
231
+ readonly inputSchema: Record<string, unknown>;
232
+ }
233
+
234
+ export interface McpToolResult {
235
+ readonly content: readonly { readonly type: "text"; readonly text: string }[];
236
+ readonly isError: boolean;
237
+ readonly structuredContent: {
238
+ readonly outcome: ToolOutcome["outcome"];
239
+ readonly exitCode: number;
240
+ readonly blockers: SerializedBlockers;
241
+ };
242
+ }
243
+
244
+ /** Either the argv the boundary runs, or the typed usage failure to report. */
245
+ type ArgumentTranslation = { readonly ok: true; readonly argv: readonly string[] } | { readonly ok: false; readonly blocker: Blocker };
246
+
247
+ interface ToolDefinition {
248
+ readonly name: string;
249
+ readonly description: string;
250
+ readonly inputSchema: Record<string, unknown>;
251
+ readonly command: CommandDescriptor;
252
+ translate(args: Record<string, unknown>): ArgumentTranslation;
253
+ }
254
+
255
+ // ── Argument translation ─────────────────────────────────────────────────────
256
+
257
+ function usageBlocker(code: string, summary: string, details: string, remediationSummary: string): Blocker {
258
+ return commandBlocker({
259
+ code,
260
+ sourceId: MCP_SOURCE_ID,
261
+ summary,
262
+ details,
263
+ remediations: [{ id: "correct-the-tool-call", kind: "manual_action", summary: remediationSummary }],
264
+ });
265
+ }
266
+
267
+ const TOOL_NAMES_SENTENCE = (): string => TOOLS.map((tool) => tool.name).join(", ");
268
+
269
+ /**
270
+ * The managed-delivery operations this server will translate, and the whole
271
+ * set of them.
272
+ *
273
+ * WHY ONLY THESE. The facade's boundary is that a tool surface inspects a
274
+ * delivery rather than orchestrating it: the product owns whether a checkpoint
275
+ * is valid and what evidence suffices, and the host owns how it delegates and
276
+ * sequences the work. A tool that could submit a stage result or run a sensor
277
+ * would be a second orchestrator sitting beside the host — so the CLI's control
278
+ * operations are simply not reachable from here, and the contract-inventory
279
+ * sensor checks that every name below is a `read`-class operation in the
280
+ * facade's own inventory.
281
+ */
282
+ export const MANAGED_READ_OPERATIONS: readonly string[] = Object.freeze([
283
+ "status",
284
+ "deliveries",
285
+ "next",
286
+ "explain-blocker",
287
+ "blockers",
288
+ "operations",
289
+ ]);
290
+
291
+ /**
292
+ * Rejects members the tool does not define, for the same reason the manifest
293
+ * validator rejects them: a tolerated stranger is how a caller comes to believe
294
+ * it configured something. Unlike a manifest, the offending name is caller
295
+ * text — it reaches an agent's screen through the renderer, never raw.
296
+ */
297
+ function rejectUnknownMembers(tool: ToolDefinition, args: Record<string, unknown>, defined: readonly string[]): Blocker | null {
298
+ const unknown = Object.keys(args).filter((name) => !defined.includes(name));
299
+ if (unknown.length === 0) return null;
300
+ return usageBlocker(
301
+ "unknown_tool_argument",
302
+ `The ${tool.name} tool does not define every argument this call supplied.`,
303
+ `Undefined argument(s): ${unknown.join(", ")}. Defined: ${defined.length === 0 ? "(none)" : defined.join(", ")}.`,
304
+ `Call ${tool.name} with only the arguments its input schema declares.`,
305
+ );
306
+ }
307
+
308
+ /**
309
+ * The tools, named exactly as the CLI names its commands.
310
+ *
311
+ * That is the naming decision this unit settles: one vocabulary for an operator
312
+ * reading a terminal and an agent reading a tool list, so guidance that names
313
+ * `submit-evidence` is executable on both surfaces without translation.
314
+ */
315
+ const TOOLS: readonly ToolDefinition[] = [
316
+ {
317
+ name: reviewContextCommand.name,
318
+ description:
319
+ "Show the reviewable-change context for the prepared candidate: its tree, the relevant-line count, whether the gate's obligations activate, and how to submit evidence. Requires a current preparation receipt.",
320
+ inputSchema: { type: "object", properties: {}, required: [], additionalProperties: false },
321
+ command: reviewContextCommand,
322
+ translate(args) {
323
+ const unknown = rejectUnknownMembers(this, args, []);
324
+ if (unknown !== null) return { ok: false, blocker: unknown };
325
+ return { ok: true, argv: [reviewContextCommand.name] };
326
+ },
327
+ },
328
+ {
329
+ name: submitEvidenceCommand.name,
330
+ description:
331
+ "Validate a provider evidence manifest against delivery-evidence/1 and publish its per-claim records. Rejections carry the violated rule's code; nothing is published unless the whole submission is accepted.",
332
+ inputSchema: {
333
+ type: "object",
334
+ properties: {
335
+ manifest: { type: "string", description: "Path to the manifest.json to submit, inside its allocated run root." },
336
+ },
337
+ required: ["manifest"],
338
+ additionalProperties: false,
339
+ },
340
+ command: submitEvidenceCommand,
341
+ translate(args) {
342
+ const unknown = rejectUnknownMembers(this, args, ["manifest"]);
343
+ if (unknown !== null) return { ok: false, blocker: unknown };
344
+ const manifest = args["manifest"];
345
+ // Absent is deliberately *not* rejected here. The schema says the member
346
+ // is required, and the command says so too — in its own words, with its
347
+ // own exit-2 class. Delegating keeps one sentence for both surfaces
348
+ // instead of two that drift.
349
+ if (manifest === undefined) return { ok: true, argv: [submitEvidenceCommand.name] };
350
+ if (typeof manifest !== "string") {
351
+ return {
352
+ ok: false,
353
+ blocker: usageBlocker(
354
+ "invalid_tool_argument",
355
+ "The submit-evidence tool's manifest argument must be a filesystem path.",
356
+ `manifest was ${manifest === null ? "null" : typeof manifest}, not a string.`,
357
+ "Call submit-evidence with manifest set to the path of the manifest.json to submit.",
358
+ ),
359
+ };
360
+ }
361
+ return { ok: true, argv: [submitEvidenceCommand.name, "--manifest", manifest] };
362
+ },
363
+ },
364
+ {
365
+ name: managedCommand.name,
366
+ description:
367
+ "Inspect the managed delivery: its typed status model, the next valid checkpoint, the current blocker and its remediation, the blocker inventory, this installation's registered deliveries, and the facade's own operation contract. Read-only.",
368
+ inputSchema: {
369
+ type: "object",
370
+ properties: {
371
+ operation: {
372
+ type: "string",
373
+ enum: [...MANAGED_READ_OPERATIONS],
374
+ description: "Which read-only projection to return.",
375
+ },
376
+ },
377
+ required: ["operation"],
378
+ additionalProperties: false,
379
+ },
380
+ command: managedCommand,
381
+ translate(args) {
382
+ const unknown = rejectUnknownMembers(this, args, ["operation"]);
383
+ if (unknown !== null) return { ok: false, blocker: unknown };
384
+ const operation = args["operation"];
385
+ if (operation === undefined) return { ok: true, argv: [managedCommand.name] };
386
+ if (typeof operation !== "string") {
387
+ return {
388
+ ok: false,
389
+ blocker: usageBlocker(
390
+ "invalid_tool_argument",
391
+ "The managed tool's operation argument must be a string.",
392
+ `operation was ${operation === null ? "null" : typeof operation}, not a string.`,
393
+ `Call managed with operation set to one of: ${MANAGED_READ_OPERATIONS.join(", ")}.`,
394
+ ),
395
+ };
396
+ }
397
+ // The enum is enforced here rather than delegated, because delegating it
398
+ // would hand a control operation straight to the command. What the CLI
399
+ // offers an operator at a terminal and what this server offers a model
400
+ // are deliberately different sets, and this is where that difference is
401
+ // mechanical rather than documentary.
402
+ if (!MANAGED_READ_OPERATIONS.includes(operation)) {
403
+ return {
404
+ ok: false,
405
+ blocker: usageBlocker(
406
+ "operation_not_offered",
407
+ "This server offers the managed delivery's read-only projections only.",
408
+ `Requested operation: ${operation}. Offered: ${MANAGED_READ_OPERATIONS.join(", ")}.`,
409
+ "Drive checkpoints from the bound worktree's CLI; this tool inspects the delivery, it does not orchestrate it.",
410
+ ),
411
+ };
412
+ }
413
+ return { ok: true, argv: [managedCommand.name, operation] };
414
+ },
415
+ },
416
+ ];
417
+
418
+ // ── The advertised surface ───────────────────────────────────────────────────
419
+
420
+ export function listTools(): readonly ToolListing[] {
421
+ return TOOLS.map((tool) => ({ name: tool.name, description: tool.description, inputSchema: tool.inputSchema }));
422
+ }
423
+
424
+ // ── Running one call through the command core ────────────────────────────────
425
+
426
+ const EMPTY_BLOCKERS: readonly Blocker[] = Object.freeze([]);
427
+
428
+ function classify(exitCode: number): ToolOutcome["outcome"] {
429
+ if (exitCode === EXIT_OK) return "ok";
430
+ if (exitCode === EXIT_USAGE) return "usage";
431
+ return "blocked";
432
+ }
433
+
434
+ function outcomeFor(exitCode: number, text: string, blockers: readonly Blocker[]): ToolOutcome {
435
+ return { outcome: classify(exitCode), exitCode, text, blockers: serializeBlockers(blockers) };
436
+ }
437
+
438
+ /** A usage failure this module raised itself, rendered like every other one. */
439
+ function usageOutcome(blocker: Blocker): ToolOutcome {
440
+ return outcomeFor(EXIT_USAGE, renderBlockers([blocker]), [blocker]);
441
+ }
442
+
443
+ /**
444
+ * The structured half of a failure the boundary is about to render as text.
445
+ *
446
+ * The boundary reports failures on a stream; it does not hand back the typed
447
+ * blockers behind them, and an agent context window needs the structured form.
448
+ * Rather than re-deciding anything, this observes the same values on their way
449
+ * past: a `BlockedError` carries its blockers, and an unexpected throw is
450
+ * described with the very constructor the boundary uses, so the code and
451
+ * summary an agent parses are the ones an operator would have read.
452
+ */
453
+ function blockersFromThrow(error: unknown, descriptor: CommandDescriptor): readonly Blocker[] {
454
+ if (error instanceof CliInterruption) return EMPTY_BLOCKERS;
455
+ if (error instanceof BlockedError) return error.blockers;
456
+ return [
457
+ createInternalErrorBlocker({
458
+ source: { kind: "command", id: descriptor.sourceId },
459
+ error,
460
+ reproduce: ["delivery-harness", descriptor.name],
461
+ }),
462
+ ];
463
+ }
464
+
465
+ async function throughCommandCore(tool: ToolDefinition, argv: readonly string[], host: ToolHostRuntime): Promise<ToolOutcome> {
466
+ const out: string[] = [];
467
+ const err: string[] = [];
468
+ let observed: readonly Blocker[] = EMPTY_BLOCKERS;
469
+
470
+ // The command, wrapped only to observe. It runs the real `run`, returns the
471
+ // real result, and rethrows the real error: the boundary sees exactly what it
472
+ // would have seen with the unwrapped descriptor.
473
+ const observedCommand: CommandDescriptor = {
474
+ ...tool.command,
475
+ async run(context) {
476
+ try {
477
+ const result = await tool.command.run(context);
478
+ if (result.kind === "blocked") observed = result.blockers;
479
+ return result;
480
+ } catch (error) {
481
+ observed = blockersFromThrow(error, tool.command);
482
+ throw error;
483
+ }
484
+ },
485
+ };
486
+
487
+ const loadConfig = host.loadConfig ?? importHarnessConfig;
488
+ const runtime: CliRuntime = {
489
+ cwd: host.cwd,
490
+ env: host.env,
491
+ // No TTY on either side: stdin and stdout are the transport, and a waiver
492
+ // prompt would have nobody to answer it.
493
+ stdinIsTTY: false,
494
+ stdoutIsTTY: false,
495
+ stdout: (text) => out.push(text),
496
+ stderr: (text) => err.push(text),
497
+ loadConfig: async (rootDir) => {
498
+ try {
499
+ return await loadConfig(rootDir);
500
+ } catch (error) {
501
+ // A config that will not load blocks before the command runs, so the
502
+ // wrapper above never sees it.
503
+ observed = blockersFromThrow(error, tool.command);
504
+ throw error;
505
+ }
506
+ },
507
+ ...(host.artifacts === undefined ? {} : { artifacts: host.artifacts }),
508
+ ...(host.liveResults === undefined ? {} : { liveResults: host.liveResults }),
509
+ };
510
+
511
+ const exitCode = await runCliBoundary(argv, [observedCommand], runtime);
512
+ return outcomeFor(exitCode, `${out.join("")}${err.join("")}`.trimEnd(), observed);
513
+ }
514
+
515
+ /**
516
+ * Runs one tool call to a classified outcome. Total, like the boundary it wraps:
517
+ * an unknown tool, unusable arguments, a policy block and an internal error all
518
+ * come back as an outcome rather than a throw.
519
+ */
520
+ export async function callTool(name: string, args: unknown, host: ToolHostRuntime): Promise<ToolOutcome> {
521
+ const tool = TOOLS.find((candidate) => candidate.name === name);
522
+ if (tool === undefined) {
523
+ return usageOutcome(
524
+ usageBlocker(
525
+ "unknown_tool",
526
+ "This server does not offer the tool this call named.",
527
+ `Requested tool: ${name}. Offered: ${TOOL_NAMES_SENTENCE()}.`,
528
+ `Call one of the tools this server advertises: ${TOOL_NAMES_SENTENCE()}.`,
529
+ ),
530
+ );
531
+ }
532
+
533
+ // Absent arguments are an empty object (the spec's own default); anything
534
+ // else that is not an object is a usage failure rather than a coerced guess.
535
+ const supplied = args === undefined || args === null ? {} : args;
536
+ if (typeof supplied !== "object" || Array.isArray(supplied)) {
537
+ return usageOutcome(
538
+ usageBlocker(
539
+ "invalid_tool_arguments",
540
+ `The ${tool.name} tool's arguments must be an object.`,
541
+ `Arguments were ${Array.isArray(supplied) ? "an array" : typeof supplied}.`,
542
+ `Call ${tool.name} with an arguments object matching its input schema.`,
543
+ ),
544
+ );
545
+ }
546
+
547
+ const translation = tool.translate(supplied as Record<string, unknown>);
548
+ if (!translation.ok) return usageOutcome(translation.blocker);
549
+ return throughCommandCore(tool, translation.argv, host);
550
+ }
551
+
552
+ // ── The MCP shape ────────────────────────────────────────────────────────────
553
+
554
+ /**
555
+ * Shapes an outcome as an MCP tool result.
556
+ *
557
+ * One text block, and it is the renderer's output — an agent's context window
558
+ * is a rendering surface, so the bytes that reach it are the neutralized ones,
559
+ * not a raw provider string re-serialized on the way out. `structuredContent`
560
+ * is the renderer's other face (`serializeBlockers`), which neutralizes the
561
+ * same way. No `outputSchema` is declared: declaring one obliges a server to
562
+ * repeat the structured content as JSON text for older clients, and a second
563
+ * copy of every blocker in an agent's context buys nothing this text block does
564
+ * not already say.
565
+ *
566
+ * `isError` is true for both failure classes. A tool result is how MCP reports
567
+ * a call that ran and failed, and a policy block is exactly that — the protocol
568
+ * error channel is for calls that could not run at all.
569
+ */
570
+ export function toolResultFor(outcome: ToolOutcome): McpToolResult {
571
+ return {
572
+ content: [{ type: "text", text: outcome.text }],
573
+ isError: outcome.outcome !== "ok",
574
+ structuredContent: { outcome: outcome.outcome, exitCode: outcome.exitCode, blockers: outcome.blockers },
575
+ };
576
+ }
package/src/stdio.ts ADDED
@@ -0,0 +1,535 @@
1
+ /**
2
+ * The stdio transport: newline-delimited JSON-RPC 2.0 over stdin and stdout.
3
+ *
4
+ * WHY HAND-ROLLED. MCP's stdio transport is one line of JSON per message, and
5
+ * the methods a tools-only server answers are `initialize`, `server/discover`,
6
+ * `tools/list`, `tools/call` and `ping`. That is the whole surface, and it is
7
+ * written below without a dependency — which matters in a repo that declined an
8
+ * npm canonicalizer on supply-chain grounds and has carried zero runtime
9
+ * dependencies since. The SDK would own framing this file states in a page,
10
+ * and would own it behind a transitive tree nobody here reviews.
11
+ *
12
+ * TWO ERAS, ONE PROCESS. MCP 2026-07-28 removed the `initialize` handshake:
13
+ * every request declares its own protocol version and client capabilities in
14
+ * `_meta`, results carry a `resultType`, list results carry caching hints,
15
+ * `server/discover` is mandatory, and `ping` is gone. The three revisions
16
+ * before it still negotiate through `initialize` and still have `ping`. This
17
+ * server serves both, and the spec says how: "A dual-era server selects its
18
+ * behavior from how the client opens ... A request carrying modern per-request
19
+ * `_meta` is served statelessly according to this revision. An `initialize`
20
+ * request selects legacy semantics." The branch is per request and reads
21
+ * nothing remembered, because the newer revision forbids inferring context from
22
+ * an earlier request on the same connection. Nothing from the newer revision
23
+ * appears in an older revision's result: a client that negotiated 2024-11-05
24
+ * reads the bytes it read before 2026-07-28 existed.
25
+ *
26
+ * THE TWO ERROR CHANNELS ARE NOT INTERCHANGEABLE. A JSON-RPC error means the
27
+ * call could not run: unparseable bytes, a method this server does not
28
+ * implement, params that are not even shaped like params. A *tool result* with
29
+ * `isError` means the call ran and the harness said no — a policy block, a
30
+ * usage failure, an unknown tool. Reporting a policy block as a protocol error
31
+ * would strip the blockers, the remediations, and the outcome class an agent
32
+ * acts on; reporting a protocol fault as a tool result would tell a client its
33
+ * malformed request succeeded in reaching a tool. Each stays on its own channel.
34
+ *
35
+ * STDOUT IS THE WIRE. Nothing here ever prints diagnostics to stdout — a stray
36
+ * line would be a protocol violation, not a log. Diagnostics go to stderr.
37
+ *
38
+ * WHICH REVISIONS ARE ADVERTISED, AND WHY THE MIDDLE ONE IS NOT. MCP added
39
+ * JSON-RPC batching in 2025-03-26 — a server speaking that revision must accept
40
+ * an array of messages — and removed it again in 2025-06-18. This transport
41
+ * refuses arrays outright, so 2025-03-26 is deliberately absent from the
42
+ * advertised list: a client asking for it is answered with a revision where
43
+ * the refusal is the truth. 2024-11-05 predates batching entirely and carries
44
+ * no such requirement, so it stays. Advertising a revision whose requirements
45
+ * this file does not meet would be the one protocol lie that costs a client its
46
+ * ability to reason about the connection at all.
47
+ */
48
+ import { createInterface } from "node:readline";
49
+ import { invokedDirectly } from "@agent-delivery-harness/kernel";
50
+ import {
51
+ HANDSHAKE_PROTOCOL_VERSIONS,
52
+ LOG_LEVELS,
53
+ MCP_PROTOCOL_VERSION,
54
+ MCP_SERVER_CAPABILITIES,
55
+ MCP_SERVER_INFO,
56
+ META_CLIENT_CAPABILITIES,
57
+ META_LOG_LEVEL,
58
+ META_PROTOCOL_VERSION,
59
+ META_SERVER_INFO,
60
+ META_STATELESS_KEYS,
61
+ STATELESS_PROTOCOL_VERSIONS,
62
+ TOOL_LIST_CACHE_SCOPE,
63
+ TOOL_LIST_TTL_MS,
64
+ callTool,
65
+ listTools,
66
+ toolResultFor,
67
+ type ToolHostRuntime,
68
+ } from "./server.ts";
69
+
70
+ // ── JSON-RPC ─────────────────────────────────────────────────────────────────
71
+
72
+ export const JSON_RPC_VERSION = "2.0";
73
+
74
+ export const PARSE_ERROR = -32700;
75
+ export const INVALID_REQUEST = -32600;
76
+ export const METHOD_NOT_FOUND = -32601;
77
+ export const INVALID_PARAMS = -32602;
78
+
79
+ /**
80
+ * `UnsupportedProtocolVersionError`, at the code 2026-07-28 renumbered it to.
81
+ *
82
+ * It is -32022, not the -32004 the draft used. That revision partitioned the
83
+ * JSON-RPC implementation-defined range: `-32000` to `-32019` is grandfathered
84
+ * and "new implementations **SHOULD NOT** use codes from this sub-range at
85
+ * all", while `-32020` to `-32099` is "reserved for the MCP specification" and
86
+ * implementations "**MUST NOT** emit any code from this sub-range that is not
87
+ * defined by this specification". This is the only code from that range this
88
+ * server emits, and it is emitted with the specified meaning.
89
+ */
90
+ export const UNSUPPORTED_PROTOCOL_VERSION = -32022;
91
+
92
+ export type JsonRpcId = string | number | null;
93
+
94
+ export interface JsonRpcResponse {
95
+ readonly jsonrpc: typeof JSON_RPC_VERSION;
96
+ readonly id: JsonRpcId;
97
+ readonly result?: unknown;
98
+ readonly error?: { readonly code: number; readonly message: string; readonly data?: unknown };
99
+ }
100
+
101
+ function ok(id: JsonRpcId, result: unknown): JsonRpcResponse {
102
+ return { jsonrpc: JSON_RPC_VERSION, id, result };
103
+ }
104
+
105
+ function fail(id: JsonRpcId, code: number, message: string, data?: unknown): JsonRpcResponse {
106
+ return { jsonrpc: JSON_RPC_VERSION, id, error: { code, message, ...(data === undefined ? {} : { data }) } };
107
+ }
108
+
109
+ /**
110
+ * Session state the protocol carries; the tools themselves are stateless.
111
+ *
112
+ * NO HANDSHAKE GATE, AND THEREFORE NO `initialized` FLAG. Refusing `tools/list`
113
+ * or `tools/call` until the handshake completed was the alternative, and it was
114
+ * declined on two grounds. The spec's ordering rule is a SHOULD addressed to
115
+ * the *client*, so a hard server-side gate refuses a pipelining client the spec
116
+ * allows to be answered, and it would need a rejection code the protocol does
117
+ * not define for the case. And a flag written on every handshake but read by no
118
+ * decision is state that can only go stale — the kind a later reader trusts
119
+ * without noticing nothing maintains it. `protocolVersion` stays because it is
120
+ * the negotiated result the connection is bound to and the `initialize`
121
+ * response is built from it.
122
+ */
123
+ export interface McpSession {
124
+ protocolVersion: string;
125
+ }
126
+
127
+ export function createSession(): McpSession {
128
+ return { protocolVersion: MCP_PROTOCOL_VERSION };
129
+ }
130
+
131
+ function isRecord(value: unknown): value is Record<string, unknown> {
132
+ return typeof value === "object" && value !== null && !Array.isArray(value);
133
+ }
134
+
135
+ /** The three id shapes JSON-RPC 2.0 permits. Anything else is not an id. */
136
+ function isUsableId(id: unknown): id is JsonRpcId {
137
+ return typeof id === "string" || typeof id === "number" || id === null;
138
+ }
139
+
140
+ function idOf(message: Record<string, unknown>): JsonRpcId {
141
+ const id = message["id"];
142
+ return isUsableId(id) ? id : null;
143
+ }
144
+
145
+ /**
146
+ * A notification is a message with no `id` *member* — not a message whose id
147
+ * happens to be null. The distinction matters: answering an explicit null id
148
+ * with silence would hang a client that is waiting for a response it is
149
+ * entitled to.
150
+ */
151
+ function isNotification(message: Record<string, unknown>): boolean {
152
+ return !Object.prototype.hasOwnProperty.call(message, "id");
153
+ }
154
+
155
+ /**
156
+ * The handshake's version selection, drawn only from the revisions that *have*
157
+ * a handshake.
158
+ *
159
+ * A client naming 2026-07-28 in `initialize` is answered with 2025-11-25 rather
160
+ * than its own request. Echoing it would be this server promising a session
161
+ * under the revision that abolished sessions — the client would then omit the
162
+ * per-request `_meta` that revision requires and this server would read every
163
+ * subsequent request as handshake-era. That is the one place backward
164
+ * compatibility costs something: a stateless-capable client that opens with
165
+ * `initialize` is served an older revision instead. The spec's own remedy is
166
+ * the probe, and `server/discover` is implemented for exactly that.
167
+ */
168
+ function negotiate(requested: unknown): string {
169
+ return typeof requested === "string" && HANDSHAKE_PROTOCOL_VERSIONS.includes(requested) ? requested : MCP_PROTOCOL_VERSION;
170
+ }
171
+
172
+ // ── The stateless revision ───────────────────────────────────────────────────
173
+
174
+ /**
175
+ * Whether a request carries stateless per-request metadata.
176
+ *
177
+ * The era selector is the spec's: "A dual-era server selects its behavior from
178
+ * how the client opens ... A request carrying modern per-request `_meta` is
179
+ * served statelessly according to this revision. An `initialize` request
180
+ * selects legacy semantics." The test is the presence of any of the four
181
+ * per-request protocol fields that revision defines, not of the protocol
182
+ * version alone — a request that carries `clientCapabilities` and forgot the
183
+ * version is still unmistakably a stateless request, and it must be told so
184
+ * with a -32602 rather than handed a legacy-shaped success for a revision it
185
+ * never asked for. It is deliberately *not* a test on the
186
+ * `io.modelcontextprotocol/` prefix, which a handshake-era task-augmented
187
+ * request may also carry; see `META_STATELESS_KEYS`. Whatever is or is not
188
+ * there, the answer comes from this request only: "Servers **MUST NOT** rely on
189
+ * prior requests over the same connection to establish context".
190
+ */
191
+ function isStatelessRequest(params: unknown): boolean {
192
+ if (!isRecord(params)) return false;
193
+ const meta = params["_meta"];
194
+ if (!isRecord(meta)) return false;
195
+ return META_STATELESS_KEYS.some((key) => Object.prototype.hasOwnProperty.call(meta, key));
196
+ }
197
+
198
+ function metaOf(params: unknown): Record<string, unknown> {
199
+ const meta = isRecord(params) ? params["_meta"] : undefined;
200
+ return isRecord(meta) ? meta : {};
201
+ }
202
+
203
+ /** Every stateless result carries the server's identity and its type. */
204
+ function statelessResult(fields: Record<string, unknown>): Record<string, unknown> {
205
+ return { resultType: "complete", ...fields, _meta: { [META_SERVER_INFO]: MCP_SERVER_INFO } };
206
+ }
207
+
208
+ /** The two hints a `CacheableResult` must carry. */
209
+ const CACHE_HINTS = { ttlMs: TOOL_LIST_TTL_MS, cacheScope: TOOL_LIST_CACHE_SCOPE } as const;
210
+
211
+ /**
212
+ * Answers one request that declared a protocol version in `_meta`.
213
+ *
214
+ * WHAT IS NOT HERE. `ping`, `logging/setLevel` and
215
+ * `notifications/roots/list_changed` were removed by this revision, and
216
+ * `initialize` with it — none of them is dispatched below, so each falls to
217
+ * "method not found", which is the truth under 2026-07-28. `ping` remains
218
+ * answerable through the handshake path, where the revisions that define it
219
+ * still live.
220
+ *
221
+ * WHAT THIS SERVER STILL DOES NOT EMIT. No `notifications/message`, on any
222
+ * revision. The revision that moved log level onto `_meta` deprecated the
223
+ * Logging feature in the same release — "New implementations **SHOULD NOT**
224
+ * adopt it; existing implementations **SHOULD** migrate to logging to `stderr`
225
+ * for stdio transports" — and this server already logs to stderr. So the
226
+ * requirement that a server "**MUST NOT** emit `notifications/message` for a
227
+ * request that does not include this field" holds by construction rather than
228
+ * by a check, and a request that *does* set the field is answered with its
229
+ * response and nothing else. An unrecognized level is still rejected: the
230
+ * logging page asks for `-32602` there, and a value this server cannot name is
231
+ * not a request it can claim to have understood.
232
+ *
233
+ * WHAT IS DELIBERATELY ABSENT. No `resultType: "input_required"`. That belongs
234
+ * to the multi round-trip pattern, which replaces server-initiated requests —
235
+ * and this server initiates none: it has no sampling, no elicitation, no roots,
236
+ * and no waiver prompt an agent could be asked to answer. Likewise no
237
+ * `subscriptions/listen` (nothing here changes while the process runs) and no
238
+ * tasks extension (advertising an extension is a promise, and there is no
239
+ * durable request behind it).
240
+ */
241
+ async function handleStateless(
242
+ method: string,
243
+ id: JsonRpcId,
244
+ params: unknown,
245
+ requested: unknown,
246
+ host: ToolHostRuntime,
247
+ ): Promise<JsonRpcResponse> {
248
+ // A version that is absent, or present but not a string, is a malformed
249
+ // request rather than an unsupported revision: -32022 means "this version is
250
+ // one I do not implement", and a missing member names no version at all. Both
251
+ // fall under the same rule as any other required field — "A request missing
252
+ // any required field is malformed; the server MUST reject it with JSON-RPC
253
+ // error code -32602".
254
+ if (typeof requested !== "string") {
255
+ return fail(id, INVALID_PARAMS, `A stateless request must carry ${META_PROTOCOL_VERSION} in _meta as a protocol version string.`);
256
+ }
257
+
258
+ if (!STATELESS_PROTOCOL_VERSIONS.includes(requested)) {
259
+ // "the server ... MUST respond with an UnsupportedProtocolVersionError
260
+ // listing the versions it does support". `supported` names what *this*
261
+ // channel can serve, not everything this server speaks: the client's rule
262
+ // is to "select a mutually supported version from the `supported` list and
263
+ // retry the request", and a handshake revision offered here would send it
264
+ // round the same loop, since those revisions have no per-request form. The
265
+ // full list — both eras — is what `server/discover` is for.
266
+ return fail(id, UNSUPPORTED_PROTOCOL_VERSION, "Unsupported protocol version", {
267
+ supported: STATELESS_PROTOCOL_VERSIONS,
268
+ requested,
269
+ });
270
+ }
271
+
272
+ const meta = metaOf(params);
273
+
274
+ // "A request missing any required field is malformed; the server MUST reject
275
+ // it with JSON-RPC error code -32602 (Invalid params)." Capabilities are
276
+ // required; client info is not, and its absence is not a fault.
277
+ if (!isRecord(meta[META_CLIENT_CAPABILITIES])) {
278
+ return fail(id, INVALID_PARAMS, `A request under ${requested} must carry ${META_CLIENT_CAPABILITIES} in _meta.`);
279
+ }
280
+
281
+ const logLevel = meta[META_LOG_LEVEL];
282
+ if (logLevel !== undefined && (typeof logLevel !== "string" || !LOG_LEVELS.includes(logLevel))) {
283
+ return fail(id, INVALID_PARAMS, `${META_LOG_LEVEL} must be one of: ${LOG_LEVELS.join(", ")}.`);
284
+ }
285
+
286
+ if (method === "server/discover") {
287
+ return ok(
288
+ id,
289
+ statelessResult({
290
+ supportedVersions: STATELESS_PROTOCOL_VERSIONS,
291
+ capabilities: MCP_SERVER_CAPABILITIES,
292
+ ...CACHE_HINTS,
293
+ }),
294
+ );
295
+ }
296
+
297
+ if (method === "tools/list") return ok(id, statelessResult({ tools: listTools(), ...CACHE_HINTS }));
298
+
299
+ if (method === "tools/call") {
300
+ if (!isRecord(params)) return fail(id, INVALID_PARAMS, "tools/call requires a params object.");
301
+ const name = params["name"];
302
+ if (typeof name !== "string") return fail(id, INVALID_PARAMS, "tools/call requires a string name.");
303
+ const outcome = await callTool(name, params["arguments"], host);
304
+ return ok(id, statelessResult({ ...toolResultFor(outcome) }));
305
+ }
306
+
307
+ return fail(id, METHOD_NOT_FOUND, `Method ${JSON.stringify(method)} is not implemented by this server under ${requested}.`);
308
+ }
309
+
310
+ /**
311
+ * Answers one decoded message. Returns `null` for a notification — the one
312
+ * shape the protocol forbids a response to.
313
+ */
314
+ export async function handleRpcMessage(message: unknown, host: ToolHostRuntime, session: McpSession): Promise<JsonRpcResponse | null> {
315
+ if (Array.isArray(message)) {
316
+ // No revision this server advertises supports batching, and answering a
317
+ // batch anyway would leave a client believing it negotiated something it
318
+ // did not.
319
+ return fail(null, INVALID_REQUEST, "JSON-RPC batches are not part of the protocol revisions this server speaks.");
320
+ }
321
+ if (!isRecord(message)) return fail(null, INVALID_REQUEST, "A JSON-RPC message must be an object.");
322
+ if (message["jsonrpc"] !== JSON_RPC_VERSION) return fail(idOf(message), INVALID_REQUEST, 'A JSON-RPC message must declare jsonrpc "2.0".');
323
+
324
+ // ID HYGIENE, BEFORE ANY DISPATCH. An id of an unusable shape cannot be
325
+ // echoed, so the client could never correlate the result — and `tools/call`
326
+ // is side-effecting. Coercing such an id to null and running the call anyway
327
+ // would publish evidence on behalf of a request nobody can match a response
328
+ // to. An absent id member is a notification and is not an id at all.
329
+ if (!isNotification(message) && !isUsableId(message["id"])) {
330
+ return fail(null, INVALID_REQUEST, "A JSON-RPC id must be a string, a number, or null.");
331
+ }
332
+
333
+ const method = message["method"];
334
+ if (typeof method !== "string") return fail(idOf(message), INVALID_REQUEST, "A JSON-RPC message must name a string method.");
335
+
336
+ const id = idOf(message);
337
+ const params = message["params"];
338
+
339
+ // SHAPE DECIDES, NOT THE METHOD NAME. Notifications — `initialized`,
340
+ // cancellation, progress — produce no response in this dispatcher. The stdio
341
+ // loop intercepts a well-formed cancellation first so it can suppress active
342
+ // responses and queued work; other transports may provide their own request
343
+ // lifecycle. A message carrying an id is a request whatever it is named, so
344
+ // an id-bearing `notifications/initialized` falls through to normal dispatch and is
345
+ // answered there. Special-casing the name above this check is what used to
346
+ // swallow it and leave the client waiting.
347
+ if (isNotification(message)) return null;
348
+
349
+ // THE ERA BRANCH, AND THE ONE METHOD THAT DECIDES IT BY NAME. The spec gives
350
+ // a dual-era server two selectors, not one: "A request carrying modern
351
+ // per-request `_meta` is served statelessly according to this revision. An
352
+ // `initialize` request selects legacy semantics." So `initialize` is answered
353
+ // as a handshake first, whatever `_meta` it happens to carry — a client whose
354
+ // transport attaches per-request metadata to everything it sends must still
355
+ // be able to open with the handshake, or the fallback path the spec tells it
356
+ // to take does not exist. Every other method takes the era from the request:
357
+ // a declared protocol version means stateless, its absence means handshake.
358
+ // Either way the decision is made per request and from the request alone — a
359
+ // client may interleave both on one connection, and this server must not
360
+ // infer the era from anything it remembers.
361
+ if (method === "initialize") {
362
+ session.protocolVersion = negotiate(isRecord(params) ? params["protocolVersion"] : undefined);
363
+ return ok(id, {
364
+ protocolVersion: session.protocolVersion,
365
+ capabilities: MCP_SERVER_CAPABILITIES,
366
+ serverInfo: MCP_SERVER_INFO,
367
+ });
368
+ }
369
+
370
+ if (isStatelessRequest(params)) return handleStateless(method, id, params, metaOf(params)[META_PROTOCOL_VERSION], host);
371
+
372
+ if (method === "ping") return ok(id, {});
373
+
374
+ if (method === "tools/list") return ok(id, { tools: listTools() });
375
+
376
+ if (method === "tools/call") {
377
+ if (!isRecord(params)) return fail(id, INVALID_PARAMS, "tools/call requires a params object.");
378
+ const name = params["name"];
379
+ if (typeof name !== "string") {
380
+ // Not "unknown tool": the request never named one, which is a malformed
381
+ // request rather than a call this server declined.
382
+ return fail(id, INVALID_PARAMS, "tools/call requires a string name.");
383
+ }
384
+ const outcome = await callTool(name, params["arguments"], host);
385
+ return ok(id, toolResultFor(outcome));
386
+ }
387
+
388
+ return fail(id, METHOD_NOT_FOUND, `Method ${JSON.stringify(method)} is not implemented by this server.`);
389
+ }
390
+
391
+ /**
392
+ * Decodes one line and answers it. Kept separate from the loop so the framing —
393
+ * blank lines skipped, undecodable lines answered with a parse error rather
394
+ * than closing the connection — is testable without streams.
395
+ */
396
+ export async function handleRpcLine(line: string, host: ToolHostRuntime, session: McpSession): Promise<JsonRpcResponse | null> {
397
+ const decoded = decodeRpcLine(line);
398
+ if (decoded.kind === "blank") return null;
399
+ if (decoded.kind === "parse-error") return decoded.response;
400
+ return handleRpcMessage(decoded.message, host, session);
401
+ }
402
+
403
+ type DecodedRpcLine =
404
+ | { readonly kind: "blank" }
405
+ | { readonly kind: "parse-error"; readonly response: JsonRpcResponse }
406
+ | { readonly kind: "message"; readonly message: unknown };
407
+
408
+ function decodeRpcLine(line: string): DecodedRpcLine {
409
+ const trimmed = line.trim();
410
+ if (trimmed === "") return { kind: "blank" };
411
+ try {
412
+ return { kind: "message", message: JSON.parse(trimmed) as unknown };
413
+ } catch {
414
+ return { kind: "parse-error", response: fail(null, PARSE_ERROR, "The message could not be parsed as JSON.") };
415
+ }
416
+ }
417
+
418
+ /**
419
+ * Serializes a response onto one line.
420
+ *
421
+ * The framing requires that no message contain an embedded newline. Every
422
+ * newline this server produces is inside a rendered blocker string, and
423
+ * `JSON.stringify` escapes those — so the invariant holds by construction
424
+ * rather than by a scrub pass that could be forgotten.
425
+ */
426
+ export function encodeResponse(response: JsonRpcResponse): string {
427
+ return `${JSON.stringify(response)}\n`;
428
+ }
429
+
430
+ // ── The loop ─────────────────────────────────────────────────────────────────
431
+
432
+ /**
433
+ * Serves until the input ends. Ordinary messages are answered in arrival order,
434
+ * while a well-formed `notifications/cancelled` is observed immediately. The
435
+ * split matters: awaiting the active request before reading its cancellation
436
+ * makes suppression impossible by construction.
437
+ *
438
+ * Cancellation is response suppression, not a new tool API. Active work is
439
+ * allowed to settle but its response is discarded; queued work is skipped
440
+ * before it reaches the host. Older revisions say a receiver SHOULD stop
441
+ * processing and suppress the response, while 2026-07-28 strengthens the stdio
442
+ * rule to no further messages for that request. The same suppression satisfies
443
+ * all four revisions this server advertises.
444
+ *
445
+ * Unknown and completed ids are absent from `pending`, so they are ignored.
446
+ * Malformed cancellation notifications never reach this branch and are ignored
447
+ * as ordinary notifications. `initialize` is deliberately non-cancellable, as
448
+ * the protocol requires: a cancellation naming it is ignored whether queued or
449
+ * active. These choices are explicit because accidental notification dropping
450
+ * was the defect this loop previously carried.
451
+ */
452
+ export async function serveStdio(input: NodeJS.ReadableStream, write: (line: string) => void, host: ToolHostRuntime): Promise<void> {
453
+ const session = createSession();
454
+ const lines = createInterface({ input, crlfDelay: Infinity });
455
+ type PendingRequest = { readonly id: JsonRpcId; readonly method: string; state: "queued" | "active"; cancelled: boolean };
456
+ const pending = new Map<JsonRpcId, PendingRequest>();
457
+ let work = Promise.resolve();
458
+
459
+ for await (const line of lines) {
460
+ const decoded = decodeRpcLine(line);
461
+ if (decoded.kind === "message" && isRecord(decoded.message)) {
462
+ const message = decoded.message;
463
+ const params = message["params"];
464
+ if (
465
+ message["jsonrpc"] === JSON_RPC_VERSION &&
466
+ isNotification(message) &&
467
+ message["method"] === "notifications/cancelled" &&
468
+ isRecord(params) &&
469
+ isUsableId(params["requestId"])
470
+ ) {
471
+ const target = pending.get(params["requestId"]);
472
+ if (target !== undefined && target.method !== "initialize") target.cancelled = true;
473
+ continue;
474
+ }
475
+ }
476
+
477
+ let tracked: PendingRequest | undefined;
478
+ if (
479
+ decoded.kind === "message" &&
480
+ isRecord(decoded.message) &&
481
+ decoded.message["jsonrpc"] === JSON_RPC_VERSION &&
482
+ !isNotification(decoded.message) &&
483
+ isUsableId(decoded.message["id"]) &&
484
+ typeof decoded.message["method"] === "string"
485
+ ) {
486
+ tracked = { id: decoded.message["id"], method: decoded.message["method"], state: "queued", cancelled: false };
487
+ pending.set(tracked.id, tracked);
488
+ }
489
+
490
+ work = work.then(async () => {
491
+ if (tracked?.cancelled === true) {
492
+ if (pending.get(tracked.id) === tracked) pending.delete(tracked.id);
493
+ return;
494
+ }
495
+ if (tracked !== undefined) tracked.state = "active";
496
+
497
+ const response =
498
+ decoded.kind === "blank"
499
+ ? null
500
+ : decoded.kind === "parse-error"
501
+ ? decoded.response
502
+ : await handleRpcMessage(decoded.message, host, session);
503
+
504
+ if (tracked !== undefined) {
505
+ if (pending.get(tracked.id) === tracked) pending.delete(tracked.id);
506
+ if (tracked.cancelled) return;
507
+ }
508
+ if (response !== null) write(encodeResponse(response));
509
+ });
510
+ }
511
+ await work;
512
+ }
513
+
514
+ /** Built inside a function, never at import time: the sensor's env rule. */
515
+ export function defaultToolRuntime(): ToolHostRuntime {
516
+ return { cwd: process.cwd(), env: process.env };
517
+ }
518
+
519
+ export async function main(): Promise<void> {
520
+ await serveStdio(process.stdin, (line) => process.stdout.write(line), defaultToolRuntime());
521
+ }
522
+
523
+ if (invokedDirectly(process.argv[1], import.meta.url)) {
524
+ // Fail closed for the same reason the CLI does: a server that fell over
525
+ // without saying so must not look like a clean shutdown.
526
+ process.exitCode = 1;
527
+ main()
528
+ .then(() => {
529
+ process.exitCode = 0;
530
+ })
531
+ .catch((error: unknown) => {
532
+ process.stderr.write(`${error instanceof Error ? (error.stack ?? error.message) : String(error)}\n`);
533
+ process.exitCode = 1;
534
+ });
535
+ }