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.
Files changed (53) hide show
  1. package/README.md +24 -9
  2. package/assets/tools/memory_compact.json +1 -2
  3. package/assets/tools/memory_recall.json +1 -3
  4. package/assets/tools/shiftwork_clock_in.json +1 -1
  5. package/assets/tools/shiftwork_clock_out.json +1 -1
  6. package/assets/tools/validate_json.json +1 -1
  7. package/assets/tools/work_plan.json +14 -8
  8. package/dist/assets.js +28 -4
  9. package/dist/cli.js +83 -24
  10. package/dist/contract.d.ts +0 -9
  11. package/dist/contract.js +51 -12
  12. package/dist/eventlog.d.ts +22 -2
  13. package/dist/eventlog.js +30 -3
  14. package/dist/hookadapter.js +263 -43
  15. package/dist/hostinstall.d.ts +2 -2
  16. package/dist/hostinstall.js +318 -84
  17. package/dist/index.d.ts +2 -2
  18. package/dist/index.js +1 -1
  19. package/dist/mcp/identity.js +27 -1
  20. package/dist/mcp/pyargs.d.ts +22 -6
  21. package/dist/mcp/pyargs.js +113 -39
  22. package/dist/mcp/server.js +177 -41
  23. package/dist/mcp/status.js +11 -4
  24. package/dist/mcp/transport.d.ts +51 -6
  25. package/dist/mcp/transport.js +217 -26
  26. package/dist/mcpreport.d.ts +12 -0
  27. package/dist/mcpreport.js +67 -15
  28. package/dist/memory/cli.js +18 -3
  29. package/dist/memory/component.d.ts +17 -0
  30. package/dist/memory/component.js +51 -7
  31. package/dist/memory/factfile.d.ts +21 -3
  32. package/dist/memory/factfile.js +351 -12
  33. package/dist/memory/layers.d.ts +19 -0
  34. package/dist/memory/layers.js +102 -2
  35. package/dist/memory/pyyaml.d.ts +6 -4
  36. package/dist/memory/pyyaml.js +6 -4
  37. package/dist/memory/store.d.ts +82 -7
  38. package/dist/memory/store.js +277 -29
  39. package/dist/npminstall.d.ts +18 -0
  40. package/dist/npminstall.js +26 -5
  41. package/dist/pyargparse.js +48 -2
  42. package/dist/pyjsonschema.d.ts +85 -4
  43. package/dist/pyjsonschema.js +1812 -181
  44. package/dist/selfupdate.d.ts +112 -32
  45. package/dist/selfupdate.js +221 -79
  46. package/dist/shiftwork.d.ts +46 -7
  47. package/dist/shiftwork.js +246 -42
  48. package/dist/tokenledger.js +20 -4
  49. package/dist/updatecheck.d.ts +15 -3
  50. package/dist/updatecheck.js +23 -8
  51. package/dist/workplan.d.ts +8 -3
  52. package/dist/workplan.js +62 -3
  53. package/package.json +2 -2
@@ -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
- * WHAT THE URL IS. `https://errors.pydantic.dev/2.13/v/<type>` names an entry in pydantic's
11
- * public error catalogue. This runtime has no pydantic; the URL is part of the sentence the
12
- * reference emits, reproduced because the sentence is the contract. `2.13` is therefore a
13
- * PIN on the reference, not a claim about this process — and it is compared against the
14
- * live server by `tools/conformance/suites/wire.mjs`, so the day the Python side bumps to
15
- * 2.14 the suite goes red here instead of the two runtimes drifting apart quietly.
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
  *
@@ -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
- * WHAT THE URL IS. `https://errors.pydantic.dev/2.13/v/<type>` names an entry in pydantic's
11
- * public error catalogue. This runtime has no pydantic; the URL is part of the sentence the
12
- * reference emits, reproduced because the sentence is the contract. `2.13` is therefore a
13
- * PIN on the reference, not a claim about this process — and it is compared against the
14
- * live server by `tools/conformance/suites/wire.mjs`, so the day the Python side bumps to
15
- * 2.14 the suite goes red here instead of the two runtimes drifting apart quietly.
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. The manifest's `minimum: 0` is advisory to the
111
- // client; nothing here floors it — a negative value reaches `MemoryStore.compact`, whose
112
- // `max(0, min(reserve, budget // 2))` is the only clamp on either side (6b966e5).
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, int] | None =
131
- // None, check: str | None = None, budget: int | None = None)` on the reference (job44).
132
- // `usage` is the only `dict[str, int]` on the surface and the reason `dictInt` exists:
133
- // pydantic validates a typed dict's VALUES, so `{'a': 'x'}` is `usage.a` / `int_parsing`
134
- // where `handoff_patch`'s `dict[str, Any]` takes anything. `budget` carries no `le`,
135
- // because the reference binds none — the manifest's `maximum` is advisory to the client,
136
- // and the handler's own refusal is the negative one. `versions` is the only
137
- // `dict[str, str]`, and the reason `dictStr` exists beside it: a `str` field is STRICT even
138
- // in lax mode, so `{'a': 1}` is `versions.a` / `string_type` where the same value under
139
- // `usage` would validate.
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 = checkInt(`${spec.name}.${key}`, item);
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 = checkInt(spec.name, value);
386
- if (!('value' in checked))
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)}]\n For further information visit ` +
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
  }
@@ -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';
@@ -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` then reads `node["id"]`,
181
- * `node.get("depends_on") or []` and `node.get("priority", 0)` off raw Python objects. This
182
- * reproduces those three reads and their defaults.
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 the
185
- * reference, whose `str()` is the key in single quotes, so the SDK's envelope reads
186
- * `Error executing tool work_plan: 'id'`. Defaulting it here would answer a plan over a
187
- * node the reference refused to look at.
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
- const node = item.t === 'dict' ? item.v : new Map();
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
- const declared = node.get('depends_on');
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: noted(result.asJson()) }, wrapped: true };
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: noted(tokenledger.asJson(result)) }, wrapped: true };
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 skill = /^bantamkit:\/\/skills\/([^/]*)$/.exec(uri);
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 rubric = /^bantamkit:\/\/rubrics\/([^/]*)$/.exec(uri);
1114
+ const rubricName = templateName(uri, 'bantamkit://rubrics/');
1115
+ const rubric = rubricName === null ? null : [uri, rubricName];
1107
1116
  if (rubric) {
1108
- 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`;
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
- // The template variable matches ONE segment and MAY BE EMPTY — measured: the reference
1119
- // answers `bantamkit://skills/` with -32603 `unknown skill asset: `, not with "unknown
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
- 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;
1178
1314
  }
1179
1315
  catch {
1180
1316
  // Unreachable over a line the SDK already parsed; a re-parse failure must not be the
@@ -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, so the next save ` +
257
- 'is close to being refused — archive or shorten facts with ' +
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