@engine-room/after-effects-mcp 0.4.0 → 0.5.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@engine-room/after-effects-mcp",
3
- "version": "0.4.0",
3
+ "version": "0.5.1",
4
4
  "type": "module",
5
5
  "description": "Control Adobe After Effects with AI — describe the animation you want and it gets built: layers, keyframes, effects, expressions and text, all editable afterwards.",
6
6
  "license": "MIT",
@@ -1,8 +1,8 @@
1
1
  <?xml version="1.0" encoding="UTF-8"?>
2
- <ExtensionManifest Version="11.0" ExtensionBundleId="games.engine-room.ae-mcp" ExtensionBundleVersion="0.4.0"
2
+ <ExtensionManifest Version="11.0" ExtensionBundleId="games.engine-room.ae-mcp" ExtensionBundleVersion="0.5.1"
3
3
  ExtensionBundleName="AE MCP Bridge" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
4
4
  <ExtensionList>
5
- <Extension Id="games.engine-room.ae-mcp.panel" Version="0.4.0" />
5
+ <Extension Id="games.engine-room.ae-mcp.panel" Version="0.5.1" />
6
6
  </ExtensionList>
7
7
  <ExecutionEnvironment>
8
8
  <HostList>
@@ -212,10 +212,137 @@
212
212
  }
213
213
 
214
214
  // ---------- Port file (so MCP server can discover) ----------
