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.
Files changed (3) hide show
  1. package/README.md +6 -2
  2. package/dist/index.js +212 -46
  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 (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 = 1;
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 ctx = { idPrefix, look, cd };
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, z3 = e[0].point, F = z3, O = z3, E = F, J = false;
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
- z3 = O, F = E, ne && (J = true);
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(z3, O) > te) && (_.push(O), z3 = O), E = l(o, oe), (r <= 1 || A(F, E) > te) && (M.push(E), F = E), H = i, X = s;
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="100%" height="100%"/></symbol>`);
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 HIDDEN = /* @__PURE__ */ new Set([PUT_TOOL, SCENE_TOOL]);
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 upstream = new Client({ name: "vizo-mcp-proxy", version: "0.2.0" });
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.2.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 r = await renderDiagram(spec, { diagramId });
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
- return await exportImage(JSON.parse(body), args);
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);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vizo-mcp",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
4
4
  "description": "stdio MCP proxy for a Vizo whiteboard server",
5
5
  "license": "MIT",
6
6
  "type": "module",