@nexusbloom/mcp-server 2.0.0 → 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/README.md CHANGED
@@ -107,19 +107,4 @@ server that explains itself on each request, not a process that exits or a host
107
107
  that concludes the server is broken. A failed catalogue refresh keeps serving the
108
108
  last good list rather than emptying it.
109
109
 
110
- ## Development
111
110
 
112
- ```bash
113
- npm test # unit + integration, no network
114
- npm run test:coverage # with coverage
115
- npm run lint
116
- ```
117
-
118
- The suite has **417 tests** at **100% line coverage** of `src/`. It includes
119
- end-to-end tests that drive the real server as a child process over stdio, using
120
- scripted miniature agents that discover, choose, read a schema and execute — the
121
- same loop a model runs, asserted on whether the *task* succeeded.
122
-
123
- No test touches the network: `NEXUSBLOOM_API_URL` is pointed at an unroutable
124
- `.invalid` host and every HTTP client is injected, so the suite cannot pass
125
- while production is broken, and cannot fail because production is down.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nexusbloom/mcp-server",
3
- "version": "2.0.0",
3
+ "version": "2.0.2",
4
4
  "description": "MCP server for NexusBloom — agents discover tools by intent, read exact schemas, and execute them. Built on @nexusbloom/core.",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -35,6 +35,9 @@
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
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",
38
+ "shell": "node scripts/mcp-shell.mjs",
39
+ "shell:mock": "node scripts/mcp-shell.mjs --mock",
40
+ "mock-api": "node scripts/mock-api.mjs",
38
41
  "lint": "node --check index.js && for f in src/*.js; do node --check \"$f\" || exit 1; done"
39
42
  }
40
43
  }
package/src/client.js CHANGED
@@ -41,6 +41,15 @@ export class ApiClient {
41
41
  // not fire (some fetch polyfills). Whichever rejects first wins; the loser
42
42
  // is an unhandled rejection we must not let crash the process.
43
43
  this._inFlight = new Set();
44
+
45
+ /**
46
+ * Most recent rate-limit state reported by the API, as
47
+ * `{remaining, limit, reset}`. Anonymous execution is capped per minute, so
48
+ * an agent benefits from seeing its own budget rather than discovering it
49
+ * as a 429.
50
+ * @type {{remaining: number|null, limit: number|null, reset: string|null}|null}
51
+ */
52
+ this.rateLimit = null;
44
53
  }
45
54
 
