@nexusbloom/mcp-server 2.0.2 → 2.1.1

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/src/handlers.js CHANGED
@@ -18,9 +18,13 @@ import {
18
18
  suggest,
19
19
  } from "./discovery.js";
20
20
  import { asNexusBloomError, ErrorCode, NexusBloomError } from "./errors.js";
21
- import { ManifestCache, normaliseTool } from "./manifests.js";
21
+ import { ManifestCache, normaliseTool, partitionAdvertisable } from "./manifests.js";
22
22
  import {
23
+ renderBatch,
23
24
  renderConnectivityReport,
25
+ renderDiff,
26
+ renderHistory,
27
+ renderRun,
24
28
  renderResult,
25
29
  respond,
26
30
  respondError,
@@ -30,6 +34,29 @@ import {
30
34
  renderToolReportedError,
31
35
  } from "./render.js";
32
36
  import { assertValidInput, coerceParams } from "./validate.js";
37
+ import { RunHistory, summariseRun } from "./history.js";
38
+ import { runLocal } from "./local.js";
39
+
40
+ /**
41
+ * Unwrap the `{ success, data }` envelope the API wraps run results in.
42
+ *
43
+ * The check is for key *presence*, not truthiness. `body?.data ?? body` looks
44
+ * equivalent and is not: a tool whose result is legitimately `null` — an
45
+ * empty match set, a validator that found nothing — came back as
46
+ * `{ success: true, data: null }`, and `??` treats that null as "absent" and
47
+ * hands the agent the entire envelope as its result. It then renders
48
+ * `{"success":true,"data":null}` where the answer should have been `null`, which
49
+ * is the kind of wrong-but-plausible output an agent will build a conclusion on.
50
+ *
51
+ * @param {any} body the parsed response body
52
+ * @returns {any} the payload, or the body unchanged when it is not enveloped
53
+ */
54
+ export function unwrapData(body) {
55
+ if (body && typeof body === "object" && !Array.isArray(body) && "data" in body) {
56
+ return body.data;
57
+ }
58
+ return body;
59
+ }
33
60
 
34
61
  /**
35
62
  * Assemble a handler set over a client and a manifest cache.
@@ -39,7 +66,10 @@ import { assertValidInput, coerceParams } from "./validate.js";
39
66
  * @param {ManifestCache} deps.cache
40
67
  * @param {object} deps.config
41
68
  */
42
- export function createHandlers({ client, cache, config }) {
69
+ export function createHandlers({ client, cache, config, history: providedHistory }) {
70
+ // One log per handler set. Injected so a test can assert on a deterministic
71
+ // clock, and so a host that restarts the server does not inherit a stale log.
72
+ const history = providedHistory ?? new RunHistory();
43
73
  /**
44
74
  * Look up a tool by exact slug or unambiguous abbreviation.
45
75
  *
@@ -89,7 +119,7 @@ export function createHandlers({ client, cache, config }) {
89
119
  if (tools.length === 0) {
90
120
  const result = await client
91
121
  .request(`/run/${encodeURIComponent(slug.trim())}`, { method: "POST", body: params })
92
- .then((body) => ({ success: true, data: body?.data ?? body }))
122
+ .then((body) => ({ success: true, data: unwrapData(body) }))
93
123
  .catch((err) => {
94
124
  throw enrichNotFound(err, slug.trim(), []);
95
125
  });
@@ -112,19 +142,80 @@ export function createHandlers({ client, cache, config }) {
112
142
  assertValidInput(params, inputSchema, tool.slug);
113
143
 
114
144
  const startedAt = Date.now();
145
+
146
+ // Local execution, when the operator has opted in. Decided before the request
147
+ // so a `local` run never touches /api/run at all, and an `auto` run falls
148
+ // through to the remote path below when no source is published.
149
+ const local = await runLocal({
150
+ client,
151
+ slug: tool.slug,
152
+ params,
153
+ mode: config?.execution ?? "remote",
154
+ config,
155
+ deps: opts.deps,
156
+ });
157
+
158
+ if (local.executed) {
159
+ const durationMs = Date.now() - startedAt;
160
+ history.record({
161
+ tool: tool.slug,
162
+ params,
163
+ data: local.data,
164
+ ok: true,
165
+ durationMs,
166
+ batch: Boolean(opts.batch),
167
+ });
168
+ return {
169
+ success: true,
170
+ data: local.data,
171
+ tool: tool.slug,
172
+ execution: "local",
173
+ durationMs,
174
+ rateLimit: null,
175
+ };
176
+ }
177
+
115
178
  // The run endpoint records usage itself, so attribution rides on a header
116
179
  // rather than a second tracking call — a separate POST would either be a
117
180
  // no-op anonymously or double-count once authenticated.
118
- const body = await client.request(`/run/${encodeURIComponent(tool.slug)}`, {
119
- method: "POST",
120
- body: params,
121
- headers: { "X-NexusBloom-Source": "mcp" },
122
- });
181
+ let body;
182
+ try {
183
+ body = await client.request(`/run/${encodeURIComponent(tool.slug)}`, {
184
+ method: "POST",
185
+ body: params,
186
+ headers: { "X-NexusBloom-Source": "mcp" },
187
+ });
188
+ } catch (err) {
189
+ // Failures are recorded too: a log that only holds successes cannot explain
190
+ // why an agent is retrying, which is the main reason to keep one.
191
+ const nxb = asNexusBloomError(err);
192
+ history.record({
193
+ tool: tool.slug,
194
+ params,
195
+ ok: false,
196
+ durationMs: Date.now() - startedAt,
197
+ error: nxb.message,
198
+ code: nxb.code,
199
+ retryable: nxb.retryable ?? false,
200
+ batch: Boolean(opts.batch),
201
+ });
202
+ throw err;
203
+ }
123
204
  const durationMs = Date.now() - startedAt;
205
+ const data = unwrapData(body);
206
+
207
+ history.record({
208
+ tool: tool.slug,
209
+ params,
210
+ data,
211
+ ok: true,
212
+ durationMs,
213
+ batch: Boolean(opts.batch),
214
+ });
124
215
 
125
216
  return {
126
217
  success: true,
127
- data: body?.data ?? body,
218
+ data,
128
219
  tool: tool.slug,
129
220
  execution: "remote",
130
221
  durationMs,
@@ -133,7 +224,7 @@ export function createHandlers({ client, cache, config }) {
133
224
  }
134
225
 
135
226
  /** Meta-tool: discover, inspect, or run. */
136
- async function meta(args) {
227
+ async function meta(args, { onProgress } = {}) {
137
228
  const command = args?.command;
138
229
 
139
230
  if (!command) {
@@ -193,6 +284,36 @@ export function createHandlers({ client, cache, config }) {
193
284
  return respond(renderSchema(schema, tools));
194
285
  }
195
286
 
287
+ if (command === "batch") {
288
+ const batch = await runBatch(args?.runs, { onProgress });
289
+ return respond(renderBatch(batch));
290
+ }
291
+
292
+ if (command === "history") {
293
+ // `show` narrows to one run; otherwise this is an index of what ran.
294
+ if (args?.show) {
295
+ const run = history.find(args.show);
296
+ if (!run) throw unknownRun(history, args.show);
297
+ return respond(renderRun(run));
298
+ }
299
+ const limit = Number.isInteger(args?.limit) && args.limit > 0 ? Math.min(args.limit, 50) : 10;
300
+ const all = history.list();
301
+ return respond(renderHistory(history.recent(limit), { total: all.length, scope: history.scope() }));
302
+ }
303
+
304
+ if (command === "diff") {
305
+ const from = args?.from ?? "-2";
306
+ const to = args?.to ?? "-1";
307
+ if (!history.list().length) {
308
+ throw new NexusBloomError(
309
+ "No runs are recorded yet, so there is nothing to compare. Run a tool first — " +
310
+ 'history is in memory only and starts empty on every server start.',
311
+ ErrorCode.INVALID_ARGS,
312
+ );
313
+ }
314
+ return respond(renderDiff(history.diff(from, to)));
315
+ }
316
+
196
317
  // run
197
318
  const slug = (args?.slug || "").trim();
198
319
  if (!slug) {
@@ -205,6 +326,113 @@ export function createHandlers({ client, cache, config }) {
205
326
  return respondResult(result);
206
327
  }
207
328
 
329
+ /**
330
+ * Run several tools in one call.
331
+ *
332
+ * Two properties make this worth having over N direct calls:
333
+ *
334
+ * 1. **Resolution happens up front.** Every slug is resolved and every schema
335
+ * validated before the first run, so a typo in item four costs zero quota
336
+ * rather than three runs. A batch is one intent, so a malformed batch is an
337
+ * error, not eight partial results.
338
+ * 2. **Failures are isolated.** Once past resolution, each run is independent:
339
+ * one tool erroring must not discard the seven results that worked, because
340
+ * the whole point of batching is getting all of them in one turn.
341
+ */
342
+ async function runBatch(runs, { onProgress } = {}) {
343
+ const specs = normaliseBatchInput(runs);
344
+ const total = specs.length;
345
+
346
+ // Phase 1 — resolve and validate everything.
347
+ const tools = await cache.tools();
348
+ const planned = [];
349
+ const unknown = [];
350
+
351
+ for (const spec of specs) {
352
+ const { tool, ambiguous } = resolveSlugStrict(tools, spec.slug);
353
+ if (!tool) {
354
+ // An ambiguous prefix is a different mistake from a typo, and the
355
+ // recovery differs: candidates the agent must choose between, versus a
356
+ // near match it can just use. Both are recorded so one message can carry
357
+ // the right hint for each.
358
+ const candidates = [
359
+ ...new Set([
360
+ ...ambiguous.map((t) => t.slug),
361
+ ...suggest(tools, spec.slug).map((t) => t.slug),
362
+ ]),
363
+ ].slice(0, 3);
364
+ unknown.push({ slug: spec.slug, candidates });
365
+ continue;
366
+ }
367
+ planned.push({ spec, tool });
368
+ }
369
+
370
+ if (unknown.length) {
371
+ const parts = unknown.map(({ slug, candidates }) =>
372
+ candidates.length ? `"${slug}" → ${candidates.join(" or ")}` : `"${slug}" (no close match)`,
373
+ );
374
+ throw new NexusBloomError(
375
+ `Nothing in the batch ran. Unresolvable: ${parts.join("; ")}. ` +
376
+ `Call {"command":"search","query":"<what you need>"} or {"command":"list"}.`,
377
+ ErrorCode.INVALID_ARGS,
378
+ { details: { unknown: unknown.map((u) => u.slug) } },
379
+ );
380
+ }
381
+
382
+ // Coerce and validate before spending anything, matching the single-call path.
383
+ const validated = [];
384
+ for (const { spec, tool } of planned) {
385
+ const params = coerceParams(spec.params, tool.slug);
386
+ let inputSchema = tool.input_schema;
387
+ try {
388
+ const manifest = await cache.manifest(tool.slug);
389
+ inputSchema = manifest.input_schema || inputSchema;
390
+ } catch {
391
+ /* the list schema is good enough; the API is authoritative anyway */
392
+ }
393
+ assertValidInput(params, inputSchema, tool.slug);
394
+ validated.push({ slug: tool.slug, params });
395
+ }
396
+
397
+ // Phase 2 — run, isolating failures.
398
+ const startedAt = Date.now();
399
+ const results = [];
400
+
401
+ await onProgress?.({ progress: 0, total, message: `Running ${total} tool${total === 1 ? "" : "s"}…` });
402
+
403
+ for (const [index, run] of validated.entries()) {
404
+ try {
405
+ const result = await execute(run.slug, run.params, { batch: true });
406
+ const data = result.data;
407
+ const reported = data && typeof data === "object" && (data.success === false || (data.error && !data.data));
408
+ results.push({
409
+ slug: run.slug,
410
+ success: !reported,
411
+ data,
412
+ durationMs: result.durationMs,
413
+ ...(reported ? { error: data.error || data.message || "The tool reported an error.", code: data.code } : {}),
414
+ });
415
+ } catch (err) {
416
+ const nxb = asNexusBloomError(err);
417
+ results.push({
418
+ slug: run.slug,
419
+ success: false,
420
+ error: nxb.message,
421
+ code: nxb.code,
422
+ retryable: nxb.retryable ?? false,
423
+ });
424
+ }
425
+
426
+ await onProgress?.({
427
+ progress: index + 1,
428
+ total,
429
+ message: `Finished ${run.slug} (${index + 1}/${total})`,
430
+ });
431
+ }
432
+
433
+ return { runs: validated.length, results, durationMs: Date.now() - startedAt };
434
+ }
435
+
208
436
  /** Direct invocation by tool slug. */
209
437
  async function callDirect(name, args) {
210
438
  const result = await execute(name, args ?? {});
@@ -221,8 +449,22 @@ export function createHandlers({ client, cache, config }) {
221
449
  }
222
450
 
223
451
  return {
224
- listTools: buildToolList(cache),
452
+ // Withheld tools are reported once, on stderr, and not behind a debug flag:
453
+ // a slug the catalogue cannot express as an MCP tool name is a data problem
454
+ // somebody has to fix, and silence leaves the tool quietly unreachable.
455
+ listTools: buildToolList(cache, {
456
+ onWithheld(withheld) {
457
+ for (const w of withheld) {
458
+ process.stderr.write(
459
+ `[catalogue] "${w.slug}" is not advertised as an MCP tool — ${w.reason}. ` +
460
+ 'It is still reachable via {"command":"run"} and nexusbloom://catalogue.\n',
461
+ );
462
+ }
463
+ },
464
+ }),
225
465
  meta,
466
+ batch: runBatch,
467
+ history,
226
468
  callDirect,
227
469
  execute,
228
470
  findTool,
@@ -231,7 +473,69 @@ export function createHandlers({ client, cache, config }) {
231
473
  }
232
474
 
233
475
  /** The meta-tool's accepted commands, used in errors and tests. */
234
- export const META_COMMANDS = ["list", "search", "schema", "run"];
476
+ export const META_COMMANDS = ["list", "search", "schema", "run", "batch", "history", "diff"];
477
+
478
+ /**
479
+ * How many runs one batch may contain.
480
+ *
481
+ * Bounded because anonymous execution is capped at 30 requests/minute: a batch of
482
+ * fifty would spend the whole budget in one turn and 429 the user's next ten
483
+ * calls. Ten is enough for the real tasks (validate, lint, transform a set of
484
+ * files) and leaves headroom.
485
+ */
486
+ export const MAX_BATCH_SIZE = 10;
487
+
488
+ /**
489
+ * Validate the batch argument and return `{slug, params}` specs.
490
+ *
491
+ * A single malformed entry rejects the whole batch, because a batch is one
492
+ * intent — silently dropping item three would return a result set the caller
493
+ * cannot reconcile with what it asked for.
494
+ */
495
+ export function normaliseBatchInput(runs) {
496
+ if (!Array.isArray(runs) || runs.length === 0) {
497
+ throw new NexusBloomError(
498
+ `Missing "runs" for batch. Example: ` +
499
+ `{"command":"batch","runs":[{"slug":"env-validator","params":{"env_content":"DEBUG=true"}}]}`,
500
+ ErrorCode.INVALID_ARGS,
501
+ );
502
+ }
503
+ if (runs.length > MAX_BATCH_SIZE) {
504
+ throw new NexusBloomError(
505
+ `A batch may contain at most ${MAX_BATCH_SIZE} runs; got ${runs.length}. ` +
506
+ `Split it — or run them as direct tool calls, which do not share this cap.`,
507
+ ErrorCode.INVALID_ARGS,
508
+ );
509
+ }
510
+
511
+ return runs.map((entry, i) => {
512
+ // `{slug}` alone is accepted; params defaults to {} for a no-argument tool.
513
+ const spec = typeof entry === "string" ? { slug: entry } : entry;
514
+ if (!spec || typeof spec !== "object" || Array.isArray(spec)) {
515
+ throw new NexusBloomError(
516
+ `runs[${i}] must be an object like {"slug":"…","params":{…}}, or a slug string.`,
517
+ ErrorCode.INVALID_ARGS,
518
+ );
519
+ }
520
+ const slug = typeof spec.slug === "string" ? spec.slug.trim() : "";
521
+ if (!slug) {
522
+ throw new NexusBloomError(`runs[${i}] is missing a "slug".`, ErrorCode.INVALID_ARGS);
523
+ }
524
+ return { slug, params: spec.params ?? {} };
525
+ });
526
+ }
527
+
528
+ /** A run reference that is not in the log, with the ids that are. */
529
+ export function unknownRun(history, ref) {
530
+ const known = history.list().map((r) => r.id);
531
+ return new NexusBloomError(
532
+ `No run recorded for "${ref}". ` +
533
+ `Known run ids: ${known.join(", ") || "(none yet)"}. ` +
534
+ `Pass an id, or -1 for the most recent run.`,
535
+ ErrorCode.NOT_FOUND,
536
+ { details: { known } },
537
+ );
538
+ }
235
539
 
236
540
  /**
237
541
  * Build a not-found error that tells the agent what to do next.
@@ -277,7 +581,7 @@ function enrichNotFound(err, slug, tools) {
277
581
  * meta-tool is advertised too — it is how an agent recovers when a direct call
278
582
  * is not what it wanted.
279
583
  */
280
- export function buildToolList(cache) {
584
+ export function buildToolList(cache, { onWithheld } = {}) {
281
585
  return async function listTools() {
282
586
  // A catalogue failure must not fail the whole request. If the API is
283
587
  // unreachable, a host that receives a JSON-RPC error considers the *server*
@@ -290,7 +594,16 @@ export function buildToolList(cache) {
290
594
  tools = [];
291
595
  }
292
596
 
293
- const entries = tools.map((t) => ({
597
+ // A slug that is not a legal MCP tool name is held back rather than
598
+ // emitted. A strict host validates every name in this array and rejects the
599
+ // entire response over one bad entry, so a single odd slug in the catalogue
600
+ // would cost the agent every tool — not just that one. The meta-tool still
601
+ // reaches the withheld tools, and the reason is surfaced rather than
602
+ // swallowed, so the catalogue can be fixed at source.
603
+ const { advertisable, withheld } = partitionAdvertisable(tools);
604
+ if (withheld.length) onWithheld?.(withheld);
605
+
606
+ const entries = advertisable.map((t) => ({
294
607
  name: t.slug,
295
608
  description: buildToolDescription(t),
296
609
  inputSchema: t.input_schema,
@@ -303,9 +616,15 @@ export function buildToolList(cache) {
303
616
  "Use this instead of guessing a slug:\n" +
304
617
  '- {"command":"search","query":"validate environment file"} — rank tools by intent\n' +
305
618
  '- {"command":"list"} — every published tool, one line each\n' +
306
- '- {"command":"schema","slug":"<slug>"} — exact parameters plus a ready-to-send example\n\n' +
619
+ '- {"command":"schema","slug":"<slug>"} — exact parameters plus a ready-to-send example\n' +
620
+ '- {"command":"batch","runs":[{"slug":"<slug>","params":{…}},…]} — run up to 10 tools in one call\n' +
621
+ '- {"command":"history"} / {"command":"history","show":"<id>"} — what this server process has run\n' +
622
+ '- {"command":"diff","from":"<id>","to":"<id>"} — compare two runs field by field\n\n' +
307
623
  "Any published tool is also callable directly by its slug, with its own " +
308
- "parameters as arguments — this tool is for when you do not yet know which one to use.",
624
+ "parameters as arguments — this tool is for when you do not yet know which one to use.\n\n" +
625
+ "If your host supports MCP resources, the same information is readable without a " +
626
+ 'call: "nexusbloom://catalogue" lists every tool, "nexusbloom://tools/{slug}" ' +
627
+ 'carries one full manifest, and "nexusbloom://guide" explains the recovery paths.',
309
628
  inputSchema: {
310
629
  type: "object",
311
630
  properties: {
@@ -313,7 +632,9 @@ export function buildToolList(cache) {
313
632
  type: "string",
314
633
  enum: META_COMMANDS,
315
634
  description:
316
- "list: all tools. search: find by intent. schema: a tool's parameters. run: execute a tool.",
635
+ "list: all tools. search: find by intent. schema: a tool's parameters. " +
636
+ "run: execute one tool. batch: execute up to 10 tools in one call. " +
637
+ "history: what this server process has run, and since when. diff: compare two recorded runs.",
317
638
  },
318
639
  slug: {
319
640
  type: "string",
@@ -329,6 +650,36 @@ export function buildToolList(cache) {
329
650
  type: "object",
330
651
  description: "Input parameters for 'run', matching the tool's input schema.",
331
652
  },
653
+ show: {
654
+ type: "string",
655
+ description:
656
+ "Run id for 'history' — a single run with its input and result. " +
657
+ '-1 is the most recent, -2 the one before it.',
658
+ },
659
+ from: {
660
+ type: "string",
661
+ description: "Run id or negative index for 'diff'. Default \"-2\".",
662
+ },
663
+ to: {
664
+ type: "string",
665
+ description: "Run id or negative index for 'diff'. Default \"-1\".",
666
+ },
667
+ runs: {
668
+ type: "array",
669
+ description:
670
+ "Up to 10 entries for 'batch', each {\"slug\":\"…\",\"params\":{…}}. " +
671
+ "A bare slug string is accepted for a tool that takes no arguments. " +
672
+ "All slugs are resolved and validated before anything runs, so a typo costs no quota.",
673
+ maxItems: MAX_BATCH_SIZE,
674
+ items: {
675
+ type: "object",
676
+ properties: {
677
+ slug: { type: "string", description: "Tool slug." },
678
+ params: { type: "object", description: "That tool's input parameters." },
679
+ },
680
+ required: ["slug"],
681
+ },
682
+ },
332
683
  limit: {
333
684
  type: "integer",
334
685
  description: "Maximum results for 'search'. Default 10.",