@nexusbloom/mcp-server 2.0.0 → 2.1.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/src/handlers.js CHANGED
@@ -20,7 +20,11 @@ import {
20
20
  import { asNexusBloomError, ErrorCode, NexusBloomError } from "./errors.js";
21
21
  import { ManifestCache, normaliseTool } 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,23 +142,89 @@ export function createHandlers({ client, cache, config }) {
112
142
  assertValidInput(params, inputSchema, tool.slug);
113
143
 
114
144
  const startedAt = Date.now();
115
- const body = await client.request(`/run/${encodeURIComponent(tool.slug)}`, {
116
- method: "POST",
117
- body: params,
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,
118
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
+
178
+ // The run endpoint records usage itself, so attribution rides on a header
179
+ // rather than a second tracking call — a separate POST would either be a
180
+ // no-op anonymously or double-count once authenticated.
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
+ }
119
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
+ });
120
215
 
121
216
  return {
122
217
  success: true,
123
- data: body?.data ?? body,
218
+ data,
124
219
  tool: tool.slug,
125
220
  execution: "remote",
126
221
  durationMs,
222
+ rateLimit: client.rateLimit ?? null,
127
223
  };
128
224
  }
129
225
 
130
226
  /** Meta-tool: discover, inspect, or run. */
131
- async function meta(args) {
227
+ async function meta(args, { onProgress } = {}) {
132
228
  const command = args?.command;
133
229
 
134
230
  if (!command) {
@@ -188,6 +284,36 @@ export function createHandlers({ client, cache, config }) {
188
284
  return respond(renderSchema(schema, tools));
189
285
  }
190
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 }));
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
+
191
317
  // run
192
318
  const slug = (args?.slug || "").trim();
