@engine-room/after-effects-mcp 0.1.1 → 0.2.0

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/bin/server.js CHANGED
@@ -9,167 +9,277 @@ var __export = (target, all) => {
9
9
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
10
10
 
11
11
  // src/cli/init.ts
12
+ import path2 from "node:path";
13
+
14
+ // src/setup/scaffold.ts
12
15
  import fs from "node:fs";
16
+ import os from "node:os";
13
17
  import path from "node:path";
14
- var HOUSE_STYLE = `---
15
- name: house-style
16
- description: The visual and motion style for this project \u2014 palette, type, timing and layout rules. Load whenever building or editing anything in After Effects for this project.
17
- ---
18
-
19
- # House style
20
-
21
- <!--
22
- This file is yours. Claude reads it before building anything in After Effects,
23
- so whatever you write here becomes the default look of your work.
24
-
25
- Replace the placeholders below with your own values. Be specific and concrete:
26
- "dark navy #131521 at 92% opacity" is usable, "modern and clean" is not. If a
27
- number matters \u2014 a corner radius, a stroke width, a hold duration \u2014 write the
28
- number down.
29
-
30
- The fastest way to fill this in: build one piece the way you like it, then ask
31
- Claude "read this comp and write it up in my house-style skill".
32
- -->
33
-
34
- ## Palette
35
-
36
- | Role | Colour | Notes |
37
- |---|---|---|
38
- | Background | \`#000000\` | |
39
- | Primary text | \`#FFFFFF\` | |
40
- | Accent | \`#3DC46E\` | Used for emphasis and positive values |
41
- | Warning / negative | \`#E03333\` | |
42
-
43
- ## Type
44
-
45
- - **Headings:** _font name_, weight, size range
46
- - **Body:** _font name_, weight, size range
47
- - **Tracking / leading:** _your defaults_
48
- - Text is left-aligned unless stated otherwise.
49
-
50
- ## Motion
51
-
52
- - **Standard in:** scale 0 \u2192 108 \u2192 100 with easy ease, over ~0.4s
53
- - **Standard out:** scale \u2192 0 over ~0.3s
54
- - **Easing:** easy ease on everything; no linear motion unless mechanical
55
- - **Idle life:** subtle \`wiggle()\` on position so nothing sits perfectly still
56
-
57
- ## Layout
58
-
59
- - Comp size and frame rate: _e.g. 1920\xD71080 at 30fps_
60
- - Safe margins: _e.g. 120px from every edge_
61
- - Where elements sit by default: _e.g. lower third, left-aligned_
62
-
63
- ## Rules
64
-
65
- - _Anything that should always or never happen. E.g. "never put text directly on
66
- footage \u2014 always on a rounded chip", "keep total runtime under 8 seconds"._
67
- `;
68
- var CLAUDE_MD = `# {{NAME}}
18
+ var ScaffoldError = class extends Error {
19
+ };
20
+ function detectClient(clientName) {
21
+ const n = (clientName ?? "").toLowerCase();
22
+ if (!n) return "generic";
23
+ if (n.includes("claude-code") || n.includes("claude code")) return "claude-code";
24
+ if (n.includes("claude-ai") || n.includes("claude desktop")) return "claude-desktop";
25
+ if (n.includes("cursor")) return "cursor";
26
+ if (n.includes("windsurf") || n.includes("codeium")) return "windsurf";
27
+ if (n.includes("codex")) return "codex";
28
+ if (n.includes("visual studio code") || n.includes("vscode") || n.includes("copilot")) return "vscode";
29
+ return "generic";
30
+ }
31
+ var MCP_SERVER_ENTRY = {
32
+ command: "npx",
33
+ args: ["-y", "@engine-room/after-effects-mcp"]
34
+ };
35
+ function mcpConfigFor(client) {
36
+ const standard = JSON.stringify({ mcpServers: { "after-effects": MCP_SERVER_ENTRY } }, null, 2) + "\n";
37
+ switch (client) {
38
+ case "claude-code":
39
+ return { rel: ".mcp.json", json: standard };
40
+ case "cursor":
41
+ return { rel: path.join(".cursor", "mcp.json"), json: standard };
42
+ case "vscode":
43
+ return {
44
+ rel: path.join(".vscode", "mcp.json"),
45
+ json: JSON.stringify({ servers: { "after-effects": { type: "stdio", ...MCP_SERVER_ENTRY } } }, null, 2) + "\n"
46
+ };
47
+ default:
48
+ return void 0;
49
+ }
50
+ }
51
+ function globalConfigHint(client) {
52
+ const standard = JSON.stringify({ mcpServers: { "after-effects": MCP_SERVER_ENTRY } }, null, 2) + "\n";
53
+ const home = os.homedir();
54
+ switch (client) {
55
+ case "claude-desktop":
56
+ return {
57
+ path: process.platform === "darwin" ? path.join(home, "Library", "Application Support", "Claude", "claude_desktop_config.json") : path.join(process.env.APPDATA ?? path.join(home, "AppData", "Roaming"), "Claude", "claude_desktop_config.json"),
58
+ json: standard
59
+ };
60
+ case "windsurf":
61
+ return { path: path.join(home, ".codeium", "windsurf", "mcp_config.json"), json: standard };
62
+ case "codex":
63
+ return {
64
+ path: path.join(home, ".codex", "config.toml"),
65
+ json: '[mcp_servers.after-effects]\ncommand = "npx"\nargs = ["-y", "@engine-room/after-effects-mcp"]\n'
66
+ };
67
+ default:
68
+ return void 0;
69
+ }
70
+ }
71
+ var AGENTS_MD = (name) => `# ${name}
69
72
 
70
- After Effects project folder. The tools drive After Effects directly from here.
73
+ An After Effects project folder. The AE tools drive After Effects directly from
74
+ here \u2014 describe what you want and it gets built in the open project.
71
75
 
72
76
  ## How to work in this folder
73
77
 
74
78
  1. Open After Effects with the project you want to work on.
75
- 2. Describe what you want in plain language \u2014 "build a lower third that says
76
- Chapter One and slides in from the left".
79
+ 2. Say what you want in plain language \u2014 "build a lower third that says Chapter
80
+ One and slides in from the left".
77
81
  3. The current state of the comp is read, the change is made, and you see it.
78
82
 
79
83
  ## Style
80
84
 
81
- The look of everything built here is defined in
82
- \`.claude/skills/house-style/SKILL.md\`. Edit that file to change the defaults \u2014
83
- palette, type, timing, layout. It is read automatically.
85
+ The look of everything built here comes from \`house-style.md\`, which sits next
86
+ to the After Effects project file itself. Ask for a style guide and one gets
87
+ written from a comp you already like; edit it in any text editor afterwards.
88
+
89
+ It travels with the .aep, so it applies wherever the project is opened.
90
+
91
+ ## When a tool misbehaves
92
+
93
+ Check \`list_known_issues\` before guessing \u2014 an earlier session may already have
94
+ solved it. Anything newly worked out goes in with \`log_issue\`, and the
95
+ report-ae-issue prompt sends it to the maintainers.
84
96
 
85
97
  ## Conventions for this project
86
98
 
87
- <!-- Anything specific to this project rather than to your general style:
88
- naming conventions for comps and layers, delivery specs, the client's
99
+ <!-- Anything specific to this project rather than to your general style: naming
100
+ conventions for comps and layers, delivery specs, the client's
89
101
  requirements, what lives in which comp. -->
90
102
 
91
103
  - Renders go in \`renders/\`.
92
104
  `;
93
- var MCP_JSON = `{
94
- "mcpServers": {
95
- "after-effects": {
96
- "command": "npx",
97
- "args": ["-y", "@engine-room/after-effects-mcp"]
98
- }
105
+ function pointerContent(rel) {
106
+ const body = `# After Effects project
107
+
108
+ See [AGENTS.md](AGENTS.md) for how this folder works, and \`house-style.md\`
109
+ beside the .aep for the look everything should follow.
110
+ `;
111
+ if (rel.endsWith(".mdc")) {
112
+ return `---
113
+ description: How this After Effects project folder works
114
+ alwaysApply: true
115
+ ---
116
+
117
+ ${body}`;
99
118
  }
119
+ return body;
100
120
  }
101
- `;
102
- var KNOWN_FLAGS = ["--no-mcp", "--with-mcp"];
121
+ function pointerFiles(client) {
122
+ switch (client) {
123
+ case "claude-code":
124
+ return ["CLAUDE.md"];
125
+ case "cursor":
126
+ return [path.join(".cursor", "rules", "after-effects.mdc")];
127
+ case "windsurf":
128
+ return [".windsurfrules"];
129
+ case "vscode":
130
+ return [path.join(".github", "copilot-instructions.md")];
131
+ default:
132
+ return [];
133
+ }
134
+ }
135
+ function resolveTarget(dir, roots) {
136
+ if (dir && dir.trim().length > 0) {
137
+ return { dir: path.resolve(dir.trim()), resolvedFrom: "argument" };
138
+ }
139
+ const root = roots?.find((r) => r && r.trim().length > 0);
140
+ if (root) return { dir: path.resolve(root), resolvedFrom: "client-root" };
141
+ const cwd = process.cwd();
142
+ const isFilesystemRoot = cwd === path.parse(cwd).root;
143
+ if (isFilesystemRoot || cwd === os.homedir()) {
144
+ throw new ScaffoldError(
145
+ `No project folder to write to. This client did not say which folder it is working in, and the server was started in ${cwd}, which is not somewhere a project should be created. Ask the user which folder they want the project in \u2014 a new one is fine \u2014 and pass it as \`dir\`.`
146
+ );
147
+ }
148
+ return { dir: cwd, resolvedFrom: "working-directory" };
149
+ }
150
+ function scaffold(opts) {
151
+ const { dir, resolvedFrom } = resolveTarget(opts.dir, opts.roots);
152
+ const name = opts.name?.trim() || path.basename(dir);
153
+ const files = [["AGENTS.md", AGENTS_MD(name)]];
154
+ for (const rel of pointerFiles(opts.client)) files.push([rel, pointerContent(rel)]);
155
+ files.push([path.join("renders", ".gitkeep"), ""]);
156
+ const projectConfig = opts.withMcpConfig ? mcpConfigFor(opts.client) : void 0;
157
+ if (projectConfig) files.push([projectConfig.rel, projectConfig.json]);
158
+ const existing = files.map(([rel]) => rel).filter((rel) => fs.existsSync(path.join(dir, rel)));
159
+ if (existing.length > 0) {
160
+ throw new ScaffoldError(
161
+ `${dir} already has ${existing.join(", ")}. Nothing was written. This folder is already set up \u2014 or pick a different one.`
162
+ );
163
+ }
164
+ for (const [rel, content] of files) {
165
+ const full = path.join(dir, rel);
166
+ fs.mkdirSync(path.dirname(full), { recursive: true });
167
+ fs.writeFileSync(full, content, "utf8");
168
+ }
169
+ const hint = opts.withMcpConfig && !projectConfig ? globalConfigHint(opts.client) : void 0;
170
+ const nextSteps = [
171
+ "Open After Effects and open (or save) the project you want to work on.",
172
+ "Ask to set up After Effects if the tools cannot reach it yet \u2014 that installs the panel, once per machine.",
173
+ "Ask for a style guide, pointing at a comp that already looks the way you want. It is saved next to the .aep."
174
+ ];
175
+ if (hint) nextSteps.unshift(`Add the After Effects server to ${hint.path}, then restart the app.`);
176
+ return {
177
+ dir,
178
+ name,
179
+ client: opts.client,
180
+ written: files.map(([rel]) => rel),
181
+ resolvedFrom,
182
+ mcpConfigHint: hint,
183
+ nextSteps
184
+ };
185
+ }
186
+
187
+ // src/cli/init.ts
188
+ var CLIENTS = [
189
+ "claude-code",
190
+ "claude-desktop",
191
+ "cursor",
192
+ "vscode",
193
+ "windsurf",
194
+ "codex",
195
+ "generic"
196
+ ];
103
197
  function parseInitArgs(argv) {
104
- const positional = argv.filter((a) => !a.startsWith("-"));
105
- const withMcp = !argv.includes("--no-mcp");
106
- const unknown = argv.filter((a) => a.startsWith("-") && !KNOWN_FLAGS.includes(a));
107
- if (unknown.length > 0) return { error: `Unknown option: ${unknown[0]}` };
198
+ const positional = [];
199
+ let withMcp = true;
200
+ let client = "claude-code";
201
+ for (let i = 0; i < argv.length; i++) {
202
+ const arg = argv[i];
203
+ if (arg === "--no-mcp") withMcp = false;
204
+ else if (arg === "--with-mcp") withMcp = true;
205
+ else if (arg === "--client" || arg.startsWith("--client=")) {
206
+ const value = arg.startsWith("--client=") ? arg.slice("--client=".length) : argv[++i];
207
+ if (!value) return { error: "--client needs a value." };
208
+ if (!CLIENTS.includes(value)) {
209
+ return { error: `Unknown client "${value}". One of: ${CLIENTS.join(", ")}.` };
210
+ }
211
+ client = value;
212
+ } else if (arg.startsWith("-")) {
213
+ return { error: `Unknown option: ${arg}` };
214
+ } else positional.push(arg);
215
+ }
108
216
  if (positional.length === 0) return { error: "Missing target directory." };
109
217
  if (positional.length > 1) return { error: `Expected one directory, got ${positional.length}.` };
110
- return { dir: positional[0], withMcp };
218
+ return { dir: positional[0], withMcp, client };
111
219
  }
