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.
Files changed (3) hide show
  1. package/README.md +6 -2
  2. package/dist/index.js +134 -11
  3. 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, e.g. `npx -y vizo-mcp@latest`.
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 = 1;
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 ctx = { idPrefix, look, cd };
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="100%" height="100%"/></symbol>`);
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 HIDDEN = /* @__PURE__ */ new Set([PUT_TOOL, SCENE_TOOL]);
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.2.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.2.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 r = await renderDiagram(spec, { diagramId });
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);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vizo-mcp",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "stdio MCP proxy for a Vizo whiteboard server",
5
5
  "license": "MIT",
6
6
  "type": "module",