@nexusbloom/mcp-server 1.0.3 → 2.0.2

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/errors.js ADDED
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Errors — one class, codes agents can branch on.
3
+ *
4
+ * v1 threw bare `new Error(err.error)`, discarding the API's `code`. An agent
5
+ * that hit a validation failure and a rate limit got two different HTTP statuses
6
+ * flattened into the same untyped string, so it had to parse prose to decide
7
+ * whether to retry. `NexusBloomError` keeps the code all the way to the MCP
8
+ * response.
9
+ */
10
+
11
+ /** Codes the server itself produces, independent of what the API said. */
12
+ export const ErrorCode = {
13
+ TIMEOUT: "TIMEOUT",
14
+ NETWORK: "NETWORK",
15
+ NOT_FOUND: "TOOL_NOT_FOUND",
16
+ UNAUTHORIZED: "UNAUTHORIZED",
17
+ RATE_LIMITED: "RATE_LIMITED",
18
+ INVALID_ARGS: "INVALID_ARGS",
19
+ VALIDATION_FAILED: "VALIDATION_FAILED",
20
+ API_ERROR: "API_ERROR",
21
+ CONFIG: "CONFIG_ERROR",
22
+ };
23
+
24
+ /**
25
+ * Map an HTTP status to a code, used only when the response body carries none.
26
+ *
27
+ * The API already returns codes through `_middleware.js`; this is the fallback
28
+ * for a proxy or CDN error page that never reaches the handler.
29
+ */
30
+ export function codeForStatus(status) {
31
+ if (status === 401 || status === 403) return ErrorCode.UNAUTHORIZED;
32
+ if (status === 404) return ErrorCode.NOT_FOUND;
33
+ if (status === 429) return ErrorCode.RATE_LIMITED;
34
+ if (status === 400) return ErrorCode.VALIDATION_FAILED;
35
+ return ErrorCode.API_ERROR;
36
+ }
37
+
38
+ export class NexusBloomError extends Error {
39
+ /**
40
+ * @param {string} message Human-readable, safe to show an agent.
41
+ * @param {string} code Stable identifier; see ErrorCode.
42
+ * @param {object} [opts]
43
+ * @param {number} [opts.status] HTTP status, when there was one.
44
+ * @param {*} [opts.details] Extra structured context (field errors, etc).
45
+ * @param {boolean}[opts.retryable] Whether retrying could plausibly succeed.
46
+ */
47
+ constructor(message, code, opts = {}) {
48
+ super(message);
49
+ this.name = "NexusBloomError";
50
+ this.code = code || ErrorCode.API_ERROR;
51
+ this.status = opts.status ?? null;
52
+ this.details = opts.details ?? null;
53
+ this.retryable = opts.retryable ?? isRetryableCode(this.code, opts.status);
54
+ }
55
+
56
+ /** Shape embedded in the MCP text response. */
57
+ toJSON() {
58
+ return {
59
+ success: false,
60
+ error: this.message,
61
+ code: this.code,
62
+ ...(this.status ? { status: this.status } : {}),
63
+ ...(this.details ? { details: this.details } : {}),
64
+ retryable: this.retryable,
65
+ };
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Retry only what is worth retrying.
71
+ *
72
+ * A 400 will fail identically forever; a timeout or a 429 might not. Agents act
73
+ * on this flag, so getting it wrong causes either an infinite retry loop or a
74
+ * premature give-up.
75
+ */
76
+ function isRetryableCode(code, status) {
77
+ if (code === ErrorCode.TIMEOUT || code === ErrorCode.NETWORK) return true;
78
+ if (code === ErrorCode.RATE_LIMITED) return true;
79
+ // 5xx is a server fault that may not repeat.
80
+ return typeof status === "number" && status >= 500 && status < 600;
81
+ }
82
+
83
+ /**
84
+ * Wrap an arbitrary thrown value as a NexusBloomError.
85
+ *
86
+ * `fetch` rejects with a TypeError for DNS failure, connection refused and TLS
87
+ * errors alike, all of which read as "fetch failed" — useless to an agent. The
88
+ * cause is unwrapped so the distinction survives.
89
+ */
90
+ export function asNexusBloomError(err) {
91
+ if (err instanceof NexusBloomError) return err;
92
+
93
+ if (err?.name === "AbortError" || err?.name === "TimeoutError") {
94
+ return new NexusBloomError(
95
+ `Request timed out. The NexusBloom API did not respond in time.`,
96
+ ErrorCode.TIMEOUT,
97
+ { retryable: true },
98
+ );
99
+ }
100
+
101
+ const detail = err?.cause?.message || err?.message || String(err);
102
+ return new NexusBloomError(
103
+ `Could not reach the NexusBloom API: ${detail}. Check NEXUSBLOOM_API_URL and network connectivity.`,
104
+ ErrorCode.NETWORK,
105
+ { retryable: true },
106
+ );
107
+ }
@@ -0,0 +1,431 @@
1
+ /**
2
+ * Request handlers — every behaviour of the server, with no MCP SDK import.
3
+ *
4
+ * Keeping this layer free of the SDK is what makes the suite meaningful: the
5
+ * handlers are plain async functions returning plain objects, so a test drives
6
+ * them directly with a stubbed client and asserts on the exact bytes an agent
7
+ * would receive. The SDK wiring in server.js is then a thin adapter over
8
+ * something already verified.
9
+ */
10
+
11
+ import { ApiClient } from "./client.js";
12
+ import { loadConfig } from "./config.js";
13
+ import {
14
+ describeParameters,
15
+ groupByCategory,
16
+ resolveSlugStrict,
17
+ searchTools,
18
+ suggest,
19
+ } from "./discovery.js";
20
+ import { asNexusBloomError, ErrorCode, NexusBloomError } from "./errors.js";
21
+ import { ManifestCache, normaliseTool } from "./manifests.js";
22
+ import {
23
+ renderConnectivityReport,
24
+ renderResult,
25
+ respond,
26
+ respondError,
27
+ renderSchema,
28
+ renderSearchResults,
29
+ renderToolList,
30
+ renderToolReportedError,
31
+ } from "./render.js";
32
+ import { assertValidInput, coerceParams } from "./validate.js";
33
+
34
+ /**
35
+ * Assemble a handler set over a client and a manifest cache.
36
+ *
37
+ * @param {object} deps
38
+ * @param {ApiClient} deps.client
39
+ * @param {ManifestCache} deps.cache
40
+ * @param {object} deps.config
41
+ */
42
+ export function createHandlers({ client, cache, config }) {
43
+ /**
44
+ * Look up a tool by exact slug or unambiguous abbreviation.
45
+ *
46
+ * Uses the strict resolver, so execution never silently runs a different tool
47
+ * than the agent named.
48
+ */
49
+ async function findTool(slug, { refresh = false } = {}) {
50
+ const tools = await cache.tools({ force: refresh });
51
+ return { tools, ...resolveSlugStrict(tools, slug) };
52
+ }
53
+
54
+ /**
55
+ * Execute a tool by slug.
56
+ *
57
+ * The sequence matters: resolve, then validate against the manifest, then
58
+ * call. Validating first means a bad call costs no quota and returns an error
59
+ * naming the offending field.
60
+ */
61
+ async function execute(slug, rawParams, opts = {}) {
62
+ if (!slug || typeof slug !== "string" || !slug.trim()) {
63
+ throw new NexusBloomError(
64
+ "A tool slug is required. Call `nexusbloom` with command \"list\" to see available tools.",
65
+ ErrorCode.INVALID_ARGS,
66
+ );
67
+ }
68
+
69
+ const params = coerceParams(rawParams, slug);
70
+
71
+ let tools = [];
72
+ let tool = null;
73
+ let ambiguous = [];
74
+
75
+ try {
76
+ const found = await findTool(slug.trim(), opts);
77
+ tools = found.tools;
78
+ tool = found.tool;
79
+ ambiguous = found.ambiguous;
80
+ } catch {
81
+ // A catalogue failure must not block execution. /api/tools and /api/run
82
+ // are separate endpoints, and the API is authoritative about which slugs
83
+ // exist — so fall through and let the run call decide. If that fails too,
84
+ // its error is the more informative one to show.
85
+ }
86
+
87
+ if (!tool) {
88
+ // No list available at all — let the API be the judge.
89
+ if (tools.length === 0) {
90
+ const result = await client
91
+ .request(`/run/${encodeURIComponent(slug.trim())}`, { method: "POST", body: params })
92
+ .then((body) => ({ success: true, data: body?.data ?? body }))
93
+ .catch((err) => {
94
+ throw enrichNotFound(err, slug.trim(), []);
95
+ });
96
+ return { ...result, tool: slug.trim(), execution: "remote" };
97
+ }
98
+
99
+ throw notFoundError(slug.trim(), tools, ambiguous);
100
+ }
101
+
102
+ // Best-effort schema for validation; a manifest fetch failure is not fatal
103
+ // because the API validates authoritatively anyway.
104
+ let inputSchema = tool.input_schema;
105
+ try {
106
+ const manifest = await cache.manifest(tool.slug);
107
+ inputSchema = manifest.input_schema || inputSchema;
108
+ } catch {
109
+ /* keep the list schema */
110
+ }
111
+
112
+ assertValidInput(params, inputSchema, tool.slug);
113
+
114
+ const startedAt = Date.now();
115
+ // The run endpoint records usage itself, so attribution rides on a header
116
+ // rather than a second tracking call — a separate POST would either be a
117
+ // 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
+ });
123
+ const durationMs = Date.now() - startedAt;
124
+
125
+ return {
126
+ success: true,
127
+ data: body?.data ?? body,
128
+ tool: tool.slug,
129
+ execution: "remote",
130
+ durationMs,
131
+ rateLimit: client.rateLimit ?? null,
132
+ };
133
+ }
134
+
135
+ /** Meta-tool: discover, inspect, or run. */
136
+ async function meta(args) {
137
+ const command = args?.command;
138
+
139
+ if (!command) {
140
+ throw new NexusBloomError(
141
+ `Missing "command". Use one of: ${META_COMMANDS.join(", ")}.`,
142
+ ErrorCode.INVALID_ARGS,
143
+ );
144
+ }
145
+ if (!META_COMMANDS.includes(command)) {
146
+ throw new NexusBloomError(
147
+ `Unknown command "${command}". Use one of: ${META_COMMANDS.join(", ")}.`,
148
+ ErrorCode.INVALID_ARGS,
149
+ );
150
+ }
151
+
152
+ if (command === "list") {
153
+ const tools = await cache.tools({ force: args?.refresh === true });
154
+ return respond(renderToolList(tools));
155
+ }
156
+
157
+ if (command === "search") {
158
+ const query = (args?.query || "").trim();
159
+ if (!query) {
160
+ throw new NexusBloomError(
161
+ `Missing "query" for search. Example: {"command":"search","query":"validate env file"}.`,
162
+ ErrorCode.INVALID_ARGS,
163
+ );
164
+ }
165
+ const tools = await cache.tools();
166
+ const limit = Number.isInteger(args?.limit) && args.limit > 0 ? Math.min(args.limit, 50) : 10;
167
+ const results = searchTools(tools, query, { limit, includeAll: false });
168
+ const categories = groupByCategory(tools).map((g) => g.category);
169
+ return respond(renderSearchResults(query, results, tools.length, categories));
170
+ }
171
+
172
+ if (command === "schema") {
173
+ const slug = (args?.slug || "").trim();
174
+ if (!slug) {
175
+ throw new NexusBloomError(
176
+ `Missing "slug" for schema. Call {"command":"list"} or {"command":"search","query":"…"} first.`,
177
+ ErrorCode.INVALID_ARGS,
178
+ );
179
+ }
180
+ const { tools, tool, ambiguous } = await findTool(slug);
181
+ if (!tool) throw notFoundError(slug, tools, ambiguous);
182
+
183
+ // The list carries a schema now that the API ships them; fall back to a
184
+ // dedicated fetch only when it does not.
185
+ let schema = tool;
186
+ if (!tool.input_schema || Object.keys(tool.input_schema.properties || {}).length === 0) {
187
+ try {
188
+ schema = await cache.manifest(tool.slug);
189
+ } catch {
190
+ /* use the list schema */
191
+ }
192
+ }
193
+ return respond(renderSchema(schema, tools));
194
+ }
195
+
196
+ // run
197
+ const slug = (args?.slug || "").trim();
198
+ if (!slug) {
199
+ throw new NexusBloomError(
200
+ `Missing "slug" for run. Call {"command":"list"} or {"command":"search","query":"…"} first.`,
201
+ ErrorCode.INVALID_ARGS,
202
+ );
203
+ }
204
+ const result = await execute(slug, args?.params ?? {});
205
+ return respondResult(result);
206
+ }
207
+
208
+ /** Direct invocation by tool slug. */
209
+ async function callDirect(name, args) {
210
+ const result = await execute(name, args ?? {});
211
+ return respondResult(result);
212
+ }
213
+
214
+ /** Wrap a successful execution, distinguishing a tool-reported error. */
215
+ function respondResult(result) {
216
+ const data = result.data;
217
+ if (data && typeof data === "object" && (data.success === false || (data.error && !data.data))) {
218
+ return respond(renderToolReportedError(result.tool, data));
219
+ }
220
+ return respond(renderResult(result.tool, data, result));
221
+ }
222
+
223
+ return {
224
+ listTools: buildToolList(cache),
225
+ meta,
226
+ callDirect,
227
+ execute,
228
+ findTool,
229
+ handleError: respondError,
230
+ };
231
+ }
232
+
233
+ /** The meta-tool's accepted commands, used in errors and tests. */
234
+ export const META_COMMANDS = ["list", "search", "schema", "run"];
235
+
236
+ /**
237
+ * Build a not-found error that tells the agent what to do next.
238
+ *
239
+ * The suggestions come from edit distance *and* scored search, so both a typo
240
+ * (`env-validatr`) and a vague reference (`environment checker`) recover.
241
+ */
242
+ export function notFoundError(slug, tools, ambiguous = []) {
243
+ const near = [...new Set([...ambiguous.map((t) => t.slug), ...suggest(tools, slug).map((t) => t.slug)])];
244
+ const searched = searchTools(tools, slug, { limit: 3, includeAll: false });
245
+
246
+ const parts = [`No tool named "${slug}" is published.`];
247
+ if (searched.length) {
248
+ parts.push(`Closest matches: ${searched.map((t) => t.slug).join(", ")}.`);
249
+ } else if (near.length) {
250
+ parts.push(`Did you mean: ${near.join(", ")}?`);
251
+ }
252
+ parts.push(`Call {"command":"search","query":"<what you need>"} to find a tool by intent.`);
253
+
254
+ return new NexusBloomError(parts.join(" "), ErrorCode.NOT_FOUND, {
255
+ details: {
256
+ suggestions: searched.length ? searched.map((t) => t.slug) : near.slice(0, 3),
257
+ catalogueSize: tools.length,
258
+ },
259
+ });
260
+ }
261
+
262
+ /** Add suggestions to a not-found error that came from the API. */
263
+ function enrichNotFound(err, slug, tools) {
264
+ if (err?.code !== ErrorCode.NOT_FOUND) return err;
265
+ const suggestions = tools.length ? suggest(tools, slug).map((t) => t.slug) : [];
266
+ const hint = suggestions.length ? ` Did you mean: ${suggestions.join(", ")}?` : "";
267
+ return new NexusBloomError(`${err.message}${hint}`, ErrorCode.NOT_FOUND, {
268
+ status: err.status,
269
+ details: { suggestions },
270
+ });
271
+ }
272
+
273
+ /**
274
+ * The advertised tool list.
275
+ *
276
+ * One entry per tool, named by its slug so an agent can call it directly. The
277
+ * meta-tool is advertised too — it is how an agent recovers when a direct call
278
+ * is not what it wanted.
279
+ */
280
+ export function buildToolList(cache) {
281
+ return async function listTools() {
282
+ // A catalogue failure must not fail the whole request. If the API is
283
+ // unreachable, a host that receives a JSON-RPC error considers the *server*
284
+ // broken and may refuse to retry; returning the meta-tool alone lets the
285
+ // agent discover that the API is down and report it, which is recoverable.
286
+ let tools = [];
287
+ try {
288
+ tools = await cache.tools();
289
+ } catch {
290
+ tools = [];
291
+ }
292
+
293
+ const entries = tools.map((t) => ({
294
+ name: t.slug,
295
+ description: buildToolDescription(t),
296
+ inputSchema: t.input_schema,
297
+ }));
298
+
299
+ entries.push({
300
+ name: "nexusbloom",
301
+ description:
302
+ "Discover and inspect NexusBloom tools without knowing their names.\n\n" +
303
+ "Use this instead of guessing a slug:\n" +
304
+ '- {"command":"search","query":"validate environment file"} — rank tools by intent\n' +
305
+ '- {"command":"list"} — every published tool, one line each\n' +
306
+ '- {"command":"schema","slug":"<slug>"} — exact parameters plus a ready-to-send example\n\n' +
307
+ "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.",
309
+ inputSchema: {
310
+ type: "object",
311
+ properties: {
312
+ command: {
313
+ type: "string",
314
+ enum: META_COMMANDS,
315
+ description:
316
+ "list: all tools. search: find by intent. schema: a tool's parameters. run: execute a tool.",
317
+ },
318
+ slug: {
319
+ type: "string",
320
+ description: "Tool slug. Required for 'schema' and 'run'.",
321
+ },
322
+ query: {
323
+ type: "string",
324
+ description:
325
+ "What you want to do, in plain language. Used by 'search'. " +
326
+ 'Example: "check a cron expression is valid".',
327
+ },
328
+ params: {
329
+ type: "object",
330
+ description: "Input parameters for 'run', matching the tool's input schema.",
331
+ },
332
+ limit: {
333
+ type: "integer",
334
+ description: "Maximum results for 'search'. Default 10.",
335
+ minimum: 1,
336
+ maximum: 50,
337
+ },
338
+ refresh: {
339
+ type: "boolean",
340
+ description: "Bypass the cached tool list for 'list'. Default false.",
341
+ },
342
+ },
343
+ required: ["command"],
344
+ additionalProperties: false,
345
+ },
346
+ });
347
+
348
+ return { tools: entries };
349
+ };
350
+ }
351
+
352
+ /**
353
+ * One line that lets an agent decide *whether* to call a tool.
354
+ *
355
+ * Includes the required-argument count because "how hard is this to call" is
356
+ * part of the decision, and a tool needing five arguments is a different
357
+ * commitment from one needing none.
358
+ */
359
+ export function buildToolDescription(tool) {
360
+ // Built by appending to one string rather than joining an array: the leading
361
+ // clause already carries its own separator, so a join would leave a double
362
+ // space before the description.
363
+ let desc = tool.name ? `${tool.name}: ` : "";
364
+ desc += tool.short_description || "No description";
365
+ if (tool.category && tool.category !== "uncategorized") desc += ` [${tool.category}]`;
366
+
367
+ const props = Object.keys(tool.input_schema?.properties || {});
368
+ const required = (tool.input_schema?.required || []).length;
369
+
370
+ if (tool.hasSchema === false) desc += " (schema not published — call schema before running)";
371
+ else if (required === 0 && props.length === 0) desc += " (takes no arguments)";
372
+ else if (required === 0) desc += ` (all ${props.length} params optional)`;
373
+ else desc += ` (${required} required of ${props.length} params)`;
374
+
375
+ if (tool.tags?.length) desc += ` Tags: ${tool.tags.slice(0, 4).join(", ")}.`;
376
+ return desc;
377
+ }
378
+
379
+ /**
380
+ * Probe the API and report what was reachable.
381
+ *
382
+ * Never throws and never blocks startup on failure: an unreachable API degrades
383
+ * the server to a clear error on each request rather than preventing it from
384
+ * starting at all, which is what v1's unguarded probe did.
385
+ */
386
+ export async function checkConnectivity(client, roots, config, deps = {}) {
387
+ const fetchImpl = deps.fetchImpl || client.fetchImpl;
388
+ const timeoutMs = Math.min(config.timeoutMs, deps.timeoutMs ?? 5_000);
389
+ const errors = [];
390
+ let connected = null;
391
+
392
+ for (const root of roots) {
393
+ const controller = new AbortController();
394
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
395
+ try {
396
+ const res = await fetchImpl(`${root}/tools`, {
397
+ method: "GET",
398
+ headers: { Accept: "application/json" },
399
+ signal: controller.signal,
400
+ });
401
+ // 429 means the endpoint exists and is rate-limiting us, which is a
402
+ // successful connectivity check.
403
+ if (res.ok || res.status === 429) {
404
+ connected = root;
405
+ break;
406
+ }
407
+ errors.push(`${root}/tools → HTTP ${res.status}`);
408
+ } catch (err) {
409
+ errors.push(`${root}/tools → ${err?.message || "unreachable"}`);
410
+ } finally {
411
+ clearTimeout(timer);
412
+ }
413
+ }
414
+
415
+ return {
416
+ connected,
417
+ roots,
418
+ apiBase: config.apiBase,
419
+ anonymous: config.anonymous,
420
+ errors,
421
+ report: renderConnectivityReport({
422
+ connected,
423
+ roots,
424
+ apiBase: config.apiBase,
425
+ anonymous: config.anonymous,
426
+ errors,
427
+ }),
428
+ };
429
+ }
430
+
431
+ export { ApiClient, loadConfig, ManifestCache, normaliseTool, asNexusBloomError, describeParameters };