112
220
  function runInit(argv) {
113
221
  const parsed = parseInitArgs(argv);
114
222
  if ("error" in parsed) {
115
- process.stderr.write(`${parsed.error}
116
-
117
- Usage: npx @engine-room/after-effects-mcp init <directory> [--no-mcp]
118
- `);
119
- return 1;
120
- }
121
- const target = path.resolve(parsed.dir);
122
- const name = path.basename(target);
123
- const files = [
124
- ["CLAUDE.md", CLAUDE_MD.replace("{{NAME}}", name)],
125
- [path.join(".claude", "skills", "house-style", "SKILL.md"), HOUSE_STYLE],
126
- [path.join("renders", ".gitkeep"), ""]
127
- ];
128
- if (parsed.withMcp) files.push([".mcp.json", MCP_JSON]);
129
- const existing = files.map(([rel]) => rel).filter((rel) => fs.existsSync(path.join(target, rel)));
130
- if (existing.length > 0) {
131
223
  process.stderr.write(
132
- `Refusing to overwrite existing files in ${target}:
133
- ` + existing.map((f) => ` ${f}
134
- `).join("") + `
135
- Delete them first, or choose a different directory.
224
+ `${parsed.error}
225
+
226
+ Usage: npx @engine-room/after-effects-mcp init <directory> [--no-mcp] [--client <name>]
227
+ clients: ${CLIENTS.join(", ")} (default claude-code)
136
228
  `
137
229
  );
138
230
  return 1;
139
231
  }
140
- for (const [rel, content] of files) {
141
- const full = path.join(target, rel);
142
- fs.mkdirSync(path.dirname(full), { recursive: true });
143
- fs.writeFileSync(full, content, "utf8");
232
+ let result;
233
+ try {
234
+ result = scaffold({
235
+ dir: parsed.dir,
236
+ client: parsed.client,
237
+ withMcpConfig: parsed.withMcp
238
+ });
239
+ } catch (e) {
240
+ if (e instanceof ScaffoldError) {
241
+ process.stderr.write(`${e.message}
242
+ `);
243
+ return 1;
244
+ }
245
+ throw e;
246
+ }
247
+ const lines = [`Created ${result.dir}`, ``];
248
+ for (const rel of result.written) {
249
+ if (rel.endsWith(".gitkeep")) lines.push(` ${pad(path2.dirname(rel) + "/")} exports land here`);
250
+ else if (rel === "AGENTS.md") lines.push(` ${pad(rel)} what this project is`);
251
+ else if (rel.endsWith("mcp.json")) lines.push(` ${pad(rel)} connects your client to After Effects`);
252
+ else lines.push(` ${pad(rel)} points your client at AGENTS.md`);
144
253
  }
145
- const out = [
146
- `Created ${target}`,
147
- ``,
148
- ` CLAUDE.md what this project is`,
149
- ` .claude/skills/house-style/SKILL.md your look \u2014 edit this first`,
150
- ` renders/ exports land here`,
151
- ...parsed.withMcp ? [` .mcp.json connects your client to After Effects`] : [],
152
- ``,
153
- `Next:`,
154
- ` 1. Open the folder in your MCP client: cd ${parsed.dir}`,
155
- ` 2. Open After Effects, then ask it to set up After Effects.`,
156
- ` 3. Fill in .claude/skills/house-style/SKILL.md with your palette, type and timing.`,
157
- ...parsed.withMcp ? [] : [
254
+ lines.push(``, `Next:`);
255
+ lines.push(` 1. Open the folder in your AI client: cd ${parsed.dir}`);
256
+ result.nextSteps.forEach((s, i) => lines.push(` ${i + 2}. ${s}`));
257
+ if (result.mcpConfigHint) {
258
+ lines.push(
259
+ ``,
260
+ `${parsed.client} configures servers globally rather than per folder. Add this to`,
261
+ `${result.mcpConfigHint.path}:`,
158
262
  ``,
159
- `This folder has no .mcp.json, so your client must already provide the`,
160
- `After Effects tools some other way.`
161
- ],
162
- ``
163
- ].join("\n");
164
- process.stdout.write(out);
263
+ result.mcpConfigHint.json.trimEnd()
264
+ );
265
+ }
266
+ lines.push(``);
267
+ process.stdout.write(lines.join("\n"));
165
268
  return 0;
166
269
  }
270
+ function pad(s) {
271
+ return s.padEnd(38);
272
+ }
167
273
 
168
274
  // src/server.ts
169
275
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
170
276
  import {
171
277
  CallToolRequestSchema,
172
- ListToolsRequestSchema
278
+ GetPromptRequestSchema,
279
+ ListPromptsRequestSchema,
280
+ ListResourcesRequestSchema,
281
+ ListToolsRequestSchema,
282
+ ReadResourceRequestSchema
173
283
  } from "@modelcontextprotocol/sdk/types.js";
174
284
  import { zodToJsonSchema } from "zod-to-json-schema";
175
285
 
@@ -232,6 +342,7 @@ __export(schemas_exports, {
232
342
  AddMask: () => AddMask,
233
343
  AddShapeContent: () => AddShapeContent,
234
344
  AddTextAnimator: () => AddTextAnimator,
345
+ AeGuide: () => AeGuide,
235
346
  AwaitJob: () => AwaitJob,
236
347
  CancelJob: () => CancelJob,
237
348
  CheckSetup: () => CheckSetup,
@@ -251,18 +362,24 @@ __export(schemas_exports, {
251
362
  DeleteLayer: () => DeleteLayer,
252
363
  DuplicateLayer: () => DuplicateLayer,
253
364
  FindLayers: () => FindLayers,
365
+ GUIDE_TOPICS: () => GUIDE_TOPICS,
254
366
  GetComp: () => GetComp,
255
367
  GetCompTree: () => GetCompTree,
256
368
  GetExpression: () => GetExpression,
369
+ GetHouseStyle: () => GetHouseStyle,
257
370
  GetJob: () => GetJob,
258
371
  GetKeyframes: () => GetKeyframes,
259
372
  GetLayerFull: () => GetLayerFull,
260
373
  GetProjectSummary: () => GetProjectSummary,
374
+ InitProject: () => InitProject,
261
375
  Interpolation: () => Interpolation,
262
376
  ListAvailableEffects: () => ListAvailableEffects,
263
377
  ListComps: () => ListComps,
264
378
  ListEffects: () => ListEffects,
379
+ ListKnownIssues: () => ListKnownIssues,
265
380
  ListLayers: () => ListLayers,
381
+ LogIssue: () => LogIssue,
382
+ MarkIssueReported: () => MarkIssueReported,
266
383
  OpSchemas: () => OpSchemas,
267
384
  ParentLayer: () => ParentLayer,
268
385
  PropertyPath: () => PropertyPath,
@@ -280,6 +397,7 @@ __export(schemas_exports, {
280
397
  SetEffectEnabled: () => SetEffectEnabled,
281
398
  SetEffectParam: () => SetEffectParam,
282
399
  SetExpression: () => SetExpression,
400
+ SetHouseStyle: () => SetHouseStyle,
283
401
  SetInterpolation: () => SetInterpolation,
284
402
  SetLayer: () => SetLayer,
285
403
  SetMask: () => SetMask,
@@ -664,11 +782,41 @@ var FindLayers = z2.object({
664
782
  hasEffectMatchName: z2.string().optional()
665
783
  });
666
784
  var RunJsx = z2.object({ code: z2.string() });
785
+ var GetHouseStyle = z2.object({}).strict();
786
+ var SetHouseStyle = z2.object({
787
+ content: z2.string().min(1).describe("The complete style guide as markdown. Replaces the file, so send the whole document."),
788
+ overwrite: z2.boolean().default(false).optional().describe("Required to replace an existing guide. Read it with get_house_style and merge first \u2014 this is not a patch.")
789
+ }).strict();
667
790
  var CheckSetup = z2.object({}).strict();
668
791
  var SetupPanel = z2.object({
669
792
  enableDebugMode: z2.boolean().default(true).optional().describe("Also enable Adobe's PlayerDebugMode preference, which AE requires to load this unsigned panel. Default true."),
670
793
  force: z2.boolean().default(false).optional().describe("Replace an existing symlinked (development) install with a copy. Default false.")
671
794
  }).strict();
795
+ var GUIDE_TOPICS = ["ae-setup", "after-effects", "style-guide"];
796
+ var AeGuide = z2.object({
797
+ topic: z2.enum(GUIDE_TOPICS).describe("after-effects: building, animating, easing, expressions, the traps. style-guide: capturing the user's look. ae-setup: connecting to AE when a tool cannot reach it.")
798
+ }).strict();
799
+ var InitProject = z2.object({
800
+ dir: z2.string().optional().describe("Folder to create or fill, absolute or relative to the server's working directory. Ask the user if you do not know; do not invent one."),
801
+ name: z2.string().optional().describe("Project name for the generated docs. Defaults to the folder name."),
802
+ client: z2.enum(["auto", "claude-code", "claude-desktop", "cursor", "vscode", "windsurf", "codex", "generic"]).default("auto").optional().describe("Which client's layout to write. 'auto' detects it from the MCP handshake \u2014 leave it alone unless the user says otherwise."),
803
+ withMcpConfig: z2.boolean().default(false).optional().describe("Also write a client MCP config pointing at this server. Default false: you are already connected, so the user does not need one.")
804
+ }).strict();
805
+ var LogIssue = z2.object({
806
+ title: z2.string().min(3).describe("One line naming the problem, specific enough to recognise again. Becomes the entry's id."),
807
+ symptom: z2.string().min(3).describe("What went wrong, including the exact error text and the call that produced it."),
808
+ workaround: z2.string().min(3).describe("What actually worked \u2014 concrete enough for the next session to apply without rediscovering it."),
809
+ cause: z2.string().optional().describe("Why it happens, if you worked it out."),
810
+ tools: z2.array(z2.string()).optional().describe("Tool names involved, e.g. ['set_temporal_ease'].")
811
+ }).strict();
812
+ var ListKnownIssues = z2.object({
813
+ status: z2.enum(["all", "unreported", "reported"]).default("all").optional(),
814
+ tool: z2.string().optional().describe("Only entries about this tool, e.g. 'set_temporal_ease'. Omit for everything.")
815
+ }).strict();
816
+ var MarkIssueReported = z2.object({
817
+ id: z2.string().describe("The entry id returned by log_issue or list_known_issues."),
818
+ url: z2.string().optional().describe("Link to the issue that was opened.")
819
+ }).strict();
672
820
  var AwaitJob = z2.object({ jobId: z2.string(), timeoutMs: z2.number().int().positive().default(6e5).optional() });
673
821
  var GetJob = z2.object({ jobId: z2.string() });
674
822
  var CancelJob = z2.object({ jobId: z2.string() });
@@ -742,13 +890,23 @@ var OpSchemas = {
742
890
  find_layers: FindLayers,
743
891
  // raw
744
892
  run_jsx: RunJsx,
893
+ // house style
894
+ get_house_style: GetHouseStyle,
895
+ set_house_style: SetHouseStyle,
745
896
  // jobs
746
897
  await_job: AwaitJob,
747
898
  get_job: GetJob,
748
899
  cancel_job: CancelJob,
749
900
  // setup
750
901
  check_setup: CheckSetup,
751
- setup_panel: SetupPanel
902
+ setup_panel: SetupPanel,
903
+ init_project: InitProject,
904
+ // guidance
905
+ ae_guide: AeGuide,
906
+ // issue journal
907
+ log_issue: LogIssue,
908
+ list_known_issues: ListKnownIssues,
909
+ mark_issue_reported: MarkIssueReported
752
910
  };
753
911
 
754
912
  // src/util/errors.ts
@@ -793,8 +951,8 @@ var logger = {
793
951
 
794
952
  // src/bridge/discovery.ts
795
953
  import fs2 from "node:fs";
796
- import os from "node:os";
797
- import path2 from "node:path";
954
+ import os2 from "node:os";
955
+ import path3 from "node:path";
798
956
  var DEFAULT_PORT = 7777;
799
957
  function discoverPort() {
800
958
  const envPort = process.env.AE_MCP_PORT;
@@ -803,7 +961,7 @@ function discoverPort() {
803
961
  if (Number.isFinite(n)) return n;
804
962
  }
805
963
  try {
806
- const f = path2.join(os.homedir(), ".engineroom-ae-mcp", "port");
964
+ const f = path3.join(os2.homedir(), ".engineroom-ae-mcp", "port");
807
965
  if (fs2.existsSync(f)) {
808
966
  const txt = fs2.readFileSync(f, "utf8").trim();
809
967
  const n = parseInt(txt, 10);
@@ -822,6 +980,8 @@ var HttpClient = class {
822
980
  this.port = port ?? discoverPort();
823
981
  this.base = `http://127.0.0.1:${this.port}`;
824
982
  }
983
+ // `bundleHash` is absent on panels installed before it was added; callers must
984
+ // treat undefined as "too old to say" rather than as a mismatch.
825
985
  async health() {
826
986
  try {
827
987
  const r = await fetch(`${this.base}/health`, { signal: AbortSignal.timeout(2e3) });
@@ -1100,24 +1260,34 @@ var descriptions = {
1100
1260
  find_layers: "Search across one or all comps for layers matching name/type/effect filters.",
1101
1261
  // ---------- raw ----------
1102
1262
  run_jsx: "Escape hatch: arbitrary ExtendScript in an undo group. `comp`/`app`/`OPS`/helpers in scope. Use `return X` to send a value back; complex AE objects are coerced to plain props.",
1263
+ // ---------- house style ----------
1264
+ get_house_style: "The user's palette, type, motion and layout defaults for the project that is open, read from `house-style.md` beside the .aep. Call it once before building anything so your work matches the rest of theirs. `found:false` means none exists yet \u2014 build with sensible defaults and offer to capture one afterwards. Cheap; never a reason to skip.",
1265
+ set_house_style: "Write the project's style guide. Replaces the whole file, so read it first and send the merged document \u2014 `overwrite:true` is required to replace an existing one. The project must have been saved at least once, since the file lives beside the .aep. Use the style-guide topic of ae_guide for how to capture a style worth writing down.",
1266
+ // ---------- guidance ----------
1267
+ ae_guide: "The full working guidance for these tools, by topic. Read `after-effects` before a first substantial build in a session, `style-guide` when capturing or editing the user's look, `ae-setup` when a tool cannot reach After Effects. Covers the traps that silently produce wrong output and are not visible from any single tool's schema.",
1103
1268
  // ---------- jobs ----------
1104
1269
  await_job: "Block until job is done. Default 10min timeout. Returns the same payload the tool would have.",
1105
1270
  get_job: "Non-blocking job status: progress/total/state/error.",
1106
1271
  cancel_job: "Set cancel flag; chunked loop stops at next boundary.",
1107
1272
  // ---------- setup ----------
1108
- check_setup: "Diagnose the After Effects connection: panel installed, up to date, Adobe debug preference on, AE running, bridge answering. Read-only and safe to call any time. Call this FIRST whenever another tool reports it cannot reach After Effects, then relay `nextSteps` to the user in plain language.",
1109
- setup_panel: "Install or refresh the After Effects panel and enable the Adobe preference AE needs to load it. Run this when check_setup reports the panel is missing or out of date. It writes to the user's Adobe CEP extensions folder and sets a user-level Adobe preference \u2014 tell the user what it will do before calling it. Afterwards, AE must be restarted; if the preference was newly enabled, a one-time Mac reboot may also be needed."
1273
+ check_setup: "Diagnose the After Effects connection: panel installed, up to date, the version AE is actually running, Adobe debug preference on, AE running, bridge answering. Read-only and safe to call any time. Call this FIRST whenever another tool reports it cannot reach After Effects or says the panel is out of date, then relay `nextSteps` to the user in plain language. `panelRunningCurrent` is the one that predicts whether calls will work \u2014 it can fail while `panelUpToDate` passes, which means an update is installed but AE has not been restarted.",
1274
+ setup_panel: "Install or refresh the After Effects panel and enable the Adobe preference AE needs to load it. Run this when check_setup reports the panel is missing, out of date, or older than what AE is running. It writes to the user's Adobe CEP extensions folder and sets a user-level Adobe preference \u2014 tell the user what it will do before calling it. Prefer running it while AE is CLOSED: the panel then loads when they open it, with no restart. If AE is already open they must quit and reopen it, and until they do, the old panel keeps answering. If the preference was newly enabled, a one-time Mac reboot may also be needed.",
1275
+ init_project: "Set up a working folder for one video, series or client: a project brief and a pointer to the house style, written in whichever layout this client reads. Run it when the user is starting out or asks to set up a project. It writes files to disk \u2014 say which folder before calling, and pass `dir` explicitly unless the client already told the server where it is working. It never overwrites anything and reports every path it wrote.",
1276
+ // ---------- issue journal ----------
1277
+ list_known_issues: "Problems earlier sessions hit with these tools, with the workarounds that worked. Read it when a tool fails in a way you don't immediately understand \u2014 pass `tool` to narrow it to that one \u2014 and before nontrivial work. It can save you rediscovering a fix that already cost someone an hour. Also returns the repo and server version needed to report one.",
1278
+ log_issue: "Record a problem you hit and the workaround that got past it, so the next session doesn't rediscover it. Log only what cost real effort and will recur: a tool failing for a non-obvious reason, an argument shape the schema didn't imply, AE behaving unlike the docs. Not your own typos, not one-off user mistakes. Call list_known_issues first and reuse the same title to extend an existing entry rather than duplicating it. If the result comes back with reported:false, then AFTER you have finished the actual work, close your reply by telling the user in plain language that something took much longer than it should have and offering to pass it to the people who maintain this tool \u2014 phrase it for a motion designer, in terms of what actually happened, and don't say 'GitHub issue' or 'bug report' unless they say it first.",
1279
+ mark_issue_reported: "Record that a journal entry has been sent to the maintainers, with the resulting URL. Call it only once the issue really exists, so later sessions don't ask the user to report the same thing twice."
1110
1280
  };
1111
1281
 
1112
1282
  // src/setup/check.ts
1113
- import crypto from "node:crypto";
1114
- import fs4 from "node:fs";
1115
- import path4 from "node:path";
1283
+ import crypto2 from "node:crypto";
1284
+ import fs5 from "node:fs";
1285
+ import path6 from "node:path";
1116
1286
 
1117
1287
  // src/setup/paths.ts
1118
1288
  import fs3 from "node:fs";
1119
- import os2 from "node:os";
1120
- import path3 from "node:path";
1289
+ import os3 from "node:os";
1290
+ import path4 from "node:path";
1121
1291
  import { createRequire } from "node:module";
1122
1292
  import { fileURLToPath } from "node:url";
1123
1293
  var BUNDLE_ID = "games.engine-room.ae-mcp";
@@ -1125,48 +1295,64 @@ function isSupportedPlatform() {
1125
1295
  return process.platform === "darwin" || process.platform === "win32";
1126
1296
  }
1127
1297
  function packageRoot() {
1128
- let dir = path3.dirname(fileURLToPath(import.meta.url));
1298
+ let dir = path4.dirname(fileURLToPath(import.meta.url));
1129
1299
  for (let i = 0; i < 8; i++) {
1130
- if (fs3.existsSync(path3.join(dir, "package.json"))) return dir;
1131
- const parent = path3.dirname(dir);
1300
+ if (fs3.existsSync(path4.join(dir, "package.json"))) return dir;
1301
+ const parent = path4.dirname(dir);
1132
1302
  if (parent === dir) break;
1133
1303
  dir = parent;
1134
1304
  }
1135
- return path3.dirname(fileURLToPath(import.meta.url));
1305
+ return path4.dirname(fileURLToPath(import.meta.url));
1306
+ }
1307
+ function executableDir() {
1308
+ return path4.dirname(process.execPath);
1309
+ }
1310
+ function packageVersion() {
1311
+ for (const dir of [packageRoot(), executableDir()]) {
1312
+ try {
1313
+ const pkg = JSON.parse(fs3.readFileSync(path4.join(dir, "package.json"), "utf8"));
1314
+ if (typeof pkg.version === "string") return pkg.version;
1315
+ } catch {
1316
+ }
1317
+ }
1318
+ return "unknown";
1136
1319
  }
1137
1320
  function panelSourceDir() {
1138
1321
  const candidates = [
1139
1322
  // The live workspace copy comes first so a git checkout always installs
1140
1323
  // what the developer is editing, never a stale vendored copy left behind by
1141
1324
  // a previous `npm pack`. Only the second path exists in the tarball.
1142
- path3.resolve(packageRoot(), "..", "ae-panel"),
1143
- path3.join(packageRoot(), "panel")
1325
+ path4.resolve(packageRoot(), "..", "ae-panel"),
1326
+ path4.join(packageRoot(), "panel"),
1327
+ // Compiled single-file build: the panel ships beside the executable.
1328
+ path4.join(executableDir(), "panel")
1144
1329
  ];
1145
1330
  for (const dir of candidates) {
1146
- if (fs3.existsSync(path3.join(dir, "CSXS", "manifest.xml"))) return dir;
1331
+ if (fs3.existsSync(path4.join(dir, "CSXS", "manifest.xml"))) return dir;
1147
1332
  }
1148
1333
  return null;
1149
1334
  }
1150
1335
  function cepExtensionsDir() {
1151
1336
  if (process.platform === "win32") {
1152
- const appData = process.env.APPDATA ?? path3.join(os2.homedir(), "AppData", "Roaming");
1153
- return path3.join(appData, "Adobe", "CEP", "extensions");
1337
+ const appData = process.env.APPDATA ?? path4.join(os3.homedir(), "AppData", "Roaming");
1338
+ return path4.join(appData, "Adobe", "CEP", "extensions");
1154
1339
  }
1155
- return path3.join(os2.homedir(), "Library", "Application Support", "Adobe", "CEP", "extensions");
1340
+ return path4.join(os3.homedir(), "Library", "Application Support", "Adobe", "CEP", "extensions");
1156
1341
  }
1157
1342
  function installedPanelDir() {
1158
- return path3.join(cepExtensionsDir(), BUNDLE_ID);
1343
+ return path4.join(cepExtensionsDir(), BUNDLE_ID);
1159
1344
  }
1160
1345
  function wsModuleDir() {
1161
1346
  try {
1162
1347
  const require2 = createRequire(import.meta.url);
1163
1348
  const entry = require2.resolve("ws");
1164
- const marker = `${path3.sep}node_modules${path3.sep}ws${path3.sep}`;
1349
+ const marker = `${path4.sep}node_modules${path4.sep}ws${path4.sep}`;
1165
1350
  const idx = entry.lastIndexOf(marker);
1166
1351
  if (idx >= 0) return entry.slice(0, idx + marker.length - 1);
1167
- return path3.dirname(entry);
1352
+ return path4.dirname(entry);
1168
1353
  } catch {
1169
- return null;
1354
+ const beside = path4.join(executableDir(), "node_modules", "ws");
1355
+ return fs3.existsSync(beside) ? beside : null;
1170
1356
  }
1171
1357
  }
1172
1358
  function copyRecursive(src, dst) {
@@ -1174,7 +1360,7 @@ function copyRecursive(src, dst) {
1174
1360
  if (stat.isDirectory()) {
1175
1361
  fs3.mkdirSync(dst, { recursive: true });
1176
1362
  for (const entry of fs3.readdirSync(src)) {
1177
- copyRecursive(path3.join(src, entry), path3.join(dst, entry));
1363
+ copyRecursive(path4.join(src, entry), path4.join(dst, entry));
1178
1364
  }
1179
1365
  } else if (stat.isSymbolicLink()) {
1180
1366
  fs3.symlinkSync(fs3.readlinkSync(src), dst);
@@ -1183,6 +1369,56 @@ function copyRecursive(src, dst) {
1183
1369
  }
1184
1370
  }
1185
1371
 
1372
+ // src/setup/panelVersion.ts
1373
+ import crypto from "node:crypto";
1374
+ import fs4 from "node:fs";
1375
+ import path5 from "node:path";
1376
+ var cachedSourceHash;
1377
+ function sourceBundleHash() {
1378
+ if (cachedSourceHash !== void 0) return cachedSourceHash;
1379
+ const source = panelSourceDir();
1380
+ cachedSourceHash = source ? hashFile(path5.join(source, "jsx", "bundle.jsx")) : null;
1381
+ return cachedSourceHash;
1382
+ }
1383
+ function installedBundleHash(installedPanel) {
1384
+ return hashFile(path5.join(installedPanel, "jsx", "bundle.jsx"));
1385
+ }
1386
+ function hashFile(file) {
1387
+ try {
1388
+ return crypto.createHash("sha256").update(fs4.readFileSync(file)).digest("hex");
1389
+ } catch {
1390
+ return null;
1391
+ }
1392
+ }
1393
+ var STALE_PANEL_ADVICE = "Tell the user this in plain language, then do it: the After Effects panel is older than these tools and does not understand everything they can do now. Run setup_panel, then ask them to quit and reopen After Effects. Do not retry the failed call until they confirm it has restarted.";
1394
+ function assessPanel(runningHash, installedHash) {
1395
+ const shipped = sourceBundleHash();
1396
+ if (!shipped) return { state: "unknown", message: "" };
1397
+ if (runningHash === shipped) return { state: "current", message: "" };
1398
+ if (typeof runningHash !== "string" || runningHash.length === 0) {
1399
+ if (installedHash === shipped) {
1400
+ return {
1401
+ state: "restart-needed",
1402
+ message: "The After Effects panel has been updated on disk, but After Effects is still running the previous version. Ask the user to quit and reopen After Effects, then try again."
1403
+ };
1404
+ }
1405
+ return {
1406
+ state: "unknown",
1407
+ message: "The After Effects panel is too old to report its version, which means it predates these tools. " + STALE_PANEL_ADVICE
1408
+ };
1409
+ }
1410
+ if (installedHash === shipped) {
1411
+ return {
1412
+ state: "restart-needed",
1413
+ message: "The After Effects panel has been updated on disk, but After Effects is still running the previous version. Ask the user to quit and reopen After Effects, then try again. Running setup_panel again will not help \u2014 only a restart loads the new panel."
1414
+ };
1415
+ }
1416
+ return { state: "update-needed", message: `The After Effects panel is out of date. ${STALE_PANEL_ADVICE}` };
1417
+ }
1418
+ function unknownOpMessage(op) {
1419
+ return `The After Effects panel does not recognise "${op}". That always means the installed panel is older than these tools \u2014 this op did not exist when it was installed. ${STALE_PANEL_ADVICE}`;
1420
+ }
1421
+
1186
1422
  // src/setup/platform.ts
1187
1423
  import { execFile } from "node:child_process";
1188
1424
  import { promisify } from "node:util";
@@ -1263,7 +1499,7 @@ function debugModeLocation() {
1263
1499
  // src/setup/check.ts
1264
1500
  function sha256(file) {
1265
1501
  try {
1266
- return crypto.createHash("sha256").update(fs4.readFileSync(file)).digest("hex");
1502
+ return crypto2.createHash("sha256").update(fs5.readFileSync(file)).digest("hex");
1267
1503
  } catch {
1268
1504
  return null;
1269
1505
  }
@@ -1272,7 +1508,8 @@ async function bridgeReachable(port) {
1272
1508
  try {
1273
1509
  const res = await fetch(`http://127.0.0.1:${port}/health`, { signal: AbortSignal.timeout(2e3) });
1274
1510
  if (!res.ok) return { ok: false, detail: `port ${port} returned HTTP ${res.status}` };
1275
- return { ok: true, detail: `responding on port ${port}` };
1511
+ const body = await res.json().catch(() => ({}));
1512
+ return { ok: true, detail: `responding on port ${port}`, bundleHash: body.bundleHash };
1276
1513
  } catch (e) {
1277
1514
  return { ok: false, detail: `no response on port ${port} (${e.message})` };
1278
1515
  }
@@ -1301,7 +1538,7 @@ async function checkSetup() {
1301
1538
  fix: debugMode.on ? void 0 : `Run the setup_panel tool. After Effects only loads unsigned panels when ${debugModeLocation()} is set.`
1302
1539
  });
1303
1540
  const installed = installedPanelDir();
1304
- const isInstalled = fs4.existsSync(path4.join(installed, "CSXS", "manifest.xml"));
1541
+ const isInstalled = fs5.existsSync(path6.join(installed, "CSXS", "manifest.xml"));
1305
1542
  checks.push({
1306
1543
  name: "panelInstalled",
1307
1544
  ok: isInstalled,
@@ -1309,8 +1546,8 @@ async function checkSetup() {
1309
1546
  fix: isInstalled ? void 0 : "Run the setup_panel tool to install it."
1310
1547
  });
1311
1548
  if (isInstalled && source) {
1312
- const installedHash = sha256(path4.join(installed, "jsx", "bundle.jsx"));
1313
- const sourceHash = sha256(path4.join(source, "jsx", "bundle.jsx"));
1549
+ const installedHash = sha256(path6.join(installed, "jsx", "bundle.jsx"));
1550
+ const sourceHash = sha256(path6.join(source, "jsx", "bundle.jsx"));
1314
1551
  const upToDate = installedHash !== null && installedHash === sourceHash;
1315
1552
  checks.push({
1316
1553
  name: "panelUpToDate",
@@ -1334,6 +1571,16 @@ async function checkSetup() {
1334
1571
  detail: bridge.detail,
1335
1572
  fix: bridge.ok ? void 0 : "If the other checks pass, restart After Effects so the panel reloads."
1336
1573
  });
1574
+ if (bridge.ok && source) {
1575
+ const assessment = assessPanel(bridge.bundleHash, sha256(path6.join(installed, "jsx", "bundle.jsx")));
1576
+ const ok = assessment.state === "current";
1577
+ checks.push({
1578
+ name: "panelRunningCurrent",
1579
+ ok,
1580
+ detail: ok ? "After Effects is running the panel that ships with these tools" : assessment.state === "restart-needed" ? "After Effects is still running the previous panel \u2014 the update needs a restart to take effect" : assessment.state === "unknown" ? "the running panel is too old to report its version" : "After Effects is running a panel older than these tools",
1581
+ fix: ok ? void 0 : assessment.message
1582
+ });
1583
+ }
1337
1584
  if (bridge.ok && !isInstalled) {
1338
1585
  checks.push({
1339
1586
  name: "panelIdentity",
@@ -1366,8 +1613,8 @@ function buildNextSteps(checks, ready) {
1366
1613
  steps.push(identity.fix);
1367
1614
  }
1368
1615
  if (by("afterEffectsRunning")?.ok === false) {
1369
- steps.push("Open After Effects 2026.");
1370
- } else if (needsInstall) {
1616
+ steps.push(needsInstall ? "Open After Effects 2026 \u2014 the panel loads with it." : "Open After Effects 2026.");
1617
+ } else if (needsInstall || by("panelRunningCurrent")?.ok === false) {
1371
1618
  steps.push("Quit and reopen After Effects so it picks up the panel.");
1372
1619
  }
1373
1620
  if (steps.length === 0 && by("bridgeReachable")?.ok === false) {
@@ -1378,8 +1625,8 @@ function buildNextSteps(checks, ready) {
1378
1625
  }
1379
1626
 
1380
1627
  // src/setup/install.ts
1381
- import fs5 from "node:fs";
1382
- import path5 from "node:path";
1628
+ import fs6 from "node:fs";
1629
+ import path7 from "node:path";
1383
1630
  async function installPanel(opts = {}) {
1384
1631
  const actions = [];
1385
1632
  const notes = [];
@@ -1392,13 +1639,13 @@ async function installPanel(opts = {}) {
1392
1639
  if (!source) {
1393
1640
  throw new Error("Could not find the CEP panel assets that ship with this server. Reinstall the package.");
1394
1641
  }
1395
- if (!fs5.existsSync(path5.join(source, "jsx", "bundle.jsx"))) {
1642
+ if (!fs6.existsSync(path7.join(source, "jsx", "bundle.jsx"))) {
1396
1643
  throw new Error(`The panel at ${source} has no jsx/bundle.jsx. In a git checkout, run \`npm run build:jsx\` first.`);
1397
1644
  }
1398
1645
  const target = installedPanelDir();
1399
- const existing = fs5.lstatSync(target, { throwIfNoEntry: false });
1646
+ const existing = fs6.lstatSync(target, { throwIfNoEntry: false });
1400
1647
  if (existing?.isSymbolicLink() && !opts.force) {
1401
- const linkTarget = fs5.readlinkSync(target);
1648
+ const linkTarget = fs6.readlinkSync(target);
1402
1649
  return {
1403
1650
  ok: true,
1404
1651
  panelPath: target,
@@ -1412,17 +1659,17 @@ async function installPanel(opts = {}) {
1412
1659
  };
1413
1660
  }
1414
1661
  if (existing) {
1415
- fs5.rmSync(target, { recursive: true, force: true });
1662
+ fs6.rmSync(target, { recursive: true, force: true });
1416
1663
  actions.push("Removed the previously installed panel.");
1417
1664
  }
1418
- fs5.mkdirSync(path5.dirname(target), { recursive: true });
1665
+ fs6.mkdirSync(path7.dirname(target), { recursive: true });
1419
1666
  copyRecursive(source, target);
1420
1667
  actions.push(`Installed the panel to ${target}.`);
1421
1668
  const ws = wsModuleDir();
1422
1669
  if (ws) {
1423
- const dest = path5.join(target, "node_modules", "ws");
1424
- fs5.mkdirSync(path5.dirname(dest), { recursive: true });
1425
- fs5.rmSync(dest, { recursive: true, force: true });
1670
+ const dest = path7.join(target, "node_modules", "ws");
1671
+ fs6.mkdirSync(path7.dirname(dest), { recursive: true });
1672
+ fs6.rmSync(dest, { recursive: true, force: true });
1426
1673
  copyRecursive(ws, dest);
1427
1674
  actions.push("Copied the `ws` module the panel needs at runtime.");
1428
1675
  } else {
@@ -1456,6 +1703,255 @@ async function installPanel(opts = {}) {
1456
1703
  };
1457
1704
  }
1458
1705
 
1706
+ // src/generated/content.ts
1707
+ var GUIDES = [
1708
+ {
1709
+ name: "ae-setup",
1710
+ description: "Diagnose and repair the connection between the AE MCP tools and After Effects \u2014 panel not installed, AE not running, Adobe debug preference off, bridge not responding. Load when an After Effects tool reports it cannot reach AE, or when the user is setting this up for the first time.",
1711
+ body: "# Getting After Effects connected\n\nThe tools talk to a small panel that runs **inside** After Effects. Three things must be true for that to work: the panel is installed, Adobe is willing to load it, and AE is open.\n\nAssume the person you are helping is a motion designer, not a developer. They should never need to open a terminal \u2014 you have tools for all of this.\n\n## Always start with check_setup\n\n`check_setup` is read-only and safe to call at any time. It returns a `checks` array and a `nextSteps` list already written in plain language.\n\n**Relay `nextSteps` to the user directly.** Do not paraphrase it into jargon, and do not invent steps it did not mention.\n\n## Install before they open After Effects, if you still can\n\nThe panel loads at launch and only at launch. So the order matters, and it is\nthe opposite of what people assume:\n\n- **After Effects is closed** \u2014 install now. When they open it, the panel is\n simply there. No restart, nothing to ask for. This is the good path, and on a\n first-time setup you can usually get it.\n- **After Effects is open** \u2014 install, then they have to quit and reopen it.\n Unavoidable, but worth avoiding: if they have not opened AE yet in this\n conversation, do the install *first* and tell them to open it after.\n\n`check_setup` reports `afterEffectsRunning`, so you always know which case you\nare in before you say anything.\n\n## The repair path\n\n1. **`check_setup`** \u2014 find out what is actually wrong.\n2. **`setup_panel`** \u2014 if the panel is missing or out of date. Tell the user what it will do *before* you call it: it copies the panel into their Adobe extensions folder and switches on the Adobe preference that permits unsigned panels. Both changes are user-level and reversible.\n3. **Get the panel loaded.** If AE was closed, ask them to open it. If it was already open, ask them to quit and reopen it. You cannot do either for them.\n4. **`check_setup`** again to confirm.\n\n## What the individual failures mean\n\n| Check | Meaning when it fails |\n|---|---|\n| `platform` | Not macOS or Windows. After Effects only runs on those two, so there is nothing to fix. |\n| `panelAssetsPresent` | The server package is incomplete \u2014 it needs reinstalling. |\n| `cepDebugMode` | Adobe refuses to load unsigned panels until this preference is on. `setup_panel` sets it. |\n| `panelInstalled` | The panel is not in the Adobe extensions folder yet. `setup_panel` installs it. |\n| `panelUpToDate` | The files on disk are older than this server. Run `setup_panel`. |\n| `panelRunningCurrent` | AE is *running* an older panel than these tools ship. This is the one that predicts whether calls will actually work \u2014 `panelUpToDate` can pass while this fails, for the whole window between installing an update and restarting AE. |\n| `afterEffectsRunning` | AE is closed. If the panel also needs installing, install it now and then ask them to open AE \u2014 that saves a restart. |\n| `bridgeReachable` | Everything is installed but the panel isn't answering \u2014 almost always fixed by restarting AE. |\n\n## The reboot case\n\n`cepDebugMode` is an Adobe preference that, on some macOS builds, only takes effect after a **restart of the Mac** \u2014 not just of After Effects. If `setup_panel` reports `rebootRecommended: true` and restarting AE alone did not fix it, ask the user to reboot once. This is a one-time cost, never needed again.\n\n## When a tool says the panel is out of date\n\nYou may get an error saying the panel is older than these tools, or that it does\nnot recognise an op. That is a version mismatch, not a broken tool, and the\nmessage tells you which of the two fixes applies:\n\n- **\"updated on disk \u2026 still running the previous version\"** \u2014 `setup_panel` has\n already done its part. Only a restart of After Effects will help; running it\n again will not.\n- **anything else** \u2014 run `setup_panel`, then get AE restarted.\n\nEither way, do not retry the failed call until the user confirms AE has\nrestarted. Say it as a version mismatch in plain language, not as a failure:\ntheir tools moved ahead of the panel, and it takes a restart to catch up.\n\n## If it still will not connect\n\nAsk the user to open **Window > Extensions > AE MCP Bridge** inside After Effects. That panel shows its own status and a log, and will say whether it started, which port it took, or what error it hit. Have them read it back to you.\n\nA common cause is a stale install: the panel loaded an older script bundle than the server expects. `check_setup`'s `panelUpToDate` catches that \u2014 the fix is `setup_panel` followed by an AE restart."
1712
+ },
1713
+ {
1714
+ name: "after-effects",
1715
+ description: "How to drive Adobe After Effects well through the AE MCP tools \u2014 orienting in a project, building and animating layers, keyframes and easing, expressions, effects, text and shapes, and the gotchas that silently produce wrong output. Load whenever a task involves After Effects, motion graphics, comps, layers, or keyframes.",
1716
+ body: '# Driving After Effects\n\nYou have direct control of a live After Effects session. The user sees every change immediately, and every tool call is a real undo step in their project. Work like a motion designer at the keyboard, not like a script that fires blind.\n\n## Read the house style first\n\n`get_house_style` returns the style guide for the project that is currently open\n\u2014 palette, type, motion defaults, layout rules \u2014 read from `house-style.md`\nsitting next to the `.aep` file. Call it once at the start of any build task and\nfollow what it says. It costs one cheap call and it is the difference between\nwork that matches everything else the user has made and work that does not.\n\nIf it reports `found: false`, build with sensible defaults and offer once, at the\nend, to capture a style guide from what you just made. Don\'t nag about it.\n\n## Orient before you touch anything\n\nNever guess at project state. Cheap reads exist for exactly this:\n\n| Question | Tool |\n|---|---|\n| What\'s in this project? | `get_project_summary` |\n| What comps exist? | `list_comps` |\n| What\'s in this comp? | `get_comp_tree` |\n| Everything about one layer | `get_layer_full` \u2B50 |\n| Where is a layer, by name/type/effect? | `find_layers` |\n\n`get_layer_full` is the one to reach for. It returns transforms **with their keyframes and expressions**, effects with every parameter, masks, markers, and `sourceRect` (the layer\'s visible bounds) in a single call. Prefer one `get_layer_full` over four narrow queries \u2014 it is faster and it shows you context you did not know to ask for.\n\n## Identify things by ID, never by index\n\nEvery comp and layer has a stable numeric `id`. Layer `index` is a 1-based position that **shifts whenever layers are added, deleted, or reordered**. Store `(compId, layerId)` and pass those. An index captured before a `create_*` call may point at a different layer by the time you use it.\n\n## Read, then write, then verify\n\n1. Read the current state (`get_layer_full`).\n2. Make the change.\n3. Verify by reading back the properties \u2014 not by screenshotting.\n\nProperty values are the ground truth. A screenshot tells you something *looks* wrong; `get_layer_full` tells you *why*.\n\n## Screenshots are a diagnostic, not a feedback loop\n\n`screenshot_frame` and `screenshot_layer` are **one-off checks**. Do not screenshot every frame, do not scrub through time, do not screenshot after every edit.\n\n- Take at most 2\u20133 across an animation \u2014 typically start, middle, end.\n- **Always pass `downsample`** on large comps: `2` for 1080p, `3`\u2013`4` for 4K. A full-resolution 4K frame is large enough to blow out your context in one call.\n- The result reports the dimensions actually returned and warns if the downsample could not be applied \u2014 trust those numbers rather than assuming.\n\nTo check motion, read the keyframe values. That is exact; a picture is not.\n\n## Bulk work goes through run_batch\n\nBuilding 40 layers with 40 separate calls is slow and produces 40 undo steps. `run_batch` runs many ops in one ExtendScript pass as a **single undo step**, which is also what the user expects when they ask to undo "that thing you just built".\n\n- `transactional: true` (the default) rolls back the whole batch on the first error.\n- Over 500 ops it returns a `jobId` and streams progress; call `await_job(jobId)` for the final result.\n\n## Keyframes and easing\n\n`add_keyframe` sets a value at a time. Interpolation is separate:\n\n- `set_interpolation` \u2014 linear / bezier / hold, per keyframe, in and out.\n- `set_temporal_ease` \u2014 influence and speed, the "easy ease" controls.\n- `set_spatial_tangents` \u2014 the shape of a motion path through a position keyframe.\n\n**The array-size trap.** `set_temporal_ease` wants one ease entry *per dimension* for ordinary multi-dimensional properties (Scale, Color), but exactly **one** entry for spatial properties (Position, Anchor Point) regardless of whether the layer is 2D or 3D \u2014 because the ease applies along the motion path, not per axis. If you see `Value array does not have 1 elements`, you fed a spatial property one entry per axis.\n\n## Expressions\n\n`set_expression` takes a `propertyPath` such as `["Transform","Position"]` or `["Effects","Gaussian Blur","Blurriness"]`. Expressions are ExtendScript-flavoured JavaScript evaluated by AE per frame.\n\nExpressions are usually a better answer than dense keyframes for anything procedural \u2014 wiggle, loops, counters, follow-through, time remapping. They stay editable by the user afterwards, where a wall of baked keyframes does not.\n\nUse `get_expression` to read one back and `toggle_expression` to disable without deleting.\n\n## Effects\n\nEffects are added by **matchName**, not display name: `add_effect({matchName: "ADBE Gaussian Blur 2"})`. If you do not know a matchName, call `list_available_effects` and search it \u2014 do not guess. `list_effects` shows what is already on a layer, with every parameter.\n\nSet parameters with `set_effect_param` by parameter name (e.g. `"Blurriness"`).\n\n## Text\n\n`create_text_layer` places **point text anchored at the bounding-box centre**, which is not where you would expect from the visible left edge. The tool defaults to `anchorAlign: "left"` so that `position` lines up with the left edge as a designer would read it. Pass `"center"` or `"right"` when you want those, `"none"` for AE\'s raw behaviour.\n\n`set_text` controls font, size, colour, tracking, leading and justification. To auto-fit a background to text, read `sourceRect` from `get_layer_full` and size the shape from its width and height plus padding.\n\n## Shapes\n\n`add_shape_content` builds one node at a time under `Contents` \u2014 `rect`, `ellipse`, `star`, `path`, `fill`, `stroke`, `trim`, `repeater`, `merge`, `group`. Properties are set with friendly names in the same call (`size`, `position`, `roundness`, `color`, `width`, `lineCap`, \u2026).\n\nThis tool is **all-or-nothing**: if a key cannot be applied, the whole node is removed and you get an error naming the bad key. A success result therefore means everything landed. Don\'t add defensive re-reads for it, but do read the error carefully \u2014 it usually means the property is named differently on that node type, and `get_layer_full` will show you the real name.\n\nFor a custom path, use `{type: "path", vertices: [[x,y], \u2026], closed: true}`. The key is `vertices`, not `points`.\n\n## The escape hatch\n\n`run_jsx` executes arbitrary ExtendScript with `app`, `comp`, `OPS` and the helper functions in scope. Reach for it when a needed operation has no tool \u2014 duplicating a comp, driving the render queue, batch-renaming.\n\nTwo warnings: ExtendScript is **single-threaded**, so a long synchronous loop freezes the user\'s AE UI; and returned objects are flattened, so return a string you have assembled yourself rather than a nested object.\n\n## When something costs you real time\n\nThese tools have rough edges, and the same ones catch every session. Two tools\nexist so that each one is only paid for once.\n\n**`list_known_issues`** \u2014 what earlier sessions hit and how they got past it.\nRead it when a tool fails in a way you do not immediately understand, before you\nstart guessing. The answer is often already there.\n\n**`log_issue`** \u2014 write down what you worked out, the moment you work it out.\n\nLog something when all three are true: it cost real effort, it was the tool\'s\nfault rather than yours, and the next session would hit it too. A schema that\naccepts an argument AE then rejects, an error message that names the wrong\nthing, a property whose real name is nothing like its display name. Not your own\ntypos. Not "I forgot the layer was 3D".\n\nWrite the entry for someone who has not seen the failure: the exact error text,\nthe call that produced it, and a workaround concrete enough to apply directly.\nReuse the existing title when you are extending an entry \u2014 that keeps one good\nrecord instead of five thin ones.\n\n### Then offer to pass it on\n\nIf `log_issue` comes back with `reported: false`, mention it to the user \u2014 but\nfinish the actual work first, and put it at the very end, after you have told\nthem what you built. It is a footnote, not the headline.\n\nSay it the way you would to a colleague who does not write code. What you were\ntrying to do, that it fought back, that you got there anyway, and that you can\nsend it to the people who maintain the tool so the next person does not lose the\nsame time. Something like:\n\n> Done \u2014 the lower third is in. One thing worth mentioning: getting the ease\n> onto that position keyframe took a lot longer than it should have, because the\n> tool kept rejecting a value it had just asked for. I found a way around it and\n> made a note. Want me to send it to the people who maintain this so they can\n> fix it properly?\n\nDo not say "GitHub issue", "file a bug" or "open a ticket" unless they say it\nfirst. If they say yes, use the **report-ae-issue** prompt this server provides\n(`/report-ae-issue` where your client exposes prompts as commands) \u2014 it handles\nthe rest. If they say no, drop it; the note stays and can be offered again\nanother time.\n\nNever claim you have reported something you have not.\n\n## When something is not connected\n\nIf a tool reports it cannot reach After Effects, call `check_setup` and relay its `nextSteps` to the user in plain language. Do not try to diagnose CEP by hand.'
1717
+ },
1718
+ {
1719
+ name: "style-guide",
1720
+ description: "Help a motion designer capture their house style \u2014 palette, type, motion and layout \u2014 into the house-style.md file that sits next to their After Effects project and shapes everything built afterwards. Load when the user asks to create, edit or review their style guide, when they say work does not look like theirs, or when get_house_style reports none exists.",
1721
+ body: '# Capturing a house style\n\nA house style is the difference between an assistant that builds *a* lower third\nand one that builds *their* lower third. It lives in `house-style.md` beside the\n`.aep` file, and `get_house_style` reads it before any build task.\n\nYour job here is to get one written with as little effort from the user as\npossible. They are a motion designer. They know exactly what their work looks\nlike and will struggle to dictate it as a specification \u2014 so do not ask them to.\n\n## Two ways in. Prefer the first.\n\n### 1. Read it off work they already like\n\nThis is far better than any questionnaire, because it produces real numbers\ninstead of adjectives.\n\n1. Ask which comp to learn from \u2014 "point me at something that looks the way you\n want everything to look."\n2. `get_comp` for size and frame rate, then `get_layer_full` on the layers that\n carry the look: the text, the background, the accent shapes.\n3. Pull out the concrete values \u2014 hex colours, font families and sizes, tracking,\n corner radii, stroke widths, the position of things relative to the frame.\n4. Read the keyframes too. `get_keyframes` plus the ease settings tell you the\n timing signature: how long a standard in-animation takes, whether it\n overshoots, whether anything is ever linear.\n5. Show them what you found, in their language, and ask what to change:\n\n > Here\'s what I read off that comp: near-black background `#0B0D12`, white\n > text in Inter Semibold at 64px with slightly tight tracking, one green\n > accent `#3DC46E`. Things scale in over about 0.4s with an overshoot to 108%\n > and easy ease on both ends. Nothing sits perfectly still \u2014 there\'s a slow\n > wiggle on the chip. Does that sound like your style, or was that comp a\n > one-off?\n\n6. Write it with `set_house_style`.\n\n### 2. Ask, when there is nothing to read\n\nOnly if the project is empty or they have no reference. Keep it to four\nquestions, and offer concrete options rather than open ones \u2014 "dark or light\nbackground?" beats "what\'s your palette?". Then build one small example, show it\nwith `screenshot_frame`, and refine from their reaction. Reacting is easier than\nspecifying.\n\n## What makes a guide that actually works\n\n**Numbers, not adjectives.** `#131521 at 92% opacity` is usable. "Dark and clean"\nis not. If a corner radius, a stroke width or a hold duration matters, write the\nnumber. Anything vague will be silently reinterpreted every time it is read.\n\n**Rules, not just values.** The most valuable lines are the prohibitions: "never\nput text directly on footage \u2014 always on a rounded chip", "keep total runtime\nunder 8 seconds", "no linear motion unless something mechanical is moving".\nThose are what stop work drifting.\n\n**Only what you verified.** Do not pad the file with plausible-sounding defaults\nthey never asked for. A short guide that is true beats a complete one that is\nhalf invented. Leave a heading empty rather than filling it with a guess.\n\n## Keep it current\n\nWhen the user corrects the same thing twice \u2014 "no, the accent green, not the\nblue" \u2014 that is a missing rule, not a one-off. Offer to add it:\n\n> I\'ve had to switch that green twice now. Want me to put it in the style guide\n> so it\'s the default from here?\n\nRead the existing guide with `get_house_style` before writing, and preserve what\nis already there. `set_house_style` replaces the whole file, so send back the\nfull document, not just your additions.\n\n## The one thing to warn them about\n\n`house-style.md` is written next to the `.aep`, so **the project has to have been\nsaved at least once** \u2014 an unsaved project has no folder to write into, and\n`get_house_style` will say so. If that happens, ask them to save the project\nfirst, then write the guide.\n\nThe file is plain markdown. Tell them where it is and that they can edit it in\nany text editor without going through you.\n\n## Starting point\n\nWhen writing a guide from scratch, this is the shape to fill in. Drop headings\nyou have nothing real to put under.\n\n```markdown\n# House style\n\n## Palette\n| Role | Colour | Notes |\n|---|---|---|\n| Background | `#0B0D12` | |\n| Primary text | `#FFFFFF` | |\n| Accent | `#3DC46E` | Emphasis and positive values |\n| Negative | `#E03333` | |\n\n## Type\n- Headings: Inter Semibold, 56\u201372px, tracking -10\n- Body: Inter Regular, 28\u201334px\n- Left-aligned unless stated otherwise\n\n## Motion\n- Standard in: scale 0 \u2192 108 \u2192 100, easy ease, ~0.4s\n- Standard out: scale \u2192 0, ~0.3s\n- Easy ease on everything; no linear motion unless mechanical\n- Subtle wiggle on position so nothing sits perfectly still\n\n## Layout\n- 1920\xD71080 at 30fps\n- 120px safe margin from every edge\n- Lower thirds sit bottom-left, above the margin\n\n## Rules\n- Never put text directly on footage \u2014 always on a rounded chip\n- Total runtime under 8 seconds\n```'
1722
+ }
1723
+ ];
1724
+ var PROMPTS = [
1725
+ {
1726
+ name: "create-style-guide",
1727
+ description: "Capture or update the look of this project \u2014 palette, type, motion and layout \u2014 into the style guide that shapes everything built afterwards",
1728
+ argumentHint: "[the comp to learn the style from, if you have one in mind]",
1729
+ body: "# Set up the style guide\n\nThe user wants everything you build to look like *their* work rather than\ngeneric motion graphics. That is what the style guide is for. It is saved as\n`house-style.md` next to their After Effects project and read before every build.\n\n`$ARGUMENTS` may name a comp to learn from.\n\nLoad the `style-guide` topic of `ae_guide` and follow it. In short:\n\n1. `get_house_style` first. If one exists, you are editing, not creating \u2014 read\n it, keep what is there, and send the whole merged document back.\n2. Prefer reading the style off work they already like over asking them to\n describe it. Ask which comp, then `get_comp` and `get_layer_full` on the\n layers that carry the look, and `get_keyframes` for the timing signature.\n3. Show what you found in plain language and let them correct it. Adjectives\n from you, numbers in the file.\n4. Write it with `set_house_style` (`overwrite: true` when replacing), then tell\n them where it is and that they can edit it in any text editor.\n\nTwo things that will stop you: the project must have been **saved** at least\nonce, and `set_house_style` replaces the whole file rather than patching it.\n\nIf they have nothing to learn from, do not run a long interview. Ask four\nquestions with concrete options, build one small example, screenshot it, and\nrefine from their reaction \u2014 reacting is much easier than specifying."
1730
+ },
1731
+ {
1732
+ name: "init-after-effects",
1733
+ description: "Set up After Effects from scratch \u2014 install the panel, create a project folder, and capture a house style",
1734
+ argumentHint: "[folder to set the project up in, if you know it]",
1735
+ body: "# Set up After Effects\n\nThe user has just connected these tools and wants to start working. Take them\nall the way from nothing to a first build. They are a motion designer, not a\ndeveloper \u2014 they should never be asked to open a terminal, edit JSON, or read a\nfile path they did not ask about.\n\n`$ARGUMENTS` is the folder they named, if they named one.\n\nWork through these in order, and **stop at the first one that needs something\nfrom them**. Do not run ahead and report four steps at once.\n\n## 1. Install the panel \u2014 before they open After Effects\n\nCall `check_setup`. It is read-only and safe.\n\n**Do not ask them to open After Effects yet.** The panel only loads when AE\nlaunches, so installing while AE is still closed means it is simply there when\nthey open it \u2014 no restart to ask for. If AE is already running you have to ask\nfor one, which is why this step comes first.\n\n- **Everything green** \u2014 say so in one line and move on.\n- **Anything red** \u2014 explain what `setup_panel` is about to do before calling\n it: it copies a small panel into their Adobe extensions folder and switches on\n the Adobe setting that allows unsigned panels. Both are user-level and\n reversible. Call it, then:\n - if `afterEffectsRunning` was false, ask them to **open** After Effects;\n - if it was true, ask them to **quit and reopen** it.\n\n Then `check_setup` again to confirm.\n\nIf it still fails, load the `ae-setup` topic of `ae_guide` and work through it.\nDo not improvise CEP diagnostics.\n\n## 2. Where does the project live?\n\nCall `init_project`. Pass `dir` when you know it \u2014 from `$ARGUMENTS`, or from\nwhat the user says. If you do not know, **ask before calling**: \"which folder\nshould this project live in? A new empty one is fine.\"\n\nNever invent a path. If the tool reports it could not work out where to write,\nthat is exactly what it means \u2014 ask, then call again with `dir`.\n\nTell them the folder it created and what is in it, in one sentence. Do not paste\nthe file list.\n\n## 3. Now bring up After Effects\n\nBy this point the panel is installed, so this is the moment to have them open\nAfter Effects and load the project they want to work on \u2014 or create one and\n**save** it. Saving matters: the style guide is written next to the .aep, and an\nunsaved project has no folder to put it in.\n\n`get_project_summary` will tell you what is open.\n\n## 4. Offer a style guide\n\nCall `get_house_style`. If one already exists, say what it covers and stop \u2014\nthey are set up.\n\nIf not, offer it in their terms:\n\n> Do you want me to set up a style guide? If you point me at a comp that already\n> looks the way you like, I'll read the colours, fonts and timing off it and save\n> them next to your project. Everything I build afterwards follows it.\n\nIf they say yes, load the `style-guide` topic of `ae_guide` and follow it \u2014 read\na comp they nominate, show them what you found in plain language, and write it\nwith `set_house_style`. If they say no, drop it; it can be offered again later.\n\n## 5. Hand over\n\nClose with one short paragraph: they are set up, and here is the kind of thing\nthey can now ask for. Give one concrete example rather than a list of features:\n\n> You're set. Try something like \"build a lower third that says Chapter One and\n> slides in from the left\" \u2014 I'll read the comp, build it, and you'll see it\n> happen in After Effects."
1736
+ },
1737
+ {
1738
+ name: "report-ae-issue",
1739
+ description: "Send a problem you hit with the After Effects tools to the people who maintain them",
1740
+ argumentHint: "[what went wrong, in your own words]",
1741
+ body: '# Report a problem with the After Effects tools\n\nThe user wants to tell the maintainers about something that did not work. They are\nmost likely a motion designer, not a developer: they may never have seen GitHub,\nand they should not have to. Do the technical part yourself and only ask them\nthings they can actually answer.\n\n`$ARGUMENTS` is what they typed, if anything.\n\n## 1. Find out what to report\n\nCall `list_known_issues` with `status: "unreported"`. It returns entries earlier\nsessions wrote down, plus `repo`, `newIssueUrl`, `serverVersion` and `platform`.\n\n- **Entries exist** \u2014 show them as a short numbered list, one plain sentence each\n ("Text layers ended up in the wrong place when a font was missing"), not the\n raw titles. Ask which to send; offer "all of them" as an option.\n- **No entries, but `$ARGUMENTS` describes something** \u2014 work from that. Ask what\n they were trying to do and what happened instead, then `log_issue` it so it is\n recorded before you send it.\n- **Nothing either way** \u2014 say there is nothing recorded to send, and that you\n will write things down as you hit them from now on. Stop there.\n\n## 2. Draft it\n\nShort. A maintainer should understand the problem in fifteen seconds.\n\n**Title:** one line, concrete. `set_temporal_ease fails on Position with "Value\narray does not have 1 elements"` \u2014 not `Keyframe bug`.\n\n**Body:** four short sections, a couple of sentences each.\n\n```markdown\n**What happens**\n<the failing call and the exact error, or the wrong result>\n\n**Why** (if known)\n<one line \u2014 omit this section entirely if unknown>\n\n**Workaround**\n<what got past it>\n\n**Environment**\nafter-effects-mcp <serverVersion> \xB7 <platform> \xB7 After Effects 2026\n```\n\nInclude the failing call and error text verbatim \u2014 that is the part that makes it\nfixable. Leave out the user\'s own content: comp and layer names from their\nproject, file paths, client names, anything about the video they are making. If a\ndetail like that is load-bearing, replace it with a placeholder.\n\n## 3. Show it and get a yes\n\nShow the finished title and body and ask whether to send it. This posts publicly\nto a repository under their name if `gh` is authenticated, so it needs a real\nanswer, not an assumption. If they want to change the wording, change it.\n\n## 4. Send it\n\nTry `gh` first:\n\n```bash\ngh issue create --repo <repo> --title "<title>" --body "<body>"\n```\n\nIf `gh` is missing or not authenticated, do not try to install or configure it.\nBuild a prefilled link instead \u2014 URL-encode the title and body onto\n`<newIssueUrl>` as `?title=\u2026&body=\u2026` \u2014 and give it to them with one line of\ninstruction: open this, it will already be filled in, press the green button. A\nGitHub account is needed to press it; if they do not have one, say so plainly and\noffer to write the text out for them to send another way.\n\n## 5. Close the loop\n\nOn success, call `mark_issue_reported` with the entry `id` and the URL, so no\nlater session asks them to report the same thing twice. Then tell them where it\nwent, in one sentence, with the link.\n\nIf they decline, leave the entry alone \u2014 it stays unreported and can be offered\nagain another day. Do not mark it.'
1742
+ }
1743
+ ];
1744
+ var GUIDE_NAMES = GUIDES.map((g) => g.name);
1745
+ function getGuide(name) {
1746
+ return GUIDES.find((g) => g.name === name);
1747
+ }
1748
+ function getPrompt(name) {
1749
+ return PROMPTS.find((p) => p.name === name);
1750
+ }
1751
+ var SERVER_INSTRUCTIONS = "You are driving a live After Effects session through this server. The user sees\nevery change as it happens and every call is a real undo step in their project.\n\nSix things that are not obvious from the tool list:\n\n1. Read the house style before you build. `get_house_style` returns the user's\n palette, type and motion defaults for the project that is open. One cheap call.\n2. Orient before you touch anything. `get_layer_full` returns a layer's\n transforms with keyframes and expressions, every effect and parameter, masks,\n markers and visible bounds in a single call \u2014 prefer it over several narrow reads.\n3. Identify by id, never by index. Layer `index` shifts whenever layers are\n added, deleted or reordered. Carry `(compId, layerId)`.\n4. Verify by reading properties back, not by screenshotting. Screenshots are\n one-off diagnostics: 2-3 across an animation, and always pass `downsample`\n (2 for 1080p, 3-4 for 4K) or a single frame can fill your context.\n5. Bulk work goes through `run_batch` \u2014 one ExtendScript pass, one undo step.\n6. When a tool fails in a way you do not understand, call `list_known_issues`\n before guessing; an earlier session may have solved it already. When you solve\n a new one, `log_issue` it.\n\nCall `ae_guide` for the full guidance on any of this \u2014 topics: ae-setup, after-effects, style-guide.\nIf a tool reports it cannot reach After Effects, call `check_setup` and relay\nits `nextSteps` verbatim; do not diagnose CEP by hand.";
1752
+
1753
+ // src/issues/journal.ts
1754
+ import fs7 from "node:fs";
1755
+ import os4 from "node:os";
1756
+ import path8 from "node:path";
1757
+ var REPO = "Engine-Room-Games/after-effects-mcp";
1758
+ var NEW_ISSUE_URL = `https://github.com/${REPO}/issues/new`;
1759
+ var SECTION_SYMPTOM = "What went wrong";
1760
+ var SECTION_CAUSE = "Why";
1761
+ var SECTION_WORKAROUND = "What worked";
1762
+ function journalRoot() {
1763
+ const override = process.env.AE_MCP_HOME?.trim();
1764
+ if (override && override.length > 0) return { dir: override, scope: "project" };
1765
+ const cwd = process.cwd();
1766
+ const unusable = cwd === path8.parse(cwd).root || cwd === os4.homedir();
1767
+ if (!unusable) {
1768
+ try {
1769
+ fs7.accessSync(cwd, fs7.constants.W_OK);
1770
+ return { dir: path8.join(cwd, ".ae-mcp"), scope: "project" };
1771
+ } catch {
1772
+ }
1773
+ }
1774
+ return { dir: path8.join(os4.homedir(), ".after-effects-mcp"), scope: "home" };
1775
+ }
1776
+ function journalDir() {
1777
+ return path8.join(journalRoot().dir, "issues");
1778
+ }
1779
+ function ensureJournalDir() {
1780
+ const { dir } = journalRoot();
1781
+ const issues = path8.join(dir, "issues");
1782
+ fs7.mkdirSync(issues, { recursive: true });
1783
+ const ignore = path8.join(dir, ".gitignore");
1784
+ if (!fs7.existsSync(ignore)) fs7.writeFileSync(ignore, "*\n", "utf8");
1785
+ return issues;
1786
+ }
1787
+ function slugify(text) {
1788
+ const slug = text.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60).replace(/-+$/g, "");
1789
+ return slug.length > 0 ? slug : `issue-${Date.now()}`;
1790
+ }
1791
+ function today() {
1792
+ return (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
1793
+ }
1794
+ function oneLine(text) {
1795
+ return text.replace(/\s+/g, " ").trim();
1796
+ }
1797
+ function entryPath(id) {
1798
+ const dir = path8.resolve(journalDir());
1799
+ const file = path8.resolve(dir, `${id}.md`);
1800
+ if (path8.dirname(file) !== dir) throw new Error(`Invalid issue id: ${id}`);
1801
+ return file;
1802
+ }
1803
+ function render(entry) {
1804
+ const lines = [
1805
+ "---",
1806
+ `id: ${entry.id}`,
1807
+ `title: ${oneLine(entry.title)}`,
1808
+ `tools: ${entry.tools.join(", ")}`,
1809
+ `firstSeen: ${entry.firstSeen}`,
1810
+ `lastSeen: ${entry.lastSeen}`,
1811
+ `occurrences: ${entry.occurrences}`,
1812
+ `reported: ${entry.reported}`,
1813
+ `issueUrl: ${entry.issueUrl ?? ""}`,
1814
+ "---",
1815
+ "",
1816
+ `## ${SECTION_SYMPTOM}`,
1817
+ "",
1818
+ entry.symptom.trim(),
1819
+ ""
1820
+ ];
1821
+ if (entry.cause && entry.cause.trim().length > 0) {
1822
+ lines.push(`## ${SECTION_CAUSE}`, "", entry.cause.trim(), "");
1823
+ }
1824
+ lines.push(`## ${SECTION_WORKAROUND}`, "", entry.workaround.trim(), "");
1825
+ return lines.join("\n");
1826
+ }
1827
+ function readSections(body) {
1828
+ const marks = [];
1829
+ for (const heading of [SECTION_SYMPTOM, SECTION_CAUSE, SECTION_WORKAROUND]) {
1830
+ const m = new RegExp(`^##[ \\t]+${heading}[ \\t]*$`, "im").exec(body);
1831
+ if (m) marks.push({ key: heading.toLowerCase(), from: m.index, to: m.index + m[0].length });
1832
+ }
1833
+ marks.sort((a, b) => a.from - b.from);
1834
+ const sections = /* @__PURE__ */ new Map();
1835
+ marks.forEach((mark, i) => {
1836
+ const end = i + 1 < marks.length ? marks[i + 1].from : body.length;
1837
+ sections.set(mark.key, body.slice(mark.to, end).trim());
1838
+ });
1839
+ return sections;
1840
+ }
1841
+ function parse(text, fallbackId) {
1842
+ const meta = {};
1843
+ let body = text;
1844
+ const fm = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(text);
1845
+ if (fm) {
1846
+ for (const line of fm[1].split(/\r?\n/)) {
1847
+ const sep = line.indexOf(":");
1848
+ if (sep <= 0) continue;
1849
+ meta[line.slice(0, sep).trim()] = line.slice(sep + 1).trim();
1850
+ }
1851
+ body = text.slice(fm[0].length);
1852
+ }
1853
+ const sections = readSections(body);
1854
+ const occurrences = Number.parseInt(meta.occurrences ?? "1", 10);
1855
+ return {
1856
+ id: meta.id || fallbackId,
1857
+ title: meta.title || fallbackId.replace(/-/g, " "),
1858
+ tools: (meta.tools ?? "").split(",").map((t) => t.trim()).filter((t) => t.length > 0),
1859
+ firstSeen: meta.firstSeen || "",
1860
+ lastSeen: meta.lastSeen || meta.firstSeen || "",
1861
+ occurrences: Number.isFinite(occurrences) && occurrences > 0 ? occurrences : 1,
1862
+ reported: meta.reported === "true",
1863
+ issueUrl: meta.issueUrl && meta.issueUrl.length > 0 ? meta.issueUrl : void 0,
1864
+ // A hand-edited file with no recognised headings still has its text kept,
1865
+ // rather than being silently reduced to an empty entry.
1866
+ symptom: sections.get(SECTION_SYMPTOM.toLowerCase()) ?? (sections.size === 0 ? body.trim() : ""),
1867
+ cause: sections.get(SECTION_CAUSE.toLowerCase()) || void 0,
1868
+ workaround: sections.get(SECTION_WORKAROUND.toLowerCase()) ?? ""
1869
+ };
1870
+ }
1871
+ function readEntry(file) {
1872
+ try {
1873
+ return parse(fs7.readFileSync(file, "utf8"), path8.basename(file, ".md"));
1874
+ } catch {
1875
+ return null;
1876
+ }
1877
+ }
1878
+ function logIssue(input) {
1879
+ const id = slugify(input.title);
1880
+ const file = entryPath(id);
1881
+ const existing = fs7.existsSync(file) ? readEntry(file) : null;
1882
+ const entry = {
1883
+ id,
1884
+ title: oneLine(input.title),
1885
+ tools: input.tools ?? existing?.tools ?? [],
1886
+ firstSeen: existing?.firstSeen || today(),
1887
+ lastSeen: today(),
1888
+ // Repeats are worth counting: an entry seen five times is the one most
1889
+ // worth reporting, and the count is the only evidence of that.
1890
+ occurrences: (existing?.occurrences ?? 0) + 1,
1891
+ // Reporting state belongs to the entry, not to this sighting — a fresh
1892
+ // description of a known problem must not un-report it.
1893
+ reported: existing?.reported ?? false,
1894
+ issueUrl: existing?.issueUrl,
1895
+ symptom: input.symptom,
1896
+ // A cause worked out once is not lost because a later sighting was logged
1897
+ // without one.
1898
+ cause: input.cause ?? existing?.cause,
1899
+ workaround: input.workaround
1900
+ };
1901
+ ensureJournalDir();
1902
+ fs7.writeFileSync(file, render(entry), "utf8");
1903
+ return {
1904
+ id,
1905
+ path: file,
1906
+ occurrences: entry.occurrences,
1907
+ previouslyLogged: existing !== null,
1908
+ reported: entry.reported,
1909
+ issueUrl: entry.issueUrl
1910
+ };
1911
+ }
1912
+ function listIssues(status = "all", tool) {
1913
+ const { scope } = journalRoot();
1914
+ const dir = journalDir();
1915
+ let entries = [];
1916
+ try {
1917
+ entries = fs7.readdirSync(dir).filter((f) => f.endsWith(".md")).map((f) => readEntry(path8.join(dir, f))).filter((e) => e !== null);
1918
+ } catch {
1919
+ entries = [];
1920
+ }
1921
+ const wanted = tool?.trim().toLowerCase();
1922
+ const filtered = entries.filter((e) => {
1923
+ const byStatus = status === "all" ? true : status === "reported" ? e.reported : !e.reported;
1924
+ if (!byStatus) return false;
1925
+ if (!wanted) return true;
1926
+ return e.tools.some((t) => t.toLowerCase() === wanted) || e.title.toLowerCase().includes(wanted);
1927
+ });
1928
+ filtered.sort((a, b) => b.lastSeen.localeCompare(a.lastSeen) || b.occurrences - a.occurrences);
1929
+ return {
1930
+ dir,
1931
+ scope,
1932
+ repo: REPO,
1933
+ newIssueUrl: NEW_ISSUE_URL,
1934
+ serverVersion: packageVersion(),
1935
+ platform: process.platform,
1936
+ count: filtered.length,
1937
+ issues: filtered
1938
+ };
1939
+ }
1940
+ function markReported(id, url) {
1941
+ const file = entryPath(slugify(id));
1942
+ const entry = fs7.existsSync(file) ? readEntry(file) : null;
1943
+ if (!entry) {
1944
+ const known = listIssues("all").issues.map((e) => e.id);
1945
+ throw new Error(
1946
+ `No journal entry with id "${id}".` + (known.length > 0 ? ` Known ids: ${known.join(", ")}` : "")
1947
+ );
1948
+ }
1949
+ entry.reported = true;
1950
+ if (url) entry.issueUrl = oneLine(url);
1951
+ fs7.writeFileSync(file, render(entry), "utf8");
1952
+ return entry;
1953
+ }
1954
+
1459
1955
  // src/util/pngImage.ts
1460
1956
  function imageContent(meta, base64) {
1461
1957
  return {
@@ -1467,22 +1963,42 @@ function imageContent(meta, base64) {
1467
1963
  }
1468
1964
 
1469
1965
  // src/server.ts
1966
+ import { fileURLToPath as fileURLToPath2 } from "node:url";
1470
1967
  var { OpSchemas: OpSchemas2 } = schemas_exports;
1471
1968
  var VISION_OPS = /* @__PURE__ */ new Set(["screenshot_frame", "screenshot_layer"]);
1472
1969
  var ASYNC_OPS = /* @__PURE__ */ new Set(["run_batch"]);
1473
- var SERVER_OPS = /* @__PURE__ */ new Set(["await_job", "get_job", "cancel_job", "check_setup", "setup_panel"]);
1970
+ var SERVER_OPS = /* @__PURE__ */ new Set([
1971
+ "await_job",
1972
+ "get_job",
1973
+ "cancel_job",
1974
+ "check_setup",
1975
+ "setup_panel",
1976
+ "init_project",
1977
+ "ae_guide",
1978
+ "log_issue",
1979
+ "list_known_issues",
1980
+ "mark_issue_reported"
1981
+ ]);
1982
+ var GUIDE_URI_PREFIX = "ae://guide/";
1474
1983
  var AwaitJobSchema = schemas_exports.AwaitJob;
1475
1984
  var GetJobSchema = schemas_exports.GetJob;
1476
1985
  var CancelJobSchema = schemas_exports.CancelJob;
1477
1986
  function createServer() {
1478
1987
  const server = new Server(
1479
- { name: "after-effects-mcp", version: "0.1.1" },
1480
- { capabilities: { tools: {}, logging: {} } }
1988
+ { name: "after-effects-mcp", version: "0.2.0" },
1989
+ {
1990
+ capabilities: { tools: {}, logging: {}, prompts: {}, resources: {} },
1991
+ // Clients that honour this fold it into the system prompt, which is the
1992
+ // only way non-Claude clients get the cross-cutting guidance at all —
1993
+ // skills and slash commands do not exist outside Claude's own clients.
1994
+ instructions: SERVER_INSTRUCTIONS
1995
+ }
1481
1996
  );
1482
1997
  const bridge = new HttpClient();
1483
1998
  const jobs = new JobManager();
1484
1999
  const ws = new WsClient(bridge.port, jobs);
1485
2000
  ws.start();
2001
+ const panelGate = createPanelGate(bridge);
1486
2002
  bridge.health().then(
1487
2003
  (h) => logger.info(`Bridge healthy on port ${h.port}`),
1488
2004
  (e) => logger.warn(`Bridge not reachable yet: ${e.message}`)
@@ -1502,6 +2018,41 @@ function createServer() {
1502
2018
  });
1503
2019
  return { tools };
1504
2020
  });
2021
+ server.setRequestHandler(ListPromptsRequestSchema, async () => ({
2022
+ prompts: PROMPTS.map((p) => ({
2023
+ name: p.name,
2024
+ description: p.description,
2025
+ arguments: p.argumentHint ? [{ name: "arguments", description: p.argumentHint, required: false }] : []
2026
+ }))
2027
+ }));
2028
+ server.setRequestHandler(GetPromptRequestSchema, async (req) => {
2029
+ const prompt = getPrompt(req.params.name);
2030
+ if (!prompt) throw new Error(`Unknown prompt: ${req.params.name}`);
2031
+ const given = req.params.arguments?.arguments ?? "";
2032
+ return {
2033
+ description: prompt.description,
2034
+ messages: [
2035
+ {
2036
+ role: "user",
2037
+ content: { type: "text", text: prompt.body.replaceAll("$ARGUMENTS", given) }
2038
+ }
2039
+ ]
2040
+ };
2041
+ });
2042
+ server.setRequestHandler(ListResourcesRequestSchema, async () => ({
2043
+ resources: GUIDES.map((g) => ({
2044
+ uri: `${GUIDE_URI_PREFIX}${g.name}`,
2045
+ name: g.name,
2046
+ description: g.description,
2047
+ mimeType: "text/markdown"
2048
+ }))
2049
+ }));
2050
+ server.setRequestHandler(ReadResourceRequestSchema, async (req) => {
2051
+ const uri = req.params.uri;
2052
+ const guide = uri.startsWith(GUIDE_URI_PREFIX) ? getGuide(uri.slice(GUIDE_URI_PREFIX.length)) : void 0;
2053
+ if (!guide) throw new Error(`Unknown resource: ${uri}`);
2054
+ return { contents: [{ uri, mimeType: "text/markdown", text: guide.body }] };
2055
+ });
1505
2056
  server.setRequestHandler(CallToolRequestSchema, async (req, extra) => {
1506
2057
  const name = req.params.name;
1507
2058
  const rawArgs = req.params.arguments ?? {};
@@ -1533,8 +2084,41 @@ function createServer() {
1533
2084
  if (name === "setup_panel") {
1534
2085
  const a = schemas_exports.SetupPanel.parse(rawArgs);
1535
2086
  const installed = await installPanel({ enableDebugMode: a.enableDebugMode, force: a.force });
2087
+ panelGate.invalidate();
1536
2088
  return textResult({ ...installed, setup: await checkSetup() });
1537
2089
  }
2090
+ if (name === "init_project") {
2091
+ const a = schemas_exports.InitProject.parse(rawArgs);
2092
+ const client = !a.client || a.client === "auto" ? detectClient(server.getClientVersion()?.name) : a.client;
2093
+ return textResult(
2094
+ scaffold({
2095
+ dir: a.dir,
2096
+ name: a.name,
2097
+ client,
2098
+ withMcpConfig: a.withMcpConfig ?? false,
2099
+ roots: a.dir ? void 0 : await clientRoots(server)
2100
+ })
2101
+ );
2102
+ }
2103
+ if (name === "ae_guide") {
2104
+ const a = schemas_exports.AeGuide.parse(rawArgs);
2105
+ const guide = getGuide(a.topic);
2106
+ if (!guide) return errorResult(`Unknown guide topic: ${a.topic}`);
2107
+ return { content: [{ type: "text", text: guide.body }] };
2108
+ }
2109
+ if (name === "log_issue") {
2110
+ const a = schemas_exports.LogIssue.parse(rawArgs);
2111
+ return textResult(logIssue(a));
2112
+ }
2113
+ if (name === "list_known_issues") {
2114
+ const a = schemas_exports.ListKnownIssues.parse(rawArgs);
2115
+ return textResult(listIssues(a.status ?? "all", a.tool));
2116
+ }
2117
+ if (name === "mark_issue_reported") {
2118
+ const a = schemas_exports.MarkIssueReported.parse(rawArgs);
2119
+ const entry = markReported(a.id, a.url);
2120
+ return textResult({ ok: true, id: entry.id, reported: true, issueUrl: entry.issueUrl });
2121
+ }
1538
2122
  } catch (e) {
1539
2123
  return errorResult(e.message);
1540
2124
  }
@@ -1545,6 +2129,8 @@ function createServer() {
1545
2129
  } catch (e) {
1546
2130
  return errorResult(`Invalid arguments for ${name}: ${e.message}`);
1547
2131
  }
2132
+ const staleness = await panelGate.check();
2133
+ if (staleness) return errorResult(staleness);
1548
2134
  try {
1549
2135
  const result = await bridge.runOp(name, args, progressToken);
1550
2136
  if (ASYNC_OPS.has(name) && isAsyncEnvelope(result)) {
@@ -1583,12 +2169,54 @@ function createServer() {
1583
2169
  return textResult(result);
1584
2170
  } catch (e) {
1585
2171
  if (e instanceof BridgeUnreachableError) return errorResult(e.message);
1586
- if (e instanceof AeError) return errorResult(`AE: ${e.message}${e.line ? ` (line ${e.line})` : ""}`);
2172
+ if (e instanceof AeError) {
2173
+ if (/^Unknown op: /.test(e.message)) {
2174
+ panelGate.invalidate();
2175
+ return errorResult(unknownOpMessage(name));
2176
+ }
2177
+ return errorResult(`AE: ${e.message}${e.line ? ` (line ${e.line})` : ""}`);
2178
+ }
1587
2179
  return errorResult(e.message);
1588
2180
  }
1589
2181
  });
1590
2182
  return server;
1591
2183
  }
2184
+ function createPanelGate(bridge) {
2185
+ const RECHECK_MS = 6e4;
2186
+ let verdict = null;
2187
+ let checkedAt = 0;
2188
+ return {
2189
+ /** The message to return instead of forwarding, or null to proceed. */
2190
+ async check() {
2191
+ if (verdict !== null) return verdict;
2192
+ if (Date.now() - checkedAt < RECHECK_MS) return null;
2193
+ try {
2194
+ const health = await bridge.health();
2195
+ const assessment = assessPanel(health.bundleHash, installedBundleHash(installedPanelDir()));
2196
+ checkedAt = Date.now();
2197
+ verdict = assessment.state === "current" || assessment.state === "unknown" ? null : assessment.message;
2198
+ if (assessment.state === "unknown" && assessment.message) logger.warn(assessment.message);
2199
+ return verdict;
2200
+ } catch {
2201
+ return null;
2202
+ }
2203
+ },
2204
+ invalidate() {
2205
+ verdict = null;
2206
+ checkedAt = 0;
2207
+ }
2208
+ };
2209
+ }
2210
+ async function clientRoots(server) {
2211
+ if (!server.getClientCapabilities()?.roots) return void 0;
2212
+ try {
2213
+ const { roots } = await server.listRoots();
2214
+ return roots.map((r) => r.uri).filter((uri) => uri.startsWith("file://")).map((uri) => fileURLToPath2(uri));
2215
+ } catch (e) {
2216
+ logger.warn(`Client advertised roots but listing them failed: ${e.message}`);
2217
+ return void 0;
2218
+ }
2219
+ }
1592
2220
  function textResult(value) {
1593
2221
  return {
1594
2222
  content: [{ type: "text", text: JSON.stringify(value, null, 2) }]
@@ -1663,7 +2291,7 @@ ${USAGE}`);
1663
2291
  await server.connect(transport);
1664
2292
  logger.info("MCP server running on stdio");
1665
2293
  }
1666
- var VERSION = "0.1.1";
2294
+ var VERSION = "0.2.0";
1667
2295
  main().catch((e) => {
1668
2296
  logger.error("fatal", e.message);
1669
2297
  process.exit(1);