vizo-mcp 0.2.0 → 0.3.1
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 +6 -2
- package/dist/index.js +212 -46
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -3,12 +3,16 @@
|
|
|
3
3
|
stdio MCP proxy for a [Vizo](https://vizo.twinkle.hk) whiteboard server, for MCP clients that only speak stdio.
|
|
4
4
|
|
|
5
5
|
```sh
|
|
6
|
-
claude mcp add vizo -- npx -y vizo-mcp --url https://vizo.twinkle.hk --token vz_…
|
|
6
|
+
claude mcp add vizo -- npx -y vizo-mcp@latest --url https://vizo.twinkle.hk --token vz_…
|
|
7
7
|
```
|
|
8
8
|
|
|
9
9
|
Create a token at `<url>/settings`. `VIZO_URL` and `VIZO_TOKEN` work instead of the flags.
|
|
10
10
|
|
|
11
|
-
Diagrams (`draw_diagram`, `update_diagram`) are laid out on your machine and sent to the server finished. The proxy's layout version must match the server's: if they differ, the server refuses the call and asks you to update
|
|
11
|
+
Diagrams (`draw_diagram`, `update_diagram`) are laid out on your machine and sent to the server finished. The proxy's layout version must match the server's: if they differ, the server refuses the call and asks you to update. With `vizo-mcp@latest` in the command, as above, restarting your MCP client is enough: npx checks for a newer version on each start (without `@latest` it keeps reusing the copy it cached first).
|
|
12
|
+
|
|
13
|
+
## Adding images
|
|
14
|
+
|
|
15
|
+
`add_image` also takes a local file `path` here (up to 20 MB): the proxy uploads the file straight to the board with your token, then places it by its `fileId`, so large images don't have to fit in an MCP request. The image is placed on the board, or with `place: false` only uploaded, and the returned `fileId` can be used as a flowchart or component node's `image` to show the picture in a diagram.
|
|
12
16
|
|
|
13
17
|
## Exporting images
|
|
14
18
|
|
package/dist/index.js
CHANGED
|
@@ -15,6 +15,9 @@ import {
|
|
|
15
15
|
ListToolsRequestSchema,
|
|
16
16
|
ReadResourceRequestSchema
|
|
17
17
|
} from "@modelcontextprotocol/sdk/types.js";
|
|
18
|
+
import { createHash } from "node:crypto";
|
|
19
|
+
import { readFileSync as readFileSync3, statSync } from "node:fs";
|
|
20
|
+
import { resolve as resolve2 } from "node:path";
|
|
18
21
|
|
|
19
22
|
// ../diagram/src/schema.ts
|
|
20
23
|
import { z } from "zod";
|
|
@@ -41,11 +44,16 @@ var Group = z.object({
|
|
|
41
44
|
children: z.array(id).describe("Node ids (or nested group ids) inside this group"),
|
|
42
45
|
color: Color.optional()
|
|
43
46
|
});
|
|
47
|
+
var nodeImage = {
|
|
48
|
+
image: z.string().min(1).max(128).regex(/^[A-Za-z0-9_-]+$/, "image must be a fileId").optional().describe("fileId of an image on this board (from add_image, or an image's fileId in get_board). The node becomes a card: picture above the label."),
|
|
49
|
+
imageSize: z.number().min(16).max(800).optional().describe("Longer side of the picture in board units (default 96)")
|
|
50
|
+
};
|
|
44
51
|
var FlowchartNode = z.object({
|
|
45
52
|
id,
|
|
46
53
|
label,
|
|
47
|
-
shape: z.enum(["process", "decision", "terminal", "io", "database", "subroutine", "note"]).optional().describe("process (default) rectangle; decision diamond; terminal start/end pill; database; note sticky"),
|
|
48
|
-
color: Color.optional()
|
|
54
|
+
shape: z.enum(["process", "decision", "terminal", "io", "database", "subroutine", "note"]).optional().describe("process (default) rectangle; decision diamond; terminal start/end pill; database; note sticky. Nodes with an image are always rectangles."),
|
|
55
|
+
color: Color.optional(),
|
|
56
|
+
...nodeImage
|
|
49
57
|
});
|
|
50
58
|
var FlowchartSpec = z.object({
|
|
51
59
|
kind: z.literal("flowchart"),
|
|
@@ -61,7 +69,8 @@ var ComponentNode = z.object({
|
|
|
61
69
|
type: z.enum(["service", "database", "queue", "cache", "client", "external", "storage", "function", "user", "generic"]).optional().describe("Drives the shape and color"),
|
|
62
70
|
tech: z.string().max(60).optional().describe("Technology caption, e.g. 'PostgreSQL', 'React'"),
|
|
63
71
|
description: z.string().max(200).optional(),
|
|
64
|
-
color: Color.optional()
|
|
72
|
+
color: Color.optional(),
|
|
73
|
+
...nodeImage
|
|
65
74
|
});
|
|
66
75
|
var ComponentSpec = z.object({
|
|
67
76
|
kind: z.literal("component"),
|
|
@@ -159,6 +168,10 @@ var DiagramSpec = z.discriminatedUnion("kind", [
|
|
|
159
168
|
StateSpec,
|
|
160
169
|
MindmapSpec
|
|
161
170
|
]);
|
|
171
|
+
function specImageIds(spec) {
|
|
172
|
+
const nodes = spec.kind === "flowchart" || spec.kind === "component" ? spec.nodes : [];
|
|
173
|
+
return [...new Set(nodes.flatMap((n) => n.image ? [n.image] : []))];
|
|
174
|
+
}
|
|
162
175
|
function validateSpec(spec) {
|
|
163
176
|
const errors = [];
|
|
164
177
|
const check = (ids, refs, what, groups) => {
|
|
@@ -433,6 +446,15 @@ function arrowLabel(a2, value, o = {}) {
|
|
|
433
446
|
a2.boundElements = [...a2.boundElements ?? [], { id: t.id, type: "text" }];
|
|
434
447
|
return t;
|
|
435
448
|
}
|
|
449
|
+
function image(id2, x, y, w, h, fileId, o = {}) {
|
|
450
|
+
return {
|
|
451
|
+
...base(id2, "image", x, y, w, h, { strokeColor: "transparent", ...o }),
|
|
452
|
+
fileId,
|
|
453
|
+
status: "saved",
|
|
454
|
+
scale: [1, 1],
|
|
455
|
+
crop: null
|
|
456
|
+
};
|
|
457
|
+
}
|
|
436
458
|
function frame(id2, x, y, w, h, name, o = {}) {
|
|
437
459
|
return { ...base(id2, "frame", x, y, w, h, { roughness: 0, strokeColor: "#bbb", ...o }), roundness: null, name };
|
|
438
460
|
}
|
|
@@ -677,6 +699,43 @@ function labelledNode(ctx, nodeId, label2, kind, colors, opts = {}) {
|
|
|
677
699
|
}
|
|
678
700
|
};
|
|
679
701
|
}
|
|
702
|
+
var CARD_PAD = 10;
|
|
703
|
+
var CARD_GAP = 8;
|
|
704
|
+
var BOUND_TEXT_PADDING = 5;
|
|
705
|
+
function imageCard(ctx, nodeId, label2, fileId, size, colors, opts = {}) {
|
|
706
|
+
const fontSize = opts.fontSize ?? NODE_FONT;
|
|
707
|
+
const natural = ctx.images[fileId];
|
|
708
|
+
const k = size / Math.max(natural.width, natural.height);
|
|
709
|
+
const iw = Math.round(natural.width * k);
|
|
710
|
+
const ih = Math.round(natural.height * k);
|
|
711
|
+
const wrapped = wrapText(label2, fontSize, ctx.look.font, Math.max(MAX_LABEL_W, iw));
|
|
712
|
+
const m = measureText(wrapped, fontSize, ctx.look.font);
|
|
713
|
+
const w = Math.ceil(Math.max(opts.minW ?? 120, iw + CARD_PAD * 2, m.width + 10 + CARD_PAD * 2));
|
|
714
|
+
const h = Math.ceil(CARD_PAD + ih + CARD_GAP + m.height + BOUND_TEXT_PADDING);
|
|
715
|
+
const id2 = `${ctx.idPrefix}.n.${nodeId}`;
|
|
716
|
+
return {
|
|
717
|
+
id: nodeId,
|
|
718
|
+
width: w,
|
|
719
|
+
height: h,
|
|
720
|
+
bindId: id2,
|
|
721
|
+
render: (x, y) => {
|
|
722
|
+
const groupIds = [`${id2}.grp`];
|
|
723
|
+
const s = shape("rectangle", id2, x, y, w, h, {
|
|
724
|
+
strokeColor: colors.stroke,
|
|
725
|
+
backgroundColor: colors.bg,
|
|
726
|
+
strokeStyle: opts.strokeStyle,
|
|
727
|
+
roughness: ctx.look.roughness,
|
|
728
|
+
groupIds,
|
|
729
|
+
customData: ctx.cd("node", nodeId)
|
|
730
|
+
});
|
|
731
|
+
const pic = image(`${id2}.img`, x + (w - iw) / 2, y + CARD_PAD, iw, ih, fileId, { groupIds, customData: ctx.cd("node-image", nodeId) });
|
|
732
|
+
const t = boundText(s, wrapped, { fontSize, font: ctx.look.font, strokeColor: INK, verticalAlign: "bottom" });
|
|
733
|
+
t.y = y + h - BOUND_TEXT_PADDING - t.height;
|
|
734
|
+
return [s, pic, t];
|
|
735
|
+
}
|
|
736
|
+
};
|
|
737
|
+
}
|
|
738
|
+
var DEFAULT_IMAGE_SIZE = 96;
|
|
680
739
|
var fill = (c) => ({ stroke: INK, bg: PALETTE[c][1] });
|
|
681
740
|
function flowchartModel(spec, ctx) {
|
|
682
741
|
const nodes = spec.nodes.map((n) => {
|
|
@@ -691,6 +750,11 @@ function flowchartModel(spec, ctx) {
|
|
|
691
750
|
note: ["rectangle", "yellow"]
|
|
692
751
|
};
|
|
693
752
|
const [kind, color] = def[shp];
|
|
753
|
+
if (n.image)
|
|
754
|
+
return imageCard(ctx, n.id, n.label, n.image, n.imageSize ?? DEFAULT_IMAGE_SIZE, fill(n.color ?? color), {
|
|
755
|
+
strokeStyle: shp === "note" ? "dashed" : "solid",
|
|
756
|
+
fontSize: shp === "note" ? 16 : NODE_FONT
|
|
757
|
+
});
|
|
694
758
|
return labelledNode(ctx, n.id, n.label, kind, fill(n.color ?? color), {
|
|
695
759
|
strokeStyle: shp === "note" ? "dashed" : "solid",
|
|
696
760
|
fontSize: shp === "note" ? 16 : NODE_FONT
|
|
@@ -725,6 +789,12 @@ function componentModel(spec, ctx) {
|
|
|
725
789
|
if (n.tech) lines.push(`[${n.tech}]`);
|
|
726
790
|
else if (st.caption) lines.push(`\xAB${st.caption}\xBB`);
|
|
727
791
|
if (n.description) lines.push(wrapText(n.description, 16, ctx.look.font, MAX_LABEL_W));
|
|
792
|
+
if (n.image)
|
|
793
|
+
return imageCard(ctx, n.id, lines.join("\n"), n.image, n.imageSize ?? DEFAULT_IMAGE_SIZE, fill(n.color ?? st.color), {
|
|
794
|
+
strokeStyle: st.dashed ? "dashed" : "solid",
|
|
795
|
+
fontSize: n.description ? 16 : 18,
|
|
796
|
+
minW: 160
|
|
797
|
+
});
|
|
728
798
|
return labelledNode(ctx, n.id, lines.join("\n"), st.kind, fill(n.color ?? st.color), {
|
|
729
799
|
strokeStyle: st.dashed ? "dashed" : "solid",
|
|
730
800
|
fontSize: n.description ? 16 : 18,
|
|
@@ -1215,7 +1285,7 @@ function layoutSequence(spec, ctx) {
|
|
|
1215
1285
|
|
|
1216
1286
|
// ../diagram/src/render.ts
|
|
1217
1287
|
var FRAME_PAD = 40;
|
|
1218
|
-
var LAYOUT_VERSION =
|
|
1288
|
+
var LAYOUT_VERSION = 2;
|
|
1219
1289
|
var SpecError = class extends Error {
|
|
1220
1290
|
constructor(issues) {
|
|
1221
1291
|
super(`Invalid diagram spec:
|
|
@@ -1238,7 +1308,10 @@ async function renderDiagram(spec, opts) {
|
|
|
1238
1308
|
const look = LOOKS[spec.style ?? "sketch"];
|
|
1239
1309
|
const idPrefix = opts.diagramId;
|
|
1240
1310
|
const cd = (role, ref) => vizoData(opts.diagramId, role, ref);
|
|
1241
|
-
const
|
|
1311
|
+
const images = opts.images ?? {};
|
|
1312
|
+
const missing = specImageIds(spec).filter((id2) => !images[id2]);
|
|
1313
|
+
if (missing.length) throw new SpecError(missing.map((id2) => `image '${id2}' is not on this board; upload it with add_image and use the fileId it returns`));
|
|
1314
|
+
const ctx = { idPrefix, look, cd, images };
|
|
1242
1315
|
let els;
|
|
1243
1316
|
switch (spec.kind) {
|
|
1244
1317
|
case "flowchart":
|
|
@@ -1277,36 +1350,6 @@ function defaultTitle(spec) {
|
|
|
1277
1350
|
return { flowchart: "Flowchart", component: "Architecture", class: "Class diagram", sequence: "Sequence diagram", state: "State machine", mindmap: spec.kind === "mindmap" ? spec.root.label : "Mind map" }[spec.kind];
|
|
1278
1351
|
}
|
|
1279
1352
|
|
|
1280
|
-
// ../diagram/src/primitives.ts
|
|
1281
|
-
import { z as z2 } from "zod";
|
|
1282
|
-
var Near = z2.object({
|
|
1283
|
-
elementId: z2.string(),
|
|
1284
|
-
side: z2.enum(["right", "left", "above", "below"]).optional(),
|
|
1285
|
-
gap: z2.number().min(0).max(1e3).optional()
|
|
1286
|
-
});
|
|
1287
|
-
var ShapeInput = z2.object({
|
|
1288
|
-
id: z2.string().max(64).regex(/^[A-Za-z0-9_.:-]+$/).optional().describe("Optional id to reference later; generated if omitted"),
|
|
1289
|
-
type: z2.enum(["rectangle", "ellipse", "diamond", "text", "sticky", "code"]),
|
|
1290
|
-
text: z2.string().max(4e3).optional(),
|
|
1291
|
-
x: z2.number().optional(),
|
|
1292
|
-
y: z2.number().optional(),
|
|
1293
|
-
width: z2.number().min(10).max(4e3).optional(),
|
|
1294
|
-
height: z2.number().min(10).max(4e3).optional(),
|
|
1295
|
-
color: Color.optional(),
|
|
1296
|
-
fontSize: z2.number().min(8).max(96).optional(),
|
|
1297
|
-
dashed: z2.boolean().optional(),
|
|
1298
|
-
near: Near.optional().describe("Place relative to an existing element instead of x/y")
|
|
1299
|
-
});
|
|
1300
|
-
var ConnectInput = z2.object({
|
|
1301
|
-
from: z2.string(),
|
|
1302
|
-
to: z2.string(),
|
|
1303
|
-
label: z2.string().max(200).optional(),
|
|
1304
|
-
dashed: z2.boolean().optional(),
|
|
1305
|
-
arrowhead: z2.enum(["arrow", "triangle", "none"]).optional(),
|
|
1306
|
-
bidirectional: z2.boolean().optional(),
|
|
1307
|
-
color: Color.optional()
|
|
1308
|
-
});
|
|
1309
|
-
|
|
1310
1353
|
// src/export.ts
|
|
1311
1354
|
import { existsSync, mkdirSync, readFileSync as readFileSync2, writeFileSync } from "node:fs";
|
|
1312
1355
|
import { dirname, extname, resolve } from "node:path";
|
|
@@ -3162,7 +3205,7 @@ function ce(e, t = {}) {
|
|
|
3162
3205
|
o = C(1, r + (W2 - r) * (s * pe));
|
|
3163
3206
|
}
|
|
3164
3207
|
return (r + o) / 2;
|
|
3165
|
-
}, e[0].pressure), m = $(u, h, e[e.length - 1].pressure, n), U, X = e[0].vector,
|
|
3208
|
+
}, e[0].pressure), m = $(u, h, e[e.length - 1].pressure, n), U, X = e[0].vector, z2 = e[0].point, F = z2, O = z2, E = F, J = false;
|
|
3166
3209
|
for (let r = 0; r < e.length; r++) {
|
|
3167
3210
|
let { pressure: i } = e[r], { point: o, vector: s, distance: W2, runningLength: I } = e[r];
|
|
3168
3211
|
if (r < e.length - 1 && p - I < 3) continue;
|
|
@@ -3180,7 +3223,7 @@ function ce(e, t = {}) {
|
|
|
3180
3223
|
if (be || ne) {
|
|
3181
3224
|
let v = b(R(X), m);
|
|
3182
3225
|
for (let Z = 1 / 13, w = 0; w <= 1; w += Z) O = L(a(o, v), o, V * w), _.push(O), E = L(l(o, v), o, V * -w), M.push(E);
|
|
3183
|
-
|
|
3226
|
+
z2 = O, F = E, ne && (J = true);
|
|
3184
3227
|
continue;
|
|
3185
3228
|
}
|
|
3186
3229
|
if (J = false, r === e.length - 1) {
|
|
@@ -3189,7 +3232,7 @@ function ce(e, t = {}) {
|
|
|
3189
3232
|
continue;
|
|
3190
3233
|
}
|
|
3191
3234
|
let oe = b(R(K(re, s, Y)), m);
|
|
3192
|
-
O = a(o, oe), (r <= 1 || A(
|
|
3235
|
+
O = a(o, oe), (r <= 1 || A(z2, O) > te) && (_.push(O), z2 = O), E = l(o, oe), (r <= 1 || A(F, E) > te) && (M.push(E), F = E), H = i, X = s;
|
|
3193
3236
|
}
|
|
3194
3237
|
let P = e[0].point.slice(0, 2), k = e.length > 1 ? e[e.length - 1].point.slice(0, 2) : l(e[0].point, [1, 1]), Q = [], N = [];
|
|
3195
3238
|
if (e.length === 1) {
|
|
@@ -3690,7 +3733,7 @@ function exportSvg(input, files, opts = {}) {
|
|
|
3690
3733
|
const symbolId = `image-${file.id}`;
|
|
3691
3734
|
if (!imageSymbols.has(symbolId)) {
|
|
3692
3735
|
imageSymbols.add(symbolId);
|
|
3693
|
-
defs.unshift(`<symbol id="${esc(symbolId)}"><image href="${esc(file.dataURL)}" preserveAspectRatio="none" width="
|
|
3736
|
+
defs.unshift(`<symbol id="${esc(symbolId)}" viewBox="0 0 1 1" preserveAspectRatio="none"><image href="${esc(file.dataURL)}" preserveAspectRatio="none" width="1" height="1"/></symbol>`);
|
|
3694
3737
|
}
|
|
3695
3738
|
const flip = e.scale && (e.scale[0] !== 1 || e.scale[1] !== 1) ? `translate(${cx} ${cy}) scale(${e.scale[0]} ${e.scale[1]}) translate(${-cx} ${-cy})` : void 0;
|
|
3696
3739
|
let g = `<g${attrs({ transform: transform() })}><use${attrs({ href: `#${symbolId}`, width: Math.round(e.width), height: Math.round(e.height), opacity, transform: flip })}/></g>`;
|
|
@@ -3989,7 +4032,10 @@ async function exportImage(scene, args) {
|
|
|
3989
4032
|
var PROXY_HEADER = "X-Vizo-Proxy";
|
|
3990
4033
|
var PUT_TOOL = "put_diagram_elements";
|
|
3991
4034
|
var SCENE_TOOL = "get_board_scene";
|
|
3992
|
-
var
|
|
4035
|
+
var SIZES_TOOL = "get_image_sizes";
|
|
4036
|
+
var HIDDEN = /* @__PURE__ */ new Set([PUT_TOOL, SCENE_TOOL, SIZES_TOOL]);
|
|
4037
|
+
var IMAGE_TOOL = "add_image";
|
|
4038
|
+
var MAX_IMAGE_BYTES = 2e7;
|
|
3993
4039
|
var LOCAL_TOOLS = /* @__PURE__ */ new Set(["draw_diagram", "update_diagram"]);
|
|
3994
4040
|
var toolError = (text2) => ({ content: [{ type: "text", text: text2 }], isError: true });
|
|
3995
4041
|
function arg(name) {
|
|
@@ -4008,14 +4054,15 @@ async function main() {
|
|
|
4008
4054
|
process.exit(2);
|
|
4009
4055
|
}
|
|
4010
4056
|
const endpoint = new URL(base2.endsWith("/mcp") ? base2 : `${base2}/mcp`);
|
|
4011
|
-
const
|
|
4057
|
+
const site = base2.replace(/\/mcp$/, "");
|
|
4058
|
+
const upstream = new Client({ name: "vizo-mcp-proxy", version: "0.3.1" });
|
|
4012
4059
|
await upstream.connect(
|
|
4013
4060
|
new StreamableHTTPClientTransport(endpoint, { requestInit: { headers: { Authorization: `Bearer ${token}`, [PROXY_HEADER]: String(LAYOUT_VERSION) } } })
|
|
4014
4061
|
);
|
|
4015
4062
|
const caps = upstream.getServerCapabilities() ?? {};
|
|
4016
4063
|
log(`connected to ${endpoint.href} (local layout v${LAYOUT_VERSION})`);
|
|
4017
4064
|
const server = new Server(
|
|
4018
|
-
{ name: "vizo", version: "0.
|
|
4065
|
+
{ name: "vizo", version: "0.3.1" },
|
|
4019
4066
|
{
|
|
4020
4067
|
capabilities: {
|
|
4021
4068
|
tools: caps.tools ? {} : void 0,
|
|
@@ -4034,12 +4081,23 @@ This local proxy also has export_image: save a board or some of its diagrams as
|
|
|
4034
4081
|
const r = await upstream.listTools(req.params);
|
|
4035
4082
|
if (r.tools.some((t) => t.name === PUT_TOOL)) serverSupportsLocal = true;
|
|
4036
4083
|
serverSupportsExport = r.tools.some((t) => t.name === SCENE_TOOL);
|
|
4037
|
-
const tools = r.tools.filter((t) => !HIDDEN.has(t.name));
|
|
4084
|
+
const tools = r.tools.filter((t) => !HIDDEN.has(t.name)).map((t) => t.name === IMAGE_TOOL ? withPath(t) : t);
|
|
4038
4085
|
return { ...r, tools: serverSupportsExport && !r.nextCursor ? [...tools, EXPORT_TOOL] : tools };
|
|
4039
4086
|
});
|
|
4040
4087
|
server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
4041
4088
|
const { name, arguments: args = {} } = req.params;
|
|
4042
4089
|
if (name === EXPORT_TOOL.name) return exportLocally(args);
|
|
4090
|
+
if (name === IMAGE_TOOL && (args.path || args.url || args.dataURL) && !args.fileId) {
|
|
4091
|
+
const { path, url, dataURL, ...rest } = args;
|
|
4092
|
+
if ([path, url, dataURL].filter(Boolean).length !== 1) return toolError("Give exactly one of path, url, dataURL or fileId.");
|
|
4093
|
+
try {
|
|
4094
|
+
const image2 = path ? readLocalImage(String(path)) : url ? await downloadImage(String(url)) : decodeDataURL(String(dataURL));
|
|
4095
|
+
const fileId = await uploadImage(String(args.boardId ?? ""), image2);
|
|
4096
|
+
return upstream.callTool({ ...req.params, arguments: { ...rest, fileId } });
|
|
4097
|
+
} catch (e) {
|
|
4098
|
+
return toolError(e instanceof Error ? e.message : String(e));
|
|
4099
|
+
}
|
|
4100
|
+
}
|
|
4043
4101
|
if (!LOCAL_TOOLS.has(name)) return upstream.callTool(req.params);
|
|
4044
4102
|
serverSupportsLocal ??= (await upstream.listTools()).tools.some((t) => t.name === PUT_TOOL);
|
|
4045
4103
|
if (!serverSupportsLocal) {
|
|
@@ -4059,6 +4117,39 @@ This local proxy also has export_image: save a board or some of its diagrams as
|
|
|
4059
4117
|
server.setRequestHandler(ListPromptsRequestSchema, (req) => upstream.listPrompts(req.params));
|
|
4060
4118
|
server.setRequestHandler(GetPromptRequestSchema, (req) => upstream.getPrompt(req.params));
|
|
4061
4119
|
}
|
|
4120
|
+
async function uploadImage(boardRef, image2) {
|
|
4121
|
+
const boardId = boardIdOf(boardRef);
|
|
4122
|
+
const fileId = createHash("sha1").update(image2.bytes).digest("hex");
|
|
4123
|
+
const res = await fetch(`${site}/api/boards/${encodeURIComponent(boardId)}/files/${fileId}`, {
|
|
4124
|
+
method: "PUT",
|
|
4125
|
+
body: image2.bytes,
|
|
4126
|
+
headers: { Authorization: `Bearer ${token}`, "Content-Type": image2.type }
|
|
4127
|
+
});
|
|
4128
|
+
if (!res.ok) {
|
|
4129
|
+
const msg = await res.json().then((j) => j.error).catch(() => res.statusText);
|
|
4130
|
+
throw new Error(res.status === 404 ? `Board '${boardId}' not found or you have no access. Use list_boards.` : msg);
|
|
4131
|
+
}
|
|
4132
|
+
return fileId;
|
|
4133
|
+
}
|
|
4134
|
+
async function downloadImages(boardRef, files) {
|
|
4135
|
+
const boardId = boardIdOf(boardRef);
|
|
4136
|
+
const out = [];
|
|
4137
|
+
let next = 0;
|
|
4138
|
+
const worker = async () => {
|
|
4139
|
+
while (next < files.length) {
|
|
4140
|
+
const f = files[next++];
|
|
4141
|
+
const res = await fetch(`${site}/api/boards/${encodeURIComponent(boardId)}/files/${encodeURIComponent(f.id)}`, { headers: { Authorization: `Bearer ${token}` } });
|
|
4142
|
+
if (!res.ok) {
|
|
4143
|
+
log(`image ${f.id}: HTTP ${res.status}`);
|
|
4144
|
+
continue;
|
|
4145
|
+
}
|
|
4146
|
+
const type = res.headers.get("content-type") || f.mimeType;
|
|
4147
|
+
out.push({ id: f.id, mimeType: type, dataURL: `data:${type};base64,${Buffer.from(await res.arrayBuffer()).toString("base64")}` });
|
|
4148
|
+
}
|
|
4149
|
+
};
|
|
4150
|
+
await Promise.all(Array.from({ length: Math.min(6, files.length) }, worker));
|
|
4151
|
+
return out;
|
|
4152
|
+
}
|
|
4062
4153
|
async function renderLocally(name, args) {
|
|
4063
4154
|
let spec;
|
|
4064
4155
|
try {
|
|
@@ -4070,7 +4161,21 @@ This local proxy also has export_image: save a board or some of its diagrams as
|
|
|
4070
4161
|
const drawing = name === "draw_diagram";
|
|
4071
4162
|
const diagramId = drawing ? `d-${randomId(8)}` : String(args.diagramId ?? "");
|
|
4072
4163
|
if (!diagramId) return toolError("diagramId is required");
|
|
4073
|
-
const
|
|
4164
|
+
const imageIds = specImageIds(spec);
|
|
4165
|
+
let images = {};
|
|
4166
|
+
if (imageIds.length) {
|
|
4167
|
+
const res = await upstream.callTool({ name: SIZES_TOOL, arguments: { boardId: args.boardId, fileIds: imageIds } });
|
|
4168
|
+
const body = res.content?.find((c) => c.type === "text")?.text ?? "";
|
|
4169
|
+
if (res.isError) return toolError(body || "Couldn't read the board's images.");
|
|
4170
|
+
images = Object.fromEntries(JSON.parse(body).map((f) => [f.id, { width: f.width, height: f.height }]));
|
|
4171
|
+
}
|
|
4172
|
+
let r;
|
|
4173
|
+
try {
|
|
4174
|
+
r = await renderDiagram(spec, { diagramId, images });
|
|
4175
|
+
} catch (e) {
|
|
4176
|
+
if (e instanceof SpecError) return toolError(e.message);
|
|
4177
|
+
throw e;
|
|
4178
|
+
}
|
|
4074
4179
|
return upstream.callTool({
|
|
4075
4180
|
name: PUT_TOOL,
|
|
4076
4181
|
arguments: {
|
|
@@ -4086,11 +4191,14 @@ This local proxy also has export_image: save a board or some of its diagrams as
|
|
|
4086
4191
|
}
|
|
4087
4192
|
async function exportLocally(args) {
|
|
4088
4193
|
if (!args?.boardId) return toolError("boardId is required");
|
|
4089
|
-
const r = await upstream.callTool({ name: SCENE_TOOL, arguments: { boardId: args.boardId } });
|
|
4194
|
+
const r = await upstream.callTool({ name: SCENE_TOOL, arguments: { boardId: args.boardId, fileRefs: true } });
|
|
4090
4195
|
const body = r.content?.find((c) => c.type === "text")?.text ?? "";
|
|
4091
4196
|
if (r.isError) return toolError(body || "Couldn't read the board.");
|
|
4092
4197
|
try {
|
|
4093
|
-
|
|
4198
|
+
const scene = JSON.parse(body);
|
|
4199
|
+
const refs = scene.files.filter((f) => !f.dataURL);
|
|
4200
|
+
if (refs.length) scene.files = [...scene.files.filter((f) => f.dataURL), ...await downloadImages(args.boardId, refs)];
|
|
4201
|
+
return await exportImage(scene, args);
|
|
4094
4202
|
} catch (e) {
|
|
4095
4203
|
if (e instanceof ExportError) return toolError(e.message);
|
|
4096
4204
|
log("export failed:", e);
|
|
@@ -4106,6 +4214,64 @@ This local proxy also has export_image: save a board or some of its diagrams as
|
|
|
4106
4214
|
process.on("SIGTERM", shutdown);
|
|
4107
4215
|
process.stdin.on("close", shutdown);
|
|
4108
4216
|
}
|
|
4217
|
+
function withPath(t) {
|
|
4218
|
+
const schema = t.inputSchema ?? { type: "object", properties: {} };
|
|
4219
|
+
return {
|
|
4220
|
+
...t,
|
|
4221
|
+
description: `${t.description ?? ""} Here you can also pass a local file \`path\`, and url, path and dataURL images can be up to ${MAX_IMAGE_BYTES / 1e6} MB (this proxy uploads them).`,
|
|
4222
|
+
inputSchema: {
|
|
4223
|
+
...schema,
|
|
4224
|
+
properties: { path: { type: "string", description: "Image file on this machine (absolute, or relative to the working directory); use instead of url/dataURL/fileId" }, ...schema.properties }
|
|
4225
|
+
}
|
|
4226
|
+
};
|
|
4227
|
+
}
|
|
4228
|
+
var boardIdOf = (ref) => {
|
|
4229
|
+
const id2 = ref.match(/\/b\/([A-Za-z0-9_-]+)/)?.[1] ?? ref.trim();
|
|
4230
|
+
if (!id2) throw new Error("boardId is required");
|
|
4231
|
+
return id2;
|
|
4232
|
+
};
|
|
4233
|
+
var tooLarge = (size) => `Image too large (${(size / 1e6).toFixed(1)} MB; max ${MAX_IMAGE_BYTES / 1e6} MB). Use a smaller or more compressed image.`;
|
|
4234
|
+
function readLocalImage(path) {
|
|
4235
|
+
const full = resolve2(path);
|
|
4236
|
+
let size;
|
|
4237
|
+
try {
|
|
4238
|
+
size = statSync(full).size;
|
|
4239
|
+
} catch {
|
|
4240
|
+
throw new Error(`No such file: ${full}`);
|
|
4241
|
+
}
|
|
4242
|
+
if (size > MAX_IMAGE_BYTES) throw new Error(tooLarge(size));
|
|
4243
|
+
return { bytes: readFileSync3(full), type: MIME[full.split(".").pop()?.toLowerCase() ?? ""] ?? "application/octet-stream" };
|
|
4244
|
+
}
|
|
4245
|
+
async function downloadImage(raw) {
|
|
4246
|
+
let url;
|
|
4247
|
+
try {
|
|
4248
|
+
url = new URL(raw);
|
|
4249
|
+
} catch {
|
|
4250
|
+
throw new Error(`url '${raw}' is not a valid URL`);
|
|
4251
|
+
}
|
|
4252
|
+
if (url.protocol !== "https:" && url.protocol !== "http:") throw new Error("url must be http(s)");
|
|
4253
|
+
const res = await fetch(url, { headers: { accept: "image/*", "user-agent": "Mozilla/5.0 (compatible; vizo-mcp)" }, signal: AbortSignal.timeout(3e4) });
|
|
4254
|
+
if (!res.ok || !res.body) throw new Error(`Couldn't fetch ${url.href}: HTTP ${res.status}`);
|
|
4255
|
+
const declared = Number(res.headers.get("content-length") ?? 0);
|
|
4256
|
+
if (declared > MAX_IMAGE_BYTES) throw new Error(tooLarge(declared));
|
|
4257
|
+
const parts = [];
|
|
4258
|
+
let size = 0;
|
|
4259
|
+
for await (const chunk of res.body) {
|
|
4260
|
+
size += chunk.length;
|
|
4261
|
+
if (size > MAX_IMAGE_BYTES) throw new Error(tooLarge(size));
|
|
4262
|
+
parts.push(Buffer.from(chunk));
|
|
4263
|
+
}
|
|
4264
|
+
return { bytes: Buffer.concat(parts), type: res.headers.get("content-type") ?? "application/octet-stream" };
|
|
4265
|
+
}
|
|
4266
|
+
function decodeDataURL(dataURL) {
|
|
4267
|
+
const m = dataURL.match(/^data:([^;,]*)(;base64)?,/);
|
|
4268
|
+
if (!m) throw new Error("dataURL must look like data:image/png;base64,....");
|
|
4269
|
+
const body = dataURL.slice(m[0].length);
|
|
4270
|
+
const bytes = m[2] ? Buffer.from(body, "base64") : Buffer.from(decodeURIComponent(body));
|
|
4271
|
+
if (bytes.length > MAX_IMAGE_BYTES) throw new Error(tooLarge(bytes.length));
|
|
4272
|
+
return { bytes, type: m[1] || "application/octet-stream" };
|
|
4273
|
+
}
|
|
4274
|
+
var MIME = { png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif", webp: "image/webp", bmp: "image/bmp", svg: "image/svg+xml" };
|
|
4109
4275
|
main().catch((e) => {
|
|
4110
4276
|
log(e instanceof Error ? e.message : e);
|
|
4111
4277
|
process.exit(1);
|