@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 +105 -0
- package/NOTICE +5 -0
- package/package.json +28 -0
- package/src/index.ts +52 -0
- package/src/server.ts +576 -0
- package/src/stdio.ts +535 -0
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
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
|
+
}
|