@nexusbloom/mcp-server 2.1.1 → 2.1.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/README.md CHANGED
@@ -258,6 +258,20 @@ Input is checked against the tool's own schema before anything is sent, so a
258
258
  malformed call costs no API quota and returns an error naming the exact field.
259
259
  The API remains authoritative — this only rejects what is provably wrong.
260
260
 
261
+ ## Developer scripts
262
+
263
+ | Command | What it does |
264
+ |---|---|
265
+ | `npm run shell` | Interactive MCP client over real stdio. `shell:mock` starts a fake API for you. |
266
+ | `npm run agent-demo` | Walks the path an LLM takes — discover, search, schema, run, batch, recover — with no model needed. `agent-demo:mock` starts its own mock. |
267
+ | `npm run catalogue-check` | Every tool in the seed catalogue walked through the real server: advertise, describe, validate, run, batch, resolve as a resource. Exits non-zero on any failure, so it works as a CI gate. |
268
+ | `npm run mock-api [port]` | The fake API on its own. Reuses a running one or explains what holds the port. |
269
+
270
+ `agent-demo` checks the API is reachable before it starts. That is deliberate:
271
+ when the mock is not running, every check otherwise fails with its own
272
+ `ECONNREFUSED`, and ten identical failures read as a broken server rather than a
273
+ missing dependency.
274
+
261
275
  ## Design notes
262
276
 
263
277
  **Execution never guesses.** Slugs resolve by exact match or unambiguous
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@nexusbloom/mcp-server",
3
- "version": "2.1.1",
4
- "description": "MCP server for NexusBloom — agents discover tools by intent, read exact schemas, and execute them. Built on @nexusbloom/core.",
3
+ "version": "2.1.2",
4
+ "description": "MCP server for NexusBloom \u2014 agents discover tools by intent, read exact schemas, and execute them. Built on @nexusbloom/core.",
5
5
  "type": "module",
6
6
  "main": "index.js",
7
7
  "bin": {
@@ -34,13 +34,13 @@
34
34
  "test": "node --test --import ./test/setup.mjs test/*.test.js",
35
35
  "test:coverage": "node --test --experimental-test-coverage --import ./test/setup.mjs test/*.test.js",
36
36
  "test:watch": "node --test --watch --import ./test/setup.mjs test/*.test.js",
37
- "test:src-only": "node --test --import ./test/setup.mjs test/config.test.js test/errors.test.js test/manifests.test.js test/discovery.test.js test/client.test.js test/validate.test.js test/render.test.js test/cache.test.js test/handlers.test.js test/resources.test.js test/batch.test.js test/progress.test.js test/prompts.test.js test/history.test.js test/local.test.js",
38
37
  "shell": "node scripts/mcp-shell.mjs",
39
38
  "shell:mock": "node scripts/mcp-shell.mjs --mock",
40
39
  "mock-api": "node scripts/mock-api.mjs",
41
40
  "agent-demo": "node scripts/agent-demo.mjs",
42
- "agent-demo:mock": "node scripts/agent-demo.mjs --url http://127.0.0.1:8787/api",
41
+ "agent-demo:mock": "node scripts/agent-demo.mjs --mock",
43
42
  "catalogue-check": "node scripts/full-catalogue-check.mjs",
44
- "lint": "node --check index.js && for f in src/*.js; do node --check \"$f\" || exit 1; done"
43
+ "lint": "node --check index.js && for f in src/*.js; do node --check \"$f\" || exit 1; done",
44
+ "prepublishOnly": "npm run lint && npm test && npm run catalogue-check"
45
45
  }
46
46
  }
package/src/client.js CHANGED
@@ -24,6 +24,49 @@ export function makeLogger(debug) {
24
24
  };
25
25
  }
26
26
 
27
+ /**
28
+ * Turn whatever the API put in `error` into a message an agent can act on.
29
+ *
30
+ * The obvious version of this is `String(apiMessage).trim() || fallback`, but
31
+ * `String({})` is `"[object Object]"` — technically a string, useless as a
32
+ * diagnosis. Production returns structured errors (`error` as an object, an
33
+ * array, occasionally a number), and the previous code called `.trim()` on the
34
+ * raw value: optional chaining guards null and undefined, not "exists and is not
35
+ * a function". Every structured API error therefore died as
36
+ * `TypeError: apiMessage?.trim is not a function` — thrown from the error path,
37
+ * so it fired only once something was already broken, and it replaced the real
38
+ * status and code with a stack trace about the client's own bug.
39
+ *
40
+ * So: preserve structure as JSON, because `{"hint":"rate limited"}` tells an
41
+ * agent what `[object Object]` cannot, and never throw while reporting a
42
+ * failure.
43
+ *
44
+ * @param {unknown} candidate
45
+ * @param {Response} res
46
+ * @param {{method: string, url: string}} ctx
47
+ * @returns {string}
48
+ */
49
+ export function describeApiError(candidate, res, ctx) {
50
+ const fallback = `API returned ${res.status} for ${ctx.method} ${ctx.url}`;
51
+
52
+ if (candidate === null || candidate === undefined) return fallback;
53
+
54
+ if (typeof candidate === "string") return candidate.trim() || fallback;
55
+
56
+ if (typeof candidate === "object") {
57
+ try {
58
+ const json = JSON.stringify(candidate);
59
+ return json && json !== "{}" && json !== "[]" ? json : fallback;
60
+ } catch {
61
+ // Circular, or a BigInt. The status line still identifies the failure.
62
+ return fallback;
63
+ }
64
+ }
65
+
66
+ // number, boolean, bigint, symbol, function — all fine as text.
67
+ return String(candidate) || fallback;
68
+ }
69
+
27
70
  export class ApiClient {
28
71
  /**
29
72
  * @param {object} config From loadConfig().
@@ -143,15 +186,13 @@ export class ApiClient {
143
186
  }
144
187
 
145
188
  if (!res.ok) {
146
- const apiMessage =
147
- parsed?.error || parsed?.message || parsed?.data?.error || text?.slice(0, 300);
148
- const code = parsed?.code || codeForStatus(res.status);
149
-
150
- const message = apiMessage?.trim()
151
- ? `${apiMessage}`
152
- : `API returned ${res.status} for ${ctx.method} ${ctx.url}`;
189
+ const message = describeApiError(
190
+ parsed?.error ?? parsed?.message ?? parsed?.data?.error ?? text?.slice(0, 300),
191
+ res,
192
+ ctx,
193
+ );
153
194
 
154
- throw new NexusBloomError(message, code, {
195
+ throw new NexusBloomError(message, parsed?.code || codeForStatus(res.status), {
155
196
  status: res.status,
156
197
  // The API returns `fields` on schema failures; keep it so an agent can
157
198
  // repair exactly the bad keys instead of re-reading the whole schema.
@@ -1,185 +0,0 @@
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
- });