215
+ // Written only once a port is actually bound, and only with that port. The
216
+ // file is a hint for the server, never the authority — the socket is — and
217
+ // the one way it lies is by describing a bind that did not happen or a panel
218
+ // that has since gone (issue #92). So it is written after `listen` succeeds
219
+ // and removed again on unload, and never with a port this panel merely tried.
220
+ var portDir = path.join(os.homedir(), ".engineroom-ae-mcp");
221
+ var portFile = path.join(portDir, "port");
222
+ var configFile = path.join(portDir, "config.json");
223
+
215
224
  function writePortFile(port) {
216
- var dir = path.join(os.homedir(), ".engineroom-ae-mcp");
217
- try { fs.mkdirSync(dir, { recursive: true }); } catch (e) {}
218
- try { fs.writeFileSync(path.join(dir, "port"), String(port), "utf8"); } catch (e) { log("warn", "could not write port file: " + e.message); }
225
+ try { fs.mkdirSync(portDir, { recursive: true }); } catch (e) {}
226
+ try { fs.writeFileSync(portFile, String(port), "utf8"); } catch (e) { log("warn", "could not write port file: " + e.message); }
227
+ }
228
+
229
+ // Only this panel's own entry. Another panel may have written the file since
230
+ // — a second After Effects instance, say — and its port is not ours to erase.
231
+ function removePortFile(port) {
232
+ try {
233
+ if (fs.readFileSync(portFile, "utf8").trim() === String(port)) fs.unlinkSync(portFile);
234
+ } catch (e) {}
235
+ }
236
+
237
+ // ---------- Bridge token (issue #106) ----------
238
+ // Why a token and not a CORS rule, and why per port: docs/fragile-areas-bridge.md.
239
+ // One per panel session. 384 is 0600 for engines that predate 0o literals.
240
+ var sessionToken = crypto.randomBytes(32).toString("hex");
241
+ var TOKEN_MODE = 384;
242
+
243
+ function tokenFile(port) { return path.join(portDir, "token-" + port); }
244
+
245
+ function writeTokenFile(port) {
246
+ try { fs.mkdirSync(portDir, { recursive: true }); } catch (e) {}
247
+ try {
248
+ fs.writeFileSync(tokenFile(port), sessionToken, { encoding: "utf8", mode: TOKEN_MODE });
249
+ // writeFileSync applies `mode` only when it creates the file.
250
+ try { fs.chmodSync(tokenFile(port), TOKEN_MODE); } catch (e) {}
251
+ } catch (e) {
252
+ log("error", "could not write the bridge token file " + tokenFile(port) + ": " + e.message +
253
+ " — the MCP server will not be able to authenticate, and every tool call will be refused.");
254
+ }
255
+ }
256
+
257
+ // Same rule as the port file: only this panel's own entry.
258
+ function removeTokenFile(port) {
259
+ try {
260
+ if (fs.readFileSync(tokenFile(port), "utf8").trim() === sessionToken) fs.unlinkSync(tokenFile(port));
261
+ } catch (e) {}
262
+ }
263
+
264
+ function sameToken(given) {
265
+ if (typeof given !== "string" || given.length !== sessionToken.length) return false;
266
+ try {
267
+ return crypto.timingSafeEqual(Buffer.from(given, "utf8"), Buffer.from(sessionToken, "utf8"));
268
+ } catch (e) {
269
+ return false;
270
+ }
271
+ }
272
+
273
+ // The token is what holds. The Origin check is depth: browsers always send one,
274
+ // Node's fetch and `ws` never do.
275
+ function authFailure(req) {
276
+ var h = (req && req.headers) || {};
277
+ if (h.origin) return "it came from a web page (Origin: " + h.origin + ")";
278
+ if (!sameToken(h["x-ae-mcp-token"])) return "the bridge token was missing or did not match";
279
+ return null;
280
+ }
281
+
282
+ // Written for an MCP server older than this panel: it relays `error` verbatim,
283
+ // so the remedy has to be in here.
284
+ function denialMessage() {
285
+ return "This After Effects bridge requires a token and this request did not carry a valid one. " +
286
+ "Nothing was run in After Effects. The panel writes the token beside its port file, in " + portDir +
287
+ ", each time it binds; a local process can read it and a web page cannot, which is the entire point " +
288
+ "(issue #106). If you are the MCP server: you are older than the panel After Effects is running — " +
289
+ "update after-effects-mcp to 0.5.1 or newer and reconnect it.";
290
+ }
291
+
292
+ // Rate-limited: a page firing in a loop must not fill the panel's log.
293
+ var denied = 0;
294
+ var deniedLoggedAt = 0;
295
+ function noteDenial(what, why) {
296
+ denied += 1;
297
+ var now = Date.now();
298
+ if (denied === 1 || now - deniedLoggedAt > 10000) {
299
+ deniedLoggedAt = now;
300
+ log("warn", "refused a request to " + what + ": " + why +
301
+ (denied > 1 ? " (" + denied + " refused so far)" : "") +
302
+ ". If this is the MCP server rather than a web page, it is older than this panel: update it and reconnect.");
303
+ }
304
+ }
305
+
306
+ // ---------- Config ----------
307
+ // A CEP panel cannot read an environment variable the user set: it runs in
308
+ // After Effects' process, with Adobe's environment. So its two knobs live in
309
+ // a JSON file beside the port file, ~/.engineroom-ae-mcp/config.json:
310
+ //
311
+ // { "port": 7777, "allowPortWalk": false }
312
+ //
313
+ // port the port to bind. Default 7777. 0 binds an ephemeral port,
314
+ // which is what the offline tests use.
315
+ // allowPortWalk when the port is held by another AE MCP panel, move to the
316
+ // next free one instead of waiting for it. For people who run
317
+ // two After Effects instances at once. Off by default: on one
318
+ // machine the holder is a zombie, and walking around it is
319
+ // what issue #92 was.
320
+ //
321
+ // Anything unreadable is logged and ignored; the defaults are what shipped.
322
+ var DEFAULT_PORT = 7777;
323
+ function readConfig() {
324
+ var cfg = { port: DEFAULT_PORT, allowPortWalk: false, bindRetryMs: 2000, source: null };
325
+ var raw;
326
+ try { raw = fs.readFileSync(configFile, "utf8"); } catch (e) { return cfg; }
327
+ var parsed;
328
+ try { parsed = JSON.parse(raw); }
329
+ catch (e) { log("warn", "ignoring " + configFile + " — not valid JSON (" + e.message + ")"); return cfg; }
330
+ if (!parsed || typeof parsed !== "object") {
331
+ log("warn", "ignoring " + configFile + " — expected a JSON object");
332
+ return cfg;
333
+ }
334
+ cfg.source = configFile;
335
+ if (parsed.port !== undefined) {
336
+ if (typeof parsed.port === "number" && parsed.port >= 0 && parsed.port <= 65535 && parsed.port === Math.floor(parsed.port)) cfg.port = parsed.port;
337
+ else log("warn", "ignoring \"port\" in " + configFile + " — expected a whole number 0-65535, got " + JSON.stringify(parsed.port));
338
+ }
339
+ if (parsed.allowPortWalk !== undefined) {
340
+ if (typeof parsed.allowPortWalk === "boolean") cfg.allowPortWalk = parsed.allowPortWalk;
341
+ else log("warn", "ignoring \"allowPortWalk\" in " + configFile + " — expected true or false");
342
+ }
343
+ // Undocumented on purpose: how often to retry a held port. Tests shorten it.
344
+ if (typeof parsed.bindRetryMs === "number" && parsed.bindRetryMs >= 50) cfg.bindRetryMs = parsed.bindRetryMs;
345
+ return cfg;
219
346
  }
