ae-channel MCP Server
Server Configuration
Describes the environment variables required to run the server.
| Name | Required | Description | Default |
|---|---|---|---|
| DSH_AE_PORT | No | Bridge port the panel connects to, default `127.0.0.1:38941`. Only one client can be connected to AE at a time; use another port to run multiple clients. | |
| DSH_AE_STATE_ROOT | No | Root directory for state and artifacts (jobs, checkpoints, captures, renders, recovery), default `~/.dsh/ae-state`. | |
| DSH_AE_ENABLE_WRITE | No | Write gate. Without it, all write tools return `writes_disabled` (even dry-runs). Set to `1` to enable write tools. | |
| DSH_AE_TOOL_PROFILE | No | Process-level default tool profile (tool exposure surface). One of `idle` (default, 2 tools), `read` (33), `qc` (25), `audit` (6), `start` (44), `authoring` (83), `full` (108). |
Instructions
Guidance the server publishes about itself, which clients place ahead of the tool catalog so the model reads it before choosing anything.
Tools for inspecting the open After Effects project.
Every call replies with a JSON envelope: {ok: true, data: {...}} or {ok: false, error, errorCode}. CHECK ok FIRST: the protocol-level isError flag is only set for invalid arguments, so an application failure (ae_not_connected, writes_disabled, confirmation_required, not_found, ...) arrives with isError=false.
Write tools answer writes_disabled unless the server runs with DSH_AE_ENABLE_WRITE=1; a preview (dryRun=true, list/listing=true) is exempt from that gate.
Long tools (ae_macro_run, ae_checkpoint_save, ae_checkpoint_restore, ae_preset_apply, ae_render_range_run, ae_render_start) take wait_seconds: with it the call answers {ok: true, jobId, status: "queued"|"running"} instead of blocking, so ok:true there means ACCEPTED, not finished - poll ae_job_get(jobId=...) until status is done/failed and read the job's reply.
Prerequisite: AE must be open with the ae-channel CEP panel (or ae/ae-channel-bridge.jsx) running; otherwise tools answer errorCode ae_not_connected.
comp may be a composition name, its item index, or omitted ("active"). layer is a layer index (number) or layer name (string).
Animation tools (expression_read/set, keyframe_add, keyframe_easing, keyframe_set_easing) address one property as a transform name, effects.. or shapes. (see ae_shape_get for Contents paths).
Keyframe structure editing: ae_keyframe_remove (key|time|time range|all), ae_keyframe_value_set (existing key only), ae_keyframe_move (retime one key), ae_keyframe_retime (offset/stretch/reverse a track), ae_keyframe_copy (onto another property or layer), ae_keyframe_bake (expression -> keyframes).
Effects/plug-ins: ae_effect_catalog_list lists every effect this install can apply (incl. third-party plug-ins) with displayName/category/matchName; ae_effect_add accepts an alias, a display name, a matchName or an unambiguous part of one.
Animation presets (.ffx): presets have NO enumeration API, so ae_preset_catalog_list scans the Presets directories - inspect=<path|unique substring> reports the effect matchNames one preset references and audit=true cross-checks every preset against app.effects. ae_preset_apply applies one preset to a layer and REFUSES by default when it references an effect that is not installed: the apply would substitute PLACEHOLDER effects that keep every parameter and keyframe, so the result LOOKS like it worked; a modal dialog MAY also block AE's main thread (the documented behaviour), but v37 measured an 886 ms success with NO dialog, so a timeout is NOT the expected outcome - judge the placeholder fingerprint instead. allowMissing=true is the deliberate last resort, not a workaround.
Batch keyframe editing: every ae_keyframe_* tool also takes targets=[{layer, property, key|time, ...}] to apply the same action to many targets in one call; top-level arguments are the defaults and each target may override them, so one shared easing curve can be stamped across layers/properties at once. Any argument may be omitted entirely for a batch-only call - omitting beats passing a dummy, because a target that also omits the field then fails loudly instead of silently inheriting the dummy. The batch is ONE Undo group (max 200 targets); shape/type errors refuse the whole call, while per-target resolution/AE errors are reported in results[] and the other targets still run (reply fields: mode/targets/succeeded/failed/allSucceeded/results/undoGrouped). ae_keyframe_copy treats the top-level layer/property as the source and each target as a destination. The SAME form rides on ae_layer_set, ae_text_style_set, ae_layer_inout_set, ae_layer_blending_set, ae_layer_style_set and ae_text_animator_set (one call, one Undo group).
Saved modules (macros): ae_macro_save / ae_macro_list / ae_macro_remove keep parameterized step lists in macros/*.json, and ae_macro_run runs a saved macro (name=...) or an inline list (steps=[...]) as ONE AE call inside ONE Undo group - a step is {"tool": "", "args": {...}} and "{{param}}" in a string argument is filled from params={...} (a whole-placeholder value keeps its raw JSON type). Shape errors and unknown tool names refuse the call before anything runs; dryRun=true validates + substitutes + previews without touching AE (works with writes disabled); stopOnError defaults to true because module steps usually depend on the previous ones. A module may carry notes/example for self-description: list echoes them and a dry run with no params previews from example (usedExample=true) - a real run still requires explicit params. Recording: every job this server forwards is journaled (arguments only), so ae_macro_history_get shows what happened (each entry has a stable seq) and ae_macro_save(name=..., last=N, parameterize=[...]) turns the newest recordable writes into a saved module, while fromSeq=/toSeq= records one exact closed range of the log (mutually exclusive with last, optional tools=[...] filter); parameterize a LIST of argument names or a {"": ""} object to make the recording reusable.
Engine-side loops: a module step may be {"tool": "forEach", "args": {"over": "selection"|"layers"|"effects", "match"?, "comp"?, "layer"?, "maxSeconds"?, "allowHeavy"?, "as"?, "steps": [...]}} for a set only AE can resolve (the live selection / layer list / a layer's effects). The engine binds {{i}} (0-based index in the FILTERED set), {{item}} or the as name (the element's ADDRESS - a layer or effect index, ready to pass as layer/effect) and {{itemName}}; a whole-placeholder argument keeps its raw type. maxSeconds stops the loop BETWEEN iterations and reports PARTIAL completion (ok:true with allSucceeded:false, stop.reason="time_budget", partialIterations{iterationsDone,iterationsPlanned}) - it is NOT a failure and NOT a silent green: check allSucceeded before telling the user a module finished. Heavy or index-shifting tools (render_frame, preset_apply, layer_remove, project_save, ...) are refused inside a loop unless allowHeavy=true; revert is refused even then. A module containing a forEach cannot be previewed as concrete steps - dryRun says runtimeResolved:true and shows the template.
BUDGET: a macro's bridge wait is 5s per step, capped at 600s, but the panel polls ~31s by default and a host client may give up sooner (~65s) - so pass wait_seconds for any module over ~12 steps, and for EVERY module containing a forEach, then read the outcome with ae_job_get. Skipping that is what turns a slow module into "timed out, but the project already changed".
Checkpoints & rollback (the file-level safety net): ae_checkpoint_save saves the project and copies it into the bridge's state root (refused for a project that was never saved - saving it to a private path would silently re-point your project); ae_checkpoint_save(list=true) lists them without AE. ae_checkpoint_restore(id=..., confirm=true) re-verifies the copy's SHA-256, optionally checkpoints the current state first (keepCurrent, default true), then rolls the project back and saves it to the ORIGINAL path; it DISCARDS everything after the checkpoint and clears the Undo history. Frame-level comparison: ae_preview_compare(a=..., b=...) turns "did that change anything?" into numbers (changedRatio/changedPixels/meanAbsDiff/maxAbsDiff/bbox - bbox is always in A's original pixel coordinates) plus a difference heat-map and a side-by-side PNG; each selector is {"time": seconds} to render now or {"capture_id": ..., "index": n} to reuse an already rendered frame (its SHA-256 is re-checked). ae_preview_sheet_export(range={"start":..,"end":..,"count":..}) renders a uniform sample (both endpoints included) into one labelled contact sheet. Both flag flat/empty frames and report any resampling in warnings; the artifact PNGs live under the state root.
Shape-path overlay: ae_render_frame_run takes overlay=false|true|{layer?, points?, tangents?, width?, color?, labels?, allowUnknownMapping?} - the pen GEOMETRY (node markers, direction handles, a thickened path) is drawn on the frame with Pillow and THAT image is the one inlined. The image is still an APPENDED block: the conclusion stays in the envelope at content[0].text, and still only ONE image block per reply (the envelope's overlay object says whether it was drawn and why not; the frame it replaced is named in notInlined). What is drawn is the PATH, not the render: shape operators (Repeater/Trim Paths/Merge), effects, masks and blending are deliberately absent, so never read the picture as "the final pixels". Nothing is drawn unless the preconditions are verified - same time for geometry and frame, a structurally complete PNG (#131), a comp pixel size that matches the frame, and an engine-vouched group transform in the comp-space vertices (an unverified one is the silent 86/32-px offset of the research fixture); an untrustworthy overlay is refused in the envelope instead of drawn (#138). Needs the engine's shape_path arm (batch 6 / T15); without it the overlay reports applied:false + reason:geometry_unavailable and the frame itself still works.
Failure envelopes & result deposit: a failed WRITE answers more than an error - dispatchState (rejected = never left this process / dispatched = AE had the job), executionUnknown (a timeout or link change: the job may have run), projectRevision{before,after}, touched (what changed, from before/after structure snapshots: level none|item_diff|layer_diff, capped, truncated) and, when the outcome is dirty or unknown, a recoveryId. Retry ONLY when retrySafe is true; otherwise resolve the record with ae_checkpoint_recover(recoveryId=...) - retryMode="restore" rolls back to the checkpoint the record carries, retryMode="continue" deliberately keeps the dirty state, and either can replay corrected steps=[...] or the recorded call (useRecorded=true). ae_checkpoint_recover(list=true) lists records. A string argument that looks like an elided chat-history body ("[omitted 200 lines]") is refused BEFORE dispatch with errorCode placeholder_rejected - rewrite the value or replay a saved module. Reuse what worked: ae_macro_candidate_list shows the hidden, deduped, expiring pool derived from the operation journal; ae_macro_save(candidateIds=[...]) turns candidates into a saved/pinned module (identical content is refused with the existing name); ae_macro_status_set moves a module along saved/pinned/archived/deprecated; ae_macro_export/ae_macro_import move one module as one hash-verified JSON; ae_macro_list reports status/contentHash/provenance and the useCount/lastUsedAt metrics that ae_macro_run keeps.
Declared vs actual (probes): every other fact this server reports was DECLARED by ExtendScript, and AE has been caught reporting values that are not what the project does. ae_expression_probe answers "is this expression really evaluating?" by reading the text/enabled flag AND sampling the actual value at several times, and ae_keyframe_curve_probe answers "is the real interpolation what inType/outType declare?" by sampling INSIDE each keyframe segment. Both are READ-ONLY (no write gate, no job, no Undo) and report FACTS ONLY: the decidable conclusions are literal_mismatch/time_invariant/engine_error (the defaults; an expression the engine itself reported as broken is reported WITHOUT asking for it, #99) plus the opt-in not_available fallback bucket (the engine refused to report the property; unreachable on AE 25.2, #100), and hold/linear segment checks for curves, while a bezier segment is evidence at info severity and NEVER a verdict. Every reply carries rulesRun AND rulesNotRun, so a default run cannot read as "everything was checked". Missing facts land in skipped and unreadable targets in unreadable - neither is guessed. They dispatch per layer/per property/per segment, so they add read entries to the operation journal: record what you need with ae_macro_save FIRST, then probe.
Write tools are disabled by default (errorCode writes_disabled).
Sequence rendering goes through aerender, NOT through the engine: ae_render_start launches the EXTERNAL aerender.exe (never with -reuse, which would put the render back on the interactive AE's main thread), so it renders the .aep AS SAVED ON DISK - a project with unsaved changes is refused unless allowUnsavedChanges=true, because the result would silently not match the open project. It runs headless (no interactive AE needed) as soon as it knows WHICH .aep to render: pass project= when AE is not connected. The render is a job: poll ae_job_get(jobId=...) for status/progress/artifacts and stop it with ae_render_cancel(jobId=...). aerender never writes the .aep, so no render outcome ever dirties the project and none of them carries a recoveryId.
Destructive tools require an explicit confirm=true argument and refuse with errorCode confirmation_required otherwise: ae_layer_remove (deletes a layer), ae_comp_work_area_trim (trims comp duration), ae_system_save when it would overwrite an existing file. Prefer ae_comp_get / ae_layer_get to inspect before destructive calls.
WRITE OUTCOMES (read this before acting on any write): every write reply carries ONE three-state field, outcome: "succeeded" (the write was dispatched and AE answered; outcomeEvidence.basis says whether that is just the engine's acknowledgement or a revision that demonstrably moved, and a tool that re-read its own write publishes it under outcomeEvidence.readback), "unknown" (NO evidence either way - a timeout, a lost reply, a job accepted with wait_seconds, or a module that stopped halfway; it ALWAYS carries outcomeReason and an outcomeAdvice pointing at ae_job_get/ae_checkpoint_recover, and it is NOT a failure - never re-send it blindly), or "failed" (a definite error; errorCode and retrySafe say what to do). A call that was REFUSED before dispatch (writes_disabled, confirmation_required, bad arguments, the placeholder guard) carries NO outcome at all and reports dispatchState:"rejected" - there is nothing to report because nothing happened. ok keeps its old meaning and stays the first thing to read; resultTruncated (a storage cap) and imageOmitted (evidence transport) are orthogonal to outcome and never change it.
NAMED TRAPS: a few specific operations are refused BY CONDITION before dispatch when this project has MEASURED that they destroy work which cannot be recovered. Such a refusal carries errorCode trap_refused, names the trap, the condition that fired and the evidence behind it, and carries retry_with={"acknowledge_trap": true}. That is not a ban - re-send the SAME call with acknowledge_trap=true to accept the hazard deliberately (the acknowledgement is journaled and echoed back in trapAcknowledged), and let a HUMAN make that call, not you.
IDEMPOTENCY (request-level, for stream loss): every write tool also takes an optional request_id (6-64 chars of [A-Za-z0-9_-]), a caller-made key that names ONE operation. Send the same key again with the same content and the request is REFUSED before dispatch (errorCode duplicate_in_flight / duplicate_unresolved, dispatchState:"rejected", NO outcome) - it points at the FIRST execution in idempotency.related (its record id, jobId and when) and it is NOT safe to retry: read that one instead. The same key with DIFFERENT content is refused as idempotency_key_reused (one key = one operation). A DIFFERENT key with identical arguments DOES run, so a deliberate repeat is never swallowed. Without a key there is no guarantee, and the reply says so (idempotency.state: "not_requested"); ae_checkpoint_recover, ae_render_cancel, ae_system_check and ae_macro_run(dryRun=true) are exempt and say "exempt". If you are re-issuing a request after a lost reply, RE-USE ITS KEY - that is what makes the re-issue safe.
Capabilities
Features and capabilities supported by this server
Protocol revision2025-11-25
| Capability | Details |
|---|---|
| tools | {
"listChanged": true
} |
| prompts | {
"listChanged": false
} |
| resources | {
"subscribe": false,
"listChanged": false
} |
| experimental | {} |
Tools
Functions exposed to the LLM to take actions
| Name | Description |
|---|---|
| ae_system_profile_setA | Inspect or CHANGE the MCP tool profile (which tools the model is shown). This is the ONE tool that is visible in EVERY profile, because it is how a
narrow profile is widened again: without it, a session started on YOU START ON WHERE THE COST IS. The tool list is re-sent to the model on every request, so it is paid for continuously - which is why the profiles are not cosmetic. The numbers live in docs/tool-description-rationale.md. THE SWITCH IS NOT INSTANT, AND THAT IS A CONTRACT, NOT A WARNING. The host
only re-reads Switching is a META operation: it is journaled (event WHOSE TOOL LIST MOVES:
|
| ae_system_tool_searchA | List or search the tools the ACTIVE profile exposes, at three detail levels. This is the discovery half of layered exposure (design-layered-tool-exposure
section 3.4 / P3). Narrowing a profile lowers what every model request pays,
but it also means the model cannot see what it is missing - this tool is how
it looks BEFORE widening, instead of guessing a name and getting
DETAIL LEVELS, and why the default is the cheapest:
IT CANNOT SEARCH PAST THE ACTIVE PROFILE. The candidate set is exactly
Read-only, needs no After Effects, never dispatches to the engine and never needs the write gate - it filters an in-process registry. |
Prompts
Interactive templates invoked by user choice
| Name | Description |
|---|---|
No prompts | |
Resources
Contextual data attached and managed by the client
| Name | Description |
|---|---|
No resources | |
TDQS
Scored across 2 tools
One tool changes the exposure profile, the other searches the currently exposed catalogue, so purposes are distinct in principle. There is mild overlap in that `ae_system_profile_set` also 'inspects' the profile while `ae_system_tool_search` lists the active profile's tools, which could cause a model to pick either when it just wants to know what is available.
Both names follow the identical `ae_system_<noun>_<verb>` pattern (`ae_system_profile_set`, `ae_system_tool_search`), with consistent snake_case and shared namespace prefix. No deviations.
Two tools is below the typical 3-15 range, but the thinness is deliberate and justified: this server is a minimal meta control plane whose whole point is to avoid paying for a large prompt. Each tool earns its place, though there is no dedicated read-only 'current profile' introspection tool.
The pair covers the full widen-inspect-switch loop: search at three detail levels, switch profile with session/process scope, and switch back. Minor gaps exist — no explicit tool to enumerate all available profile names or to read the current profile state without invoking the setter.