bantamkit-mcp 0.35.8 → 0.35.9
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/README.md +13 -2
- package/dist/assets.js +28 -4
- package/dist/cli.js +55 -24
- package/dist/eventlog.d.ts +8 -1
- package/dist/eventlog.js +7 -0
- package/dist/hookadapter.js +16 -2
- package/dist/hostinstall.d.ts +2 -2
- package/dist/hostinstall.js +69 -6
- package/dist/mcp/identity.js +27 -1
- package/dist/mcp/server.js +159 -14
- package/dist/mcp/transport.d.ts +51 -6
- package/dist/mcp/transport.js +217 -26
- package/dist/pyargparse.js +48 -2
- package/dist/tokenledger.js +20 -4
- package/dist/updatecheck.d.ts +15 -3
- package/dist/updatecheck.js +23 -8
- package/package.json +2 -2
package/dist/mcp/server.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* A port of `runtime-py/src/bantamkit/mcpserver.py`'s `build_server`, across two unrelated
|
|
5
5
|
* SDK lineages — Python runs `mcp` 2.0.0 (`from mcp.server import MCPServer`), this runs
|
|
6
|
-
* `@modelcontextprotocol/sdk` 1.
|
|
6
|
+
* `@modelcontextprotocol/sdk` 1.31.0. Where the two SDKs make a difference unavoidable it is
|
|
7
7
|
* a `ruling` case in `tools/conformance/suites/wire.mjs`, carrying its reason and required to
|
|
8
8
|
* KEEP differing — a ruling whose case quietly started matching fails the suite too. Where
|
|
9
9
|
* they do not, this file takes the reference's answer even when the SDK offers an easier one.
|
|
@@ -27,10 +27,10 @@
|
|
|
27
27
|
* part of the wire and no asset carries it.
|
|
28
28
|
*/
|
|
29
29
|
import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs';
|
|
30
|
-
import { dirname, join } from 'node:path';
|
|
30
|
+
import { dirname, join, sep } from 'node:path';
|
|
31
31
|
import { fileURLToPath } from 'node:url';
|
|
32
32
|
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
33
|
-
import { CallToolRequestSchema, ErrorCode, GetPromptRequestSchema, ListPromptsRequestSchema, ListResourceTemplatesRequestSchema, ListResourcesRequestSchema, ListToolsRequestSchema, ReadResourceRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
|
|
33
|
+
import { CallToolRequestSchema, ErrorCode, GetPromptRequestSchema, InitializeRequestSchema, ListPromptsRequestSchema, ListResourceTemplatesRequestSchema, ListResourcesRequestSchema, ListToolsRequestSchema, PingRequestSchema, ReadResourceRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
|
|
34
34
|
import { AssetNotFound, assetsRoot, loadSkill, loadToolAsset } from '../assets.js';
|
|
35
35
|
import { BantamError } from '../errors.js';
|
|
36
36
|
import { bantamkitReadUnknownPart, documentError, documentManifest, documentOffsetPastEnd, documentPage, schemaError, toolFailed, } from '../contract.js';
|
|
@@ -471,6 +471,22 @@ function runTool(name, memory, args, version, log, documents) {
|
|
|
471
471
|
out.set('bantamkit_degraded', { t: 'str', v: line });
|
|
472
472
|
return { t: 'dict', v: out };
|
|
473
473
|
};
|
|
474
|
+
/**
|
|
475
|
+
* A reply that is ONE JSON DOCUMENT rendered as text (`skill_audit`, `token_ledger`), plus
|
|
476
|
+
* the footer if there is one, as a `bantamkit_degraded` key LAST in that document (#124).
|
|
477
|
+
*
|
|
478
|
+
* `noted` would append a sentence after the closing brace and the reply would stop parsing.
|
|
479
|
+
* The key is spliced into the text rather than the document being re-parsed, so every byte
|
|
480
|
+
* of a degraded reply before the key is the healthy reply's: `asJson` is
|
|
481
|
+
* `JSON.stringify(dict, null, 2)` of a non-empty object, so it always ends `\n}`, and the key
|
|
482
|
+
* goes in at that two-space indent, exactly where `json.dumps(indent=2)` would put it.
|
|
483
|
+
*/
|
|
484
|
+
const notedJson = (document) => {
|
|
485
|
+
const line = notice();
|
|
486
|
+
if (line === '')
|
|
487
|
+
return document;
|
|
488
|
+
return `${document.slice(0, -2)},\n "bantamkit_degraded": ${JSON.stringify(line)}\n}`;
|
|
489
|
+
};
|
|
474
490
|
switch (name) {
|
|
475
491
|
case 'memory_save': {
|
|
476
492
|
const links = args.get('links');
|
|
@@ -802,7 +818,7 @@ function runTool(name, memory, args, version, log, documents) {
|
|
|
802
818
|
findings: result.findings.length,
|
|
803
819
|
omissions: result.omissions.length,
|
|
804
820
|
});
|
|
805
|
-
return { value: { t: 'str', v:
|
|
821
|
+
return { value: { t: 'str', v: notedJson(result.asJson()) }, wrapped: true };
|
|
806
822
|
});
|
|
807
823
|
}
|
|
808
824
|
case 'repo_map': {
|
|
@@ -921,7 +937,7 @@ function runTool(name, memory, args, version, log, documents) {
|
|
|
921
937
|
requests: result.requests,
|
|
922
938
|
sessions: result.sessions.length,
|
|
923
939
|
});
|
|
924
|
-
return { value: { t: 'str', v:
|
|
940
|
+
return { value: { t: 'str', v: notedJson(tokenledger.asJson(result)) }, wrapped: true };
|
|
925
941
|
});
|
|
926
942
|
}
|
|
927
943
|
case 'work_plan':
|
|
@@ -1063,7 +1079,8 @@ export function buildServer(memory, wire, version, log = EventLog.fromEnv(memory
|
|
|
1063
1079
|
}));
|
|
1064
1080
|
server.setRequestHandler(ReadResourceRequestSchema, (request) => {
|
|
1065
1081
|
const uri = request.params.uri;
|
|
1066
|
-
const
|
|
1082
|
+
const skillName = templateName(uri, 'bantamkit://skills/');
|
|
1083
|
+
const skill = skillName === null ? null : [uri, skillName];
|
|
1067
1084
|
if (skill) {
|
|
1068
1085
|
// A skill that pairs with a tool is served on that tool's surfaces and no other. The
|
|
1069
1086
|
// pairing is the one the reference's `filegraph.py` spells — `load_tool("file_graph")`
|
|
@@ -1094,9 +1111,14 @@ export function buildServer(memory, wire, version, log = EventLog.fromEnv(memory
|
|
|
1094
1111
|
}
|
|
1095
1112
|
return { contents: [{ mimeType: 'text/plain', text, uri }] };
|
|
1096
1113
|
}
|
|
1097
|
-
const
|
|
1114
|
+
const rubricName = templateName(uri, 'bantamkit://rubrics/');
|
|
1115
|
+
const rubric = rubricName === null ? null : [uri, rubricName];
|
|
1098
1116
|
if (rubric) {
|
|
1099
|
-
|
|
1117
|
+
// Joined as TEXT, not with `path.join`: `join` folds `x/../y` lexically, and the
|
|
1118
|
+
// reference's `Path / f"{name}.yaml"` does not, so `x%2F..%2Fcode-quality` (no
|
|
1119
|
+
// traversal ABOVE the start, so the security check passes it) would read
|
|
1120
|
+
// `code-quality.yaml` here and nothing there. The kernel resolves `x/..` on both.
|
|
1121
|
+
const path = `${join(assetsRoot(), 'rubrics')}${sep}${rubric[1]}.yaml`;
|
|
1100
1122
|
let text;
|
|
1101
1123
|
try {
|
|
1102
1124
|
text = pyReadText(path);
|
|
@@ -1106,11 +1128,8 @@ export function buildServer(memory, wire, version, log = EventLog.fromEnv(memory
|
|
|
1106
1128
|
}
|
|
1107
1129
|
return { contents: [{ mimeType: 'text/plain', text, uri }] };
|
|
1108
1130
|
}
|
|
1109
|
-
//
|
|
1110
|
-
//
|
|
1111
|
-
// resource", so `[^/]*` and not `[^/]+`. A traversal is still not a match at all,
|
|
1112
|
-
// because `bantamkit://skills/../../etc/passwd` has separators in it, and the reference
|
|
1113
|
-
// answers that one -32602.
|
|
1131
|
+
// No template took the URI (`templateName` says how the reference decides), so the
|
|
1132
|
+
// reference's `ResourceNotFoundError`: -32602 `Unknown resource: <uri>` (#180).
|
|
1114
1133
|
throw rpcError(ErrorCode.InvalidParams, `Unknown resource: ${uri}`, { uri });
|
|
1115
1134
|
});
|
|
1116
1135
|
server.setRequestHandler(CallToolRequestSchema, (request, extra) => {
|
|
@@ -1158,14 +1177,140 @@ export function buildServer(memory, wire, version, log = EventLog.fromEnv(memory
|
|
|
1158
1177
|
...(structuredValue === null ? {} : { structuredContent: toJs(structuredValue) }),
|
|
1159
1178
|
};
|
|
1160
1179
|
});
|
|
1180
|
+
guardRequests(server, wire);
|
|
1161
1181
|
return server;
|
|
1162
1182
|
}
|
|
1183
|
+
/**
|
|
1184
|
+
* The reference's ONE answer to a request it will not hand to a handler, byte for byte:
|
|
1185
|
+
* `mcp` 2.0.0's `ServerRunner._on_request` raises `MCPError(INVALID_PARAMS, "Invalid request
|
|
1186
|
+
* parameters", data="")` both for params its pydantic model refuses and for a served method
|
|
1187
|
+
* asked before `initialize` was accepted ("pinned compat: the same error shape the union
|
|
1188
|
+
* validation produced"). No pydantic or zod text reaches the wire.
|
|
1189
|
+
*/
|
|
1190
|
+
const invalidParams = () => rpcError(ErrorCode.InvalidParams, 'Invalid request parameters', '');
|
|
1191
|
+
/** The request schema of every method this server answers; `guardRequests` refuses a gap. */
|
|
1192
|
+
const REQUEST_SCHEMAS = new Map([
|
|
1193
|
+
InitializeRequestSchema,
|
|
1194
|
+
PingRequestSchema,
|
|
1195
|
+
ListToolsRequestSchema,
|
|
1196
|
+
CallToolRequestSchema,
|
|
1197
|
+
ListResourcesRequestSchema,
|
|
1198
|
+
ListResourceTemplatesRequestSchema,
|
|
1199
|
+
ReadResourceRequestSchema,
|
|
1200
|
+
ListPromptsRequestSchema,
|
|
1201
|
+
GetPromptRequestSchema,
|
|
1202
|
+
].map((schema) => [schema.shape.method.value, schema]));
|
|
1203
|
+
/** Only `ping` may be asked before `initialize` (`runner._INIT_EXEMPT`). */
|
|
1204
|
+
const INIT_EXEMPT = new Set(['ping']);
|
|
1205
|
+
/**
|
|
1206
|
+
* #147 AND #178: the two refusals the reference makes before a handler runs, put in front of
|
|
1207
|
+
* every handler the SDK holds.
|
|
1208
|
+
*
|
|
1209
|
+
* #147. The TS SDK parses a request's params INSIDE the handler it registered, so a refusal
|
|
1210
|
+
* left as a thrown zod error: -32603 Internal error with the issue list pretty-printed as the
|
|
1211
|
+
* message (`tools/call` with `params: {}`, `arguments: "notadict"`, `resources/read` with no
|
|
1212
|
+
* `uri`, ...). The reference answers all of them -32602 with a short message. The schema is
|
|
1213
|
+
* the SDK's own, checked here first, so what is refused does not change — only how.
|
|
1214
|
+
*
|
|
1215
|
+
* #178. The TS SDK has no initialization gate: a `tools/call` with no `initialize` before it
|
|
1216
|
+
* was served. The reference refuses every method it serves, except `ping`, until an
|
|
1217
|
+
* `initialize` has been ACCEPTED (its answer, not `notifications/initialized`), with the same
|
|
1218
|
+
* -32602; a method it does not serve is -32601 either way, which the SDK already says because
|
|
1219
|
+
* an unserved method never reaches this table. The gate opens SYNCHRONOUSLY when a valid
|
|
1220
|
+
* `initialize` reaches its handler: the reference parks its read loop while `initialize` runs,
|
|
1221
|
+
* so a request written in the same chunk behind it is served, and here the next handler call
|
|
1222
|
+
* is a later microtask in arrival order. `_oninitialize` cannot refuse a valid request (it
|
|
1223
|
+
* negotiates down to its own latest version), so opening before it returns is not optimism.
|
|
1224
|
+
*
|
|
1225
|
+
* THE SEAM IS THE SDK'S HANDLER TABLE, `Protocol._requestHandlers`: TypeScript-private, and
|
|
1226
|
+
* the only place that sees `initialize` and `ping`, which the SDK registers itself. Pinned to
|
|
1227
|
+
* the capped SDK, and checked here: a table that is not a Map, or a handler with no schema
|
|
1228
|
+
* in `REQUEST_SCHEMAS`, stops the server at startup rather than serving it unguarded.
|
|
1229
|
+
*/
|
|
1230
|
+
function guardRequests(server, wire) {
|
|
1231
|
+
const table = server._requestHandlers;
|
|
1232
|
+
if (!(table instanceof Map)) {
|
|
1233
|
+
throw new Error('the MCP SDK keeps no _requestHandlers map: the #147/#178 request guard has no seam');
|
|
1234
|
+
}
|
|
1235
|
+
const handlers = table;
|
|
1236
|
+
let accepted = false;
|
|
1237
|
+
for (const [method, handler] of [...handlers]) {
|
|
1238
|
+
const schema = REQUEST_SCHEMAS.get(method);
|
|
1239
|
+
if (schema === undefined)
|
|
1240
|
+
throw new Error(`no request schema for served method ${method}: the #147 guard would skip it`);
|
|
1241
|
+
handlers.set(method, (request, extra) => {
|
|
1242
|
+
// pydantic's optional fields (`cursor`, `arguments`, `_meta`, ...) are `X | None =
|
|
1243
|
+
// None`, so `null` is the same as absent there; zod's `.optional()` refuses it. A
|
|
1244
|
+
// REQUIRED field (`name`, `uri`) is still refused once its `null` is set aside.
|
|
1245
|
+
const params = request.params;
|
|
1246
|
+
if (params !== null && typeof params === 'object' && !Array.isArray(params)) {
|
|
1247
|
+
for (const [key, value] of Object.entries(params))
|
|
1248
|
+
if (value === null)
|
|
1249
|
+
delete params[key];
|
|
1250
|
+
}
|
|
1251
|
+
// `?.`: the in-memory test harnesses hand `buildServer` a two-method stub for `wire`.
|
|
1252
|
+
if (wire.paramsRefused?.(extra.requestId) === true || !schema.safeParse(request).success)
|
|
1253
|
+
throw invalidParams();
|
|
1254
|
+
if (method === 'initialize')
|
|
1255
|
+
accepted = true;
|
|
1256
|
+
else if (!accepted && !INIT_EXEMPT.has(method))
|
|
1257
|
+
throw invalidParams();
|
|
1258
|
+
return handler(request, extra);
|
|
1259
|
+
});
|
|
1260
|
+
}
|
|
1261
|
+
}
|
|
1262
|
+
/**
|
|
1263
|
+
* `mcp` 2.0.0's `ResourceTemplate.matches` for a template `<prefix>{name}`: the decoded
|
|
1264
|
+
* name, or `null` when the reference's resource manager would answer `Unknown resource`
|
|
1265
|
+
* (#180). Three steps, each measured against the running reference:
|
|
1266
|
+
*
|
|
1267
|
+
* - `UriTemplate.match`: a URI past 65 536 code points is no match; the literal prefix must
|
|
1268
|
+
* be there; a simple `{name}` stops at `/ ? # & ,`, so a tail holding any of them is no
|
|
1269
|
+
* match (`memory?x=1`, `memory#frag`, `a/b`). The empty tail matches (`bantamkit://skills/`
|
|
1270
|
+
* is -32603 `unknown skill asset: ` on both sides).
|
|
1271
|
+
* - `urllib.parse.unquote`: `%XX` runs decoded as UTF-8 with replacement, so `%6Demory` IS
|
|
1272
|
+
* `memory` and `..%2F..%2Fpackage` is `../../package`.
|
|
1273
|
+
* - `ResourceSecurity`'s default policy on the decoded value: a NUL, a `..` that climbs
|
|
1274
|
+
* above the start (`/` and `\` both separators), or an absolute path (`/x`, `\x`, `C:`)
|
|
1275
|
+
* raises `ResourceSecurityError`, which the manager turns into `Unknown resource`.
|
|
1276
|
+
*
|
|
1277
|
+
* Before this the port matched `[^/]*` on the RAW text and answered -32603 `unknown skill
|
|
1278
|
+
* asset: ..` for all three of the issue's URIs; neither side ever read outside the pack.
|
|
1279
|
+
*/
|
|
1280
|
+
function templateName(uri, prefix) {
|
|
1281
|
+
if ([...uri].length > 65_536 || !uri.startsWith(prefix))
|
|
1282
|
+
return null;
|
|
1283
|
+
const raw = uri.slice(prefix.length);
|
|
1284
|
+
if (/[/?#&,]/.test(raw))
|
|
1285
|
+
return null;
|
|
1286
|
+
const name = raw.replace(/(?:%[0-9A-Fa-f]{2})+/g, (run) => new TextDecoder('utf-8').decode(Uint8Array.from(run.split('%').slice(1), (h) => parseInt(h, 16))));
|
|
1287
|
+
if (name.includes('\0') || climbs(name) || isAbsolutePath(name))
|
|
1288
|
+
return null;
|
|
1289
|
+
return name;
|
|
1290
|
+
}
|
|
1291
|
+
/** `mcp.shared.path_security.contains_path_traversal`: does a `..` climb above the start? */
|
|
1292
|
+
function climbs(value) {
|
|
1293
|
+
let depth = 0;
|
|
1294
|
+
for (const part of value.replace(/\\/g, '/').split('/')) {
|
|
1295
|
+
if (part === '..') {
|
|
1296
|
+
depth -= 1;
|
|
1297
|
+
if (depth < 0)
|
|
1298
|
+
return true;
|
|
1299
|
+
}
|
|
1300
|
+
else if (part !== '' && part !== '.')
|
|
1301
|
+
depth += 1;
|
|
1302
|
+
}
|
|
1303
|
+
return false;
|
|
1304
|
+
}
|
|
1305
|
+
/** `mcp.shared.path_security.is_absolute_path`: POSIX, UNC/root-relative, or a drive letter. */
|
|
1306
|
+
const isAbsolutePath = (value) => value !== '' && (value[0] === '/' || value[0] === '\\' || /^[A-Za-z]:/.test(value));
|
|
1163
1307
|
/** `params.arguments` out of the raw request line, with every number literal intact. */
|
|
1164
1308
|
function argumentsFrom(line) {
|
|
1165
1309
|
try {
|
|
1166
1310
|
const params = asDict(parseJson(line)).get('params');
|
|
1167
1311
|
const args = params === undefined ? undefined : asDict(params).get('arguments');
|
|
1168
|
-
|
|
1312
|
+
// `arguments: null` is no arguments (pydantic's `dict | None = None`, #147).
|
|
1313
|
+
return args === undefined || args.t === 'null' ? { t: 'dict', v: new Map() } : args;
|
|
1169
1314
|
}
|
|
1170
1315
|
catch {
|
|
1171
1316
|
// Unreachable over a line the SDK already parsed; a re-parse failure must not be the
|
package/dist/mcp/transport.d.ts
CHANGED
|
@@ -10,13 +10,13 @@
|
|
|
10
10
|
* The SDK exposes no hook for this — `ReadBuffer.readMessage` parses inside the buffer and
|
|
11
11
|
* the buffer is private — so the buffering is done here instead. It is thirty lines
|
|
12
12
|
* (`indexOf('\n')`, strip a trailing `\r`) and it keeps the LINE next to the parsed
|
|
13
|
-
* message, keyed
|
|
13
|
+
* message, keyed PER MESSAGE (see 3). A handler then re-reads its own arguments with
|
|
14
14
|
* `pyjson.parseJson`, which tags `5.0` as a float and keeps an integer past 2^53 exact.
|
|
15
15
|
*
|
|
16
16
|
* 2. THE OUTGOING NUMBER LITERALS AND THE ENVELOPE ORDER. `serializeMessage` is
|
|
17
17
|
* `JSON.stringify(message) + '\n'`, which un-does the work above on the way out: the same
|
|
18
18
|
* `5.0` in a `structuredContent` leaves as `5`. So a tool handler registers the EXACT text
|
|
19
|
-
* of its result here, keyed
|
|
19
|
+
* of its result here, keyed per message, and `send` splices it in. Everything else still
|
|
20
20
|
* goes through `JSON.stringify`, which is byte-for-byte what pydantic-core writes for
|
|
21
21
|
* strings, bools, nulls and safe integers.
|
|
22
22
|
*
|
|
@@ -25,6 +25,30 @@
|
|
|
25
25
|
* order is not observable to a conforming client, but it costs nothing here and it removes
|
|
26
26
|
* a difference from the list rather than adding a ruling to it.
|
|
27
27
|
*
|
|
28
|
+
* 3. ONE ENTRY PER MESSAGE, NOT PER ID (#146, #148). The side table used to be keyed by the
|
|
29
|
+
* client's id, so two requests in flight under ONE id (JSON-RPC forbids it; a client can
|
|
30
|
+
* still do it) shared one slot: the second line overwrote the first and both answers were
|
|
31
|
+
* computed from the second request's arguments. And the SDK's schema refuses an integer id
|
|
32
|
+
* past 2^53 (`JSON.parse` has already rounded it), so such a request got no answer at all.
|
|
33
|
+
* Both are fixed the same way: every request is handed to the SDK under a TOKEN of this
|
|
34
|
+
* file's own (`bantamkit-<n>`, unique per message), and the client's id is kept as the
|
|
35
|
+
* exact TEXT it arrived as, read losslessly with `pyjson.parseJson`; `send` writes that
|
|
36
|
+
* text back. The reference answers each request from its own arguments and echoes a big
|
|
37
|
+
* id exactly, so this is the port catching up, not a choice. A `notifications/cancelled`
|
|
38
|
+
* naming a client id is pointed at the newest token carrying it.
|
|
39
|
+
*
|
|
40
|
+
* 4. A FRAME THE SDK WOULD DROP IS ANSWERED (#148). A line that is not JSON is answered
|
|
41
|
+
* `-32700 Parse error`, and JSON that is not a JSON-RPC message (a batch array, a bare
|
|
42
|
+
* number, a message with no `jsonrpc`) `-32600 Invalid Request`, both with `id: null`
|
|
43
|
+
* as JSON-RPC 2.0 section 5.1 requires, and both in the bytes `mcpstdio.py` writes. Before,
|
|
44
|
+
* both went to `onerror` and the client waited forever. A blank line is still skipped.
|
|
45
|
+
*
|
|
46
|
+
* 5. A HOST THAT CLOSED ITS READ END ENDS THE SESSION QUIETLY (#181). `process.stdout` had no
|
|
47
|
+
* `error` listener, so an EPIPE mid-answer was an unhandled `error` event: exit 1 and a
|
|
48
|
+
* stack. It is now what stdin EOF already was, the host going away: the transport closes,
|
|
49
|
+
* stdin is released, and the process exits 0 with nothing on stderr. Any OTHER write error
|
|
50
|
+
* is rethrown and still crashes, because it is not that.
|
|
51
|
+
*
|
|
28
52
|
* NOTHING IN THIS FILE WRITES TO STDOUT EXCEPT `send`. That is the invariant the whole
|
|
29
53
|
* package is under, and it is checked by `test/server.test.mjs`, which asserts that every
|
|
30
54
|
* line a real session produced parses as a frame and that stdout never ends mid-line.
|
|
@@ -33,6 +57,12 @@ import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
|
|
|
33
57
|
import type { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js';
|
|
34
58
|
type Readable = NodeJS.ReadableStream;
|
|
35
59
|
type Writable = NodeJS.WritableStream;
|
|
60
|
+
/**
|
|
61
|
+
* The two frames JSON-RPC 2.0 section 5.1 prescribes for a message no id can be read from,
|
|
62
|
+
* byte for byte what `mcpstdio.py` writes (`JSONRPCError(id=None, ...)` dumped by the SDK).
|
|
63
|
+
*/
|
|
64
|
+
export declare const PARSE_ERROR_FRAME = "{\"jsonrpc\":\"2.0\",\"id\":null,\"error\":{\"code\":-32700,\"message\":\"Parse error\"}}";
|
|
65
|
+
export declare const INVALID_REQUEST_FRAME = "{\"jsonrpc\":\"2.0\",\"id\":null,\"error\":{\"code\":-32600,\"message\":\"Invalid Request\"}}";
|
|
36
66
|
export declare class RawStdioTransport implements Transport {
|
|
37
67
|
private readonly input;
|
|
38
68
|
private readonly output;
|
|
@@ -42,18 +72,31 @@ export declare class RawStdioTransport implements Transport {
|
|
|
42
72
|
sessionId?: string;
|
|
43
73
|
private buffer;
|
|
44
74
|
private started;
|
|
45
|
-
private
|
|
46
|
-
private
|
|
75
|
+
private outputGone;
|
|
76
|
+
private sequence;
|
|
77
|
+
/** Token (the id the SDK sees) -> the request it stands for. One entry per MESSAGE. */
|
|
78
|
+
private readonly inFlight;
|
|
79
|
+
/** Client id text -> the newest token carrying it, for `notifications/cancelled`. */
|
|
80
|
+
private readonly newest;
|
|
47
81
|
private readonly ondata;
|
|
48
82
|
private readonly onstreamerror;
|
|
83
|
+
private readonly onoutputerror;
|
|
49
84
|
constructor(input?: Readable, output?: Writable);
|
|
50
|
-
/** The verbatim request line
|
|
85
|
+
/** The verbatim request line this message token stands for, or `undefined` if it is gone. */
|
|
51
86
|
rawLineFor(id: unknown): string | undefined;
|
|
52
|
-
/**
|
|
87
|
+
/**
|
|
88
|
+
* #147: whether the reference would refuse this request's params on its `_meta` alone, a
|
|
89
|
+
* member the TS SDK checks in its ENVELOPE schema and the reference only per method.
|
|
90
|
+
*/
|
|
91
|
+
paramsRefused(id: unknown): boolean;
|
|
92
|
+
/** Register the exact JSON text to send as this message's `result`. */
|
|
53
93
|
setExactResult(id: unknown, json: string): void;
|
|
54
94
|
start(): Promise<void>;
|
|
55
95
|
close(): Promise<void>;
|
|
56
96
|
send(message: JSONRPCMessage): Promise<void>;
|
|
97
|
+
private write;
|
|
98
|
+
/** Answer a line no id could be read from, in order with every other frame. */
|
|
99
|
+
private refuse;
|
|
57
100
|
/**
|
|
58
101
|
* One frame, in the reference's key order, with an exact `result` when one was registered.
|
|
59
102
|
*
|
|
@@ -64,5 +107,7 @@ export declare class RawStdioTransport implements Transport {
|
|
|
64
107
|
*/
|
|
65
108
|
private frame;
|
|
66
109
|
private consume;
|
|
110
|
+
/** Point a `notifications/cancelled` at the token now standing for the id it names. */
|
|
111
|
+
private retarget;
|
|
67
112
|
}
|
|
68
113
|
export {};
|
package/dist/mcp/transport.js
CHANGED
|
@@ -1,7 +1,37 @@
|
|
|
1
1
|
import { JSONRPCMessageSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
2
2
|
import { PY_LINESEP } from '../memory/pyfs.js';
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
import { parseJson } from '../pyjson.js';
|
|
4
|
+
/**
|
|
5
|
+
* The two frames JSON-RPC 2.0 section 5.1 prescribes for a message no id can be read from,
|
|
6
|
+
* byte for byte what `mcpstdio.py` writes (`JSONRPCError(id=None, ...)` dumped by the SDK).
|
|
7
|
+
*/
|
|
8
|
+
export const PARSE_ERROR_FRAME = '{"jsonrpc":"2.0","id":null,"error":{"code":-32700,"message":"Parse error"}}';
|
|
9
|
+
export const INVALID_REQUEST_FRAME = '{"jsonrpc":"2.0","id":null,"error":{"code":-32600,"message":"Invalid Request"}}';
|
|
10
|
+
/** The write errors that mean the reader of stdout is gone, on POSIX and on Windows (libuv EOF). */
|
|
11
|
+
const HOST_GONE = new Set(['EPIPE', 'ECONNRESET', 'EOF', 'ERR_STREAM_DESTROYED']);
|
|
12
|
+
/**
|
|
13
|
+
* The client's id as the exact JSON text to echo, or `undefined` when it is not one a
|
|
14
|
+
* JSON-RPC id may be. A string is re-serialised (`JSON.stringify` is pydantic-core's string
|
|
15
|
+
* spelling); a number is read again LOSSLESSLY, because `JSON.parse` has already rounded an
|
|
16
|
+
* integer past 2^53 and cannot tell `1.0` from `1`. The reference's `RequestId` is a STRICT
|
|
17
|
+
* int or a str, so a float literal is no id at all there either.
|
|
18
|
+
*/
|
|
19
|
+
function clientIdText(line, id) {
|
|
20
|
+
if (typeof id === 'string')
|
|
21
|
+
return JSON.stringify(id);
|
|
22
|
+
if (typeof id !== 'number')
|
|
23
|
+
return undefined;
|
|
24
|
+
try {
|
|
25
|
+
const frame = parseJson(line);
|
|
26
|
+
const value = frame.t === 'dict' ? frame.v.get('id') : undefined;
|
|
27
|
+
return value?.t === 'int' ? value.v.toString() : undefined;
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return Number.isSafeInteger(id) ? String(id) : undefined;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
/** The JSON text of an id named INSIDE a message (a cancellation's `requestId`). */
|
|
34
|
+
const namedIdText = (id) => typeof id === 'string' ? JSON.stringify(id) : Number.isSafeInteger(id) ? String(id) : undefined;
|
|
5
35
|
export class RawStdioTransport {
|
|
6
36
|
input;
|
|
7
37
|
output;
|
|
@@ -11,21 +41,45 @@ export class RawStdioTransport {
|
|
|
11
41
|
sessionId;
|
|
12
42
|
buffer = Buffer.alloc(0);
|
|
13
43
|
started = false;
|
|
14
|
-
|
|
15
|
-
|
|
44
|
+
outputGone = false;
|
|
45
|
+
sequence = 0;
|
|
46
|
+
/** Token (the id the SDK sees) -> the request it stands for. One entry per MESSAGE. */
|
|
47
|
+
inFlight = new Map();
|
|
48
|
+
/** Client id text -> the newest token carrying it, for `notifications/cancelled`. */
|
|
49
|
+
newest = new Map();
|
|
16
50
|
ondata = (chunk) => this.consume(chunk);
|
|
17
51
|
onstreamerror = (error) => this.onerror?.(error);
|
|
52
|
+
onoutputerror = (error) => {
|
|
53
|
+
if (!HOST_GONE.has(error.code ?? ''))
|
|
54
|
+
throw error;
|
|
55
|
+
if (this.outputGone)
|
|
56
|
+
return;
|
|
57
|
+
this.outputGone = true;
|
|
58
|
+
// The host is gone, so nothing will ever read another answer: stop reading too, or a
|
|
59
|
+
// stdin the host left open would keep the process alive with nowhere to write.
|
|
60
|
+
this.input.destroy?.();
|
|
61
|
+
void this.close();
|
|
62
|
+
};
|
|
18
63
|
constructor(input = process.stdin, output = process.stdout) {
|
|
19
64
|
this.input = input;
|
|
20
65
|
this.output = output;
|
|
21
66
|
}
|
|
22
|
-
/** The verbatim request line
|
|
67
|
+
/** The verbatim request line this message token stands for, or `undefined` if it is gone. */
|
|
23
68
|
rawLineFor(id) {
|
|
24
|
-
return this.
|
|
69
|
+
return typeof id === 'string' ? this.inFlight.get(id)?.line : undefined;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* #147: whether the reference would refuse this request's params on its `_meta` alone, a
|
|
73
|
+
* member the TS SDK checks in its ENVELOPE schema and the reference only per method.
|
|
74
|
+
*/
|
|
75
|
+
paramsRefused(id) {
|
|
76
|
+
return typeof id === 'string' && this.inFlight.get(id)?.paramsRefused === true;
|
|
25
77
|
}
|
|
26
|
-
/** Register the exact JSON text to send as this
|
|
78
|
+
/** Register the exact JSON text to send as this message's `result`. */
|
|
27
79
|
setExactResult(id, json) {
|
|
28
|
-
this.
|
|
80
|
+
const entry = typeof id === 'string' ? this.inFlight.get(id) : undefined;
|
|
81
|
+
if (entry !== undefined)
|
|
82
|
+
entry.exact = json;
|
|
29
83
|
}
|
|
30
84
|
async start() {
|
|
31
85
|
if (this.started)
|
|
@@ -33,6 +87,7 @@ export class RawStdioTransport {
|
|
|
33
87
|
this.started = true;
|
|
34
88
|
this.input.on('data', this.ondata);
|
|
35
89
|
this.input.on('error', this.onstreamerror);
|
|
90
|
+
this.output.on('error', this.onoutputerror);
|
|
36
91
|
}
|
|
37
92
|
async close() {
|
|
38
93
|
this.input.off('data', this.ondata);
|
|
@@ -40,13 +95,19 @@ export class RawStdioTransport {
|
|
|
40
95
|
if (this.input.listenerCount('data') === 0)
|
|
41
96
|
this.input.pause();
|
|
42
97
|
this.buffer = Buffer.alloc(0);
|
|
43
|
-
this.
|
|
44
|
-
this.
|
|
98
|
+
this.inFlight.clear();
|
|
99
|
+
this.newest.clear();
|
|
45
100
|
this.onclose?.();
|
|
46
101
|
}
|
|
47
102
|
send(message) {
|
|
103
|
+
return this.write(this.frame(message));
|
|
104
|
+
}
|
|
105
|
+
write(text) {
|
|
48
106
|
return new Promise((resolve) => {
|
|
49
|
-
|
|
107
|
+
if (this.outputGone) {
|
|
108
|
+
resolve();
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
50
111
|
// `os.linesep`, because the reference's stdout is a TEXT stream. The Python SDK writes
|
|
51
112
|
// its frames through `TextIOWrapper(sys.stdout.buffer)` with the default
|
|
52
113
|
// `newline=None`, so on Windows every frame CPython emits ends `\r\n`. MEASURED, run
|
|
@@ -59,6 +120,10 @@ export class RawStdioTransport {
|
|
|
59
120
|
this.output.once('drain', () => resolve());
|
|
60
121
|
});
|
|
61
122
|
}
|
|
123
|
+
/** Answer a line no id could be read from, in order with every other frame. */
|
|
124
|
+
refuse(frame) {
|
|
125
|
+
void this.write(frame);
|
|
126
|
+
}
|
|
62
127
|
/**
|
|
63
128
|
* One frame, in the reference's key order, with an exact `result` when one was registered.
|
|
64
129
|
*
|
|
@@ -69,17 +134,18 @@ export class RawStdioTransport {
|
|
|
69
134
|
*/
|
|
70
135
|
frame(message) {
|
|
71
136
|
const framed = message;
|
|
72
|
-
if (framed.id === undefined)
|
|
137
|
+
if (framed.id === undefined || 'method' in framed)
|
|
73
138
|
return JSON.stringify(message);
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
|
|
139
|
+
const entry = typeof framed.id === 'string' ? this.inFlight.get(framed.id) : undefined;
|
|
140
|
+
if (entry !== undefined) {
|
|
141
|
+
this.inFlight.delete(framed.id);
|
|
142
|
+
if (this.newest.get(entry.idText) === framed.id)
|
|
143
|
+
this.newest.delete(entry.idText);
|
|
144
|
+
}
|
|
145
|
+
const id = entry?.idText ?? JSON.stringify(framed.id);
|
|
77
146
|
if ('result' in framed) {
|
|
78
|
-
|
|
79
|
-
this.exactResults.delete(key);
|
|
80
|
-
return `{"jsonrpc":"2.0","id":${id},"result":${exact ?? JSON.stringify(framed.result)}}`;
|
|
147
|
+
return `{"jsonrpc":"2.0","id":${id},"result":${entry?.exact ?? JSON.stringify(framed.result)}}`;
|
|
81
148
|
}
|
|
82
|
-
this.exactResults.delete(key);
|
|
83
149
|
if ('error' in framed)
|
|
84
150
|
return `{"jsonrpc":"2.0","id":${id},"error":${JSON.stringify(framed.error)}}`;
|
|
85
151
|
return JSON.stringify(message);
|
|
@@ -94,18 +160,143 @@ export class RawStdioTransport {
|
|
|
94
160
|
this.buffer = this.buffer.subarray(at + 1);
|
|
95
161
|
if (line.trim() === '')
|
|
96
162
|
continue;
|
|
97
|
-
let
|
|
163
|
+
let parsed;
|
|
98
164
|
try {
|
|
99
|
-
|
|
165
|
+
parsed = JSON.parse(line);
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
this.refuse(PARSE_ERROR_FRAME);
|
|
169
|
+
continue;
|
|
100
170
|
}
|
|
101
|
-
|
|
102
|
-
|
|
171
|
+
// A LONE SURROGATE is text `JSON.parse` keeps and pydantic's parser refuses, so the
|
|
172
|
+
// reference's SDK cannot read such a frame at all; `mcpstdio.py` answers it `-32600`
|
|
173
|
+
// (the standard library CAN read it). The one exception is job65/#160's: a `tools/call`
|
|
174
|
+
// carrying one in its arguments is refused BY NAME, which the server does here too.
|
|
175
|
+
if (LONE_ESCAPE.test(line) && holdsLoneSurrogate(parsed) && !refusedByName(line, parsed)) {
|
|
176
|
+
this.refuse(INVALID_REQUEST_FRAME);
|
|
103
177
|
continue;
|
|
104
178
|
}
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
179
|
+
// The reference's adapter is pydantic, which IGNORES a member it has no field for and
|
|
180
|
+
// takes `params: null` as no params; the TS SDK's schemas are strict and refuse both.
|
|
181
|
+
// Measured on both SDKs (J66-3 note): normalised here, so a frame the reference answers
|
|
182
|
+
// is not one the port calls an Invalid Request.
|
|
183
|
+
if (isObject(parsed)) {
|
|
184
|
+
for (const key of Object.keys(parsed))
|
|
185
|
+
if (!MESSAGE_KEYS.has(key))
|
|
186
|
+
delete parsed[key];
|
|
187
|
+
if (parsed['params'] === null)
|
|
188
|
+
delete parsed['params'];
|
|
189
|
+
}
|
|
190
|
+
// A REQUEST (an object with an id and a method) travels under a token of this file's
|
|
191
|
+
// own; its client id is kept as text. Anything else goes to the SDK's schema unchanged.
|
|
192
|
+
let token;
|
|
193
|
+
let idText;
|
|
194
|
+
if (isObject(parsed) && 'id' in parsed && 'method' in parsed) {
|
|
195
|
+
idText = clientIdText(line, parsed['id']);
|
|
196
|
+
if (idText === undefined) {
|
|
197
|
+
// Not a str and not an int (`null`, `1.0`, `true`): the reference's `RequestId`
|
|
198
|
+
// refuses it, and its union then reads the frame as a NOTIFICATION with the id
|
|
199
|
+
// ignored, so nothing is answered. Measured; the port does the same.
|
|
200
|
+
delete parsed['id'];
|
|
201
|
+
}
|
|
202
|
+
else {
|
|
203
|
+
token = `bantamkit-${++this.sequence}`;
|
|
204
|
+
parsed['id'] = token;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
let message;
|
|
208
|
+
let paramsRefused = false;
|
|
209
|
+
try {
|
|
210
|
+
message = JSONRPCMessageSchema.parse(parsed);
|
|
211
|
+
}
|
|
212
|
+
catch {
|
|
213
|
+
// #147: the TS SDK's envelope checks a request's `params._meta`; the reference's
|
|
214
|
+
// envelope takes any object as params and checks `_meta` with the METHOD's model,
|
|
215
|
+
// so `_meta: 5` is -32602 with the client's id there (and `_meta: null` or a
|
|
216
|
+
// `progressToken: true` is served), where this used to be -32600 with id null. A
|
|
217
|
+
// request the envelope takes once `_meta` is set aside travels on without it, and
|
|
218
|
+
// `server.ts`'s guard refuses it -32602 if the reference's model would have.
|
|
219
|
+
const params = token !== undefined && isObject(parsed) ? parsed['params'] : undefined;
|
|
220
|
+
const withoutMeta = isObject(params) && '_meta' in params ? withoutKey(parsed, params) : undefined;
|
|
221
|
+
const retry = withoutMeta === undefined ? undefined : JSONRPCMessageSchema.safeParse(withoutMeta);
|
|
222
|
+
if (retry === undefined || !retry.success) {
|
|
223
|
+
this.refuse(INVALID_REQUEST_FRAME);
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
message = retry.data;
|
|
227
|
+
paramsRefused = !referenceTakesMeta(params['_meta']);
|
|
228
|
+
}
|
|
229
|
+
if (token !== undefined && idText !== undefined) {
|
|
230
|
+
this.inFlight.set(token, paramsRefused ? { idText, line, paramsRefused } : { idText, line });
|
|
231
|
+
this.newest.set(idText, token);
|
|
232
|
+
}
|
|
233
|
+
else {
|
|
234
|
+
this.retarget(message);
|
|
235
|
+
}
|
|
108
236
|
this.onmessage?.(message);
|
|
109
237
|
}
|
|
110
238
|
}
|
|
239
|
+
/** Point a `notifications/cancelled` at the token now standing for the id it names. */
|
|
240
|
+
retarget(message) {
|
|
241
|
+
const note = message;
|
|
242
|
+
if (note.method !== 'notifications/cancelled' || note.params === undefined)
|
|
243
|
+
return;
|
|
244
|
+
const named = namedIdText(note.params.requestId);
|
|
245
|
+
const token = named === undefined ? undefined : this.newest.get(named);
|
|
246
|
+
if (token !== undefined)
|
|
247
|
+
note.params.requestId = token;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
/** Only a line spelling a surrogate escape can hold a lone surrogate (`mcpstdio._ESCAPE`). */
|
|
251
|
+
const LONE_ESCAPE = /\\u[dD][89a-fA-F][0-9a-fA-F]{2}/;
|
|
252
|
+
const LONE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/;
|
|
253
|
+
/** Whether any string in the value, member names included, holds a lone surrogate. */
|
|
254
|
+
function holdsLoneSurrogate(value) {
|
|
255
|
+
if (typeof value === 'string')
|
|
256
|
+
return LONE.test(value);
|
|
257
|
+
if (Array.isArray(value))
|
|
258
|
+
return value.some(holdsLoneSurrogate);
|
|
259
|
+
if (isObject(value))
|
|
260
|
+
return Object.entries(value).some(([key, item]) => LONE.test(key) || holdsLoneSurrogate(item));
|
|
261
|
+
return false;
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* `mcpstdio.refusal_for`'s test, less the roster: a `tools/call` with a str or int id whose
|
|
265
|
+
* `arguments` object holds a lone surrogate goes on to the server, which refuses it by name
|
|
266
|
+
* (`pyargs.loneSurrogateArgument`). The reference also requires the tool to be one it
|
|
267
|
+
* serves; for an unserved name it answers `-32600` where the port answers `Unknown tool:`.
|
|
268
|
+
*/
|
|
269
|
+
function refusedByName(line, parsed) {
|
|
270
|
+
if (!isObject(parsed) || parsed['method'] !== 'tools/call' || !('id' in parsed))
|
|
271
|
+
return false;
|
|
272
|
+
if (clientIdText(line, parsed['id']) === undefined)
|
|
273
|
+
return false;
|
|
274
|
+
const params = parsed['params'];
|
|
275
|
+
return isObject(params) && typeof params['name'] === 'string' && isObject(params['arguments']) && holdsLoneSurrogate(params['arguments']);
|
|
276
|
+
}
|
|
277
|
+
/** The frame again with `params._meta` left out. */
|
|
278
|
+
function withoutKey(frame, params) {
|
|
279
|
+
const { _meta: _dropped, ...rest } = params;
|
|
280
|
+
return { ...frame, params: rest };
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* Whether `mcp_types.RequestParams.meta` (`RequestParamsMeta | None`, lax) takes this
|
|
284
|
+
* `_meta`: absent or `null`, or an open object whose `progressToken`, if any, is a str or
|
|
285
|
+
* what lax `int` takes — an integral number or a bool (`NotRequired`, so not `null`).
|
|
286
|
+
* Measured on the reference: `5`, `[]`, `{"progressToken": 1.5}`, `{"progressToken": null}`
|
|
287
|
+
* and `{"progressToken": {}}` are -32602; `null`, `{"progressToken": true}` and
|
|
288
|
+
* `{"progressToken": 2.0}` are served.
|
|
289
|
+
*/
|
|
290
|
+
function referenceTakesMeta(meta) {
|
|
291
|
+
if (meta === undefined || meta === null)
|
|
292
|
+
return true;
|
|
293
|
+
if (!isObject(meta))
|
|
294
|
+
return false;
|
|
295
|
+
const token = meta['progressToken'];
|
|
296
|
+
return token === undefined || typeof token === 'string' || typeof token === 'boolean' || Number.isInteger(token);
|
|
297
|
+
}
|
|
298
|
+
/** The members a JSON-RPC 2.0 message may carry; any other is dropped as pydantic drops it. */
|
|
299
|
+
const MESSAGE_KEYS = new Set(['jsonrpc', 'id', 'method', 'params', 'result', 'error']);
|
|
300
|
+
function isObject(value) {
|
|
301
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
111
302
|
}
|