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/pyargs.d.ts
CHANGED
|
@@ -7,12 +7,12 @@
|
|
|
7
7
|
* ship a server whose recovery behaviour differs from the reference's on every mistyped
|
|
8
8
|
* argument, and that is a product difference, not a cosmetic one.
|
|
9
9
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
10
|
+
* WHY THERE IS NO URL (job65/#185). pydantic ends each error with `For further information
|
|
11
|
+
* visit https://errors.pydantic.dev/<major.minor>/v/<type>`, naming whichever pydantic pip
|
|
12
|
+
* resolved. This port used to reproduce it with `2.13` hard-coded, and the reference printed
|
|
13
|
+
* `2.14` the day PyPI resolved 2.14 — the refusal texts differed by install date. The
|
|
14
|
+
* reference now sets `PYDANTIC_ERRORS_INCLUDE_URL=0` at import (`bantamkit.mcpserver`), so
|
|
15
|
+
* neither runtime prints the line; `tools/conformance/suites/wire.mjs` compares the two.
|
|
16
16
|
*
|
|
17
17
|
* EVERY RULE BELOW WAS MEASURED, not read off pydantic's documentation. The probes are in
|
|
18
18
|
* `scratchpad/job38-n7/probe{4,7,8,9}.py` and each drove the real stdio server:
|
|
@@ -35,6 +35,12 @@ export interface FieldSpec {
|
|
|
35
35
|
readonly optional: boolean;
|
|
36
36
|
/** `Field(le=...)`: an inclusive ceiling, checked AFTER the lax int parse succeeds. */
|
|
37
37
|
readonly le?: bigint;
|
|
38
|
+
/**
|
|
39
|
+
* `Field(ge=...)`: an inclusive floor, checked the same way. On a `dictInt` it binds the
|
|
40
|
+
* VALUES — `dict[str, Annotated[int, Field(ge=0)]]` on the reference — so the frame's
|
|
41
|
+
* location is `usage.<key>`, as the type frame's is.
|
|
42
|
+
*/
|
|
43
|
+
readonly ge?: bigint;
|
|
38
44
|
}
|
|
39
45
|
export interface ArgModel {
|
|
40
46
|
/** The pydantic model name, which is the FUNCTION name plus `Arguments`. */
|
|
@@ -60,6 +66,15 @@ export interface ArgModel {
|
|
|
60
66
|
* register a tool.
|
|
61
67
|
*/
|
|
62
68
|
export declare const OFFSET_MAXIMUM = 9007199254740991n;
|
|
69
|
+
/**
|
|
70
|
+
* `assets/tools/skill_audit.json` `budget.maximum` — the same `Number.MAX_SAFE_INTEGER` as
|
|
71
|
+
* `OFFSET_MAXIMUM`, spelled as its own constant because it is tied to a different asset
|
|
72
|
+
* and would stay where it is if the other ever moved. Bound on the reference as
|
|
73
|
+
* `Annotated[int, Field(le=SKILL_BUDGET_MAXIMUM)]` (#133, job65): until then the manifest
|
|
74
|
+
* advertised a ceiling neither runtime enforced, and `budget: 2**53` was audited as if it
|
|
75
|
+
* were a number a catalogue could be measured against.
|
|
76
|
+
*/
|
|
77
|
+
export declare const SKILL_BUDGET_MAXIMUM = 9007199254740991n;
|
|
63
78
|
/**
|
|
64
79
|
* One model per handler, mirroring `build_server`'s closures signature for signature.
|
|
65
80
|
*
|
|
@@ -122,6 +137,7 @@ export declare class PyValidationFailure extends Error {
|
|
|
122
137
|
readonly detail: string;
|
|
123
138
|
constructor(detail: string);
|
|
124
139
|
}
|
|
140
|
+
export declare function loneSurrogateArgument(args: PyValue): string | null;
|
|
125
141
|
/**
|
|
126
142
|
* `model.model_validate(arguments)` for one tool, or the exact `ValidationError` text.
|
|
127
143
|
*
|
package/dist/mcp/pyargs.js
CHANGED
|
@@ -7,12 +7,12 @@
|
|
|
7
7
|
* ship a server whose recovery behaviour differs from the reference's on every mistyped
|
|
8
8
|
* argument, and that is a product difference, not a cosmetic one.
|
|
9
9
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
10
|
+
* WHY THERE IS NO URL (job65/#185). pydantic ends each error with `For further information
|
|
11
|
+
* visit https://errors.pydantic.dev/<major.minor>/v/<type>`, naming whichever pydantic pip
|
|
12
|
+
* resolved. This port used to reproduce it with `2.13` hard-coded, and the reference printed
|
|
13
|
+
* `2.14` the day PyPI resolved 2.14 — the refusal texts differed by install date. The
|
|
14
|
+
* reference now sets `PYDANTIC_ERRORS_INCLUDE_URL=0` at import (`bantamkit.mcpserver`), so
|
|
15
|
+
* neither runtime prints the line; `tools/conformance/suites/wire.mjs` compares the two.
|
|
16
16
|
*
|
|
17
17
|
* EVERY RULE BELOW WAS MEASURED, not read off pydantic's documentation. The probes are in
|
|
18
18
|
* `scratchpad/job38-n7/probe{4,7,8,9}.py` and each drove the real stdio server:
|
|
@@ -54,6 +54,15 @@ const opt = (name, kind, bound = {}) => ({
|
|
|
54
54
|
* register a tool.
|
|
55
55
|
*/
|
|
56
56
|
export const OFFSET_MAXIMUM = 9007199254740991n;
|
|
57
|
+
/**
|
|
58
|
+
* `assets/tools/skill_audit.json` `budget.maximum` — the same `Number.MAX_SAFE_INTEGER` as
|
|
59
|
+
* `OFFSET_MAXIMUM`, spelled as its own constant because it is tied to a different asset
|
|
60
|
+
* and would stay where it is if the other ever moved. Bound on the reference as
|
|
61
|
+
* `Annotated[int, Field(le=SKILL_BUDGET_MAXIMUM)]` (#133, job65): until then the manifest
|
|
62
|
+
* advertised a ceiling neither runtime enforced, and `budget: 2**53` was audited as if it
|
|
63
|
+
* were a number a catalogue could be measured against.
|
|
64
|
+
*/
|
|
65
|
+
export const SKILL_BUDGET_MAXIMUM = 9007199254740991n;
|
|
57
66
|
/**
|
|
58
67
|
* One model per handler, mirroring `build_server`'s closures signature for signature.
|
|
59
68
|
*
|
|
@@ -107,9 +116,10 @@ export const ARG_MODELS = {
|
|
|
107
116
|
bantamkit_status: { model: 'bantamkit_statusArguments', fields: [] },
|
|
108
117
|
// `reserve: int | None = None` on the reference, so it is the same lax `int` as `k`:
|
|
109
118
|
// `'2'`, `True` and `3.0` validate, `'2.5'` is `int_parsing`, `2.5` is `int_from_float`,
|
|
110
|
-
// and an explicit `null` is the default.
|
|
111
|
-
//
|
|
112
|
-
//
|
|
119
|
+
// and an explicit `null` is the default. Nothing here floors it — a negative value
|
|
120
|
+
// reaches `MemoryStore.compact`, whose `max(0, min(reserve, budget // 2))` is the only
|
|
121
|
+
// clamp on either side (6b966e5) — and since #133 (job65) the manifest no longer
|
|
122
|
+
// advertises a `minimum` it does not enforce: the clamp is stated in the description.
|
|
113
123
|
memory_compact: { model: 'memory_compactArguments', fields: [opt('reserve', 'int')] },
|
|
114
124
|
// `bantamkit_read(path: str, part: str | None = None, offset: int | None = None,
|
|
115
125
|
// limit: int | None = None)` on the reference (job43). `path` and `part` are the strict
|
|
@@ -127,16 +137,19 @@ export const ARG_MODELS = {
|
|
|
127
137
|
model: 'bantamkit_readArguments',
|
|
128
138
|
fields: [req('path', 'str'), opt('part', 'str'), opt('offset', 'int', { le: OFFSET_MAXIMUM }), opt('limit', 'int')],
|
|
129
139
|
},
|
|
130
|
-
// `skill_audit(root: str, enabled: list[str] | None = None, usage: dict[str,
|
|
131
|
-
//
|
|
132
|
-
//
|
|
133
|
-
//
|
|
134
|
-
//
|
|
135
|
-
//
|
|
136
|
-
//
|
|
137
|
-
//
|
|
138
|
-
//
|
|
139
|
-
// `
|
|
140
|
+
// `skill_audit(root: str, enabled: list[str] | None = None, usage: dict[str,
|
|
141
|
+
// Annotated[int, Field(ge=0)]] | None = None, check: str | None = None, budget:
|
|
142
|
+
// Annotated[int, Field(le=SKILL_BUDGET_MAXIMUM)] | None = None)` on the reference (job44;
|
|
143
|
+
// the two bounds since #133, job65). `usage` is the only `dict[str, int]` on the surface
|
|
144
|
+
// and the reason `dictInt` exists: pydantic validates a typed dict's VALUES, so `{'a':
|
|
145
|
+
// 'x'}` is `usage.a` / `int_parsing` and `{'a': -1}` is `usage.a` /
|
|
146
|
+
// `greater_than_equal`, where `handoff_patch`'s `dict[str, Any]` takes anything. `budget`'s
|
|
147
|
+
// ceiling is the manifest's `maximum`, bound the way `bantamkit_read`'s `offset` is;
|
|
148
|
+
// its floor is still the handler's own refusal (`budget must not be negative`), which
|
|
149
|
+
// the manifest's `minimum: 0` describes and this table does not pre-empt. `versions` is
|
|
150
|
+
// the only `dict[str, str]`, and the reason `dictStr` exists beside it: a `str` field is
|
|
151
|
+
// STRICT even in lax mode, so `{'a': 1}` is `versions.a` / `string_type` where the same
|
|
152
|
+
// value under `usage` would validate.
|
|
140
153
|
// `dry_run: bool | None = None` on the reference (job45 row 5). LAX bool, and every arm
|
|
141
154
|
// below was MEASURED against the real stdio server, not read off pydantic's docs
|
|
142
155
|
// (`scratchpad` probe, 33 inputs): `0`/`1` and `0.0`/`1.0` coerce; any OTHER integral
|
|
@@ -176,9 +189,9 @@ export const ARG_MODELS = {
|
|
|
176
189
|
fields: [
|
|
177
190
|
req('root', 'str'),
|
|
178
191
|
opt('enabled', 'listStr'),
|
|
179
|
-
opt('usage', 'dictInt'),
|
|
192
|
+
opt('usage', 'dictInt', { ge: 0n }),
|
|
180
193
|
opt('check', 'str'),
|
|
181
|
-
opt('budget', 'int'),
|
|
194
|
+
opt('budget', 'int', { le: SKILL_BUDGET_MAXIMUM }),
|
|
182
195
|
opt('versions', 'dictStr'),
|
|
183
196
|
],
|
|
184
197
|
},
|
|
@@ -297,7 +310,7 @@ function checkField(spec, value) {
|
|
|
297
310
|
const bad = [];
|
|
298
311
|
const coerced = new Map();
|
|
299
312
|
for (const [key, item] of value.v) {
|
|
300
|
-
const checked =
|
|
313
|
+
const checked = checkBoundedInt(spec, `${spec.name}.${key}`, item);
|
|
301
314
|
if ('value' in checked)
|
|
302
315
|
coerced.set(key, checked.value);
|
|
303
316
|
else
|
|
@@ -382,24 +395,37 @@ function checkField(spec, value) {
|
|
|
382
395
|
return wrongType();
|
|
383
396
|
}
|
|
384
397
|
case 'int': {
|
|
385
|
-
const checked =
|
|
386
|
-
|
|
387
|
-
return [checked];
|
|
388
|
-
// `less_than_equal` names the ORIGINAL input, as every constraint error does.
|
|
389
|
-
if (spec.le !== undefined && checked.value.t === 'int' && checked.value.v > spec.le) {
|
|
390
|
-
return [
|
|
391
|
-
{
|
|
392
|
-
loc: spec.name,
|
|
393
|
-
type: 'less_than_equal',
|
|
394
|
-
msg: `Input should be less than or equal to ${spec.le}`,
|
|
395
|
-
input: value,
|
|
396
|
-
},
|
|
397
|
-
];
|
|
398
|
-
}
|
|
399
|
-
return checked;
|
|
398
|
+
const checked = checkBoundedInt(spec, spec.name, value);
|
|
399
|
+
return 'value' in checked ? checked : [checked];
|
|
400
400
|
}
|
|
401
401
|
}
|
|
402
402
|
}
|
|
403
|
+
/**
|
|
404
|
+
* The lax int parse, then the field's `ge`/`le` bounds on what it parsed.
|
|
405
|
+
*
|
|
406
|
+
* A constraint frame names the ORIGINAL input, as every pydantic constraint error does:
|
|
407
|
+
* `usage: {'a': '-1'}` is `greater_than_equal` with `input_value='-1', input_type=str`, the
|
|
408
|
+
* string the client sent and not the `-1` it parsed to. Measured against pydantic 2.13 on
|
|
409
|
+
* `dict[str, Annotated[int, Field(ge=0)]]` and `Annotated[int, Field(le=2**53-1)]`,
|
|
410
|
+
* 2026-10-15 (job65 J65-13 note, §3), and compared over stdio by `suites/wire.mjs`.
|
|
411
|
+
*/
|
|
412
|
+
function checkBoundedInt(spec, loc, value) {
|
|
413
|
+
const checked = checkInt(loc, value);
|
|
414
|
+
if (!('value' in checked) || checked.value.t !== 'int')
|
|
415
|
+
return checked;
|
|
416
|
+
if (spec.ge !== undefined && checked.value.v < spec.ge) {
|
|
417
|
+
return {
|
|
418
|
+
loc,
|
|
419
|
+
type: 'greater_than_equal',
|
|
420
|
+
msg: `Input should be greater than or equal to ${spec.ge}`,
|
|
421
|
+
input: value,
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
if (spec.le !== undefined && checked.value.v > spec.le) {
|
|
425
|
+
return { loc, type: 'less_than_equal', msg: `Input should be less than or equal to ${spec.le}`, input: value };
|
|
426
|
+
}
|
|
427
|
+
return checked;
|
|
428
|
+
}
|
|
403
429
|
function checkInt(loc, value) {
|
|
404
430
|
const tooBig = () => ({
|
|
405
431
|
loc,
|
|
@@ -550,6 +576,52 @@ export class PyValidationFailure extends Error {
|
|
|
550
576
|
this.name = 'PyValidationFailure';
|
|
551
577
|
}
|
|
552
578
|
}
|
|
579
|
+
/**
|
|
580
|
+
* A LONE SURROGATE IN ANY STRING ARGUMENT IS REFUSED BY NAME (job65/#160), before any other
|
|
581
|
+
* check. JSON can spell `\ud800` on its own; no UTF-8 file or frame can carry it. This port
|
|
582
|
+
* used to accept it and write U+FFFD ("saved", data silently altered); the reference never
|
|
583
|
+
* answered at all, because the SDK's frame parser rejects the escape and drops the request.
|
|
584
|
+
* Both now answer `Error executing tool <name>: argument '<path>' holds a lone surrogate
|
|
585
|
+
* (U+XXXX), which is not text; nothing was done` — the reference from its stdio reader
|
|
586
|
+
* (`bantamkit.mcpstdio`), this port from here. `<path>` is the top-level argument, then
|
|
587
|
+
* `[i]` for a list item and `.key` for a dict member; the first in document order wins.
|
|
588
|
+
* Keys are not inspected: an unknown key is ignored by both sides anyway.
|
|
589
|
+
*/
|
|
590
|
+
const LONE_SURROGATE = /[\ud800-\udbff](?![\udc00-\udfff])|(?<![\ud800-\udbff])[\udc00-\udfff]/;
|
|
591
|
+
export function loneSurrogateArgument(args) {
|
|
592
|
+
const walk = (value, where) => {
|
|
593
|
+
if (value.t === 'str') {
|
|
594
|
+
const found = LONE_SURROGATE.exec(value.v);
|
|
595
|
+
if (found === null)
|
|
596
|
+
return null;
|
|
597
|
+
const unit = found[0].charCodeAt(0).toString(16).toUpperCase().padStart(4, '0');
|
|
598
|
+
return `argument '${where}' holds a lone surrogate (U+${unit}), which is not text; nothing was done`;
|
|
599
|
+
}
|
|
600
|
+
if (value.t === 'list') {
|
|
601
|
+
for (let i = 0; i < value.v.length; i += 1) {
|
|
602
|
+
const hit = walk(value.v[i], `${where}[${i}]`);
|
|
603
|
+
if (hit !== null)
|
|
604
|
+
return hit;
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
if (value.t === 'dict') {
|
|
608
|
+
for (const [key, item] of value.v) {
|
|
609
|
+
const hit = walk(item, `${where}.${key}`);
|
|
610
|
+
if (hit !== null)
|
|
611
|
+
return hit;
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
return null;
|
|
615
|
+
};
|
|
616
|
+
if (args.t !== 'dict')
|
|
617
|
+
return null;
|
|
618
|
+
for (const [key, item] of args.v) {
|
|
619
|
+
const hit = walk(item, key);
|
|
620
|
+
if (hit !== null)
|
|
621
|
+
return hit;
|
|
622
|
+
}
|
|
623
|
+
return null;
|
|
624
|
+
}
|
|
553
625
|
/**
|
|
554
626
|
* `model.model_validate(arguments)` for one tool, or the exact `ValidationError` text.
|
|
555
627
|
*
|
|
@@ -564,6 +636,9 @@ export class PyValidationFailure extends Error {
|
|
|
564
636
|
* its `input_value` and not the string the wire held.
|
|
565
637
|
*/
|
|
566
638
|
export function validateArguments(model, args) {
|
|
639
|
+
const lone = loneSurrogateArgument(args);
|
|
640
|
+
if (lone !== null)
|
|
641
|
+
throw new Error(lone);
|
|
567
642
|
const unwrapped = preParseJson(model, args);
|
|
568
643
|
const supplied = unwrapped.t === 'dict' ? unwrapped.v : new Map();
|
|
569
644
|
const errors = [];
|
|
@@ -589,8 +664,7 @@ export function validateArguments(model, args) {
|
|
|
589
664
|
const head = `${count} validation error${count === 1 ? '' : 's'} for ${model.model}`;
|
|
590
665
|
const body = errors
|
|
591
666
|
.map((e) => `${e.loc}\n ${e.msg} [type=${e.type}, input_value=${truncateRepr(reprValue(e.input))}, ` +
|
|
592
|
-
`input_type=${pyTypeName(e.input)}]
|
|
593
|
-
`https://errors.pydantic.dev/2.13/v/${e.type}`)
|
|
667
|
+
`input_type=${pyTypeName(e.input)}]`)
|
|
594
668
|
.join('\n');
|
|
595
669
|
throw new PyValidationFailure(`${head}\n${body}`);
|
|
596
670
|
}
|
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';
|
|
@@ -174,41 +174,32 @@ function rpcError(code, message, data) {
|
|
|
174
174
|
const asDict = (value) => (value.t === 'dict' ? value.v : new Map());
|
|
175
175
|
const asText = (value) => (value && value.t === 'str' ? value.v : '');
|
|
176
176
|
/**
|
|
177
|
-
* `work_plan`'s `nodes` as `workplan.plan` takes them.
|
|
177
|
+
* `work_plan`'s `nodes` as `workplan.plan` takes them: the decoded objects, UNCHANGED.
|
|
178
178
|
*
|
|
179
179
|
* The reference's model is `list[dict[str, Any]]`, so pydantic guarantees a list of dicts
|
|
180
|
-
* and NOTHING about what is inside one; `workplan.plan`
|
|
181
|
-
*
|
|
182
|
-
*
|
|
180
|
+
* and NOTHING about what is inside one; what is inside is checked by `workplan.plan` itself
|
|
181
|
+
* on both sides against the item schema `assets/tools/work_plan.json` advertises (#127,
|
|
182
|
+
* job65). Until that check existed THIS function was where the port diverged: it coerced a
|
|
183
|
+
* string `depends_on` to `[]`, a non-numeric `priority` to 0 and a numeric id to its
|
|
184
|
+
* `str()`, so `[{id: 'a', depends_on: 'b'}, {id: 'b', ...}]` was planned with `a` beside
|
|
185
|
+
* `b` here and `b` before `a` there. Nothing is reinterpreted here any more: `toJs` hands
|
|
186
|
+
* the planner the same value `json.loads` hands the reference, and a node that contradicts
|
|
187
|
+
* the schema comes back as the planner's own `{result: 'error', reason: 'nodes[<i>].<field>
|
|
188
|
+
* must be ...'}` — one sentence, both runtimes, compared over stdio by
|
|
189
|
+
* `tools/conformance/suites/workplan.mjs`.
|
|
183
190
|
*
|
|
184
|
-
* A NODE WITH NO `id` RAISES, and raising is the port: `node["id"]` is a `KeyError` on
|
|
185
|
-
* reference, whose `str()` is the key in single quotes, so the SDK's envelope reads
|
|
186
|
-
* `Error executing tool work_plan: 'id'
|
|
187
|
-
*
|
|
188
|
-
*
|
|
189
|
-
* WHERE THE DYNAMIC HALF STOPS, said rather than hidden. Python keeps an id's own type and
|
|
190
|
-
* this side stringifies it with `str()`'s spelling (`reprValue` for every non-`str`), so a
|
|
191
|
-
* document mixing `5` and `"5"` is two nodes there and one here, and a non-numeric
|
|
192
|
-
* `priority` is a `TypeError` there and a 0 here. Both need a node whose types contradict
|
|
193
|
-
* `assets/tools/work_plan.json`, which nothing in the shift-work corpus produces; they are
|
|
194
|
-
* named here because an unwritten difference is the one that is found later.
|
|
191
|
+
* A NODE WITH NO `id` STILL RAISES, and raising is the port: `node["id"]` is a `KeyError` on
|
|
192
|
+
* the reference, whose `str()` is the key in single quotes, so the SDK's envelope reads
|
|
193
|
+
* `Error executing tool work_plan: 'id'` (`tool/no-id` in the same suite). It is reproduced
|
|
194
|
+
* here, before the planner runs, because the planner's own answer to a missing id is the
|
|
195
|
+
* non-string-id sentence and the reference never reaches that line.
|
|
195
196
|
*/
|
|
196
197
|
function planNodes(value) {
|
|
197
|
-
const pyText = (v) => (v.t === 'str' ? v.v : reprValue(v));
|
|
198
198
|
const items = value !== undefined && value.t === 'list' ? value.v : [];
|
|
199
199
|
return items.map((item) => {
|
|
200
|
-
|
|
201
|
-
const id = node.get('id');
|
|
202
|
-
if (id === undefined)
|
|
200
|
+
if (item.t === 'dict' && !item.v.has('id'))
|
|
203
201
|
throw new BantamError("'id'");
|
|
204
|
-
|
|
205
|
-
const priority = node.get('priority');
|
|
206
|
-
return {
|
|
207
|
-
id: pyText(id),
|
|
208
|
-
// `list(node.get("depends_on") or [])`: missing, `null` and `[]` are one case.
|
|
209
|
-
depends_on: declared !== undefined && declared.t === 'list' ? declared.v.map(pyText) : [],
|
|
210
|
-
priority: priority !== undefined && (priority.t === 'int' || priority.t === 'float') ? Number(priority.v) : 0,
|
|
211
|
-
};
|
|
202
|
+
return toJs(item);
|
|
212
203
|
});
|
|
213
204
|
}
|
|
214
205
|
/** `int | None` after validation: the tagged int, or `null` for the default. */
|
|
@@ -480,6 +471,22 @@ function runTool(name, memory, args, version, log, documents) {
|
|
|
480
471
|
out.set('bantamkit_degraded', { t: 'str', v: line });
|
|
481
472
|
return { t: 'dict', v: out };
|
|
482
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
|
+
};
|
|
483
490
|
switch (name) {
|
|
484
491
|
case 'memory_save': {
|
|
485
492
|
const links = args.get('links');
|
|
@@ -811,7 +818,7 @@ function runTool(name, memory, args, version, log, documents) {
|
|
|
811
818
|
findings: result.findings.length,
|
|
812
819
|
omissions: result.omissions.length,
|
|
813
820
|
});
|
|
814
|
-
return { value: { t: 'str', v:
|
|
821
|
+
return { value: { t: 'str', v: notedJson(result.asJson()) }, wrapped: true };
|
|
815
822
|
});
|
|
816
823
|
}
|
|
817
824
|
case 'repo_map': {
|
|
@@ -930,7 +937,7 @@ function runTool(name, memory, args, version, log, documents) {
|
|
|
930
937
|
requests: result.requests,
|
|
931
938
|
sessions: result.sessions.length,
|
|
932
939
|
});
|
|
933
|
-
return { value: { t: 'str', v:
|
|
940
|
+
return { value: { t: 'str', v: notedJson(tokenledger.asJson(result)) }, wrapped: true };
|
|
934
941
|
});
|
|
935
942
|
}
|
|
936
943
|
case 'work_plan':
|
|
@@ -1072,7 +1079,8 @@ export function buildServer(memory, wire, version, log = EventLog.fromEnv(memory
|
|
|
1072
1079
|
}));
|
|
1073
1080
|
server.setRequestHandler(ReadResourceRequestSchema, (request) => {
|
|
1074
1081
|
const uri = request.params.uri;
|
|
1075
|
-
const
|
|
1082
|
+
const skillName = templateName(uri, 'bantamkit://skills/');
|
|
1083
|
+
const skill = skillName === null ? null : [uri, skillName];
|
|
1076
1084
|
if (skill) {
|
|
1077
1085
|
// A skill that pairs with a tool is served on that tool's surfaces and no other. The
|
|
1078
1086
|
// pairing is the one the reference's `filegraph.py` spells — `load_tool("file_graph")`
|
|
@@ -1103,9 +1111,14 @@ export function buildServer(memory, wire, version, log = EventLog.fromEnv(memory
|
|
|
1103
1111
|
}
|
|
1104
1112
|
return { contents: [{ mimeType: 'text/plain', text, uri }] };
|
|
1105
1113
|
}
|
|
1106
|
-
const
|
|
1114
|
+
const rubricName = templateName(uri, 'bantamkit://rubrics/');
|
|
1115
|
+
const rubric = rubricName === null ? null : [uri, rubricName];
|
|
1107
1116
|
if (rubric) {
|
|
1108
|
-
|
|
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`;
|
|
1109
1122
|
let text;
|
|
1110
1123
|
try {
|
|
1111
1124
|
text = pyReadText(path);
|
|
@@ -1115,11 +1128,8 @@ export function buildServer(memory, wire, version, log = EventLog.fromEnv(memory
|
|
|
1115
1128
|
}
|
|
1116
1129
|
return { contents: [{ mimeType: 'text/plain', text, uri }] };
|
|
1117
1130
|
}
|
|
1118
|
-
//
|
|
1119
|
-
//
|
|
1120
|
-
// resource", so `[^/]*` and not `[^/]+`. A traversal is still not a match at all,
|
|
1121
|
-
// because `bantamkit://skills/../../etc/passwd` has separators in it, and the reference
|
|
1122
|
-
// 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).
|
|
1123
1133
|
throw rpcError(ErrorCode.InvalidParams, `Unknown resource: ${uri}`, { uri });
|
|
1124
1134
|
});
|
|
1125
1135
|
server.setRequestHandler(CallToolRequestSchema, (request, extra) => {
|
|
@@ -1167,14 +1177,140 @@ export function buildServer(memory, wire, version, log = EventLog.fromEnv(memory
|
|
|
1167
1177
|
...(structuredValue === null ? {} : { structuredContent: toJs(structuredValue) }),
|
|
1168
1178
|
};
|
|
1169
1179
|
});
|
|
1180
|
+
guardRequests(server, wire);
|
|
1170
1181
|
return server;
|
|
1171
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));
|
|
1172
1307
|
/** `params.arguments` out of the raw request line, with every number literal intact. */
|
|
1173
1308
|
function argumentsFrom(line) {
|
|
1174
1309
|
try {
|
|
1175
1310
|
const params = asDict(parseJson(line)).get('params');
|
|
1176
1311
|
const args = params === undefined ? undefined : asDict(params).get('arguments');
|
|
1177
|
-
|
|
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;
|
|
1178
1314
|
}
|
|
1179
1315
|
catch {
|
|
1180
1316
|
// Unreachable over a line the SDK already parsed; a re-parse failure must not be the
|
package/dist/mcp/status.js
CHANGED
|
@@ -251,11 +251,14 @@ export function indexPressureCondition(memory) {
|
|
|
251
251
|
// and this comparison is the whole reason they cannot.
|
|
252
252
|
if (size === null || size * 100 < INDEX_PRESSURE_PERCENT * budget)
|
|
253
253
|
return null;
|
|
254
|
+
// AT OR OVER THE BUDGET IS NOT "CLOSE" (job65/#186): the reply that carried the budget
|
|
255
|
+
// refusal used to close with "the next save is close to being refused". At `size >= budget`
|
|
256
|
+
// no save that adds a byte can land, so the sentence says so; the 90 % band keeps its words.
|
|
257
|
+
const consequence = size >= budget ? 'so it is full and every save is refused until it is compacted' : 'so the next save is close to being refused';
|
|
254
258
|
return {
|
|
255
259
|
key: 'index-budget-low',
|
|
256
|
-
sentence: `the memory index is ${size} bytes of a ${budget}-byte budget,
|
|
257
|
-
'
|
|
258
|
-
'`bantamkit-memory compact`.',
|
|
260
|
+
sentence: `the memory index is ${size} bytes of a ${budget}-byte budget, ${consequence} — ` +
|
|
261
|
+
'archive or shorten facts with `bantamkit-memory compact`.',
|
|
259
262
|
};
|
|
260
263
|
}
|
|
261
264
|
/**
|
|
@@ -422,7 +425,11 @@ export function statusReport(memory, log, conditions, identity, tools, prompts,
|
|
|
422
425
|
: `${plural(facts, 'fact')} in the project store`) +
|
|
423
426
|
', index ' +
|
|
424
427
|
(size === null ? 'unreadable' : String(size)) +
|
|
425
|
-
` of ${budget} bytes
|
|
428
|
+
` of ${budget} bytes` +
|
|
429
|
+
// job65/#142: the store this line counts, by path. WAS: no path at all, so a store the
|
|
430
|
+
// walk picked up ABOVE the project — an ancestor's, or `~/.bantamkit/memory` itself —
|
|
431
|
+
// answered as the writable project layer with nothing on the surface saying where.
|
|
432
|
+
`; bound to ${memory.store.root}`,
|
|
426
433
|
`event log: ${log.enabled ? 'on' : 'off'}`,
|
|
427
434
|
// THE UPDATE LINE IS NOT A CONDITION, and that is the ruling of 2026-09-19 rather than an
|
|
428
435
|
// omission. A newer version existing is not a fault: the server is serving correctly, and
|