@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.
@@ -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
+ "<": "&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
+ }