46
55
  /**
@@ -61,12 +70,13 @@ export class ApiClient {
61
70
  const headers = { Accept: "application/json" };
62
71
  if (body !== undefined) headers["Content-Type"] = "application/json";
63
72
  if (auth && this.config.apiKey) headers["Authorization"] = `Bearer ${this.config.apiKey}`;
73
+ Object.assign(headers, opts.headers || {});
64
74
 
65
75
  // Own controller so an external signal can cancel too, and so the timer is
66
76
  // always cleared — a leaked timer keeps the event loop alive and the MCP
67
77
  // server never exits cleanly.
68
78
  const controller = new AbortController();
69
- const timer = setTimeout(() => controller.abort(), this.config.timeoutMs);
79
+ const timer = setTimeout(() => controller.abort(), opts.timeoutMs ?? this.config.timeoutMs);
70
80
  this._inFlight.add(controller);
71
81
 
72
82
  const onExternalAbort = () => controller.abort();
@@ -106,6 +116,23 @@ export class ApiClient {
106
116
  async _decode(res, ctx) {
107
117
  const text = await res.text().catch(() => "");
108
118
 
119
+ // Capture quota state before any early return. The API advertises these for
120
+ // CORS exposure but nothing consumed them, so agents hit 429 blind.
121
+ // `Number(null)` is 0, so an absent header must be rejected explicitly —
122
+ // otherwise a client with full quota would be told it has none left.
123
+ const numHeader = (name) => {
124
+ const raw = res.headers?.get?.(name);
125
+ if (raw === null || raw === undefined || raw === "") return null;
126
+ const n = Number(raw);
127
+ return Number.isFinite(n) ? n : null;
128
+ };
129
+
130
+ this.rateLimit = {
131
+ remaining: numHeader("x-ratelimit-remaining"),
132
+ limit: numHeader("x-ratelimit-limit"),
133
+ reset: res.headers?.get?.("x-ratelimit-reset") ?? null,
134
+ };
135
+
109
136
  let parsed = null;
110
137
  if (text) {
111
138
  try {
package/src/handlers.js CHANGED
@@ -112,9 +112,13 @@ export function createHandlers({ client, cache, config }) {
112
112
  assertValidInput(params, inputSchema, tool.slug);
113
113
 
114
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.
115
118
  const body = await client.request(`/run/${encodeURIComponent(tool.slug)}`, {
116
119
  method: "POST",
117
120
  body: params,
121
+ headers: { "X-NexusBloom-Source": "mcp" },
118
122
  });
119
123
  const durationMs = Date.now() - startedAt;
120
124
 
@@ -124,6 +128,7 @@ export function createHandlers({ client, cache, config }) {
124
128
  tool: tool.slug,
125
129
  execution: "remote",
126
130
  durationMs,
131
+ rateLimit: client.rateLimit ?? null,
127
132
  };
128
133
  }
129
134
 
package/src/preview.js ADDED
@@ -0,0 +1,319 @@
1
+ /**
2
+ * Tier-1 result rendering.
3
+ *
4
+ * A tool's output is turned into a visual block by inferring from the *shape of
5
+ * the value* — never from the slug. Tools that do not exist yet render without
6
+ * their author doing anything, which is the whole point: the alternative is a
7
+ * per-slug renderer that dies at tool #31.
8
+ *
9
+ * Everything here is a pure function of (value, schema). No I/O, no network,
10
+ * no slug lookups, so it is trivially testable and deterministic.
11
+ *
12
+ * Security note: tool output is untrusted. Once third parties can publish tools,
13
+ * a "render the SVG" feature is a stored-XSS surface. Rather than attempt to
14
+ * sanitise arbitrary markup with regex — which gives false confidence — any SVG
15
+ * containing an active or external construct is REJECTED and falls back to
16
+ * plain text. Failing closed is cheaper than being wrong here.
17
+ */
18
+
19
+ // ─── primitives ─────────────────────────────────────────────────────────────
20
+
21
+ const HEX = /^#(?:[0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/i;
22
+ // Unanchored: tools emit full CSS declarations such as
23
+ // `background: linear-gradient(90deg, #a, #b);`, not a bare gradient function.
24
+ const GRADIENT = /(?:repeating-)?(?:linear|radial|conic)-gradient\(/i;
25
+
26
+ /** Escape text for safe interpolation into SVG markup. */
27
+ export function escapeXml(value) {
28
+ return String(value).replace(/[<>&"']/g, (c) => ({
29
+ "<": "&lt;",
30
+ ">": "&gt;",
31
+ "&": "&amp;",
32
+ '"': "&quot;",
33
+ "'": "&apos;",
34
+ })[c]);
35
+ }
36
+
37
+ const isHex = (v) => typeof v === "string" && HEX.test(v.trim());
38
+
39
+ // The subset of CSS colour syntax worth resolving for a preview. Anything else
40
+ // (var(), color-mix, exotic functions) yields no stops, which means no preview
41
+ // rather than a wrong one.
42
+ const STOP = /#[0-9a-f]{3,8}\b|rgba?\(\s*[\d.\s,%]+\)|hsla?\(\s*[\d.\s,%deg]+\)|\b(?:red|blue|green|black|white|gray|grey|orange|purple|pink|yellow|cyan|magenta|teal|navy|olive|maroon|lime|aqua|silver|gold|brown|coral|salmon|khaki|violet|indigo|crimson)\b/gi;
43
+
44
+ /** Expand `#abc` / `#abcd` to the 6/8-digit form SVG understands. */
45
+ function normalizeHex(hex) {
46
+ const h = hex.slice(1);
47
+ if (h.length === 3) return `#${h[0]}${h[0]}${h[1]}${h[1]}${h[2]}${h[2]}`;
48
+ if (h.length === 4) return `#${h[0]}${h[0]}${h[1]}${h[1]}${h[2]}${h[2]}${h[3]}${h[3]}`;
49
+ return `#${h.toLowerCase()}`;
50
+ }
51
+
52
+ /** Colour stops from a CSS gradient string, in order, de-duplicated. */
53
+ function extractStops(css) {
54
+ const out = [];
55
+ for (const m of css.matchAll(STOP)) {
56
+ const raw = m[0];
57
+ const value = raw.startsWith("#") ? normalizeHex(raw) : raw.toLowerCase();
58
+ if (!out.includes(value)) out.push(value);
59
+ }
60
+ return out.slice(0, 12);
61
+ }
62
+
63
+ /** Keys that, when their value is a hex colour, indicate a colour payload. */
64
+ const COLOR_KEY = /^(?:colou?rs?|colors?|palette|hex|primary|secondary|accent|background|foreground|border|fill|stroke|shade|tone|from|to|start|end|base_?colou?r)$/i;
65
+
66
+ /**
67
+ * Reject SVG that can execute or fetch. Deliberately a denylist of whole
68
+ * constructs: if any is present we refuse to render rather than strip it.
69
+ */
70
+ export function isSafeSvg(svg) {
71
+ const s = String(svg);
72
+ return !(
73
+ /<\s*script/i.test(s) ||
74
+ /<\s*foreignObject/i.test(s) ||
75
+ /<\s*(iframe|object|embed|link|meta|base)\b/i.test(s) ||
76
+ /<!DOCTYPE|<!ENTITY/i.test(s) ||
77
+ /\son[a-z]+\s*=/i.test(s) ||
78
+ /javascript\s*:/i.test(s) ||
79
+ /\b(?:href|src|xlink:href)\s*=\s*["']?\s*(?:https?:|\/\/|data:text\/html)/i.test(s)
80
+ );
81
+ }
82
+
83
+ const svgWrap = (inner, w, h, label) =>
84
+ `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}" role="img" aria-label="${escapeXml(label)}">${inner}</svg>`;
85
+
86
+ // ─── detectors ──────────────────────���────────────────────────────────────────
87
+ // Each: test(value, schema) -> boolean, render(value, ctx) -> block | null
88
+ // Order matters: first match wins, so put the most precise rules first.
89
+
90
+ const DATA_IMAGE = /^data:image\/(png|jpeg|jpg|gif|webp);base64,[A-Za-z0-9+/=\s]+$/i;
91
+
92
+ const detectors = [
93
+ {
94
+ id: "data-image",
95
+ test: (v) => typeof v === "string" && DATA_IMAGE.test(v.trim()),
96
+ render: (v) => {
97
+ const m = DATA_IMAGE.exec(v.trim());
98
+ const mime = m[1].toLowerCase() === "jpg" ? "jpeg" : m[1].toLowerCase();
99
+ return {
100
+ kind: "image",
101
+ block: { type: "image", data: v.trim().split(",")[1], mimeType: `image/${mime}` },
102
+ };
103
+ },
104
+ },
105
+
106
+ {
107
+ id: "gradient",
108
+ test: (v) => typeof v === "string" && GRADIENT.test(v.trim()),
109
+ render: (v) => {
110
+ const css = v.trim();
111
+ // Resolve real stops rather than trusting `fill="linear-gradient(...)"`,
112
+ // which most SVG renderers ignore.
113
+ const stops = extractStops(css);
114
+ if (stops.length < 2) return null;
115
+
116
+ const type = /radial-gradient\(/i.test(css) ? "radial" : /conic-gradient\(/i.test(css) ? "conic" : "linear";
117
+ const W = 480;
118
+ const H = 72;
119
+ const w = W;
120
+ const gap = 0;
121
+ const offset = (i) => (stops.length === 1 ? 0 : (i / (stops.length - 1)) * 100).toFixed(2);
122
+
123
+ const stopEls = stops
124
+ .map((c, i) => `<stop offset="${offset(i)}%" stop-color="${escapeXml(c)}"/>`)
125
+ .join("");
126
+
127
+ // Conic has no SVG equivalent, so fall back to the stop ramp rather than
128
+ // draw something that lies about the result.
129
+ const fill =
130
+ type === "linear"
131
+ ? `<linearGradient id="g" x1="0" y1="0" x2="1" y2="0">${stopEls}</linearGradient>`
132
+ : type === "radial"
133
+ ? `<radialGradient id="g" cx="0.5" cy="0.5" r="0.7">${stopEls}</radialGradient>`
134
+ : null;
135
+
136
+ const inner = fill
137
+ ? `<defs>${fill}</defs><rect width="${W}" height="${H}" rx="10" fill="url(#g)"/>`
138
+ : `<rect width="${W}" height="${H}" rx="10" fill="none"/>` +
139
+ stops
140
+ .map((c, i) => {
141
+ const sw = W / stops.length;
142
+ return `<rect x="${(i * sw).toFixed(2)}" y="0" width="${sw.toFixed(2)}" height="${H}" fill="${escapeXml(c)}"/>`;
143
+ })
144
+ .join("");
145
+
146
+ return {
147
+ kind: type === "conic" ? "gradient-conic" : "gradient",
148
+ block: {
149
+ type: "resource",
150
+ resource: {
151
+ uri: "nexusbloom://preview/gradient.svg",
152
+ mimeType: "image/svg+xml",
153
+ text: svgWrap(inner, W, H, `${type} gradient`),
154
+ },
155
+ },
156
+ note: css,
157
+ };
158
+ },
159
+ },
160
+
161
+ {
162
+ id: "svg",
163
+ test: (v) => typeof v === "string" && /<\s*svg[\s>]/i.test(v),
164
+ render: (v, ctx) => {
165
+ const raw = v.trim();
166
+ if (!isSafeSvg(raw)) {
167
+ return { kind: "rejected-svg", block: null, note: "SVG contained active or external content; not rendered." };
168
+ }
169
+
170
+ // A square SVG is treated as a code symbol (QR and friends). The padding
171
+ // is the quiet zone: the spec requires >= 4 modules, and the generators
172
+ // emit 1, so a faithful render would not scan. 12px satisfies that at the
173
+ // default 3px module size.
174
+ const dim = /\bwidth=["'](\d+)["'][^>]*\bheight=["'](\d+)["']/i.exec(raw);
175
+ const w = dim ? Number(dim[1]) : 0;
176
+ const h = dim ? Number(dim[2]) : 0;
177
+ const square = w > 0 && h > 0 && Math.abs(w - h) <= Math.max(w, h) * 0.08;
178
+
179
+ if (square) {
180
+ const pad = 12;
181
+ const outer = w + pad * 2;
182
+ return {
183
+ kind: "code",
184
+ block: {
185
+ type: "resource",
186
+ resource: {
187
+ uri: `nexusbloom://preview/${ctx.slug}.svg`,
188
+ mimeType: "image/svg+xml",
189
+ // Nested svg keeps the original coordinate system intact.
190
+ text:
191
+ `<svg xmlns="http://www.w3.org/2000/svg" width="${outer}" height="${outer}" viewBox="0 0 ${outer} ${outer}" role="img" aria-label="${escapeXml(ctx.slug)} output">` +
192
+ `<rect width="${outer}" height="${outer}" rx="10" fill="#ffffff"/>` +
193
+ `<g transform="translate(${pad},${pad})">${raw}</g>` +
194
+ `</svg>`,
195
+ },
196
+ },
197
+ };
198
+ }
199
+
200
+ return {
201
+ kind: "svg",
202
+ block: {
203
+ type: "resource",
204
+ resource: {
205
+ uri: `nexusbloom://preview/${ctx.slug}.svg`,
206
+ mimeType: "image/svg+xml",
207
+ text: raw,
208
+ },
209
+ },
210
+ };
211
+ },
212
+ },
213
+
214
+ {
215
+ id: "colors",
216
+ test: (v) => {
217
+ if (Array.isArray(v)) return v.length > 0 && v.length <= 24 && v.every(isHex);
218
+ if (v && typeof v === "object") {
219
+ const hexes = Object.entries(v).filter(([, val]) => isHex(val));
220
+ return hexes.length > 0 && hexes.length <= 24;
221
+ }
222
+ return isHex(v);
223
+ },
224
+ render: (v) => {
225
+ const swatches = Array.isArray(v)
226
+ ? v
227
+ : v && typeof v === "object"
228
+ ? Object.values(v).filter(isHex)
229
+ : [v];
230
+
231
+ const cell = 72;
232
+ const gap = 10;
233
+ const w = swatches.length * cell + (swatches.length - 1) * gap;
234
+ const h = cell + 26;
235
+
236
+ const rects = swatches
237
+ .map((c, i) => {
238
+ const x = i * (cell + gap);
239
+ return (
240
+ `<rect x="${x}" y="0" width="${cell}" height="${cell}" rx="9" fill="${escapeXml(c.trim())}"/>` +
241
+ `<text x="${x + cell / 2}" y="${cell + 18}" text-anchor="middle" font-family="ui-monospace,SFMono-Regular,Menlo,monospace" font-size="11" fill="#8b93a7">${escapeXml(c.trim())}</text>`
242
+ );
243
+ })
244
+ .join("");
245
+
246
+ return {
247
+ kind: "colors",
248
+ block: {
249
+ type: "resource",
250
+ resource: {
251
+ uri: "nexusbloom://preview/swatches.svg",
252
+ mimeType: "image/svg+xml",
253
+ text: svgWrap(`<rect width="${w}" height="${h}" fill="none"/>${rects}`, w, h, "Colour swatches"),
254
+ },
255
+ },
256
+ };
257
+ },
258
+ },
259
+
260
+ {
261
+ id: "url",
262
+ test: (v) => typeof v === "string" && /^https?:\/\/\S+$/i.test(v.trim()),
263
+ render: () => ({ kind: "url", block: null }),
264
+ },
265
+ ];
266
+
267
+ /**
268
+ * Infer a visual block from a tool result.
269
+ *
270
+ * Returns null when nothing matches — the caller then emits its normal text
271
+ * block, so this can never produce an empty or broken response.
272
+ *
273
+ * @param {unknown} value tool output
274
+ * @param {{slug?: string}} [ctx]
275
+ * @returns {{kind: string, block: object|null, note?: string}|null}
276
+ */
277
+ export function inferPreview(value, ctx = {}) {
278
+ if (value === null || value === undefined) return null;
279
+
280
+ for (const d of detectors) {
281
+ let hit = false;
282
+ try {
283
+ hit = d.test(value);
284
+ } catch {
285
+ hit = false;
286
+ }
287
+ if (!hit) continue;
288
+
289
+ try {
290
+ const out = d.render(value, ctx);
291
+ if (out) return out;
292
+ } catch {
293
+ /* a broken renderer must never break the response */
294
+ }
295
+ }
296
+
297
+ return null;
298
+ }
299
+
300
+ /**
301
+ * Walk a tool result and return the first renderable preview found.
302
+ * Results are shallow — the interesting payload is usually one level down.
303
+ */
304
+ export function previewResult(data, ctx = {}) {
305
+ if (!data || typeof data !== "object") return inferPreview(data, ctx);
306
+
307
+ for (const value of Object.values(data)) {
308
+ const found = inferPreview(value, ctx);
309
+ if (found && found.block) return found;
310
+ }
311
+
312
+ // Arrays of hex (a bare palette) as a last resort.
313
+ if (Array.isArray(data)) {
314
+ const found = inferPreview(data, ctx);
315
+ if (found && found.block) return found;
316
+ }
317
+
318
+ return null;
319
+ }
@@ -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 CHANGED
@@ -17,6 +17,7 @@
17
17
  import { describeParameters, groupByCategory } from "./discovery.js";
18
18
  import { ErrorCode } from "./errors.js";
19
19
  import { normaliseTool } from "./manifests.js";
20
+ import { previewResult } from "./preview.js";
20
21
 
21
22
  /**
22
23
  * Build an MCP response.
@@ -26,7 +27,9 @@ import { normaliseTool } from "./manifests.js";
26
27
  * over prose gets an exact payload instead of parsing markdown.
27
28
  */
28
29
  export function respond(payload) {
29
- const content = [{ type: "text", text: payload.text }];
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 }];
30
33
  if (payload.data !== undefined) {
31
34
  content.push({
32
35
  type: "resource",
@@ -116,9 +119,10 @@ export function renderToolList(tools, { total } = {}) {
116
119
  */
117
120
  export function renderSearchResults(query, tools, catalogueSize, categories = []) {
118
121
  if (tools.length === 0) {
119
- const categoryHint = categories.length
120
- ? `\n• A category name: ${categories.slice(0, 8).join(", ")}`
121
- : "";
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(", ")}` : "";
122
126
  return {
123
127
  text:
124
128
  `# No tools match "${query}"\n\n` +
@@ -282,14 +286,40 @@ function placeholderFor(prop) {
282
286
  * re-read it, and deeply indented JSON costs tokens on every turn. Kept at 2
283
287
  * spaces because it is far easier to diff and reason about.
284
288
  */
285
- export function renderResult(slug, data, { durationMs, execution = "remote" } = {}) {
289
+ export function renderResult(slug, data, { durationMs, execution = "remote", rateLimit } = {}) {
286
290
  const json = safeStringify(data);
287
291
  const meta = [`Tool: \`${slug}\``, `Execution: ${execution}`];
288
292
  if (typeof durationMs === "number") meta.push(`Duration: ${durationMs}ms`);
289
293
 
290
- return {
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",
291
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,
292
320
  data,
321
+ content,
322
+ previewKind: preview?.kind ?? null,
293
323
  };
294
324
  }
295
325