@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 +202 -0
- package/NOTICE +5 -0
- package/package.json +23 -0
- package/src/index.ts +52 -0
- package/src/server.ts +493 -0
- package/src/stdio.ts +497 -0
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
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
|
+
}
|