bantamkit-mcp 0.35.7 → 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 +24 -9
- package/assets/tools/memory_compact.json +1 -2
- package/assets/tools/memory_recall.json +1 -3
- package/assets/tools/shiftwork_clock_in.json +1 -1
- package/assets/tools/shiftwork_clock_out.json +1 -1
- package/assets/tools/validate_json.json +1 -1
- package/assets/tools/work_plan.json +14 -8
- package/dist/assets.js +28 -4
- package/dist/cli.js +83 -24
- package/dist/contract.d.ts +0 -9
- package/dist/contract.js +51 -12
- package/dist/eventlog.d.ts +22 -2
- package/dist/eventlog.js +30 -3
- package/dist/hookadapter.js +263 -43
- package/dist/hostinstall.d.ts +2 -2
- package/dist/hostinstall.js +318 -84
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/mcp/identity.js +27 -1
- package/dist/mcp/pyargs.d.ts +22 -6
- package/dist/mcp/pyargs.js +113 -39
- package/dist/mcp/server.js +177 -41
- package/dist/mcp/status.js +11 -4
- package/dist/mcp/transport.d.ts +51 -6
- package/dist/mcp/transport.js +217 -26
- package/dist/mcpreport.d.ts +12 -0
- package/dist/mcpreport.js +67 -15
- package/dist/memory/cli.js +18 -3
- package/dist/memory/component.d.ts +17 -0
- package/dist/memory/component.js +51 -7
- package/dist/memory/factfile.d.ts +21 -3
- package/dist/memory/factfile.js +351 -12
- package/dist/memory/layers.d.ts +19 -0
- package/dist/memory/layers.js +102 -2
- package/dist/memory/pyyaml.d.ts +6 -4
- package/dist/memory/pyyaml.js +6 -4
- package/dist/memory/store.d.ts +82 -7
- package/dist/memory/store.js +277 -29
- package/dist/npminstall.d.ts +18 -0
- package/dist/npminstall.js +26 -5
- package/dist/pyargparse.js +48 -2
- package/dist/pyjsonschema.d.ts +85 -4
- package/dist/pyjsonschema.js +1812 -181
- package/dist/selfupdate.d.ts +112 -32
- package/dist/selfupdate.js +221 -79
- package/dist/shiftwork.d.ts +46 -7
- package/dist/shiftwork.js +246 -42
- package/dist/tokenledger.js +20 -4
- package/dist/updatecheck.d.ts +15 -3
- package/dist/updatecheck.js +23 -8
- package/dist/workplan.d.ts +8 -3
- package/dist/workplan.js +62 -3
- package/package.json +2 -2
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
|
}
|
package/dist/mcpreport.d.ts
CHANGED
|
@@ -74,6 +74,18 @@ export interface HostCall {
|
|
|
74
74
|
}
|
|
75
75
|
/** The one timestamp the join hangs on when only one end was logged. */
|
|
76
76
|
export declare function anchorMs(call: HostCall): number;
|
|
77
|
+
/**
|
|
78
|
+
* The two input paths, spelled in ASCII.
|
|
79
|
+
*
|
|
80
|
+
* `docs/mcpreport.md` promises an ASCII report, and the two paths it echoes are the only
|
|
81
|
+
* bytes in it that come from outside a closed vocabulary. Measured 2026-10-13 (#187): a root
|
|
82
|
+
* named `แคช-😀` reached stdout verbatim. Every UTF-16 code unit outside printable ASCII
|
|
83
|
+
* (0x20–0x7E) becomes `\uXXXX`, lowercase hex — one escape per code unit, so a character
|
|
84
|
+
* outside the BMP is two — and nothing else is touched: a backslash stays a backslash, so a
|
|
85
|
+
* Windows path reads as typed. The reference walks code points and splits astral ones into
|
|
86
|
+
* surrogates by hand; the two spell the same bytes.
|
|
87
|
+
*/
|
|
88
|
+
export declare function escapeAscii(text: string): string;
|
|
77
89
|
export interface PairedCalls {
|
|
78
90
|
readonly calls: HostCall[];
|
|
79
91
|
readonly incomplete: number;
|
package/dist/mcpreport.js
CHANGED
|
@@ -211,6 +211,57 @@ export function parseHostLine(line) {
|
|
|
211
211
|
export function anchorMs(call) {
|
|
212
212
|
return call.startMs !== null ? call.startMs : (call.endMs ?? 0);
|
|
213
213
|
}
|
|
214
|
+
/**
|
|
215
|
+
* `min()` / `max()` over a non-empty list, as a LOOP, and `list.extend` as a loop too.
|
|
216
|
+
*
|
|
217
|
+
* `Math.min(...values)` and `out.push(...lines)` spread a list into CALL ARGUMENTS, and V8
|
|
218
|
+
* caps those at the stack. Measured 2026-10-14 (#130): one session of 130,000 calls — 32 MB
|
|
219
|
+
* of host log, a legal busy day — ended `--mcp-report` with `RangeError: Maximum call stack
|
|
220
|
+
* size exceeded` on this side where the reference answered the same log in 1.4 s. The host
|
|
221
|
+
* log is UNBOUNDED by contract (`docs/mcpreport.md`: the host never rotates it), so nothing
|
|
222
|
+
* derived from it — a session's anchors, a bucket's durations, one line per session — may be
|
|
223
|
+
* spread into a call. `[...items]` in an array literal is iteration, not a call, and stays.
|
|
224
|
+
*/
|
|
225
|
+
function minOf(values) {
|
|
226
|
+
let best = values[0];
|
|
227
|
+
for (let i = 1; i < values.length; i += 1)
|
|
228
|
+
if (values[i] < best)
|
|
229
|
+
best = values[i];
|
|
230
|
+
return best;
|
|
231
|
+
}
|
|
232
|
+
function maxOf(values) {
|
|
233
|
+
let best = values[0];
|
|
234
|
+
for (let i = 1; i < values.length; i += 1)
|
|
235
|
+
if (values[i] > best)
|
|
236
|
+
best = values[i];
|
|
237
|
+
return best;
|
|
238
|
+
}
|
|
239
|
+
function extend(out, lines) {
|
|
240
|
+
for (const line of lines)
|
|
241
|
+
out.push(line);
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* The two input paths, spelled in ASCII.
|
|
245
|
+
*
|
|
246
|
+
* `docs/mcpreport.md` promises an ASCII report, and the two paths it echoes are the only
|
|
247
|
+
* bytes in it that come from outside a closed vocabulary. Measured 2026-10-13 (#187): a root
|
|
248
|
+
* named `แคช-😀` reached stdout verbatim. Every UTF-16 code unit outside printable ASCII
|
|
249
|
+
* (0x20–0x7E) becomes `\uXXXX`, lowercase hex — one escape per code unit, so a character
|
|
250
|
+
* outside the BMP is two — and nothing else is touched: a backslash stays a backslash, so a
|
|
251
|
+
* Windows path reads as typed. The reference walks code points and splits astral ones into
|
|
252
|
+
* surrogates by hand; the two spell the same bytes.
|
|
253
|
+
*/
|
|
254
|
+
export function escapeAscii(text) {
|
|
255
|
+
let out = '';
|
|
256
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
257
|
+
const unit = text.charCodeAt(i);
|
|
258
|
+
if (unit >= 0x20 && unit <= 0x7e)
|
|
259
|
+
out += text[i];
|
|
260
|
+
else
|
|
261
|
+
out += `\\u${unit.toString(16).padStart(4, '0')}`;
|
|
262
|
+
}
|
|
263
|
+
return out;
|
|
264
|
+
}
|
|
214
265
|
/** `sorted(key=...)` over tuples of numbers and strings — Python's, so codepoint order. */
|
|
215
266
|
function byKey(items, key) {
|
|
216
267
|
return [...items].sort((a, b) => {
|
|
@@ -508,8 +559,8 @@ function candidates(event, calls, windowMs) {
|
|
|
508
559
|
const ends = [call.startMs, call.endMs].filter((t) => t !== null);
|
|
509
560
|
if (ends.length === 0)
|
|
510
561
|
continue;
|
|
511
|
-
const lo =
|
|
512
|
-
const hi =
|
|
562
|
+
const lo = minOf(ends) - windowMs;
|
|
563
|
+
const hi = maxOf(ends) + windowMs;
|
|
513
564
|
if (lo <= event.tsMs && event.tsMs <= hi)
|
|
514
565
|
out.push(index);
|
|
515
566
|
}
|
|
@@ -628,22 +679,23 @@ export function render(host, events, joined) {
|
|
|
628
679
|
out.push(`host-log-root: unknown on this platform (${host.platform}); set ${HOST_LOG_ROOT_ENV}`);
|
|
629
680
|
}
|
|
630
681
|
else if (host.missing === 'not-found') {
|
|
631
|
-
out.push(`host-log-root: not found at ${host.root}`);
|
|
682
|
+
out.push(`host-log-root: not found at ${escapeAscii(host.root ?? '')}`);
|
|
632
683
|
}
|
|
633
684
|
else {
|
|
634
|
-
out.push(`host-log-root: ${host.root}`);
|
|
685
|
+
out.push(`host-log-root: ${escapeAscii(host.root ?? '')}`);
|
|
635
686
|
}
|
|
636
687
|
out.push(`host-log-dirs: ${host.dirs}`, `host-log-files: ${host.files}`, `host-records: ${host.records}`, `host-records-lifecycle: ${host.lifecycle}`, `host-records-unclassified: ${host.unclassified}`, `host-error-records: ${host.errors}`, `host-calls: ${host.calls.length}`, `host-calls-incomplete: ${host.incomplete}`, `host-calls-orphan-completion: ${host.orphanCompletions}`);
|
|
637
688
|
if (events.missing === 'off')
|
|
638
689
|
out.push('event-log: off (BANTAMKIT_EVENT_LOG unset or off)');
|
|
639
|
-
else if (events.missing === 'not-found')
|
|
640
|
-
out.push(`event-log: not found at ${events.path}`);
|
|
690
|
+
else if (events.missing === 'not-found') {
|
|
691
|
+
out.push(`event-log: not found at ${escapeAscii(events.path ?? '')}`);
|
|
692
|
+
}
|
|
641
693
|
else
|
|
642
|
-
out.push(`event-log: ${events.path}`);
|
|
694
|
+
out.push(`event-log: ${escapeAscii(events.path ?? '')}`);
|
|
643
695
|
out.push(`event-records: ${events.records}`, `event-records-unreadable: ${events.unreadable}`, '', '[join]', 'method: (ts, tool) interval containment, mutually unique only', 'APPROXIMATE: the host stamps the dispatch, bantamkit stamps the decision,' +
|
|
644
696
|
' and under concurrency the two files can disagree about order. (ts, tool)' +
|
|
645
697
|
' is EVIDENCE, NOT A KEY.', `window-ms: ${joined.windowMs}`, `matched-pairs: ${joined.matched.length}`, `ambiguous-groups: ${joined.ambiguousGroups.length}`, `ambiguous-events: ${joined.ambiguousEvents}`, `ambiguous-calls: ${joined.ambiguousCalls}`, `unmatched-events: ${joined.unmatchedEvents.length}`, `unmatched-calls: ${joined.unmatchedCalls.length}`, `attributed-events: ${joined.matched.length} of ${events.events.length}`);
|
|
646
|
-
out
|
|
698
|
+
extend(out, linesOrNone(joined.ambiguousGroups.map(([e, c]) => `ambiguous-group: events=${e} calls=${c}`)));
|
|
647
699
|
// --- durations: the number NEITHER file holds. `ms` is source A, `outcome` is B.
|
|
648
700
|
const buckets = new Map();
|
|
649
701
|
for (const [event, call] of joined.matched) {
|
|
@@ -659,8 +711,8 @@ export function render(host, events, joined) {
|
|
|
659
711
|
}
|
|
660
712
|
}
|
|
661
713
|
out.push('', '[durations]', 'matched pairs only; ms from the host, outcome from bantamkit');
|
|
662
|
-
out
|
|
663
|
-
` p50=${p50(values)} max=${
|
|
714
|
+
extend(out, linesOrNone(byKey([...buckets.values()], (b) => [b.tool, b.outcome]).map(({ tool, outcome, values }) => `${tool} ${outcome} n=${values.length} min=${minOf(values)}` +
|
|
715
|
+
` p50=${p50(values)} max=${maxOf(values)}`)));
|
|
664
716
|
// --- sessions: the only place a sessionId exists, plus what B could attach to it.
|
|
665
717
|
const perSession = new Map();
|
|
666
718
|
for (const call of host.calls) {
|
|
@@ -675,19 +727,19 @@ export function render(host, events, joined) {
|
|
|
675
727
|
attributed.set(call.session, (attributed.get(call.session) ?? 0) + 1);
|
|
676
728
|
}
|
|
677
729
|
const orderedSessions = byKey([...perSession.entries()], ([name, calls]) => [
|
|
678
|
-
|
|
730
|
+
minOf(calls.map(anchorMs)),
|
|
679
731
|
name,
|
|
680
732
|
]);
|
|
681
733
|
out.push('', '[sessions]');
|
|
682
|
-
out
|
|
734
|
+
extend(out, linesOrNone(orderedSessions.map(([name, calls]) => {
|
|
683
735
|
const anchors = calls.map(anchorMs);
|
|
684
736
|
const tools = [...new Set(calls.map((c) => c.tool))].sort(cmpCodepoint);
|
|
685
737
|
return (`${name} calls=${calls.length}` +
|
|
686
738
|
` ok=${calls.filter((c) => c.ok === true).length}` +
|
|
687
739
|
` fail=${calls.filter((c) => c.ok === false).length}` +
|
|
688
740
|
` incomplete=${calls.filter((c) => c.ok === null).length}` +
|
|
689
|
-
` first=${formatTimestamp(
|
|
690
|
-
` last=${formatTimestamp(
|
|
741
|
+
` first=${formatTimestamp(minOf(anchors))}` +
|
|
742
|
+
` last=${formatTimestamp(maxOf(anchors))}` +
|
|
691
743
|
` attributed=${attributed.get(name) ?? 0}` +
|
|
692
744
|
` tools=${tools.join(',')}`);
|
|
693
745
|
})));
|
|
@@ -703,7 +755,7 @@ export function render(host, events, joined) {
|
|
|
703
755
|
` session=${call.session} later-calls=${later.length}` +
|
|
704
756
|
` later-same-tool=${later.filter((c) => c.tool === event.tool).length}`);
|
|
705
757
|
}
|
|
706
|
-
out
|
|
758
|
+
extend(out, linesOrNone(follow));
|
|
707
759
|
// --- the host's errors, counted and never quoted.
|
|
708
760
|
out.push('', '[host-errors]', `records=${host.errors} with-tool=${host.errorsWithTool}` +
|
|
709
761
|
` without-tool=${host.errors - host.errorsWithTool}`, 'text withheld by design: the host log has been measured to persist argument' +
|
package/dist/memory/cli.js
CHANGED
|
@@ -89,6 +89,18 @@ function positive(text) {
|
|
|
89
89
|
throw new ArgumentTypeError('must be >= 1');
|
|
90
90
|
return value;
|
|
91
91
|
}
|
|
92
|
+
/**
|
|
93
|
+
* `_nonnegative`: `--reserve 0` is a real request — compact to the budget itself — and the
|
|
94
|
+
* tool's schema says `minimum: 0`, so the CLI accepts it too (#134). WAS `positive`, which
|
|
95
|
+
* refused 0 with `must be >= 1` on both CLIs while `memory_compact` took it. `typeName` is
|
|
96
|
+
* `_nonnegative` for the reason `positive` gives: argparse prints the callable's `__name__`.
|
|
97
|
+
*/
|
|
98
|
+
function nonnegative(text) {
|
|
99
|
+
const value = pyIntStrict(text);
|
|
100
|
+
if (value < 0)
|
|
101
|
+
throw new ArgumentTypeError('must be >= 0');
|
|
102
|
+
return value;
|
|
103
|
+
}
|
|
92
104
|
/**
|
|
93
105
|
* `common(sub)`: the store selector and the budget, on every subcommand.
|
|
94
106
|
*
|
|
@@ -176,9 +188,12 @@ const PARSER = {
|
|
|
176
188
|
dest: 'reserve',
|
|
177
189
|
metavar: 'BYTES',
|
|
178
190
|
kind: 'store',
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
191
|
+
// The default is `MemoryStore.compact`'s (job46): the sentence quotes it rather
|
|
192
|
+
// than the pre-job46 "largest index line kept" it used to state (#134).
|
|
193
|
+
help: 'headroom to leave below the budget (default: the budget minus its 90 percent ' +
|
|
194
|
+
'warning line, plus the largest index line kept; capped at half the budget)',
|
|
195
|
+
convert: nonnegative,
|
|
196
|
+
typeName: '_nonnegative',
|
|
182
197
|
defaultValue: null,
|
|
183
198
|
},
|
|
184
199
|
])),
|
|
@@ -104,6 +104,21 @@ export interface RecallOutcome {
|
|
|
104
104
|
* that says so, `null` for every other query — the walk that has always run, unchanged.
|
|
105
105
|
*/
|
|
106
106
|
readonly lookup: string | null;
|
|
107
|
+
/**
|
|
108
|
+
* Every fact file a layer this walk READ could not read as a fact (job65/#119), as
|
|
109
|
+
* `{layer, file, reason}`; the reply names them in a closing paragraph (`skippedNote`).
|
|
110
|
+
*/
|
|
111
|
+
readonly skipped: ReadonlyArray<{
|
|
112
|
+
layer: string;
|
|
113
|
+
file: string;
|
|
114
|
+
reason: string;
|
|
115
|
+
}>;
|
|
116
|
+
/**
|
|
117
|
+
* The header line of each fact in the reply, in reply order, BUILT FROM THE PARSED FIELDS
|
|
118
|
+
* (job65/#155). The hook reads these instead of filtering the reply's lines, so a body line
|
|
119
|
+
* shaped like a header can never pass for one.
|
|
120
|
+
*/
|
|
121
|
+
readonly headers: readonly string[];
|
|
107
122
|
}
|
|
108
123
|
/**
|
|
109
124
|
* What `dream` DID, beside the sentence it says about it.
|
|
@@ -384,5 +399,7 @@ export declare class Memory {
|
|
|
384
399
|
* literal, so the message and the resolver cannot drift onto two different variables.
|
|
385
400
|
*/
|
|
386
401
|
private bindingDiagnosis;
|
|
402
|
+
/** `[layer] [name] (type) description`, from the parsed fields (job65/#155). */
|
|
403
|
+
private header;
|
|
387
404
|
private format;
|
|
388
405
|
}
|