vizo-mcp 0.2.0 → 0.3.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 +6 -2
- package/dist/index.js +134 -11
- 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: the proxy reads the file and uploads it. 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,8 @@ import {
|
|
|
15
15
|
ListToolsRequestSchema,
|
|
16
16
|
ReadResourceRequestSchema
|
|
17
17
|
} from "@modelcontextprotocol/sdk/types.js";
|
|
18
|
+
import { readFileSync as readFileSync3, statSync } from "node:fs";
|
|
19
|
+
import { resolve as resolve2 } from "node:path";
|
|
18
20
|
|
|
19
21
|
// ../diagram/src/schema.ts
|
|
20
22
|
import { z } from "zod";
|
|
@@ -41,11 +43,16 @@ var Group = z.object({
|
|
|
41
43
|
children: z.array(id).describe("Node ids (or nested group ids) inside this group"),
|
|
42
44
|
color: Color.optional()
|
|
43
45
|
});
|
|
46
|
+
var nodeImage = {
|
|
47
|
+
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."),
|
|
48
|
+
imageSize: z.number().min(16).max(800).optional().describe("Longer side of the picture in board units (default 96)")
|
|
49
|
+
};
|
|
44
50
|
var FlowchartNode = z.object({
|
|
45
51
|
id,
|
|
46
52
|
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()
|
|
53
|
+
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."),
|
|
54
|
+
color: Color.optional(),
|
|
55
|
+
...nodeImage
|
|
49
56
|
});
|
|
50
57
|
var FlowchartSpec = z.object({
|
|
51
58
|
kind: z.literal("flowchart"),
|
|
@@ -61,7 +68,8 @@ var ComponentNode = z.object({
|
|
|
61
68
|
type: z.enum(["service", "database", "queue", "cache", "client", "external", "storage", "function", "user", "generic"]).optional().describe("Drives the shape and color"),
|
|
62
69
|
tech: z.string().max(60).optional().describe("Technology caption, e.g. 'PostgreSQL', 'React'"),
|
|
63
70
|
description: z.string().max(200).optional(),
|
|
64
|
-
color: Color.optional()
|
|
71
|
+
color: Color.optional(),
|
|
72
|
+
...nodeImage
|
|
65
73
|
});
|
|
66
74
|
var ComponentSpec = z.object({
|
|
67
75
|
kind: z.literal("component"),
|
|
@@ -159,6 +167,10 @@ var DiagramSpec = z.discriminatedUnion("kind", [
|
|
|
159
167
|
StateSpec,
|
|
160
168
|
MindmapSpec
|
|
161
169
|
]);
|
|
170
|
+
function specImageIds(spec) {
|
|
171
|
+
const nodes = spec.kind === "flowchart" || spec.kind === "component" ? spec.nodes : [];
|
|
172
|
+
return [...new Set(nodes.flatMap((n) => n.image ? [n.image] : []))];
|
|
173
|
+
}
|
|
162
174
|
function validateSpec(spec) {
|
|
163
175
|
const errors = [];
|
|
164
176
|
const check = (ids, refs, what, groups) => {
|
|
@@ -433,6 +445,15 @@ function arrowLabel(a2, value, o = {}) {
|
|
|
433
445
|
a2.boundElements = [...a2.boundElements ?? [], { id: t.id, type: "text" }];
|
|
434
446
|
return t;
|
|
435
447
|
}
|
|
448
|
+
function image(id2, x, y, w, h, fileId, o = {}) {
|
|
449
|
+
return {
|
|
450
|
+
...base(id2, "image", x, y, w, h, { strokeColor: "transparent", ...o }),
|
|
451
|
+
fileId,
|
|
452
|
+
status: "saved",
|
|
453
|
+
scale: [1, 1],
|
|
454
|
+
crop: null
|
|
455
|
+
};
|
|
456
|
+
}
|
|
436
457
|
function frame(id2, x, y, w, h, name, o = {}) {
|
|
437
458
|
return { ...base(id2, "frame", x, y, w, h, { roughness: 0, strokeColor: "#bbb", ...o }), roundness: null, name };
|
|
438
459
|
}
|
|
@@ -677,6 +698,43 @@ function labelledNode(ctx, nodeId, label2, kind, colors, opts = {}) {
|
|
|
677
698
|
}
|
|
678
699
|
};
|
|
679
700
|
}
|
|
701
|
+
var CARD_PAD = 10;
|
|
702
|
+
var CARD_GAP = 8;
|
|
703
|
+
var BOUND_TEXT_PADDING = 5;
|
|
704
|
+
function imageCard(ctx, nodeId, label2, fileId, size, colors, opts = {}) {
|
|
705
|
+
const fontSize = opts.fontSize ?? NODE_FONT;
|
|
706
|
+
const natural = ctx.images[fileId];
|
|
707
|
+
const k = size / Math.max(natural.width, natural.height);
|
|
708
|
+
const iw = Math.round(natural.width * k);
|
|
709
|
+
const ih = Math.round(natural.height * k);
|
|
710
|
+
const wrapped = wrapText(label2, fontSize, ctx.look.font, Math.max(MAX_LABEL_W, iw));
|
|
711
|
+
const m = measureText(wrapped, fontSize, ctx.look.font);
|
|
712
|
+
const w = Math.ceil(Math.max(opts.minW ?? 120, iw + CARD_PAD * 2, m.width + 10 + CARD_PAD * 2));
|
|
713
|
+
const h = Math.ceil(CARD_PAD + ih + CARD_GAP + m.height + BOUND_TEXT_PADDING);
|
|
714
|
+
const id2 = `${ctx.idPrefix}.n.${nodeId}`;
|
|
715
|
+
return {
|
|
716
|
+
id: nodeId,
|
|
717
|
+
width: w,
|
|
718
|
+
height: h,
|
|
719
|
+
bindId: id2,
|
|
720
|
+
render: (x, y) => {
|
|
721
|
+
const groupIds = [`${id2}.grp`];
|
|
722
|
+
const s = shape("rectangle", id2, x, y, w, h, {
|
|
723
|
+
strokeColor: colors.stroke,
|
|
724
|
+
backgroundColor: colors.bg,
|
|
725
|
+
strokeStyle: opts.strokeStyle,
|
|
726
|
+
roughness: ctx.look.roughness,
|
|
727
|
+
groupIds,
|
|
728
|
+
customData: ctx.cd("node", nodeId)
|
|
729
|
+
});
|
|
730
|
+
const pic = image(`${id2}.img`, x + (w - iw) / 2, y + CARD_PAD, iw, ih, fileId, { groupIds, customData: ctx.cd("node-image", nodeId) });
|
|
731
|
+
const t = boundText(s, wrapped, { fontSize, font: ctx.look.font, strokeColor: INK, verticalAlign: "bottom" });
|
|
732
|
+
t.y = y + h - BOUND_TEXT_PADDING - t.height;
|
|
733
|
+
return [s, pic, t];
|
|
734
|
+
}
|
|
735
|
+
};
|
|
736
|
+
}
|
|
737
|
+
var DEFAULT_IMAGE_SIZE = 96;
|
|
680
738
|
var fill = (c) => ({ stroke: INK, bg: PALETTE[c][1] });
|
|
681
739
|
function flowchartModel(spec, ctx) {
|
|
682
740
|
const nodes = spec.nodes.map((n) => {
|
|
@@ -691,6 +749,11 @@ function flowchartModel(spec, ctx) {
|
|
|
691
749
|
note: ["rectangle", "yellow"]
|
|
692
750
|
};
|
|
693
751
|
const [kind, color] = def[shp];
|
|
752
|
+
if (n.image)
|
|
753
|
+
return imageCard(ctx, n.id, n.label, n.image, n.imageSize ?? DEFAULT_IMAGE_SIZE, fill(n.color ?? color), {
|
|
754
|
+
strokeStyle: shp === "note" ? "dashed" : "solid",
|
|
755
|
+
fontSize: shp === "note" ? 16 : NODE_FONT
|
|
756
|
+
});
|
|
694
757
|
return labelledNode(ctx, n.id, n.label, kind, fill(n.color ?? color), {
|
|
695
758
|
strokeStyle: shp === "note" ? "dashed" : "solid",
|
|
696
759
|
fontSize: shp === "note" ? 16 : NODE_FONT
|
|
@@ -725,6 +788,12 @@ function componentModel(spec, ctx) {
|
|
|
725
788
|
if (n.tech) lines.push(`[${n.tech}]`);
|
|
726
789
|
else if (st.caption) lines.push(`\xAB${st.caption}\xBB`);
|
|
727
790
|
if (n.description) lines.push(wrapText(n.description, 16, ctx.look.font, MAX_LABEL_W));
|
|
791
|
+
if (n.image)
|
|
792
|
+
return imageCard(ctx, n.id, lines.join("\n"), n.image, n.imageSize ?? DEFAULT_IMAGE_SIZE, fill(n.color ?? st.color), {
|
|
793
|
+
strokeStyle: st.dashed ? "dashed" : "solid",
|
|
794
|
+
fontSize: n.description ? 16 : 18,
|
|
795
|
+
minW: 160
|
|
796
|
+
});
|
|
728
797
|
return labelledNode(ctx, n.id, lines.join("\n"), st.kind, fill(n.color ?? st.color), {
|
|
729
798
|
strokeStyle: st.dashed ? "dashed" : "solid",
|
|
730
799
|
fontSize: n.description ? 16 : 18,
|
|
@@ -1215,7 +1284,7 @@ function layoutSequence(spec, ctx) {
|
|
|
1215
1284
|
|
|
1216
1285
|
// ../diagram/src/render.ts
|
|
1217
1286
|
var FRAME_PAD = 40;
|
|
1218
|
-
var LAYOUT_VERSION =
|
|
1287
|
+
var LAYOUT_VERSION = 2;
|
|
1219
1288
|
var SpecError = class extends Error {
|
|
1220
1289
|
constructor(issues) {
|
|
1221
1290
|
super(`Invalid diagram spec:
|
|
@@ -1238,7 +1307,10 @@ async function renderDiagram(spec, opts) {
|
|
|
1238
1307
|
const look = LOOKS[spec.style ?? "sketch"];
|
|
1239
1308
|
const idPrefix = opts.diagramId;
|
|
1240
1309
|
const cd = (role, ref) => vizoData(opts.diagramId, role, ref);
|
|
1241
|
-
const
|
|
1310
|
+
const images = opts.images ?? {};
|
|
1311
|
+
const missing = specImageIds(spec).filter((id2) => !images[id2]);
|
|
1312
|
+
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`));
|
|
1313
|
+
const ctx = { idPrefix, look, cd, images };
|
|
1242
1314
|
let els;
|
|
1243
1315
|
switch (spec.kind) {
|
|
1244
1316
|
case "flowchart":
|
|
@@ -3690,7 +3762,7 @@ function exportSvg(input, files, opts = {}) {
|
|
|
3690
3762
|
const symbolId = `image-${file.id}`;
|
|
3691
3763
|
if (!imageSymbols.has(symbolId)) {
|
|
3692
3764
|
imageSymbols.add(symbolId);
|
|
3693
|
-
defs.unshift(`<symbol id="${esc(symbolId)}"><image href="${esc(file.dataURL)}" preserveAspectRatio="none" width="
|
|
3765
|
+
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
3766
|
}
|
|
3695
3767
|
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
3768
|
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 +4061,10 @@ async function exportImage(scene, args) {
|
|
|
3989
4061
|
var PROXY_HEADER = "X-Vizo-Proxy";
|
|
3990
4062
|
var PUT_TOOL = "put_diagram_elements";
|
|
3991
4063
|
var SCENE_TOOL = "get_board_scene";
|
|
3992
|
-
var
|
|
4064
|
+
var SIZES_TOOL = "get_image_sizes";
|
|
4065
|
+
var HIDDEN = /* @__PURE__ */ new Set([PUT_TOOL, SCENE_TOOL, SIZES_TOOL]);
|
|
4066
|
+
var IMAGE_TOOL = "add_image";
|
|
4067
|
+
var MAX_IMAGE_BYTES = 47e5;
|
|
3993
4068
|
var LOCAL_TOOLS = /* @__PURE__ */ new Set(["draw_diagram", "update_diagram"]);
|
|
3994
4069
|
var toolError = (text2) => ({ content: [{ type: "text", text: text2 }], isError: true });
|
|
3995
4070
|
function arg(name) {
|
|
@@ -4008,14 +4083,14 @@ async function main() {
|
|
|
4008
4083
|
process.exit(2);
|
|
4009
4084
|
}
|
|
4010
4085
|
const endpoint = new URL(base2.endsWith("/mcp") ? base2 : `${base2}/mcp`);
|
|
4011
|
-
const upstream = new Client({ name: "vizo-mcp-proxy", version: "0.
|
|
4086
|
+
const upstream = new Client({ name: "vizo-mcp-proxy", version: "0.3.0" });
|
|
4012
4087
|
await upstream.connect(
|
|
4013
4088
|
new StreamableHTTPClientTransport(endpoint, { requestInit: { headers: { Authorization: `Bearer ${token}`, [PROXY_HEADER]: String(LAYOUT_VERSION) } } })
|
|
4014
4089
|
);
|
|
4015
4090
|
const caps = upstream.getServerCapabilities() ?? {};
|
|
4016
4091
|
log(`connected to ${endpoint.href} (local layout v${LAYOUT_VERSION})`);
|
|
4017
4092
|
const server = new Server(
|
|
4018
|
-
{ name: "vizo", version: "0.
|
|
4093
|
+
{ name: "vizo", version: "0.3.0" },
|
|
4019
4094
|
{
|
|
4020
4095
|
capabilities: {
|
|
4021
4096
|
tools: caps.tools ? {} : void 0,
|
|
@@ -4034,12 +4109,22 @@ This local proxy also has export_image: save a board or some of its diagrams as
|
|
|
4034
4109
|
const r = await upstream.listTools(req.params);
|
|
4035
4110
|
if (r.tools.some((t) => t.name === PUT_TOOL)) serverSupportsLocal = true;
|
|
4036
4111
|
serverSupportsExport = r.tools.some((t) => t.name === SCENE_TOOL);
|
|
4037
|
-
const tools = r.tools.filter((t) => !HIDDEN.has(t.name));
|
|
4112
|
+
const tools = r.tools.filter((t) => !HIDDEN.has(t.name)).map((t) => t.name === IMAGE_TOOL ? withPath(t) : t);
|
|
4038
4113
|
return { ...r, tools: serverSupportsExport && !r.nextCursor ? [...tools, EXPORT_TOOL] : tools };
|
|
4039
4114
|
});
|
|
4040
4115
|
server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
4041
4116
|
const { name, arguments: args = {} } = req.params;
|
|
4042
4117
|
if (name === EXPORT_TOOL.name) return exportLocally(args);
|
|
4118
|
+
if (name === IMAGE_TOOL && typeof args.path === "string") {
|
|
4119
|
+
const { path, ...rest } = args;
|
|
4120
|
+
let dataURL;
|
|
4121
|
+
try {
|
|
4122
|
+
dataURL = readImage(path);
|
|
4123
|
+
} catch (e) {
|
|
4124
|
+
return toolError(e instanceof Error ? e.message : String(e));
|
|
4125
|
+
}
|
|
4126
|
+
return upstream.callTool({ ...req.params, arguments: { ...rest, dataURL } });
|
|
4127
|
+
}
|
|
4043
4128
|
if (!LOCAL_TOOLS.has(name)) return upstream.callTool(req.params);
|
|
4044
4129
|
serverSupportsLocal ??= (await upstream.listTools()).tools.some((t) => t.name === PUT_TOOL);
|
|
4045
4130
|
if (!serverSupportsLocal) {
|
|
@@ -4070,7 +4155,21 @@ This local proxy also has export_image: save a board or some of its diagrams as
|
|
|
4070
4155
|
const drawing = name === "draw_diagram";
|
|
4071
4156
|
const diagramId = drawing ? `d-${randomId(8)}` : String(args.diagramId ?? "");
|
|
4072
4157
|
if (!diagramId) return toolError("diagramId is required");
|
|
4073
|
-
const
|
|
4158
|
+
const imageIds = specImageIds(spec);
|
|
4159
|
+
let images = {};
|
|
4160
|
+
if (imageIds.length) {
|
|
4161
|
+
const res = await upstream.callTool({ name: SIZES_TOOL, arguments: { boardId: args.boardId, fileIds: imageIds } });
|
|
4162
|
+
const body = res.content?.find((c) => c.type === "text")?.text ?? "";
|
|
4163
|
+
if (res.isError) return toolError(body || "Couldn't read the board's images.");
|
|
4164
|
+
images = Object.fromEntries(JSON.parse(body).map((f) => [f.id, { width: f.width, height: f.height }]));
|
|
4165
|
+
}
|
|
4166
|
+
let r;
|
|
4167
|
+
try {
|
|
4168
|
+
r = await renderDiagram(spec, { diagramId, images });
|
|
4169
|
+
} catch (e) {
|
|
4170
|
+
if (e instanceof SpecError) return toolError(e.message);
|
|
4171
|
+
throw e;
|
|
4172
|
+
}
|
|
4074
4173
|
return upstream.callTool({
|
|
4075
4174
|
name: PUT_TOOL,
|
|
4076
4175
|
arguments: {
|
|
@@ -4106,6 +4205,30 @@ This local proxy also has export_image: save a board or some of its diagrams as
|
|
|
4106
4205
|
process.on("SIGTERM", shutdown);
|
|
4107
4206
|
process.stdin.on("close", shutdown);
|
|
4108
4207
|
}
|
|
4208
|
+
function withPath(t) {
|
|
4209
|
+
const schema = t.inputSchema ?? { type: "object", properties: {} };
|
|
4210
|
+
return {
|
|
4211
|
+
...t,
|
|
4212
|
+
description: `${t.description ?? ""} Here you can also pass a local file \`path\` instead of url/dataURL.`,
|
|
4213
|
+
inputSchema: {
|
|
4214
|
+
...schema,
|
|
4215
|
+
properties: { path: { type: "string", description: "Image file on this machine (absolute, or relative to the working directory); use instead of url/dataURL" }, ...schema.properties }
|
|
4216
|
+
}
|
|
4217
|
+
};
|
|
4218
|
+
}
|
|
4219
|
+
var MIME = { png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif", webp: "image/webp", bmp: "image/bmp", svg: "image/svg+xml" };
|
|
4220
|
+
function readImage(path) {
|
|
4221
|
+
const full = resolve2(path);
|
|
4222
|
+
let size;
|
|
4223
|
+
try {
|
|
4224
|
+
size = statSync(full).size;
|
|
4225
|
+
} catch {
|
|
4226
|
+
throw new Error(`No such file: ${full}`);
|
|
4227
|
+
}
|
|
4228
|
+
if (size > MAX_IMAGE_BYTES) throw new Error(`Image too large (${(size / 1e6).toFixed(1)} MB; max ${MAX_IMAGE_BYTES / 1e6} MB). Use a smaller or more compressed image.`);
|
|
4229
|
+
const mime = MIME[full.split(".").pop()?.toLowerCase() ?? ""] ?? "application/octet-stream";
|
|
4230
|
+
return `data:${mime};base64,${readFileSync3(full).toString("base64")}`;
|
|
4231
|
+
}
|
|
4109
4232
|
main().catch((e) => {
|
|
4110
4233
|
log(e instanceof Error ? e.message : e);
|
|
4111
4234
|
process.exit(1);
|