bantamkit-mcp 0.26.0

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 (148) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +398 -0
  3. package/assets/contracts/default.yaml +90 -0
  4. package/assets/evals/devteam/manifest.yaml +351 -0
  5. package/assets/evals/devteam/repo/HISTORY.md +18 -0
  6. package/assets/evals/devteam/repo/README.md +12 -0
  7. package/assets/evals/devteam/repo/docs/architecture.md +17 -0
  8. package/assets/evals/devteam/repo/docs/runbook.md +10 -0
  9. package/assets/evals/devteam/repo/issues/142-settlement-timeout.md +23 -0
  10. package/assets/evals/devteam/repo/patches/0009-retry-budget.patch +38 -0
  11. package/assets/evals/devteam/repo/src/ledger/__init__.py +3 -0
  12. package/assets/evals/devteam/repo/src/ledger/config.py +35 -0
  13. package/assets/evals/devteam/repo/src/ledger/errors.py +13 -0
  14. package/assets/evals/devteam/repo/src/ledger/posting.py +12 -0
  15. package/assets/evals/devteam/repo/src/ledger/registry.py +7 -0
  16. package/assets/evals/devteam/repo/src/ledger/report.py +9 -0
  17. package/assets/evals/devteam/repo/src/ledger/retry.py +17 -0
  18. package/assets/evals/devteam/repo/src/ledger/settle.py +16 -0
  19. package/assets/evals/devteam/repo/src/ledger/validate.py +14 -0
  20. package/assets/evals/devteam/repo/tests/test_posting.py +13 -0
  21. package/assets/evals/devteam/repo/tests/test_settle.py +9 -0
  22. package/assets/evals/devteam/tasks/dt-error-contract.yaml +186 -0
  23. package/assets/evals/devteam/tasks/dt-handler-map.yaml +183 -0
  24. package/assets/evals/devteam/tasks/dt-patch-before-after.yaml +182 -0
  25. package/assets/evals/devteam/tasks/dt-retry-attempts.yaml +181 -0
  26. package/assets/evals/devteam/tasks/dt-settlement-config.yaml +185 -0
  27. package/assets/evals/devteam/tasks/dt-symbol-home.yaml +181 -0
  28. package/assets/evals/devteam/tasks/dt-trace-blame.yaml +182 -0
  29. package/assets/evals/devteam/tasks/dt-unread-key.yaml +180 -0
  30. package/assets/evals/document/tasks/doc-large-in-137.yaml +38 -0
  31. package/assets/evals/document/tasks/doc-large-in-359.yaml +44 -0
  32. package/assets/evals/document/tasks/doc-large-in-372.yaml +38 -0
  33. package/assets/evals/document/tasks/doc-large-out-11764.yaml +37 -0
  34. package/assets/evals/document/tasks/doc-large-out-4137.yaml +37 -0
  35. package/assets/evals/document/tasks/doc-large-out-8022.yaml +37 -0
  36. package/assets/evals/document/tasks/doc-small-137.yaml +37 -0
  37. package/assets/evals/document/tasks/doc-small-261.yaml +37 -0
  38. package/assets/evals/document/tasks/doc-small-388.yaml +37 -0
  39. package/assets/evals/fixtures/.gitkeep +0 -0
  40. package/assets/evals/fixtures/catalog.json +6 -0
  41. package/assets/evals/perturbations/task-completion.yaml +576 -0
  42. package/assets/evals/tasks/.gitkeep +0 -0
  43. package/assets/evals/tasks/extract-contact.yaml +14 -0
  44. package/assets/evals/tasks/extract-invoice.yaml +14 -0
  45. package/assets/evals/tasks/extract-order.yaml +15 -0
  46. package/assets/evals/tasks/extract-schedule.yaml +14 -0
  47. package/assets/evals/tasks/extract-versions.yaml +17 -0
  48. package/assets/evals/tasks/nav-prod-port.yaml +84 -0
  49. package/assets/evals/tasks/nav-release-bundle.yaml +87 -0
  50. package/assets/evals/tasks/recall-audit-retention.yaml +17 -0
  51. package/assets/evals/tasks/recall-cache-ttl.yaml +13 -0
  52. package/assets/evals/tasks/recall-db-port.yaml +17 -0
  53. package/assets/evals/tasks/recall-deploy.yaml +13 -0
  54. package/assets/evals/tasks/recall-env-endpoint.yaml +18 -0
  55. package/assets/evals/tasks/recall-oncall-rotation.yaml +21 -0
  56. package/assets/evals/tasks/recall-oncall.yaml +13 -0
  57. package/assets/evals/tasks/recall-org-quota.yaml +18 -0
  58. package/assets/evals/tasks/recall-owner.yaml +13 -0
  59. package/assets/evals/tasks/shop-basket-total.yaml +10 -0
  60. package/assets/evals/tasks/shop-cheapest.yaml +9 -0
  61. package/assets/evals/tasks/shop-compare.yaml +9 -0
  62. package/assets/evals/tasks/shop-gadget-value.yaml +9 -0
  63. package/assets/evals/tasks/shop-stock-total.yaml +9 -0
  64. package/assets/evals/tasks/shop-total.yaml +9 -0
  65. package/assets/profiles/default.yaml +31 -0
  66. package/assets/profiles/patient.yaml +31 -0
  67. package/assets/rubrics/.gitkeep +0 -0
  68. package/assets/rubrics/code-quality.yaml +20 -0
  69. package/assets/rubrics/grounded-completion.yaml +37 -0
  70. package/assets/rubrics/task-completion.yaml +28 -0
  71. package/assets/schemas/shiftwork-checkpoint.json +188 -0
  72. package/assets/skills/.gitkeep +0 -0
  73. package/assets/skills/file-graph.md +7 -0
  74. package/assets/skills/memory.md +35 -0
  75. package/assets/tools/.gitkeep +0 -0
  76. package/assets/tools/bantamkit_read.json +48 -0
  77. package/assets/tools/bantamkit_status.json +25 -0
  78. package/assets/tools/build_identity.json +17 -0
  79. package/assets/tools/document_list.json +12 -0
  80. package/assets/tools/document_read.json +31 -0
  81. package/assets/tools/file_graph.json +12 -0
  82. package/assets/tools/memory_compact.json +31 -0
  83. package/assets/tools/memory_recall.json +38 -0
  84. package/assets/tools/memory_save.json +61 -0
  85. package/assets/tools/shiftwork_clock_in.json +25 -0
  86. package/assets/tools/shiftwork_clock_out.json +60 -0
  87. package/assets/tools/shiftwork_status.json +25 -0
  88. package/assets/tools/skill_audit.json +70 -0
  89. package/assets/tools/validate_json.json +31 -0
  90. package/dist/assets.d.ts +48 -0
  91. package/dist/assets.js +109 -0
  92. package/dist/charsets.d.ts +17 -0
  93. package/dist/charsets.js +580 -0
  94. package/dist/cli.d.ts +12 -0
  95. package/dist/cli.js +280 -0
  96. package/dist/contract.d.ts +145 -0
  97. package/dist/contract.js +608 -0
  98. package/dist/docread.d.ts +357 -0
  99. package/dist/docread.js +3315 -0
  100. package/dist/errors.d.ts +11 -0
  101. package/dist/errors.js +14 -0
  102. package/dist/eventlog.d.ts +137 -0
  103. package/dist/eventlog.js +294 -0
  104. package/dist/htmlentities.d.ts +2 -0
  105. package/dist/htmlentities.js +16 -0
  106. package/dist/index.d.ts +23 -0
  107. package/dist/index.js +13 -0
  108. package/dist/mcp/identity.d.ts +25 -0
  109. package/dist/mcp/identity.js +275 -0
  110. package/dist/mcp/pyargs.d.ts +80 -0
  111. package/dist/mcp/pyargs.js +362 -0
  112. package/dist/mcp/sdkjson.d.ts +56 -0
  113. package/dist/mcp/sdkjson.js +91 -0
  114. package/dist/mcp/server.d.ts +45 -0
  115. package/dist/mcp/server.js +740 -0
  116. package/dist/mcp/status.d.ts +210 -0
  117. package/dist/mcp/status.js +357 -0
  118. package/dist/mcp/transport.d.ts +68 -0
  119. package/dist/mcp/transport.js +111 -0
  120. package/dist/mcpreport.d.ts +214 -0
  121. package/dist/mcpreport.js +758 -0
  122. package/dist/memory/cli.d.ts +2 -0
  123. package/dist/memory/cli.js +396 -0
  124. package/dist/memory/component.d.ts +256 -0
  125. package/dist/memory/component.js +497 -0
  126. package/dist/memory/factfile.d.ts +120 -0
  127. package/dist/memory/factfile.js +406 -0
  128. package/dist/memory/layers.d.ts +92 -0
  129. package/dist/memory/layers.js +410 -0
  130. package/dist/memory/pyfs.d.ts +521 -0
  131. package/dist/memory/pyfs.js +1608 -0
  132. package/dist/memory/pyyaml.d.ts +155 -0
  133. package/dist/memory/pyyaml.js +868 -0
  134. package/dist/memory/store.d.ts +478 -0
  135. package/dist/memory/store.js +1034 -0
  136. package/dist/pyargparse.d.ts +141 -0
  137. package/dist/pyargparse.js +813 -0
  138. package/dist/pyjson.d.ts +110 -0
  139. package/dist/pyjson.js +524 -0
  140. package/dist/pyjsonschema.d.ts +50 -0
  141. package/dist/pyjsonschema.js +951 -0
  142. package/dist/shiftwork.d.ts +60 -0
  143. package/dist/shiftwork.js +324 -0
  144. package/dist/skillaudit.d.ts +172 -0
  145. package/dist/skillaudit.js +914 -0
  146. package/dist/statusline.d.ts +106 -0
  147. package/dist/statusline.js +281 -0
  148. package/package.json +45 -0
