@nexusbloom/mcp-server 2.0.0 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +149 -15
- package/index.js +13 -1
- package/package.json +8 -3
- package/src/client.js +28 -1
- package/src/config.js +5 -0
- package/src/handlers.js +346 -11
- package/src/history.js +285 -0
- package/src/local.js +164 -0
- package/src/preview.js +319 -0
- package/src/preview.test.js +185 -0
- package/src/progress.js +88 -0
- package/src/prompts.js +282 -0
- package/src/render.js +234 -15
- package/src/resources.js +318 -0
- package/src/server.js +106 -7
- package/src/validate.js +14 -1
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/progress.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Progress notifications.
|
|
3
|
+
*
|
|
4
|
+
* A batch of five tools can take fifteen seconds, and a host that shows nothing
|
|
5
|
+
* for fifteen seconds is a host the user interrupts. The protocol already has a
|
|
6
|
+
* channel for this — `notifications/progress`, keyed by the `progressToken` the
|
|
7
|
+
* client sent in `_meta` — but it is opt-in on both sides, so every detail here
|
|
8
|
+
* is about degrading quietly when it is absent.
|
|
9
|
+
*
|
|
10
|
+
* The rules that matter:
|
|
11
|
+
*
|
|
12
|
+
* - **No token, no notifications.** A client that does not send one has not asked
|
|
13
|
+
* for progress, and unsolicited notifications are noise at best and a protocol
|
|
14
|
+
* violation at worst.
|
|
15
|
+
* - **Progress is monotonic.** The spec requires it to increase; a host that
|
|
16
|
+
* renders a percentage bar will go backwards if a report repeats a value.
|
|
17
|
+
* - **A failed notification never fails the call.** The report is diagnostics
|
|
18
|
+
* about work already done. Losing one must not lose the result.
|
|
19
|
+
*
|
|
20
|
+
* No SDK import: the notification is a plain object, and the suite validates it
|
|
21
|
+
* against the SDK's own schema in test/progress.test.js rather than coupling
|
|
22
|
+
* this file to a transport.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Build a progress reporter bound to one in-flight request.
|
|
27
|
+
*
|
|
28
|
+
* @param {object} deps
|
|
29
|
+
* @param {object} [deps.extra] The SDK's per-request extra, which carries
|
|
30
|
+
* `sendNotification` already associated with the originating request.
|
|
31
|
+
* @param {string|number} [deps.progressToken] From `params._meta.progressToken`.
|
|
32
|
+
* @returns {{enabled: boolean, report: (progress: number, message?: string, total?: number) => Promise<void>}}
|
|
33
|
+
*/
|
|
34
|
+
export function createProgressReporter({ extra, progressToken } = {}) {
|
|
35
|
+
const token = progressToken ?? extra?._meta?.progressToken;
|
|
36
|
+
const send = typeof extra?.sendNotification === "function" ? extra.sendNotification.bind(extra) : null;
|
|
37
|
+
|
|
38
|
+
if (token === undefined || token === null || !send) {
|
|
39
|
+
return { enabled: false, report: async () => {} };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
let last = -1;
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
enabled: true,
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Report progress. Never rejects.
|
|
49
|
+
*
|
|
50
|
+
* `progress` counts units done. When the total is unknown the spec allows a
|
|
51
|
+
* bare increasing number, which is why `total` is optional everywhere.
|
|
52
|
+
*/
|
|
53
|
+
async report(progress, message, total) {
|
|
54
|
+
const value = Number.isFinite(progress) ? progress : 0;
|
|
55
|
+
// The spec requires progress to increase on every notification. Skipping a
|
|
56
|
+
// repeat keeps a host's percentage bar monotonic without adding work.
|
|
57
|
+
if (value <= last) return;
|
|
58
|
+
last = value;
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
await send({
|
|
62
|
+
method: "notifications/progress",
|
|
63
|
+
params: {
|
|
64
|
+
progressToken: token,
|
|
65
|
+
progress: value,
|
|
66
|
+
...(Number.isFinite(total) ? { total } : {}),
|
|
67
|
+
...(message ? { message } : {}),
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
} catch {
|
|
71
|
+
// A dropped notification is not worth losing a completed run over.
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Adapt a progress reporter to the callback shape the handlers take.
|
|
79
|
+
*
|
|
80
|
+
* Handlers stay transport-free by receiving a plain `onProgress({progress,
|
|
81
|
+
* message, total})` function; this is the only place that knows about the
|
|
82
|
+
* protocol, so a handler test can pass a plain function and assert the sequence.
|
|
83
|
+
*/
|
|
84
|
+
export function handlerProgress(reporter) {
|
|
85
|
+
if (!reporter?.enabled) return undefined;
|
|
86
|
+
return ({ progress, message, total } = {}) =>
|
|
87
|
+
reporter.report(progress ?? 0, message, total);
|
|
88
|
+
}
|