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.
- package/app/docs/content.js +76 -60
- package/cli/dist/commands/edit.d.ts +1 -0
- package/cli/dist/commands/edit.js +224 -11
- package/cli/dist/index.js +1 -1
- package/cli/package.json +1 -1
- package/lib/local-slide-editor-launcher.js +31 -2
- package/lib/pptx-studio-mcp-core.js +30 -19
- package/mcp/pptx-studio-mcp-server.mjs +48 -13
- package/package.json +4 -2
- package/scripts/extract-html2pptx-comments.mjs +172 -0
- package/scripts/install-mcp.mjs +150 -253
|
@@ -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 >
|
|
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",
|
|
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.",
|
|
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.",
|
|
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}`,
|
|
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,
|
|
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 (
|
|
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 (
|
|
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 (!
|
|
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
|
|
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
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
|
664
|
-
'',
|
|
665
|
-
'
|
|
666
|
-
'',
|
|
667
|
-
'
|
|
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
|
|
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
|
|
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,
|
|
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
|
|
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
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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 (
|
|
186
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
],
|