@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 +0 -15
- package/package.json +4 -1
- package/src/client.js +28 -1
- package/src/handlers.js +5 -0
- package/src/preview.js +319 -0
- package/src/preview.test.js +185 -0
- package/src/render.js +36 -6
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.
|
|
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
|
+
"<": "<",
|
|
30
|
+
">": ">",
|
|
31
|
+
"&": "&",
|
|
32
|
+
'"': """,
|
|
33
|
+
"'": "'",
|
|
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>&"'`), "<script>&"'");
|
|
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
|
-
|
|
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
|
-
|
|
120
|
-
|
|
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
|
-
|
|
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
|
|