slides-studio-mcp 0.1.0-beta.1
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/README.md +24 -0
- package/guidance/design.md +44 -0
- package/package.json +34 -0
- package/skill/slide-studio/SKILL.md +16 -0
- package/src/cli.mjs +37 -0
- package/src/companion.mjs +97 -0
- package/src/config.mjs +31 -0
- package/src/daemon.mjs +413 -0
- package/src/mcp-server.mjs +214 -0
- package/src/setup.mjs +94 -0
- package/src/stdio-server.mjs +23 -0
package/README.md
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# Slide Studio MCP
|
|
2
|
+
|
|
3
|
+
Local-first MCP companion for the hosted [Slide Studio test editor](https://slides-mcp-poc-0821.pages.dev). It exposes complete project, slide, text, image, layer, history, rendering, and export controls to any stdio MCP client while the editor remains in the browser.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npx slides-studio-mcp@beta setup
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
The companion binds only to `127.0.0.1`. There is no hosted relay: projects remain in browser IndexedDB and local images remain on the user's computer.
|
|
10
|
+
|
|
11
|
+
Any MCP client can launch it with:
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npx -y slides-studio-mcp@beta serve
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Useful commands:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
npx slides-studio-mcp@beta setup --dry-run
|
|
21
|
+
npx slides-studio-mcp@beta doctor
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Open the test editor, click **Connect AI**, and call `get_design_guidance` before editing. The server enforces that one-time read and provides `render_slide` so agents can inspect actual pixels without permanently storing previews.
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# Slide Studio design guidance
|
|
2
|
+
|
|
3
|
+
Read this before creating or editing slides. Use it as a compact quality bar, then inspect the rendered slide instead of assuming code values look good.
|
|
4
|
+
|
|
5
|
+
## Defaults that usually look good
|
|
6
|
+
|
|
7
|
+
- Build for a 9:16 phone canvas and keep one clear idea per slide.
|
|
8
|
+
- Prefer `boxed` text with `backgroundShape: "lines"` for highlighted copy. The per-line treatment is the product's strongest default.
|
|
9
|
+
- Avoid `backgroundShape: "full"` unless a deliberate large label or card is required. A large rectangular text box usually looks heavy.
|
|
10
|
+
- Use plain or outlined text for supporting copy. Use no more than two text treatments on one slide.
|
|
11
|
+
- Start headlines around 64–88 px and supporting text around 42–60 px. Adjust after rendering.
|
|
12
|
+
- Keep important content inside roughly `x: 0.06..0.86` and `y: 0.08..0.78` when the TikTok overlay matters. The right and bottom edges are occupied by interface controls and captions.
|
|
13
|
+
- Give text boxes generous width and height. Leave at least 0.04 of canvas width beyond the visible longest line and enough height for every line plus its background. Never let glyphs or rounded backgrounds touch a box edge.
|
|
14
|
+
- Use short lines. Two to four lines for a headline is usually stronger than one dense paragraph.
|
|
15
|
+
- Keep strong contrast between copy and the image. Use black boxed backgrounds with white text or white boxed backgrounds with near-black text.
|
|
16
|
+
- Preserve an obvious focal image. Do not cover faces or the main subject unless the composition intentionally calls for it.
|
|
17
|
+
- Align related text layers consistently. Center is a safe default; use left alignment for editorial layouts.
|
|
18
|
+
- Use rotation sparingly. Small intentional angles can add energy; arbitrary angles make carousels feel inconsistent.
|
|
19
|
+
- Reuse a small palette and consistent type scale across the project.
|
|
20
|
+
|
|
21
|
+
## Working method
|
|
22
|
+
|
|
23
|
+
1. Inspect the editor and use the returned project, slide, asset, and layer IDs.
|
|
24
|
+
2. Create or update one slide at a time. The editor automatically switches to the most recently changed slide.
|
|
25
|
+
3. Use `apply_operations` when several related edits can be expressed compactly; the browser still shows each operation live.
|
|
26
|
+
4. Call `render_slide` after a meaningful composition change and look at the returned image.
|
|
27
|
+
5. Correct clipping, collisions, weak contrast, unsafe placement, inconsistent spacing, and visual imbalance before continuing.
|
|
28
|
+
6. Render the complete set at least once before exporting.
|
|
29
|
+
|
|
30
|
+
## Text-box clipping checklist
|
|
31
|
+
|
|
32
|
+
- Increase width before shrinking type when a line almost fits.
|
|
33
|
+
- Increase height when multiline text or per-line backgrounds approach the top or bottom edge.
|
|
34
|
+
- Keep `x + width` and `y + height` within the canvas unless an off-canvas effect is intentional.
|
|
35
|
+
- With boxed text, keep `backgroundShape: "lines"` and do not size the box tightly around the letters; rounded pills need breathing room.
|
|
36
|
+
- If the result is uncertain, render it. Numeric state is not a visual review.
|
|
37
|
+
|
|
38
|
+
## Agent behavior
|
|
39
|
+
|
|
40
|
+
- Do not delete projects, slides, assets, or layers unless the request calls for it.
|
|
41
|
+
- Do not overwrite an existing export unless explicitly requested.
|
|
42
|
+
- Keep tool responses and progress messages concise.
|
|
43
|
+
- Prefer IDs returned by tools over guessed names or array positions.
|
|
44
|
+
- Do not claim a slide looks good until you have inspected a rendered image.
|
package/package.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "slides-studio-mcp",
|
|
3
|
+
"version": "0.1.0-beta.1",
|
|
4
|
+
"description": "Local-first MCP companion for the hosted Slide Studio editor",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/alexgusevski/tiktokslideeditor.git",
|
|
10
|
+
"directory": "packages/mcp"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://slides-mcp-poc-0821.pages.dev",
|
|
13
|
+
"bugs": "https://github.com/alexgusevski/tiktokslideeditor/issues",
|
|
14
|
+
"bin": {
|
|
15
|
+
"slides-studio-mcp": "src/cli.mjs"
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"src",
|
|
19
|
+
"guidance",
|
|
20
|
+
"skill",
|
|
21
|
+
"README.md"
|
|
22
|
+
],
|
|
23
|
+
"engines": {
|
|
24
|
+
"node": ">=20"
|
|
25
|
+
},
|
|
26
|
+
"dependencies": {
|
|
27
|
+
"@modelcontextprotocol/server": "2.0.0",
|
|
28
|
+
"zod": "^4.4.3"
|
|
29
|
+
},
|
|
30
|
+
"publishConfig": {
|
|
31
|
+
"access": "public",
|
|
32
|
+
"provenance": true
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: slide-studio
|
|
3
|
+
description: Create, edit, visually inspect, and export Slide Studio carousel slides through the local Slide Studio MCP companion.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Slide Studio
|
|
7
|
+
|
|
8
|
+
Use the Slide Studio MCP tools for all presentation changes. The hosted browser editor is the live visual surface; local files and rendered images pass only through the local companion.
|
|
9
|
+
|
|
10
|
+
Before the first mutation in a task, call `get_design_guidance`. The server intentionally rejects mutations until this guidance has been read.
|
|
11
|
+
|
|
12
|
+
Inspect the editor before editing and keep the returned IDs. Work on the requested project and slide; do not infer targets when multiple editors or projects are ambiguous. Prefer `apply_operations` for compact related changes, while preserving logical edit order.
|
|
13
|
+
|
|
14
|
+
After each meaningful composition or after a short batch, call `render_slide` and inspect the returned image. Fix clipping, spacing, contrast, unsafe TikTok-overlay placement, and weak hierarchy before claiming the slide is finished. Use `export_slide` or `export_project` only when local files are requested; do not overwrite existing files unless authorized.
|
|
15
|
+
|
|
16
|
+
The browser automatically opens the latest slide changed by an agent. Use `show_notification` only for short, useful status messages.
|
package/src/cli.mjs
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { companionDoctor } from "./companion.mjs";
|
|
3
|
+
import { PACKAGE_NAME, PACKAGE_VERSION, TEST_EDITOR_URL } from "./config.mjs";
|
|
4
|
+
import { runSetup } from "./setup.mjs";
|
|
5
|
+
import { serveMcp } from "./stdio-server.mjs";
|
|
6
|
+
|
|
7
|
+
const [command = "serve", ...arguments_] = process.argv.slice(2);
|
|
8
|
+
|
|
9
|
+
async function main() {
|
|
10
|
+
if (command === "serve") {
|
|
11
|
+
await serveMcp();
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
if (command === "setup") {
|
|
15
|
+
await runSetup(arguments_);
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
if (command === "doctor") {
|
|
19
|
+
const health = await companionDoctor();
|
|
20
|
+
process.stdout.write(`${JSON.stringify({ package: PACKAGE_NAME, packageVersion: PACKAGE_VERSION, editor: TEST_EDITOR_URL, daemon: health }, null, 2)}\n`);
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
if (command === "version" || command === "--version" || command === "-v") {
|
|
24
|
+
process.stdout.write(`${PACKAGE_VERSION}\n`);
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
if (command === "help" || command === "--help" || command === "-h") {
|
|
28
|
+
process.stdout.write(`Slide Studio MCP ${PACKAGE_VERSION}\n\nUsage:\n slides-studio-mcp serve\n slides-studio-mcp setup [--client=claude,codex,hermes,opencode,openclaw] [--yes] [--dry-run]\n slides-studio-mcp doctor\n slides-studio-mcp version\n`);
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
throw new Error(`Unknown command: ${command}`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
main().catch((error) => {
|
|
35
|
+
process.stderr.write(`${error.message}\n`);
|
|
36
|
+
process.exit(1);
|
|
37
|
+
});
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { readFile } from "node:fs/promises";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { BRIDGE_URL, DAEMON_STATE_PATH, PACKAGE_VERSION, PROTOCOL_VERSION } from "./config.mjs";
|
|
6
|
+
|
|
7
|
+
const DAEMON_ENTRY = fileURLToPath(new URL("daemon.mjs", import.meta.url));
|
|
8
|
+
|
|
9
|
+
const wait = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
10
|
+
|
|
11
|
+
async function readState() {
|
|
12
|
+
try { return JSON.parse(await readFile(DAEMON_STATE_PATH, "utf8")); }
|
|
13
|
+
catch { return null; }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async function daemonRequest(state, path, init = {}) {
|
|
17
|
+
const response = await fetch(`${BRIDGE_URL}${path}`, {
|
|
18
|
+
...init,
|
|
19
|
+
headers: { "Authorization": `Bearer ${state.secret}`, "Content-Type": "application/json", ...(init.headers || {}) },
|
|
20
|
+
});
|
|
21
|
+
const value = await response.json().catch(() => ({}));
|
|
22
|
+
if (!response.ok) throw new Error(value.error || `Local companion returned ${response.status}.`);
|
|
23
|
+
return value;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function healthyState() {
|
|
27
|
+
const state = await readState();
|
|
28
|
+
if (!state?.secret || state.port == null) return null;
|
|
29
|
+
try {
|
|
30
|
+
const result = await daemonRequest(state, "/internal/health");
|
|
31
|
+
if (result.protocolVersion !== PROTOCOL_VERSION) throw new Error(`Local companion protocol ${result.protocolVersion} is incompatible with package protocol ${PROTOCOL_VERSION}. Restart all Slide Studio MCP clients.`);
|
|
32
|
+
return state;
|
|
33
|
+
} catch (error) {
|
|
34
|
+
if (String(error.message).includes("incompatible")) throw error;
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function ensureDaemon() {
|
|
40
|
+
const existing = await healthyState();
|
|
41
|
+
if (existing) return existing;
|
|
42
|
+
const child = spawn(process.execPath, [DAEMON_ENTRY], {
|
|
43
|
+
detached: true,
|
|
44
|
+
stdio: "ignore",
|
|
45
|
+
env: process.env,
|
|
46
|
+
});
|
|
47
|
+
child.unref();
|
|
48
|
+
const deadline = Date.now() + 8000;
|
|
49
|
+
while (Date.now() < deadline) {
|
|
50
|
+
await wait(100);
|
|
51
|
+
const state = await healthyState();
|
|
52
|
+
if (state) return state;
|
|
53
|
+
}
|
|
54
|
+
throw new Error("Could not start the local Slide Studio companion. Run `npx slides-studio-mcp doctor` for details.");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function createCompanion(initialName = "MCP agent", initialVersion = null) {
|
|
58
|
+
const state = await ensureDaemon();
|
|
59
|
+
const clientId = randomUUID();
|
|
60
|
+
let clientName = initialName;
|
|
61
|
+
let clientVersion = initialVersion;
|
|
62
|
+
let closed = false;
|
|
63
|
+
|
|
64
|
+
const post = (path, body) => daemonRequest(state, path, { method: "POST", body: JSON.stringify(body) });
|
|
65
|
+
const register = () => post("/internal/client/connect", { clientId, name: clientName, version: clientVersion });
|
|
66
|
+
await register();
|
|
67
|
+
const heartbeat = setInterval(() => {
|
|
68
|
+
if (!closed) void post("/internal/client/heartbeat", { clientId }).catch(() => {});
|
|
69
|
+
}, 15_000);
|
|
70
|
+
heartbeat.unref();
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
clientId,
|
|
74
|
+
daemon: { pid: state.pid, url: BRIDGE_URL, version: state.version, packageVersion: PACKAGE_VERSION },
|
|
75
|
+
async identify(name, version) {
|
|
76
|
+
clientName = name || clientName;
|
|
77
|
+
clientVersion = version || clientVersion;
|
|
78
|
+
await register();
|
|
79
|
+
},
|
|
80
|
+
async call(action, body = {}) {
|
|
81
|
+
const response = await post("/internal/call", { clientId, action, ...body });
|
|
82
|
+
return response.result;
|
|
83
|
+
},
|
|
84
|
+
async close() {
|
|
85
|
+
if (closed) return;
|
|
86
|
+
closed = true;
|
|
87
|
+
clearInterval(heartbeat);
|
|
88
|
+
await post("/internal/client/disconnect", { clientId }).catch(() => {});
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export async function companionDoctor() {
|
|
94
|
+
const state = await ensureDaemon();
|
|
95
|
+
const health = await daemonRequest(state, "/internal/health");
|
|
96
|
+
return { ...health, url: BRIDGE_URL, stateFile: DAEMON_STATE_PATH };
|
|
97
|
+
}
|
package/src/config.mjs
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { homedir, platform } from "node:os";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
|
|
6
|
+
export const PACKAGE_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
7
|
+
export const PACKAGE_JSON = JSON.parse(readFileSync(join(PACKAGE_ROOT, "package.json"), "utf8"));
|
|
8
|
+
export const PACKAGE_NAME = PACKAGE_JSON.name;
|
|
9
|
+
export const PACKAGE_VERSION = PACKAGE_JSON.version;
|
|
10
|
+
export const PROTOCOL_VERSION = 1;
|
|
11
|
+
export const BRIDGE_HOST = "127.0.0.1";
|
|
12
|
+
export const BRIDGE_PORT = Number(process.env.SLIDE_STUDIO_BRIDGE_PORT) || 43117;
|
|
13
|
+
export const BRIDGE_URL = `http://${BRIDGE_HOST}:${BRIDGE_PORT}`;
|
|
14
|
+
export const TEST_EDITOR_URL = "https://slides-mcp-poc-0821.pages.dev";
|
|
15
|
+
export const ALLOWED_ORIGINS = new Set((process.env.SLIDE_STUDIO_ALLOWED_ORIGINS || [
|
|
16
|
+
TEST_EDITOR_URL,
|
|
17
|
+
"http://127.0.0.1:4173",
|
|
18
|
+
"http://localhost:4173",
|
|
19
|
+
].join(",")).split(",").map((value) => value.trim()).filter(Boolean));
|
|
20
|
+
export const GUIDANCE_PATH = join(PACKAGE_ROOT, "guidance", "design.md");
|
|
21
|
+
|
|
22
|
+
function defaultStateDirectory() {
|
|
23
|
+
if (process.env.SLIDE_STUDIO_STATE_DIR) return process.env.SLIDE_STUDIO_STATE_DIR;
|
|
24
|
+
if (platform() === "win32") return join(process.env.LOCALAPPDATA || homedir(), "SlideStudioMCP");
|
|
25
|
+
if (platform() === "darwin") return join(homedir(), "Library", "Caches", "SlideStudioMCP");
|
|
26
|
+
return join(process.env.XDG_RUNTIME_DIR || join(homedir(), ".cache"), "slides-studio-mcp");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const STATE_DIRECTORY = defaultStateDirectory();
|
|
30
|
+
export const DAEMON_STATE_PATH = join(STATE_DIRECTORY, `daemon-${BRIDGE_PORT}.json`);
|
|
31
|
+
export const DAEMON_LOCK_PATH = join(STATE_DIRECTORY, `daemon-${BRIDGE_PORT}.lock`);
|
package/src/daemon.mjs
ADDED
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { createServer } from "node:http";
|
|
3
|
+
import { randomBytes, randomUUID } from "node:crypto";
|
|
4
|
+
import { mkdir, open, readFile, rename, stat, unlink, writeFile } from "node:fs/promises";
|
|
5
|
+
import { basename, extname } from "node:path";
|
|
6
|
+
import {
|
|
7
|
+
ALLOWED_ORIGINS, BRIDGE_HOST, BRIDGE_PORT, BRIDGE_URL, DAEMON_LOCK_PATH,
|
|
8
|
+
DAEMON_STATE_PATH, PACKAGE_NAME, PACKAGE_VERSION, PROTOCOL_VERSION, STATE_DIRECTORY,
|
|
9
|
+
} from "./config.mjs";
|
|
10
|
+
|
|
11
|
+
const MAX_JSON_BYTES = 40 * 1024 * 1024;
|
|
12
|
+
const MAX_MEDIA_BYTES = 25 * 1024 * 1024;
|
|
13
|
+
const EDITOR_TTL_MS = 45_000;
|
|
14
|
+
const CLIENT_TTL_MS = 45_000;
|
|
15
|
+
const MEDIA_TTL_MS = 5 * 60_000;
|
|
16
|
+
const COMMAND_TIMEOUT_MS = 90_000;
|
|
17
|
+
const daemonSecret = randomBytes(32).toString("base64url");
|
|
18
|
+
const editors = new Map();
|
|
19
|
+
const clients = new Map();
|
|
20
|
+
const inflight = new Map();
|
|
21
|
+
const media = new Map();
|
|
22
|
+
let focusedEditorId = null;
|
|
23
|
+
let lockHandle = null;
|
|
24
|
+
let idleSince = null;
|
|
25
|
+
|
|
26
|
+
function log(message) {
|
|
27
|
+
process.stderr.write(`[slide-studio-daemon] ${message}\n`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function activeEditors() {
|
|
31
|
+
const cutoff = Date.now() - EDITOR_TTL_MS;
|
|
32
|
+
return [...editors.values()].filter((editor) => editor.lastSeen >= cutoff);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function activeClients() {
|
|
36
|
+
const cutoff = Date.now() - CLIENT_TTL_MS;
|
|
37
|
+
return [...clients.values()].filter((client) => client.lastSeen >= cutoff);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function publicClient(client) {
|
|
41
|
+
return { id: client.id, name: client.name || "MCP agent", version: client.version || null };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function browserCors(origin) {
|
|
45
|
+
if (!origin || !ALLOWED_ORIGINS.has(origin)) return null;
|
|
46
|
+
return {
|
|
47
|
+
"Access-Control-Allow-Origin": origin,
|
|
48
|
+
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
|
|
49
|
+
"Access-Control-Allow-Headers": "Authorization, Content-Type",
|
|
50
|
+
"Access-Control-Expose-Headers": "X-Slide-Studio-Filename",
|
|
51
|
+
"Access-Control-Allow-Private-Network": "true",
|
|
52
|
+
"Access-Control-Max-Age": "600",
|
|
53
|
+
"Cache-Control": "no-store",
|
|
54
|
+
Vary: "Origin",
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function sendJson(response, statusCode, value, headers = {}) {
|
|
59
|
+
const body = JSON.stringify(value);
|
|
60
|
+
response.writeHead(statusCode, { "Content-Type": "application/json; charset=utf-8", "Content-Length": Buffer.byteLength(body), "Cache-Control": "no-store", ...headers });
|
|
61
|
+
response.end(body);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function readJson(request) {
|
|
65
|
+
return new Promise((resolve, reject) => {
|
|
66
|
+
const chunks = [];
|
|
67
|
+
let size = 0;
|
|
68
|
+
request.on("data", (chunk) => {
|
|
69
|
+
size += chunk.length;
|
|
70
|
+
if (size > MAX_JSON_BYTES) {
|
|
71
|
+
reject(new Error("Request body is too large."));
|
|
72
|
+
request.destroy();
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
chunks.push(chunk);
|
|
76
|
+
});
|
|
77
|
+
request.on("end", () => {
|
|
78
|
+
try { resolve(JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}")); }
|
|
79
|
+
catch { reject(new Error("Request body must be valid JSON.")); }
|
|
80
|
+
});
|
|
81
|
+
request.on("error", reject);
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function bearer(request) {
|
|
86
|
+
const value = request.headers.authorization || "";
|
|
87
|
+
return value.startsWith("Bearer ") ? value.slice(7) : null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function requireInternal(request, response) {
|
|
91
|
+
if (bearer(request) === daemonSecret) return true;
|
|
92
|
+
sendJson(response, 401, { error: "Unauthorized." });
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function requireEditor(request, response, editorId, cors) {
|
|
97
|
+
const editor = editors.get(editorId);
|
|
98
|
+
if (!editor || bearer(request) !== editor.sessionToken) {
|
|
99
|
+
sendJson(response, 401, { error: "Editor session is not authorized." }, cors);
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
editor.lastSeen = Date.now();
|
|
103
|
+
return editor;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function queueEditorEvent(editor, event) {
|
|
107
|
+
editor.queue.push(event);
|
|
108
|
+
if (editor.queue.length > 100) editor.queue.splice(0, editor.queue.length - 100);
|
|
109
|
+
deliverNext(editor);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function deliverNext(editor) {
|
|
113
|
+
if (!editor?.poll || !editor.queue.length) return;
|
|
114
|
+
const response = editor.poll;
|
|
115
|
+
editor.poll = null;
|
|
116
|
+
sendJson(response, 200, editor.queue.shift(), editor.cors);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function broadcastAgents() {
|
|
120
|
+
const event = { kind: "system", type: "agents.changed", agents: activeClients().map(publicClient) };
|
|
121
|
+
for (const editor of activeEditors()) queueEditorEvent(editor, event);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function selectEditor(clientId) {
|
|
125
|
+
const connected = activeEditors();
|
|
126
|
+
const client = clients.get(clientId);
|
|
127
|
+
const selected = client?.selectedEditorId && connected.find((editor) => editor.id === client.selectedEditorId);
|
|
128
|
+
if (selected) return selected;
|
|
129
|
+
const focused = focusedEditorId && connected.find((editor) => editor.id === focusedEditorId);
|
|
130
|
+
if (focused) return focused;
|
|
131
|
+
if (connected.length === 1) return connected[0];
|
|
132
|
+
if (!connected.length) throw new Error("No Slide Studio editor is connected. Open the test editor and click Connect AI.");
|
|
133
|
+
throw new Error("Multiple editors are connected and none is selected. Call list_editors, then select_editor.");
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function callBrowser(clientId, toolName, operation, label) {
|
|
137
|
+
const editor = selectEditor(clientId);
|
|
138
|
+
const client = clients.get(clientId) || { id: clientId, name: "MCP agent" };
|
|
139
|
+
const requestId = randomUUID();
|
|
140
|
+
return new Promise((resolve, reject) => {
|
|
141
|
+
const timer = setTimeout(() => {
|
|
142
|
+
inflight.delete(requestId);
|
|
143
|
+
reject(new Error("The browser did not answer within 90 seconds."));
|
|
144
|
+
}, COMMAND_TIMEOUT_MS);
|
|
145
|
+
inflight.set(requestId, { resolve, reject, timer, editorId: editor.id });
|
|
146
|
+
queueEditorEvent(editor, { kind: "command", requestId, toolName, operation, label, agent: publicClient(client) });
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function detectedMime(buffer, filename) {
|
|
151
|
+
if (buffer.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))) return "image/png";
|
|
152
|
+
if (buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) return "image/jpeg";
|
|
153
|
+
if (["GIF87a", "GIF89a"].includes(buffer.subarray(0, 6).toString("ascii"))) return "image/gif";
|
|
154
|
+
if (buffer.subarray(0, 4).toString("ascii") === "RIFF" && buffer.subarray(8, 12).toString("ascii") === "WEBP") return "image/webp";
|
|
155
|
+
const header = buffer.subarray(0, 64).toString("ascii");
|
|
156
|
+
if (/ftyp(?:avif|avis)/.test(header)) return "image/avif";
|
|
157
|
+
const text = buffer.subarray(0, 1024).toString("utf8").trimStart();
|
|
158
|
+
if (/^(?:<\?xml[^>]*>\s*)?<svg[\s>]/i.test(text)) return "image/svg+xml";
|
|
159
|
+
const extension = extname(filename).toLowerCase();
|
|
160
|
+
if (extension === ".jpg" || extension === ".jpeg") return "image/jpeg";
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function prepareMedia(filePath) {
|
|
165
|
+
const metadata = await stat(filePath);
|
|
166
|
+
if (!metadata.isFile()) throw new Error("Image path must point to a regular file.");
|
|
167
|
+
if (metadata.size > MAX_MEDIA_BYTES) throw new Error("Image is larger than the 25 MB local-transfer limit.");
|
|
168
|
+
const buffer = await readFile(filePath);
|
|
169
|
+
const mimeType = detectedMime(buffer, filePath);
|
|
170
|
+
if (!mimeType) throw new Error("Unsupported image. Use PNG, JPEG, WebP, GIF, SVG, or AVIF.");
|
|
171
|
+
const id = randomUUID();
|
|
172
|
+
media.set(id, { id, buffer, mimeType, filename: basename(filePath), expiresAt: Date.now() + MEDIA_TTL_MS });
|
|
173
|
+
return { mediaId: id, filename: basename(filePath), mimeType, size: buffer.length };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async function writeExport(filePath, data, overwrite) {
|
|
177
|
+
const buffer = Buffer.from(data, "base64");
|
|
178
|
+
const handle = await open(filePath, overwrite ? "w" : "wx", 0o600).catch((error) => {
|
|
179
|
+
if (error.code === "EEXIST") throw new Error(`Export already exists: ${filePath}. Set overwrite=true only when intended.`);
|
|
180
|
+
throw error;
|
|
181
|
+
});
|
|
182
|
+
try { await handle.writeFile(buffer); } finally { await handle.close(); }
|
|
183
|
+
return { path: filePath, bytes: buffer.length };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async function handleInternalCall(body) {
|
|
187
|
+
const client = clients.get(body.clientId);
|
|
188
|
+
if (!client) throw new Error("MCP client session is not registered.");
|
|
189
|
+
client.lastSeen = Date.now();
|
|
190
|
+
if (body.action === "list_editors") {
|
|
191
|
+
return {
|
|
192
|
+
selectedEditorId: client.selectedEditorId || focusedEditorId || null,
|
|
193
|
+
editors: activeEditors().map((editor) => ({ id: editor.id, selected: editor.id === client.selectedEditorId, focused: editor.id === focusedEditorId, pageUrl: editor.pageUrl, state: editor.state })),
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
if (body.action === "select_editor") {
|
|
197
|
+
const editor = activeEditors().find((item) => item.id === body.editorId);
|
|
198
|
+
if (!editor) throw new Error(`Editor is not connected: ${body.editorId}`);
|
|
199
|
+
client.selectedEditorId = editor.id;
|
|
200
|
+
return { editorId: editor.id, pageUrl: editor.pageUrl, state: editor.state };
|
|
201
|
+
}
|
|
202
|
+
if (body.action === "prepare_media") return prepareMedia(body.path);
|
|
203
|
+
if (body.action === "write_export") return writeExport(body.path, body.data, Boolean(body.overwrite));
|
|
204
|
+
if (body.action === "notify") {
|
|
205
|
+
const editor = selectEditor(body.clientId);
|
|
206
|
+
queueEditorEvent(editor, { kind: "system", type: "notification", message: body.message, tone: body.tone, agent: publicClient(client) });
|
|
207
|
+
return { shown: true, editorId: editor.id };
|
|
208
|
+
}
|
|
209
|
+
if (body.action === "browser") return callBrowser(body.clientId, body.toolName, body.operation, body.label);
|
|
210
|
+
if (body.action === "batch") {
|
|
211
|
+
const results = [];
|
|
212
|
+
for (const item of body.items) results.push(await callBrowser(body.clientId, "apply_operations", item.operation, item.label));
|
|
213
|
+
return { applied: results.length, results };
|
|
214
|
+
}
|
|
215
|
+
throw new Error(`Unknown internal action: ${body.action}`);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const server = createServer(async (request, response) => {
|
|
219
|
+
const host = request.headers.host || "";
|
|
220
|
+
if (![`${BRIDGE_HOST}:${BRIDGE_PORT}`, `localhost:${BRIDGE_PORT}`].includes(host)) return sendJson(response, 421, { error: "Invalid Host header." });
|
|
221
|
+
const url = new URL(request.url || "/", BRIDGE_URL);
|
|
222
|
+
const origin = request.headers.origin;
|
|
223
|
+
const cors = browserCors(origin);
|
|
224
|
+
|
|
225
|
+
if (request.method === "OPTIONS") {
|
|
226
|
+
if (!cors) return sendJson(response, 403, { error: "Origin not allowed." });
|
|
227
|
+
response.writeHead(204, cors);
|
|
228
|
+
response.end();
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
if (url.pathname === "/health" && request.method === "GET") {
|
|
232
|
+
if (origin && !cors) return sendJson(response, 403, { error: "Origin not allowed." });
|
|
233
|
+
return sendJson(response, 200, { ok: true, service: PACKAGE_NAME, version: PACKAGE_VERSION, protocolVersion: PROTOCOL_VERSION, editors: activeEditors().length, agents: activeClients().length }, cors || {});
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
try {
|
|
237
|
+
if (url.pathname.startsWith("/internal/")) {
|
|
238
|
+
if (!requireInternal(request, response)) return;
|
|
239
|
+
if (url.pathname === "/internal/health" && request.method === "GET") return sendJson(response, 200, { ok: true, pid: process.pid, version: PACKAGE_VERSION, protocolVersion: PROTOCOL_VERSION });
|
|
240
|
+
const body = await readJson(request);
|
|
241
|
+
if (url.pathname === "/internal/client/connect" && request.method === "POST") {
|
|
242
|
+
const existing = clients.get(body.clientId) || { id: body.clientId };
|
|
243
|
+
Object.assign(existing, { name: body.name || existing.name || "MCP agent", version: body.version || existing.version || null, lastSeen: Date.now() });
|
|
244
|
+
clients.set(existing.id, existing);
|
|
245
|
+
broadcastAgents();
|
|
246
|
+
return sendJson(response, 200, { ok: true, client: publicClient(existing) });
|
|
247
|
+
}
|
|
248
|
+
if (url.pathname === "/internal/client/disconnect" && request.method === "POST") {
|
|
249
|
+
clients.delete(body.clientId);
|
|
250
|
+
broadcastAgents();
|
|
251
|
+
return sendJson(response, 200, { ok: true });
|
|
252
|
+
}
|
|
253
|
+
if (url.pathname === "/internal/client/heartbeat" && request.method === "POST") {
|
|
254
|
+
const client = clients.get(body.clientId);
|
|
255
|
+
if (client) client.lastSeen = Date.now();
|
|
256
|
+
return sendJson(response, 200, { ok: Boolean(client) });
|
|
257
|
+
}
|
|
258
|
+
if (url.pathname === "/internal/call" && request.method === "POST") return sendJson(response, 200, { ok: true, result: await handleInternalCall(body) });
|
|
259
|
+
return sendJson(response, 404, { error: "Internal endpoint not found." });
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if (!cors) return sendJson(response, 403, { error: "Origin not allowed." });
|
|
263
|
+
if (url.pathname === "/connect" && request.method === "POST") {
|
|
264
|
+
const body = await readJson(request);
|
|
265
|
+
if (!body.editorId || typeof body.editorId !== "string") return sendJson(response, 400, { error: "editorId is required." }, cors);
|
|
266
|
+
if (body.protocolVersion !== PROTOCOL_VERSION) return sendJson(response, 409, { error: `Protocol mismatch. Browser=${body.protocolVersion}; companion=${PROTOCOL_VERSION}.`, protocolVersion: PROTOCOL_VERSION }, cors);
|
|
267
|
+
const previous = editors.get(body.editorId);
|
|
268
|
+
if (previous?.poll) { previous.poll.writeHead(204, previous.cors); previous.poll.end(); }
|
|
269
|
+
const editor = {
|
|
270
|
+
id: body.editorId, queue: previous?.queue || [], poll: null, pageUrl: body.pageUrl,
|
|
271
|
+
state: body.state, lastSeen: Date.now(), cors, sessionToken: randomBytes(32).toString("base64url"),
|
|
272
|
+
};
|
|
273
|
+
editors.set(editor.id, editor);
|
|
274
|
+
if (body.hasFocus && body.visibilityState === "visible") focusedEditorId = editor.id;
|
|
275
|
+
log(`Editor connected (${editor.id.slice(0, 8)})`);
|
|
276
|
+
return sendJson(response, 200, { ok: true, editorId: editor.id, sessionToken: editor.sessionToken, protocolVersion: PROTOCOL_VERSION, agents: activeClients().map(publicClient) }, cors);
|
|
277
|
+
}
|
|
278
|
+
if (url.pathname === "/activate" && request.method === "POST") {
|
|
279
|
+
const body = await readJson(request);
|
|
280
|
+
const editor = requireEditor(request, response, body.editorId, cors);
|
|
281
|
+
if (!editor) return;
|
|
282
|
+
focusedEditorId = editor.id;
|
|
283
|
+
return sendJson(response, 200, { ok: true, editorId: editor.id }, cors);
|
|
284
|
+
}
|
|
285
|
+
if (url.pathname === "/events" && request.method === "GET") {
|
|
286
|
+
const editor = requireEditor(request, response, url.searchParams.get("editorId"), cors);
|
|
287
|
+
if (!editor) return;
|
|
288
|
+
editor.cors = cors;
|
|
289
|
+
if (editor.poll) { editor.poll.writeHead(204, editor.cors); editor.poll.end(); }
|
|
290
|
+
editor.poll = response;
|
|
291
|
+
deliverNext(editor);
|
|
292
|
+
if (editor.poll) setTimeout(() => {
|
|
293
|
+
if (editor.poll !== response) return;
|
|
294
|
+
editor.poll = null;
|
|
295
|
+
response.writeHead(204, cors);
|
|
296
|
+
response.end();
|
|
297
|
+
}, 20_000).unref();
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
if (url.pathname === "/result" && request.method === "POST") {
|
|
301
|
+
const body = await readJson(request);
|
|
302
|
+
const editor = requireEditor(request, response, body.editorId, cors);
|
|
303
|
+
if (!editor) return;
|
|
304
|
+
const pending = inflight.get(body.requestId);
|
|
305
|
+
if (!pending || pending.editorId !== editor.id) return sendJson(response, 404, { error: "Unknown request." }, cors);
|
|
306
|
+
inflight.delete(body.requestId);
|
|
307
|
+
clearTimeout(pending.timer);
|
|
308
|
+
if (body.ok) {
|
|
309
|
+
if (body.result?.project || body.result?.projects) editor.state = body.result;
|
|
310
|
+
pending.resolve(body.result);
|
|
311
|
+
} else pending.reject(new Error(body.error || "Browser operation failed."));
|
|
312
|
+
return sendJson(response, 200, { ok: true }, cors);
|
|
313
|
+
}
|
|
314
|
+
if (url.pathname.startsWith("/media/") && request.method === "GET") {
|
|
315
|
+
const editor = requireEditor(request, response, url.searchParams.get("editorId"), cors);
|
|
316
|
+
if (!editor) return;
|
|
317
|
+
const id = decodeURIComponent(url.pathname.slice("/media/".length));
|
|
318
|
+
const item = media.get(id);
|
|
319
|
+
if (!item || item.expiresAt < Date.now()) return sendJson(response, 404, { error: "Local image transfer expired." }, cors);
|
|
320
|
+
media.delete(id);
|
|
321
|
+
response.writeHead(200, { ...cors, "Content-Type": item.mimeType, "Content-Length": item.buffer.length, "X-Slide-Studio-Filename": encodeURIComponent(item.filename) });
|
|
322
|
+
response.end(item.buffer);
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
return sendJson(response, 404, { error: "Not found." }, cors);
|
|
326
|
+
} catch (error) {
|
|
327
|
+
const headers = cors || {};
|
|
328
|
+
const statusCode = error.code === "ENOENT" ? 404 : error.code === "EACCES" ? 403 : 400;
|
|
329
|
+
return sendJson(response, statusCode, { error: error.message }, headers);
|
|
330
|
+
}
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
async function acquireDaemonLock() {
|
|
334
|
+
await mkdir(STATE_DIRECTORY, { recursive: true, mode: 0o700 });
|
|
335
|
+
try {
|
|
336
|
+
lockHandle = await open(DAEMON_LOCK_PATH, "wx", 0o600);
|
|
337
|
+
await lockHandle.writeFile(String(process.pid));
|
|
338
|
+
} catch (error) {
|
|
339
|
+
if (error.code !== "EEXIST") throw error;
|
|
340
|
+
try {
|
|
341
|
+
const lockPid = Number(await readFile(DAEMON_LOCK_PATH, "utf8"));
|
|
342
|
+
if (!Number.isInteger(lockPid) || lockPid <= 0) throw Object.assign(new Error("Invalid daemon lock."), { code: "ESTALE" });
|
|
343
|
+
process.kill(lockPid, 0);
|
|
344
|
+
const running = new Error(`Slide Studio daemon is already running or starting (pid ${lockPid}).`);
|
|
345
|
+
running.code = "EALREADY";
|
|
346
|
+
throw running;
|
|
347
|
+
} catch (checkError) {
|
|
348
|
+
if (checkError.code === "EALREADY") throw checkError;
|
|
349
|
+
if (!["ESRCH", "ENOENT", "ESTALE"].includes(checkError.code)) throw checkError;
|
|
350
|
+
await unlink(DAEMON_LOCK_PATH).catch(() => {});
|
|
351
|
+
lockHandle = await open(DAEMON_LOCK_PATH, "wx", 0o600);
|
|
352
|
+
await lockHandle.writeFile(String(process.pid));
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
async function writeDaemonState() {
|
|
358
|
+
const temporary = `${DAEMON_STATE_PATH}.${process.pid}.tmp`;
|
|
359
|
+
await writeFile(temporary, JSON.stringify({ pid: process.pid, port: BRIDGE_PORT, secret: daemonSecret, version: PACKAGE_VERSION, protocolVersion: PROTOCOL_VERSION }), { mode: 0o600 });
|
|
360
|
+
await rename(temporary, DAEMON_STATE_PATH);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
async function cleanup() {
|
|
364
|
+
for (const editor of editors.values()) {
|
|
365
|
+
if (editor.poll) { editor.poll.writeHead(204, editor.cors || {}); editor.poll.end(); }
|
|
366
|
+
}
|
|
367
|
+
for (const pending of inflight.values()) { clearTimeout(pending.timer); pending.reject(new Error("Local companion is shutting down.")); }
|
|
368
|
+
server.closeAllConnections?.();
|
|
369
|
+
await new Promise((resolve) => server.close(resolve));
|
|
370
|
+
const state = await readFile(DAEMON_STATE_PATH, "utf8").then(JSON.parse).catch(() => null);
|
|
371
|
+
if (state?.pid === process.pid) await unlink(DAEMON_STATE_PATH).catch(() => {});
|
|
372
|
+
await lockHandle?.close().catch(() => {});
|
|
373
|
+
await unlink(DAEMON_LOCK_PATH).catch(() => {});
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
setInterval(() => {
|
|
377
|
+
const now = Date.now();
|
|
378
|
+
for (const [id, item] of media) if (item.expiresAt < now) media.delete(id);
|
|
379
|
+
let clientsChanged = false;
|
|
380
|
+
for (const [id, client] of clients) if (client.lastSeen < now - CLIENT_TTL_MS) { clients.delete(id); clientsChanged = true; }
|
|
381
|
+
if (clientsChanged) broadcastAgents();
|
|
382
|
+
if (activeClients().length || activeEditors().length) idleSince = null;
|
|
383
|
+
else if (!idleSince) idleSince = now;
|
|
384
|
+
else if (now - idleSince > 10 * 60_000) void shutdown();
|
|
385
|
+
}, 15_000).unref();
|
|
386
|
+
|
|
387
|
+
async function main() {
|
|
388
|
+
await acquireDaemonLock();
|
|
389
|
+
server.on("error", (error) => { log(`Bridge failed: ${error.message}`); process.exitCode = 1; });
|
|
390
|
+
await new Promise((resolve, reject) => {
|
|
391
|
+
server.once("error", reject);
|
|
392
|
+
server.listen(BRIDGE_PORT, BRIDGE_HOST, resolve);
|
|
393
|
+
});
|
|
394
|
+
await writeDaemonState();
|
|
395
|
+
log(`Listening on ${BRIDGE_URL}`);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
let shuttingDown = false;
|
|
399
|
+
async function shutdown() {
|
|
400
|
+
if (shuttingDown) return;
|
|
401
|
+
shuttingDown = true;
|
|
402
|
+
await cleanup().catch((error) => log(`Cleanup failed: ${error.message}`));
|
|
403
|
+
process.exit();
|
|
404
|
+
}
|
|
405
|
+
process.on("SIGINT", shutdown);
|
|
406
|
+
process.on("SIGTERM", shutdown);
|
|
407
|
+
process.on("SIGHUP", shutdown);
|
|
408
|
+
|
|
409
|
+
main().catch(async (error) => {
|
|
410
|
+
log(error.message);
|
|
411
|
+
await lockHandle?.close().catch(() => {});
|
|
412
|
+
process.exit(1);
|
|
413
|
+
});
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { mkdir, readFile, stat } from "node:fs/promises";
|
|
2
|
+
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
3
|
+
import { McpServer } from "@modelcontextprotocol/server";
|
|
4
|
+
import * as z from "zod/v4";
|
|
5
|
+
import { GUIDANCE_PATH, PACKAGE_NAME, PACKAGE_VERSION, TEST_EDITOR_URL } from "./config.mjs";
|
|
6
|
+
|
|
7
|
+
const id = z.string().min(1).max(160);
|
|
8
|
+
const optionalId = id.optional();
|
|
9
|
+
const color = z.string().regex(/^#?[0-9a-f]{3}(?:[0-9a-f]{3})?$/i, "Use a 3- or 6-digit hex color.");
|
|
10
|
+
const unit = z.number().min(-0.5).max(1.5);
|
|
11
|
+
const positiveUnit = z.number().min(0.01).max(2.4);
|
|
12
|
+
const expectedRevision = z.number().int().min(0).optional().describe("Optional optimistic-concurrency guard from inspect_editor.");
|
|
13
|
+
const targetProject = { projectId: optionalId, expectedRevision };
|
|
14
|
+
const targetSlide = { projectId: optionalId, slideId: optionalId, expectedRevision };
|
|
15
|
+
const textFields = {
|
|
16
|
+
text: z.string().max(4000).optional(), x: unit.optional(), y: unit.optional(), width: positiveUnit.optional(), height: positiveUnit.optional(),
|
|
17
|
+
size: z.number().min(20).max(180).optional(), style: z.enum(["plain", "outline", "boxed"]).optional(),
|
|
18
|
+
outlineWidth: z.number().min(0).max(40).optional(), color: color.optional(), background: z.enum(["white", "black"]).optional(),
|
|
19
|
+
backgroundShape: z.enum(["lines", "full"]).optional(), align: z.enum(["left", "center", "right"]).optional(),
|
|
20
|
+
rotation: z.number().min(-720).max(720).optional(), z: z.number().optional(),
|
|
21
|
+
};
|
|
22
|
+
const imageFields = {
|
|
23
|
+
x: unit.optional(), y: unit.optional(), width: positiveUnit.optional(), height: positiveUnit.optional(),
|
|
24
|
+
rotation: z.number().min(-720).max(720).optional(), z: z.number().optional(),
|
|
25
|
+
cropX: z.number().min(0).max(0.95).optional(), cropY: z.number().min(0).max(0.95).optional(),
|
|
26
|
+
cropW: z.number().min(0.05).max(1).optional(), cropH: z.number().min(0.05).max(1).optional(),
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const definitions = new Map();
|
|
30
|
+
|
|
31
|
+
function textResult(value, summary = value) {
|
|
32
|
+
return { content: [{ type: "text", text: typeof summary === "string" ? summary : JSON.stringify(summary) }], structuredContent: value };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function compactMutation(value) {
|
|
36
|
+
const keys = ["projectId", "slideId", "revision", "createdSlideId", "createdTextId", "createdImageId", "assetId", "deletedProjectId", "deletedSlideId", "deletedLayerIds", "updatedTextIds", "updatedImageIds", "applied", "path", "bytes"];
|
|
37
|
+
return Object.fromEntries(keys.flatMap((key) => value?.[key] == null ? [] : [[key, value[key]]]));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function clientIdentity(context, server) {
|
|
41
|
+
return context?.mcpReq?.envelope?.clientInfo || server.server.getClientVersion?.() || null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function absolutePath(value) {
|
|
45
|
+
return isAbsolute(value) ? value : resolve(process.cwd(), value);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function pathExists(value) {
|
|
49
|
+
try { await stat(value); return true; } catch (error) { if (error.code === "ENOENT") return false; throw error; }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function operationLabel(toolName) {
|
|
53
|
+
return ({
|
|
54
|
+
create_project: "Creating a project…", update_project: "Updating the project…", delete_project: "Deleting a project…",
|
|
55
|
+
open_project: "Opening a project…", add_slide: "Adding a slide…", update_slide: "Updating a slide…",
|
|
56
|
+
duplicate_slide: "Duplicating a slide…", reorder_slides: "Reordering slides…", delete_slide: "Deleting a slide…",
|
|
57
|
+
add_text: "Adding text…", update_text: "Updating text…", import_asset: "Importing a local image…",
|
|
58
|
+
update_asset: "Updating an image asset…", delete_asset: "Deleting an image asset…", add_image: "Placing an image…",
|
|
59
|
+
update_image: "Updating an image…", delete_layers: "Deleting layers…", duplicate_layers: "Duplicating layers…",
|
|
60
|
+
reorder_layers: "Reordering layers…", undo: "Undoing the last edit…", redo: "Redoing the last edit…",
|
|
61
|
+
set_view: "Updating the editor view…", render_slide: "Rendering the slide…",
|
|
62
|
+
})[toolName] || "Editing in Slide Studio…";
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function browserOperation(companion, toolName, args) {
|
|
66
|
+
const operation = await prepareOperation(companion, toolName, args);
|
|
67
|
+
return companion.call("browser", { toolName, operation, label: operationLabel(toolName) });
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function prepareOperation(companion, toolName, args) {
|
|
71
|
+
const operation = { ...args };
|
|
72
|
+
if (operation.backgroundPath) {
|
|
73
|
+
const prepared = await companion.call("prepare_media", { path: absolutePath(operation.backgroundPath) });
|
|
74
|
+
operation.mediaId = prepared.mediaId;
|
|
75
|
+
delete operation.backgroundPath;
|
|
76
|
+
}
|
|
77
|
+
if (toolName === "import_asset") {
|
|
78
|
+
const prepared = await companion.call("prepare_media", { path: absolutePath(operation.path) });
|
|
79
|
+
operation.mediaId = prepared.mediaId;
|
|
80
|
+
delete operation.path;
|
|
81
|
+
}
|
|
82
|
+
const type = ({
|
|
83
|
+
create_project: "project.create", open_project: "project.open", update_project: "project.update", delete_project: "project.delete",
|
|
84
|
+
add_slide: "slide.add", update_slide: "slide.update", duplicate_slide: "slide.duplicate", reorder_slides: "slide.reorder", delete_slide: "slide.delete",
|
|
85
|
+
add_text: "text.add", update_text: "text.update", import_asset: "asset.import", update_asset: "asset.update", delete_asset: "asset.delete",
|
|
86
|
+
add_image: "image.add", update_image: "image.update", delete_layers: "layer.delete", duplicate_layers: "layer.duplicate", reorder_layers: "layer.reorder",
|
|
87
|
+
undo: "history.undo", redo: "history.redo", set_view: "view.update", render_slide: "slide.render", inspect_editor: "editor.inspect",
|
|
88
|
+
})[toolName];
|
|
89
|
+
if (!type) throw new Error(`Unsupported operation tool: ${toolName}`);
|
|
90
|
+
return { type, ...operation };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export async function createSlideStudioMcpServer(companion) {
|
|
94
|
+
const guidance = await readFile(GUIDANCE_PATH, "utf8");
|
|
95
|
+
let guidanceRead = false;
|
|
96
|
+
let identifiedAs = null;
|
|
97
|
+
const server = new McpServer({ name: PACKAGE_NAME, version: PACKAGE_VERSION }, {
|
|
98
|
+
instructions: `Open ${TEST_EDITOR_URL}, click Connect AI, then call get_design_guidance before editing. Mutations are intentionally blocked until the design guidance is read. Use render_slide to inspect actual pixels.`,
|
|
99
|
+
capabilities: { tools: {}, resources: {} },
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
async function identify(context) {
|
|
103
|
+
const info = clientIdentity(context, server);
|
|
104
|
+
const signature = info ? `${info.name || "MCP agent"}@${info.version || "unknown"}` : null;
|
|
105
|
+
if (signature && signature !== identifiedAs) {
|
|
106
|
+
identifiedAs = signature;
|
|
107
|
+
await companion.identify(info.name, info.version);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function register(name, description, inputSchema, handler, annotations = {}) {
|
|
112
|
+
const guidanceExempt = new Set(["select_editor", "open_project", "set_view", "show_notification"]);
|
|
113
|
+
definitions.set(name, { inputSchema, handler, mutating: !annotations.readOnlyHint && !guidanceExempt.has(name) });
|
|
114
|
+
server.registerTool(name, { title: name.split("_").map((part) => part[0].toUpperCase() + part.slice(1)).join(" "), description, inputSchema, annotations }, async (args, context) => {
|
|
115
|
+
await identify(context);
|
|
116
|
+
if (definitions.get(name).mutating && !guidanceRead) throw new Error("Call get_design_guidance before changing slides. This one-time step prevents avoidable clipping and unattractive defaults.");
|
|
117
|
+
const value = await handler(args, context);
|
|
118
|
+
if (value?.__rawMcpResult) {
|
|
119
|
+
const { __rawMcpResult, ...result } = value;
|
|
120
|
+
return result;
|
|
121
|
+
}
|
|
122
|
+
return textResult(value, annotations.readOnlyHint ? value : compactMutation(value));
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
server.registerResource("slide-studio-design-guidance", "slide-studio://guidance/design", {
|
|
127
|
+
title: "Slide Studio design guidance", description: "Required visual-quality and text-box safety guidance.", mimeType: "text/markdown",
|
|
128
|
+
}, async (uri) => {
|
|
129
|
+
guidanceRead = true;
|
|
130
|
+
return { contents: [{ uri: uri.href, mimeType: "text/markdown", text: guidance }] };
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
register("get_design_guidance", "Read the required compact design and clipping guidance. Call once before any mutation.", z.object({}).strict(), async () => {
|
|
134
|
+
guidanceRead = true;
|
|
135
|
+
return { __rawMcpResult: true, content: [{ type: "text", text: guidance }], structuredContent: { read: true } };
|
|
136
|
+
}, { readOnlyHint: true, idempotentHint: true });
|
|
137
|
+
|
|
138
|
+
register("list_editors", "List connected browser tabs and show which tab this MCP session targets.", z.object({}).strict(), () => companion.call("list_editors"), { readOnlyHint: true });
|
|
139
|
+
register("select_editor", "Select a connected browser tab for this MCP session.", z.object({ editorId: id }).strict(), ({ editorId }) => companion.call("select_editor", { editorId }), { idempotentHint: true });
|
|
140
|
+
register("inspect_editor", "Inspect projects, slides, assets, and every text/image layer without returning image bytes.", z.object({ ...targetSlide, includeAllProjects: z.boolean().default(true) }).strict(), (args) => browserOperation(companion, "inspect_editor", args), { readOnlyHint: true });
|
|
141
|
+
register("show_notification", "Show a short visual notification in the connected editor for status or marketing demos.", z.object({ message: z.string().min(1).max(240), tone: z.enum(["agent", "success", "info", "error"]).default("agent") }).strict(), (args) => companion.call("notify", args), { idempotentHint: false });
|
|
142
|
+
|
|
143
|
+
register("create_project", "Create and open an empty project.", z.object({ name: z.string().min(1).max(160) }).strict(), (args) => browserOperation(companion, "create_project", args));
|
|
144
|
+
register("open_project", "Open a project and optionally a specific slide without changing content.", z.object({ projectId: id, slideId: optionalId }).strict(), (args) => browserOperation(companion, "open_project", args), { idempotentHint: true });
|
|
145
|
+
register("update_project", "Rename a project.", z.object({ ...targetProject, name: z.string().min(1).max(160) }).strict(), (args) => browserOperation(companion, "update_project", args));
|
|
146
|
+
register("delete_project", "Delete a project from browser storage.", z.object({ ...targetProject, projectId: id }).strict(), (args) => browserOperation(companion, "delete_project", args), { destructiveHint: true });
|
|
147
|
+
|
|
148
|
+
register("add_slide", "Add and open a slide using a solid color or local background image path.", z.object({ ...targetProject, name: z.string().max(160).optional(), index: z.number().int().min(0).optional(), backgroundColor: color.optional(), backgroundPath: z.string().min(1).optional() }).strict(), (args) => browserOperation(companion, "add_slide", args));
|
|
149
|
+
register("update_slide", "Rename a slide, replace its background, or change background pan/zoom. The browser opens this slide.", z.object({ ...targetSlide, name: z.string().max(160).optional(), backgroundColor: color.optional(), backgroundPath: z.string().min(1).optional(), imageScale: z.number().min(1).max(3).optional(), imageX: unit.optional(), imageY: unit.optional() }).strict(), (args) => browserOperation(companion, "update_slide", args));
|
|
150
|
+
register("duplicate_slide", "Duplicate a slide with all layers and open the copy.", z.object({ ...targetSlide, name: z.string().max(160).optional() }).strict(), (args) => browserOperation(companion, "duplicate_slide", args));
|
|
151
|
+
register("reorder_slides", "Set the complete slide order using every slide ID exactly once.", z.object({ ...targetProject, slideIds: z.array(id).min(1) }).strict(), (args) => browserOperation(companion, "reorder_slides", args));
|
|
152
|
+
register("delete_slide", "Delete one slide.", z.object({ ...targetSlide, slideId: id }).strict(), (args) => browserOperation(companion, "delete_slide", args), { destructiveHint: true });
|
|
153
|
+
|
|
154
|
+
register("add_text", "Add a text layer. Coordinates and dimensions are normalized to the 9:16 canvas; attractive defaults use generous bounds and per-line boxes.", z.object({ ...targetSlide, ...textFields, text: z.string().min(1).max(4000) }).strict(), (args) => browserOperation(companion, "add_text", args));
|
|
155
|
+
register("update_text", "Update one or more text layers, including content, geometry, color, style, alignment, rotation, and stacking.", z.object({ ...targetSlide, updates: z.array(z.object({ id, ...textFields }).strict()).min(1).max(100) }).strict(), (args) => browserOperation(companion, "update_text", args));
|
|
156
|
+
|
|
157
|
+
register("import_asset", "Import a local image file into the active project's reusable asset library. Image bytes stay local.", z.object({ ...targetSlide, path: z.string().min(1), name: z.string().max(160).optional() }).strict(), (args) => browserOperation(companion, "import_asset", args));
|
|
158
|
+
register("update_asset", "Rename a reusable image asset.", z.object({ ...targetProject, assetId: id, name: z.string().min(1).max(160) }).strict(), (args) => browserOperation(companion, "update_asset", args));
|
|
159
|
+
register("delete_asset", "Delete an asset and every placed instance that references it.", z.object({ ...targetProject, assetId: id }).strict(), (args) => browserOperation(companion, "delete_asset", args), { destructiveHint: true });
|
|
160
|
+
register("add_image", "Place an imported asset as an image layer and optionally set geometry, crop, rotation, and stacking.", z.object({ ...targetSlide, assetId: id, ...imageFields }).strict(), (args) => browserOperation(companion, "add_image", args));
|
|
161
|
+
register("update_image", "Update one or more placed image layers, including geometry, crop, rotation, and stacking.", z.object({ ...targetSlide, updates: z.array(z.object({ id, ...imageFields }).strict()).min(1).max(100) }).strict(), (args) => browserOperation(companion, "update_image", args));
|
|
162
|
+
|
|
163
|
+
register("delete_layers", "Delete text and/or image layers by ID.", z.object({ ...targetSlide, layerIds: z.array(id).min(1).max(200) }).strict(), (args) => browserOperation(companion, "delete_layers", args), { destructiveHint: true });
|
|
164
|
+
register("duplicate_layers", "Duplicate text and/or image layers with an optional normalized offset.", z.object({ ...targetSlide, layerIds: z.array(id).min(1).max(100), offsetX: z.number().min(-1).max(1).optional(), offsetY: z.number().min(-1).max(1).optional() }).strict(), (args) => browserOperation(companion, "duplicate_layers", args));
|
|
165
|
+
register("reorder_layers", "Set the complete back-to-front layer order using every layer ID exactly once.", z.object({ ...targetSlide, layerIds: z.array(id).min(1).max(300) }).strict(), (args) => browserOperation(companion, "reorder_layers", args));
|
|
166
|
+
register("undo", "Undo the latest project edit.", z.object(targetSlide).strict(), (args) => browserOperation(companion, "undo", args));
|
|
167
|
+
register("redo", "Redo the latest undone project edit.", z.object(targetSlide).strict(), (args) => browserOperation(companion, "redo", args));
|
|
168
|
+
register("set_view", "Open a project/slide and control editor-only canvas zoom or TikTok safe-area overlay.", z.object({ ...targetSlide, canvasZoom: z.number().min(0.2).max(3).optional(), showTikTokOverlay: z.boolean().optional() }).strict(), (args) => browserOperation(companion, "set_view", args), { idempotentHint: true });
|
|
169
|
+
|
|
170
|
+
register("render_slide", "Render and return the actual slide image for visual inspection. This does not persist the rendered file.", z.object({ ...targetSlide, width: z.number().int().min(180).max(1080).default(540), format: z.enum(["png", "jpeg"]).default("png"), quality: z.number().min(0.4).max(1).default(0.9) }).strict(), async (args) => {
|
|
171
|
+
const rendered = await browserOperation(companion, "render_slide", args);
|
|
172
|
+
return {
|
|
173
|
+
content: [{ type: "image", data: rendered.data, mimeType: rendered.mimeType }, { type: "text", text: JSON.stringify({ slideId: args.slideId || null, width: rendered.width, height: rendered.height, temporary: true }) }],
|
|
174
|
+
structuredContent: { slideId: args.slideId || null, width: rendered.width, height: rendered.height, mimeType: rendered.mimeType, temporary: true },
|
|
175
|
+
__rawMcpResult: true,
|
|
176
|
+
};
|
|
177
|
+
}, { readOnlyHint: true });
|
|
178
|
+
|
|
179
|
+
register("export_slide", "Render a full-resolution PNG and write it to a local path. Existing files are protected unless overwrite=true.", z.object({ ...targetSlide, outputPath: z.string().min(1), overwrite: z.boolean().default(false) }).strict(), async ({ outputPath, overwrite, ...target }) => {
|
|
180
|
+
const rendered = await browserOperation(companion, "render_slide", { ...target, width: 1080, format: "png", quality: 1 });
|
|
181
|
+
const path = absolutePath(outputPath);
|
|
182
|
+
await mkdir(dirname(path), { recursive: true });
|
|
183
|
+
return companion.call("write_export", { path, data: rendered.data, overwrite });
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
register("export_project", "Render every slide at full resolution into a local directory. Existing files are protected unless overwrite=true.", z.object({ ...targetProject, outputDirectory: z.string().min(1), overwrite: z.boolean().default(false) }).strict(), async ({ outputDirectory, overwrite, ...target }) => {
|
|
187
|
+
const inspected = await browserOperation(companion, "inspect_editor", { ...target, includeAllProjects: false });
|
|
188
|
+
if (!inspected.project?.slides?.length) throw new Error("The project has no slides to export.");
|
|
189
|
+
const directory = absolutePath(outputDirectory);
|
|
190
|
+
await mkdir(directory, { recursive: true });
|
|
191
|
+
const files = [];
|
|
192
|
+
for (const slide of inspected.project.slides) {
|
|
193
|
+
const rendered = await browserOperation(companion, "render_slide", { projectId: inspected.project.id, slideId: slide.id, width: 1080, format: "png", quality: 1 });
|
|
194
|
+
const path = join(directory, `${String(slide.index + 1).padStart(2, "0")}-${rendered.filename}`);
|
|
195
|
+
if (!overwrite && await pathExists(path)) throw new Error(`Export already exists: ${path}. Set overwrite=true only when intended.`);
|
|
196
|
+
files.push(await companion.call("write_export", { path, data: rendered.data, overwrite }));
|
|
197
|
+
}
|
|
198
|
+
return { projectId: inspected.project.id, outputDirectory: directory, fileCount: files.length, files };
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
const batchTools = [...definitions.entries()].filter(([, definition]) => definition.mutating).map(([name]) => name).filter((name) => !["export_slide", "export_project"].includes(name));
|
|
202
|
+
register("apply_operations", "Apply many ordered editing operations in one compact tool call. Each edit still appears live in the browser.", z.object({ operations: z.array(z.object({ tool: z.enum(batchTools), arguments: z.record(z.string(), z.unknown()).default({}) }).strict()).min(1).max(100) }).strict(), async ({ operations }) => {
|
|
203
|
+
const items = [];
|
|
204
|
+
for (const item of operations) {
|
|
205
|
+
const definition = definitions.get(item.tool);
|
|
206
|
+
if (!definition?.mutating) throw new Error(`Tool cannot be batched: ${item.tool}`);
|
|
207
|
+
const args = definition.inputSchema.parse(item.arguments);
|
|
208
|
+
items.push({ operation: await prepareOperation(companion, item.tool, args), label: operationLabel(item.tool) });
|
|
209
|
+
}
|
|
210
|
+
return companion.call("batch", { items });
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
return server;
|
|
214
|
+
}
|
package/src/setup.mjs
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { cp, mkdir } from "node:fs/promises";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { createInterface } from "node:readline/promises";
|
|
6
|
+
import { PACKAGE_NAME, PACKAGE_ROOT, PACKAGE_VERSION, TEST_EDITOR_URL } from "./config.mjs";
|
|
7
|
+
|
|
8
|
+
const supported = ["claude", "codex", "hermes", "opencode", "openclaw"];
|
|
9
|
+
|
|
10
|
+
function commandExists(command) {
|
|
11
|
+
return spawnSync(command, ["--version"], { stdio: "ignore" }).error?.code !== "ENOENT";
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function commandVersion(command) {
|
|
15
|
+
const result = spawnSync(command, ["--version"], { encoding: "utf8" });
|
|
16
|
+
return `${result.stdout || ""}${result.stderr || ""}`.match(/\d+\.\d+(?:\.\d+)?/)?.[0] || null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function shellCommand(client, specifier) {
|
|
20
|
+
if (client === "claude") return ["claude", "mcp", "add", "--scope", "user", "--transport", "stdio", "slide-studio", "--", "npx", "-y", specifier, "serve"];
|
|
21
|
+
if (client === "codex") return ["codex", "mcp", "add", "slide-studio", "--", "npx", "-y", specifier, "serve"];
|
|
22
|
+
if (client === "hermes") return ["hermes", "mcp", "add", "slide-studio", "--command", "npx", "--args", "-y", specifier, "serve"];
|
|
23
|
+
if (client === "openclaw") return ["openclaw", "mcp", "add", "slide-studio", "--command", "npx", "--arg", "-y", "--arg", specifier, "--arg", "serve"];
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function quote(value) {
|
|
28
|
+
return /^[A-Za-z0-9_@./:-]+$/.test(value) ? value : `'${value.replaceAll("'", "'\\''")}'`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function openCodeSnippet(specifier, version = commandVersion("opencode")) {
|
|
32
|
+
const server = { type: "local", command: ["npx", "-y", specifier, "serve"] };
|
|
33
|
+
return JSON.stringify(Number(version?.split(".")[0]) >= 2
|
|
34
|
+
? { mcp: { servers: { "slide-studio": server } } }
|
|
35
|
+
: { mcp: { "slide-studio": { ...server, enabled: true } } }, null, 2);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function installSkill() {
|
|
39
|
+
const source = join(PACKAGE_ROOT, "skill", "slide-studio");
|
|
40
|
+
const targets = [
|
|
41
|
+
join(homedir(), ".agents", "skills", "slide-studio"),
|
|
42
|
+
join(homedir(), ".hermes", "skills", "slide-studio"),
|
|
43
|
+
];
|
|
44
|
+
for (const target of targets) {
|
|
45
|
+
await mkdir(target, { recursive: true });
|
|
46
|
+
await cp(source, target, { recursive: true, force: true });
|
|
47
|
+
}
|
|
48
|
+
return targets;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function runSetup(arguments_) {
|
|
52
|
+
const flags = new Set(arguments_);
|
|
53
|
+
const clientArgument = arguments_.find((value) => value.startsWith("--client="))?.slice("--client=".length);
|
|
54
|
+
const requested = clientArgument ? clientArgument.split(",").map((value) => value.trim().toLowerCase()) : supported.filter(commandExists);
|
|
55
|
+
const clients = [...new Set(requested)].filter((client) => supported.includes(client));
|
|
56
|
+
const specifier = `${PACKAGE_NAME}@${PACKAGE_VERSION}`;
|
|
57
|
+
const dryRun = flags.has("--dry-run");
|
|
58
|
+
const assumeYes = flags.has("--yes") || flags.has("-y");
|
|
59
|
+
if (!clients.length) throw new Error("No supported agent CLI was detected. Use --client=claude,codex,hermes,opencode,openclaw or copy the generic stdio config below.");
|
|
60
|
+
|
|
61
|
+
process.stdout.write(`Slide Studio MCP ${PACKAGE_VERSION}\nDetected: ${clients.join(", ")}\nEditor: ${TEST_EDITOR_URL}\n\n`);
|
|
62
|
+
for (const client of clients) {
|
|
63
|
+
const command = shellCommand(client, specifier);
|
|
64
|
+
if (command) process.stdout.write(`${client}: ${command.map(quote).join(" ")}\n`);
|
|
65
|
+
else process.stdout.write(`opencode config:\n${openCodeSnippet(specifier)}\n`);
|
|
66
|
+
}
|
|
67
|
+
process.stdout.write(`\nGeneric stdio: npx -y ${specifier} serve\n`);
|
|
68
|
+
if (dryRun) return { clients, dryRun: true };
|
|
69
|
+
|
|
70
|
+
let approved = assumeYes;
|
|
71
|
+
if (!approved && process.stdin.isTTY) {
|
|
72
|
+
const prompt = createInterface({ input: process.stdin, output: process.stdout });
|
|
73
|
+
const answer = await prompt.question("\nAdd Slide Studio to the detected agent configs and install its skill? [y/N] ");
|
|
74
|
+
prompt.close();
|
|
75
|
+
approved = /^y(?:es)?$/i.test(answer.trim());
|
|
76
|
+
}
|
|
77
|
+
if (!approved) {
|
|
78
|
+
process.stdout.write("\nNo configuration changed. Re-run with --yes when ready.\n");
|
|
79
|
+
return { clients, installed: false };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const configured = [];
|
|
83
|
+
for (const client of clients) {
|
|
84
|
+
const command = shellCommand(client, specifier);
|
|
85
|
+
if (!command) continue;
|
|
86
|
+
const result = spawnSync(command[0], command.slice(1), { stdio: "inherit" });
|
|
87
|
+
if (result.status === 0) configured.push(client);
|
|
88
|
+
else process.stderr.write(`Could not configure ${client}; its command is printed above for manual setup.\n`);
|
|
89
|
+
}
|
|
90
|
+
const skillTargets = await installSkill();
|
|
91
|
+
process.stdout.write(`\nConfigured: ${configured.join(", ") || "none automatically"}\nSkill installed in:\n${skillTargets.map((value) => ` ${value}`).join("\n")}\n\nOpen ${TEST_EDITOR_URL}, click Connect AI, and restart the configured agent.\n`);
|
|
92
|
+
if (clients.includes("opencode")) process.stdout.write("OpenCode currently uses its JSON config; merge the snippet printed above into opencode.json.\n");
|
|
93
|
+
return { clients, configured, skillTargets };
|
|
94
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { serveStdio } from "@modelcontextprotocol/server/stdio";
|
|
2
|
+
import { createCompanion } from "./companion.mjs";
|
|
3
|
+
import { createSlideStudioMcpServer } from "./mcp-server.mjs";
|
|
4
|
+
|
|
5
|
+
export async function serveMcp() {
|
|
6
|
+
const companion = await createCompanion();
|
|
7
|
+
const handle = serveStdio(() => createSlideStudioMcpServer(companion), {
|
|
8
|
+
legacy: "serve",
|
|
9
|
+
onerror: (error) => process.stderr.write(`[slides-studio-mcp] ${error.message}\n`),
|
|
10
|
+
});
|
|
11
|
+
let closing = false;
|
|
12
|
+
const close = async () => {
|
|
13
|
+
if (closing) return;
|
|
14
|
+
closing = true;
|
|
15
|
+
await handle.close().catch(() => {});
|
|
16
|
+
await companion.close();
|
|
17
|
+
};
|
|
18
|
+
process.once("SIGINT", () => void close().finally(() => process.exit()));
|
|
19
|
+
process.once("SIGTERM", () => void close().finally(() => process.exit()));
|
|
20
|
+
process.once("SIGHUP", () => void close().finally(() => process.exit()));
|
|
21
|
+
process.stdin.once("end", () => void close());
|
|
22
|
+
return { handle, companion, close };
|
|
23
|
+
}
|