bantamkit-mcp 0.29.2 → 0.31.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/README.md +83 -2
- package/assets/pricing/default.json +7 -0
- package/assets/schemas/shiftwork-checkpoint.json +12 -1
- package/assets/tools/memory_dream.json +20 -0
- package/assets/tools/repo_map.json +44 -0
- package/assets/tools/shiftwork_clock_out.json +1 -1
- package/assets/tools/token_ledger.json +40 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +135 -4
- package/dist/eventlog.js +6 -4
- package/dist/index.d.ts +5 -3
- package/dist/index.js +2 -1
- package/dist/mcp/identity.d.ts +80 -0
- package/dist/mcp/identity.js +466 -4
- package/dist/mcp/pyargs.d.ts +1 -1
- package/dist/mcp/pyargs.js +78 -0
- package/dist/mcp/server.d.ts +37 -6
- package/dist/mcp/server.js +209 -5
- package/dist/mcp/status.d.ts +54 -10
- package/dist/mcp/status.js +86 -10
- package/dist/memory/cli.js +1 -1
- package/dist/memory/component.d.ts +73 -2
- package/dist/memory/component.js +157 -5
- package/dist/memory/dream.d.ts +247 -0
- package/dist/memory/dream.js +903 -0
- package/dist/memory/store.d.ts +101 -1
- package/dist/memory/store.js +178 -4
- package/dist/pricing.d.ts +100 -0
- package/dist/pricing.js +357 -0
- package/dist/repomap.d.ts +247 -0
- package/dist/repomap.js +1040 -0
- package/dist/selfupdate.d.ts +320 -0
- package/dist/selfupdate.js +594 -0
- package/dist/shiftwork.d.ts +5 -3
- package/dist/shiftwork.js +82 -4
- package/dist/tokenledger.d.ts +91 -0
- package/dist/tokenledger.js +372 -0
- package/package.json +1 -1
package/dist/mcp/identity.d.ts
CHANGED
|
@@ -1,4 +1,13 @@
|
|
|
1
1
|
export declare const SERVER_NAME = "bantamkit";
|
|
2
|
+
/**
|
|
3
|
+
* A build fact that could not be derived, carrying WHY. Never becomes a value.
|
|
4
|
+
*
|
|
5
|
+
* Exported since job46: `bantamkit_status` has to distinguish "this install could not be
|
|
6
|
+
* classified" — which is a REPORTED gap and not a degraded server — from a real fault, and
|
|
7
|
+
* `instanceof` is the only way to do that without matching on a message.
|
|
8
|
+
*/
|
|
9
|
+
export declare class Undetermined extends Error {
|
|
10
|
+
}
|
|
2
11
|
/**
|
|
3
12
|
* sha256 over (relative path, size, bytes) of every file, in sorted path order.
|
|
4
13
|
*
|
|
@@ -47,6 +56,77 @@ export declare const notBytecodeCache: (parts: string[]) => boolean;
|
|
|
47
56
|
* reference's printer uses and what the conformance case compares the count against.
|
|
48
57
|
*/
|
|
49
58
|
export declare function packFileCount(root: string): number;
|
|
59
|
+
/**
|
|
60
|
+
* The closed vocabulary, spelled the same way in both runtimes, and it answers ONE question:
|
|
61
|
+
* what would updating this install even mean?
|
|
62
|
+
*
|
|
63
|
+
* registry came from a package index (npm here, PyPI there). Reinstall by name.
|
|
64
|
+
* local-file came from a path on THIS machine — an archive or a directory whose contents
|
|
65
|
+
* were copied in. The path is recorded and may be gone.
|
|
66
|
+
* linked a directory on this machine is still the source being read: a `file:` dir or
|
|
67
|
+
* an `npm link` symlink here, an editable install there. Update that tree.
|
|
68
|
+
* checkout no installer recorded this tree at all. Git.
|
|
69
|
+
* ephemeral a temporary environment discarded after the run. Nothing to update.
|
|
70
|
+
*
|
|
71
|
+
* `ephemeral` IS ANSWERED HERE AND NEVER BY `runtime-py`, which is the one divergence in this
|
|
72
|
+
* surface: npm writes an `_npx` marker into the cache project's own `package.json`, so this is
|
|
73
|
+
* a RECORD, while a `pipx run` or `uvx` environment is not distinguishable from an ordinary
|
|
74
|
+
* venv without pattern-matching cache directory names — a guess, and that surface does not
|
|
75
|
+
* guess. The word is declared on BOTH sides anyway so a consumer of either runtime handles ONE
|
|
76
|
+
* set of five rather than two sets it has to reconcile.
|
|
77
|
+
*/
|
|
78
|
+
export declare const INSTALL_SHAPES: readonly ["registry", "local-file", "linked", "checkout", "ephemeral"];
|
|
79
|
+
/**
|
|
80
|
+
* Where the running code came from, and the origin path it can still be checked against.
|
|
81
|
+
*
|
|
82
|
+
* `source` is `null` for the shapes that HAVE no origin path rather than for the ones whose
|
|
83
|
+
* path could not be read — `sourceReason` carries which, in the operator's words, and
|
|
84
|
+
* `buildIdentity` turns it into the `{"unavailable": ...}` shape RB-P51 requires. A shape that
|
|
85
|
+
* could not be derived at all is an `Undetermined`, never a sixth word.
|
|
86
|
+
*/
|
|
87
|
+
export interface Install {
|
|
88
|
+
readonly shape: string;
|
|
89
|
+
readonly source: string | null;
|
|
90
|
+
readonly sourceReason: string;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* The whole diagnosis, over a running file and the path the process was launched through.
|
|
94
|
+
*
|
|
95
|
+
* Taken as ARGUMENTS rather than read from the process so that every shape can be built as a
|
|
96
|
+
* real `node_modules` tree with a real hidden lockfile, a real symlink and a really-absent
|
|
97
|
+
* tarball, and read back through the same code the server runs. A stubbed filesystem here
|
|
98
|
+
* would assert this function's own reasoning back at it, and the entire question is what npm
|
|
99
|
+
* actually writes down.
|
|
100
|
+
*/
|
|
101
|
+
export declare function deriveInstall(runningFile: string, entryPath: string | null): Install;
|
|
102
|
+
/**
|
|
103
|
+
* Is the recorded origin still on disk? `present: null` when the check itself could not be made.
|
|
104
|
+
*
|
|
105
|
+
* `existsSync` is deliberately not used: it swallows EVERY error and answers `false`, so an
|
|
106
|
+
* origin under a directory the server may not read would be reported as DELETED — a wrong
|
|
107
|
+
* answer where the reference gives none. This is the half that is re-read on every tool call,
|
|
108
|
+
* so it is one `stat` and nothing else.
|
|
109
|
+
*/
|
|
110
|
+
export declare function originStat(path: string): {
|
|
111
|
+
present: boolean | null;
|
|
112
|
+
reason: string;
|
|
113
|
+
};
|
|
114
|
+
/**
|
|
115
|
+
* Which install shape is running. Importable, and NOT owned by the status path.
|
|
116
|
+
*
|
|
117
|
+
* DERIVED ONCE, because the bytes that were imported cannot change under a process. The footer
|
|
118
|
+
* runs `degradedConditions` on every tool call and `docs/status.md` promises what that costs;
|
|
119
|
+
* walking `node_modules` for a hidden lockfile is not on that list. So the SHAPE is memoised
|
|
120
|
+
* and only the EXISTENCE of the recorded path is re-read — which is the half that can actually
|
|
121
|
+
* change while a server is running, and the half the measured defect is about. The failure is
|
|
122
|
+
* memoised too: a derivation that already failed would fail identically a second time.
|
|
123
|
+
*
|
|
124
|
+
* Deliberately a module-level function taking no arguments and touching no server state: the
|
|
125
|
+
* `--update` flag planned as J46-29 has to be shape-aware — two of the five shapes have no
|
|
126
|
+
* registry route at all — and it must be able to ask this question from the CLI, before a
|
|
127
|
+
* memory store or a transport exists, without building an MCP server to do it.
|
|
128
|
+
*/
|
|
129
|
+
export declare function currentInstall(): Install;
|
|
50
130
|
/** `sha256(json.dumps(inputs, sort_keys=True, separators=(",", ":")))` — the identity. */
|
|
51
131
|
export declare function buildIdFor(inputs: Record<string, string>): string;
|
|
52
132
|
/**
|
package/dist/mcp/identity.js
CHANGED
|
@@ -40,15 +40,21 @@
|
|
|
40
40
|
* partial identity would agree with every other build that lost the same input.
|
|
41
41
|
*/
|
|
42
42
|
import { createHash } from 'node:crypto';
|
|
43
|
-
import { lstatSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
44
|
-
import { dirname, join } from 'node:path';
|
|
43
|
+
import { lstatSync, readFileSync, readdirSync, readlinkSync, realpathSync, statSync } from 'node:fs';
|
|
44
|
+
import { basename, dirname, join, relative, resolve, sep } from 'node:path';
|
|
45
45
|
import { fileURLToPath } from 'node:url';
|
|
46
46
|
import { AssetNotFound, assetsRoot } from '../assets.js';
|
|
47
47
|
import { sortedPathParts } from '../memory/pyfs.js';
|
|
48
48
|
import { PyOSError, asPyOSError } from '../memory/pyfs.js';
|
|
49
49
|
export const SERVER_NAME = 'bantamkit';
|
|
50
|
-
/**
|
|
51
|
-
|
|
50
|
+
/**
|
|
51
|
+
* A build fact that could not be derived, carrying WHY. Never becomes a value.
|
|
52
|
+
*
|
|
53
|
+
* Exported since job46: `bantamkit_status` has to distinguish "this install could not be
|
|
54
|
+
* classified" — which is a REPORTED gap and not a degraded server — from a real fault, and
|
|
55
|
+
* `instanceof` is the only way to do that without matching on a message.
|
|
56
|
+
*/
|
|
57
|
+
export class Undetermined extends Error {
|
|
52
58
|
}
|
|
53
59
|
/**
|
|
54
60
|
* The shape an underivable fact takes on the wire — `RB-P51`, unchanged from Python.
|
|
@@ -221,6 +227,437 @@ function assetsFingerprint() {
|
|
|
221
227
|
throw new Undetermined(`asset pack at ${root} contains no files`);
|
|
222
228
|
return { digest: walked.digest, files: walked.files, root };
|
|
223
229
|
}
|
|
230
|
+
// ---------------------------------------------------------------------------------------
|
|
231
|
+
// WHICH INSTALL SHAPE IS RUNNING — offline, from this server's own location.
|
|
232
|
+
//
|
|
233
|
+
// THE DEFECT IS A NODE DEFECT AND IT IS MEASURED (`docs/roadmap-agent-stack.md` AS-7). A
|
|
234
|
+
// Claude Desktop entry sat on 0.25.0 from 2026-08-24 through five releases with nothing in
|
|
235
|
+
// the config, the logs or any tool reply saying so — and its `package.json` declared the
|
|
236
|
+
// dependency as `"bantamkit-mcp": "file:/private/tmp/.../scratchpad/bantamkit-mcp-0.25.0.tgz"`,
|
|
237
|
+
// a local tarball in a temp directory that no longer existed. An update run where that install
|
|
238
|
+
// lives is a no-op BY CONSTRUCTION, and nothing anywhere said which of those two things was
|
|
239
|
+
// wrong. Four of those caches are still on this machine under `~/.npm/_npx/*/`.
|
|
240
|
+
//
|
|
241
|
+
// WHY THIS IS A REFUSAL AND NOT A LOOKUP. The origin is written down by the installer, on this
|
|
242
|
+
// disk, in `node_modules/.package-lock.json` — npm's hidden lockfile, the Node counterpart of
|
|
243
|
+
// PEP 610's `direct_url.json`. Whether that path still exists is a `stat`. NO NETWORK IS
|
|
244
|
+
// TOUCHED ON ANY PATH BELOW: comparing the running version against a registry is AS-7(b), a
|
|
245
|
+
// separate unit, gated behind this one, and deliberately opt-in because an offline toolbox
|
|
246
|
+
// must not grow a network call in its health check.
|
|
247
|
+
//
|
|
248
|
+
// AMENDED 2026-09-11, J46-31: AS-7(b) HAS SHIPPED, as `--update`, and the first sentence
|
|
249
|
+
// above is still exactly true — that is the point of recording it here. The registry
|
|
250
|
+
// comparison lives in `selfupdate.ts`, reached only from the flag in `cli.ts`, never from any
|
|
251
|
+
// path below and never from `bantamkit_status`. The clause that is now dated is "gated behind
|
|
252
|
+
// this one": the user reversed AS-7 on 2026-09-11 and the gate was overridden rather than met.
|
|
253
|
+
// "Deliberately opt-in" survived the reversal intact and is the reason `--update` is a CLI
|
|
254
|
+
// flag and not an MCP tool — see the ruling appended to AS-7.
|
|
255
|
+
//
|
|
256
|
+
// SHAPE IS LOCATION, NEVER IDENTITY. It is reported for the same reason `package_path` and
|
|
257
|
+
// `interpreter` are — it is what a person acts on — and it is kept OUT of `build_id` for the
|
|
258
|
+
// same reason they are: one build installed two ways is ONE build, and
|
|
259
|
+
// `test/install-shape.test.mjs` recomputes the hash from its five named inputs to prove it.
|
|
260
|
+
//
|
|
261
|
+
// WHAT NPM ACTUALLY WRITES, MEASURED AGAINST npm 11.6.2 BEFORE ANY OF THIS WAS WRITTEN — every
|
|
262
|
+
// branch below is a record that was read off this machine, not a shape recalled from docs:
|
|
263
|
+
//
|
|
264
|
+
// tarball `{"resolved": "file:../x.tgz"}` — `file:` then a RAW path, relative to the
|
|
265
|
+
// directory that owns `node_modules`, and NOT percent-encoded (measured with a
|
|
266
|
+
// space in the directory name, which is the case that actually turns up).
|
|
267
|
+
// `file:` dir `{"resolved": "../src", "link": true}` — no `file:` prefix at all, and the
|
|
268
|
+
// package directory under `node_modules` is a real symlink.
|
|
269
|
+
// registry `{"resolved": "https://registry.npmjs.org/…"}`.
|
|
270
|
+
// global `<npm prefix -g>/lib/node_modules` carries NO `.package-lock.json` at all,
|
|
271
|
+
// and npm 7+ writes no `_resolved` into the installed `package.json` either.
|
|
272
|
+
// npx the cache project's own `package.json` carries `"_npx": {"packages": [...]}`.
|
|
273
|
+
// ---------------------------------------------------------------------------------------
|
|
274
|
+
/**
|
|
275
|
+
* The closed vocabulary, spelled the same way in both runtimes, and it answers ONE question:
|
|
276
|
+
* what would updating this install even mean?
|
|
277
|
+
*
|
|
278
|
+
* registry came from a package index (npm here, PyPI there). Reinstall by name.
|
|
279
|
+
* local-file came from a path on THIS machine — an archive or a directory whose contents
|
|
280
|
+
* were copied in. The path is recorded and may be gone.
|
|
281
|
+
* linked a directory on this machine is still the source being read: a `file:` dir or
|
|
282
|
+
* an `npm link` symlink here, an editable install there. Update that tree.
|
|
283
|
+
* checkout no installer recorded this tree at all. Git.
|
|
284
|
+
* ephemeral a temporary environment discarded after the run. Nothing to update.
|
|
285
|
+
*
|
|
286
|
+
* `ephemeral` IS ANSWERED HERE AND NEVER BY `runtime-py`, which is the one divergence in this
|
|
287
|
+
* surface: npm writes an `_npx` marker into the cache project's own `package.json`, so this is
|
|
288
|
+
* a RECORD, while a `pipx run` or `uvx` environment is not distinguishable from an ordinary
|
|
289
|
+
* venv without pattern-matching cache directory names — a guess, and that surface does not
|
|
290
|
+
* guess. The word is declared on BOTH sides anyway so a consumer of either runtime handles ONE
|
|
291
|
+
* set of five rather than two sets it has to reconcile.
|
|
292
|
+
*/
|
|
293
|
+
export const INSTALL_SHAPES = ['registry', 'local-file', 'linked', 'checkout', 'ephemeral'];
|
|
294
|
+
const NODE_MODULES = 'node_modules';
|
|
295
|
+
const REGISTRY = () => ({
|
|
296
|
+
shape: 'registry',
|
|
297
|
+
source: null,
|
|
298
|
+
sourceReason: 'a registry install records no origin path on this machine: npm writes a local origin ' +
|
|
299
|
+
'into `node_modules/.package-lock.json` only for a `file:` path or a link, and this ' +
|
|
300
|
+
'install has none. Reinstall by name to move it.',
|
|
301
|
+
});
|
|
302
|
+
const CHECKOUT_REASON = 'no installer recorded this tree, so there is no origin path to check — the source IS ' +
|
|
303
|
+
'`package_path`, and it is updated where it was cloned.';
|
|
304
|
+
const EPHEMERAL_REASON = 'an ephemeral install records no origin path on this machine: npm resolves the `npx` cache ' +
|
|
305
|
+
'again on the next run rather than updating it in place, so there is nothing here to ' +
|
|
306
|
+
'refresh. Change the spec the host command line names to move it.';
|
|
307
|
+
/** Parsed JSON from a file, or `null` when the file is missing, unreadable or not an object. */
|
|
308
|
+
function readJsonObject(path) {
|
|
309
|
+
let raw;
|
|
310
|
+
try {
|
|
311
|
+
raw = readFileSync(path, 'utf8');
|
|
312
|
+
}
|
|
313
|
+
catch {
|
|
314
|
+
return null;
|
|
315
|
+
}
|
|
316
|
+
let parsed;
|
|
317
|
+
try {
|
|
318
|
+
parsed = JSON.parse(raw);
|
|
319
|
+
}
|
|
320
|
+
catch {
|
|
321
|
+
return null;
|
|
322
|
+
}
|
|
323
|
+
return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)
|
|
324
|
+
? parsed
|
|
325
|
+
: null;
|
|
326
|
+
}
|
|
327
|
+
/**
|
|
328
|
+
* A recorded origin as a path on this machine, or `null` when it names neither.
|
|
329
|
+
*
|
|
330
|
+
* TWO FORMS, AND THE DIFFERENCE IS MEASURED. npm writes `file:` followed by a RAW path — a
|
|
331
|
+
* space in a person's own directory arrives as a space, not as `%20`, so decoding that form
|
|
332
|
+
* would corrupt any real filename containing a literal `%`. The `file://` URL form IS
|
|
333
|
+
* percent-encoded, carries a leading slash before a Windows drive letter that is not part of
|
|
334
|
+
* the path, and may name `localhost`; `url.fileURLToPath` handles it, and is not used here
|
|
335
|
+
* because it throws on the bare form npm actually writes and would have to be guarded either
|
|
336
|
+
* way. A `file://` URL with a real authority (`file://otherhost/share`) names a path on a
|
|
337
|
+
* machine that is not this one, so it is not a local origin and the caller is told so.
|
|
338
|
+
*/
|
|
339
|
+
function localOriginPath(resolved, base) {
|
|
340
|
+
if (!resolved.startsWith('file:'))
|
|
341
|
+
return null;
|
|
342
|
+
const rest = resolved.slice('file:'.length);
|
|
343
|
+
if (!rest.startsWith('//'))
|
|
344
|
+
return resolve(base, rest);
|
|
345
|
+
let path = rest.slice(2);
|
|
346
|
+
if (path.startsWith('localhost/'))
|
|
347
|
+
path = path.slice('localhost'.length);
|
|
348
|
+
if (!path.startsWith('/'))
|
|
349
|
+
return null;
|
|
350
|
+
path = decodeURIComponent(path);
|
|
351
|
+
// `/C:/x` is `C:/x`; a POSIX path never matches this shape.
|
|
352
|
+
if (path.length > 2 && /[A-Za-z]/.test(path[1]) && path[2] === ':')
|
|
353
|
+
path = path.slice(1);
|
|
354
|
+
return resolve(base, path);
|
|
355
|
+
}
|
|
356
|
+
/** `Path.is_relative_to`, tolerating a `parent` that is a symlink or does not exist. */
|
|
357
|
+
function isWithin(child, parent) {
|
|
358
|
+
for (const candidate of [parent, safeRealpath(parent)]) {
|
|
359
|
+
if (candidate === null)
|
|
360
|
+
continue;
|
|
361
|
+
const step = relative(candidate, child);
|
|
362
|
+
if (step === '' || (!step.startsWith('..') && !step.startsWith(`${sep}`)))
|
|
363
|
+
return true;
|
|
364
|
+
}
|
|
365
|
+
return false;
|
|
366
|
+
}
|
|
367
|
+
/** `Path.is_symlink()`: false for a missing path and for anything that is not a link. */
|
|
368
|
+
function isSymlink(path) {
|
|
369
|
+
try {
|
|
370
|
+
return lstatSync(path).isSymbolicLink();
|
|
371
|
+
}
|
|
372
|
+
catch (e) {
|
|
373
|
+
if (e.code === undefined)
|
|
374
|
+
throw e;
|
|
375
|
+
return false;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
function safeRealpath(path) {
|
|
379
|
+
try {
|
|
380
|
+
return realpathSync.native(path);
|
|
381
|
+
}
|
|
382
|
+
catch {
|
|
383
|
+
return null;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
/**
|
|
387
|
+
* The package that OWNS the running file: the nearest `package.json` above it.
|
|
388
|
+
*
|
|
389
|
+
* The nearest one and not a named one, so a rename of the published package does not silently
|
|
390
|
+
* turn every install into a `checkout`. It is the same question `_dist_owns` asks on the
|
|
391
|
+
* Python side — which distribution put the file that is running on disk — answered the way
|
|
392
|
+
* Node's own resolver answers it.
|
|
393
|
+
*/
|
|
394
|
+
function packageRootFor(startDir) {
|
|
395
|
+
let here = resolve(startDir);
|
|
396
|
+
for (;;) {
|
|
397
|
+
const manifest = readJsonObject(join(here, 'package.json'));
|
|
398
|
+
if (manifest !== null) {
|
|
399
|
+
const name = manifest['name'];
|
|
400
|
+
return { root: here, name: typeof name === 'string' ? name : '' };
|
|
401
|
+
}
|
|
402
|
+
const parent = dirname(here);
|
|
403
|
+
if (parent === here)
|
|
404
|
+
return null;
|
|
405
|
+
here = parent;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
/**
|
|
409
|
+
* Every `node_modules` this package sits under, nearest first, with the lockfile key each one
|
|
410
|
+
* would use.
|
|
411
|
+
*
|
|
412
|
+
* There can be more than one: a nested `a/node_modules/b/node_modules/c` is keyed from the
|
|
413
|
+
* OUTERMOST project, which is the only place npm writes the hidden lockfile, while a plain
|
|
414
|
+
* install is keyed from the nearest. Both are tried rather than guessed between.
|
|
415
|
+
*/
|
|
416
|
+
function nodeModulesAncestors(packageRoot) {
|
|
417
|
+
const found = [];
|
|
418
|
+
let here = dirname(packageRoot);
|
|
419
|
+
for (;;) {
|
|
420
|
+
if (basename(here) === NODE_MODULES) {
|
|
421
|
+
const projectRoot = dirname(here);
|
|
422
|
+
found.push({
|
|
423
|
+
projectRoot,
|
|
424
|
+
nodeModules: here,
|
|
425
|
+
key: relative(projectRoot, packageRoot).split(sep).join('/'),
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
const parent = dirname(here);
|
|
429
|
+
if (parent === here)
|
|
430
|
+
return found;
|
|
431
|
+
here = parent;
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
/** npm's own record for this package, from the hidden lockfile, or `null` when there is none. */
|
|
435
|
+
function lockfileEntry(place) {
|
|
436
|
+
const lock = readJsonObject(join(place.nodeModules, '.package-lock.json'));
|
|
437
|
+
const packages = lock?.['packages'];
|
|
438
|
+
if (typeof packages !== 'object' || packages === null)
|
|
439
|
+
return null;
|
|
440
|
+
const entry = packages[place.key];
|
|
441
|
+
return typeof entry === 'object' && entry !== null && !Array.isArray(entry)
|
|
442
|
+
? entry
|
|
443
|
+
: null;
|
|
444
|
+
}
|
|
445
|
+
/** npm's own marker for an `npx` cache project — a record it writes, not a directory name. */
|
|
446
|
+
function isNpxProject(projectRoot) {
|
|
447
|
+
const manifest = readJsonObject(join(projectRoot, 'package.json'));
|
|
448
|
+
return manifest !== null && typeof manifest['_npx'] === 'object' && manifest['_npx'] !== null;
|
|
449
|
+
}
|
|
450
|
+
/** The origin npm wrote down, as one of the five words. */
|
|
451
|
+
function originFromEntry(entry, projectRoot) {
|
|
452
|
+
const raw = entry['resolved'];
|
|
453
|
+
const recorded = typeof raw === 'string' ? raw : '';
|
|
454
|
+
if (entry['link'] === true && recorded !== '') {
|
|
455
|
+
// A link entry carries a bare relative path with no `file:` prefix at all (measured).
|
|
456
|
+
return { shape: 'linked', source: resolve(projectRoot, recorded), sourceReason: '' };
|
|
457
|
+
}
|
|
458
|
+
// AN http(s) ORIGIN IS `registry` AND NOT A REFUSAL, which is where this parts company with
|
|
459
|
+
// the reference. There, an http `direct_url.json` is underivable; here the registry IS an
|
|
460
|
+
// https URL, and telling a published tarball URL from the configured index would mean
|
|
461
|
+
// knowing which index was configured — a guess. Either way the origin is REMOTE, so it can
|
|
462
|
+
// never be the dangling-local-path defect this surface exists for, and the route out is a
|
|
463
|
+
// reinstall by name.
|
|
464
|
+
if (/^https?:\/\//i.test(recorded))
|
|
465
|
+
return REGISTRY();
|
|
466
|
+
const path = localOriginPath(recorded, projectRoot);
|
|
467
|
+
if (path !== null)
|
|
468
|
+
return { shape: 'local-file', source: path, sourceReason: '' };
|
|
469
|
+
if (recorded === '')
|
|
470
|
+
return REGISTRY();
|
|
471
|
+
throw new Undetermined(`this install records its origin as ${recorded}, which is neither a package index nor a ` +
|
|
472
|
+
`path on this machine, so its shape is not one of ${INSTALL_SHAPES.join(', ')}. A git ` +
|
|
473
|
+
'or http origin is updated by reinstalling from that same URL.');
|
|
474
|
+
}
|
|
475
|
+
/** Every path a symlink chain passes through, starting with the one it was handed. */
|
|
476
|
+
function symlinkChain(start) {
|
|
477
|
+
const chain = [];
|
|
478
|
+
let here = resolve(start);
|
|
479
|
+
for (let step = 0; step < 40; step += 1) {
|
|
480
|
+
chain.push(here);
|
|
481
|
+
let target;
|
|
482
|
+
try {
|
|
483
|
+
target = readlinkSync(here);
|
|
484
|
+
}
|
|
485
|
+
catch {
|
|
486
|
+
break;
|
|
487
|
+
}
|
|
488
|
+
here = resolve(dirname(here), target);
|
|
489
|
+
if (chain.includes(here))
|
|
490
|
+
break;
|
|
491
|
+
}
|
|
492
|
+
return chain;
|
|
493
|
+
}
|
|
494
|
+
/** Does this path pass through `node_modules/<name>` — the entry npm's link would occupy? */
|
|
495
|
+
function passesThroughEntry(path, name) {
|
|
496
|
+
const parts = path.split(/[\\/]/);
|
|
497
|
+
const wanted = name.split('/');
|
|
498
|
+
for (let i = 0; i < parts.length; i += 1) {
|
|
499
|
+
if (parts[i] !== NODE_MODULES)
|
|
500
|
+
continue;
|
|
501
|
+
if (wanted.every((segment, j) => parts[i + 1 + j] === segment))
|
|
502
|
+
return true;
|
|
503
|
+
}
|
|
504
|
+
return false;
|
|
505
|
+
}
|
|
506
|
+
/**
|
|
507
|
+
* Is this tree being SERVED through an `npm link`, even though it does not sit in node_modules?
|
|
508
|
+
*
|
|
509
|
+
* MEASURED, AND IT IS THE REASON THIS FUNCTION EXISTS AT ALL. Node realpaths ESM specifiers,
|
|
510
|
+
* so a linked install's `import.meta.url` names the SOURCE tree and the `node_modules` ancestor
|
|
511
|
+
* is gone from it — which would make every `npm link` and every `file:` directory dependency
|
|
512
|
+
* report `checkout`. `process.argv[1]` is the half Node does NOT resolve: driving the same file
|
|
513
|
+
* through a `file:` dir install printed `import.meta.url = …/src/dist/index.js` beside
|
|
514
|
+
* `argv[1] = …/node_modules/bantamkit-mcp/dist/index.js`. So the pair is the evidence, and both
|
|
515
|
+
* halves are location — neither is server state, and both are fixed for the life of a process.
|
|
516
|
+
*
|
|
517
|
+
* The entry has to actually BE this tree (its realpath lands inside the package root) and it
|
|
518
|
+
* has to pass through `node_modules/<this package>` (npm's link, not somebody else's): a
|
|
519
|
+
* checkout keeps its own `node_modules` for devDependencies, so the bare segment proves nothing.
|
|
520
|
+
*/
|
|
521
|
+
function servedThroughNodeModules(entryPath, packageRoot, name) {
|
|
522
|
+
if (name === '')
|
|
523
|
+
return false;
|
|
524
|
+
const chain = symlinkChain(entryPath);
|
|
525
|
+
const last = chain[chain.length - 1];
|
|
526
|
+
if (last === undefined)
|
|
527
|
+
return false;
|
|
528
|
+
const landing = safeRealpath(last);
|
|
529
|
+
if (landing === null || !isWithin(landing, packageRoot))
|
|
530
|
+
return false;
|
|
531
|
+
return chain.some((step) => passesThroughEntry(step, name));
|
|
532
|
+
}
|
|
533
|
+
/**
|
|
534
|
+
* The whole diagnosis, over a running file and the path the process was launched through.
|
|
535
|
+
*
|
|
536
|
+
* Taken as ARGUMENTS rather than read from the process so that every shape can be built as a
|
|
537
|
+
* real `node_modules` tree with a real hidden lockfile, a real symlink and a really-absent
|
|
538
|
+
* tarball, and read back through the same code the server runs. A stubbed filesystem here
|
|
539
|
+
* would assert this function's own reasoning back at it, and the entire question is what npm
|
|
540
|
+
* actually writes down.
|
|
541
|
+
*/
|
|
542
|
+
export function deriveInstall(runningFile, entryPath) {
|
|
543
|
+
const owner = packageRootFor(dirname(resolve(runningFile)));
|
|
544
|
+
if (owner === null) {
|
|
545
|
+
throw new Undetermined(`no package.json owns ${runningFile}, so there is no installer record to read and this ` +
|
|
546
|
+
'build cannot say where it came from.');
|
|
547
|
+
}
|
|
548
|
+
const places = nodeModulesAncestors(owner.root);
|
|
549
|
+
for (const place of places) {
|
|
550
|
+
const entry = lockfileEntry(place);
|
|
551
|
+
if (entry === null)
|
|
552
|
+
continue;
|
|
553
|
+
return withEnvironment(originFromEntry(entry, place.projectRoot), places);
|
|
554
|
+
}
|
|
555
|
+
// Under `node_modules` with no record anywhere: the global-install shape measured above. The
|
|
556
|
+
// ABSENCE is positive evidence, because the two alternatives both leave a trace — a `file:`
|
|
557
|
+
// origin in the lockfile, or a symlink where the package directory should be — and an
|
|
558
|
+
// installed copy that left neither came from an index.
|
|
559
|
+
if (places.length > 0) {
|
|
560
|
+
// A package directory that IS a symlink is npm's link, whatever the lockfile says or fails
|
|
561
|
+
// to say. Reached only where the symlink survived to here — Node realpaths ESM specifiers,
|
|
562
|
+
// so in practice that is `--preserve-symlinks` — and it is checked before falling back to
|
|
563
|
+
// the absence-is-evidence branch, because an absence proves nothing next to a symlink.
|
|
564
|
+
const linkedTarget = safeRealpath(owner.root);
|
|
565
|
+
if (linkedTarget !== null && isSymlink(owner.root)) {
|
|
566
|
+
return { shape: 'linked', source: linkedTarget, sourceReason: '' };
|
|
567
|
+
}
|
|
568
|
+
return withEnvironment(REGISTRY(), places);
|
|
569
|
+
}
|
|
570
|
+
if (entryPath !== null && servedThroughNodeModules(entryPath, owner.root, owner.name)) {
|
|
571
|
+
return { shape: 'linked', source: owner.root, sourceReason: '' };
|
|
572
|
+
}
|
|
573
|
+
return { shape: 'checkout', source: null, sourceReason: CHECKOUT_REASON };
|
|
574
|
+
}
|
|
575
|
+
/**
|
|
576
|
+
* `ephemeral` is the ENVIRONMENT and the origin is the ORIGIN — two axes, and only one word.
|
|
577
|
+
*
|
|
578
|
+
* The environment wins the word, because it is the half that is true whatever the origin was:
|
|
579
|
+
* an `npx` cache is not updated in place at all, so what has to change is the spec on the
|
|
580
|
+
* host's command line. The origin is NOT lost with it — `source` and `install_source_exists`
|
|
581
|
+
* still carry it, and `install-source-missing` still fires and still names the path. Collapsing
|
|
582
|
+
* the two would have thrown away exactly the fact AS-7 was filed about, since the measured
|
|
583
|
+
* incident is both at once: an `npx` cache filled from a tarball that no longer exists.
|
|
584
|
+
*/
|
|
585
|
+
function withEnvironment(origin, places) {
|
|
586
|
+
if (!places.some((place) => isNpxProject(place.projectRoot)))
|
|
587
|
+
return origin;
|
|
588
|
+
return {
|
|
589
|
+
shape: 'ephemeral',
|
|
590
|
+
source: origin.source,
|
|
591
|
+
sourceReason: origin.source === null ? EPHEMERAL_REASON : '',
|
|
592
|
+
};
|
|
593
|
+
}
|
|
594
|
+
/**
|
|
595
|
+
* The errnos that mean "not there" rather than "could not look" — the reference's
|
|
596
|
+
* `pathlib._ignore_error` set, which is what makes `Path.exists()` return `False` instead of
|
|
597
|
+
* raising. Everything else is a check that did not happen, and saying `false` for one of those
|
|
598
|
+
* would report a deleted origin where the truth is a directory nobody may read.
|
|
599
|
+
*/
|
|
600
|
+
const NOT_THERE = new Set(['ENOENT', 'ENOTDIR', 'EBADF', 'ELOOP', 'EINVAL', 'ENAMETOOLONG']);
|
|
601
|
+
/**
|
|
602
|
+
* Is the recorded origin still on disk? `present: null` when the check itself could not be made.
|
|
603
|
+
*
|
|
604
|
+
* `existsSync` is deliberately not used: it swallows EVERY error and answers `false`, so an
|
|
605
|
+
* origin under a directory the server may not read would be reported as DELETED — a wrong
|
|
606
|
+
* answer where the reference gives none. This is the half that is re-read on every tool call,
|
|
607
|
+
* so it is one `stat` and nothing else.
|
|
608
|
+
*/
|
|
609
|
+
export function originStat(path) {
|
|
610
|
+
try {
|
|
611
|
+
statSync(path);
|
|
612
|
+
return { present: true, reason: '' };
|
|
613
|
+
}
|
|
614
|
+
catch (e) {
|
|
615
|
+
const code = e.code;
|
|
616
|
+
if (code === undefined)
|
|
617
|
+
throw e;
|
|
618
|
+
if (NOT_THERE.has(code))
|
|
619
|
+
return { present: false, reason: '' };
|
|
620
|
+
return {
|
|
621
|
+
present: null,
|
|
622
|
+
reason: `the recorded origin path could not be checked: ${e.message}`,
|
|
623
|
+
};
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
let installOnce = null;
|
|
627
|
+
/**
|
|
628
|
+
* Which install shape is running. Importable, and NOT owned by the status path.
|
|
629
|
+
*
|
|
630
|
+
* DERIVED ONCE, because the bytes that were imported cannot change under a process. The footer
|
|
631
|
+
* runs `degradedConditions` on every tool call and `docs/status.md` promises what that costs;
|
|
632
|
+
* walking `node_modules` for a hidden lockfile is not on that list. So the SHAPE is memoised
|
|
633
|
+
* and only the EXISTENCE of the recorded path is re-read — which is the half that can actually
|
|
634
|
+
* change while a server is running, and the half the measured defect is about. The failure is
|
|
635
|
+
* memoised too: a derivation that already failed would fail identically a second time.
|
|
636
|
+
*
|
|
637
|
+
* Deliberately a module-level function taking no arguments and touching no server state: the
|
|
638
|
+
* `--update` flag planned as J46-29 has to be shape-aware — two of the five shapes have no
|
|
639
|
+
* registry route at all — and it must be able to ask this question from the CLI, before a
|
|
640
|
+
* memory store or a transport exists, without building an MCP server to do it.
|
|
641
|
+
*/
|
|
642
|
+
export function currentInstall() {
|
|
643
|
+
if (installOnce === null) {
|
|
644
|
+
try {
|
|
645
|
+
installOnce = {
|
|
646
|
+
install: deriveInstall(fileURLToPath(import.meta.url), process.argv[1] ?? null),
|
|
647
|
+
error: null,
|
|
648
|
+
};
|
|
649
|
+
}
|
|
650
|
+
catch (e) {
|
|
651
|
+
if (!(e instanceof Undetermined))
|
|
652
|
+
throw e;
|
|
653
|
+
installOnce = { install: null, error: e };
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
if (installOnce.install === null) {
|
|
657
|
+
throw installOnce.error ?? new Undetermined('the install shape was not derived');
|
|
658
|
+
}
|
|
659
|
+
return installOnce.install;
|
|
660
|
+
}
|
|
224
661
|
/** `sha256(json.dumps(inputs, sort_keys=True, separators=(",", ":")))` — the identity. */
|
|
225
662
|
export function buildIdFor(inputs) {
|
|
226
663
|
const canonical = `{${Object.keys(inputs)
|
|
@@ -280,6 +717,31 @@ export function buildIdentity(version, sdkVersion) {
|
|
|
280
717
|
// chosen by the operator or resolved by the package is a fact a caller comparing two
|
|
281
718
|
// endpoints needs. A bool, so it is never confusable with a missing one.
|
|
282
719
|
identity.set('assets_root_from_env', Boolean(process.env.BANTAMKIT_ASSETS));
|
|
720
|
+
// Location too, and the same rule: which install SHAPE is running says what updating this
|
|
721
|
+
// server would even mean, and says nothing about which build it is. A `file:` install whose
|
|
722
|
+
// tarball was deleted answers with a path that no longer exists — the measured defect AS-7
|
|
723
|
+
// filed — and `install_source_exists` is the bit that says so. No network is consulted for
|
|
724
|
+
// any of the three.
|
|
725
|
+
try {
|
|
726
|
+
const install = currentInstall();
|
|
727
|
+
identity.set('install_shape', install.shape);
|
|
728
|
+
if (install.source === null) {
|
|
729
|
+
identity.set('install_source', unavailable(install.sourceReason));
|
|
730
|
+
identity.set('install_source_exists', unavailable(`a ${install.shape} install records no origin path, so there is nothing here to check for.`));
|
|
731
|
+
}
|
|
732
|
+
else {
|
|
733
|
+
identity.set('install_source', install.source);
|
|
734
|
+
const origin = originStat(install.source);
|
|
735
|
+
identity.set('install_source_exists', origin.present === null ? unavailable(origin.reason) : origin.present);
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
catch (e) {
|
|
739
|
+
if (!(e instanceof Undetermined))
|
|
740
|
+
throw e;
|
|
741
|
+
for (const field of ['install_shape', 'install_source', 'install_source_exists']) {
|
|
742
|
+
identity.set(field, unavailable(e.message));
|
|
743
|
+
}
|
|
744
|
+
}
|
|
283
745
|
identity.set('git_commit', unavailable(GIT_COMMIT_REFUSAL));
|
|
284
746
|
identity.set('interpreter', process.execPath || unavailable('process.execPath is empty; this process cannot name its own binary'));
|
|
285
747
|
identity.set('node_version', process.versions.node);
|
package/dist/mcp/pyargs.d.ts
CHANGED
|
@@ -31,7 +31,7 @@ import { type PyValue } from '../pyjson.js';
|
|
|
31
31
|
/** What a parameter accepts. `optional` is `| None = None` in the signature. */
|
|
32
32
|
export interface FieldSpec {
|
|
33
33
|
readonly name: string;
|
|
34
|
-
readonly kind: 'str' | 'int' | 'dict' | 'dictInt' | 'dictStr' | 'listStr';
|
|
34
|
+
readonly kind: 'str' | 'int' | 'bool' | 'dict' | 'dictInt' | 'dictStr' | 'listStr';
|
|
35
35
|
readonly optional: boolean;
|
|
36
36
|
/** `Field(le=...)`: an inclusive ceiling, checked AFTER the lax int parse succeeds. */
|
|
37
37
|
readonly le?: bigint;
|