193
319
  if (!slug) {
@@ -200,6 +326,113 @@ export function createHandlers({ client, cache, config }) {
200
326
  return respondResult(result);
201
327
  }
202
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
+
203
436
  /** Direct invocation by tool slug. */
204
437
  async function callDirect(name, args) {
205
438
  const result = await execute(name, args ?? {});
@@ -218,6 +451,8 @@ export function createHandlers({ client, cache, config }) {
218
451
  return {
219
452
  listTools: buildToolList(cache),
220
453
  meta,
454
+ batch: runBatch,
455
+ history,
221
456
  callDirect,
222
457
  execute,
223
458
  findTool,
@@ -226,7 +461,69 @@ export function createHandlers({ client, cache, config }) {
226
461
  }
227
462
 
228
463
  /** The meta-tool's accepted commands, used in errors and tests. */
229
- export const META_COMMANDS = ["list", "search", "schema", "run"];
464
+ export const META_COMMANDS = ["list", "search", "schema", "run", "batch", "history", "diff"];
465
+
466
+ /**
467
+ * How many runs one batch may contain.
468
+ *
469
+ * Bounded because anonymous execution is capped at 30 requests/minute: a batch of
470
+ * fifty would spend the whole budget in one turn and 429 the user's next ten
471
+ * calls. Ten is enough for the real tasks (validate, lint, transform a set of
472
+ * files) and leaves headroom.
473
+ */
474
+ export const MAX_BATCH_SIZE = 10;
475
+
476
+ /**
477
+ * Validate the batch argument and return `{slug, params}` specs.
478
+ *
479
+ * A single malformed entry rejects the whole batch, because a batch is one
480
+ * intent — silently dropping item three would return a result set the caller
481
+ * cannot reconcile with what it asked for.
482
+ */
483
+ export function normaliseBatchInput(runs) {
484
+ if (!Array.isArray(runs) || runs.length === 0) {
485
+ throw new NexusBloomError(
486
+ `Missing "runs" for batch. Example: ` +
487
+ `{"command":"batch","runs":[{"slug":"env-validator","params":{"env_content":"DEBUG=true"}}]}`,
488
+ ErrorCode.INVALID_ARGS,
489
+ );
490
+ }
491
+ if (runs.length > MAX_BATCH_SIZE) {
492
+ throw new NexusBloomError(
493
+ `A batch may contain at most ${MAX_BATCH_SIZE} runs; got ${runs.length}. ` +
494
+ `Split it — or run them as direct tool calls, which do not share this cap.`,
495
+ ErrorCode.INVALID_ARGS,
496
+ );
497
+ }
498
+
499
+ return runs.map((entry, i) => {
500
+ // `{slug}` alone is accepted; params defaults to {} for a no-argument tool.
501
+ const spec = typeof entry === "string" ? { slug: entry } : entry;
502
+ if (!spec || typeof spec !== "object" || Array.isArray(spec)) {
503
+ throw new NexusBloomError(
504
+ `runs[${i}] must be an object like {"slug":"…","params":{…}}, or a slug string.`,
505
+ ErrorCode.INVALID_ARGS,
506
+ );
507
+ }
508
+ const slug = typeof spec.slug === "string" ? spec.slug.trim() : "";
509
+ if (!slug) {
510
+ throw new NexusBloomError(`runs[${i}] is missing a "slug".`, ErrorCode.INVALID_ARGS);
511
+ }
512
+ return { slug, params: spec.params ?? {} };
513
+ });
514
+ }
515
+
516
+ /** A run reference that is not in the log, with the ids that are. */
517
+ export function unknownRun(history, ref) {
518
+ const known = history.list().map((r) => r.id);
519
+ return new NexusBloomError(
520
+ `No run recorded for "${ref}". ` +
521
+ `Known run ids: ${known.join(", ") || "(none yet)"}. ` +
522
+ `Pass an id, or -1 for the most recent run.`,
523
+ ErrorCode.NOT_FOUND,
524
+ { details: { known } },
525
+ );
526
+ }
230
527
 
231
528
  /**
232
529
  * Build a not-found error that tells the agent what to do next.
@@ -298,9 +595,15 @@ export function buildToolList(cache) {
298
595
  "Use this instead of guessing a slug:\n" +
299
596
  '- {"command":"search","query":"validate environment file"} — rank tools by intent\n' +
300
597
  '- {"command":"list"} — every published tool, one line each\n' +
301
- '- {"command":"schema","slug":"<slug>"} — exact parameters plus a ready-to-send example\n\n' +
598
+ '- {"command":"schema","slug":"<slug>"} — exact parameters plus a ready-to-send example\n' +
599
+ '- {"command":"batch","runs":[{"slug":"<slug>","params":{…}},…]} — run up to 10 tools in one call\n' +
600
+ '- {"command":"history"} / {"command":"history","show":"<id>"} — what this session has run\n' +
601
+ '- {"command":"diff","from":"<id>","to":"<id>"} — compare two runs field by field\n\n' +
302
602
  "Any published tool is also callable directly by its slug, with its own " +
303
- "parameters as arguments — this tool is for when you do not yet know which one to use.",
603
+ "parameters as arguments — this tool is for when you do not yet know which one to use.\n\n" +
604
+ "If your host supports MCP resources, the same information is readable without a " +
605
+ 'call: "nexusbloom://catalogue" lists every tool, "nexusbloom://tools/{slug}" ' +
606
+ 'carries one full manifest, and "nexusbloom://guide" explains the recovery paths.',
304
607
  inputSchema: {
305
608
  type: "object",
306
609
  properties: {
@@ -308,7 +611,9 @@ export function buildToolList(cache) {
308
611
  type: "string",
309
612
  enum: META_COMMANDS,
310
613
  description:
311
- "list: all tools. search: find by intent. schema: a tool's parameters. run: execute a tool.",
614
+ "list: all tools. search: find by intent. schema: a tool's parameters. " +
615
+ "run: execute one tool. batch: execute up to 10 tools in one call. " +
616
+ "history: what this session has run. diff: compare two recorded runs.",
312
617
  },
313
618
  slug: {
314
619
  type: "string",
@@ -324,6 +629,36 @@ export function buildToolList(cache) {
324
629
  type: "object",
325
630
  description: "Input parameters for 'run', matching the tool's input schema.",
326
631
  },
632
+ show: {
633
+ type: "string",
634
+ description:
635
+ "Run id for 'history' — a single run with its input and result. " +
636
+ '-1 is the most recent, -2 the one before it.',
637
+ },
638
+ from: {
639
+ type: "string",
640
+ description: "Run id or negative index for 'diff'. Default \"-2\".",
641
+ },
642
+ to: {
643
+ type: "string",
644
+ description: "Run id or negative index for 'diff'. Default \"-1\".",
645
+ },
646
+ runs: {
647
+ type: "array",
648
+ description:
649
+ "Up to 10 entries for 'batch', each {\"slug\":\"…\",\"params\":{…}}. " +
650
+ "A bare slug string is accepted for a tool that takes no arguments. " +
651
+ "All slugs are resolved and validated before anything runs, so a typo costs no quota.",
652
+ maxItems: MAX_BATCH_SIZE,
653
+ items: {
654
+ type: "object",
655
+ properties: {
656
+ slug: { type: "string", description: "Tool slug." },
657
+ params: { type: "object", description: "That tool's input parameters." },
658
+ },
659
+ required: ["slug"],
660
+ },
661
+ },
327
662
  limit: {
328
663
  type: "integer",
329
664
  description: "Maximum results for 'search'. Default 10.",