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.
@@ -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.30.0. Where the two SDKs make a difference unavoidable it is
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: noted(result.asJson()) }, wrapped: true };
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: noted(tokenledger.asJson(result)) }, wrapped: true };
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 skill = /^bantamkit:\/\/skills\/([^/]*)$/.exec(uri);
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 rubric = /^bantamkit:\/\/rubrics\/([^/]*)$/.exec(uri);
1114
+ const rubricName = templateName(uri, 'bantamkit://rubrics/');
1115
+ const rubric = rubricName === null ? null : [uri, rubricName];
1098
1116
  if (rubric) {
1099
- const path = join(assetsRoot(), 'rubrics', `${rubric[1]}.yaml`);
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
- // The template variable matches ONE segment and MAY BE EMPTY — measured: the reference
1110
- // answers `bantamkit://skills/` with -32603 `unknown skill asset: `, not with "unknown
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
- return args ?? { t: 'dict', v: new Map() };
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
@@ -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 by JSON-RPC id. A handler then re-reads its own arguments with
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 by request id, and `send` splices it in. Everything else still
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 readonly rawLines;
46
- private readonly exactResults;
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 that carried this id, or `undefined` if it is gone. */
85
+ /** The verbatim request line this message token stands for, or `undefined` if it is gone. */
51
86
  rawLineFor(id: unknown): string | undefined;
52
- /** Register the exact JSON text to send as this request's `result`. */
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 {};
@@ -1,7 +1,37 @@
1
1
  import { JSONRPCMessageSchema } from '@modelcontextprotocol/sdk/types.js';
2
2
  import { PY_LINESEP } from '../memory/pyfs.js';
3
- /** A JSON-RPC id is a string or a number; the map key is its JSON text, which is total. */
4
- const idKey = (id) => JSON.stringify(id ?? null);
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
- rawLines = new Map();
15
- exactResults = new Map();
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 that carried this id, or `undefined` if it is gone. */
67
+ /** The verbatim request line this message token stands for, or `undefined` if it is gone. */
23
68
  rawLineFor(id) {
24
- return this.rawLines.get(idKey(id));
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 request's `result`. */
78
+ /** Register the exact JSON text to send as this message's `result`. */
27
79
  setExactResult(id, json) {
28
- this.exactResults.set(idKey(id), json);
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.rawLines.clear();
44
- this.exactResults.clear();
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
- const text = this.frame(message);
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 key = idKey(framed.id);
75
- this.rawLines.delete(key);
76
- const id = JSON.stringify(framed.id);
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
- const exact = this.exactResults.get(key);
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 message;
163
+ let parsed;
98
164
  try {
99
- message = JSONRPCMessageSchema.parse(JSON.parse(line));
165
+ parsed = JSON.parse(line);
166
+ }
167
+ catch {
168
+ this.refuse(PARSE_ERROR_FRAME);
169
+ continue;
100
170
  }
101
- catch (error) {
102
- this.onerror?.(error);
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
- const id = message.id;
106
- if (id !== undefined)
107
- this.rawLines.set(idKey(id), line);
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
  }