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.
- package/LICENSE +21 -0
- package/README.md +398 -0
- package/assets/contracts/default.yaml +90 -0
- package/assets/evals/devteam/manifest.yaml +351 -0
- package/assets/evals/devteam/repo/HISTORY.md +18 -0
- package/assets/evals/devteam/repo/README.md +12 -0
- package/assets/evals/devteam/repo/docs/architecture.md +17 -0
- package/assets/evals/devteam/repo/docs/runbook.md +10 -0
- package/assets/evals/devteam/repo/issues/142-settlement-timeout.md +23 -0
- package/assets/evals/devteam/repo/patches/0009-retry-budget.patch +38 -0
- package/assets/evals/devteam/repo/src/ledger/__init__.py +3 -0
- package/assets/evals/devteam/repo/src/ledger/config.py +35 -0
- package/assets/evals/devteam/repo/src/ledger/errors.py +13 -0
- package/assets/evals/devteam/repo/src/ledger/posting.py +12 -0
- package/assets/evals/devteam/repo/src/ledger/registry.py +7 -0
- package/assets/evals/devteam/repo/src/ledger/report.py +9 -0
- package/assets/evals/devteam/repo/src/ledger/retry.py +17 -0
- package/assets/evals/devteam/repo/src/ledger/settle.py +16 -0
- package/assets/evals/devteam/repo/src/ledger/validate.py +14 -0
- package/assets/evals/devteam/repo/tests/test_posting.py +13 -0
- package/assets/evals/devteam/repo/tests/test_settle.py +9 -0
- package/assets/evals/devteam/tasks/dt-error-contract.yaml +186 -0
- package/assets/evals/devteam/tasks/dt-handler-map.yaml +183 -0
- package/assets/evals/devteam/tasks/dt-patch-before-after.yaml +182 -0
- package/assets/evals/devteam/tasks/dt-retry-attempts.yaml +181 -0
- package/assets/evals/devteam/tasks/dt-settlement-config.yaml +185 -0
- package/assets/evals/devteam/tasks/dt-symbol-home.yaml +181 -0
- package/assets/evals/devteam/tasks/dt-trace-blame.yaml +182 -0
- package/assets/evals/devteam/tasks/dt-unread-key.yaml +180 -0
- package/assets/evals/document/tasks/doc-large-in-137.yaml +38 -0
- package/assets/evals/document/tasks/doc-large-in-359.yaml +44 -0
- package/assets/evals/document/tasks/doc-large-in-372.yaml +38 -0
- package/assets/evals/document/tasks/doc-large-out-11764.yaml +37 -0
- package/assets/evals/document/tasks/doc-large-out-4137.yaml +37 -0
- package/assets/evals/document/tasks/doc-large-out-8022.yaml +37 -0
- package/assets/evals/document/tasks/doc-small-137.yaml +37 -0
- package/assets/evals/document/tasks/doc-small-261.yaml +37 -0
- package/assets/evals/document/tasks/doc-small-388.yaml +37 -0
- package/assets/evals/fixtures/.gitkeep +0 -0
- package/assets/evals/fixtures/catalog.json +6 -0
- package/assets/evals/perturbations/task-completion.yaml +576 -0
- package/assets/evals/tasks/.gitkeep +0 -0
- package/assets/evals/tasks/extract-contact.yaml +14 -0
- package/assets/evals/tasks/extract-invoice.yaml +14 -0
- package/assets/evals/tasks/extract-order.yaml +15 -0
- package/assets/evals/tasks/extract-schedule.yaml +14 -0
- package/assets/evals/tasks/extract-versions.yaml +17 -0
- package/assets/evals/tasks/nav-prod-port.yaml +84 -0
- package/assets/evals/tasks/nav-release-bundle.yaml +87 -0
- package/assets/evals/tasks/recall-audit-retention.yaml +17 -0
- package/assets/evals/tasks/recall-cache-ttl.yaml +13 -0
- package/assets/evals/tasks/recall-db-port.yaml +17 -0
- package/assets/evals/tasks/recall-deploy.yaml +13 -0
- package/assets/evals/tasks/recall-env-endpoint.yaml +18 -0
- package/assets/evals/tasks/recall-oncall-rotation.yaml +21 -0
- package/assets/evals/tasks/recall-oncall.yaml +13 -0
- package/assets/evals/tasks/recall-org-quota.yaml +18 -0
- package/assets/evals/tasks/recall-owner.yaml +13 -0
- package/assets/evals/tasks/shop-basket-total.yaml +10 -0
- package/assets/evals/tasks/shop-cheapest.yaml +9 -0
- package/assets/evals/tasks/shop-compare.yaml +9 -0
- package/assets/evals/tasks/shop-gadget-value.yaml +9 -0
- package/assets/evals/tasks/shop-stock-total.yaml +9 -0
- package/assets/evals/tasks/shop-total.yaml +9 -0
- package/assets/profiles/default.yaml +31 -0
- package/assets/profiles/patient.yaml +31 -0
- package/assets/rubrics/.gitkeep +0 -0
- package/assets/rubrics/code-quality.yaml +20 -0
- package/assets/rubrics/grounded-completion.yaml +37 -0
- package/assets/rubrics/task-completion.yaml +28 -0
- package/assets/schemas/shiftwork-checkpoint.json +188 -0
- package/assets/skills/.gitkeep +0 -0
- package/assets/skills/file-graph.md +7 -0
- package/assets/skills/memory.md +35 -0
- package/assets/tools/.gitkeep +0 -0
- package/assets/tools/bantamkit_read.json +48 -0
- package/assets/tools/bantamkit_status.json +25 -0
- package/assets/tools/build_identity.json +17 -0
- package/assets/tools/document_list.json +12 -0
- package/assets/tools/document_read.json +31 -0
- package/assets/tools/file_graph.json +12 -0
- package/assets/tools/memory_compact.json +31 -0
- package/assets/tools/memory_recall.json +38 -0
- package/assets/tools/memory_save.json +61 -0
- package/assets/tools/shiftwork_clock_in.json +25 -0
- package/assets/tools/shiftwork_clock_out.json +60 -0
- package/assets/tools/shiftwork_status.json +25 -0
- package/assets/tools/skill_audit.json +70 -0
- package/assets/tools/validate_json.json +31 -0
- package/dist/assets.d.ts +48 -0
- package/dist/assets.js +109 -0
- package/dist/charsets.d.ts +17 -0
- package/dist/charsets.js +580 -0
- package/dist/cli.d.ts +12 -0
- package/dist/cli.js +280 -0
- package/dist/contract.d.ts +145 -0
- package/dist/contract.js +608 -0
- package/dist/docread.d.ts +357 -0
- package/dist/docread.js +3315 -0
- package/dist/errors.d.ts +11 -0
- package/dist/errors.js +14 -0
- package/dist/eventlog.d.ts +137 -0
- package/dist/eventlog.js +294 -0
- package/dist/htmlentities.d.ts +2 -0
- package/dist/htmlentities.js +16 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.js +13 -0
- package/dist/mcp/identity.d.ts +25 -0
- package/dist/mcp/identity.js +275 -0
- package/dist/mcp/pyargs.d.ts +80 -0
- package/dist/mcp/pyargs.js +362 -0
- package/dist/mcp/sdkjson.d.ts +56 -0
- package/dist/mcp/sdkjson.js +91 -0
- package/dist/mcp/server.d.ts +45 -0
- package/dist/mcp/server.js +740 -0
- package/dist/mcp/status.d.ts +210 -0
- package/dist/mcp/status.js +357 -0
- package/dist/mcp/transport.d.ts +68 -0
- package/dist/mcp/transport.js +111 -0
- package/dist/mcpreport.d.ts +214 -0
- package/dist/mcpreport.js +758 -0
- package/dist/memory/cli.d.ts +2 -0
- package/dist/memory/cli.js +396 -0
- package/dist/memory/component.d.ts +256 -0
- package/dist/memory/component.js +497 -0
- package/dist/memory/factfile.d.ts +120 -0
- package/dist/memory/factfile.js +406 -0
- package/dist/memory/layers.d.ts +92 -0
- package/dist/memory/layers.js +410 -0
- package/dist/memory/pyfs.d.ts +521 -0
- package/dist/memory/pyfs.js +1608 -0
- package/dist/memory/pyyaml.d.ts +155 -0
- package/dist/memory/pyyaml.js +868 -0
- package/dist/memory/store.d.ts +478 -0
- package/dist/memory/store.js +1034 -0
- package/dist/pyargparse.d.ts +141 -0
- package/dist/pyargparse.js +813 -0
- package/dist/pyjson.d.ts +110 -0
- package/dist/pyjson.js +524 -0
- package/dist/pyjsonschema.d.ts +50 -0
- package/dist/pyjsonschema.js +951 -0
- package/dist/shiftwork.d.ts +60 -0
- package/dist/shiftwork.js +324 -0
- package/dist/skillaudit.d.ts +172 -0
- package/dist/skillaudit.js +914 -0
- package/dist/statusline.d.ts +106 -0
- package/dist/statusline.js +281 -0
- 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
|
+
}
|