@@ -0,0 +1,740 @@
1
+ /**
2
+ * bantamkit as an MCP server: memory + validation over stdio, one instance per person.
3
+ *
4
+ * A port of `runtime-py/src/bantamkit/mcpserver.py`'s `build_server`, across two unrelated
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
7
+ * a `ruling` case in `tools/conformance/suites/wire.mjs`, carrying its reason and required to
8
+ * KEEP differing — a ruling whose case quietly started matching fails the suite too. Where
9
+ * they do not, this file takes the reference's answer even when the SDK offers an easier one.
10
+ *
11
+ * THE LOW-LEVEL `Server` AND NOT `McpServer`, and the reason is the whole property. The
12
+ * high-level class derives an advertisement from a zod schema and decides its own
13
+ * capabilities; both would have been the TS SDK's answers rather than the reference's. Every
14
+ * payload below is built by hand, out of the asset pack, in the reference's key order, so
15
+ * that `tools/list`, `resources/templates/list` and every tool result are the SAME BYTES the
16
+ * Python server emits. What the SDK is used for is the part that is genuinely protocol —
17
+ * framing, request routing, the initialize handshake, cancellation, error codes.
18
+ *
19
+ * THE `surfaces` GATE IS LOAD-BEARING. `assets/tools/` serves the eval agent too, and three
20
+ * of the thirteen manifests (`document_list`, `document_read`, `file_graph`) claim only `agent`.
21
+ * `fromManifest` REFUSES those by name rather than filtering them out, because a filter is
22
+ * indistinguishable from a typo: a manifest renamed or a surface dropped would silently
23
+ * shrink the served set, and the field would be a comment.
24
+ */
25
+ import { existsSync, readFileSync } from 'node:fs';
26
+ import { dirname, join } from 'node:path';
27
+ import { fileURLToPath } from 'node:url';
28
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
29
+ import { CallToolRequestSchema, ErrorCode, GetPromptRequestSchema, ListPromptsRequestSchema, ListResourceTemplatesRequestSchema, ListResourcesRequestSchema, ListToolsRequestSchema, ReadResourceRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
30
+ import { AssetNotFound, assetsRoot, loadSkill, loadToolAsset } from '../assets.js';
31
+ import { BantamError } from '../errors.js';
32
+ import { bantamkitReadUnknownPart, documentError, documentManifest, documentOffsetPastEnd, documentPage, schemaError, toolFailed, } from '../contract.js';
33
+ import * as docread from '../docread.js';
34
+ import * as skillaudit from '../skillaudit.js';
35
+ import { EventLog } from '../eventlog.js';
36
+ import { PyOSError, asPyOSError, pyReadText } from '../memory/pyfs.js';
37
+ import { fromJs, parseJson, reprValue, toJs } from '../pyjson.js';
38
+ import * as shiftwork from '../shiftwork.js';
39
+ import { buildIdentity, SERVER_NAME } from './identity.js';
40
+ import { ARG_MODELS, PyValidationFailure, validateArguments } from './pyargs.js';
41
+ import { sdkJson } from './sdkjson.js';
42
+ import { degradedConditions, degradedNotice, SERVED_PROMPTS, SERVED_RESOURCE_TEMPLATES, statusReport, STATUS_NAME, STATUS_PROMPT_DESCRIPTION, STATUS_PROMPT_TAIL, STATUS_PROMPT_TITLE, } from './status.js';
43
+ /**
44
+ * The eleven, in the order `build_server` lists them — which is the order `tools/list` emits.
45
+ *
46
+ * `bantamkit_status` went LAST rather than first, `memory_compact` after it rather than
47
+ * beside `memory_save` where a reader would look for it, `bantamkit_read` after that and
48
+ * `skill_audit` after that, for the same reason the reference appends all four: registration
49
+ * order IS the served order, and appending is the only edit that leaves the other ten where
50
+ * every existing declaration says they are.
51
+ */
52
+ export const MCP_TOOLS = [
53
+ 'memory_save',
54
+ 'memory_recall',
55
+ 'validate_json',
56
+ 'shiftwork_clock_in',
57
+ 'shiftwork_clock_out',
58
+ 'shiftwork_status',
59
+ 'build_identity',
60
+ 'bantamkit_status',
61
+ 'memory_compact',
62
+ 'bantamkit_read',
63
+ 'skill_audit',
64
+ ];
65
+ /**
66
+ * The advertisement for one tool, out of its manifest, with the surface checked.
67
+ *
68
+ * Key order is `description, inputSchema, name, outputSchema` because that is the order the
69
+ * reference's pydantic `Tool` model dumps in, and the nested schemas keep the manifest's own
70
+ * key order because both runtimes preserve insertion order in a JSON object.
71
+ *
72
+ * `output_schema` is advertised VERBATIM and is not derived from anything this file knows.
73
+ * That includes `build_identity.json`'s `"title": "build_identity_toolDictOutput"`, which
74
+ * leaks the Python closure name `build_identity_tool` into a Node server's advertisement.
75
+ * IT IS REPRODUCED, NOT FIXED. The manifest is an asset, `build_identity` hashes every asset
76
+ * byte, and changing it would move `assets_digest` — the one field that is supposed to be
77
+ * comparable across the two runtimes. A cosmetic title is not worth breaking the instrument
78
+ * that detects drift. Registered here, not fixed here; `runtime-py` is the reference.
79
+ */
80
+ export function fromManifest(name) {
81
+ const asset = loadToolAsset(name);
82
+ const surfaces = (asset['surfaces'] ?? []);
83
+ if (!surfaces.includes('mcp')) {
84
+ throw new BantamError(`tool asset '${name}' does not claim the mcp surface: [${surfaces.map((s) => `'${s}'`).join(', ')}]`);
85
+ }
86
+ return {
87
+ description: asset['description'],
88
+ inputSchema: asset['parameters'],
89
+ name: asset['name'],
90
+ outputSchema: asset['output_schema'],
91
+ };
92
+ }
93
+ /**
94
+ * `metadata.version("mcp")`'s analogue: the SDK's own manifest, found by walking UP.
95
+ *
96
+ * NOT `import.meta.resolve('@modelcontextprotocol/sdk/package.json')`. That resolves through
97
+ * the SDK's `exports` map, whose `"./*"` arm points into `dist/esm/`, so it returns
98
+ * `dist/esm/package.json` — the two-key `{"type": "module"}` marker file, with no `version`
99
+ * at all. Measured: that route reported `mcp_sdk_version: null` from BOTH a dev checkout and
100
+ * an installed tarball, which is exactly the sentinel `RB-P51` forbids — a field typed as a
101
+ * version holding a value that reads as one. Found by driving the packaged install, not by
102
+ * the suite, which is why there is now a case for it.
103
+ *
104
+ * So: resolve a real module, then walk parents until a `package.json` names the SDK AND
105
+ * carries a version. Anything short of that is the unavailable object, never a sentinel.
106
+ */
107
+ function sdkVersion() {
108
+ const unavailable = {
109
+ unavailable: 'no package.json naming @modelcontextprotocol/sdk with a version was found above the ' +
110
+ 'resolved SDK module, so the SDK version is not knowable here',
111
+ };
112
+ try {
113
+ let dir = dirname(fileURLToPath(import.meta.resolve('@modelcontextprotocol/sdk/server/index.js')));
114
+ for (let up = 0; up < 8; up += 1) {
115
+ const candidate = join(dir, 'package.json');
116
+ if (existsSync(candidate)) {
117
+ const parsed = JSON.parse(readFileSync(candidate, 'utf8'));
118
+ if (parsed.name === '@modelcontextprotocol/sdk' && typeof parsed.version === 'string')
119
+ return parsed.version;
120
+ }
121
+ const parent = dirname(dir);
122
+ if (parent === dir)
123
+ break;
124
+ dir = parent;
125
+ }
126
+ }
127
+ catch {
128
+ return unavailable;
129
+ }
130
+ return unavailable;
131
+ }
132
+ /**
133
+ * A JSON-RPC error, and NOT `McpError`.
134
+ *
135
+ * `new McpError(code, message)` sets `.message` to `MCP error <code>: <message>`, and
136
+ * `Protocol` copies `.message` straight onto the wire — so the SDK's own error class would
137
+ * put a prefix in a field the reference fills with the bare sentence. The protocol reads
138
+ * `code`, `message` and `data` off whatever is thrown, so a plain `Error` carrying the three
139
+ * is both simpler and exact.
140
+ */
141
+ function rpcError(code, message, data) {
142
+ return Object.assign(new Error(message), { code, data });
143
+ }
144
+ const asDict = (value) => (value.t === 'dict' ? value.v : new Map());
145
+ const asText = (value) => (value && value.t === 'str' ? value.v : '');
146
+ /** `int | None` after validation: the tagged int, or `null` for the default. */
147
+ function asInt(value) {
148
+ return value && value.t === 'int' ? Number(value.v) : null;
149
+ }
150
+ /**
151
+ * Run `body` and, if it throws, record the exception's TYPE before letting it fly on.
152
+ *
153
+ * The host already logs that a tool failed and how long it took; what it cannot say is which
154
+ * exception the component threw — and the one place it tried, it leaked an argument value
155
+ * doing it (`eventlog.ts`'s comment, measured). So the constructor name and nothing else, via
156
+ * `EventLog.raised`, which has no other input available to it.
157
+ *
158
+ * The rethrow is unconditional: nothing here decides whether the tool fails, only whether the
159
+ * failure was written down. `EventLog.record` swallows its own system errors, so this cannot
160
+ * mask the original.
161
+ */
162
+ /** A rejection the filesystem produced: libuv's numeric `errno` and the `syscall` it names. */
163
+ function isFsError(e) {
164
+ const err = e;
165
+ return e instanceof Error && typeof err.errno === 'number' && typeof err.syscall === 'string';
166
+ }
167
+ function recordRaise(log, tool, body) {
168
+ try {
169
+ return body();
170
+ }
171
+ catch (e) {
172
+ log.raised(tool, e);
173
+ throw e;
174
+ }
175
+ }
176
+ /**
177
+ * Run a shiftwork handler and record its `result` — the register's OWN verdict.
178
+ *
179
+ * Every `shiftwork` entry point answers a dict whose `result` key is the decision it made:
180
+ * `brief`, `escalate`, `success`, `ok`, `status`, `error`. `clockIn` returning `escalate` is
181
+ * the sharp case — the register's whole stop-and-ask contract, invisible in the host's log
182
+ * because the tool call succeeded. Reading the key is reading the decision; nothing here
183
+ * inspects a `reason` string, which is prose and can carry a checkpoint path.
184
+ *
185
+ * `raised` is the outcome for an ESCAPING exception and is spelled differently from `error`
186
+ * on purpose: `result: "error"` is a refusal the register composed and returned normally, and
187
+ * collapsing the two would lose the only distinction between a checkpoint that was rejected
188
+ * and a handler that fell over.
189
+ */
190
+ function recordResult(log, tool, call) {
191
+ const answer = recordRaise(log, tool, call);
192
+ const result = asDict(answer).get('result');
193
+ // `str(answer.get("result", "unknown"))`. `str` of a `str` is the string itself; `str` of
194
+ // anything else is its `repr`, which is what `reprValue` writes. Every exit answers a
195
+ // string today, so the second arm is a shape change being recorded rather than hidden.
196
+ log.record(tool, result === undefined ? 'unknown' : result.t === 'str' ? result.v : reprValue(result));
197
+ return answer;
198
+ }
199
+ /**
200
+ * Run one tool and return its Python-shaped answer.
201
+ *
202
+ * The two return kinds are the SDK's, not this file's: `memory_save`, `memory_recall`,
203
+ * `bantamkit_status`, `memory_compact` and `bantamkit_read` are annotated `-> str` in the
204
+ * reference, so `_create_wrapped_model` puts them under a `result` key; the other five are
205
+ * `-> dict[str, Any]` and pass through as themselves. That is why `structuredContent` is
206
+ * exactly `{ result }` for those five of the ten, and carries the handler's own keys for
207
+ * the other five.
208
+ *
209
+ * DO NOT READ THAT AS "FIVE TOOLS HAVE A `result` KEY". Driven over stdio, eight of the ten
210
+ * answer with a `result` somewhere in `structuredContent`: the three shiftwork tools carry
211
+ * one of their own, and it is the register's verdict, not this wrapper. `wrapped` below is
212
+ * the bit that actually decides, and it is `true` exactly five times.
213
+ *
214
+ * EVERY RECORD BELOW COMES FROM A DECISION, NEVER FROM A REPLY. `memory_save` reads
215
+ * `SaveOutcome.status`, `memory_recall` reads `RecallOutcome.status`, `memory_compact`
216
+ * reads `CompactOutcome.status` (the store's own `archived` list, empty or not), the three shiftwork
217
+ * tools read the register's own `result` key, `validate_json` reads the `valid` bool it is
218
+ * about to return, `build_identity` reads the length of the `unavailable` list it
219
+ * computed, and `bantamkit_read` records the BRANCH it took. Not one of them looks at the
220
+ * words. Change a reply's wording and the record must be byte-identical —
221
+ * `test/eventlog.test.mjs` holds exactly that.
222
+ *
223
+ * Logging happens HERE and not in the caller's `try`, so that an argument refusal — which
224
+ * `validateArguments` raises before this function is entered, exactly as pydantic does before
225
+ * the reference's handler is entered — writes no record on either side.
226
+ *
227
+ * THE DEGRADED FOOTER IS APPLIED AFTER THE RECORD, EVERY TIME. A footer is a rendering
228
+ * decision about somebody else's answer; the record is the decision the component made.
229
+ * Folding one into the other would put a filesystem observation into a line a conformance case
230
+ * byte-compares, and would make the log move when nothing the tool did moved. `noted` and
231
+ * `notedDict` below therefore run last, on the way out.
232
+ */
233
+ function runTool(name, memory, args, version, log) {
234
+ /**
235
+ * The footer for right now — recomputed per call, never cached.
236
+ *
237
+ * A cache would be the one thing that could make this lie: a condition that cleared (or
238
+ * arrived) between two calls has to be visible on the next one, and the whole probe is a
239
+ * handful of `stat`s. It is also what keeps two servers over one store from disagreeing
240
+ * about the state of it.
241
+ */
242
+ const notice = () => degradedNotice(degradedConditions(memory, log));
243
+ /** A prose reply, plus the footer if there is one. Byte-identical when healthy. */
244
+ const noted = (reply) => {
245
+ const line = notice();
246
+ return line === '' ? reply : `${reply}\n\n${line}`;
247
+ };
248
+ /**
249
+ * A structured reply, plus the footer if there is one, under one reserved key.
250
+ *
251
+ * A JSON result has no margin to write in: the rendered text of these tools IS the
252
+ * serialised object, so a sentence appended to it would stop being parseable. The footer
253
+ * therefore arrives as `bantamkit_degraded`, LAST in the key order and only when it exists —
254
+ * every one of these tools advertises `additionalProperties: true`, so a key that comes and
255
+ * goes is inside the contract it already declares. A healthy call is byte-identical to what
256
+ * it was before this surface existed, which is the same guarantee `noted` gives for prose.
257
+ */
258
+ const notedDict = (answer) => {
259
+ const line = notice();
260
+ if (line === '' || answer.t !== 'dict')
261
+ return answer;
262
+ // A fresh Map preserves the answer's own key order and appends: the reserved key is not
263
+ // one of these tools' fields, so `set` puts it at the end and nothing else moves.
264
+ const out = new Map(answer.v);
265
+ out.set('bantamkit_degraded', { t: 'str', v: line });
266
+ return { t: 'dict', v: out };
267
+ };
268
+ switch (name) {
269
+ case 'memory_save': {
270
+ const links = args.get('links');
271
+ const asStrings = links && links.t === 'list' ? links.v.map((v) => asText(v)) : null;
272
+ const outcome = recordRaise(log, 'memory_save', () => memory.saveOutcome(asText(args.get('type')), asText(args.get('name')), asText(args.get('description')), asText(args.get('body')), asStrings));
273
+ if (log.enabled) {
274
+ // Only when a log is actually on: `indexAccounting` re-parses `facts/`, and a
275
+ // diagnostic must not put that on the path of an operator who did not ask for one.
276
+ // Headroom is left to the reader rather than stored — it is `budget - index_bytes`,
277
+ // and a derived field is a second thing to keep true.
278
+ const [indexBytes, budget] = memory.indexAccounting();
279
+ const detail = { budget };
280
+ if (indexBytes !== null)
281
+ detail['index_bytes'] = indexBytes;
282
+ log.record('memory_save', outcome.status, detail);
283
+ }
284
+ return { value: { t: 'str', v: noted(outcome.reply) }, wrapped: true };
285
+ }
286
+ case 'memory_recall': {
287
+ let k = asInt(args.get('k'));
288
+ // The advertised schema's bounds; clients may ignore it, so the server does not.
289
+ if (k !== null)
290
+ k = Math.max(1, Math.min(k, 5));
291
+ const outcome = recordRaise(log, 'memory_recall', () => memory.recallOutcome(asText(args.get('query')), k));
292
+ const detail = {
293
+ budget: outcome.budget,
294
+ candidates: outcome.candidates,
295
+ layers: outcome.layers,
296
+ reached: outcome.reached,
297
+ returned: outcome.returned,
298
+ unreadable: outcome.unreadable,
299
+ };
300
+ if (outcome.source !== null)
301
+ detail['source'] = outcome.source;
302
+ log.record('memory_recall', outcome.status, detail);
303
+ return { value: { t: 'str', v: noted(outcome.reply) }, wrapped: true };
304
+ }
305
+ case 'validate_json': {
306
+ const error = recordRaise(log, 'validate_json', () => schemaError(asText(args.get('output')), args.get('schema')));
307
+ const out = new Map([['valid', { t: 'bool', v: error === null }]]);
308
+ out.set('feedback', error === null ? { t: 'null' } : { t: 'str', v: `${error}\nReturn ONLY a JSON object matching the schema.` });
309
+ log.record('validate_json', error === null ? 'valid' : 'invalid');
310
+ return { value: notedDict({ t: 'dict', v: out }), wrapped: false };
311
+ }
312
+ case 'shiftwork_clock_in':
313
+ return {
314
+ value: notedDict(recordResult(log, name, () => shiftwork.clockIn(asText(args.get('checkpoint'))))),
315
+ wrapped: false,
316
+ };
317
+ case 'shiftwork_clock_out':
318
+ return {
319
+ value: notedDict(recordResult(log, name, () => shiftwork.clockOut(asText(args.get('checkpoint')), asText(args.get('unit_id')), asText(args.get('status')), args.get('handoff_patch'), args.get('history_entry'), args.get('accounting') ?? { t: 'null' }))),
320
+ wrapped: false,
321
+ };
322
+ case 'shiftwork_status':
323
+ return {
324
+ value: notedDict(recordResult(log, name, () => shiftwork.status(asText(args.get('checkpoint'))))),
325
+ wrapped: false,
326
+ };
327
+ case 'build_identity': {
328
+ const identity = recordRaise(log, 'build_identity', () => buildIdentity(version, sdkVersion()));
329
+ // The COUNT of underivable fields, not the fields and not the digests. A code digest is
330
+ // by construction different in the two runtimes — they fingerprint two different trees
331
+ // — so putting one in a record that a conformance case byte-compares would make the
332
+ // record unportable to buy nothing the tool's own reply does not already say.
333
+ const unavailable = identity.get('unavailable');
334
+ log.record('build_identity', unavailable.length ? 'partial' : 'complete', { unavailable: unavailable.length });
335
+ return { value: notedDict(fromJs(identity)), wrapped: false };
336
+ }
337
+ case 'bantamkit_status': {
338
+ // NO FOOTER ON THIS ONE, and the omission is the design rather than an oversight: the
339
+ // report already carries every condition in full, and a footer would repeat the worst of
340
+ // them three lines below itself.
341
+ //
342
+ // NOTHING IS RECORDED IN THE EVENT LOG EITHER. Every other handler records the decision
343
+ // its component made; this one makes no decision — it observes. A record would be a
344
+ // second, worse copy of a state the log's own reader can see, and its `outcome` would
345
+ // have to be the health verdict, which moves with the filesystem rather than with
346
+ // anything the call did. `recordRaise` still wraps the body, so a handler that FALLS
347
+ // OVER is still written down.
348
+ const report = recordRaise(log, 'bantamkit_status', () => statusReport(memory, log, degradedConditions(memory, log), buildIdentity(version, sdkVersion()), MCP_TOOLS.length, SERVED_PROMPTS, SERVED_RESOURCE_TEMPLATES));
349
+ return { value: { t: 'str', v: report }, wrapped: true };
350
+ }
351
+ case 'memory_compact': {
352
+ // The model's half of compaction; the hook (`docs/hooks.md`) is the automatic half.
353
+ // `memory_save`'s refused-budget reply names this tool, so it acts on exactly the store
354
+ // that refused: `compactOutcome` reaches `memory.store` — the writable project layer —
355
+ // and never a grant or the profile layer. The status is the store's own decision
356
+ // (`archived` when the archive list is non-empty, `nothing-archived` otherwise), never
357
+ // a match on the reply.
358
+ const reserve = asInt(args.get('reserve'));
359
+ // No floor here: `MemoryStore.compact` clamps `reserve` to `[0, budget/2]` itself, as
360
+ // the reference's does, so a negative value is handled where the arithmetic lives.
361
+ const outcome = recordRaise(log, 'memory_compact', () => memory.compactOutcome(reserve));
362
+ log.record('memory_compact', outcome.status, {
363
+ archived: outcome.archived,
364
+ budget: outcome.budget,
365
+ index_after: outcome.indexAfter,
366
+ index_before: outcome.indexBefore,
367
+ });
368
+ return { value: { t: 'str', v: noted(outcome.reply) }, wrapped: true };
369
+ }
370
+ case 'bantamkit_read': {
371
+ // The reader on the MCP surface (job43): `docread` digests, `contract` words it. The
372
+ // handler makes the SAME `contract` calls the reference's `bantamkit_read` makes,
373
+ // with the path standing in for the document name, so the two servers print the
374
+ // same bytes for the same file. Three sentences are this tool's own — the continuation
375
+ // line names `bantamkit_read`, an unknown part is a fact about the file, and so is a
376
+ // part with no rows (`document_error` over `"{part}" in {path} has no rows`).
377
+ //
378
+ // THE RECORD IS A DECISION, NEVER A REPLY. `manifest` and `page` are the branch
379
+ // taken; `refused-unreadable` is `extract` raising (a missing file, a directory, a
380
+ // container this reader has no extractor for, an `OSError` the filesystem threw —
381
+ // all of them reach the model as a `document_error` sentence in the reader's own
382
+ // words, never as an exception on the wire); `refused-unknown-part` and
383
+ // `refused-offset` are the two argument refusals — the latter also when the part has
384
+ // no rows at all, where NO offset can be in range and the sentence says so instead of
385
+ // "numbered 0 to -1" (review round 3). `detail` carries the container
386
+ // kind (a token from `docread`'s closed set), the part count, and the rows and UTF-8
387
+ // bytes the reply carries — never the path, never a part name, never a row.
388
+ //
389
+ // `limit` is clamped to the advertised `[1, 200]` and `offset` to `>= 0` the way
390
+ // `memory_recall` clamps `k`: the schema says so, and a client may ignore it.
391
+ const path = asText(args.get('path'));
392
+ const partArg = args.get('part');
393
+ const part = partArg !== undefined && partArg.t === 'str' ? partArg.v : null;
394
+ const offsetArg = asInt(args.get('offset'));
395
+ const start = offsetArg === null ? 0 : Math.max(0, offsetArg);
396
+ const limitArg = asInt(args.get('limit'));
397
+ const rows = limitArg === null ? docread.DEFAULT_ROW_LIMIT : Math.max(1, Math.min(limitArg, docread.PAGE_MAX_ROWS));
398
+ // `with _record_raise(log, "bantamkit_read")` wraps the reference's WHOLE handler, the
399
+ // `extract` call included, so an exception the reader lets escape (`LookupError` from
400
+ // an XML declaration naming no codec) is recorded `raised` and then reaches the wire
401
+ // as an `isError` frame. A corrupt deflate stream or a lying CRC no longer does: since
402
+ // review round 3 `_read`/`readMember` word both as the damaged-member sentence, a
403
+ // `document_error` like the encrypted one. Until job43 G2 this arm ran `extract`
404
+ // outside `recordRaise` with a hand-copied catch.
405
+ return recordRaise(log, 'bantamkit_read', () => {
406
+ let doc;
407
+ try {
408
+ doc = docread.extract(path);
409
+ }
410
+ catch (e) {
411
+ // `except (docread.DocumentReadError, OSError)`. A Node fs error is CPython's
412
+ // `OSError` with the sentence rebuilt by `asPyOSError` — `[Errno 13] Permission
413
+ // denied: '<path>'` for a file this process may not open — through the CRT arm,
414
+ // because `open()` is the call the reference makes. A `BadZipFile` never reaches
415
+ // this arm on either side: `docread.ts`'s `zipKind`/`openZip` catch it exactly
416
+ // where `docread.py`'s `_zip_kind`/`_open` do, and what leaves them is the
417
+ // sentence naming what the reader saw, never the zip module's.
418
+ // A `PyOSError` the reader raised itself is already the reference's sentence —
419
+ // `[Errno 22] Invalid argument` from a zip whose member offset is negative, with
420
+ // NO filename, because the `seek` that fails there has none. Re-wrapping it would
421
+ // append `: '<path>'`.
422
+ // ONLY a filesystem rejection is an `OSError`: numeric `errno` AND the `syscall`
423
+ // it came from. A string `code` alone is not one — `Z_DATA_ERROR`,
424
+ // `ERR_INVALID_ARG_VALUE` and `ERR_STRING_TOO_LONG` all carry one, and until job43
425
+ // G2 each was printed as a fabricated `[Errno 0] …` sentence where the reference
426
+ // raises (the corrupt stream) or says `no such file` (the NUL byte, which
427
+ // `docread.statPath` now answers before this arm is reached).
428
+ const refusal = e instanceof docread.DocumentReadError || e instanceof PyOSError
429
+ ? e
430
+ : isFsError(e)
431
+ ? asPyOSError(e, path, undefined, 'crt')
432
+ : null;
433
+ if (refusal === null)
434
+ throw e;
435
+ log.record('bantamkit_read', 'refused-unreadable');
436
+ return { value: { t: 'str', v: noted(documentError(refusal)) }, wrapped: true };
437
+ }
438
+ const detail = { kind: doc.kind, parts: doc.parts.length };
439
+ if (part === null) {
440
+ const reply = documentManifest(doc.parts.map((p) => ({
441
+ document: path,
442
+ kind: doc.kind,
443
+ index: p.index,
444
+ part: p.name,
445
+ row_count: p.rowCount,
446
+ rows: p.rows,
447
+ omissions: p.omissions.map((o) => o.asDict()),
448
+ })), doc.omissions.length ? [{ document: path, omissions: doc.omissions.map((o) => o.asDict()) }] : []);
449
+ detail['rows'] = doc.parts.reduce((sum, p) => sum + p.rowCount, 0);
450
+ detail['bytes'] = doc.textBytes;
451
+ log.record('bantamkit_read', 'manifest', detail);
452
+ return { value: { t: 'str', v: noted(reply) }, wrapped: true };
453
+ }
454
+ let target;
455
+ try {
456
+ target = doc.part(part);
457
+ }
458
+ catch (e) {
459
+ if (!(e instanceof docread.DocumentReadError))
460
+ throw e;
461
+ log.record('bantamkit_read', 'refused-unknown-part', detail);
462
+ const reply = bantamkitReadUnknownPart(part, path, doc.parts.map((p) => p.name));
463
+ return { value: { t: 'str', v: noted(reply) }, wrapped: true };
464
+ }
465
+ if (target.rowCount === 0) {
466
+ log.record('bantamkit_read', 'refused-offset', detail);
467
+ const reply = documentError(`"${target.name}" in ${path} has no rows`);
468
+ return { value: { t: 'str', v: noted(reply) }, wrapped: true };
469
+ }
470
+ if (start >= target.rowCount) {
471
+ log.record('bantamkit_read', 'refused-offset', detail);
472
+ const reply = documentOffsetPastEnd(target.name, start, target.rowCount);
473
+ return { value: { t: 'str', v: noted(reply) }, wrapped: true };
474
+ }
475
+ const got = docread.page(doc, part, start, rows, docread.PAGE_MAX_BYTES);
476
+ detail['rows'] = got.rows.length;
477
+ detail['bytes'] = Buffer.byteLength(got.text, 'utf8');
478
+ log.record('bantamkit_read', 'page', detail);
479
+ const reply = documentPage(path, got.part, got.offset, got.rows, got.totalRows, got.nextOffset, got.truncatedBytes, 'bantamkit_read_page_next');
480
+ return { value: { t: 'str', v: noted(reply) }, wrapped: true };
481
+ });
482
+ }
483
+ case 'skill_audit': {
484
+ // The catalogue auditor on the MCP surface (job44): `skillaudit` measures, this serves
485
+ // it.
486
+ //
487
+ // THE REPLY IS A JSON DOCUMENT AND NOT PROSE, which is the one thing that makes this
488
+ // handler shaped differently from `bantamkit_read` above. The reader answers a person
489
+ // reading a transcript, so `contract` words it; this answers a caller that has to
490
+ // compare `catalogue_bytes` against a budget it set and act on the ids in
491
+ // `findings[].skills`. A sentence would have to be parsed back. So `Audit.asJson` is
492
+ // the reply verbatim, and the only string this layer authors is the refusal.
493
+ //
494
+ // THE REFUSALS ARE ARGUMENT FAILURES, ALL THREE OF THEM, so they go through
495
+ // `toolFailed` — an unknown `check`, a negative `budget`, a `root` that is not a
496
+ // directory. Nothing about the CONTENT of the tree refuses: a file that will not
497
+ // decode, a block that will not parse and a plugin that is switched off are recorded as
498
+ // omissions and counted.
499
+ //
500
+ // `check` defaults HERE rather than in the module's signature so that the one spelling
501
+ // of the default lives in `skillaudit.audit`, and `null` and an absent argument reach
502
+ // it as the same thing. `enabled` and `usage` do the same: an absent map is no
503
+ // measurement, an empty one is a measurement of nothing, and the two answer differently.
504
+ // `versions` is the third caller-supplied host fact: the version directory the host
505
+ // actually serves, per `<plugin>@<marketplace>`, passed straight through, with an absent
506
+ // map meaning the byte-order fallback for every plugin.
507
+ //
508
+ // THE RECORD IS A DECISION, NEVER A REPLY, and it holds no free text: `audited` carries
509
+ // the four counts the host cannot see and `refused` carries nothing at all. `root` is a
510
+ // path the operator typed and `findings[].skills` are the names of their skills;
511
+ // neither is a decision this handler made, so neither is written down.
512
+ const root = asText(args.get('root'));
513
+ const enabledArg = args.get('enabled');
514
+ const enabled = enabledArg !== undefined && enabledArg.t === 'list'
515
+ ? enabledArg.v.map((item) => (item.t === 'str' ? item.v : ''))
516
+ : null;
517
+ const usageArg = args.get('usage');
518
+ let usage = null;
519
+ if (usageArg !== undefined && usageArg.t === 'dict') {
520
+ usage = new Map();
521
+ for (const [key, value] of usageArg.v)
522
+ usage.set(key, value.t === 'int' ? value.v : 0n);
523
+ }
524
+ const checkArg = args.get('check');
525
+ const check = checkArg !== undefined && checkArg.t === 'str' ? checkArg.v : 'all';
526
+ const budgetArg = args.get('budget');
527
+ const budget = budgetArg !== undefined && budgetArg.t === 'int' ? budgetArg.v : null;
528
+ const versionsArg = args.get('versions');
529
+ let versions = null;
530
+ if (versionsArg !== undefined && versionsArg.t === 'dict') {
531
+ versions = new Map();
532
+ for (const [key, value] of versionsArg.v)
533
+ versions.set(key, value.t === 'str' ? value.v : '');
534
+ }
535
+ return recordRaise(log, 'skill_audit', () => {
536
+ let result;
537
+ try {
538
+ result = skillaudit.audit(root, { enabled, usage, check, budget, versions });
539
+ }
540
+ catch (e) {
541
+ // `except (skillaudit.SkillAuditError, OSError)`. The module answers `no such
542
+ // directory` for a root it cannot stat, so a filesystem rejection is already inside
543
+ // its own sentence and nothing else here catches one.
544
+ if (!(e instanceof skillaudit.SkillAuditError))
545
+ throw e;
546
+ log.record('skill_audit', 'refused');
547
+ return { value: { t: 'str', v: noted(toolFailed('skill_audit', e)) }, wrapped: true };
548
+ }
549
+ log.record('skill_audit', 'audited', {
550
+ skills: result.skills,
551
+ bytes: result.catalogueBytes,
552
+ findings: result.findings.length,
553
+ omissions: result.omissions.length,
554
+ });
555
+ return { value: { t: 'str', v: noted(result.asJson()) }, wrapped: true };
556
+ });
557
+ }
558
+ default:
559
+ // `tool_manager.call_tool` raises `ToolError(f"Unknown tool: {name}")`, which the
560
+ // handler turns into an isError result rather than a JSON-RPC error.
561
+ throw new BantamError(`Unknown tool: ${name}`);
562
+ }
563
+ }
564
+ /**
565
+ * Assemble the MCP server around one Memory instance (the per-person state).
566
+ *
567
+ * `wire` is the transport, and it is a parameter rather than something this function creates
568
+ * because the handlers need it twice: to read the RAW request line (so `5.0` is still a float
569
+ * when it reaches `shiftwork.clockOut`) and to register the EXACT result text (so it is still
570
+ * a float on the way back out).
571
+ *
572
+ * `log` is the event-log sink (`docs/eventlog.md`), resolved from `BANTAMKIT_EVENT_LOG` when
573
+ * the caller does not supply one and DISABLED unless that variable asks for it. It is a
574
+ * parameter and not only an environment read so that a test can inject a fixed clock and a
575
+ * scratch path without setting a process-wide variable — the same seam `runtime-py`'s
576
+ * `build_server(memory, log=None)` offers.
577
+ */
578
+ export function buildServer(memory, wire, version, log = EventLog.fromEnv(memory.store.root)) {
579
+ const server = new Server({ name: SERVER_NAME, version }, {
580
+ // The reference's advertisement, verbatim. The TS SDK's own default would be
581
+ // `{tools:{listChanged:true},resources:{listChanged:true}}` with no `prompts` and no
582
+ // `experimental` — a client that switched on any of those four would behave
583
+ // differently against the two servers, which is the difference this exists to remove.
584
+ capabilities: {
585
+ experimental: {},
586
+ prompts: { listChanged: false },
587
+ resources: { listChanged: false, subscribe: false },
588
+ tools: { listChanged: false },
589
+ },
590
+ instructions: loadSkill('memory'),
591
+ });
592
+ // Startup, not call time: a tool without a manifest, or with the wrong surface, must stop
593
+ // the process before a client ever sees a `tools/list`. The reference gets this from
594
+ // `MCPServer(..., tools=[...])`; here it is one eager pass.
595
+ const advertised = MCP_TOOLS.map((name) => fromManifest(name));
596
+ server.setRequestHandler(ListToolsRequestSchema, () => ({ tools: advertised }));
597
+ server.setRequestHandler(ListResourcesRequestSchema, () => ({ resources: [] }));
598
+ // THE PROMPT, AND WHY IT IS NOT A DUPLICATE OF THE TOOL ABOVE.
599
+ //
600
+ // `prompts/list` was EMPTY on both runtimes while both advertised `prompts: {listChanged:
601
+ // false}`, so this is a new surface rather than an addition to one. A tool is what the MODEL
602
+ // can call; a prompt is what a PERSON invokes — in Claude Code it is a slash command in the
603
+ // operator's own menu. The person wanting to know whether their server is alive is the
604
+ // operator, and until now the only way for them to ask was to talk a model into asking.
605
+ //
606
+ // Key order is `arguments, description, name, title` and `description, messages` because
607
+ // that is the order `mcp` 2.0.0's pydantic `Prompt` and `GetPromptResult` models dump in —
608
+ // measured against the running reference, not guessed. Both are compared as RAW BYTES by
609
+ // `wire`, so the order is part of the answer.
610
+ server.setRequestHandler(ListPromptsRequestSchema, () => ({
611
+ prompts: [
612
+ {
613
+ // `arguments: []` is emitted, not omitted: the reference's model has a default of
614
+ // `[]` for a prompt function with no parameters, and a client comparing two
615
+ // advertisements would see a key appear and disappear.
616
+ arguments: [],
617
+ description: STATUS_PROMPT_DESCRIPTION,
618
+ name: STATUS_NAME,
619
+ title: STATUS_PROMPT_TITLE,
620
+ },
621
+ ],
622
+ }));
623
+ // IT CARRIES THE ANSWER, not an instruction to go and get it. `prompts/get` runs
624
+ // server-side, so the report is already in the message the host inserts: the operator sees
625
+ // it with no tool round trip, and it is true as of the moment they asked.
626
+ server.setRequestHandler(GetPromptRequestSchema, (request) => {
627
+ // The reference's `PromptManager` raises for an unregistered name, which the SDK turns
628
+ // into a JSON-RPC error; the TS SDK does the same for a handler that throws. One name is
629
+ // registered, so anything else is unknown.
630
+ if (request.params.name !== STATUS_NAME) {
631
+ throw rpcError(ErrorCode.InvalidParams, `Unknown prompt: ${request.params.name}`, null);
632
+ }
633
+ const conditions = degradedConditions(memory, log);
634
+ const report = statusReport(memory, log, conditions, buildIdentity(version, sdkVersion()), MCP_TOOLS.length, SERVED_PROMPTS, SERVED_RESOURCE_TEMPLATES);
635
+ return {
636
+ description: STATUS_PROMPT_DESCRIPTION,
637
+ messages: [{ content: { text: `${report}\n\n${STATUS_PROMPT_TAIL}`, type: 'text' }, role: 'user' }],
638
+ };
639
+ });
640
+ server.setRequestHandler(ListResourceTemplatesRequestSchema, () => ({
641
+ // `description: ""` is emitted, not omitted: the reference's pydantic model has a
642
+ // default of `""` for a resource function with no docstring, and a client that compared
643
+ // two advertisements would see a key appear and disappear. `name` is the PYTHON FUNCTION
644
+ // NAME, which is why these read `skill_resource` and `rubric_resource`.
645
+ resourceTemplates: [
646
+ { description: '', mimeType: 'text/plain', name: 'skill_resource', uriTemplate: 'bantamkit://skills/{name}' },
647
+ { description: '', mimeType: 'text/plain', name: 'rubric_resource', uriTemplate: 'bantamkit://rubrics/{name}' },
648
+ ],
649
+ }));
650
+ server.setRequestHandler(ReadResourceRequestSchema, (request) => {
651
+ const uri = request.params.uri;
652
+ const skill = /^bantamkit:\/\/skills\/([^/]*)$/.exec(uri);
653
+ if (skill) {
654
+ let text;
655
+ try {
656
+ text = loadSkill(skill[1]);
657
+ }
658
+ catch (e) {
659
+ if (!(e instanceof AssetNotFound))
660
+ throw e;
661
+ throw rpcError(ErrorCode.InternalError, `unknown skill asset: ${skill[1]}`, { uri });
662
+ }
663
+ return { contents: [{ mimeType: 'text/plain', text, uri }] };
664
+ }
665
+ const rubric = /^bantamkit:\/\/rubrics\/([^/]*)$/.exec(uri);
666
+ if (rubric) {
667
+ const path = join(assetsRoot(), 'rubrics', `${rubric[1]}.yaml`);
668
+ let text;
669
+ try {
670
+ text = pyReadText(path);
671
+ }
672
+ catch {
673
+ throw rpcError(ErrorCode.InternalError, `unknown rubric asset: ${rubric[1]}`, { uri });
674
+ }
675
+ return { contents: [{ mimeType: 'text/plain', text, uri }] };
676
+ }
677
+ // The template variable matches ONE segment and MAY BE EMPTY — measured: the reference
678
+ // answers `bantamkit://skills/` with -32603 `unknown skill asset: `, not with "unknown
679
+ // resource", so `[^/]*` and not `[^/]+`. A traversal is still not a match at all,
680
+ // because `bantamkit://skills/../../etc/passwd` has separators in it, and the reference
681
+ // answers that one -32602.
682
+ throw rpcError(ErrorCode.InvalidParams, `Unknown resource: ${uri}`, { uri });
683
+ });
684
+ server.setRequestHandler(CallToolRequestSchema, (request, extra) => {
685
+ const name = request.params.name;
686
+ const raw = wire.rawLineFor(extra.requestId);
687
+ // The whole reason the transport keeps the line: `JSON.parse` has already destroyed the
688
+ // int/float distinction by the time `request.params` exists, and `5.0` in an accounting
689
+ // record has to reach the `.log.jsonl` as `5.0`.
690
+ const args = raw !== undefined ? argumentsFrom(raw) : fromJs(request.params.arguments ?? {});
691
+ let text;
692
+ let structured;
693
+ let structuredValue = null;
694
+ let isError;
695
+ try {
696
+ const model = ARG_MODELS[name];
697
+ if (model === undefined)
698
+ throw new BantamError(`Unknown tool: ${name}`);
699
+ const bound = validateArguments(model, args);
700
+ const { value, wrapped } = runTool(name, memory, bound, version, log);
701
+ // `_convert_to_content` runs BEFORE the `{"result": ...}` wrap, so a str tool's text
702
+ // block is the RAW STRING and a dict tool's is `to_json(..., indent=2)`.
703
+ text = value.t === 'str' ? value.v : sdkJson(value, 2);
704
+ structuredValue = wrapped ? { t: 'dict', v: new Map([['result', value]]) } : value;
705
+ structured = sdkJson(structuredValue);
706
+ isError = false;
707
+ }
708
+ catch (e) {
709
+ // Every exception out of a tool becomes `ToolError(f"Error executing tool {name}: {e}")`
710
+ // in the reference and reaches the client as an isError RESULT, never a JSON-RPC error.
711
+ // `Unknown tool:` is the one that is already a full sentence and is not prefixed.
712
+ const message = e instanceof PyValidationFailure ? e.detail : e.message;
713
+ text = message.startsWith('Unknown tool: ') ? message : `Error executing tool ${name}: ${message}`;
714
+ structured = null;
715
+ structuredValue = null;
716
+ isError = true;
717
+ }
718
+ wire.setExactResult(extra.requestId, `{"content":[{"text":${JSON.stringify(text)},"type":"text"}],"isError":${isError}` +
719
+ `${structured === null ? '' : `,"structuredContent":${structured}`}}`);
720
+ return {
721
+ content: [{ type: 'text', text }],
722
+ isError,
723
+ ...(structuredValue === null ? {} : { structuredContent: toJs(structuredValue) }),
724
+ };
725
+ });
726
+ return server;
727
+ }
728
+ /** `params.arguments` out of the raw request line, with every number literal intact. */
729
+ function argumentsFrom(line) {
730
+ try {
731
+ const params = asDict(parseJson(line)).get('params');
732
+ const args = params === undefined ? undefined : asDict(params).get('arguments');
733
+ return args ?? { t: 'dict', v: new Map() };
734
+ }
735
+ catch {
736
+ // Unreachable over a line the SDK already parsed; a re-parse failure must not be the
737
+ // thing that decides a tool call, so it falls back to the parsed view.
738
+ return { t: 'dict', v: new Map() };
739
+ }
740
+ }