220
347
 
221
348
  // ---------- WS broadcast ----------
@@ -806,19 +933,30 @@
806
933
  return new Promise(function (resolve, reject) {
807
934
  var server = http.createServer(function (req, res) {
808
935
  var url = req.url || "/";
809
- res.setHeader("Access-Control-Allow-Origin", "*");
810
- res.setHeader("Access-Control-Allow-Headers", "content-type");
936
+
937
+ // No CORS headers, deliberately: nothing that legitimately calls this
938
+ // bridge is a web page (issue #106).
811
939
 
812
940
  if (url === "/health") {
813
941
  res.statusCode = 200;
814
942
  res.setHeader("content-type", "application/json");
815
943
  // bundleHash identifies the code this panel is *running*, which is what
816
944
  // the server needs to know before sending an op the panel may predate.
945
+ // `ok` + `bundleHash` is also how another copy of this panel, and the
946
+ // server's discovery, recognise this as an AE MCP panel — see
947
+ // isOurHealth below and isPanelHealth in the server's discovery.ts.
948
+ // The port is read off the socket, not the argument: port 0 binds an
949
+ // ephemeral one and the argument would say 0.
950
+ var addr = server.address();
817
951
  res.end(JSON.stringify({
818
952
  ok: true,
819
- port: port,
953
+ port: addr && addr.port ? addr.port : port,
820
954
  bundleLoaded: true,
821
955
  bundleHash: loadedBundleHash,
956
+ // The gate exists, never anything about the token. Absent on a
957
+ // pre-#106 panel, which is how the server tells "needs a token"
958
+ // from "too old to want one".
959
+ auth: true,
822
960
  ts: Date.now()
823
961
  }));
824
962
  return;
@@ -829,6 +967,17 @@
829
967
  return;
830
968
  }
831
969
 
970
+ // Every route below reaches After Effects. Checked before the body is
971
+ // read, so an unidentified caller cannot make us buffer anything.
972
+ var denial = authFailure(req);
973
+ if (denial) {
974
+ noteDenial(url, denial);
975
+ res.statusCode = 403;
976
+ res.setHeader("content-type", "application/json");
977
+ res.end(JSON.stringify({ ok: false, error: denialMessage(), code: "bridge_unauthorized" }));
978
+ return;
979
+ }
980
+
832
981
  var bufs = [];
833
982
  req.on("data", function (c) { bufs.push(c); });
834
983
  req.on("end", function () {
@@ -887,26 +1036,171 @@
887
1036
  });
888
1037
  }
889
1038
 
890
- function startServers(startPort) {
891
- var tries = 0;
892
- function attempt(p) {
893
- tries++;
1039
+ // ---------- Who holds the port? ----------
1040
+ // The same test the server's discovery makes (isPanelHealth in discovery.ts):
1041
+ // `ok` plus `bundleHash` — or `bundleLoaded`, on a panel from before the hash
1042
+ // existed — is what nothing else on a loopback port would answer with.
1043
+ function isOurHealth(body) {
1044
+ return !!body && typeof body === "object" && body.ok === true &&
1045
+ ("bundleHash" in body || "bundleLoaded" in body);
1046
+ }
1047
+
1048
+ // Never rejects: every outcome is a classification the caller acts on.
1049
+ // panel — another AE MCP panel; its health body is attached
1050
+ // other — something answered, but not as the panel
1051
+ // silent — accepted the connection and said nothing, or errored
1052
+ function identifyHolder(port) {
1053
+ return new Promise(function (resolve) {
1054
+ var done = false;
1055
+ function finish(r) { if (!done) { done = true; resolve(r); } }
1056
+ var req = http.get({ host: "127.0.0.1", port: port, path: "/health", timeout: 1500 }, function (res) {
1057
+ var bufs = [];
1058
+ res.on("data", function (c) { bufs.push(c); });
1059
+ res.on("end", function () {
1060
+ var body = null;
1061
+ try { body = JSON.parse(Buffer.concat(bufs).toString("utf8")); } catch (e) {}
1062
+ if (res.statusCode === 200 && isOurHealth(body)) finish({ kind: "panel", health: body });
1063
+ else finish({ kind: "other", detail: "answered HTTP " + res.statusCode + (body ? " with an unfamiliar body" : " with no JSON") });
1064
+ });
1065
+ res.on("error", function (e) { finish({ kind: "silent", detail: e.message }); });
1066
+ });
1067
+ req.on("timeout", function () { req.destroy(); finish({ kind: "silent", detail: "accepted the connection but did not answer within 1.5s" }); });
1068
+ req.on("error", function (e) { finish({ kind: "silent", detail: e.message }); });
1069
+ });
1070
+ }
1071
+
1072
+ // 7777 through 7799 was the old ceiling; kept as a distance so a configured
1073
+ // start port walks the same number of steps.
1074
+ var WALK_LIMIT = 22;
1075
+
1076
+ /**
1077
+ * Bind the configured port, and decide what to do when it is taken.
1078
+ *
1079
+ * It used to walk: EADDRINUSE, try the next port, up to 7799, then write
1080
+ * whatever it got to the port file. On one machine nothing else ever holds
1081
+ * 7777, so the thing being walked around was always a previous instance of
1082
+ * this same panel — a CEPHtmlEngine that lingered across an After Effects
1083
+ * relaunch, or a second panel window — and the walk routed round it in
1084
+ * silence, leaving the port file as a second source of truth that could
1085
+ * disagree with the socket. Four incidents in a week (issue #92).
1086
+ *
1087
+ * So the holder is identified first. If it is one of us, this panel does not
1088
+ * move: it says so in its status and log, and retries the bind on an interval
1089
+ * so it takes the port over the moment the other one exits, with no restart.
1090
+ * If it is something else, or answers nothing, the walk happens as before —
1091
+ * loudly, naming the holder and the port chosen, because that is the one
1092
+ * case where the port file legitimately says something other than 7777.
1093
+ * `allowPortWalk` in config.json opts back into walking past our own panel,
1094
+ * for people who really run two After Effects instances.
1095
+ */
1096
+ function startServers(cfg) {
1097
+ var startPort = cfg.port;
1098
+ var retryMs = cfg.bindRetryMs;
1099
+ var waits = 0;
1100
+
1101
+ function bindWith(p) {
894
1102
  return startHttp(p).then(function (server) {
1103
+ // Read back rather than assumed: port 0 binds an ephemeral one.
1104
+ var bound = server.address().port;
1105
+ // The socket is already accepting, so the token must be on disk before
1106
+ // this promise resolves — not later in the boot.
1107
+ writeTokenFile(bound);
895
1108
  // Mount WS on the same HTTP server.
896
- var wss = new WebSocket.Server({ server: server, path: "/events" });
1109
+ var wss = new WebSocket.Server({
1110
+ server: server,
1111
+ path: "/events",
1112
+ // WebSockets ignore the same-origin policy entirely, so without this
1113
+ // any page could watch the user's event stream (issue #106).
1114
+ verifyClient: function (info, cb) {
1115
+ var why = authFailure(info.req);
1116
+ if (!why) return cb(true);
1117
+ noteDenial("/events", why);
1118
+ cb(false, 403, "Forbidden");
1119
+ },
1120
+ });
897
1121
  wss.on("connection", function (ws) {
898
1122
  wsClients.add(ws);
899
1123
  ws.on("close", function () { wsClients.delete(ws); });
900
1124
  });
901
- return { port: p, server: server, wss: wss };
902
- }).catch(function (e) {
903
- if (e.code === "EADDRINUSE" && tries < 23 && p < 7799) {
904
- return attempt(p + 1);
905
- }
906
- throw e;
1125
+ return { port: bound, server: server, wss: wss };
1126
+ });
1127
+ }
1128
+
1129
+ function walk(p, tries, why) {
1130
+ var next = p + 1;
1131
+ if (tries >= WALK_LIMIT) {
1132
+ throw new Error("ports " + startPort + " through " + p + " are all in use — nothing free to bind. Last holder: " + why);
1133
+ }
1134
+ log("warn", "port " + p + " is held by " + why + " — moving to port " + next + ". " +
1135
+ "The MCP server finds this port through the port file; if tool calls fail on " + p +
1136
+ " while check_setup passes, this is why, and the server re-checks the port on the next refused call.");
1137
+ return attempt(next, tries + 1);
1138
+ }
1139
+
1140
+ function waitForHolder(p, holder) {
1141
+ var same = holder.health.bundleHash && holder.health.bundleHash === loadedBundleHash;
1142
+ var hash = holder.health.bundleHash
1143
+ ? String(holder.health.bundleHash).slice(0, 12)
1144
+ : "no bundleHash — a panel older than 0.3";
1145
+ setStatus("waiting — port " + p + " is held by another AE MCP panel", "warn");
1146
+ if (waits === 0) {
1147
+ log("warn", "port " + p + " is already held by another AE MCP panel (bundle " + hash + ", " +
1148
+ (same ? "the same version as this one" : "a DIFFERENT version from this one") + "). " +
1149
+ "Not moving to another port: that would leave the port file pointing at whichever panel bound last, " +
1150
+ "and the MCP server talking to the wrong one (issue #92).");
1151
+ log("warn", "Retrying the bind every " + (retryMs / 1000) + "s; this panel takes the port over the moment " +
1152
+ "the other one exits, with no restart. If After Effects was relaunched and this persists, the previous " +
1153
+ "panel process is still running: quit After Effects fully, check Activity Monitor / Task Manager for a " +
1154
+ "leftover CEPHtmlEngine, and reopen it. To run two After Effects at once instead, set " +
1155
+ "\"allowPortWalk\": true in " + configFile + ".");
1156
+ } else if (waits % 30 === 0) {
1157
+ log("info", "still waiting for port " + p + " (" + waits + " attempts)");
1158
+ }
1159
+ waits += 1;
1160
+ return new Promise(function (r) { setTimeout(r, retryMs); }).then(function () { return attempt(p, 0); });
1161
+ }
1162
+
1163
+ function attempt(p, tries) {
1164
+ return bindWith(p).catch(function (e) {
1165
+ if (e.code !== "EADDRINUSE") throw e;
1166
+ return identifyHolder(p).then(function (holder) {
1167
+ if (holder.kind === "panel") {
1168
+ if (cfg.allowPortWalk) {
1169
+ return walk(p, tries, "another AE MCP panel, and allowPortWalk is on in " + configFile);
1170
+ }
1171
+ return waitForHolder(p, holder);
1172
+ }
1173
+ var why = holder.kind === "other"
1174
+ ? "something that is not an AE MCP panel (" + holder.detail + ")"
1175
+ : "something that did not identify itself (" + holder.detail + ")";
1176
+ return walk(p, tries, why);
1177
+ });
907
1178
  });
908
1179
  }
909
- return attempt(startPort);
1180
+ return attempt(startPort, 0);
1181
+ }
1182
+
1183
+ // ---------- Shutdown ----------
1184
+ // When the panel window closes or After Effects quits, give the port back and
1185
+ // take this panel's port-file entry with it. A panel that exits cleanly must
1186
+ // not leave a file naming a port nobody listens on — that is the other half
1187
+ // of how the port file came to disagree with the socket (issue #92). A
1188
+ // process that dies without unloading still leaves the file; the server's
1189
+ // discovery treats the file as a hint for exactly that reason.
1190
+ var running = null;
1191
+ function shutdown() {
1192
+ if (!running) return;
1193
+ var s = running;
1194
+ running = null;
1195
+ try { s.wss.close(); } catch (e) {}
1196
+ try { if (typeof s.server.closeAllConnections === "function") s.server.closeAllConnections(); } catch (e) {}
1197
+ try { s.server.close(); } catch (e) {}
1198
+ removePortFile(s.port);
1199
+ removeTokenFile(s.port);
1200
+ }
1201
+ if (typeof window !== "undefined" && typeof window.addEventListener === "function") {
1202
+ window.addEventListener("beforeunload", shutdown);
1203
+ window.addEventListener("unload", shutdown);
910
1204
  }
911
1205
 
912
1206
  // ---------- Boot sequence ----------
@@ -916,14 +1210,23 @@
916
1210
  $ae.textContent = (host && host.appName ? host.appName : "AE") + " " + (host && host.appVersion ? host.appVersion : "");
917
1211
  } catch (e) {}
918
1212
 
1213
+ var config = readConfig();
1214
+ if (config.source) {
1215
+ log("info", "read " + config.source + ": port " + config.port + (config.allowPortWalk ? ", allowPortWalk on" : ""));
1216
+ }
1217
+
919
1218
  loadJsxBundle()
920
- .then(function () { return startServers(7777); })
1219
+ .then(function () { return startServers(config); })
921
1220
  .then(function (s) {
1221
+ running = s;
1222
+ // The only write of the port file, and only with the port actually bound.
922
1223
  writePortFile(s.port);
923
1224
  $port.textContent = String(s.port);
924
1225
  $port.className = "ok";
925
1226
  setStatus("ready", "ok");
926
- log("info", "Bridge listening on http://127.0.0.1:" + s.port);
1227
+ log("info", "Bridge listening on http://127.0.0.1:" + s.port +
1228
+ (s.port !== config.port && config.port !== 0 ? " (configured port " + config.port + " was taken — see above)" : ""));
1229
+ log("info", "Calls are authenticated: the MCP server reads this session's token from " + tokenFile(s.port) + ".");
927
1230
  })
928
1231
  .catch(function (e) {
929
1232
  setStatus("failed", "err");