@nexusbloom/mcp-server 1.0.2 โ†’ 2.0.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.
@@ -0,0 +1,247 @@
1
+ /**
2
+ * Manifest normalisation and caching.
3
+ *
4
+ * The API's tool rows are not identical to the manifest contract: `description`
5
+ * and `short_description` are the same value, `tags` may arrive as a nested
6
+ * array from Postgres, and community tools have a `category` that built-ins do
7
+ * not. Everything downstream should be able to assume one shape.
8
+ */
9
+
10
+ import { NexusBloomError, ErrorCode } from "./errors.js";
11
+
12
+ /** Empty but valid โ€” a schema saying "takes nothing" is not the same as null. */
13
+ export function emptyInputSchema() {
14
+ return { type: "object", properties: {}, required: [] };
15
+ }
16
+
17
+ /**
18
+ * Flatten and clean a Postgres text[] column.
19
+ *
20
+ * Postgres returns `{{a,b},{c}}` for a `text[][]` column, so a naive read gives
21
+ * nested arrays that stringify as "a,b". One level of flattening covers every
22
+ * shape seen in the data.
23
+ */
24
+ export function normaliseTags(tags) {
25
+ if (!tags) return [];
26
+ const flat = (Array.isArray(tags) ? tags.flat(Infinity) : [tags])
27
+ .filter((t) => typeof t === "string")
28
+ .map((t) => t.trim().toLowerCase())
29
+ .filter(Boolean);
30
+ return [...new Set(flat)];
31
+ }
32
+
33
+ /**
34
+ * Coerce one API row into the canonical manifest shape.
35
+ *
36
+ * Returns null for anything without a usable slug: a tool that cannot be named
37
+ * cannot be called, and letting it through would surface as a tool the agent
38
+ * sees in the list but can never invoke.
39
+ */
40
+ export function normaliseTool(raw) {
41
+ if (!raw || typeof raw !== "object") return null;
42
+ const slug = typeof raw.slug === "string" ? raw.slug.trim() : "";
43
+ if (!slug) return null;
44
+
45
+ const description = (raw.short_description || raw.description || "").trim();
46
+
47
+ return {
48
+ slug,
49
+ name: (raw.name || slug).trim(),
50
+ short_description: description,
51
+ // Kept alongside because MCP's ListTools description is the agent's only
52
+ // disambiguator when two tools have similar names.
53
+ description,
54
+ category: raw.category || raw.tool_type || "uncategorized",
55
+ runtime: raw.runtime || "both",
56
+ version: raw.version || "1.0.0",
57
+ icon: raw.icon || "๐Ÿ”ง",
58
+ type: ["builtin", "community", "local"].includes(raw.type) ? raw.type : "tool",
59
+ price_type: raw.price_type || "free",
60
+ pricing: raw.pricing || null,
61
+ timeout_ms: typeof raw.timeout_ms === "number" ? raw.timeout_ms : null,
62
+ tags: normaliseTags(raw.tags),
63
+ input_schema: normaliseSchema(raw.input_schema),
64
+ output_schema: normaliseSchema(raw.output_schema, { allowNull: true }),
65
+ // Distinguishes "published with no schema" from "genuinely takes no
66
+ // parameters". Normalisation erases the difference โ€” both become an empty
67
+ // object โ€” but an agent needs to know which one it is, because the first
68
+ // means call `schema` before trying and the second means just call it.
69
+ hasSchema: isSchemaPresent(raw.input_schema),
70
+ };
71
+ }
72
+
73
+ /**
74
+ * Was a schema actually declared by the publisher?
75
+ *
76
+ * Distinct from "does it have properties": a tool may legitimately declare
77
+ * `{type:"object", properties:{}, required:[]}` to mean it takes nothing, which
78
+ * is different from having no `input_schema` at all. Only the latter is unknown.
79
+ */
80
+ function isSchemaPresent(schema) {
81
+ return Boolean(schema) && typeof schema === "object" && !Array.isArray(schema);
82
+ }
83
+
84
+ /**
85
+ * Coerce a stored schema into one a strict MCP client will accept.
86
+ *
87
+ * Schemas in the database predate the current contract, so some are missing
88
+ * `type: "object"` or carry a `required` that is not an array. Forwarding those
89
+ * verbatim makes the host reject the whole tool list.
90
+ */
91
+ export function normaliseSchema(schema, { allowNull = false } = {}) {
92
+ if (schema === null || schema === undefined) {
93
+ return allowNull ? null : emptyInputSchema();
94
+ }
95
+ if (typeof schema !== "object" || Array.isArray(schema)) {
96
+ return allowNull ? null : emptyInputSchema();
97
+ }
98
+ if (schema.type !== "object") {
99
+ // A schema missing `type: "object"` is rejected by strict MCP clients, so
100
+ // normalise rather than forward it.
101
+ return {
102
+ ...schema,
103
+ type: "object",
104
+ properties: schema.properties && typeof schema.properties === "object" ? schema.properties : {},
105
+ required: Array.isArray(schema.required) ? schema.required : [],
106
+ };
107
+ }
108
+ return {
109
+ ...schema,
110
+ properties: schema.properties && typeof schema.properties === "object" ? schema.properties : {},
111
+ required: Array.isArray(schema.required) ? schema.required : [],
112
+ };
113
+ }
114
+
115
+ /**
116
+ * Unwrap the several shapes an API list endpoint may return.
117
+ *
118
+ * The response has been `{tools:[โ€ฆ]}`, `{data:{tools:[โ€ฆ]}}` and a bare array at
119
+ * different points in its history; clients pinning an old version see all three
120
+ * depending on deployment.
121
+ */
122
+ export function unwrapToolsList(body) {
123
+ const candidates = [
124
+ body,
125
+ body?.tools,
126
+ body?.data,
127
+ body?.data?.tools,
128
+ body?.results,
129
+ ];
130
+ for (const c of candidates) {
131
+ if (Array.isArray(c)) return c.filter(Boolean);
132
+ }
133
+ return [];
134
+ }
135
+
136
+ /**
137
+ * Unwrap a manifest out of `{success,data:{manifest}}`, `{manifest}` or bare.
138
+ *
139
+ * Returns null for an object that carries no recognisable manifest. Without the
140
+ * last check, a `{success:true,data:{}}` response unwraps to `{}` โ€” truthy, so
141
+ * the caller would fabricate a tool from an empty object and report a schema
142
+ * that the publisher never declared.
143
+ */
144
+ export function unwrapManifest(body) {
145
+ const candidates = [body?.data?.manifest, body?.manifest, body?.data, body];
146
+ for (const m of candidates) {
147
+ if (m && typeof m === "object" && !Array.isArray(m) && hasManifestFields(m)) return m;
148
+ }
149
+ return null;
150
+ }
151
+
152
+ /** Any of the keys that make an object recognisable as a tool manifest. */
153
+ function hasManifestFields(o) {
154
+ return (
155
+ "slug" in o ||
156
+ "input_schema" in o ||
157
+ "output_schema" in o ||
158
+ "short_description" in o ||
159
+ "tool_type" in o
160
+ );
161
+ }
162
+
163
+ /**
164
+ * TTL cache for the tool list.
165
+ *
166
+ * The list is re-fetched on every TTL expiry rather than on a timer, so an idle
167
+ * server makes no requests at all. A failed refresh keeps serving the stale list
168
+ * rather than emptying it โ€” an agent discovering zero tools because one blip
169
+ * occurred is worse than an agent seeing slightly old metadata.
170
+ */
171
+ export class ManifestCache {
172
+ constructor(client, { ttlMs, now = () => Date.now() } = {}) {
173
+ this.client = client;
174
+ this.ttlMs = ttlMs;
175
+ this.now = now;
176
+ this._tools = null;
177
+ this._cachedAt = 0;
178
+ this._inflight = null;
179
+ }
180
+
181
+ /** True when a cached list exists and has not expired. */
182
+ isFresh() {
183
+ return this._tools !== null && this.now() - this._cachedAt < this.ttlMs;
184
+ }
185
+
186
+ /**
187
+ * Return the tool list, refreshing if stale.
188
+ *
189
+ * Concurrent callers share one in-flight request; without that, N agents
190
+ * starting at once produce N identical catalogue fetches.
191
+ */
192
+ async tools({ force = false } = {}) {
193
+ if (!force && this.isFresh()) return this._tools;
194
+ if (this._inflight) return this._inflight;
195
+
196
+ this._inflight = this._refresh().finally(() => {
197
+ this._inflight = null;
198
+ });
199
+ return this._inflight;
200
+ }
201
+
202
+ async _refresh() {
203
+ try {
204
+ const body = await this.client.request("/tools");
205
+ const tools = unwrapToolsList(body).map(normaliseTool).filter(Boolean);
206
+
207
+ // An empty list from a live API is almost always a failure upstream, and
208
+ // overwriting a good cache with it would strand every agent. Keep the old
209
+ // value and let the caller see an empty result with a clear reason.
210
+ if (tools.length === 0 && this._tools) {
211
+ this._cachedAt = this.now();
212
+ return this._tools;
213
+ }
214
+
215
+ this._tools = tools;
216
+ this._cachedAt = this.now();
217
+ return tools;
218
+ } catch (err) {
219
+ if (this._tools) {
220
+ this._cachedAt = this.now();
221
+ return this._tools;
222
+ }
223
+ throw err instanceof NexusBloomError
224
+ ? err
225
+ : new NexusBloomError(err.message, ErrorCode.API_ERROR);
226
+ }
227
+ }
228
+
229
+ /** Fetch one tool's full manifest, bypassing the list cache. */
230
+ async manifest(slug) {
231
+ const body = await this.client.request(`/run/${encodeURIComponent(slug)}`);
232
+ const manifest = unwrapManifest(body);
233
+ if (!manifest) {
234
+ throw new NexusBloomError(
235
+ `Tool "${slug}" returned no manifest from the API.`,
236
+ ErrorCode.NOT_FOUND,
237
+ );
238
+ }
239
+ return normaliseTool(manifest) || normaliseTool({ slug, ...manifest });
240
+ }
241
+
242
+ /** Drop cached state; used by tests and the `refresh` meta-command. */
243
+ clear() {
244
+ this._tools = null;
245
+ this._cachedAt = 0;
246
+ }
247
+ }
package/src/render.js ADDED
@@ -0,0 +1,348 @@
1
+ /**
2
+ * Response rendering โ€” the part of the server an agent actually reads.
3
+ *
4
+ * Design rules, in priority order:
5
+ *
6
+ * 1. **A tool that cannot be called is a defect.** Every schema response states
7
+ * the exact arguments to pass, in a copy-pasteable form. An agent should
8
+ * never have to assemble a call by reading a JSON Schema.
9
+ * 2. **Errors carry a next action.** "Tool not found" is a dead end; "no tool
10
+ * named `env-validatr`. Did you mean: env-validator" is recoverable.
11
+ * 3. **Output first.** The result leads, with framing kept to two lines. Agents
12
+ * parse the payload, not prose.
13
+ * 4. **Machine-readable is always attached.** The text block is optimised for
14
+ * reading; a structured block carries the same data for extraction.
15
+ */
16
+
17
+ import { describeParameters, groupByCategory } from "./discovery.js";
18
+ import { ErrorCode } from "./errors.js";
19
+ import { normaliseTool } from "./manifests.js";
20
+
21
+ /**
22
+ * Build an MCP response.
23
+ *
24
+ * Always a text block, because that is what a model reads. A resource block is
25
+ * appended when there is structured data, so an agent that prefers extraction
26
+ * over prose gets an exact payload instead of parsing markdown.
27
+ */
28
+ export function respond(payload) {
29
+ const content = [{ type: "text", text: payload.text }];
30
+ if (payload.data !== undefined) {
31
+ content.push({
32
+ type: "resource",
33
+ resource: {
34
+ uri: "data:application/json,",
35
+ mimeType: "application/json",
36
+ text: safeStringify(payload.data),
37
+ },
38
+ });
39
+ }
40
+ return { content };
41
+ }
42
+
43
+ /** Error response. Never throws โ€” the agent gets a value it can branch on. */
44
+ export function respondError(err) {
45
+ const message = err?.message || String(err) || "Unknown error.";
46
+ return {
47
+ content: [{ type: "text", text: message }],
48
+ isError: true,
49
+ structuredContent:
50
+ typeof err?.toJSON === "function"
51
+ ? err.toJSON()
52
+ : { success: false, error: message, code: err?.code || ErrorCode.API_ERROR, retryable: false },
53
+ };
54
+ }
55
+
56
+ /**
57
+ * How many required parameters a tool has, or -1 when it has no schema at all.
58
+ *
59
+ * The three-way result matters: "no parameters" and "parameters I can't see" are
60
+ * different situations for an agent, and conflating them would present an
61
+ * unknown schema as a tool that takes nothing.
62
+ */
63
+ export function requiredCount(tool) {
64
+ const schema = tool?.input_schema;
65
+ const required = Array.isArray(schema?.required) ? schema.required.length : 0;
66
+ if (required > 0) return required;
67
+
68
+ // No required fields, but the schema may simply be absent โ€” normaliseTool
69
+ // records which, so a tool with no published schema is not mislabelled as a
70
+ // tool that takes nothing.
71
+ return tool?.hasSchema === false ? -1 : 0;
72
+ }
73
+
74
+ /**
75
+ * The catalogue.
76
+ *
77
+ * With 30+ tools a flat dump is unusable in a context window, so this lists a
78
+ * compact one-line-per-tool summary and points at `search` for anything narrower.
79
+ */
80
+ export function renderToolList(tools, { total } = {}) {
81
+ const count = total ?? tools.length;
82
+ if (count === 0) {
83
+ return {
84
+ text:
85
+ `# NexusBloom Tools\n\nNo tools are currently published.\n\n` +
86
+ `This usually means the API is unreachable or the catalogue is empty. ` +
87
+ `Check that NEXUSBLOOM_API_URL points at a working deployment.`,
88
+ data: { tools: [], count: 0 },
89
+ };
90
+ }
91
+
92
+ const lines = tools.map((t) => {
93
+ const req = requiredCount(t); const input =
94
+ req > 0 ? `${req} required` : req === 0 ? "no required" : "schema unknown";
95
+ const tags = t.tags?.length ? ` ยท ${t.tags.slice(0, 3).join(", ")}` : "";
96
+ return `- **${t.name}** (\`${t.slug}\`) โ€” ${t.short_description || "No description"} ยท ${input}${tags}`;
97
+ });
98
+
99
+ const text =
100
+ `# NexusBloom Tools\n\n` +
101
+ `${count} tool${count === 1 ? "" : "s"} available.\n\n${lines.join("\n")}\n\n` +
102
+ `---\n` +
103
+ `Next steps:\n` +
104
+ `โ€ข Need something specific? Call \`nexusbloom\` with \`{"command":"search","query":"what you need"}\` โ€” it ranks by relevance.\n` +
105
+ `โ€ข Before running a tool, call \`{"command":"schema","slug":"<slug>"}\` to get its exact parameters.\n` +
106
+ `โ€ข To run: call the tool by its slug directly, e.g. \`{"name":"${tools[0].slug}","arguments":{โ€ฆ}}\`.`;
107
+
108
+ return { text, data: { tools, count } };
109
+ }
110
+
111
+ /**
112
+ * Search results.
113
+ *
114
+ * Explains the query when nothing matched โ€” an empty list with no reason is
115
+ * indistinguishable from a broken server.
116
+ */
117
+ export function renderSearchResults(query, tools, catalogueSize, categories = []) {
118
+ if (tools.length === 0) {
119
+ const categoryHint = categories.length
120
+ ? `\nโ€ข A category name: ${categories.slice(0, 8).join(", ")}`
121
+ : "";
122
+ return {
123
+ text:
124
+ `# No tools match "${query}"\n\n` +
125
+ `${catalogueSize} tools are published, none matching that query. Try:\n` +
126
+ `โ€ข Fewer or more general words โ€” e.g. "validate" rather than "validate my dotenv file in production"` +
127
+ `${categoryHint}\n` +
128
+ `โ€ข \`{"command":"list"}\` to see everything.`,
129
+ data: { query, tools: [], count: 0 },
130
+ };
131
+ }
132
+
133
+ const lines = tools.map(
134
+ (t) => `- **${t.name}** (\`${t.slug}\`) โ€” ${t.short_description || "No description"}`,
135
+ );
136
+
137
+ const text =
138
+ `# Tools matching "${query}"\n\n` +
139
+ `${tools.length} of ${catalogueSize} tools.\n\n${lines.join("\n")}\n\n` +
140
+ `Call \`{"command":"schema","slug":"<slug>"}\` for parameters, or invoke the tool directly by slug.`;
141
+
142
+ return { text, data: { query, tools, count: tools.length } };
143
+ }
144
+
145
+ /**
146
+ * A tool's full contract.
147
+ *
148
+ * The example invocation at the end is the load-bearing part: it is valid JSON
149
+ * containing every required parameter with a placeholder, so an agent can call
150
+ * it back by changing only the values.
151
+ */
152
+ export function renderSchema(rawSchema, tools = []) {
153
+ // Defensive normalisation: a caller may hand over a bare object rather than a
154
+ // normalised tool, and a missing name must read as a fallback, never as the
155
+ // literal string "undefined".
156
+ const schema = normaliseTool({ slug: "tool", ...rawSchema }) || normaliseTool({ slug: "tool" });
157
+
158
+ const params = describeParameters(schema.input_schema);
159
+ const required = params.filter((p) => p.required);
160
+
161
+ const paramLines = params.length
162
+ ? params
163
+ .map((p) => {
164
+ const detail = p.description ? ` โ€” ${p.description}` : "";
165
+ return `- \`${p.name}\` (${p.summary || "any"})${detail}`;
166
+ })
167
+ .join("\n")
168
+ : "_This tool takes no parameters._";
169
+
170
+ const exampleArgs = buildExampleArgs(schema);
171
+
172
+ let example;
173
+ if (params.length === 0) {
174
+ example = `Call it with no arguments:\n\`\`\`json\n{"name": "${schema.slug}", "arguments": {}}\n\`\`\``;
175
+ } else if (required.length === 0) {
176
+ example =
177
+ `Only optional parameters exist, so \`{}\` is valid:\n` +
178
+ `\`\`\`json\n{"name": "${schema.slug}", "arguments": {}}\n\`\`\``;
179
+ } else {
180
+ example =
181
+ `Replace the placeholder values, then call:\n` +
182
+ `\`\`\`json\n{"name": "${schema.slug}", "arguments": ${JSON.stringify(exampleArgs, null, 2)}}\n\`\`\``;
183
+ }
184
+
185
+ const outputNote = schema.output_schema
186
+ ? `\n**Returns:** shaped per \`output_schema\` (${describeOutputShape(schema.output_schema)}).`
187
+ : "";
188
+
189
+ const priceNote =
190
+ schema.price_type && schema.price_type !== "free"
191
+ ? `\n**Pricing:** ${schema.price_type} โ€” this tool may consume API quota.`
192
+ : "";
193
+
194
+ const related = suggestRelated(tools, schema);
195
+
196
+ const meta = [
197
+ `**Slug:** \`${schema.slug}\``,
198
+ `**Category:** ${schema.category}`,
199
+ `**Runtime:** ${schema.runtime}`,
200
+ schema.short_description ? `**Description:** ${schema.short_description}` : null,
201
+ schema.tags?.length ? `**Tags:** ${schema.tags.join(", ")}` : null,
202
+ ].filter(Boolean);
203
+
204
+ const text =
205
+ `# ${schema.icon} ${schema.name}\n\n` +
206
+ `${meta.join("\n")}\n${outputNote}${priceNote}\n` +
207
+ `\n## Parameters\n\n${paramLines}\n` +
208
+ (required.length ? `\nRequired: ${required.map((r) => `\`${r.name}\``).join(", ")}\n` : "") +
209
+ `\n## How to call it\n\n${example}` +
210
+ (related ? `\n\n## Related\n\n${related}` : "");
211
+
212
+ return { text, data: schema };
213
+ }
214
+
215
+ /** Describe an output schema's top-level shape in one clause. */
216
+ function describeOutputShape(schema) {
217
+ const props = schema?.properties || {};
218
+ const keys = Object.keys(props);
219
+ if (keys.length === 0) return "no documented fields";
220
+ if (keys.length <= 4) return keys.map((k) => `\`${k}\``).join(", ");
221
+ return `${keys.slice(0, 4).map((k) => `\`${k}\``).join(", ")} +${keys.length - 4} more`;
222
+ }
223
+
224
+ /** Other tools sharing tags or category, so an agent can compose. */
225
+ function suggestRelated(tools, schema) {
226
+ const tags = new Set(schema.tags || []);
227
+ const others = tools.filter((t) => t.slug !== schema.slug);
228
+ const byTag = others.filter((t) => (t.tags || []).some((tag) => tags.has(tag)));
229
+ const pool = (byTag.length ? byTag : others.filter((t) => t.category === schema.category))
230
+ .slice(0, 4);
231
+ if (!pool.length) return null;
232
+ return pool.map((t) => `- \`${t.slug}\` โ€” ${t.short_description || t.name}`).join("\n");
233
+ }
234
+
235
+ /**
236
+ * Build a valid example argument object from a schema.
237
+ *
238
+ * Required parameters get a placeholder derived from their declared type โ€” a
239
+ * string gets "string", a number gets 0, and so on โ€” because a placeholder the
240
+ * agent can recognise and replace is worth more than one it has to guess the
241
+ * type of. Returns null when the tool requires nothing.
242
+ */
243
+ export function buildExampleArgs(schema) {
244
+ const inputSchema = schema?.input_schema;
245
+ const properties = inputSchema?.properties || {};
246
+ const required = inputSchema?.required || [];
247
+ if (required.length === 0) return {};
248
+
249
+ const args = {};
250
+ for (const key of required) {
251
+ const prop = properties[key] || {};
252
+ args[key] = placeholderFor(prop);
253
+ }
254
+ return args;
255
+ }
256
+
257
+ function placeholderFor(prop) {
258
+ if (prop.enum?.length) return prop.enum[0];
259
+ const t = Array.isArray(prop.type) ? prop.type[0] : prop.type;
260
+ switch (t) {
261
+ case "string":
262
+ return prop.format === "date-time" ? "2026-01-01T00:00:00Z" : "string";
263
+ case "integer":
264
+ return 0;
265
+ case "number":
266
+ return 0;
267
+ case "boolean":
268
+ return false;
269
+ case "array":
270
+ return [];
271
+ case "object":
272
+ return {};
273
+ default:
274
+ return null;
275
+ }
276
+ }
277
+
278
+ /**
279
+ * A successful execution result.
280
+ *
281
+ * The payload is fenced as JSON and unindented-by-default is avoided: agents
282
+ * re-read it, and deeply indented JSON costs tokens on every turn. Kept at 2
283
+ * spaces because it is far easier to diff and reason about.
284
+ */
285
+ export function renderResult(slug, data, { durationMs, execution = "remote" } = {}) {
286
+ const json = safeStringify(data);
287
+ const meta = [`Tool: \`${slug}\``, `Execution: ${execution}`];
288
+ if (typeof durationMs === "number") meta.push(`Duration: ${durationMs}ms`);
289
+
290
+ return {
291
+ text: `# Result: ${slug}\n\n${meta.join(" ยท ")}\n\n\`\`\`json\n${json}\n\`\`\``,
292
+ data,
293
+ };
294
+ }
295
+
296
+ /** A failure that still succeeded as a call โ€” a tool returning `{error:โ€ฆ}`. */
297
+ export function renderToolReportedError(slug, data) {
298
+ const message =
299
+ data?.error || data?.message || "The tool reported an error.";
300
+ const code = data?.code ? ` (${data.code})` : "";
301
+ return {
302
+ text:
303
+ `# ${slug} reported an error${code}\n\n${message}\n\n` +
304
+ `This is the tool's own response, not a transport failure. The tool ran; check its parameters and try again.`,
305
+ data,
306
+ };
307
+ }
308
+
309
+ function safeStringify(value) {
310
+ try {
311
+ return JSON.stringify(value, null, 2);
312
+ } catch {
313
+ return String(value);
314
+ }
315
+ }
316
+
317
+ /** Startup diagnostics. Always stderr โ€” never part of an MCP response. */
318
+ export function renderConnectivityReport({ connected, roots, apiBase, anonymous, errors }) {
319
+ const lines = [];
320
+ if (connected) {
321
+ lines.push(`NexusBloom MCP server connected to ${connected}`);
322
+ } else {
323
+ lines.push("WARNING: could not reach the NexusBloom API. Starting in degraded mode.");
324
+ lines.push("Tool discovery and execution will fail until this is fixed.");
325
+ lines.push("");
326
+ lines.push("Checked:");
327
+ for (const r of roots) lines.push(` - ${r}/tools`);
328
+ if (errors?.length) {
329
+ lines.push("");
330
+ lines.push("Errors:");
331
+ for (const e of errors) lines.push(` - ${e}`);
332
+ }
333
+ lines.push("");
334
+ lines.push(
335
+ `To fix: set NEXUSBLOOM_API_URL to a reachable deployment ` +
336
+ `(currently "${apiBase}"), or check the API is deployed.`,
337
+ );
338
+ }
339
+ if (anonymous) {
340
+ lines.push("");
341
+ lines.push("No NEXUSBLOOM_API_KEY set โ€” running anonymously under the free-tier rate limit.");
342
+ lines.push("Set NEXUSBLOOM_API_KEY for authenticated execution and higher limits.");
343
+ }
344
+ lines.push(`API base: ${apiBase}`);
345
+ return lines.join("\n");
346
+ }
347
+
348
+ export { ErrorCode };
package/src/server.js ADDED
@@ -0,0 +1,83 @@
1
+ /**
2
+ * MCP SDK adapter.
3
+ *
4
+ * Deliberately thin: it translates JSON-RPC into calls on the handler set and
5
+ * nothing else. All behaviour lives in handlers.js where it can be tested
6
+ * without a transport, so this file is the only place the SDK is imported.
7
+ */
8
+
9
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
10
+ import {
11
+ CallToolRequestSchema,
12
+ ListToolsRequestSchema,
13
+ } from "@modelcontextprotocol/sdk/types.js";
14
+
15
+ import { buildToolList, createHandlers } from "./handlers.js";
16
+
17
+ export const SERVER_NAME = "nexusbloom-mcp";
18
+ export const SERVER_VERSION = "2.0.0";
19
+
20
+ /**
21
+ * Wire a handler set onto an MCP Server.
22
+ *
23
+ * @param {object} deps { client, cache, config }
24
+ * @param {object} [deps.handlers] Prebuilt handler set. Supplying one is the
25
+ * seam that lets the error-containment path be tested directly.
26
+ * @returns {{server: Server, handlers: object}}
27
+ */
28
+ export function createServer({ client, cache, config, handlers: provided }) {
29
+ const handlers = provided ?? createHandlers({ client, cache, config });
30
+ const listTools = provided ? buildToolList(cache) : handlers.listTools;
31
+
32
+ const server = new Server(
33
+ { name: SERVER_NAME, version: SERVER_VERSION },
34
+ { capabilities: { tools: {} } },
35
+ );
36
+
37
+ server.setRequestHandler(ListToolsRequestSchema, async () => listTools());
38
+
39
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
40
+ const { name: toolName, arguments: args } = request.params ?? {};
41
+
42
+ // No handler may throw: an exception here becomes a JSON-RPC protocol error,
43
+ // which a model cannot recover from, whereas an isError result is a normal
44
+ // tool outcome it can reason about.
45
+ try {
46
+ if (!toolName) {
47
+ return {
48
+ content: [{ type: "text", text: "No tool name was provided in the request." }],
49
+ isError: true,
50
+ };
51
+ }
52
+ return toolName === "nexusbloom"
53
+ ? await handlers.meta(args ?? {})
54
+ : await handlers.callDirect(toolName, args ?? {});
55
+ } catch (err) {
56
+ return handlers.handleError ? handlers.handleError(err) : fallbackError(err);
57
+ }
58
+ });
59
+
60
+ return { server, handlers };
61
+ }
62
+
63
+ /**
64
+ * Last-resort error shape.
65
+ *
66
+ * `createHandlers` always supplies `handleError`, so this only runs if a future
67
+ * caller wires up a partial handler set. It exists because an uncaught throw at
68
+ * this boundary becomes a JSON-RPC protocol error, which a model cannot recover
69
+ * from โ€” strictly worse than a value it can read.
70
+ */
71
+ export function fallbackError(err) {
72
+ const message = err?.message || "Unknown error.";
73
+ return {
74
+ content: [{ type: "text", text: message }],
75
+ isError: true,
76
+ structuredContent: {
77
+ success: false,
78
+ error: message,
79
+ code: err?.code || "API_ERROR",
80
+ retryable: err?.retryable ?? false,
81
+ },
82
+ };
83
+ }