@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.
@@ -0,0 +1,185 @@
1
+ /**
2
+ * Tier-1 preview inference.
3
+ *
4
+ * Two properties matter more than the individual detectors:
5
+ * 1. it never looks at the slug, so tools that do not exist yet still render;
6
+ * 2. it fails closed — anything unsafe or unrecognised returns null and the
7
+ * caller emits its normal text block.
8
+ */
9
+
10
+ import test from "node:test";
11
+ import assert from "node:assert/strict";
12
+
13
+ import {
14
+ inferPreview,
15
+ previewResult,
16
+ isSafeSvg,
17
+ escapeXml,
18
+ } from "./preview.js";
19
+
20
+ // ─── colours ────────────────────────────────────────────────────────────────
21
+
22
+ test("renders an array of hex colours as swatches", () => {
23
+ const out = inferPreview(["#0b1a31", "#16305c", "#204787"], { slug: "x" });
24
+ assert.equal(out.kind, "colors");
25
+ const svg = out.block.resource.text;
26
+ assert.match(svg, /#0b1a31/);
27
+ assert.match(svg, /#204787/);
28
+ assert.match(svg, /<text/);
29
+ });
30
+
31
+ test("renders 3-digit hex", () => {
32
+ const out = inferPreview(["#fff", "#000"], { slug: "x" });
33
+ assert.equal(out.kind, "colors");
34
+ });
35
+
36
+ test("renders colours pulled from a colour-keyed object", () => {
37
+ const out = inferPreview({ primary: "#3b82f6", accent: "#ff7a59" }, { slug: "x" });
38
+ assert.equal(out.kind, "colors");
39
+ assert.match(out.block.resource.text, /#3b82f6/);
40
+ });
41
+
42
+ test("ignores an array that is not all hex", () => {
43
+ assert.equal(inferPreview(["#fff", "not-a-colour"]), null);
44
+ });
45
+
46
+ test("ignores an oversized swatch list", () => {
47
+ assert.equal(inferPreview(Array.from({ length: 40 }, () => "#fff")), null);
48
+ });
49
+
50
+ // ─── gradients ──────────────────────────────────────────────────────────────
51
+
52
+ test("renders a linear gradient from real stops", () => {
53
+ const out = inferPreview("linear-gradient(90deg, #3b82f6, #ff7a59)");
54
+ assert.equal(out.kind, "gradient");
55
+ const svg = out.block.resource.text;
56
+ assert.match(svg, /<linearGradient/);
57
+ assert.match(svg, /#3b82f6/);
58
+ assert.match(svg, /#ff7a59/);
59
+ });
60
+
61
+ test("detects a gradient inside a full CSS declaration", () => {
62
+ // What css-gradient-generator actually returns.
63
+ const out = inferPreview("background: linear-gradient(90deg, #3b82f6, #ff7a59);");
64
+ assert.equal(out.kind, "gradient");
65
+ assert.match(out.block.resource.text, /#3b82f6/);
66
+ });
67
+
68
+ test("renders a radial gradient as a radialGradient", () => {
69
+ const out = inferPreview("radial-gradient(circle, #fff, #000)");
70
+ assert.equal(out.kind, "gradient");
71
+ assert.match(out.block.resource.text, /<radialGradient/);
72
+ });
73
+
74
+ test("conic degrades to a stop ramp rather than lying about the shape", () => {
75
+ const out = inferPreview("conic-gradient(#f00, #0f0, #00f)");
76
+ assert.equal(out.kind, "gradient-conic");
77
+ // 3-digit stops are expanded, and drawn as discrete bands not a real conic.
78
+ assert.match(out.block.resource.text, /#ff0000/);
79
+ assert.doesNotMatch(out.block.resource.text, /<radialGradient|<linearGradient/);
80
+ });
81
+
82
+ test("expands 3-digit hex stops", () => {
83
+ const out = inferPreview("linear-gradient(#abc, #def)");
84
+ assert.match(out.block.resource.text, /#aabbcc/);
85
+ });
86
+
87
+ test("a gradient with unresolvable colours yields no preview", () => {
88
+ assert.equal(inferPreview("linear-gradient(var(--a), var(--b))"), null);
89
+ });
90
+
91
+ // ─── svg ────────────────────────────────────────────────────────────────────
92
+
93
+ const safeSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="40" height="20"><rect width="40" height="20" fill="#3b82f6"/></svg>`;
94
+
95
+ test("renders a safe svg verbatim", () => {
96
+ const out = inferPreview(safeSvg, { slug: "x" });
97
+ assert.equal(out.kind, "svg");
98
+ assert.equal(out.block.resource.text, safeSvg);
99
+ });
100
+
101
+ test("pads a square svg so a QR code keeps its quiet zone", () => {
102
+ // 63px at the default 3px module pitch needs >= 4 modules = 12px.
103
+ const qr = `<svg xmlns="http://www.w3.org/2000/svg" width="63" height="63" viewBox="0 0 63 63"><rect width="63" height="63" fill="#fff"/></svg>`;
104
+ const out = inferPreview(qr, { slug: "qr-code-generator" });
105
+ assert.equal(out.kind, "code");
106
+ assert.match(out.block.resource.text, /width="87" height="87"/);
107
+ assert.match(out.block.resource.text, /translate\(12,12\)/);
108
+ assert.match(out.block.resource.text, /fill="#ffffff"/);
109
+ });
110
+
111
+ test("rejects svg carrying a script", () => {
112
+ const bad = `<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>`;
113
+ assert.equal(isSafeSvg(bad), false);
114
+ const out = inferPreview(bad, { slug: "x" });
115
+ assert.equal(out.block, null);
116
+ assert.equal(out.kind, "rejected-svg");
117
+ });
118
+
119
+ test("rejects svg with an event handler", () => {
120
+ assert.equal(isSafeSvg(`<svg onload="x()"></svg>`), false);
121
+ assert.equal(isSafeSvg(`<svg><rect onclick="x()"/></svg>`), false);
122
+ });
123
+
124
+ test("rejects svg reaching out to the network", () => {
125
+ assert.equal(isSafeSvg(`<svg><image href="https://evil.example/x.png"/></svg>`), false);
126
+ assert.equal(isSafeSvg(`<svg><use xlink:href="http://evil/x#y"/></svg>`), false);
127
+ });
128
+
129
+ test("rejects foreignObject and entity tricks", () => {
130
+ assert.equal(isSafeSvg(`<svg><foreignObject><body/></foreignObject></svg>`), false);
131
+ assert.equal(isSafeSvg(`<!DOCTYPE svg [<!ENTITY x SYSTEM "file:///etc/passwd">]><svg/>`), false);
132
+ });
133
+
134
+ test("accepts a plain svg", () => {
135
+ assert.equal(isSafeSvg(safeSvg), true);
136
+ });
137
+
138
+ // ─── data images ────────────────────────────────────────────────────────────
139
+
140
+ test("renders a raster data uri as a real image block", () => {
141
+ const out = inferPreview("data:image/png;base64,iVBORw0KGgo=");
142
+ assert.equal(out.kind, "image");
143
+ assert.equal(out.block.type, "image");
144
+ assert.equal(out.block.mimeType, "image/png");
145
+ assert.equal(out.block.data, "iVBORw0KGgo=");
146
+ });
147
+
148
+ // ─── falling open ───────────────────────────────────────────────────────────
149
+
150
+ test("returns null for values with no visual meaning", () => {
151
+ assert.equal(inferPreview(null), null);
152
+ assert.equal(inferPreview(undefined), null);
153
+ assert.equal(inferPreview(42), null);
154
+ assert.equal(inferPreview("just some text"), null);
155
+ assert.equal(inferPreview(true), null);
156
+ });
157
+
158
+ test("a broken detector never throws", () => {
159
+ const circular = {};
160
+ circular.self = circular;
161
+ assert.doesNotThrow(() => inferPreview(circular));
162
+ });
163
+
164
+ test("finds a preview one level down in a result object", () => {
165
+ const out = previewResult({ palette: ["#0b1a31", "#3574dd"], count: 5 }, { slug: "color-palette" });
166
+ assert.equal(out.kind, "colors");
167
+ });
168
+
169
+ test("returns null when nothing in the result is renderable", () => {
170
+ assert.equal(previewResult({ count: 5, text: "hello" }, { slug: "x" }), null);
171
+ assert.equal(previewResult({ error: "nope" }, { slug: "x" }), null);
172
+ });
173
+
174
+ // ─── escaping ───────────────────────────────────────────────────────────────
175
+
176
+ test("escapes text interpolated into svg", () => {
177
+ assert.equal(escapeXml(`<script>&"'`), "&lt;script&gt;&amp;&quot;&apos;");
178
+ });
179
+
180
+ test("a hex-like label cannot inject markup", () => {
181
+ // Colours are matched by a strict regex before reaching the renderer, so this
182
+ // documents that the escape is a second line of defence rather than the gate.
183
+ const hostile = '" onload="alert(1)';
184
+ assert.equal(inferPreview([hostile]), null);
185
+ });
package/src/render.js ADDED
@@ -0,0 +1,378 @@
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
+ import { previewResult } from "./preview.js";
21
+
22
+ /**
23
+ * Build an MCP response.
24
+ *
25
+ * Always a text block, because that is what a model reads. A resource block is
26
+ * appended when there is structured data, so an agent that prefers extraction
27
+ * over prose gets an exact payload instead of parsing markdown.
28
+ */
29
+ export function respond(payload) {
30
+ // A renderer may supply its own block list (tier-1 preview). When it does we
31
+ // still append the JSON resource so the model always has the raw payload.
32
+ const content = Array.isArray(payload.content) ? [...payload.content] : [{ type: "text", text: payload.text }];
33
+ if (payload.data !== undefined) {
34
+ content.push({
35
+ type: "resource",
36
+ resource: {
37
+ uri: "data:application/json,",
38
+ mimeType: "application/json",
39
+ text: safeStringify(payload.data),
40
+ },
41
+ });
42
+ }
43
+ return { content };
44
+ }
45
+
46
+ /** Error response. Never throws — the agent gets a value it can branch on. */
47
+ export function respondError(err) {
48
+ const message = err?.message || String(err) || "Unknown error.";
49
+ return {
50
+ content: [{ type: "text", text: message }],
51
+ isError: true,
52
+ structuredContent:
53
+ typeof err?.toJSON === "function"
54
+ ? err.toJSON()
55
+ : { success: false, error: message, code: err?.code || ErrorCode.API_ERROR, retryable: false },
56
+ };
57
+ }
58
+
59
+ /**
60
+ * How many required parameters a tool has, or -1 when it has no schema at all.
61
+ *
62
+ * The three-way result matters: "no parameters" and "parameters I can't see" are
63
+ * different situations for an agent, and conflating them would present an
64
+ * unknown schema as a tool that takes nothing.
65
+ */
66
+ export function requiredCount(tool) {
67
+ const schema = tool?.input_schema;
68
+ const required = Array.isArray(schema?.required) ? schema.required.length : 0;
69
+ if (required > 0) return required;
70
+
71
+ // No required fields, but the schema may simply be absent — normaliseTool
72
+ // records which, so a tool with no published schema is not mislabelled as a
73
+ // tool that takes nothing.
74
+ return tool?.hasSchema === false ? -1 : 0;
75
+ }
76
+
77
+ /**
78
+ * The catalogue.
79
+ *
80
+ * With 30+ tools a flat dump is unusable in a context window, so this lists a
81
+ * compact one-line-per-tool summary and points at `search` for anything narrower.
82
+ */
83
+ export function renderToolList(tools, { total } = {}) {
84
+ const count = total ?? tools.length;
85
+ if (count === 0) {
86
+ return {
87
+ text:
88
+ `# NexusBloom Tools\n\nNo tools are currently published.\n\n` +
89
+ `This usually means the API is unreachable or the catalogue is empty. ` +
90
+ `Check that NEXUSBLOOM_API_URL points at a working deployment.`,
91
+ data: { tools: [], count: 0 },
92
+ };
93
+ }
94
+
95
+ const lines = tools.map((t) => {
96
+ const req = requiredCount(t); const input =
97
+ req > 0 ? `${req} required` : req === 0 ? "no required" : "schema unknown";
98
+ const tags = t.tags?.length ? ` · ${t.tags.slice(0, 3).join(", ")}` : "";
99
+ return `- **${t.name}** (\`${t.slug}\`) — ${t.short_description || "No description"} · ${input}${tags}`;
100
+ });
101
+
102
+ const text =
103
+ `# NexusBloom Tools\n\n` +
104
+ `${count} tool${count === 1 ? "" : "s"} available.\n\n${lines.join("\n")}\n\n` +
105
+ `---\n` +
106
+ `Next steps:\n` +
107
+ `• Need something specific? Call \`nexusbloom\` with \`{"command":"search","query":"what you need"}\` — it ranks by relevance.\n` +
108
+ `• Before running a tool, call \`{"command":"schema","slug":"<slug>"}\` to get its exact parameters.\n` +
109
+ `• To run: call the tool by its slug directly, e.g. \`{"name":"${tools[0].slug}","arguments":{…}}\`.`;
110
+
111
+ return { text, data: { tools, count } };
112
+ }
113
+
114
+ /**
115
+ * Search results.
116
+ *
117
+ * Explains the query when nothing matched — an empty list with no reason is
118
+ * indistinguishable from a broken server.
119
+ */
120
+ export function renderSearchResults(query, tools, catalogueSize, categories = []) {
121
+ if (tools.length === 0) {
122
+ // "uncategorized" is a placeholder, not a category an agent can search by,
123
+ // so offering it as a hint wastes one of the three suggestions.
124
+ const useful = [...new Set(categories.filter((c) => c && c !== "uncategorized"))];
125
+ const categoryHint = useful.length ? `\n• A category name: ${useful.slice(0, 8).join(", ")}` : "";
126
+ return {
127
+ text:
128
+ `# No tools match "${query}"\n\n` +
129
+ `${catalogueSize} tools are published, none matching that query. Try:\n` +
130
+ `• Fewer or more general words — e.g. "validate" rather than "validate my dotenv file in production"` +
131
+ `${categoryHint}\n` +
132
+ `• \`{"command":"list"}\` to see everything.`,
133
+ data: { query, tools: [], count: 0 },
134
+ };
135
+ }
136
+
137
+ const lines = tools.map(
138
+ (t) => `- **${t.name}** (\`${t.slug}\`) — ${t.short_description || "No description"}`,
139
+ );
140
+
141
+ const text =
142
+ `# Tools matching "${query}"\n\n` +
143
+ `${tools.length} of ${catalogueSize} tools.\n\n${lines.join("\n")}\n\n` +
144
+ `Call \`{"command":"schema","slug":"<slug>"}\` for parameters, or invoke the tool directly by slug.`;
145
+
146
+ return { text, data: { query, tools, count: tools.length } };
147
+ }
148
+
149
+ /**
150
+ * A tool's full contract.
151
+ *
152
+ * The example invocation at the end is the load-bearing part: it is valid JSON
153
+ * containing every required parameter with a placeholder, so an agent can call
154
+ * it back by changing only the values.
155
+ */
156
+ export function renderSchema(rawSchema, tools = []) {
157
+ // Defensive normalisation: a caller may hand over a bare object rather than a
158
+ // normalised tool, and a missing name must read as a fallback, never as the
159
+ // literal string "undefined".
160
+ const schema = normaliseTool({ slug: "tool", ...rawSchema }) || normaliseTool({ slug: "tool" });
161
+
162
+ const params = describeParameters(schema.input_schema);
163
+ const required = params.filter((p) => p.required);
164
+
165
+ const paramLines = params.length
166
+ ? params
167
+ .map((p) => {
168
+ const detail = p.description ? ` — ${p.description}` : "";
169
+ return `- \`${p.name}\` (${p.summary || "any"})${detail}`;
170
+ })
171
+ .join("\n")
172
+ : "_This tool takes no parameters._";
173
+
174
+ const exampleArgs = buildExampleArgs(schema);
175
+
176
+ let example;
177
+ if (params.length === 0) {
178
+ example = `Call it with no arguments:\n\`\`\`json\n{"name": "${schema.slug}", "arguments": {}}\n\`\`\``;
179
+ } else if (required.length === 0) {
180
+ example =
181
+ `Only optional parameters exist, so \`{}\` is valid:\n` +
182
+ `\`\`\`json\n{"name": "${schema.slug}", "arguments": {}}\n\`\`\``;
183
+ } else {
184
+ example =
185
+ `Replace the placeholder values, then call:\n` +
186
+ `\`\`\`json\n{"name": "${schema.slug}", "arguments": ${JSON.stringify(exampleArgs, null, 2)}}\n\`\`\``;
187
+ }
188
+
189
+ const outputNote = schema.output_schema
190
+ ? `\n**Returns:** shaped per \`output_schema\` (${describeOutputShape(schema.output_schema)}).`
191
+ : "";
192
+
193
+ const priceNote =
194
+ schema.price_type && schema.price_type !== "free"
195
+ ? `\n**Pricing:** ${schema.price_type} — this tool may consume API quota.`
196
+ : "";
197
+
198
+ const related = suggestRelated(tools, schema);
199
+
200
+ const meta = [
201
+ `**Slug:** \`${schema.slug}\``,
202
+ `**Category:** ${schema.category}`,
203
+ `**Runtime:** ${schema.runtime}`,
204
+ schema.short_description ? `**Description:** ${schema.short_description}` : null,
205
+ schema.tags?.length ? `**Tags:** ${schema.tags.join(", ")}` : null,
206
+ ].filter(Boolean);
207
+
208
+ const text =
209
+ `# ${schema.icon} ${schema.name}\n\n` +
210
+ `${meta.join("\n")}\n${outputNote}${priceNote}\n` +
211
+ `\n## Parameters\n\n${paramLines}\n` +
212
+ (required.length ? `\nRequired: ${required.map((r) => `\`${r.name}\``).join(", ")}\n` : "") +
213
+ `\n## How to call it\n\n${example}` +
214
+ (related ? `\n\n## Related\n\n${related}` : "");
215
+
216
+ return { text, data: schema };
217
+ }
218
+
219
+ /** Describe an output schema's top-level shape in one clause. */
220
+ function describeOutputShape(schema) {
221
+ const props = schema?.properties || {};
222
+ const keys = Object.keys(props);
223
+ if (keys.length === 0) return "no documented fields";
224
+ if (keys.length <= 4) return keys.map((k) => `\`${k}\``).join(", ");
225
+ return `${keys.slice(0, 4).map((k) => `\`${k}\``).join(", ")} +${keys.length - 4} more`;
226
+ }
227
+
228
+ /** Other tools sharing tags or category, so an agent can compose. */
229
+ function suggestRelated(tools, schema) {
230
+ const tags = new Set(schema.tags || []);
231
+ const others = tools.filter((t) => t.slug !== schema.slug);
232
+ const byTag = others.filter((t) => (t.tags || []).some((tag) => tags.has(tag)));
233
+ const pool = (byTag.length ? byTag : others.filter((t) => t.category === schema.category))
234
+ .slice(0, 4);
235
+ if (!pool.length) return null;
236
+ return pool.map((t) => `- \`${t.slug}\` — ${t.short_description || t.name}`).join("\n");
237
+ }
238
+
239
+ /**
240
+ * Build a valid example argument object from a schema.
241
+ *
242
+ * Required parameters get a placeholder derived from their declared type — a
243
+ * string gets "string", a number gets 0, and so on — because a placeholder the
244
+ * agent can recognise and replace is worth more than one it has to guess the
245
+ * type of. Returns null when the tool requires nothing.
246
+ */
247
+ export function buildExampleArgs(schema) {
248
+ const inputSchema = schema?.input_schema;
249
+ const properties = inputSchema?.properties || {};
250
+ const required = inputSchema?.required || [];
251
+ if (required.length === 0) return {};
252
+
253
+ const args = {};
254
+ for (const key of required) {
255
+ const prop = properties[key] || {};
256
+ args[key] = placeholderFor(prop);
257
+ }
258
+ return args;
259
+ }
260
+
261
+ function placeholderFor(prop) {
262
+ if (prop.enum?.length) return prop.enum[0];
263
+ const t = Array.isArray(prop.type) ? prop.type[0] : prop.type;
264
+ switch (t) {
265
+ case "string":
266
+ return prop.format === "date-time" ? "2026-01-01T00:00:00Z" : "string";
267
+ case "integer":
268
+ return 0;
269
+ case "number":
270
+ return 0;
271
+ case "boolean":
272
+ return false;
273
+ case "array":
274
+ return [];
275
+ case "object":
276
+ return {};
277
+ default:
278
+ return null;
279
+ }
280
+ }
281
+
282
+ /**
283
+ * A successful execution result.
284
+ *
285
+ * The payload is fenced as JSON and unindented-by-default is avoided: agents
286
+ * re-read it, and deeply indented JSON costs tokens on every turn. Kept at 2
287
+ * spaces because it is far easier to diff and reason about.
288
+ */
289
+ export function renderResult(slug, data, { durationMs, execution = "remote", rateLimit } = {}) {
290
+ const json = safeStringify(data);
291
+ const meta = [`Tool: \`${slug}\``, `Execution: ${execution}`];
292
+ if (typeof durationMs === "number") meta.push(`Duration: ${durationMs}ms`);
293
+
294
+ // Anonymous execution is capped at 30 requests/minute. Showing the remaining
295
+ // budget lets an agent pace itself instead of discovering the cap as a 429.
296
+ if (rateLimit && Number.isFinite(rateLimit.remaining)) {
297
+ const limit = Number.isFinite(rateLimit.limit) ? `/${rateLimit.limit}` : "";
298
+ const warns = rateLimit.remaining <= 3 ? " — **pace down, further calls will 429**" : "";
299
+ meta.push(`Quota: ${rateLimit.remaining}${limit} left${warns}`);
300
+ }
301
+
302
+ // Tier-1 preview. Value-shape inference only — never the slug — so tools
303
+ // that do not exist yet still render. Returns null when nothing matches, in
304
+ // which case the response is exactly what it was before.
305
+ const preview = previewResult(data, { slug });
306
+
307
+ // The visual is addressed to the human; the JSON to the model. That split is
308
+ // what makes rich output affordable: an image in context costs roughly 1.5k
309
+ // tokens, so showing one unconditionally would make the agent worse.
310
+ const text = {
311
+ type: "text",
312
+ text: `# Result: ${slug}\n\n${meta.join(" · ")}\n\n\`\`\`json\n${json}\n\`\`\``,
313
+ annotations: { audience: preview?.block ? ["assistant"] : ["user", "assistant"] },
314
+ };
315
+
316
+ const content = preview?.block ? [text, preview.block] : [text];
317
+
318
+ return {
319
+ text: text.text,
320
+ data,
321
+ content,
322
+ previewKind: preview?.kind ?? null,
323
+ };
324
+ }
325
+
326
+ /** A failure that still succeeded as a call — a tool returning `{error:…}`. */
327
+ export function renderToolReportedError(slug, data) {
328
+ const message =
329
+ data?.error || data?.message || "The tool reported an error.";
330
+ const code = data?.code ? ` (${data.code})` : "";
331
+ return {
332
+ text:
333
+ `# ${slug} reported an error${code}\n\n${message}\n\n` +
334
+ `This is the tool's own response, not a transport failure. The tool ran; check its parameters and try again.`,
335
+ data,
336
+ };
337
+ }
338
+
339
+ function safeStringify(value) {
340
+ try {
341
+ return JSON.stringify(value, null, 2);
342
+ } catch {
343
+ return String(value);
344
+ }
345
+ }
346
+
347
+ /** Startup diagnostics. Always stderr — never part of an MCP response. */
348
+ export function renderConnectivityReport({ connected, roots, apiBase, anonymous, errors }) {
349
+ const lines = [];
350
+ if (connected) {
351
+ lines.push(`NexusBloom MCP server connected to ${connected}`);
352
+ } else {
353
+ lines.push("WARNING: could not reach the NexusBloom API. Starting in degraded mode.");
354
+ lines.push("Tool discovery and execution will fail until this is fixed.");
355
+ lines.push("");
356
+ lines.push("Checked:");
357
+ for (const r of roots) lines.push(` - ${r}/tools`);
358
+ if (errors?.length) {
359
+ lines.push("");
360
+ lines.push("Errors:");
361
+ for (const e of errors) lines.push(` - ${e}`);
362
+ }
363
+ lines.push("");
364
+ lines.push(
365
+ `To fix: set NEXUSBLOOM_API_URL to a reachable deployment ` +
366
+ `(currently "${apiBase}"), or check the API is deployed.`,
367
+ );
368
+ }
369
+ if (anonymous) {
370
+ lines.push("");
371
+ lines.push("No NEXUSBLOOM_API_KEY set — running anonymously under the free-tier rate limit.");
372
+ lines.push("Set NEXUSBLOOM_API_KEY for authenticated execution and higher limits.");
373
+ }
374
+ lines.push(`API base: ${apiBase}`);
375
+ return lines.join("\n");
376
+ }
377
+
378
+ 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
+ }