slidemind 0.0.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.
- package/.claude-plugin/plugin.json +23 -0
- package/CLAUDE.md +137 -0
- package/README.md +100 -0
- package/bin/slidemind.mjs +603 -0
- package/package.json +62 -0
- package/skills/create-slides/SKILL.md +108 -0
- package/src/bridge/build-and-push.mjs +232 -0
- package/src/bridge/bundle.mjs +373 -0
- package/src/bridge/client.mjs +338 -0
- package/src/builders/build.mjs +724 -0
- package/src/builders/lint-slides.mjs +796 -0
- package/src/builders/primitives.json +19 -0
- package/src/builders/resolve-skill.mjs +205 -0
- package/src/builders/runtime-applier.js +275 -0
- package/src/installers/brand-theme.mjs +38 -0
- package/src/installers/claude.mjs +141 -0
- package/src/installers/codex.mjs +229 -0
- package/src/installers/lib.mjs +435 -0
- package/src/installers/opencode.mjs +181 -0
- package/src/installers/tui.mjs +125 -0
- package/src/mcp/recolor.mjs +111 -0
- package/src/mcp/server.mjs +476 -0
|
@@ -0,0 +1,476 @@
|
|
|
1
|
+
// Slidemind MCP server (stdio).
|
|
2
|
+
//
|
|
3
|
+
// Exposes the deck-authoring + push-to-PowerPoint surface to a local agent
|
|
4
|
+
// (Claude Code, Codex, opencode) as native MCP tools so the agent can
|
|
5
|
+
// enumerate open PowerPoint windows and push a freshly built deck without
|
|
6
|
+
// leaving an on-disk .pptx behind.
|
|
7
|
+
//
|
|
8
|
+
// Tools:
|
|
9
|
+
// slidemind_list_open_presentations
|
|
10
|
+
// Lists PowerPoint windows that have the slidemind add-in connected to
|
|
11
|
+
// the local bridge.
|
|
12
|
+
//
|
|
13
|
+
// slidemind_push_deck
|
|
14
|
+
// Renders a deck server-side via the add-in and pushes its slides into one
|
|
15
|
+
// open PowerPoint window.
|
|
16
|
+
//
|
|
17
|
+
// slidemind_search_engine
|
|
18
|
+
// Searches the active corporate design's brand engine (icons/images today,
|
|
19
|
+
// slide engines later) over the bridge — proxied to the open task pane.
|
|
20
|
+
//
|
|
21
|
+
// slidemind_download_result
|
|
22
|
+
// Fetches a chosen search result's bytes and writes them into a deck's
|
|
23
|
+
// assets/ folder for the slide JSX to reference.
|
|
24
|
+
//
|
|
25
|
+
// Everything routes through the add-in's local bridge at
|
|
26
|
+
// https://127.0.0.1:3001/local/* with a shared token the add-in writes to
|
|
27
|
+
// `${XDG_STATE_HOME:-~/.local/state}/slidemind/bridge-token`
|
|
28
|
+
// (Windows: `%LOCALAPPDATA%\\slidemind\\bridge-token`). See src/bridge/client.mjs.
|
|
29
|
+
|
|
30
|
+
import fs from "node:fs";
|
|
31
|
+
import path from "node:path";
|
|
32
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
33
|
+
|
|
34
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
35
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
36
|
+
import { z } from "zod";
|
|
37
|
+
|
|
38
|
+
import { describeRecolor } from "./recolor.mjs";
|
|
39
|
+
|
|
40
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
41
|
+
const __dirname = path.dirname(__filename);
|
|
42
|
+
const PACKAGE_ROOT = path.resolve(__dirname, "..", "..");
|
|
43
|
+
const SRC_DIR = path.join(PACKAGE_ROOT, "src");
|
|
44
|
+
|
|
45
|
+
async function loadBridgeClient() {
|
|
46
|
+
return import(pathToFileURL(path.join(SRC_DIR, "bridge", "client.mjs")).href);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function loadBuildAndPush() {
|
|
50
|
+
return import(
|
|
51
|
+
pathToFileURL(path.join(SRC_DIR, "bridge", "build-and-push.mjs")).href
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function loadBundle() {
|
|
56
|
+
return import(pathToFileURL(path.join(SRC_DIR, "bridge", "bundle.mjs")).href);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function textResult(value) {
|
|
60
|
+
const text =
|
|
61
|
+
typeof value === "string" ? value : JSON.stringify(value, null, 2);
|
|
62
|
+
return { content: [{ type: "text", text }] };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function errorResult(err) {
|
|
66
|
+
let text = err && err.message ? err.message : String(err);
|
|
67
|
+
const code = err && err.code;
|
|
68
|
+
// Known transient states are recoverable without rebuilding anything — tell
|
|
69
|
+
// the agent exactly how to resume so it pauses for a pane focus instead of
|
|
70
|
+
// aborting the deck.
|
|
71
|
+
if (code === "pane_suspended") {
|
|
72
|
+
text +=
|
|
73
|
+
"\n\nRecoverable: ask the user to click the slidemind task pane to bring " +
|
|
74
|
+
"PowerPoint to the foreground, then retry this call — nothing needs rebuilding.";
|
|
75
|
+
} else if (code === "engine_auth_expired") {
|
|
76
|
+
text +=
|
|
77
|
+
"\n\nRecoverable: ask the user to click the slidemind task pane for ~1s so " +
|
|
78
|
+
"it refreshes its sign-in, then retry this call.";
|
|
79
|
+
}
|
|
80
|
+
return { content: [{ type: "text", text }], isError: true };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Parse a `data:<mime>;base64,<data>` URI into an MCP image content block so a
|
|
84
|
+
// vision-capable agent can "see" the result grid, exactly as the in-app design
|
|
85
|
+
// agent does.
|
|
86
|
+
function imageContentFromDataUri(dataUri) {
|
|
87
|
+
const m = /^data:([^;]+);base64,(.+)$/.exec(dataUri || "");
|
|
88
|
+
if (!m) return null;
|
|
89
|
+
return { type: "image", data: m[2], mimeType: m[1] };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const MIME_EXT = {
|
|
93
|
+
"image/svg+xml": "svg",
|
|
94
|
+
"image/png": "png",
|
|
95
|
+
"image/jpeg": "jpg",
|
|
96
|
+
"image/jpg": "jpg",
|
|
97
|
+
"image/webp": "webp",
|
|
98
|
+
"image/gif": "gif",
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
// Derive a usable file extension from an engine result's MIME type. The brand
|
|
102
|
+
// engine can return any image Content-Type (image/x-icon, image/bmp,
|
|
103
|
+
// image/avif, …) and may carry a `;charset=` suffix, so an exact-match table
|
|
104
|
+
// alone would fall back to a useless `.bin` that won't render in an <img>.
|
|
105
|
+
// Strip params, then fall back to the MIME subtype itself for any `image/*`.
|
|
106
|
+
function extFromMime(mimeType) {
|
|
107
|
+
const mt = String(mimeType || "")
|
|
108
|
+
.split(";")[0]
|
|
109
|
+
.trim()
|
|
110
|
+
.toLowerCase();
|
|
111
|
+
if (MIME_EXT[mt]) return MIME_EXT[mt];
|
|
112
|
+
const m = /^image\/(?:x-)?([a-z0-9.+-]+)$/.exec(mt);
|
|
113
|
+
if (m) {
|
|
114
|
+
const sub = m[1].replace(/\+xml$/, "");
|
|
115
|
+
if (sub === "svg") return "svg";
|
|
116
|
+
if (sub === "jpeg") return "jpg";
|
|
117
|
+
if (sub === "icon") return "ico";
|
|
118
|
+
return sub; // png, gif, webp, bmp, avif, tiff, heic, …
|
|
119
|
+
}
|
|
120
|
+
return "bin";
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const server = new McpServer(
|
|
124
|
+
{ name: "slidemind", version: "0.1.0" },
|
|
125
|
+
{
|
|
126
|
+
instructions:
|
|
127
|
+
"Build on-brand slide decks under <cwd>/decks/<slug>/slides/*.jsx for the " +
|
|
128
|
+
"presentation's active corporate design, then render + push them into a running " +
|
|
129
|
+
"PowerPoint window via the slidemind add-in. The corporate design (tokens, layouts, " +
|
|
130
|
+
"brand rules) and the .pptx render both live in the add-in — an open PowerPoint host " +
|
|
131
|
+
"with an active corporate design is required, and all brand rules come from that " +
|
|
132
|
+
"design's bundle (design.md), never hardcoded here. " +
|
|
133
|
+
"Always call slidemind_list_open_presentations first to learn which presentation_id to target. " +
|
|
134
|
+
"For brand imagery, slidemind_search_engine then slidemind_download_result.",
|
|
135
|
+
},
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
server.registerTool(
|
|
139
|
+
"slidemind_list_open_presentations",
|
|
140
|
+
{
|
|
141
|
+
title: "List open PowerPoint windows",
|
|
142
|
+
description:
|
|
143
|
+
"List PowerPoint windows that currently have the slidemind add-in connected to the local bridge. " +
|
|
144
|
+
"Each entry has a stable presId — pass it as `presentation_id` to slidemind_push_deck. " +
|
|
145
|
+
"If the list is empty the user has no PowerPoint open with the add-in loaded.",
|
|
146
|
+
inputSchema: {},
|
|
147
|
+
},
|
|
148
|
+
async () => {
|
|
149
|
+
try {
|
|
150
|
+
const { listOpenPresentations } = await loadBridgeClient();
|
|
151
|
+
const list = await listOpenPresentations();
|
|
152
|
+
return textResult({ presentations: list });
|
|
153
|
+
} catch (err) {
|
|
154
|
+
return errorResult(err);
|
|
155
|
+
}
|
|
156
|
+
},
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
server.registerTool(
|
|
160
|
+
"slidemind_push_deck",
|
|
161
|
+
{
|
|
162
|
+
title: "Push a built deck into PowerPoint",
|
|
163
|
+
description:
|
|
164
|
+
"Build the deck at deck_path and push the resulting slides into the PowerPoint window identified by " +
|
|
165
|
+
"presentation_id (from slidemind_list_open_presentations). By default slides are appended at the end. " +
|
|
166
|
+
"Use insert_at to place after a specific 0-based slide index. Use replace=true to delete existing slides " +
|
|
167
|
+
"first (destructive). slide_ids restricts the push to a subset of the built deck's slide ids and is for " +
|
|
168
|
+
"the append/insert path only — it CANNOT be combined with replace=true (that combination is rejected, " +
|
|
169
|
+
"because the bridge would otherwise wipe the whole presentation).",
|
|
170
|
+
inputSchema: {
|
|
171
|
+
deck_path: z
|
|
172
|
+
.string()
|
|
173
|
+
.describe("Path to the deck folder (must contain slides/). Absolute or relative to CWD."),
|
|
174
|
+
presentation_id: z
|
|
175
|
+
.string()
|
|
176
|
+
.describe("presId from slidemind_list_open_presentations. Required even when only one window is open."),
|
|
177
|
+
insert_at: z
|
|
178
|
+
.number()
|
|
179
|
+
.int()
|
|
180
|
+
.nonnegative()
|
|
181
|
+
.nullable()
|
|
182
|
+
.optional()
|
|
183
|
+
.describe("0-based slide index to insert after. Omit or null = append at end. Ignored when replace=true."),
|
|
184
|
+
replace: z
|
|
185
|
+
.boolean()
|
|
186
|
+
.optional()
|
|
187
|
+
.describe("If true, delete all existing slides before inserting. Destructive."),
|
|
188
|
+
slide_ids: z
|
|
189
|
+
.array(z.string())
|
|
190
|
+
.nullable()
|
|
191
|
+
.optional()
|
|
192
|
+
.describe("Optional subset of source slide ids to push. Omit or null = all slides in the built deck."),
|
|
193
|
+
},
|
|
194
|
+
},
|
|
195
|
+
async ({ deck_path, presentation_id, insert_at, replace, slide_ids }) => {
|
|
196
|
+
try {
|
|
197
|
+
const { buildAndPush } = await loadBuildAndPush();
|
|
198
|
+
const result = await buildAndPush({
|
|
199
|
+
deckDir: path.resolve(process.cwd(), deck_path),
|
|
200
|
+
presentationId: presentation_id,
|
|
201
|
+
insertAt: insert_at ?? null,
|
|
202
|
+
replace: replace === true,
|
|
203
|
+
slideIds: slide_ids ?? null,
|
|
204
|
+
});
|
|
205
|
+
if (result.paneSuspended) {
|
|
206
|
+
return {
|
|
207
|
+
content: [
|
|
208
|
+
{
|
|
209
|
+
type: "text",
|
|
210
|
+
text:
|
|
211
|
+
"PowerPoint is backgrounded, so the rendered slides couldn't be " +
|
|
212
|
+
"inserted yet. The render succeeded and was saved to " +
|
|
213
|
+
`${result.fallbackFile}. Ask the user to click the slidemind task ` +
|
|
214
|
+
"pane to bring PowerPoint to the foreground, then call " +
|
|
215
|
+
"slidemind_push_deck again — the deck is already built, so this " +
|
|
216
|
+
"just retries the insert.",
|
|
217
|
+
},
|
|
218
|
+
],
|
|
219
|
+
isError: true,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
return textResult(result);
|
|
223
|
+
} catch (err) {
|
|
224
|
+
return errorResult(err);
|
|
225
|
+
}
|
|
226
|
+
},
|
|
227
|
+
);
|
|
228
|
+
|
|
229
|
+
server.registerTool(
|
|
230
|
+
"slidemind_search_engine",
|
|
231
|
+
{
|
|
232
|
+
title: "Search the brand engine for imagery",
|
|
233
|
+
description:
|
|
234
|
+
"Search the active corporate design's brand engine over the bridge (icons/images today; slide engines later). " +
|
|
235
|
+
"Proxied to the open PowerPoint task pane, so it returns the SAME results the in-app design agent sees. " +
|
|
236
|
+
"Returns a searchId and a numbered set of result thumbnails (and a grid image you can view). " +
|
|
237
|
+
"Pick a number, then call slidemind_download_result with the searchId + selection. " +
|
|
238
|
+
"engine_id is optional — omit it to use the corporate design's first image engine.",
|
|
239
|
+
inputSchema: {
|
|
240
|
+
presentation_id: z
|
|
241
|
+
.string()
|
|
242
|
+
.describe("presId from slidemind_list_open_presentations."),
|
|
243
|
+
query: z
|
|
244
|
+
.string()
|
|
245
|
+
.describe("Concept query, e.g. 'shield', 'handshake', 'data pipeline'."),
|
|
246
|
+
engine_id: z
|
|
247
|
+
.string()
|
|
248
|
+
.nullable()
|
|
249
|
+
.optional()
|
|
250
|
+
.describe("Engine id from the design bundle's engines[]. Omit/null = first image engine."),
|
|
251
|
+
},
|
|
252
|
+
},
|
|
253
|
+
async ({ presentation_id, query, engine_id }) => {
|
|
254
|
+
try {
|
|
255
|
+
const client = await loadBridgeClient();
|
|
256
|
+
// All engine tools hard-require an open host (the truth lives in the
|
|
257
|
+
// add-in); surface the actionable "open PowerPoint…" message up front.
|
|
258
|
+
await client.requireHost();
|
|
259
|
+
let engineId = engine_id ?? null;
|
|
260
|
+
if (!engineId) {
|
|
261
|
+
const { materializeBundle, readEnginesFromDir, pickImageEngine } =
|
|
262
|
+
await loadBundle();
|
|
263
|
+
const mat = await materializeBundle(presentation_id);
|
|
264
|
+
const pick = pickImageEngine(readEnginesFromDir(mat.dir));
|
|
265
|
+
if (!pick) {
|
|
266
|
+
return errorResult(
|
|
267
|
+
new Error(
|
|
268
|
+
"This corporate design has no brand engines to search. Pass engine_id or ask the add-in owner to wire an image engine.",
|
|
269
|
+
),
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
engineId = pick.id;
|
|
273
|
+
}
|
|
274
|
+
const res = await client.engineSearch({
|
|
275
|
+
presId: presentation_id,
|
|
276
|
+
engineId,
|
|
277
|
+
query,
|
|
278
|
+
});
|
|
279
|
+
const content = [];
|
|
280
|
+
const grid = imageContentFromDataUri(res?.gridDataUri);
|
|
281
|
+
if (grid) content.push(grid);
|
|
282
|
+
content.push({
|
|
283
|
+
type: "text",
|
|
284
|
+
text: JSON.stringify(
|
|
285
|
+
{
|
|
286
|
+
ok: res?.ok ?? false,
|
|
287
|
+
engineId,
|
|
288
|
+
searchId: res?.searchId ?? null,
|
|
289
|
+
resultCount: res?.resultCount ?? (res?.thumbnails?.length ?? 0),
|
|
290
|
+
results: (res?.thumbnails ?? []).map((t, i) => ({
|
|
291
|
+
selection: i + 1,
|
|
292
|
+
title: t.title ?? null,
|
|
293
|
+
})),
|
|
294
|
+
},
|
|
295
|
+
null,
|
|
296
|
+
2,
|
|
297
|
+
),
|
|
298
|
+
});
|
|
299
|
+
return { content };
|
|
300
|
+
} catch (err) {
|
|
301
|
+
return errorResult(err);
|
|
302
|
+
}
|
|
303
|
+
},
|
|
304
|
+
);
|
|
305
|
+
|
|
306
|
+
// Fetch one chosen result over the bridge and write it into the deck's
|
|
307
|
+
// assets/. Returns the written reference plus a `recolor` descriptor so the
|
|
308
|
+
// agent never has to open the SVG to learn how it recolours. Throws on a failed
|
|
309
|
+
// download so a batch can record a per-item error without aborting the rest.
|
|
310
|
+
async function downloadOneResult({
|
|
311
|
+
client,
|
|
312
|
+
presId,
|
|
313
|
+
searchId,
|
|
314
|
+
assetsDir,
|
|
315
|
+
selection,
|
|
316
|
+
iconColor,
|
|
317
|
+
}) {
|
|
318
|
+
const res = await client.engineDownload({
|
|
319
|
+
presId,
|
|
320
|
+
searchId,
|
|
321
|
+
selection,
|
|
322
|
+
iconColor: iconColor ?? null,
|
|
323
|
+
});
|
|
324
|
+
if (!res?.ok) {
|
|
325
|
+
throw new Error(res?.error ?? "download failed");
|
|
326
|
+
}
|
|
327
|
+
const ext = extFromMime(res.mimeType);
|
|
328
|
+
const filename = `engine-${searchId}-${selection}.${ext}`;
|
|
329
|
+
const target = path.join(assetsDir, filename);
|
|
330
|
+
if (res.svgMarkup && ext === "svg") {
|
|
331
|
+
// The (possibly recoloured) SVG markup is the canonical asset.
|
|
332
|
+
fs.writeFileSync(target, res.svgMarkup, "utf8");
|
|
333
|
+
} else if (typeof res.base64 === "string" && res.base64.length > 0) {
|
|
334
|
+
fs.writeFileSync(target, Buffer.from(res.base64, "base64"));
|
|
335
|
+
} else if (res.svgMarkup) {
|
|
336
|
+
// SVG bytes arrived only as markup (mimeType lacked an svg hint).
|
|
337
|
+
fs.writeFileSync(target, res.svgMarkup, "utf8");
|
|
338
|
+
} else {
|
|
339
|
+
throw new Error("engine result carried neither base64 bytes nor svgMarkup");
|
|
340
|
+
}
|
|
341
|
+
return {
|
|
342
|
+
selection,
|
|
343
|
+
path: target,
|
|
344
|
+
reference: `./assets/${filename}`,
|
|
345
|
+
mimeType: res.mimeType,
|
|
346
|
+
recolor: describeRecolor(res.svgMarkup ?? null, res.mimeType, iconColor ?? null),
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
server.registerTool(
|
|
351
|
+
"slidemind_download_result",
|
|
352
|
+
{
|
|
353
|
+
title: "Download chosen engine result(s) into a deck",
|
|
354
|
+
description:
|
|
355
|
+
"Fetch the bytes of one or more results from a prior slidemind_search_engine call and write them into the deck's assets/ folder. " +
|
|
356
|
+
"Reference each from slide JSX as <img src=\"./assets/<file>\">. " +
|
|
357
|
+
"Each result carries a `recolor` descriptor ({ recolorable, mode, applied, note }) — so you NEVER need to open the SVG to learn how it recolours. " +
|
|
358
|
+
"To colour an icon, pass icon_color (a hex you resolve from a corporate-design.css token): the bridge bakes it into the saved SVG, correct in BOTH the HTML preview and the .pptx render. " +
|
|
359
|
+
"Download several icons (each in its own colour) in ONE call via `selections: [{ selection, icon_color? }, …]` — they download concurrently. Use `selection` for one, or `selections` for many (don't pass both).",
|
|
360
|
+
inputSchema: {
|
|
361
|
+
presentation_id: z
|
|
362
|
+
.string()
|
|
363
|
+
.describe("presId — must match the slidemind_search_engine call."),
|
|
364
|
+
deck_path: z
|
|
365
|
+
.string()
|
|
366
|
+
.describe("Path to the deck folder (assets are written under <deck>/assets/)."),
|
|
367
|
+
search_id: z
|
|
368
|
+
.number()
|
|
369
|
+
.int()
|
|
370
|
+
.describe("searchId returned by slidemind_search_engine."),
|
|
371
|
+
selection: z
|
|
372
|
+
.number()
|
|
373
|
+
.int()
|
|
374
|
+
.positive()
|
|
375
|
+
.nullable()
|
|
376
|
+
.optional()
|
|
377
|
+
.describe("1-based result number to download (single). Omit when using `selections`."),
|
|
378
|
+
icon_color: z
|
|
379
|
+
.string()
|
|
380
|
+
.nullable()
|
|
381
|
+
.optional()
|
|
382
|
+
.describe("Optional hex colour (e.g. '#0000FF') to bake into a single SVG result."),
|
|
383
|
+
selections: z
|
|
384
|
+
.array(
|
|
385
|
+
z.object({
|
|
386
|
+
selection: z
|
|
387
|
+
.number()
|
|
388
|
+
.int()
|
|
389
|
+
.positive()
|
|
390
|
+
.describe("1-based result number."),
|
|
391
|
+
icon_color: z
|
|
392
|
+
.string()
|
|
393
|
+
.nullable()
|
|
394
|
+
.optional()
|
|
395
|
+
.describe("Optional hex colour to bake into this SVG."),
|
|
396
|
+
}),
|
|
397
|
+
)
|
|
398
|
+
.nullable()
|
|
399
|
+
.optional()
|
|
400
|
+
.describe("Batch: several results to download concurrently, each in its own colour."),
|
|
401
|
+
},
|
|
402
|
+
},
|
|
403
|
+
async ({ presentation_id, deck_path, search_id, selection, icon_color, selections }) => {
|
|
404
|
+
try {
|
|
405
|
+
const batch = Array.isArray(selections) && selections.length > 0;
|
|
406
|
+
const jobs = batch
|
|
407
|
+
? selections.map((s) => ({
|
|
408
|
+
selection: s.selection,
|
|
409
|
+
iconColor: s.icon_color ?? null,
|
|
410
|
+
}))
|
|
411
|
+
: typeof selection === "number"
|
|
412
|
+
? [{ selection, iconColor: icon_color ?? null }]
|
|
413
|
+
: [];
|
|
414
|
+
if (jobs.length === 0) {
|
|
415
|
+
return errorResult(
|
|
416
|
+
new Error(
|
|
417
|
+
"Provide `selection` (one result) or `selections` (a batch). Both are missing.",
|
|
418
|
+
),
|
|
419
|
+
);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
const client = await loadBridgeClient();
|
|
423
|
+
await client.requireHost();
|
|
424
|
+
const deckDir = path.resolve(process.cwd(), deck_path);
|
|
425
|
+
const assetsDir = path.join(deckDir, "assets");
|
|
426
|
+
fs.mkdirSync(assetsDir, { recursive: true });
|
|
427
|
+
|
|
428
|
+
// Concurrent — each download is its own bridge round-trip, so issuing them
|
|
429
|
+
// in parallel turns N serial waits into one. allSettled so one bad
|
|
430
|
+
// selection doesn't sink the rest of the batch.
|
|
431
|
+
const settled = await Promise.allSettled(
|
|
432
|
+
jobs.map((job) =>
|
|
433
|
+
downloadOneResult({
|
|
434
|
+
client,
|
|
435
|
+
presId: presentation_id,
|
|
436
|
+
searchId: search_id,
|
|
437
|
+
assetsDir,
|
|
438
|
+
selection: job.selection,
|
|
439
|
+
iconColor: job.iconColor,
|
|
440
|
+
}),
|
|
441
|
+
),
|
|
442
|
+
);
|
|
443
|
+
const results = settled.map((r, i) =>
|
|
444
|
+
r.status === "fulfilled"
|
|
445
|
+
? r.value
|
|
446
|
+
: {
|
|
447
|
+
selection: jobs[i].selection,
|
|
448
|
+
error: r.reason?.message ? r.reason.message : String(r.reason),
|
|
449
|
+
},
|
|
450
|
+
);
|
|
451
|
+
|
|
452
|
+
// Single-selection calls keep the original flat shape (back-compat);
|
|
453
|
+
// a batch returns { results: [...] }.
|
|
454
|
+
if (!batch) {
|
|
455
|
+
const only = results[0];
|
|
456
|
+
if (only.error) return errorResult(new Error(only.error));
|
|
457
|
+
return textResult(only);
|
|
458
|
+
}
|
|
459
|
+
return textResult({ results });
|
|
460
|
+
} catch (err) {
|
|
461
|
+
return errorResult(err);
|
|
462
|
+
}
|
|
463
|
+
},
|
|
464
|
+
);
|
|
465
|
+
|
|
466
|
+
async function main() {
|
|
467
|
+
const transport = new StdioServerTransport();
|
|
468
|
+
await server.connect(transport);
|
|
469
|
+
// Logging on stdout would corrupt the JSON-RPC stream — keep it on stderr.
|
|
470
|
+
process.stderr.write("slidemind MCP server running on stdio\n");
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
main().catch((err) => {
|
|
474
|
+
process.stderr.write(`slidemind MCP fatal: ${err && err.stack ? err.stack : err}\n`);
|
|
475
|
+
process.exit(1);
|
|
476
|
+
});
|