@nexusbloom/mcp-server 1.0.3 → 2.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +110 -0
- package/index.js +59 -397
- package/package.json +30 -3
- package/src/client.js +183 -0
- package/src/config.js +117 -0
- package/src/discovery.js +326 -0
- package/src/errors.js +107 -0
- package/src/handlers.js +431 -0
- package/src/manifests.js +247 -0
- package/src/preview.js +319 -0
- package/src/preview.test.js +185 -0
- package/src/render.js +378 -0
- package/src/server.js +83 -0
- package/src/validate.js +104 -0
- package/test-mcp-client.mjs +0 -104
package/src/manifests.js
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Manifest normalisation and caching.
|
|
3
|
+
*
|
|
4
|
+
* The API's tool rows are not identical to the manifest contract: `description`
|
|
5
|
+
* and `short_description` are the same value, `tags` may arrive as a nested
|
|
6
|
+
* array from Postgres, and community tools have a `category` that built-ins do
|
|
7
|
+
* not. Everything downstream should be able to assume one shape.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { NexusBloomError, ErrorCode } from "./errors.js";
|
|
11
|
+
|
|
12
|
+
/** Empty but valid — a schema saying "takes nothing" is not the same as null. */
|
|
13
|
+
export function emptyInputSchema() {
|
|
14
|
+
return { type: "object", properties: {}, required: [] };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Flatten and clean a Postgres text[] column.
|
|
19
|
+
*
|
|
20
|
+
* Postgres returns `{{a,b},{c}}` for a `text[][]` column, so a naive read gives
|
|
21
|
+
* nested arrays that stringify as "a,b". One level of flattening covers every
|
|
22
|
+
* shape seen in the data.
|
|
23
|
+
*/
|
|
24
|
+
export function normaliseTags(tags) {
|
|
25
|
+
if (!tags) return [];
|
|
26
|
+
const flat = (Array.isArray(tags) ? tags.flat(Infinity) : [tags])
|
|
27
|
+
.filter((t) => typeof t === "string")
|
|
28
|
+
.map((t) => t.trim().toLowerCase())
|
|
29
|
+
.filter(Boolean);
|
|
30
|
+
return [...new Set(flat)];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Coerce one API row into the canonical manifest shape.
|
|
35
|
+
*
|
|
36
|
+
* Returns null for anything without a usable slug: a tool that cannot be named
|
|
37
|
+
* cannot be called, and letting it through would surface as a tool the agent
|
|
38
|
+
* sees in the list but can never invoke.
|
|
39
|
+
*/
|
|
40
|
+
export function normaliseTool(raw) {
|
|
41
|
+
if (!raw || typeof raw !== "object") return null;
|
|
42
|
+
const slug = typeof raw.slug === "string" ? raw.slug.trim() : "";
|
|
43
|
+
if (!slug) return null;
|
|
44
|
+
|
|
45
|
+
const description = (raw.short_description || raw.description || "").trim();
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
slug,
|
|
49
|
+
name: (raw.name || slug).trim(),
|
|
50
|
+
short_description: description,
|
|
51
|
+
// Kept alongside because MCP's ListTools description is the agent's only
|
|
52
|
+
// disambiguator when two tools have similar names.
|
|
53
|
+
description,
|
|
54
|
+
category: raw.category || raw.tool_type || "uncategorized",
|
|
55
|
+
runtime: raw.runtime || "both",
|
|
56
|
+
version: raw.version || "1.0.0",
|
|
57
|
+
icon: raw.icon || "🔧",
|
|
58
|
+
type: ["builtin", "community", "local"].includes(raw.type) ? raw.type : "tool",
|
|
59
|
+
price_type: raw.price_type || "free",
|
|
60
|
+
pricing: raw.pricing || null,
|
|
61
|
+
timeout_ms: typeof raw.timeout_ms === "number" ? raw.timeout_ms : null,
|
|
62
|
+
tags: normaliseTags(raw.tags),
|
|
63
|
+
input_schema: normaliseSchema(raw.input_schema),
|
|
64
|
+
output_schema: normaliseSchema(raw.output_schema, { allowNull: true }),
|
|
65
|
+
// Distinguishes "published with no schema" from "genuinely takes no
|
|
66
|
+
// parameters". Normalisation erases the difference — both become an empty
|
|
67
|
+
// object — but an agent needs to know which one it is, because the first
|
|
68
|
+
// means call `schema` before trying and the second means just call it.
|
|
69
|
+
hasSchema: isSchemaPresent(raw.input_schema),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Was a schema actually declared by the publisher?
|
|
75
|
+
*
|
|
76
|
+
* Distinct from "does it have properties": a tool may legitimately declare
|
|
77
|
+
* `{type:"object", properties:{}, required:[]}` to mean it takes nothing, which
|
|
78
|
+
* is different from having no `input_schema` at all. Only the latter is unknown.
|
|
79
|
+
*/
|
|
80
|
+
function isSchemaPresent(schema) {
|
|
81
|
+
return Boolean(schema) && typeof schema === "object" && !Array.isArray(schema);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Coerce a stored schema into one a strict MCP client will accept.
|
|
86
|
+
*
|
|
87
|
+
* Schemas in the database predate the current contract, so some are missing
|
|
88
|
+
* `type: "object"` or carry a `required` that is not an array. Forwarding those
|
|
89
|
+
* verbatim makes the host reject the whole tool list.
|
|
90
|
+
*/
|
|
91
|
+
export function normaliseSchema(schema, { allowNull = false } = {}) {
|
|
92
|
+
if (schema === null || schema === undefined) {
|
|
93
|
+
return allowNull ? null : emptyInputSchema();
|
|
94
|
+
}
|
|
95
|
+
if (typeof schema !== "object" || Array.isArray(schema)) {
|
|
96
|
+
return allowNull ? null : emptyInputSchema();
|
|
97
|
+
}
|
|
98
|
+
if (schema.type !== "object") {
|
|
99
|
+
// A schema missing `type: "object"` is rejected by strict MCP clients, so
|
|
100
|
+
// normalise rather than forward it.
|
|
101
|
+
return {
|
|
102
|
+
...schema,
|
|
103
|
+
type: "object",
|
|
104
|
+
properties: schema.properties && typeof schema.properties === "object" ? schema.properties : {},
|
|
105
|
+
required: Array.isArray(schema.required) ? schema.required : [],
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
return {
|
|
109
|
+
...schema,
|
|
110
|
+
properties: schema.properties && typeof schema.properties === "object" ? schema.properties : {},
|
|
111
|
+
required: Array.isArray(schema.required) ? schema.required : [],
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Unwrap the several shapes an API list endpoint may return.
|
|
117
|
+
*
|
|
118
|
+
* The response has been `{tools:[…]}`, `{data:{tools:[…]}}` and a bare array at
|
|
119
|
+
* different points in its history; clients pinning an old version see all three
|
|
120
|
+
* depending on deployment.
|
|
121
|
+
*/
|
|
122
|
+
export function unwrapToolsList(body) {
|
|
123
|
+
const candidates = [
|
|
124
|
+
body,
|
|
125
|
+
body?.tools,
|
|
126
|
+
body?.data,
|
|
127
|
+
body?.data?.tools,
|
|
128
|
+
body?.results,
|
|
129
|
+
];
|
|
130
|
+
for (const c of candidates) {
|
|
131
|
+
if (Array.isArray(c)) return c.filter(Boolean);
|
|
132
|
+
}
|
|
133
|
+
return [];
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Unwrap a manifest out of `{success,data:{manifest}}`, `{manifest}` or bare.
|
|
138
|
+
*
|
|
139
|
+
* Returns null for an object that carries no recognisable manifest. Without the
|
|
140
|
+
* last check, a `{success:true,data:{}}` response unwraps to `{}` — truthy, so
|
|
141
|
+
* the caller would fabricate a tool from an empty object and report a schema
|
|
142
|
+
* that the publisher never declared.
|
|
143
|
+
*/
|
|
144
|
+
export function unwrapManifest(body) {
|
|
145
|
+
const candidates = [body?.data?.manifest, body?.manifest, body?.data, body];
|
|
146
|
+
for (const m of candidates) {
|
|
147
|
+
if (m && typeof m === "object" && !Array.isArray(m) && hasManifestFields(m)) return m;
|
|
148
|
+
}
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Any of the keys that make an object recognisable as a tool manifest. */
|
|
153
|
+
function hasManifestFields(o) {
|
|
154
|
+
return (
|
|
155
|
+
"slug" in o ||
|
|
156
|
+
"input_schema" in o ||
|
|
157
|
+
"output_schema" in o ||
|
|
158
|
+
"short_description" in o ||
|
|
159
|
+
"tool_type" in o
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* TTL cache for the tool list.
|
|
165
|
+
*
|
|
166
|
+
* The list is re-fetched on every TTL expiry rather than on a timer, so an idle
|
|
167
|
+
* server makes no requests at all. A failed refresh keeps serving the stale list
|
|
168
|
+
* rather than emptying it — an agent discovering zero tools because one blip
|
|
169
|
+
* occurred is worse than an agent seeing slightly old metadata.
|
|
170
|
+
*/
|
|
171
|
+
export class ManifestCache {
|
|
172
|
+
constructor(client, { ttlMs, now = () => Date.now() } = {}) {
|
|
173
|
+
this.client = client;
|
|
174
|
+
this.ttlMs = ttlMs;
|
|
175
|
+
this.now = now;
|
|
176
|
+
this._tools = null;
|
|
177
|
+
this._cachedAt = 0;
|
|
178
|
+
this._inflight = null;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** True when a cached list exists and has not expired. */
|
|
182
|
+
isFresh() {
|
|
183
|
+
return this._tools !== null && this.now() - this._cachedAt < this.ttlMs;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Return the tool list, refreshing if stale.
|
|
188
|
+
*
|
|
189
|
+
* Concurrent callers share one in-flight request; without that, N agents
|
|
190
|
+
* starting at once produce N identical catalogue fetches.
|
|
191
|
+
*/
|
|
192
|
+
async tools({ force = false } = {}) {
|
|
193
|
+
if (!force && this.isFresh()) return this._tools;
|
|
194
|
+
if (this._inflight) return this._inflight;
|
|
195
|
+
|
|
196
|
+
this._inflight = this._refresh().finally(() => {
|
|
197
|
+
this._inflight = null;
|
|
198
|
+
});
|
|
199
|
+
return this._inflight;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async _refresh() {
|
|
203
|
+
try {
|
|
204
|
+
const body = await this.client.request("/tools");
|
|
205
|
+
const tools = unwrapToolsList(body).map(normaliseTool).filter(Boolean);
|
|
206
|
+
|
|
207
|
+
// An empty list from a live API is almost always a failure upstream, and
|
|
208
|
+
// overwriting a good cache with it would strand every agent. Keep the old
|
|
209
|
+
// value and let the caller see an empty result with a clear reason.
|
|
210
|
+
if (tools.length === 0 && this._tools) {
|
|
211
|
+
this._cachedAt = this.now();
|
|
212
|
+
return this._tools;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
this._tools = tools;
|
|
216
|
+
this._cachedAt = this.now();
|
|
217
|
+
return tools;
|
|
218
|
+
} catch (err) {
|
|
219
|
+
if (this._tools) {
|
|
220
|
+
this._cachedAt = this.now();
|
|
221
|
+
return this._tools;
|
|
222
|
+
}
|
|
223
|
+
throw err instanceof NexusBloomError
|
|
224
|
+
? err
|
|
225
|
+
: new NexusBloomError(err.message, ErrorCode.API_ERROR);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Fetch one tool's full manifest, bypassing the list cache. */
|
|
230
|
+
async manifest(slug) {
|
|
231
|
+
const body = await this.client.request(`/run/${encodeURIComponent(slug)}`);
|
|
232
|
+
const manifest = unwrapManifest(body);
|
|
233
|
+
if (!manifest) {
|
|
234
|
+
throw new NexusBloomError(
|
|
235
|
+
`Tool "${slug}" returned no manifest from the API.`,
|
|
236
|
+
ErrorCode.NOT_FOUND,
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
return normaliseTool(manifest) || normaliseTool({ slug, ...manifest });
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** Drop cached state; used by tests and the `refresh` meta-command. */
|
|
243
|
+
clear() {
|
|
244
|
+
this._tools = null;
|
|
245
|
+
this._cachedAt = 0;
|
|
246
|
+
}
|
|
247
|
+
}
|
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
|
+
}
|