html2pptx-local-mcp 1.1.24 → 1.1.27

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.
@@ -64,10 +64,14 @@ export function createLocalEditorServerManager(options = {}) {
64
64
  const baseUrl = `http://localhost:${port}`;
65
65
  const invocation = await resolveNextInvocation(appRoot, options);
66
66
 
67
+ // Issue #78: do not pass `--webpack`. Next.js 16.2.x webpack mode fails
68
+ // to compile App Router `.jsx` files (next-swc-loader emits JSX that the
69
+ // webpack default parser then chokes on with "Unexpected token"). Use
70
+ // Next.js's default compiler (turbopack in 16+) — App Router `.jsx` is
71
+ // handled correctly there.
67
72
  const child = spawn(invocation.command, [
68
73
  ...invocation.baseArgs,
69
74
  'dev',
70
- '--webpack',
71
75
  '-p',
72
76
  String(port),
73
77
  ], {
@@ -734,7 +734,56 @@ function buildServerInstructions(client = {}, { localOnly = false } = {}) {
734
734
  return lines.join('\n');
735
735
  }
736
736
 
737
- export async function executeTool(name, args, client, { sendNotification, progressToken } = {}) {
737
+ /**
738
+ * Pull a sanitized `{ name, version }` snapshot out of the raw
739
+ * `params.clientInfo` that MCP clients send during `initialize`. Returns
740
+ * `null` if the payload is unusable so callers can treat "no info" uniformly.
741
+ *
742
+ * @param {unknown} raw
743
+ * @returns {{ name: string, version?: string } | null}
744
+ */
745
+ export function extractClientInfo(raw) {
746
+ if (!raw || typeof raw !== 'object') return null;
747
+ const name = typeof raw.name === 'string' ? raw.name.trim() : '';
748
+ if (!name) return null;
749
+ const version = typeof raw.version === 'string' ? raw.version.trim() : '';
750
+ return version ? { name, version } : { name };
751
+ }
752
+
753
+ /**
754
+ * Decide whether html2pptx_open_local_slide_editor should auto-open the OS
755
+ * default browser when the MCP caller did not explicitly set `openBrowser`.
756
+ *
757
+ * Strategy: conservative allowlist. Only known terminal-style CLI clients
758
+ * (Claude Code, Codex CLI, anything whose advertised clientInfo.name ends in
759
+ * `-cli`) get the auto-open default. Desktop/IDE clients (Claude Desktop,
760
+ * Cursor, Cline, unknown) keep the existing "return URL only, let the host
761
+ * render it inline" behavior — that's the right default for future MCP
762
+ * side-pane UI surfaces.
763
+ *
764
+ * @param {{ name?: string } | null | undefined} clientInfo
765
+ * @returns {boolean}
766
+ */
767
+ export function inferOpenBrowserDefault(clientInfo) {
768
+ const rawName = String(clientInfo?.name ?? '').trim().toLowerCase();
769
+ if (!rawName) return false;
770
+ if (/^claude[\s\-_]?code/.test(rawName)) return true;
771
+ if (/^codex(?:[\s\-_]|$)/.test(rawName)) return true;
772
+ if (/[\-_]cli$/.test(rawName)) return true;
773
+ return false;
774
+ }
775
+
776
+ /**
777
+ * Resolve the effective `openBrowser` flag for the local slide editor tool.
778
+ * Explicit boolean from args always wins; otherwise fall back to the
779
+ * clientInfo heuristic above.
780
+ */
781
+ export function resolveOpenBrowserFlag(argsOpenBrowser, clientInfo) {
782
+ if (typeof argsOpenBrowser === 'boolean') return argsOpenBrowser;
783
+ return inferOpenBrowserDefault(clientInfo);
784
+ }
785
+
786
+ export async function executeTool(name, args, client, { sendNotification, progressToken, clientInfo } = {}) {
738
787
  switch (name) {
739
788
  case 'html2pptx_list_export_plans': {
740
789
  const data = await client.listExportPlans();
@@ -918,7 +967,7 @@ export async function executeTool(name, args, client, { sendNotification, progre
918
967
  baseUrl: typeof args.baseUrl === 'string' ? args.baseUrl : undefined,
919
968
  editorPort: Number.isFinite(args.editorPort) ? args.editorPort : undefined,
920
969
  port: Number.isFinite(args.port) ? args.port : undefined,
921
- openBrowser: args.openBrowser === true,
970
+ openBrowser: resolveOpenBrowserFlag(args.openBrowser, clientInfo),
922
971
  reuseExisting: args.reuseExisting !== false,
923
972
  });
924
973
  return buildToolResponse(renderLocalSlideEditorText(data), data);
@@ -936,7 +985,7 @@ export async function executeTool(name, args, client, { sendNotification, progre
936
985
  }
937
986
  }
938
987
 
939
- export async function handleMcpMessage(message, { protocolVersion = DEFAULT_PROTOCOL, client, sendNotification, serverInfo = SERVER_INFO, localOnly = false }) {
988
+ export async function handleMcpMessage(message, { protocolVersion = DEFAULT_PROTOCOL, client, sendNotification, serverInfo = SERVER_INFO, localOnly = false, clientInfo = null }) {
940
989
  if (!message || typeof message !== 'object') {
941
990
  return { protocolVersion, response: null };
942
991
  }
@@ -955,6 +1004,7 @@ export async function handleMcpMessage(message, { protocolVersion = DEFAULT_PROT
955
1004
  const negotiatedProtocol = negotiateProtocol(params?.protocolVersion);
956
1005
  return {
957
1006
  protocolVersion: negotiatedProtocol,
1007
+ clientInfo: extractClientInfo(params?.clientInfo),
958
1008
  response: {
959
1009
  jsonrpc: '2.0',
960
1010
  id,
@@ -1168,7 +1218,7 @@ export async function handleMcpMessage(message, { protocolVersion = DEFAULT_PROT
1168
1218
  try {
1169
1219
  const toolArgs = params?.arguments ?? {};
1170
1220
  const progressToken = toolArgs._meta?.progressToken ?? params?._meta?.progressToken;
1171
- const payload = await executeTool(params?.name, toolArgs, client, { sendNotification, progressToken });
1221
+ const payload = await executeTool(params?.name, toolArgs, client, { sendNotification, progressToken, clientInfo });
1172
1222
  return {
1173
1223
  protocolVersion,
1174
1224
  response: {
@@ -1454,14 +1504,22 @@ function renderAnimationCatalogText(catalog) {
1454
1504
  }
1455
1505
 
1456
1506
  function renderLocalSlideEditorText(data) {
1507
+ const editorUrl = data.editorUrl || 'unavailable';
1457
1508
  const lines = [
1458
1509
  data.reused ? '# Local slide editor reused' : '# Local slide editor started',
1459
1510
  '',
1511
+ '⚠️ IMPORTANT: Open the Editor URL below EXACTLY as written.',
1512
+ ' - Do NOT change the origin. The hosted https://html2pptx.app/edit-slide',
1513
+ ' page cannot reach a localhost bridge from a user\'s browser (mixed-',
1514
+ ' content + CORS) and will return an error page (Issue #71).',
1515
+ ' - Do NOT strip the URL fragment (#bridgeToken=...). The localhost',
1516
+ ' bridge requires that token to accept requests.',
1517
+ '',
1460
1518
  `File: ${data.file || 'unknown'}`,
1461
1519
  `Bridge: ${data.bridgeUrl || 'unknown'}`,
1462
1520
  `Session: ${data.sessionId || 'unknown'}`,
1463
1521
  '',
1464
- `Editor URL: ${data.editorUrl || 'unavailable'}`,
1522
+ `Editor URL: ${editorUrl}`,
1465
1523
  '',
1466
1524
  'Keep this MCP server running while editing. Use html2pptx_stop_local_slide_editor with the sessionId when you are done.',
1467
1525
  ];
@@ -2,6 +2,6 @@
2
2
  "private": true,
3
3
  "type": "module",
4
4
  "scripts": {
5
- "dev": "next dev --webpack"
5
+ "dev": "next dev"
6
6
  }
7
7
  }
@@ -14,6 +14,7 @@ import {
14
14
  let inputBuffer = Buffer.alloc(0);
15
15
  let negotiatedProtocol = DEFAULT_PROTOCOL;
16
16
  let wireMode = 'content-length';
17
+ let mcpClientInfo = null;
17
18
 
18
19
  process.stdin.on('data', (chunk) => {
19
20
  inputBuffer = Buffer.concat([inputBuffer, chunk]);
@@ -126,6 +127,7 @@ async function handleMessage(message) {
126
127
  const handled = await handleMcpMessage(message, {
127
128
  protocolVersion: negotiatedProtocol,
128
129
  client,
130
+ clientInfo: mcpClientInfo,
129
131
  serverInfo: {
130
132
  ...SERVER_INFO,
131
133
  name: 'html2pptx-local',
@@ -136,6 +138,9 @@ async function handleMessage(message) {
136
138
  },
137
139
  });
138
140
  negotiatedProtocol = handled.protocolVersion;
141
+ if (handled.clientInfo !== undefined) {
142
+ mcpClientInfo = handled.clientInfo;
143
+ }
139
144
 
140
145
  if (handled.response) {
141
146
  sendMessage(handled.response);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "html2pptx-local-mcp",
3
- "version": "1.1.24",
3
+ "version": "1.1.27",
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": {
@@ -31,6 +31,7 @@
31
31
  "@clack/prompts": "^0.10.1",
32
32
  "commander": "^13.1.0",
33
33
  "framer-motion": "^12.38.0",
34
+ "html2canvas": "^1.4.1",
34
35
  "jsdom": "^26.1.0",
35
36
  "lucide-react": "^1.7.0",
36
37
  "next": "^16.2.2",
@@ -1 +0,0 @@
1
- export declare function configShowCommand(): Promise<void>;
@@ -1,16 +0,0 @@
1
- import * as p from "@clack/prompts";
2
- import pc from "picocolors";
3
- import { loadConfig, getConfigPath } from "../config.js";
4
- export async function configShowCommand() {
5
- const config = await loadConfig();
6
- const configPath = getConfigPath();
7
- p.log.info(pc.bold("Config file: ") + pc.dim(configPath));
8
- if (!config.apiKey) {
9
- p.log.warn("No configuration found. Run " + pc.cyan("html2pptx login") + " to set up.");
10
- return;
11
- }
12
- p.log.info(pc.bold("API Key: ") +
13
- pc.dim(config.apiKey.slice(0, 12) + "..." + config.apiKey.slice(-4)));
14
- p.log.info(pc.bold("API Base URL: ") +
15
- (config.baseUrl ?? "https://html2pptx.app") + pc.dim(" (default)"));
16
- }
@@ -1,10 +0,0 @@
1
- interface ConvertOptions {
2
- output?: string;
3
- size?: string;
4
- css?: string;
5
- json?: boolean;
6
- open?: boolean;
7
- baseUrl?: string;
8
- }
9
- export declare function convertCommand(input?: string, options?: ConvertOptions): Promise<void>;
10
- export {};
@@ -1,311 +0,0 @@
1
- import { readFile, writeFile } from "node:fs/promises";
2
- import { exec } from "node:child_process";
3
- import { platform } from "node:os";
4
- import { basename, resolve } from "node:path";
5
- import * as p from "@clack/prompts";
6
- import pc from "picocolors";
7
- import { loadConfig } from "../config.js";
8
- function parseSize(size) {
9
- if (size === "16:9")
10
- return { layout: "LAYOUT_16x9" };
11
- if (size === "4:3")
12
- return { layout: "LAYOUT_4x3" };
13
- const match = size.match(/^(\d+)x(\d+)$/);
14
- if (match)
15
- return { width: parseInt(match[1]), height: parseInt(match[2]) };
16
- return { layout: "LAYOUT_16x9" };
17
- }
18
- function formatDuration(ms) {
19
- if (ms < 1000)
20
- return `${ms}ms`;
21
- return `${(ms / 1000).toFixed(1)}s`;
22
- }
23
- function openFile(filePath) {
24
- const os = platform();
25
- const cmd = os === "darwin" ? "open" : os === "win32" ? "start" : "xdg-open";
26
- exec(`${cmd} "${filePath}"`);
27
- }
28
- async function interactivePrompt() {
29
- p.intro(pc.bgCyan(pc.black(" html2pptx convert ")));
30
- const result = await p.group({
31
- input: () => p.text({
32
- message: "HTML file to convert",
33
- placeholder: "./slides.html",
34
- validate(value) {
35
- if (!value)
36
- return "HTML file path is required";
37
- if (!value.endsWith(".html") && !value.endsWith(".htm"))
38
- return "File must be .html or .htm";
39
- },
40
- }),
41
- size: () => p.select({
42
- message: "Slide size",
43
- options: [
44
- { value: "16:9", label: "16:9 (1600x900)", hint: "default" },
45
- { value: "4:3", label: "4:3 (1024x768)" },
46
- { value: "custom", label: "Custom size" },
47
- ],
48
- }),
49
- customSize: ({ results }) => {
50
- if (results.size !== "custom")
51
- return;
52
- return p.text({
53
- message: "Enter custom size (WxH)",
54
- placeholder: "1920x1080",
55
- validate(value) {
56
- if (!value?.match(/^\d+x\d+$/))
57
- return 'Format: WIDTHxHEIGHT (e.g. 1920x1080)';
58
- },
59
- });
60
- },
61
- css: () => p.text({
62
- message: "External CSS file (optional)",
63
- placeholder: "Press Enter to skip",
64
- }),
65
- output: ({ results }) => {
66
- const defaultName = basename(results.input).replace(/\.html?$/, "") + ".pptx";
67
- return p.text({
68
- message: "Output filename",
69
- placeholder: defaultName,
70
- initialValue: defaultName,
71
- });
72
- },
73
- }, {
74
- onCancel() {
75
- p.cancel("Conversion cancelled.");
76
- process.exit(0);
77
- },
78
- });
79
- return {
80
- input: result.input,
81
- output: result.output,
82
- size: result.customSize ?? result.size,
83
- css: result.css || undefined,
84
- };
85
- }
86
- async function runExport(html, css, size, fileName, baseUrl, apiKey) {
87
- const sizeParams = parseSize(size);
88
- const body = {
89
- html,
90
- fileName,
91
- responseFormat: "url",
92
- ...sizeParams,
93
- };
94
- if (css)
95
- body.css = css;
96
- const res = await fetch(`${baseUrl}/api/v1/export/jobs`, {
97
- method: "POST",
98
- headers: {
99
- "Content-Type": "application/json",
100
- Authorization: `Bearer ${apiKey}`,
101
- },
102
- body: JSON.stringify(body),
103
- });
104
- if (res.status === 401) {
105
- throw new Error("Invalid API key. Run `html2pptx login` to update your key.");
106
- }
107
- if (res.status === 403) {
108
- throw new Error("Access denied. Your API key may be expired or your plan may have changed. Visit https://html2pptx.app/dashboard to check.");
109
- }
110
- if (res.status === 413) {
111
- throw new Error("HTML payload too large for your plan. Try reducing the HTML size or upgrading your plan.");
112
- }
113
- if (res.status === 429) {
114
- throw new Error("Rate limit exceeded. Please wait a moment and try again, or upgrade your plan for higher limits.");
115
- }
116
- if (!res.ok) {
117
- const text = await res.text();
118
- throw new Error(`API error ${res.status}: ${text}`);
119
- }
120
- return (await res.json());
121
- }
122
- async function pollJob(statusUrl, apiKey, baseUrl) {
123
- if (!statusUrl.startsWith(baseUrl)) {
124
- throw new Error("Unexpected status URL from server");
125
- }
126
- const maxAttempts = 60;
127
- for (let i = 0; i < maxAttempts; i++) {
128
- await new Promise((r) => setTimeout(r, 2000));
129
- const res = await fetch(statusUrl, {
130
- headers: { Authorization: `Bearer ${apiKey}` },
131
- });
132
- if (!res.ok) {
133
- const text = await res.text();
134
- throw new Error(`Poll error ${res.status}: ${text}`);
135
- }
136
- const data = (await res.json());
137
- if (data.status === "completed")
138
- return data;
139
- if (data.status === "failed")
140
- throw new Error(data.error ?? "Export failed");
141
- }
142
- throw new Error("Export timed out after 2 minutes");
143
- }
144
- async function downloadFile(url, outputPath) {
145
- const res = await fetch(url);
146
- if (!res.ok)
147
- throw new Error(`Download failed: ${res.status}`);
148
- const buffer = Buffer.from(await res.arrayBuffer());
149
- await writeFile(outputPath, buffer);
150
- return buffer.byteLength;
151
- }
152
- function formatBytes(bytes) {
153
- if (bytes < 1024)
154
- return `${bytes} B`;
155
- if (bytes < 1024 * 1024)
156
- return `${(bytes / 1024).toFixed(1)} KB`;
157
- return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
158
- }
159
- export async function convertCommand(input, options = {}) {
160
- const config = await loadConfig();
161
- const apiKey = config.apiKey;
162
- const baseUrl = options.baseUrl ?? config.baseUrl ?? "https://html2pptx.app";
163
- const jsonMode = options.json ?? false;
164
- if (!apiKey) {
165
- if (jsonMode) {
166
- console.log(JSON.stringify({ success: false, error: "No API key found. Run html2pptx login first." }));
167
- process.exit(1);
168
- }
169
- p.log.error("No API key found. Run " + pc.cyan("html2pptx login") + " first.");
170
- process.exit(1);
171
- }
172
- let htmlPath;
173
- let outputFile;
174
- let size;
175
- let cssPath;
176
- // If args provided -> direct mode, else -> interactive mode
177
- if (input) {
178
- htmlPath = resolve(input);
179
- outputFile =
180
- options.output ?? basename(htmlPath).replace(/\.html?$/, "") + ".pptx";
181
- size = options.size ?? "16:9";
182
- cssPath = options.css;
183
- }
184
- else {
185
- const answers = await interactivePrompt();
186
- htmlPath = resolve(answers.input);
187
- outputFile = answers.output;
188
- size = answers.size;
189
- cssPath = answers.css;
190
- }
191
- const startTime = Date.now();
192
- const spinner = jsonMode ? null : p.spinner();
193
- spinner?.start("Reading HTML file...");
194
- let html;
195
- try {
196
- html = await readFile(htmlPath, "utf-8");
197
- }
198
- catch {
199
- if (jsonMode) {
200
- console.log(JSON.stringify({ success: false, error: `Could not read file: ${htmlPath}` }));
201
- }
202
- else {
203
- spinner?.stop("Failed");
204
- p.log.error(`Could not read file: ${pc.dim(htmlPath)}`);
205
- }
206
- process.exit(1);
207
- }
208
- let css;
209
- if (cssPath) {
210
- try {
211
- css = await readFile(resolve(cssPath), "utf-8");
212
- }
213
- catch {
214
- if (jsonMode) {
215
- console.log(JSON.stringify({ success: false, error: `Could not read CSS file: ${cssPath}` }));
216
- }
217
- else {
218
- spinner?.stop("Failed");
219
- p.log.error(`Could not read CSS file: ${pc.dim(cssPath)}`);
220
- }
221
- process.exit(1);
222
- }
223
- }
224
- spinner?.message("Sending to html2pptx API...");
225
- let job;
226
- try {
227
- job = await runExport(html, css, size, outputFile, baseUrl, apiKey);
228
- }
229
- catch (e) {
230
- const errMsg = e.message;
231
- if (jsonMode) {
232
- console.log(JSON.stringify({ success: false, error: errMsg }));
233
- }
234
- else {
235
- spinner?.stop("Failed");
236
- p.log.error(errMsg);
237
- }
238
- process.exit(1);
239
- }
240
- const finishConversion = async (downloadUrl) => {
241
- spinner?.message("Downloading PPTX...");
242
- const fileSize = await downloadFile(downloadUrl, resolve(outputFile));
243
- const duration = Date.now() - startTime;
244
- if (jsonMode) {
245
- const result = {
246
- success: true,
247
- file: outputFile,
248
- size: formatBytes(fileSize),
249
- duration: formatDuration(duration),
250
- };
251
- console.log(JSON.stringify(result));
252
- }
253
- else {
254
- spinner?.stop(pc.green("Done!"));
255
- p.log.success(`Saved to ${pc.cyan(outputFile)} ${pc.dim(`(${formatBytes(fileSize)}, ${formatDuration(duration)})`)}`);
256
- // Next steps guidance
257
- p.log.info(pc.dim("Next: ") +
258
- `Open the file or run ${pc.cyan("html2pptx status")} to check your usage.`);
259
- }
260
- if (options.open) {
261
- openFile(resolve(outputFile));
262
- }
263
- };
264
- // If job completed immediately
265
- if (job.status === "completed" && job.downloadUrl) {
266
- try {
267
- await finishConversion(job.downloadUrl);
268
- return;
269
- }
270
- catch (e) {
271
- const errMsg = e.message;
272
- if (jsonMode) {
273
- console.log(JSON.stringify({ success: false, error: errMsg }));
274
- }
275
- else {
276
- spinner?.stop("Failed");
277
- p.log.error(errMsg);
278
- }
279
- process.exit(1);
280
- }
281
- }
282
- // Poll for completion
283
- spinner?.message("Converting... this may take a moment");
284
- try {
285
- const result = await pollJob(job.statusUrl, apiKey, baseUrl);
286
- if (result.downloadUrl) {
287
- await finishConversion(result.downloadUrl);
288
- }
289
- else {
290
- if (jsonMode) {
291
- console.log(JSON.stringify({ success: false, error: "No download URL returned" }));
292
- }
293
- else {
294
- spinner?.stop("Failed");
295
- p.log.error("No download URL returned");
296
- }
297
- process.exit(1);
298
- }
299
- }
300
- catch (e) {
301
- const errMsg = e.message;
302
- if (jsonMode) {
303
- console.log(JSON.stringify({ success: false, error: errMsg }));
304
- }
305
- else {
306
- spinner?.stop("Failed");
307
- p.log.error(errMsg);
308
- }
309
- process.exit(1);
310
- }
311
- }
@@ -1,34 +0,0 @@
1
- import { type IncomingMessage, type ServerResponse } from "node:http";
2
- export interface EditOptions {
3
- port?: string;
4
- baseUrl?: string;
5
- noOpen?: boolean;
6
- open?: boolean;
7
- json?: boolean;
8
- }
9
- interface BridgeContext {
10
- root: string;
11
- editorOrigin: string;
12
- localStateDir: string;
13
- sessionToken: string;
14
- }
15
- declare function generateSessionToken(): string;
16
- declare function parsePort(value: string | undefined): number;
17
- declare function normalizeBaseUrl(raw: string): URL;
18
- declare function readRegisteredEditorBaseUrl(root: string): Promise<string | null>;
19
- declare function resolveEditorBaseUrl(root: string, explicitBaseUrl: string | undefined): Promise<URL>;
20
- declare function buildEditorUrl(baseUrl: URL, rel: string, bridgeUrl: string, sessionToken: string): URL;
21
- declare function createBridgeServer(ctx: BridgeContext): import("http").Server<typeof IncomingMessage, typeof ServerResponse>;
22
- declare function listen(server: ReturnType<typeof createBridgeServer>, requestedPort: number): Promise<number>;
23
- export declare function editCommand(input: string | undefined, options?: EditOptions): Promise<void>;
24
- export declare const editCommandInternalsForTest: {
25
- buildEditorUrl: typeof buildEditorUrl;
26
- createBridgeServer: typeof createBridgeServer;
27
- generateSessionToken: typeof generateSessionToken;
28
- listen: typeof listen;
29
- normalizeBaseUrl: typeof normalizeBaseUrl;
30
- parsePort: typeof parsePort;
31
- readRegisteredEditorBaseUrl: typeof readRegisteredEditorBaseUrl;
32
- resolveEditorBaseUrl: typeof resolveEditorBaseUrl;
33
- };
34
- export {};