html2pptx-local-mcp 1.1.18 → 1.1.20

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.
@@ -2,14 +2,34 @@ import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
2
2
  import { spawn } from "node:child_process";
3
3
  import { createServer } from "node:http";
4
4
  import { platform } from "node:os";
5
- import { mkdir, readFile, realpath, stat, writeFile, } from "node:fs/promises";
5
+ import { mkdir, readdir, readFile, realpath, stat, writeFile, } from "node:fs/promises";
6
6
  import { dirname, extname, join, relative, resolve, sep, } from "node:path";
7
7
  import * as p from "@clack/prompts";
8
8
  import pc from "picocolors";
9
9
  const AUTO_PORT = 0;
10
10
  const MAX_WRITE_BYTES = 5 * 1024 * 1024;
11
+ const MAX_ASSET_BYTES = 8 * 1024 * 1024;
11
12
  const ALLOWED_EXTENSIONS = [".html", ".htm"];
12
13
  const ALLOWED_EXT = new Set(ALLOWED_EXTENSIONS);
14
+ const ASSET_IMAGE_EXTENSIONS = [".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".avif"];
15
+ const ASSET_IMAGE_EXT = new Set(ASSET_IMAGE_EXTENSIONS);
16
+ const ASSET_CONTENT_TYPES = {
17
+ ".png": "image/png",
18
+ ".jpg": "image/jpeg",
19
+ ".jpeg": "image/jpeg",
20
+ ".gif": "image/gif",
21
+ ".webp": "image/webp",
22
+ ".svg": "image/svg+xml",
23
+ ".avif": "image/avif",
24
+ };
25
+ const ASSET_CONTENT_TYPE_EXT = {
26
+ "image/png": ".png",
27
+ "image/jpeg": ".jpg",
28
+ "image/gif": ".gif",
29
+ "image/webp": ".webp",
30
+ "image/svg+xml": ".svg",
31
+ "image/avif": ".avif",
32
+ };
13
33
  const DISALLOWED_TOP_DIRECTORIES = [
14
34
  "public",
15
35
  ".next",
@@ -32,6 +52,9 @@ const EDITOR_BASE_URL_EXAMPLE = "http://localhost:<port>";
32
52
  function sha256(content) {
33
53
  return createHash("sha256").update(content).digest("hex");
34
54
  }
55
+ function sha256Buffer(buf) {
56
+ return createHash("sha256").update(buf).digest("hex");
57
+ }
35
58
  function generateSessionToken() {
36
59
  return randomBytes(32).toString("base64url");
37
60
  }
@@ -278,13 +301,13 @@ function validateWriteRequest(req, ctx, reqUrl) {
278
301
  return (req.headers["x-edit-slide-local"] === "1" ||
279
302
  req.headers["x-open-slide-local"] === "1");
280
303
  }
281
- async function readBody(req) {
304
+ async function readBody(req, maxBytes = MAX_WRITE_BYTES) {
282
305
  const chunks = [];
283
306
  let total = 0;
284
307
  for await (const chunk of req) {
285
308
  const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
286
309
  total += buf.length;
287
- if (total > MAX_WRITE_BYTES + 1024 * 1024) {
310
+ if (total > maxBytes + 1024 * 1024) {
288
311
  throw new Error("request body too large");
289
312
  }
290
313
  chunks.push(buf);
@@ -427,6 +450,182 @@ async function handlePost(ctx, req, res) {
427
450
  sendJson(res, 400, { error: error?.message || "write failed" });
428
451
  }
429
452
  }
453
+ async function safeAssetPath(ctx, rel) {
454
+ if (typeof rel !== "string" || !rel) {
455
+ throw new Error("missing path");
456
+ }
457
+ const normalized = rel.replace(/^\/+/, "");
458
+ const abs = resolve(ctx.root, normalized);
459
+ if (abs !== ctx.root && !abs.startsWith(ctx.root + sep)) {
460
+ throw new Error("path escape");
461
+ }
462
+ const ext = extname(abs).toLowerCase();
463
+ if (!ASSET_IMAGE_EXT.has(ext)) {
464
+ throw new Error("only image files are allowed");
465
+ }
466
+ const real = await resolveReal(abs);
467
+ if (real !== ctx.root && !real.startsWith(ctx.root + sep)) {
468
+ throw new Error("path escape via symlink");
469
+ }
470
+ for (const candidate of [relative(ctx.root, abs), relative(ctx.root, real)]) {
471
+ const first = candidate.split(sep)[0];
472
+ if (DISALLOWED_TOP_DIRS.has(first)) {
473
+ throw new Error(`assets under ${first}/ are not allowed`);
474
+ }
475
+ }
476
+ return real;
477
+ }
478
+ function assetExt(name, contentType) {
479
+ const fromName = extname(String(name || "")).toLowerCase();
480
+ if (ASSET_IMAGE_EXT.has(fromName))
481
+ return fromName;
482
+ return ASSET_CONTENT_TYPE_EXT[String(contentType || "").toLowerCase()] || "";
483
+ }
484
+ function assetSlug(name) {
485
+ const base = String(name || "image").replace(/\.[^.]+$/, "");
486
+ const slug = base
487
+ .normalize("NFKD")
488
+ .replace(/[^\w.-]+/g, "-")
489
+ .replace(/^[-.]+|[-.]+$/g, "")
490
+ .toLowerCase();
491
+ return slug || "image";
492
+ }
493
+ function assetDirRel(scope, htmlDirRel) {
494
+ if (scope === "global")
495
+ return "assets";
496
+ return htmlDirRel ? join(htmlDirRel, "assets") : "assets";
497
+ }
498
+ async function readBytesOrNull(abs) {
499
+ try {
500
+ return await readFile(abs);
501
+ }
502
+ catch (error) {
503
+ if (error?.code === "ENOENT")
504
+ return null;
505
+ throw error;
506
+ }
507
+ }
508
+ function sendBinary(res, status, contentType, buf) {
509
+ res.statusCode = status;
510
+ res.setHeader("content-type", contentType);
511
+ res.setHeader("cache-control", "no-store");
512
+ res.setHeader("x-content-type-options", "nosniff");
513
+ res.setHeader("content-security-policy", "sandbox; default-src 'none'; style-src 'unsafe-inline'");
514
+ res.end(buf);
515
+ }
516
+ async function handleAssetGet(ctx, req, reqUrl, res) {
517
+ if (!validateBridgeRequest(req, ctx, reqUrl)) {
518
+ sendJson(res, 403, { error: "forbidden" });
519
+ return;
520
+ }
521
+ const file = reqUrl.searchParams.get("file") || "";
522
+ const htmlDirRel = file ? dirname(file) : "";
523
+ if (reqUrl.searchParams.get("list") === "1") {
524
+ const scope = reqUrl.searchParams.get("scope") === "global" ? "global" : "project";
525
+ const dirRel = assetDirRel(scope, htmlDirRel);
526
+ const absDir = resolve(ctx.root, dirRel);
527
+ const htmlDirAbs = resolve(ctx.root, htmlDirRel || ".");
528
+ let assets = [];
529
+ try {
530
+ if (absDir === ctx.root || absDir.startsWith(ctx.root + sep)) {
531
+ const names = await readdir(absDir);
532
+ assets = names
533
+ .filter((name) => ASSET_IMAGE_EXT.has(extname(name).toLowerCase()))
534
+ .map((name) => ({
535
+ name,
536
+ src: toPosixPath(relative(htmlDirAbs, join(absDir, name))),
537
+ }));
538
+ }
539
+ }
540
+ catch {
541
+ assets = [];
542
+ }
543
+ sendJson(res, 200, { assets, policy: buildPolicy(ctx) });
544
+ return;
545
+ }
546
+ const src = reqUrl.searchParams.get("src") || "";
547
+ if (!src) {
548
+ sendJson(res, 400, { error: "missing src" });
549
+ return;
550
+ }
551
+ try {
552
+ const abs = await safeAssetPath(ctx, join(htmlDirRel, src));
553
+ const buf = await readFile(abs);
554
+ const ext = extname(abs).toLowerCase();
555
+ sendBinary(res, 200, ASSET_CONTENT_TYPES[ext] || "application/octet-stream", buf);
556
+ }
557
+ catch (error) {
558
+ const message = error?.message || "read failed";
559
+ const status = message.includes("ENOENT") ? 404 : 400;
560
+ sendJson(res, status, { error: message });
561
+ }
562
+ }
563
+ async function handleAssetPost(ctx, req, res) {
564
+ const reqUrl = new URL(req.url || "/", `http://${req.headers.host || "127.0.0.1"}`);
565
+ if (!validateWriteRequest(req, ctx, reqUrl)) {
566
+ sendJson(res, 403, { error: "forbidden" });
567
+ return;
568
+ }
569
+ let body;
570
+ try {
571
+ body = JSON.parse(await readBody(req, MAX_ASSET_BYTES * 2));
572
+ }
573
+ catch (error) {
574
+ sendJson(res, 400, { error: error?.message || "invalid json" });
575
+ return;
576
+ }
577
+ const { file, scope: rawScope, name, contentType, dataBase64 } = body || {};
578
+ if (typeof dataBase64 !== "string" || !dataBase64) {
579
+ sendJson(res, 400, { error: "missing image data" });
580
+ return;
581
+ }
582
+ const buf = Buffer.from(dataBase64, "base64");
583
+ if (!buf.length) {
584
+ sendJson(res, 400, { error: "empty image data" });
585
+ return;
586
+ }
587
+ if (buf.length > MAX_ASSET_BYTES) {
588
+ sendJson(res, 413, { error: "image too large (>8MB)" });
589
+ return;
590
+ }
591
+ const ext = assetExt(name, contentType);
592
+ if (!ASSET_IMAGE_EXT.has(ext)) {
593
+ sendJson(res, 400, { error: "unsupported image type" });
594
+ return;
595
+ }
596
+ const scope = rawScope === "global" ? "global" : "project";
597
+ const htmlDirRel = typeof file === "string" && file ? dirname(file) : "";
598
+ const htmlDirAbs = resolve(ctx.root, htmlDirRel || ".");
599
+ const dirRel = assetDirRel(scope, htmlDirRel);
600
+ const slug = assetSlug(name);
601
+ try {
602
+ let fileName = `${slug}${ext}`;
603
+ let absTarget = await safeAssetPath(ctx, join(dirRel, fileName));
604
+ const existing = await readBytesOrNull(absTarget);
605
+ if (existing && !existing.equals(buf)) {
606
+ fileName = `${slug}-${sha256Buffer(buf).slice(0, 8)}${ext}`;
607
+ absTarget = await safeAssetPath(ctx, join(dirRel, fileName));
608
+ }
609
+ const alreadyIdentical = Boolean(existing && existing.equals(buf));
610
+ if (!alreadyIdentical) {
611
+ await mkdir(dirname(absTarget), { recursive: true });
612
+ await writeFile(absTarget, buf);
613
+ }
614
+ sendJson(res, 200, {
615
+ ok: true,
616
+ scope,
617
+ name: fileName,
618
+ path: toPosixPath(relative(ctx.root, absTarget)),
619
+ src: toPosixPath(relative(htmlDirAbs, absTarget)),
620
+ bytes: buf.length,
621
+ reused: alreadyIdentical,
622
+ policy: buildPolicy(ctx),
623
+ });
624
+ }
625
+ catch (error) {
626
+ sendJson(res, 400, { error: error?.message || "asset write failed" });
627
+ }
628
+ }
430
629
  function createBridgeServer(ctx) {
431
630
  return createServer(async (req, res) => {
432
631
  if (!applyCors(req, res, ctx.editorOrigin))
@@ -445,6 +644,18 @@ function createBridgeServer(ctx) {
445
644
  sendJson(res, 200, { ok: true, service: "html2pptx edit bridge", root: ctx.root });
446
645
  return;
447
646
  }
647
+ if (reqUrl.pathname === "/api/edit-slide/asset") {
648
+ if (req.method === "GET") {
649
+ await handleAssetGet(ctx, req, reqUrl, res);
650
+ return;
651
+ }
652
+ if (req.method === "POST") {
653
+ await handleAssetPost(ctx, req, res);
654
+ return;
655
+ }
656
+ sendJson(res, 405, { error: "method not allowed" });
657
+ return;
658
+ }
448
659
  if (reqUrl.pathname !== "/api/edit-slide/file") {
449
660
  sendJson(res, 404, { error: "not found" });
450
661
  return;
@@ -506,24 +717,26 @@ function bail(message, options) {
506
717
  process.exit(1);
507
718
  }
508
719
  export async function editCommand(input, options = {}) {
720
+ const noOpen = options.noOpen === true || options.open === false;
721
+ const normalizedOptions = { ...options, noOpen };
509
722
  if (!input) {
510
- bail("HTML file path is required. Example: html2pptx edit ./html2pptx/slides.html", options);
723
+ bail("HTML file path is required. Example: html2pptx edit ./html2pptx/slides.html", normalizedOptions);
511
724
  }
512
725
  const root = await realpath(process.cwd());
513
726
  const abs = resolve(root, input);
514
727
  const rel = relativeToRoot(root, abs);
515
728
  const ext = extname(abs).toLowerCase();
516
729
  if (!ALLOWED_EXT.has(ext)) {
517
- bail("Only .html/.htm files can be opened in the editor.", options);
730
+ bail("Only .html/.htm files can be opened in the editor.", normalizedOptions);
518
731
  }
519
732
  if (abs !== root && !abs.startsWith(root + sep)) {
520
- bail("The file must be inside the current working directory.", options);
733
+ bail("The file must be inside the current working directory.", normalizedOptions);
521
734
  }
522
735
  try {
523
736
  await stat(abs);
524
737
  }
525
738
  catch {
526
- bail(`File not found: ${rel}`, options);
739
+ bail(`File not found: ${rel}`, normalizedOptions);
527
740
  }
528
741
  let baseUrl;
529
742
  let requestedPort;
@@ -532,7 +745,7 @@ export async function editCommand(input, options = {}) {
532
745
  requestedPort = parsePort(options.port);
533
746
  }
534
747
  catch (error) {
535
- bail(error.message, options);
748
+ bail(error.message, normalizedOptions);
536
749
  }
537
750
  const ctx = {
538
751
  root,
@@ -550,7 +763,7 @@ export async function editCommand(input, options = {}) {
550
763
  }
551
764
  const bridgeUrl = `http://127.0.0.1:${bridgePort}`;
552
765
  const editorUrl = buildEditorUrl(baseUrl, rel, bridgeUrl, ctx.sessionToken);
553
- if (options.json) {
766
+ if (normalizedOptions.json) {
554
767
  console.log(JSON.stringify({
555
768
  success: true,
556
769
  editorUrl: editorUrl.toString(),
@@ -562,7 +775,7 @@ export async function editCommand(input, options = {}) {
562
775
  }
563
776
  else {
564
777
  p.log.success(`Local edit bridge listening on ${pc.cyan(bridgeUrl)} ${pc.dim("(session token required)")}`);
565
- if (options.noOpen) {
778
+ if (normalizedOptions.noOpen) {
566
779
  p.log.info(`Open in editor: ${pc.cyan(editorUrl.toString())}`);
567
780
  }
568
781
  else {
@@ -570,7 +783,7 @@ export async function editCommand(input, options = {}) {
570
783
  }
571
784
  p.log.info(pc.dim("Press Ctrl+C to stop the bridge."));
572
785
  }
573
- if (!options.noOpen) {
786
+ if (!normalizedOptions.noOpen) {
574
787
  openUrl(editorUrl.toString());
575
788
  }
576
789
  stopOnSignal(server);
package/cli/dist/index.js CHANGED
@@ -41,7 +41,7 @@ program
41
41
  .action(convertCommand);
42
42
  program
43
43
  .command("edit")
44
- .description("Open a local HTML slide file in the localhost visual editor")
44
+ .description("Open a local slide HTML file in the html2pptx visual editor")
45
45
  .argument("<input>", "Path to HTML file")
46
46
  .option("--port <port>", "Local bridge port. Defaults to auto.")
47
47
  .option("--no-open", "Print the editor URL without opening a browser")
package/cli/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "html2pptx-cli",
3
- "version": "0.4.2",
3
+ "version": "0.4.0",
4
4
  "description": "CLI tool to convert HTML/CSS to editable PowerPoint files via html2pptx.app",
5
5
  "type": "module",
6
6
  "bin": {
@@ -21,11 +21,22 @@ export function createLocalSlideEditorManager(options = {}) {
21
21
 
22
22
  async function open(input = {}) {
23
23
  const file = await resolveEditableFile(input.filePath, rootCwd);
24
+ const reuseExisting = input.reuseExisting !== false;
25
+ if (reuseExisting) {
26
+ const existing = findReusableSession(file);
27
+ if (existing) {
28
+ return {
29
+ ...redactSessionForResponse(existing),
30
+ reused: true,
31
+ };
32
+ }
33
+ }
34
+
24
35
  const baseUrl = normalizeEditorBaseUrl(
25
36
  input.baseUrl || process.env.HTML2PPTX_EDITOR_BASE_URL || await readRegisteredEditorBaseUrl(file.root),
26
37
  );
27
38
  const port = normalizePort(input.port, 0);
28
- const openBrowser = input.openBrowser !== false;
39
+ const openBrowser = input.openBrowser === true;
29
40
  const invocation = resolveCliInvocation(options);
30
41
  const childArgs = [
31
42
  ...invocation.baseArgs,
@@ -78,7 +89,25 @@ export function createLocalSlideEditorManager(options = {}) {
78
89
 
79
90
  const details = await waitForBridgeDetails(session, launchTimeoutMs);
80
91
  session.details = details;
81
- return redactSessionForResponse(session);
92
+ return {
93
+ ...redactSessionForResponse(session),
94
+ reused: false,
95
+ };
96
+ }
97
+
98
+ function findReusableSession(file) {
99
+ for (const session of sessions.values()) {
100
+ if (
101
+ session.filePath === file.absolutePath &&
102
+ session.root === file.root &&
103
+ session.details &&
104
+ !session.child.killed &&
105
+ session.child.exitCode == null
106
+ ) {
107
+ return session;
108
+ }
109
+ }
110
+ return null;
82
111
  }
83
112
 
84
113
  async function stop(sessionId) {
@@ -416,7 +416,7 @@ export const LOCAL_TOOL_DEFINITIONS = [
416
416
  name: 'html2pptx_open_local_slide_editor',
417
417
  title: 'Open Local Slide Editor',
418
418
  description:
419
- 'Local stdio MCP only. Start the same localhost edit bridge used by `html2pptx edit` for a .html/.htm slide file inside the MCP server working directory, then return the tokenized editor URL. This does not publish or upload the HTML file.',
419
+ 'Local stdio MCP only. Start the html2pptx CLI local edit bridge for a .html/.htm slide file inside the MCP server working directory, then return the tokenized editor URL. This does not publish or upload the HTML file.',
420
420
  inputSchema: {
421
421
  type: 'object',
422
422
  properties: {
@@ -437,7 +437,11 @@ export const LOCAL_TOOL_DEFINITIONS = [
437
437
  },
438
438
  openBrowser: {
439
439
  type: 'boolean',
440
- description: 'When true (default), the CLI opens the editor in the browser. Set false to only return the URL.',
440
+ description: 'When true, open the editor in the browser. Defaults to false for MCP calls so agents can return one URL without spawning extra tabs.',
441
+ },
442
+ reuseExisting: {
443
+ type: 'boolean',
444
+ description: 'When true (default), reuse an active editor bridge for the same file instead of starting another bridge and browser tab.',
441
445
  },
442
446
  },
443
447
  required: ['filePath'],
@@ -595,7 +599,7 @@ function buildPromptMessages(name, args) {
595
599
  `3. Save the complete HTML document to a project-local .html file, preferably "${fileName}" or another clear path under html2pptx/.`,
596
600
  '4. Open only a loopback editor UI. First ensure the local editor app is running with node scripts/dev-studio.mjs; it will choose an available port and register the editor URL under .html2pptx/edit-slide/editor-server.json in the current project.',
597
601
  '5. Call html2pptx_open_local_slide_editor with { filePath: <path> }. Pass baseUrl only when the user supplied a specific loopback editor URL. Do not use https://html2pptx.app/edit-slide for local file editing.',
598
- '6. If that local editor tool is not available, use direct `html2pptx edit <path>` when the local editor app is running, or ask the user to install the local MCP with: npx -y -p https://html2pptx.app/downloads/html2pptx-local-mcp-1.1.16.tgz html2pptx-install-mcp claude.',
602
+ '6. If that local editor tool is not available, do not pretend the editor was opened. Return the saved HTML path and the local-UI CLI fallback command: npx --yes https://html2pptx.app/downloads/html2pptx-cli-0.4.0.tgz edit <path>. If the CLI reports that the editor is not registered, start node scripts/dev-studio.mjs first.',
599
603
  '7. Do not export PPTX from this prompt. In the editor, the export button should only show a prompt telling the user to ask Claude Code or another agent to use the html2pptx skills for PowerPoint export.',
600
604
  ].join('\n'),
601
605
  },
@@ -647,7 +651,18 @@ export function negotiateProtocol(requestedProtocol) {
647
651
  }
648
652
 
649
653
  export function getToolDefinitions(client = {}, { localOnly = false } = {}) {
650
- const tools = localOnly ? [] : [...TOOL_DEFINITIONS];
654
+ if (localOnly) {
655
+ const localTools = [];
656
+ if (typeof client.openLocalSlideEditor === 'function') {
657
+ localTools.push(LOCAL_TOOL_DEFINITIONS[0]);
658
+ }
659
+ if (typeof client.stopLocalSlideEditor === 'function') {
660
+ localTools.push(LOCAL_TOOL_DEFINITIONS[1]);
661
+ }
662
+ return localTools;
663
+ }
664
+
665
+ const tools = [...TOOL_DEFINITIONS];
651
666
  if (typeof client.openLocalSlideEditor === 'function') {
652
667
  tools.push(LOCAL_TOOL_DEFINITIONS[0]);
653
668
  }
@@ -660,12 +675,11 @@ export function getToolDefinitions(client = {}, { localOnly = false } = {}) {
660
675
  function buildServerInstructions(client = {}, { localOnly = false } = {}) {
661
676
  if (localOnly) {
662
677
  return [
663
- 'html2pptx local MCP opens local slide HTML in the loopback edit-slide visual editor.',
664
- '',
665
- 'This server intentionally exposes only local editor tools. Use the remote html2pptx MCP server at https://html2pptx.app/mcp for PPTX exports, docs, usage, plans, templates, and publishing.',
666
- '',
667
- 'When the user asks to open or visually edit local slides, save the deck as a project-local .html/.htm file and call html2pptx_open_local_slide_editor with { filePath: <path> }.',
668
- 'The tool starts a localhost bridge and does not publish or upload the HTML file.',
678
+ 'html2pptx local MCP opens local slide HTML files in the no-code edit-slide editor.',
679
+ 'Use this stdio server only for local visual editing. Use the remote html2pptx MCP server for PPTX export, docs, usage, templates, and publishing workflows.',
680
+ 'When the user asks to open, preview, no-code edit, visually edit, or launch an editing screen for generated slides, save the deck as a local .html/.htm file first, usually under html2pptx/<name>.html.',
681
+ 'Then call html2pptx_open_local_slide_editor with the project-relative file path.',
682
+ 'The tool starts the html2pptx CLI localhost bridge and does not publish or upload the HTML file.',
669
683
  ].join('\n');
670
684
  }
671
685
 
@@ -715,11 +729,7 @@ function buildServerInstructions(client = {}, { localOnly = false } = {}) {
715
729
  return lines.join('\n');
716
730
  }
717
731
 
718
- export async function executeTool(name, args, client, { sendNotification, progressToken, localOnly = false } = {}) {
719
- if (localOnly && !LOCAL_TOOL_DEFINITIONS.some((tool) => tool.name === name)) {
720
- throw new Error(`${name} is available from the remote html2pptx MCP server, not the local stdio MCP server.`);
721
- }
722
-
732
+ export async function executeTool(name, args, client, { sendNotification, progressToken } = {}) {
723
733
  switch (name) {
724
734
  case 'html2pptx_list_export_plans': {
725
735
  const data = await client.listExportPlans();
@@ -902,7 +912,8 @@ export async function executeTool(name, args, client, { sendNotification, progre
902
912
  filePath: args.filePath,
903
913
  baseUrl: typeof args.baseUrl === 'string' ? args.baseUrl : undefined,
904
914
  port: Number.isFinite(args.port) ? args.port : undefined,
905
- openBrowser: args.openBrowser !== false,
915
+ openBrowser: args.openBrowser === true,
916
+ reuseExisting: args.reuseExisting !== false,
906
917
  });
907
918
  return buildToolResponse(renderLocalSlideEditorText(data), data);
908
919
  }
@@ -919,7 +930,7 @@ export async function executeTool(name, args, client, { sendNotification, progre
919
930
  }
920
931
  }
921
932
 
922
- export async function handleMcpMessage(message, { protocolVersion = DEFAULT_PROTOCOL, client, sendNotification, localOnly = false, serverInfo = SERVER_INFO }) {
933
+ export async function handleMcpMessage(message, { protocolVersion = DEFAULT_PROTOCOL, client, sendNotification, serverInfo = SERVER_INFO, localOnly = false }) {
923
934
  if (!message || typeof message !== 'object') {
924
935
  return { protocolVersion, response: null };
925
936
  }
@@ -1151,7 +1162,7 @@ export async function handleMcpMessage(message, { protocolVersion = DEFAULT_PROT
1151
1162
  try {
1152
1163
  const toolArgs = params?.arguments ?? {};
1153
1164
  const progressToken = toolArgs._meta?.progressToken ?? params?._meta?.progressToken;
1154
- const payload = await executeTool(params?.name, toolArgs, client, { sendNotification, progressToken, localOnly });
1165
+ const payload = await executeTool(params?.name, toolArgs, client, { sendNotification, progressToken });
1155
1166
  return {
1156
1167
  protocolVersion,
1157
1168
  response: {
@@ -1438,7 +1449,7 @@ function renderAnimationCatalogText(catalog) {
1438
1449
 
1439
1450
  function renderLocalSlideEditorText(data) {
1440
1451
  const lines = [
1441
- '# Local slide editor started',
1452
+ data.reused ? '# Local slide editor reused' : '# Local slide editor started',
1442
1453
  '',
1443
1454
  `File: ${data.file || 'unknown'}`,
1444
1455
  `Bridge: ${data.bridgeUrl || 'unknown'}`,
@@ -13,7 +13,7 @@ import {
13
13
 
14
14
  let inputBuffer = Buffer.alloc(0);
15
15
  let negotiatedProtocol = DEFAULT_PROTOCOL;
16
- let outputFraming = 'lines';
16
+ let wireMode = 'content-length';
17
17
 
18
18
  process.stdin.on('data', (chunk) => {
19
19
  inputBuffer = Buffer.concat([inputBuffer, chunk]);
@@ -36,16 +36,22 @@ process.stdout.on('error', (error) => {
36
36
  function drainMessages() {
37
37
  while (true) {
38
38
  const headerEnd = inputBuffer.indexOf('\r\n\r\n');
39
- if (headerEnd === -1) {
40
- const newlineEnd = inputBuffer.indexOf('\n');
41
- if (newlineEnd === -1) return;
42
- const rawLine = inputBuffer.slice(0, newlineEnd).toString('utf8').trim();
43
- inputBuffer = inputBuffer.slice(newlineEnd + 1);
39
+ const lineEnd = inputBuffer.indexOf('\n');
40
+
41
+ if (headerEnd === -1 || (lineEnd !== -1 && lineEnd < headerEnd)) {
42
+ if (lineEnd === -1) return;
43
+
44
+ const rawLine = inputBuffer.slice(0, lineEnd).toString('utf8').trim();
45
+ inputBuffer = inputBuffer.slice(lineEnd + 1);
44
46
  if (!rawLine) continue;
47
+
48
+ wireMode = 'line';
45
49
  handleRawMessage(rawLine);
46
50
  continue;
47
51
  }
48
52
 
53
+ wireMode = 'content-length';
54
+
49
55
  const headerText = inputBuffer.slice(0, headerEnd).toString('utf8');
50
56
  const contentLengthMatch = headerText.match(/Content-Length:\s*(\d+)/i);
51
57
  if (!contentLengthMatch) {
@@ -60,7 +66,6 @@ function drainMessages() {
60
66
 
61
67
  const rawBody = inputBuffer.slice(headerEnd + 4, messageEnd).toString('utf8');
62
68
  inputBuffer = inputBuffer.slice(messageEnd);
63
- outputFraming = 'headers';
64
69
 
65
70
  handleRawMessage(rawBody);
66
71
  }
@@ -85,6 +90,35 @@ function handleRawMessage(rawBody) {
85
90
 
86
91
  async function handleMessage(message) {
87
92
  const client = {
93
+ listExportPlans: async () => requestJson('/api/export/plans'),
94
+ createExportJob: async (body) =>
95
+ requestJson('/api/export/jobs', {
96
+ method: 'POST',
97
+ requireApiKey: true,
98
+ body,
99
+ }),
100
+ getExportJob: async (jobId) =>
101
+ requestJson(`/api/export/jobs/${encodeURIComponent(jobId)}`, {
102
+ requireApiKey: true,
103
+ }),
104
+ getUsage: async () =>
105
+ requestJson('/api/export/usage', {
106
+ requireApiKey: true,
107
+ }),
108
+ listTemplates: async (category) => {
109
+ const url = category
110
+ ? `/api/templates?category=${encodeURIComponent(category)}`
111
+ : '/api/templates';
112
+ // requireApiKey so the API is treated as authenticated and returns
113
+ // template prompts (anonymous requests get hasPrompt boolean only).
114
+ return requestJson(url, { requireApiKey: true });
115
+ },
116
+ getTemplateHtml: async (templateId) =>
117
+ requestJson(`/api/templates/html?id=${encodeURIComponent(templateId)}`, {
118
+ // /api/templates/html now requires auth (browser session, API key,
119
+ // or WorkOS JWT) so anonymous curl/browser can't scrape prompts.
120
+ requireApiKey: true,
121
+ }),
88
122
  openLocalSlideEditor: async (args) => localSlideEditorManager.open(args),
89
123
  stopLocalSlideEditor: async (sessionId) => localSlideEditorManager.stop(sessionId),
90
124
  };
@@ -92,11 +126,11 @@ async function handleMessage(message) {
92
126
  const handled = await handleMcpMessage(message, {
93
127
  protocolVersion: negotiatedProtocol,
94
128
  client,
95
- localOnly: true,
96
129
  serverInfo: {
97
130
  ...SERVER_INFO,
98
131
  name: 'html2pptx-local',
99
132
  },
133
+ localOnly: true,
100
134
  sendNotification: (notification) => {
101
135
  sendMessage(notification);
102
136
  },
@@ -182,13 +216,14 @@ function sendError(id, code, message) {
182
216
 
183
217
  function sendMessage(message) {
184
218
  const json = JSON.stringify(message);
185
- if (outputFraming === 'headers') {
186
- const headers = `Content-Length: ${Buffer.byteLength(json, 'utf8')}\r\nContent-Type: application/json\r\n\r\n`;
187
- process.stdout.write(headers);
188
- process.stdout.write(json);
219
+ if (wireMode === 'line') {
220
+ process.stdout.write(`${json}\n`);
189
221
  return;
190
222
  }
191
- process.stdout.write(`${json}\n`);
223
+
224
+ const headers = `Content-Length: ${Buffer.byteLength(json, 'utf8')}\r\n\r\n`;
225
+ process.stdout.write(headers);
226
+ process.stdout.write(json);
192
227
  }
193
228
 
194
229
  function logError(context, error) {
package/package.json CHANGED
@@ -1,11 +1,12 @@
1
1
  {
2
2
  "name": "html2pptx-local-mcp",
3
- "version": "1.1.18",
3
+ "version": "1.1.20",
4
4
  "type": "module",
5
5
  "description": "Local stdio MCP server for opening html2pptx slide HTML in the local edit-slide editor.",
6
6
  "bin": {
7
7
  "html2pptx-mcp": "./mcp/pptx-studio-mcp-server.mjs",
8
- "html2pptx-install-mcp": "./scripts/install-mcp.mjs"
8
+ "html2pptx-install-mcp": "./scripts/install-mcp.mjs",
9
+ "html2pptx-comments": "./scripts/extract-html2pptx-comments.mjs"
9
10
  },
10
11
  "files": [
11
12
  "app/docs/content.js",
@@ -16,6 +17,7 @@
16
17
  "lib/server/template-html-policy.mjs",
17
18
  "mcp/pptx-studio-mcp-server.mjs",
18
19
  "scripts/install-mcp.mjs",
20
+ "scripts/extract-html2pptx-comments.mjs",
19
21
  "src/animation-injector.js",
20
22
  "src/animation-renderers.js"
21
23
  ],