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.
- package/README.md +1 -1
- package/dist/index.js +93 -50
- 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
|
|
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,
|
|
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
|
-
|
|
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(
|
|
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 =
|
|
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
|
|
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.
|
|
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 &&
|
|
4119
|
-
const { path, ...rest } = args;
|
|
4120
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
4220
|
-
|
|
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(
|
|
4229
|
-
|
|
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);
|