Skill Tracer
Skills › Visual Explainer › visual-explainer › server.mjs

server.mjs

430 lines · plugins/visual-explainer/mcp/server.mjs at 9d5f880

1#!/usr/bin/env node
2import { spawn } from "node:child_process";
3import { randomUUID } from "node:crypto";
4import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs";
5import { homedir } from "node:os";
6import { dirname, join, relative, resolve, sep } from "node:path";
7import { fileURLToPath } from "node:url";
8import { McpServer } from "@modelcontextprotocol/server";
9import { serveStdio } from "@modelcontextprotocol/server/stdio";
10import * as z from "zod/v4";
11import { prepareRenderedHtml, readSourcePath, renderPageForHost, reportLine, reportPage } from "../kit/render.mjs";
12import { lookAt, lookLines } from "../kit/look.mjs";
14const serverPath = fileURLToPath(import.meta.url);
15const mcpDir = dirname(serverPath);
16const skillDir = dirname(mcpDir);
17const rootDir = join(skillDir, "..", "..");
18const packageJson = JSON.parse(readFileSync(join(rootDir, "package.json"), "utf8"));
20const viewerSchema = z.enum(["browser", "glimpse", "auto"]);
21const openStatusSchema = z.enum(["disabled", "unsupported", "dispatched", "failed"]);
22const openTargetSchema = z.enum(["browser", "glimpse"]);
24const prepareInputSchema = z.object({
25 topic: z.string().min(1).describe("What the visual explanation should cover."),
26 goal: z.string().optional().describe("What the user wants to understand, decide, or communicate."),
27 files: z.array(z.string()).optional().describe("Relevant files or paths the host model should inspect."),
28 audience: z.string().optional().describe("Intended audience, such as developer, PM, team, reviewer, or executive."),
29}).strict();
31const prepareOutputSchema = z.object({
32 topic: z.string(),
33 goal: z.string().optional(),
34 audience: z.string().optional(),
35 files: z.array(z.string()),
36 recommendedFlow: z.array(z.string()),
37});
39const renderInputSchema = z.object({
40 filename: z.string().min(1).optional().describe("With html: basename filename; .html is appended when missing."),
41 html: z.string().min(1).optional().describe("Page source or complete HTML, when you cannot write a path."),
42 path: z.string().min(1).optional().describe("Instead of html: a <name>.src.html file in the output directory."),
43 root: z.string().min(1).optional().describe("The repository a plan source (<ve-plan> tags) checks its citations against, and a page's <ve-flow> model imports from. Defaults to the server's working directory."),
44 open: z.boolean().default(false).describe("Open the written file after rendering. Defaults to false for MCP."),
45 viewer: viewerSchema.default("browser").describe("Viewer to use when open is true."),
46}).strict();
48const renderOutputSchema = z.object({
49 path: z.string(),
50 viewer: viewerSchema,
51 openAttempted: z.boolean(),
52 openStatus: openStatusSchema,
53 openTarget: openTargetSchema.optional(),
54 openError: z.string().optional(),
55 fallbackFrom: openTargetSchema.optional(),
56 fallbackError: z.string().optional(),
57 source: z.string().optional(),
58 report: z.object({ figures: z.number(), uncaptioned: z.number(), dataTags: z.number(), flows: z.number(), proseWords: z.number(), machines: z.array(z.string()).optional() }).optional(),
59});
61const promptArgsSchema = z.object({
62 request: z.string().optional().describe("User-supplied prompt arguments. This replaces $@ in the bundled command template."),
63}).strict();
65const promptFiles = [
66 "compare.md",
67 "diff-review.md",
68 "fact-check.md",
69 "generate-slides.md",
70 "generate-video.md",
71 "generate-visual-plan.md",
72 "generate-web-diagram.md",
73 "plan-review.md",
74 "project-recap.md",
75 "state-machine.md",
76];
78const resourceFiles = [
79 { name: "skill", uri: "visual-explainer://skill/SKILL.md", path: "SKILL.md", title: "Visual Explainer Skill", mimeType: "text/markdown" },
80 // every reference the skill points to, so a client with no file access can still read them
81 { name: "kit-css", uri: "visual-explainer://kit/kit.css", path: "kit/kit.css", title: "Kit stylesheet", mimeType: "text/css" },
82 ...(existsSync(join(skillDir, "references")) ? readdirSync(join(skillDir, "references")) : []).filter((file) => file.endsWith(".md")).sort().map((file) => (
83 { name: `reference-${file.slice(0, -3)}`, uri: `visual-explainer://references/${file}`, path: `references/${file}`, title: `Reference: ${file}`, mimeType: "text/markdown" }
84 )),
85];
87function readSkillFile(relativePath) {
88 return readFileSync(join(skillDir, relativePath), "utf8");
91function parsePromptFile(relativePath) {
92 const text = readSkillFile(relativePath);
93 const match = text.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
94 if (!match) {
95 const name = relativePath.split("/").pop()?.replace(/\.md$/, "") ?? relativePath;
96 return { name, description: `Visual Explainer prompt from ${relativePath}.`, body: text };
97 }
99 const frontmatter = match[1];
100 const body = match[2];
101 const name = frontmatter.match(/^name:\s*(.+)$/m)?.[1]?.trim() ?? relativePath.split("/").pop()?.replace(/\.md$/, "") ?? relativePath;
102 const description = frontmatter.match(/^description:\s*(.+)$/m)?.[1]?.trim() ?? `Visual Explainer prompt from ${relativePath}.`;
103 return { name, description, body };
106function outputFilename(input) {
107 if (/[\0-\x1f\x7f]/.test(input)) throw new Error("filename must not contain control characters");
109 const raw = input.replace(/^@/, "").trim();
111 if (!raw) throw new Error("filename is required");
112 if (raw.includes("/") || raw.includes("\\")) throw new Error("filename must be a basename, not a path");
113 if (raw.includes("..")) throw new Error("filename must not contain '..'");
115 return /\.html?$/i.test(raw) ? raw : `${raw}.html`;
118function resolveOutputDirectory() {
119 const configured = process.env.VISUAL_EXPLAINER_OUTPUT_DIR?.trim();
120 return {
121 path: configured ? resolve(configured) : join(homedir(), ".agent", "diagrams"),
122 configured: Boolean(configured),
123 };
126// Checks the nearest existing ancestor, so a rejected directory is never created through a symlink.
127function assertResolvesToItself(path) {
128 let existing = path;
129 // A missing Windows drive or UNC root is its own dirname; stop there and let realpathSync throw.
130 while (!lstatSync(existing, { throwIfNoEntry: false }) && dirname(existing) !== existing) existing = dirname(existing);
131 const resolved = join(realpathSync(existing), relative(existing, path));
132 if (resolved !== path) throw new Error(`${path} must not contain symlinks and must resolve to itself (it resolves to ${resolved})`);
135function assertOutputPathContained(resolvedDir, outputPath) {
136 const resolvedPath = resolve(outputPath);
137 const dirPrefix = resolvedDir.endsWith(sep) ? resolvedDir : `${resolvedDir}${sep}`;
138 if (resolvedPath !== resolvedDir && !resolvedPath.startsWith(dirPrefix)) {
139 throw new Error(`${outputPath} must stay inside ${resolvedDir}`);
140 }
143function writeRenderedFile(outputPath, html) {
144 const temporaryPath = `${outputPath}.${process.pid}.${randomUUID()}.tmp`;
145 try {
146 writeFileSync(temporaryPath, html, { encoding: "utf8", flag: "wx", mode: 0o666 });
147 renameSync(temporaryPath, outputPath);
148 } catch (error) {
149 throw error;
150 } finally {
151 rmSync(temporaryPath, { force: true });
152 }
155function assertHtmlDocument(html) {
156 const trimmed = html.trim();
157 if (!trimmed) throw new Error("html is required");
159 const start = trimmed.replace(/^\s*<!doctype\s+html\b[^>]*>\s*/i, "").replace(/^(?:<!--[\s\S]*?-->\s*)+/, "");
160 if (!/^<html[\s>]/i.test(start) || !/<\/html>\s*$/i.test(trimmed)) {
161 throw new Error("html must be a complete HTML document starting with <!doctype html> or <html> and ending with </html>");
162 }
165function runOpener(command, args, openTarget) {
166 return new Promise((resolve) => {
167 const child = spawn(command, args, { detached: true, stdio: "ignore", windowsHide: true });
168 let settled = false;
170 const settle = (result) => {
171 if (settled) return;
172 settled = true;
173 clearTimeout(timer);
174 resolve(result);
175 };
177 child.once("error", (error) => {
178 settle({
179 openAttempted: true,
180 openStatus: "failed",
181 openTarget,
182 openError: error.message,
183 });
184 });
186 child.once("exit", (code, signal) => {
187 if (code !== 0) {
188 settle({
189 openAttempted: true,
190 openStatus: "failed",
191 openTarget,
192 openError: code === null ? `opener exited with signal ${signal ?? "unknown"}` : `opener exited with code ${code}`,
193 });
194 }
195 });
197 const timer = setTimeout(() => {
198 settle({ openAttempted: true, openStatus: "dispatched", openTarget });
199 }, 250);
201 child.unref();
202 });
205async function openInBrowser(path) {
206 if (process.platform === "darwin") return await runOpener("open", [path], "browser");
207 if (process.platform === "linux") return await runOpener("xdg-open", [path], "browser");
208 if (process.platform === "win32") return await runOpener("cmd", ["/c", "start", "", path], "browser");
209 return { openAttempted: false, openStatus: "unsupported", openTarget: "browser" };
212async function openInGlimpse(path) {
213 return await runOpener("glimpseui", ["--width", "1200", "--height", "900", "--title", "Visual Explainer", "--open-links", path], "glimpse");
216async function openRenderedPage(path, viewer) {
217 if (viewer === "browser") return await openInBrowser(path);
218 if (viewer === "glimpse") return await openInGlimpse(path);
220 const glimpseResult = await openInGlimpse(path);
221 if (glimpseResult.openStatus !== "failed") return glimpseResult;
223 const browserResult = await openInBrowser(path);
224 return {
225 ...browserResult,
226 fallbackFrom: "glimpse",
227 fallbackError: glimpseResult.openError ?? "glimpseui failed",
228 };
231function compact(value) {
232 return Object.fromEntries(Object.entries(value).filter((entry) => entry[1] !== undefined));
235function renderToolResult(message, structuredContent, images = []) {
236 return {
237 content: [{ type: "text", text: message }, ...images],
238 structuredContent: compact(structuredContent),
239 };
242function renderToolError(error) {
243 return {
244 content: [{ type: "text", text: error instanceof Error ? error.message : String(error) }],
245 isError: true,
246 };
249function prepareVisualExplanation(params) {
250 const topic = params.topic.trim();
251 if (!topic) throw new Error("topic is required");
253 const goal = params.goal?.trim();
254 const audience = params.audience?.trim();
255 const files = params.files?.map((file) => file.trim()).filter(Boolean) ?? [];
256 const recommendedFlow = [
257 "Choose the page shape with the visual-explainer prompts and skill resources.",
258 "Gather and verify the facts yourself; the server does not call an LLM.",
259 "Write <name>.src.html in the output directory and call visual_explainer_render_html with path (without file access: html and filename).",
260 "The server writes only inside its output directory (default ~/.agent/diagrams/).",
261 ];
263 const structuredContent = { topic, goal, audience, files, recommendedFlow };
264 const message = [
265 `Prepared visual explanation for: ${topic}`,
266 goal ? `Goal: ${goal}` : undefined,
267 audience ? `Audience: ${audience}` : undefined,
268 files.length ? `Starting files: ${files.join(", ")}` : undefined,
269 "Recommended flow:",
270 ...recommendedFlow.map((step, index) => `${index + 1}. ${step}`),
271 ].filter(Boolean).join("\n");
273 return renderToolResult(message, structuredContent);
276async function writeRenderedHtml(filenameInput, htmlInput, open, viewer, planRoot, sourcePath) {
277 assertHtmlDocument(htmlInput);
278 // MCP cannot put the reader's reply into the chat, so plan and kit pages here always copy it instead (no sendBack).
279 // a <ve-flow> model runs the repo's code, so a client can only make this server run one when its operator opted in
280 const page = renderPageForHost(htmlInput, { root: planRoot, filename: outputFilename(filenameInput), models: process.env.VISUAL_EXPLAINER_RUN_MODELS === "1" });
281 // a plan page has its own summary in its note; every other page gets the report
282 const report = page ? page.report : reportPage(htmlInput);
283 const filename = page?.filename ?? outputFilename(filenameInput);
284 const html = prepareRenderedHtml(page ? page.html : htmlInput);
285 const { path: outputDir, configured } = resolveOutputDirectory();
286 const outputPath = join(outputDir, filename);
288 const outputDirStatus = lstatSync(outputDir, { throwIfNoEntry: false });
289 if (outputDirStatus?.isSymbolicLink()) throw new Error(`${outputDir} must not be a symlink`);
290 if (configured) assertResolvesToItself(outputDir);
291 mkdirSync(outputDir, { recursive: true });
292 if (configured) assertResolvesToItself(outputDir);
293 const resolvedDir = realpathSync(outputDir);
294 assertOutputPathContained(resolvedDir, join(resolvedDir, filename));
296 const outputStatus = lstatSync(outputPath, { throwIfNoEntry: false });
297 if (outputStatus?.isSymbolicLink()) throw new Error(`${outputPath} must not be a symlink`);
299 // Keep the source beside the page: revisions and build receipts are small edits to it, then a new render.
300 // The source goes first, so a failed write never leaves a new page beside an old source. A source read from path stays as it is.
301 const source = sourcePath ?? (page?.source ? join(outputDir, page.source) : undefined);
302 if (source && !sourcePath) {
303 if (lstatSync(source, { throwIfNoEntry: false })?.isSymbolicLink()) throw new Error(`${source} must not be a symlink`);
304 writeRenderedFile(source, htmlInput);
305 }
306 writeRenderedFile(outputPath, html);
307 // a kit page is looked at in a browser, and its sheet comes back with the report, so the agent never scripts its own screenshots
308 const looked = page?.kit ? await lookAt(outputPath) : undefined;
310 const openResult = open ? await openRenderedPage(outputPath, viewer) : { openAttempted: false, openStatus: "disabled" };
311 let message = `Wrote ${outputPath}.`;
312 if (openResult.openStatus === "dispatched") {
313 message += ` ${openResult.openTarget === "glimpse" ? "Glimpse" : "Browser"} open requested.`;
314 } else if (openResult.openStatus === "failed") {
315 message += ` ${openResult.openTarget === "glimpse" ? "Glimpse" : "Browser"} open failed: ${openResult.openError ?? "unknown error"}.`;
316 } else if (openResult.openStatus === "unsupported") {
317 message += " Browser opening is unsupported on this platform.";
318 }
319 if (openResult.fallbackFrom === "glimpse") {
320 message += ` Glimpse fallback reason: ${openResult.fallbackError ?? "unknown error"}.`;
321 }
322 if (page) message += page.note;
323 if (report) message += `\n${reportLine(report)}`;
324 if (looked) message += `\n${lookLines(looked).join("\n")}`;
325 const images = (looked?.sheets ?? []).map((sheet) => ({ type: "image", data: readFileSync(sheet).toString("base64"), mimeType: "image/png" }));
327 return { message, output: compact({ path: outputPath, viewer, ...openResult, source, report }), images };
330function registerResources(server) {
331 for (const resource of resourceFiles) {
332 server.registerResource(resource.name, resource.uri, {
333 title: resource.title,
334 description: `Bundled visual-explainer resource: ${resource.path}`,
335 mimeType: resource.mimeType,
336 }, async (uri) => ({
337 contents: [{ uri: uri.href, mimeType: resource.mimeType, text: readSkillFile(resource.path) }],
338 }));
339 }
341 for (const file of promptFiles) {
342 const relativePath = `commands/${file}`;
343 const prompt = parsePromptFile(relativePath);
344 server.registerResource(`command-${prompt.name}`, `visual-explainer://commands/${file}`, {
345 title: `/${prompt.name}`,
346 description: prompt.description,
347 mimeType: "text/markdown",
348 }, async (uri) => ({
349 contents: [{ uri: uri.href, mimeType: "text/markdown", text: readSkillFile(relativePath) }],
350 }));
351 }
354function registerPrompts(server) {
355 for (const file of promptFiles) {
356 const prompt = parsePromptFile(`commands/${file}`);
357 server.registerPrompt(prompt.name, {
358 title: `/${prompt.name}`,
359 description: prompt.description,
360 argsSchema: promptArgsSchema,
361 }, ({ request }) => ({
362 messages: [{
363 role: "user",
364 content: {
365 type: "text",
366 text: prompt.body.replaceAll("$@", request ?? "").trim(),
367 },
368 }],
369 }));
370 }
373function registerTools(server) {
374 server.registerTool("visual_explainer_prepare", {
375 title: "Prepare Visual Explainer",
376 description: "Plan a visual explanation. Does not call an LLM or write files.",
377 inputSchema: prepareInputSchema,
378 outputSchema: prepareOutputSchema,
379 annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: false },
380 }, async (params) => {
381 try {
382 return prepareVisualExplanation(params);
383 } catch (error) {
384 return renderToolError(error);
385 }
386 });
388 server.registerTool("visual_explainer_render_html", {
389 title: "Render Visual Explainer HTML",
390 description: "Render a page source (path) or HTML to the configured output directory (default ~/.agent/diagrams/). Does not call an LLM.",
391 inputSchema: renderInputSchema,
392 outputSchema: renderOutputSchema,
393 annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
394 }, async ({ filename, html, path, root, open, viewer }) => {
395 try {
396 if (html !== undefined && path !== undefined) throw new Error("pass html or path, not both");
397 if (path !== undefined && filename !== undefined) throw new Error("filename is not allowed with path; the page is named after its source");
398 if (path === undefined && (html === undefined || filename === undefined)) throw new Error("pass html with filename, or path");
399 const source = path === undefined ? undefined : readSourcePath(path, () => resolveOutputDirectory().path);
400 const result = await writeRenderedHtml(source?.filename ?? filename, source?.html ?? html, open, viewer, root ?? process.cwd(), source?.source);
401 return renderToolResult(result.message, result.output, result.images);
402 } catch (error) {
403 return renderToolError(error);
404 }
405 });
408export function createServer() {
409 const server = new McpServer({ name: "visual-explainer", version: packageJson.version });
410 registerResources(server);
411 registerPrompts(server);
412 registerTools(server);
413 return server;
416function isMainModule() {
417 return Boolean(process.argv[1] && realpathSync(process.argv[1]) === serverPath);
420if (isMainModule()) {
421 const handle = serveStdio(createServer);
422 console.error("visual-explainer MCP server running on stdio");
424 for (const signal of ["SIGINT", "SIGTERM"]) {
425 process.on(signal, () => {
426 void handle.close().finally(() => process.exit(0));
427 });
428 }