vizo-mcp 0.3.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 +1 -1
  2. package/dist/index.js +93 -50
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -12,7 +12,7 @@ Diagrams (`draw_diagram`, `update_diagram`) are laid out on your machine and sen
12
12
 
13
13
  ## Adding images
14
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.
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.
16
16
 
17
17
  ## Exporting images
18
18
 
package/dist/index.js CHANGED
@@ -15,6 +15,7 @@ import {
15
15
  ListToolsRequestSchema,
16
16
  ReadResourceRequestSchema
17
17
  } from "@modelcontextprotocol/sdk/types.js";
18
+ import { createHash } from "node:crypto";
18
19
  import { readFileSync as readFileSync3, statSync } from "node:fs";
19
20
  import { resolve as resolve2 } from "node:path";
20
21
 
@@ -1349,36 +1350,6 @@ function defaultTitle(spec) {
1349
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];
1350
1351
  }
1351
1352
 
1352
- // ../diagram/src/primitives.ts
1353
- import { z as z2 } from "zod";
1354
- var Near = z2.object({
1355
- elementId: z2.string(),
1356
- side: z2.enum(["right", "left", "above", "below"]).optional(),
1357
- gap: z2.number().min(0).max(1e3).optional()
1358
- });
1359
- var ShapeInput = z2.object({
1360
- id: z2.string().max(64).regex(/^[A-Za-z0-9_.:-]+$/).optional().describe("Optional id to reference later; generated if omitted"),
1361
- type: z2.enum(["rectangle", "ellipse", "diamond", "text", "sticky", "code"]),
1362
- text: z2.string().max(4e3).optional(),
1363
- x: z2.number().optional(),
1364
- y: z2.number().optional(),
1365
- width: z2.number().min(10).max(4e3).optional(),
1366
- height: z2.number().min(10).max(4e3).optional(),
1367
- color: Color.optional(),
1368
- fontSize: z2.number().min(8).max(96).optional(),
1369
- dashed: z2.boolean().optional(),
1370
- near: Near.optional().describe("Place relative to an existing element instead of x/y")
1371
- });
1372
- var ConnectInput = z2.object({
1373
- from: z2.string(),
1374
- to: z2.string(),
1375
- label: z2.string().max(200).optional(),
1376
- dashed: z2.boolean().optional(),
1377
- arrowhead: z2.enum(["arrow", "triangle", "none"]).optional(),
1378
- bidirectional: z2.boolean().optional(),
1379
- color: Color.optional()
1380
- });
1381
-
1382
1353
  // src/export.ts
1383
1354
  import { existsSync, mkdirSync, readFileSync as readFileSync2, writeFileSync } from "node:fs";
1384
1355
  import { dirname, extname, resolve } from "node:path";
@@ -3234,7 +3205,7 @@ function ce(e, t = {}) {
3234
3205
  o = C(1, r + (W2 - r) * (s * pe));
3235
3206
  }
3236
3207
  return (r + o) / 2;
