@agent-delivery-harness/mcp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,202 @@
1
+
2
+ Apache License
3
+ Version 2.0, January 2004
4
+ http://www.apache.org/licenses/
5
+
6
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
7
+
8
+ 1. Definitions.
9
+
10
+ "License" shall mean the terms and conditions for use, reproduction,
11
+ and distribution as defined by Sections 1 through 9 of this document.
12
+
13
+ "Licensor" shall mean the copyright owner or entity authorized by
14
+ the copyright owner that is granting the License.
15
+
16
+ "Legal Entity" shall mean the union of the acting entity and all
17
+ other entities that control, are controlled by, or are under common
18
+ control with that entity. For the purposes of this definition,
19
+ "control" means (i) the power, direct or indirect, to cause the
20
+ direction or management of such entity, whether by contract or
21
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
22
+ outstanding shares, or (iii) beneficial ownership of such entity.
23
+
24
+ "You" (or "Your") shall mean an individual or Legal Entity
25
+ exercising permissions granted by this License.
26
+
27
+ "Source" form shall mean the preferred form for making modifications,
28
+ including but not limited to software source code, documentation
29
+ source, and configuration files.
30
+
31
+ "Object" form shall mean any form resulting from mechanical
32
+ transformation or translation of a Source form, including but
33
+ not limited to compiled object code, generated documentation,
34
+ and conversions to other media types.
35
+
36
+ "Work" shall mean the work of authorship, whether in Source or
37
+ Object form, made available under the License, as indicated by a
38
+ copyright notice that is included in or attached to the work
39
+ (an example is provided in the Appendix below).
40
+
41
+ "Derivative Works" shall mean any work, whether in Source or Object
42
+ form, that is based on (or derived from) the Work and for which the
43
+ editorial revisions, annotations, elaborations, or other modifications
44
+ represent, as a whole, an original work of authorship. For the purposes
45
+ of this License, Derivative Works shall not include works that remain
46
+ separable from, or merely link (or bind by name) to the interfaces of,
47
+ the Work and Derivative Works thereof.
48
+
49
+ "Contribution" shall mean any work of authorship, including
50
+ the original version of the Work and any modifications or additions
51
+ to that Work or Derivative Works thereof, that is intentionally
52
+ submitted to Licensor for inclusion in the Work by the copyright owner
53
+ or by an individual or Legal Entity authorized to submit on behalf of
54
+ the copyright owner. For the purposes of this definition, "submitted"
55
+ means any form of electronic, verbal, or written communication sent
56
+ to the Licensor or its representatives, including but not limited to
57
+ communication on electronic mailing lists, source code control systems,
58
+ and issue tracking systems that are managed by, or on behalf of, the
59
+ Licensor for the purpose of discussing and improving the Work, but
60
+ excluding communication that is conspicuously marked or otherwise
61
+ designated in writing by the copyright owner as "Not a Contribution."
62
+
63
+ "Contributor" shall mean Licensor and any individual or Legal Entity
64
+ on behalf of whom a Contribution has been received by Licensor and
65
+ subsequently incorporated within the Work.
66
+
67
+ 2. Grant of Copyright License. Subject to the terms and conditions of
68
+ this License, each Contributor hereby grants to You a perpetual,
69
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
70
+ copyright license to reproduce, prepare Derivative Works of,
71
+ publicly display, publicly perform, sublicense, and distribute the
72
+ Work and such Derivative Works in Source or Object form.
73
+
74
+ 3. Grant of Patent License. Subject to the terms and conditions of
75
+ this License, each Contributor hereby grants to You a perpetual,
76
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
77
+ (except as stated in this section) patent license to make, have made,
78
+ use, offer to sell, sell, import, and otherwise transfer the Work,
79
+ where such license applies only to those patent claims licensable
80
+ by such Contributor that are necessarily infringed by their
81
+ Contribution(s) alone or by combination of their Contribution(s)
82
+ with the Work to which such Contribution(s) was submitted. If You
83
+ institute patent litigation against any entity (including a
84
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
85
+ or a Contribution incorporated within the Work constitutes direct
86
+ or contributory patent infringement, then any patent licenses
87
+ granted to You under this License for that Work shall terminate
88
+ as of the date such litigation is filed.
89
+
90
+ 4. Redistribution. You may reproduce and distribute copies of the
91
+ Work or Derivative Works thereof in any medium, with or without
92
+ modifications, and in Source or Object form, provided that You
93
+ meet the following conditions:
94
+
95
+ (a) You must give any other recipients of the Work or
96
+ Derivative Works a copy of this License; and
97
+
98
+ (b) You must cause any modified files to carry prominent notices
99
+ stating that You changed the files; and
100
+
101
+ (c) You must retain, in the Source form of any Derivative Works
102
+ that You distribute, all copyright, patent, trademark, and
103
+ attribution notices from the Source form of the Work,
104
+ excluding those notices that do not pertain to any part of
105
+ the Derivative Works; and
106
+
107
+ (d) If the Work includes a "NOTICE" text file as part of its
108
+ distribution, then any Derivative Works that You distribute must
109
+ include a readable copy of the attribution notices contained
110
+ within such NOTICE file, excluding those notices that do not
111
+ pertain to any part of the Derivative Works, in at least one
112
+ of the following places: within a NOTICE text file distributed
113
+ as part of the Derivative Works; within the Source form or
114
+ documentation, if provided along with the Derivative Works; or,
115
+ within a display generated by the Derivative Works, if and
116
+ wherever such third-party notices normally appear. The contents
117
+ of the NOTICE file are for informational purposes only and
118
+ do not modify the License. You may add Your own attribution
119
+ notices within Derivative Works that You distribute, alongside
120
+ or as an addendum to the NOTICE text from the Work, provided
121
+ that such additional attribution notices cannot be construed
122
+ as modifying the License.
123
+
124
+ You may add Your own copyright statement to Your modifications and
125
+ may provide additional or different license terms and conditions
126
+ for use, reproduction, or distribution of Your modifications, or
127
+ for any such Derivative Works as a whole, provided Your use,
128
+ reproduction, and distribution of the Work otherwise complies with
129
+ the conditions stated in this License.
130
+
131
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
132
+ any Contribution intentionally submitted for inclusion in the Work
133
+ by You to the Licensor shall be under the terms and conditions of
134
+ this License, without any additional terms or conditions.
135
+ Notwithstanding the above, nothing herein shall supersede or modify
136
+ the terms of any separate license agreement you may have executed
137
+ with Licensor regarding such Contributions.
138
+
139
+ 6. Trademarks. This License does not grant permission to use the trade
140
+ names, trademarks, service marks, or product names of the Licensor,
141
+ except as required for reasonable and customary use in describing the
142
+ origin of the Work and reproducing the content of the NOTICE file.
143
+
144
+ 7. Disclaimer of Warranty. Unless required by applicable law or
145
+ agreed to in writing, Licensor provides the Work (and each
146
+ Contributor provides its Contributions) on an "AS IS" BASIS,
147
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
148
+ implied, including, without limitation, any warranties or conditions
149
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
150
+ PARTICULAR PURPOSE. You are solely responsible for determining the
151
+ appropriateness of using or redistributing the Work and assume any
152
+ risks associated with Your exercise of permissions under this License.
153
+
154
+ 8. Limitation of Liability. In no event and under no legal theory,
155
+ whether in tort (including negligence), contract, or otherwise,
156
+ unless required by applicable law (such as deliberate and grossly
157
+ negligent acts) or agreed to in writing, shall any Contributor be
158
+ liable to You for damages, including any direct, indirect, special,
159
+ incidental, or consequential damages of any character arising as a
160
+ result of this License or out of the use or inability to use the
161
+ Work (including but not limited to damages for loss of goodwill,
162
+ work stoppage, computer failure or malfunction, or any and all
163
+ other commercial damages or losses), even if such Contributor
164
+ has been advised of the possibility of such damages.
165
+
166
+ 9. Accepting Warranty or Additional Liability. While redistributing
167
+ the Work or Derivative Works thereof, You may choose to offer,
168
+ and charge a fee for, acceptance of support, warranty, indemnity,
169
+ or other liability obligations and/or rights consistent with this
170
+ License. However, in accepting such obligations, You may act only
171
+ on Your own behalf and on Your sole responsibility, not on behalf
172
+ of any other Contributor, and only if You agree to indemnify,
173
+ defend, and hold each Contributor harmless for any liability
174
+ incurred by, or claims asserted against, such Contributor by reason
175
+ of your accepting any such warranty or additional liability.
176
+
177
+ END OF TERMS AND CONDITIONS
178
+
179
+ APPENDIX: How to apply the Apache License to your work.
180
+
181
+ To apply the Apache License to your work, attach the following
182
+ boilerplate notice, with the fields enclosed by brackets "[]"
183
+ replaced with your own identifying information. (Don't include
184
+ the brackets!) The text should be enclosed in the appropriate
185
+ comment syntax for the file format. We also recommend that a
186
+ file or class name and description of purpose be included on the
187
+ same "printed page" as the copyright notice for easier
188
+ identification within third-party archives.
189
+
190
+ Copyright [yyyy] [name of copyright owner]
191
+
192
+ Licensed under the Apache License, Version 2.0 (the "License");
193
+ you may not use this file except in compliance with the License.
194
+ You may obtain a copy of the License at
195
+
196
+ http://www.apache.org/licenses/LICENSE-2.0
197
+
198
+ Unless required by applicable law or agreed to in writing, software
199
+ distributed under the License is distributed on an "AS IS" BASIS,
200
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
201
+ See the License for the specific language governing permissions and
202
+ limitations under the License.
package/NOTICE ADDED
@@ -0,0 +1,5 @@
1
+ delivery-harness
2
+ Copyright the delivery-harness authors
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,23 @@
1
+ {
2
+ "name": "@agent-delivery-harness/mcp",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "MCP server exposing the delivery harness submission surface",
6
+ "license": "Apache-2.0",
7
+ "engines": {
8
+ "node": ">=22"
9
+ },
10
+ "dependencies": {
11
+ "@agent-delivery-harness/cli": "0.1.0",
12
+ "@agent-delivery-harness/kernel": "0.1.0"
13
+ },
14
+ "exports": {
15
+ ".": "./src/index.ts"
16
+ },
17
+ "files": [
18
+ "src",
19
+ "!src/**/*.test.ts",
20
+ "LICENSE",
21
+ "NOTICE"
22
+ ]
23
+ }
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,493 @@
1
+ /**
2
+ * The MCP tool surface: `review-context` and `submit-evidence`, at strict
3
+ * 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. Two of the CLI's seven commands are exposed. The rest
21
+ * are not tools, and a call naming one is an unknown tool: what a tool call may
22
+ * reach is the registry below, never the CLI's. Exposing less than the CLI is
23
+ * within the contract; behaving differently about what is exposed is not.
24
+ *
25
+ * WHAT AN AGENT NEVER GETS. No TTY, therefore no waiver prompt — an MCP session
26
+ * has no human at the other end to answer one, and a prompt nobody can answer
27
+ * is either a hang or a forged consent. Both TTY flags are hard `false` here.
28
+ */
29
+ import {
30
+ CliInterruption,
31
+ EXIT_OK,
32
+ EXIT_USAGE,
33
+ commandBlocker,
34
+ importHarnessConfig,
35
+ reviewContextCommand,
36
+ runCliBoundary,
37
+ submitEvidenceCommand,
38
+ type CliRuntime,
39
+ type CommandDescriptor,
40
+ } from "@agent-delivery-harness/cli";
41
+ import {
42
+ BlockedError,
43
+ createInternalErrorBlocker,
44
+ renderBlockers,
45
+ serializeBlockers,
46
+ type ArtifactsPort,
47
+ type Blocker,
48
+ type EnvSnapshot,
49
+ type HarnessConfig,
50
+ type LiveProviderResult,
51
+ type SerializedBlockers,
52
+ } from "@agent-delivery-harness/kernel";
53
+
54
+ // ── Protocol identity ────────────────────────────────────────────────────────
55
+
56
+ /**
57
+ * The revision the handshake settles on, and the older ones it will still speak
58
+ * if a client asks for one. Newest first: `initialize` echoes the client's
59
+ * version when it is on this list and answers with the newest otherwise, which
60
+ * is the negotiation the spec asks for.
61
+ *
62
+ * 2025-03-26 is missing on purpose. It is the one revision that requires a
63
+ * server to accept JSON-RPC batches, and the transport refuses every array; a
64
+ * client asking for it is answered with the newest revision here, where that
65
+ * refusal is true. 2024-11-05 predates batching and carries no such
66
+ * requirement.
67
+ */
68
+ export const MCP_PROTOCOL_VERSION = "2025-11-25";
69
+ export const HANDSHAKE_PROTOCOL_VERSIONS: readonly string[] = Object.freeze([MCP_PROTOCOL_VERSION, "2025-06-18", "2024-11-05"]);
70
+
71
+ /**
72
+ * The stateless revision, and the list of stateless revisions — one, today.
73
+ *
74
+ * 2026-07-28 removed the handshake rather than extending it. A client no longer
75
+ * negotiates once and remembers; it declares its protocol version and
76
+ * capabilities on every request, and the server answers each request on its own
77
+ * terms. That makes "which revision is this connection speaking" the wrong
78
+ * question, and `HANDSHAKE_PROTOCOL_VERSIONS` and this list the two answers to
79
+ * the right one: which revisions arrive through `initialize`, and which arrive
80
+ * in `_meta`.
81
+ */
82
+ export const MCP_STATELESS_PROTOCOL_VERSION = "2026-07-28";
83
+ export const STATELESS_PROTOCOL_VERSIONS: readonly string[] = Object.freeze([MCP_STATELESS_PROTOCOL_VERSION]);
84
+
85
+ /**
86
+ * Everything this server speaks, newest first — for documentation and for tests
87
+ * that need the whole set.
88
+ *
89
+ * NOT WHAT `server/discover` ANSWERS. That field is `supportedVersions`:
90
+ * "Protocol versions the server supports. The client should choose one of these
91
+ * for subsequent requests" — and on stdio, "The server returns a
92
+ * `DiscoverResult`: the server is modern. Select a mutually supported version
93
+ * from `supportedVersions` and continue." There is no branch back to
94
+ * `initialize` from there; a `DiscoverResult` arriving is itself what tells the
95
+ * client to stay modern. A handshake revision named in that field would
96
+ * therefore be a trap — a conforming client selects 2024-11-05, continues
97
+ * modern, and earns a guaranteed `UnsupportedProtocolVersionError`. So the
98
+ * probe answers `STATELESS_PROTOCOL_VERSIONS`, the same list the error carries,
99
+ * for the same loop-avoidance reason. What a handshake client can reach is
100
+ * discovered the way it always was: by sending `initialize`.
101
+ */
102
+ export const SUPPORTED_PROTOCOL_VERSIONS: readonly string[] = Object.freeze([
103
+ ...STATELESS_PROTOCOL_VERSIONS,
104
+ ...HANDSHAKE_PROTOCOL_VERSIONS,
105
+ ]);
106
+
107
+ /** Version tracks the package; the release mechanics keep the two in step. */
108
+ export const MCP_SERVER_INFO = { name: "delivery-harness", version: "0.0.0" } as const;
109
+
110
+ // ── Per-request protocol metadata ────────────────────────────────────────────
111
+
112
+ /**
113
+ * The `_meta` keys 2026-07-28 reserves. Named rather than spelled inline
114
+ * because a typo in one of these is a request silently read as the wrong era —
115
+ * the failure mode that has no symptom until a client sees the wrong envelope.
116
+ */
117
+ export const META_PROTOCOL_VERSION = "io.modelcontextprotocol/protocolVersion";
118
+ export const META_CLIENT_INFO = "io.modelcontextprotocol/clientInfo";
119
+ export const META_CLIENT_CAPABILITIES = "io.modelcontextprotocol/clientCapabilities";
120
+ export const META_LOG_LEVEL = "io.modelcontextprotocol/logLevel";
121
+ export const META_SERVER_INFO = "io.modelcontextprotocol/serverInfo";
122
+
123
+ /**
124
+ * The `_meta` members whose presence identifies a request as stateless: the
125
+ * four per-request protocol fields 2026-07-28 defines, and nothing else.
126
+ *
127
+ * WHY NOT THE `io.modelcontextprotocol/` PREFIX. Because that prefix marks MCP
128
+ * ownership, not the modern era, and the two are not the same set.
129
+ * 2025-11-25's tasks feature reserves a key under it — "All requests,
130
+ * notifications, and responses related to a task **MUST** include the
131
+ * `io.modelcontextprotocol/related-task` key in their `_meta` field" — so a
132
+ * handshake-era request can carry a prefixed key perfectly legitimately. A
133
+ * prefix test would route that request into the stateless path and reject it
134
+ * for a missing protocol version, which is exactly what the same page forbids:
135
+ * "Receivers that do not declare the task capability for a request type
136
+ * **MUST** process requests of that type normally, ignoring any
137
+ * task-augmentation metadata if present." This server declares no `tasks`
138
+ * capability, so that MUST binds it, and enumerating the four modern members is
139
+ * what keeps it honoured.
140
+ *
141
+ * Keying on the set rather than on `protocolVersion` alone is still what stops
142
+ * a modern request that omitted a required field from being quietly served as a
143
+ * legacy one — a request carrying `clientCapabilities` and no version is
144
+ * unmistakably modern and gets told so.
145
+ *
146
+ * WHY `logLevel` BELONGS HERE. The other three are era-exclusive by
147
+ * construction — no handshake revision has per-request protocol fields at all —
148
+ * but `logLevel` is a member a reader could plausibly imagine predating the
149
+ * rewrite, so the premise is named rather than assumed. It does not: through
150
+ * 2025-11-25 the log level travels as the `logging/setLevel` *method*, and that
151
+ * revision's `_meta` section defines only the key-name grammar and the reserved
152
+ * prefix, naming no protocol-field keys whatever. 2026-07-28 is the revision
153
+ * that moved it: "Log level is now set per-request via
154
+ * `io.modelcontextprotocol/logLevel` in `_meta`."
155
+ */
156
+ export const META_STATELESS_KEYS: readonly string[] = Object.freeze([
157
+ META_PROTOCOL_VERSION,
158
+ META_CLIENT_INFO,
159
+ META_CLIENT_CAPABILITIES,
160
+ META_LOG_LEVEL,
161
+ ]);
162
+
163
+ /** RFC 5424 severities, as the logging utility enumerates them. */
164
+ export const LOG_LEVELS: readonly string[] = Object.freeze([
165
+ "debug",
166
+ "info",
167
+ "notice",
168
+ "warning",
169
+ "error",
170
+ "critical",
171
+ "alert",
172
+ "emergency",
173
+ ]);
174
+
175
+ /**
176
+ * The caching hints on every `CacheableResult` this server returns.
177
+ *
178
+ * `"public"` is the honest scope: the advertised surface is two fixed tools,
179
+ * identical for every caller, carrying nothing user-specific — the spec's own
180
+ * example of when public is appropriate. The TTL is an hour because the list is
181
+ * compiled into the binary and cannot change while the process runs; it is a
182
+ * freshness hint, and this one is generous because there is nothing to go
183
+ * stale.
184
+ */
185
+ export const TOOL_LIST_TTL_MS = 3_600_000;
186
+ export const TOOL_LIST_CACHE_SCOPE = "public" as const;
187
+
188
+ /** What `server/discover` and the handshake both report. */
189
+ export const MCP_SERVER_CAPABILITIES = { tools: { listChanged: false } } as const;
190
+
191
+ const MCP_SOURCE_ID = "delivery-harness.mcp";
192
+
193
+ // ── What a tool call may reach ───────────────────────────────────────────────
194
+
195
+ /** Everything the host provides. No streams: this module never prints. */
196
+ export interface ToolHostRuntime {
197
+ readonly cwd: string;
198
+ readonly env: EnvSnapshot;
199
+ readonly loadConfig?: (rootDir: string) => Promise<HarnessConfig>;
200
+ readonly artifacts?: ArtifactsPort;
201
+ readonly liveResults?: readonly LiveProviderResult[];
202
+ }
203
+
204
+ /**
205
+ * One tool call, classified the way the CLI classifies an invocation.
206
+ *
207
+ * `exitCode` is carried verbatim rather than translated, because the CLI's
208
+ * three classes are the contract: 0 passed, 2 could not be parsed, anything
209
+ * else is a policy failure. `outcome` is that same classification named, so an
210
+ * agent reading structured content does not have to know shell conventions.
211
+ */
212
+ export interface ToolOutcome {
213
+ readonly outcome: "ok" | "blocked" | "usage";
214
+ readonly exitCode: number;
215
+ readonly text: string;
216
+ readonly blockers: SerializedBlockers;
217
+ }
218
+
219
+ export interface ToolListing {
220
+ readonly name: string;
221
+ readonly description: string;
222
+ readonly inputSchema: Record<string, unknown>;
223
+ }
224
+
225
+ export interface McpToolResult {
226
+ readonly content: readonly { readonly type: "text"; readonly text: string }[];
227
+ readonly isError: boolean;
228
+ readonly structuredContent: {
229
+ readonly outcome: ToolOutcome["outcome"];
230
+ readonly exitCode: number;
231
+ readonly blockers: SerializedBlockers;
232
+ };
233
+ }
234
+
235
+ /** Either the argv the boundary runs, or the typed usage failure to report. */
236
+ type ArgumentTranslation = { readonly ok: true; readonly argv: readonly string[] } | { readonly ok: false; readonly blocker: Blocker };
237
+
238
+ interface ToolDefinition {
239
+ readonly name: string;
240
+ readonly description: string;
241
+ readonly inputSchema: Record<string, unknown>;
242
+ readonly command: CommandDescriptor;
243
+ translate(args: Record<string, unknown>): ArgumentTranslation;
244
+ }
245
+
246
+ // ── Argument translation ─────────────────────────────────────────────────────
247
+
248
+ function usageBlocker(code: string, summary: string, details: string, remediationSummary: string): Blocker {
249
+ return commandBlocker({
250
+ code,
251
+ sourceId: MCP_SOURCE_ID,
252
+ summary,
253
+ details,
254
+ remediations: [{ id: "correct-the-tool-call", kind: "manual_action", summary: remediationSummary }],
255
+ });
256
+ }
257
+
258
+ const TOOL_NAMES_SENTENCE = (): string => TOOLS.map((tool) => tool.name).join(", ");
259
+
260
+ /**
261
+ * Rejects members the tool does not define, for the same reason the manifest
262
+ * validator rejects them: a tolerated stranger is how a caller comes to believe
263
+ * it configured something. Unlike a manifest, the offending name is caller
264
+ * text — it reaches an agent's screen through the renderer, never raw.
265
+ */
266
+ function rejectUnknownMembers(tool: ToolDefinition, args: Record<string, unknown>, defined: readonly string[]): Blocker | null {
267
+ const unknown = Object.keys(args).filter((name) => !defined.includes(name));
268
+ if (unknown.length === 0) return null;
269
+ return usageBlocker(
270
+ "unknown_tool_argument",
271
+ `The ${tool.name} tool does not define every argument this call supplied.`,
272
+ `Undefined argument(s): ${unknown.join(", ")}. Defined: ${defined.length === 0 ? "(none)" : defined.join(", ")}.`,
273
+ `Call ${tool.name} with only the arguments its input schema declares.`,
274
+ );
275
+ }
276
+
277
+ /**
278
+ * The tools, named exactly as the CLI names its commands.
279
+ *
280
+ * That is the naming decision this unit settles: one vocabulary for an operator
281
+ * reading a terminal and an agent reading a tool list, so guidance that names
282
+ * `submit-evidence` is executable on both surfaces without translation.
283
+ */
284
+ const TOOLS: readonly ToolDefinition[] = [
285
+ {
286
+ name: reviewContextCommand.name,
287
+ description:
288
+ "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.",
289
+ inputSchema: { type: "object", properties: {}, required: [], additionalProperties: false },
290
+ command: reviewContextCommand,
291
+ translate(args) {
292
+ const unknown = rejectUnknownMembers(this, args, []);
293
+ if (unknown !== null) return { ok: false, blocker: unknown };
294
+ return { ok: true, argv: [reviewContextCommand.name] };
295
+ },
296
+ },
297
+ {
298
+ name: submitEvidenceCommand.name,
299
+ description:
300
+ "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.",
301
+ inputSchema: {
302
+ type: "object",
303
+ properties: {
304
+ manifest: { type: "string", description: "Path to the manifest.json to submit, inside its allocated run root." },
305
+ },
306
+ required: ["manifest"],
307
+ additionalProperties: false,
308
+ },
309
+ command: submitEvidenceCommand,
310
+ translate(args) {
311
+ const unknown = rejectUnknownMembers(this, args, ["manifest"]);
312
+ if (unknown !== null) return { ok: false, blocker: unknown };
313
+ const manifest = args["manifest"];
314
+ // Absent is deliberately *not* rejected here. The schema says the member
315
+ // is required, and the command says so too — in its own words, with its
316
+ // own exit-2 class. Delegating keeps one sentence for both surfaces
317
+ // instead of two that drift.
318
+ if (manifest === undefined) return { ok: true, argv: [submitEvidenceCommand.name] };
319
+ if (typeof manifest !== "string") {
320
+ return {
321
+ ok: false,
322
+ blocker: usageBlocker(
323
+ "invalid_tool_argument",
324
+ "The submit-evidence tool's manifest argument must be a filesystem path.",
325
+ `manifest was ${manifest === null ? "null" : typeof manifest}, not a string.`,
326
+ "Call submit-evidence with manifest set to the path of the manifest.json to submit.",
327
+ ),
328
+ };
329
+ }
330
+ return { ok: true, argv: [submitEvidenceCommand.name, "--manifest", manifest] };
331
+ },
332
+ },
333
+ ];
334
+
335
+ // ── The advertised surface ───────────────────────────────────────────────────
336
+
337
+ export function listTools(): readonly ToolListing[] {
338
+ return TOOLS.map((tool) => ({ name: tool.name, description: tool.description, inputSchema: tool.inputSchema }));
339
+ }
340
+
341
+ // ── Running one call through the command core ────────────────────────────────
342
+
343
+ const EMPTY_BLOCKERS: readonly Blocker[] = Object.freeze([]);
344
+
345
+ function classify(exitCode: number): ToolOutcome["outcome"] {
346
+ if (exitCode === EXIT_OK) return "ok";
347
+ if (exitCode === EXIT_USAGE) return "usage";
348
+ return "blocked";
349
+ }
350
+
351
+ function outcomeFor(exitCode: number, text: string, blockers: readonly Blocker[]): ToolOutcome {
352
+ return { outcome: classify(exitCode), exitCode, text, blockers: serializeBlockers(blockers) };
353
+ }
354
+
355
+ /** A usage failure this module raised itself, rendered like every other one. */
356
+ function usageOutcome(blocker: Blocker): ToolOutcome {
357
+ return outcomeFor(EXIT_USAGE, renderBlockers([blocker]), [blocker]);
358
+ }
359
+
360
+ /**
361
+ * The structured half of a failure the boundary is about to render as text.
362
+ *
363
+ * The boundary reports failures on a stream; it does not hand back the typed
364
+ * blockers behind them, and an agent context window needs the structured form.
365
+ * Rather than re-deciding anything, this observes the same values on their way
366
+ * past: a `BlockedError` carries its blockers, and an unexpected throw is
367
+ * described with the very constructor the boundary uses, so the code and
368
+ * summary an agent parses are the ones an operator would have read.
369
+ */
370
+ function blockersFromThrow(error: unknown, descriptor: CommandDescriptor): readonly Blocker[] {
371
+ if (error instanceof CliInterruption) return EMPTY_BLOCKERS;
372
+ if (error instanceof BlockedError) return error.blockers;
373
+ return [
374
+ createInternalErrorBlocker({
375
+ source: { kind: "command", id: descriptor.sourceId },
376
+ error,
377
+ reproduce: ["delivery-harness", descriptor.name],
378
+ }),
379
+ ];
380
+ }
381
+
382
+ async function throughCommandCore(tool: ToolDefinition, argv: readonly string[], host: ToolHostRuntime): Promise<ToolOutcome> {
383
+ const out: string[] = [];
384
+ const err: string[] = [];
385
+ let observed: readonly Blocker[] = EMPTY_BLOCKERS;
386
+
387
+ // The command, wrapped only to observe. It runs the real `run`, returns the
388
+ // real result, and rethrows the real error: the boundary sees exactly what it
389
+ // would have seen with the unwrapped descriptor.
390
+ const observedCommand: CommandDescriptor = {
391
+ ...tool.command,
392
+ async run(context) {
393
+ try {
394
+ const result = await tool.command.run(context);
395
+ if (result.kind === "blocked") observed = result.blockers;
396
+ return result;
397
+ } catch (error) {
398
+ observed = blockersFromThrow(error, tool.command);
399
+ throw error;
400
+ }
401
+ },
402
+ };
403
+
404
+ const loadConfig = host.loadConfig ?? importHarnessConfig;
405
+ const runtime: CliRuntime = {
406
+ cwd: host.cwd,
407
+ env: host.env,
408
+ // No TTY on either side: stdin and stdout are the transport, and a waiver
409
+ // prompt would have nobody to answer it.
410
+ stdinIsTTY: false,
411
+ stdoutIsTTY: false,
412
+ stdout: (text) => out.push(text),
413
+ stderr: (text) => err.push(text),
414
+ loadConfig: async (rootDir) => {
415
+ try {
416
+ return await loadConfig(rootDir);
417
+ } catch (error) {
418
+ // A config that will not load blocks before the command runs, so the
419
+ // wrapper above never sees it.
420
+ observed = blockersFromThrow(error, tool.command);
421
+ throw error;
422
+ }
423
+ },
424
+ ...(host.artifacts === undefined ? {} : { artifacts: host.artifacts }),
425
+ ...(host.liveResults === undefined ? {} : { liveResults: host.liveResults }),
426
+ };
427
+
428
+ const exitCode = await runCliBoundary(argv, [observedCommand], runtime);
429
+ return outcomeFor(exitCode, `${out.join("")}${err.join("")}`.trimEnd(), observed);
430
+ }
431
+
432
+ /**
433
+ * Runs one tool call to a classified outcome. Total, like the boundary it wraps:
434
+ * an unknown tool, unusable arguments, a policy block and an internal error all
435
+ * come back as an outcome rather than a throw.
436
+ */
437
+ export async function callTool(name: string, args: unknown, host: ToolHostRuntime): Promise<ToolOutcome> {
438
+ const tool = TOOLS.find((candidate) => candidate.name === name);
439
+ if (tool === undefined) {
440
+ return usageOutcome(
441
+ usageBlocker(
442
+ "unknown_tool",
443
+ "This server does not offer the tool this call named.",
444
+ `Requested tool: ${name}. Offered: ${TOOL_NAMES_SENTENCE()}.`,
445
+ `Call one of the tools this server advertises: ${TOOL_NAMES_SENTENCE()}.`,
446
+ ),
447
+ );
448
+ }
449
+
450
+ // Absent arguments are an empty object (the spec's own default); anything
451
+ // else that is not an object is a usage failure rather than a coerced guess.
452
+ const supplied = args === undefined || args === null ? {} : args;
453
+ if (typeof supplied !== "object" || Array.isArray(supplied)) {
454
+ return usageOutcome(
455
+ usageBlocker(
456
+ "invalid_tool_arguments",
457
+ `The ${tool.name} tool's arguments must be an object.`,
458
+ `Arguments were ${Array.isArray(supplied) ? "an array" : typeof supplied}.`,
459
+ `Call ${tool.name} with an arguments object matching its input schema.`,
460
+ ),
461
+ );
462
+ }
463
+
464
+ const translation = tool.translate(supplied as Record<string, unknown>);
465
+ if (!translation.ok) return usageOutcome(translation.blocker);
466
+ return throughCommandCore(tool, translation.argv, host);
467
+ }
468
+
469
+ // ── The MCP shape ────────────────────────────────────────────────────────────
470
+
471
+ /**
472
+ * Shapes an outcome as an MCP tool result.
473
+ *
474
+ * One text block, and it is the renderer's output — an agent's context window
475
+ * is a rendering surface, so the bytes that reach it are the neutralized ones,
476
+ * not a raw provider string re-serialized on the way out. `structuredContent`
477
+ * is the renderer's other face (`serializeBlockers`), which neutralizes the
478
+ * same way. No `outputSchema` is declared: declaring one obliges a server to
479
+ * repeat the structured content as JSON text for older clients, and a second
480
+ * copy of every blocker in an agent's context buys nothing this text block does
481
+ * not already say.
482
+ *
483
+ * `isError` is true for both failure classes. A tool result is how MCP reports
484
+ * a call that ran and failed, and a policy block is exactly that — the protocol
485
+ * error channel is for calls that could not run at all.
486
+ */
487
+ export function toolResultFor(outcome: ToolOutcome): McpToolResult {
488
+ return {
489
+ content: [{ type: "text", text: outcome.text }],
490
+ isError: outcome.outcome !== "ok",
491
+ structuredContent: { outcome: outcome.outcome, exitCode: outcome.exitCode, blockers: outcome.blockers },
492
+ };
493
+ }
package/src/stdio.ts ADDED
@@ -0,0 +1,497 @@
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 { realpathSync } from "node:fs";
49
+ import { createInterface } from "node:readline";
50
+ import { fileURLToPath } from "node:url";
51
+ import {
52
+ HANDSHAKE_PROTOCOL_VERSIONS,
53
+ LOG_LEVELS,
54
+ MCP_PROTOCOL_VERSION,
55
+ MCP_SERVER_CAPABILITIES,
56
+ MCP_SERVER_INFO,
57
+ META_CLIENT_CAPABILITIES,
58
+ META_LOG_LEVEL,
59
+ META_PROTOCOL_VERSION,
60
+ META_SERVER_INFO,
61
+ META_STATELESS_KEYS,
62
+ STATELESS_PROTOCOL_VERSIONS,
63
+ TOOL_LIST_CACHE_SCOPE,
64
+ TOOL_LIST_TTL_MS,
65
+ callTool,
66
+ listTools,
67
+ toolResultFor,
68
+ type ToolHostRuntime,
69
+ } from "./server.ts";
70
+
71
+ // ── JSON-RPC ─────────────────────────────────────────────────────────────────
72
+
73
+ export const JSON_RPC_VERSION = "2.0";
74
+
75
+ export const PARSE_ERROR = -32700;
76
+ export const INVALID_REQUEST = -32600;
77
+ export const METHOD_NOT_FOUND = -32601;
78
+ export const INVALID_PARAMS = -32602;
79
+
80
+ /**
81
+ * `UnsupportedProtocolVersionError`, at the code 2026-07-28 renumbered it to.
82
+ *
83
+ * It is -32022, not the -32004 the draft used. That revision partitioned the
84
+ * JSON-RPC implementation-defined range: `-32000` to `-32019` is grandfathered
85
+ * and "new implementations **SHOULD NOT** use codes from this sub-range at
86
+ * all", while `-32020` to `-32099` is "reserved for the MCP specification" and
87
+ * implementations "**MUST NOT** emit any code from this sub-range that is not
88
+ * defined by this specification". This is the only code from that range this
89
+ * server emits, and it is emitted with the specified meaning.
90
+ */
91
+ export const UNSUPPORTED_PROTOCOL_VERSION = -32022;
92
+
93
+ export type JsonRpcId = string | number | null;
94
+
95
+ export interface JsonRpcResponse {
96
+ readonly jsonrpc: typeof JSON_RPC_VERSION;
97
+ readonly id: JsonRpcId;
98
+ readonly result?: unknown;
99
+ readonly error?: { readonly code: number; readonly message: string; readonly data?: unknown };
100
+ }
101
+
102
+ function ok(id: JsonRpcId, result: unknown): JsonRpcResponse {
103
+ return { jsonrpc: JSON_RPC_VERSION, id, result };
104
+ }
105
+
106
+ function fail(id: JsonRpcId, code: number, message: string, data?: unknown): JsonRpcResponse {
107
+ return { jsonrpc: JSON_RPC_VERSION, id, error: { code, message, ...(data === undefined ? {} : { data }) } };
108
+ }
109
+
110
+ /**
111
+ * Session state the protocol carries; the tools themselves are stateless.
112
+ *
113
+ * NO HANDSHAKE GATE, AND THEREFORE NO `initialized` FLAG. Refusing `tools/list`
114
+ * or `tools/call` until the handshake completed was the alternative, and it was
115
+ * declined on two grounds. The spec's ordering rule is a SHOULD addressed to
116
+ * the *client*, so a hard server-side gate refuses a pipelining client the spec
117
+ * allows to be answered, and it would need a rejection code the protocol does
118
+ * not define for the case. And a flag written on every handshake but read by no
119
+ * decision is state that can only go stale — the kind a later reader trusts
120
+ * without noticing nothing maintains it. `protocolVersion` stays because it is
121
+ * the negotiated result the connection is bound to and the `initialize`
122
+ * response is built from it.
123
+ */
124
+ export interface McpSession {
125
+ protocolVersion: string;
126
+ }
127
+
128
+ export function createSession(): McpSession {
129
+ return { protocolVersion: MCP_PROTOCOL_VERSION };
130
+ }
131
+
132
+ function isRecord(value: unknown): value is Record<string, unknown> {
133
+ return typeof value === "object" && value !== null && !Array.isArray(value);
134
+ }
135
+
136
+ /** The three id shapes JSON-RPC 2.0 permits. Anything else is not an id. */
137
+ function isUsableId(id: unknown): id is JsonRpcId {
138
+ return typeof id === "string" || typeof id === "number" || id === null;
139
+ }
140
+
141
+ function idOf(message: Record<string, unknown>): JsonRpcId {
142
+ const id = message["id"];
143
+ return isUsableId(id) ? id : null;
144
+ }
145
+
146
+ /**
147
+ * A notification is a message with no `id` *member* — not a message whose id
148
+ * happens to be null. The distinction matters: answering an explicit null id
149
+ * with silence would hang a client that is waiting for a response it is
150
+ * entitled to.
151
+ */
152
+ function isNotification(message: Record<string, unknown>): boolean {
153
+ return !Object.prototype.hasOwnProperty.call(message, "id");
154
+ }
155
+
156
+ /**
157
+ * The handshake's version selection, drawn only from the revisions that *have*
158
+ * a handshake.
159
+ *
160
+ * A client naming 2026-07-28 in `initialize` is answered with 2025-11-25 rather
161
+ * than its own request. Echoing it would be this server promising a session
162
+ * under the revision that abolished sessions — the client would then omit the
163
+ * per-request `_meta` that revision requires and this server would read every
164
+ * subsequent request as handshake-era. That is the one place backward
165
+ * compatibility costs something: a stateless-capable client that opens with
166
+ * `initialize` is served an older revision instead. The spec's own remedy is
167
+ * the probe, and `server/discover` is implemented for exactly that.
168
+ */
169
+ function negotiate(requested: unknown): string {
170
+ return typeof requested === "string" && HANDSHAKE_PROTOCOL_VERSIONS.includes(requested) ? requested : MCP_PROTOCOL_VERSION;
171
+ }
172
+
173
+ // ── The stateless revision ───────────────────────────────────────────────────
174
+
175
+ /**
176
+ * Whether a request carries stateless per-request metadata.
177
+ *
178
+ * The era selector is the spec's: "A dual-era server selects its behavior from
179
+ * how the client opens ... A request carrying modern per-request `_meta` is
180
+ * served statelessly according to this revision. An `initialize` request
181
+ * selects legacy semantics." The test is the presence of any of the four
182
+ * per-request protocol fields that revision defines, not of the protocol
183
+ * version alone — a request that carries `clientCapabilities` and forgot the
184
+ * version is still unmistakably a stateless request, and it must be told so
185
+ * with a -32602 rather than handed a legacy-shaped success for a revision it
186
+ * never asked for. It is deliberately *not* a test on the
187
+ * `io.modelcontextprotocol/` prefix, which a handshake-era task-augmented
188
+ * request may also carry; see `META_STATELESS_KEYS`. Whatever is or is not
189
+ * there, the answer comes from this request only: "Servers **MUST NOT** rely on
190
+ * prior requests over the same connection to establish context".
191
+ */
192
+ function isStatelessRequest(params: unknown): boolean {
193
+ if (!isRecord(params)) return false;
194
+ const meta = params["_meta"];
195
+ if (!isRecord(meta)) return false;
196
+ return META_STATELESS_KEYS.some((key) => Object.prototype.hasOwnProperty.call(meta, key));
197
+ }
198
+
199
+ function metaOf(params: unknown): Record<string, unknown> {
200
+ const meta = isRecord(params) ? params["_meta"] : undefined;
201
+ return isRecord(meta) ? meta : {};
202
+ }
203
+
204
+ /** Every stateless result carries the server's identity and its type. */
205
+ function statelessResult(fields: Record<string, unknown>): Record<string, unknown> {
206
+ return { resultType: "complete", ...fields, _meta: { [META_SERVER_INFO]: MCP_SERVER_INFO } };
207
+ }
208
+
209
+ /** The two hints a `CacheableResult` must carry. */
210
+ const CACHE_HINTS = { ttlMs: TOOL_LIST_TTL_MS, cacheScope: TOOL_LIST_CACHE_SCOPE } as const;
211
+
212
+ /**
213
+ * Answers one request that declared a protocol version in `_meta`.
214
+ *
215
+ * WHAT IS NOT HERE. `ping`, `logging/setLevel` and
216
+ * `notifications/roots/list_changed` were removed by this revision, and
217
+ * `initialize` with it — none of them is dispatched below, so each falls to
218
+ * "method not found", which is the truth under 2026-07-28. `ping` remains
219
+ * answerable through the handshake path, where the revisions that define it
220
+ * still live.
221
+ *
222
+ * WHAT THIS SERVER STILL DOES NOT EMIT. No `notifications/message`, on any
223
+ * revision. The revision that moved log level onto `_meta` deprecated the
224
+ * Logging feature in the same release — "New implementations **SHOULD NOT**
225
+ * adopt it; existing implementations **SHOULD** migrate to logging to `stderr`
226
+ * for stdio transports" — and this server already logs to stderr. So the
227
+ * requirement that a server "**MUST NOT** emit `notifications/message` for a
228
+ * request that does not include this field" holds by construction rather than
229
+ * by a check, and a request that *does* set the field is answered with its
230
+ * response and nothing else. An unrecognized level is still rejected: the
231
+ * logging page asks for `-32602` there, and a value this server cannot name is
232
+ * not a request it can claim to have understood.
233
+ *
234
+ * WHAT IS DELIBERATELY ABSENT. No `resultType: "input_required"`. That belongs
235
+ * to the multi round-trip pattern, which replaces server-initiated requests —
236
+ * and this server initiates none: it has no sampling, no elicitation, no roots,
237
+ * and no waiver prompt an agent could be asked to answer. Likewise no
238
+ * `subscriptions/listen` (nothing here changes while the process runs) and no
239
+ * tasks extension (advertising an extension is a promise, and there is no
240
+ * durable request behind it).
241
+ */
242
+ async function handleStateless(
243
+ method: string,
244
+ id: JsonRpcId,
245
+ params: unknown,
246
+ requested: unknown,
247
+ host: ToolHostRuntime,
248
+ ): Promise<JsonRpcResponse> {
249
+ // A version that is absent, or present but not a string, is a malformed
250
+ // request rather than an unsupported revision: -32022 means "this version is
251
+ // one I do not implement", and a missing member names no version at all. Both
252
+ // fall under the same rule as any other required field — "A request missing
253
+ // any required field is malformed; the server MUST reject it with JSON-RPC
254
+ // error code -32602".
255
+ if (typeof requested !== "string") {
256
+ return fail(id, INVALID_PARAMS, `A stateless request must carry ${META_PROTOCOL_VERSION} in _meta as a protocol version string.`);
257
+ }
258
+
259
+ if (!STATELESS_PROTOCOL_VERSIONS.includes(requested)) {
260
+ // "the server ... MUST respond with an UnsupportedProtocolVersionError
261
+ // listing the versions it does support". `supported` names what *this*
262
+ // channel can serve, not everything this server speaks: the client's rule
263
+ // is to "select a mutually supported version from the `supported` list and
264
+ // retry the request", and a handshake revision offered here would send it
265
+ // round the same loop, since those revisions have no per-request form. The
266
+ // full list — both eras — is what `server/discover` is for.
267
+ return fail(id, UNSUPPORTED_PROTOCOL_VERSION, "Unsupported protocol version", {
268
+ supported: STATELESS_PROTOCOL_VERSIONS,
269
+ requested,
270
+ });
271
+ }
272
+
273
+ const meta = metaOf(params);
274
+
275
+ // "A request missing any required field is malformed; the server MUST reject
276
+ // it with JSON-RPC error code -32602 (Invalid params)." Capabilities are
277
+ // required; client info is not, and its absence is not a fault.
278
+ if (!isRecord(meta[META_CLIENT_CAPABILITIES])) {
279
+ return fail(id, INVALID_PARAMS, `A request under ${requested} must carry ${META_CLIENT_CAPABILITIES} in _meta.`);
280
+ }
281
+
282
+ const logLevel = meta[META_LOG_LEVEL];
283
+ if (logLevel !== undefined && (typeof logLevel !== "string" || !LOG_LEVELS.includes(logLevel))) {
284
+ return fail(id, INVALID_PARAMS, `${META_LOG_LEVEL} must be one of: ${LOG_LEVELS.join(", ")}.`);
285
+ }
286
+
287
+ if (method === "server/discover") {
288
+ return ok(
289
+ id,
290
+ statelessResult({
291
+ supportedVersions: STATELESS_PROTOCOL_VERSIONS,
292
+ capabilities: MCP_SERVER_CAPABILITIES,
293
+ ...CACHE_HINTS,
294
+ }),
295
+ );
296
+ }
297
+
298
+ if (method === "tools/list") return ok(id, statelessResult({ tools: listTools(), ...CACHE_HINTS }));
299
+
300
+ if (method === "tools/call") {
301
+ if (!isRecord(params)) return fail(id, INVALID_PARAMS, "tools/call requires a params object.");
302
+ const name = params["name"];
303
+ if (typeof name !== "string") return fail(id, INVALID_PARAMS, "tools/call requires a string name.");
304
+ const outcome = await callTool(name, params["arguments"], host);
305
+ return ok(id, statelessResult({ ...toolResultFor(outcome) }));
306
+ }
307
+
308
+ return fail(id, METHOD_NOT_FOUND, `Method ${JSON.stringify(method)} is not implemented by this server under ${requested}.`);
309
+ }
310
+
311
+ /**
312
+ * Answers one decoded message. Returns `null` for a notification — the one
313
+ * shape the protocol forbids a response to.
314
+ */
315
+ export async function handleRpcMessage(message: unknown, host: ToolHostRuntime, session: McpSession): Promise<JsonRpcResponse | null> {
316
+ if (Array.isArray(message)) {
317
+ // No revision this server advertises supports batching, and answering a
318
+ // batch anyway would leave a client believing it negotiated something it
319
+ // did not.
320
+ return fail(null, INVALID_REQUEST, "JSON-RPC batches are not part of the protocol revisions this server speaks.");
321
+ }
322
+ if (!isRecord(message)) return fail(null, INVALID_REQUEST, "A JSON-RPC message must be an object.");
323
+ if (message["jsonrpc"] !== JSON_RPC_VERSION) return fail(idOf(message), INVALID_REQUEST, 'A JSON-RPC message must declare jsonrpc "2.0".');
324
+
325
+ // ID HYGIENE, BEFORE ANY DISPATCH. An id of an unusable shape cannot be
326
+ // echoed, so the client could never correlate the result — and `tools/call`
327
+ // is side-effecting. Coercing such an id to null and running the call anyway
328
+ // would publish evidence on behalf of a request nobody can match a response
329
+ // to. An absent id member is a notification and is not an id at all.
330
+ if (!isNotification(message) && !isUsableId(message["id"])) {
331
+ return fail(null, INVALID_REQUEST, "A JSON-RPC id must be a string, a number, or null.");
332
+ }
333
+
334
+ const method = message["method"];
335
+ if (typeof method !== "string") return fail(idOf(message), INVALID_REQUEST, "A JSON-RPC message must name a string method.");
336
+
337
+ const id = idOf(message);
338
+ const params = message["params"];
339
+
340
+ // SHAPE DECIDES, NOT THE METHOD NAME. Notifications — `initialized`,
341
+ // cancellation, progress — are accepted and ignored: this server keeps no
342
+ // handshake gate and starts no work a client can cancel. A message carrying
343
+ // an id is a request whatever it is named, so an id-bearing
344
+ // `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 trimmed = line.trim();
398
+ if (trimmed === "") return null;
399
+ let decoded: unknown;
400
+ try {
401
+ decoded = JSON.parse(trimmed);
402
+ } catch {
403
+ return fail(null, PARSE_ERROR, "The message could not be parsed as JSON.");
404
+ }
405
+ return handleRpcMessage(decoded, host, session);
406
+ }
407
+
408
+ /**
409
+ * Serializes a response onto one line.
410
+ *
411
+ * The framing requires that no message contain an embedded newline. Every
412
+ * newline this server produces is inside a rendered blocker string, and
413
+ * `JSON.stringify` escapes those — so the invariant holds by construction
414
+ * rather than by a scrub pass that could be forgotten.
415
+ */
416
+ export function encodeResponse(response: JsonRpcResponse): string {
417
+ return `${JSON.stringify(response)}\n`;
418
+ }
419
+
420
+ // ── The loop ─────────────────────────────────────────────────────────────────
421
+
422
+ /**
423
+ * Serves until the input ends. Messages are answered in arrival order: the
424
+ * transport is one connection with one client, and interleaving responses buys
425
+ * nothing but a reordering bug.
426
+ */
427
+ export async function serveStdio(input: NodeJS.ReadableStream, write: (line: string) => void, host: ToolHostRuntime): Promise<void> {
428
+ const session = createSession();
429
+ const lines = createInterface({ input, crlfDelay: Infinity });
430
+ for await (const line of lines) {
431
+ const response = await handleRpcLine(line, host, session);
432
+ if (response !== null) write(encodeResponse(response));
433
+ }
434
+ }
435
+
436
+ /** Built inside a function, never at import time: the sensor's env rule. */
437
+ export function defaultToolRuntime(): ToolHostRuntime {
438
+ return { cwd: process.cwd(), env: process.env };
439
+ }
440
+
441
+ /** The spelling the filesystem can vouch for: the realpath where it can answer, the spelling itself where it cannot. */
442
+ function canonicalEntryPath(entryPath: string): string {
443
+ try {
444
+ return realpathSync(entryPath);
445
+ } catch {
446
+ return entryPath;
447
+ }
448
+ }
449
+
450
+ /**
451
+ * Whether this module is the entry the process was started with.
452
+ *
453
+ * argv and `import.meta.url` may spell the same file differently: argv is the
454
+ * caller's spelling, and Node builds the module URL from the realpath by
455
+ * default but from the caller's spelling under `--preserve-symlinks-main`. So
456
+ * each side is canonicalized independently and the canonical forms compared:
457
+ * a symlinked spelling matches its realpath whenever the link can be read
458
+ * (`/tmp` → `/private/tmp` on macOS, a client config's stored path, a pnpm
459
+ * workspace link), and equal spellings still match when neither side resolves.
460
+ *
461
+ * What is NOT claimed: a symlink the filesystem cannot resolve cannot be seen
462
+ * through, and the failing-exit-code floor below sits inside this guard, so an
463
+ * under-match exits 0 in silence — the server exiting without ever serving,
464
+ * a dead transport where the client expected one. The floor cannot be hoisted
465
+ * above the guard: that would stamp a failing exit code on every process that
466
+ * merely *imports* this module. And a non-`file:` module href (a bundled or
467
+ * single-executable build) never matches — such a build must invoke `main`
468
+ * explicitly.
469
+ */
470
+ export function invokedDirectly(argvEntry: string | undefined, moduleHref: string): boolean {
471
+ if (argvEntry === undefined) return false;
472
+ let modulePath: string;
473
+ try {
474
+ modulePath = fileURLToPath(moduleHref);
475
+ } catch {
476
+ return false;
477
+ }
478
+ return canonicalEntryPath(argvEntry) === canonicalEntryPath(modulePath);
479
+ }
480
+
481
+ export async function main(): Promise<void> {
482
+ await serveStdio(process.stdin, (line) => process.stdout.write(line), defaultToolRuntime());
483
+ }
484
+
485
+ if (invokedDirectly(process.argv[1], import.meta.url)) {
486
+ // Fail closed for the same reason the CLI does: a server that fell over
487
+ // without saying so must not look like a clean shutdown.
488
+ process.exitCode = 1;
489
+ main()
490
+ .then(() => {
491
+ process.exitCode = 0;
492
+ })
493
+ .catch((error: unknown) => {
494
+ process.stderr.write(`${error instanceof Error ? (error.stack ?? error.message) : String(error)}\n`);
495
+ process.exitCode = 1;
496
+ });
497
+ }