3237
- }, 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;
3238
3209
  for (let r = 0; r < e.length; r++) {
3239
3210
  let { pressure: i } = e[r], { point: o, vector: s, distance: W2, runningLength: I } = e[r];
3240
3211
  if (r < e.length - 1 && p - I < 3) continue;
@@ -3252,7 +3223,7 @@ function ce(e, t = {}) {
3252
3223
  if (be || ne) {
3253
3224
  let v = b(R(X), m);
3254
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);
3255
- z3 = O, F = E, ne && (J = true);
3226
+ z2 = O, F = E, ne && (J = true);
3256
3227
  continue;
3257
3228
  }
3258
3229
  if (J = false, r === e.length - 1) {
@@ -3261,7 +3232,7 @@ function ce(e, t = {}) {
3261
3232
  continue;
3262
3233
  }
3263
3234
  let oe = b(R(K(re, s, Y)), m);
3264
- 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;
3265
3236
  }
3266
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 = [];
3267
3238
  if (e.length === 1) {
@@ -4064,7 +4035,7 @@ var SCENE_TOOL = "get_board_scene";
4064
4035
  var SIZES_TOOL = "get_image_sizes";
4065
4036
  var HIDDEN = /* @__PURE__ */ new Set([PUT_TOOL, SCENE_TOOL, SIZES_TOOL]);
4066
4037
  var IMAGE_TOOL = "add_image";
4067
- var MAX_IMAGE_BYTES = 47e5;
4038
+ var MAX_IMAGE_BYTES = 2e7;
4068
4039
  var LOCAL_TOOLS = /* @__PURE__ */ new Set(["draw_diagram", "update_diagram"]);
4069
4040
  var toolError = (text2) => ({ content: [{ type: "text", text: text2 }], isError: true });
4070
4041
  function arg(name) {
@@ -4083,14 +4054,15 @@ async function main() {
4083
4054
  process.exit(2);
4084
4055
  }
4085
4056
  const endpoint = new URL(base2.endsWith("/mcp") ? base2 : `${base2}/mcp`);
4086
- const upstream = new Client({ name: "vizo-mcp-proxy", version: "0.3.0" });
4057
+ const site = base2.replace(/\/mcp$/, "");
4058
+ const upstream = new Client({ name: "vizo-mcp-proxy", version: "0.3.1" });
4087
4059
  await upstream.connect(
4088
4060
  new StreamableHTTPClientTransport(endpoint, { requestInit: { headers: { Authorization: `Bearer ${token}`, [PROXY_HEADER]: String(LAYOUT_VERSION) } } })
4089
4061
  );
4090
4062
  const caps = upstream.getServerCapabilities() ?? {};
4091
4063
  log(`connected to ${endpoint.href} (local layout v${LAYOUT_VERSION})`);
4092
4064
  const server = new Server(
4093
- { name: "vizo", version: "0.3.0" },
4065
+ { name: "vizo", version: "0.3.1" },
4094
4066
  {
4095
4067
  capabilities: {
4096
4068
  tools: caps.tools ? {} : void 0,
@@ -4115,15 +4087,16 @@ This local proxy also has export_image: save a board or some of its diagrams as
4115
4087
  server.setRequestHandler(CallToolRequestSchema, async (req) => {
4116
4088
  const { name, arguments: args = {} } = req.params;
4117
4089
  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;
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.");
4121
4093
  try {
4122
- dataURL = readImage(path);
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 } });
4123
4097
  } catch (e) {
4124
4098
  return toolError(e instanceof Error ? e.message : String(e));
4125
4099
  }
4126
- return upstream.callTool({ ...req.params, arguments: { ...rest, dataURL } });
4127
4100
  }
4128
4101
  if (!LOCAL_TOOLS.has(name)) return upstream.callTool(req.params);
4129
4102
  serverSupportsLocal ??= (await upstream.listTools()).tools.some((t) => t.name === PUT_TOOL);
@@ -4144,6 +4117,39 @@ This local proxy also has export_image: save a board or some of its diagrams as
4144
4117
  server.setRequestHandler(ListPromptsRequestSchema, (req) => upstream.listPrompts(req.params));
4145
4118
  server.setRequestHandler(GetPromptRequestSchema, (req) => upstream.getPrompt(req.params));
4146
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
+ }
4147
4153
  async function renderLocally(name, args) {
4148
4154
  let spec;
4149
4155
  try {
@@ -4185,11 +4191,14 @@ This local proxy also has export_image: save a board or some of its diagrams as
4185
4191
  }
4186
4192
  async function exportLocally(args) {
4187
4193
  if (!args?.boardId) return toolError("boardId is required");
4188
- 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 } });
4189
4195
  const body = r.content?.find((c) => c.type === "text")?.text ?? "";
4190
4196
  if (r.isError) return toolError(body || "Couldn't read the board.");
4191
4197
  try {
4192
- 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);
4193
4202
  } catch (e) {
4194
4203
  if (e instanceof ExportError) return toolError(e.message);
4195
4204
  log("export failed:", e);
@@ -4209,15 +4218,20 @@ function withPath(t) {
4209
4218
  const schema = t.inputSchema ?? { type: "object", properties: {} };
4210
4219
  return {
4211
4220
  ...t,
4212
- description: `${t.description ?? ""} Here you can also pass a local file \`path\` instead of url/dataURL.`,
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).`,
4213
4222
  inputSchema: {
4214
4223
  ...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 }
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 }
4216
4225
  }
4217
4226
  };
4218
4227
  }
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) {
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) {
4221
4235
  const full = resolve2(path);
4222
4236
  let size;
4223
4237
  try {
@@ -4225,10 +4239,39 @@ function readImage(path) {
4225
4239
  } catch {
4226
4240
  throw new Error(`No such file: ${full}`);
4227
4241
  }
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")}`;
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" };
4231
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" };
4232
4275
  main().catch((e) => {
4233
4276
  log(e instanceof Error ? e.message : e);
4234
4277
  process.exit(1);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vizo-mcp",
3
- "version": "0.3.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",