Merge pull request #28 from storybored379/pr/mcp-conventions

MCP: tool annotations, core tools, resources, export progress and cancellation
This commit is contained in:
Brandon Thomas 2026-10-06 07:27:58 -04:00 committed by GitHub
commit 508a5a933f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 779 additions and 30 deletions

View File

@ -11,6 +11,7 @@
#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable))]
pub mod bridge;
pub mod long_job;
pub mod server;
pub use bridge::BridgeClient;

View File

@ -0,0 +1,183 @@
//! Long exports over MCP (docs/agents.md § Long exports): `file.exportMedia` with `wait: true`
//! runs as a background engine job; the server reports its progress as MCP
//! `notifications/progress` (when the request carried a `progressToken`) and stops it on
//! `notifications/cancelled`, deleting the partial output. The session lock is only held for
//! short polls, so other requests are answered while the export runs.
use std::path::Path;
use std::time::{Duration, SystemTime};
use rmcp::RoleServer;
use rmcp::model::{CallToolResult, ContentBlock as Content, ProgressNotificationParam, ProgressToken};
use rmcp::service::{Peer, RequestContext};
use serde_json::{Value, json};
use crate::server::FilmcraftMcp;
/// Engine commands that block until a render is written when called with `wait: true`.
pub const LONG_COMMANDS: &[&str] = &["file.exportMedia"];
/// How often the job is polled (and at most how often progress is reported).
const POLL: Duration = Duration::from_millis(100);
/// Whether `command_run {id, params}` is a long call this module runs.
pub fn is_long(id: &str, params: &Value) -> bool {
LONG_COMMANDS.contains(&id) && params.get("wait").and_then(Value::as_bool) == Some(true)
}
/// Sends `notifications/progress` for one request; progress only ever increases.
struct Reporter {
peer: Peer<RoleServer>,
token: Option<ProgressToken>,
last: f64,
}
impl Reporter {
async fn report(&mut self, done: f64, total: f64, message: &str) {
let Some(token) = self.token.clone() else { return };
if total <= 0.0 || done <= self.last {
return;
}
self.last = done;
let mut p = ProgressNotificationParam::new(token, done).with_total(total);
if !message.is_empty() {
p = p.with_message(message);
}
let _ = self.peer.notify_progress(p).await;
}
}
impl FilmcraftMcp {
/// Run `id` (a [`LONG_COMMANDS`] entry, `wait: true`) as a job, reporting progress and
/// honouring cancellation.
pub(crate) async fn run_long(&self, id: &str, mut params: Value, context: &RequestContext<RoleServer>) -> CallToolResult {
let started = SystemTime::now();
if let Some(p) = params.as_object_mut() {
p.insert("wait".into(), json!(false));
}
let start = match self.run(id, params).await {
Ok(v) => v,
Err(e) => return CallToolResult::error(vec![Content::text(e.to_string())]),
};
let Some(job) = start.get("job").and_then(Value::as_u64) else {
return CallToolResult::error(vec![Content::text(format!("{id} started no job: {start}"))]);
};
let path = start.get("path").and_then(Value::as_str).unwrap_or_default().to_string();
let mut rep = Reporter { peer: context.peer.clone(), token: context.meta.get_progress_token(), last: 0.0 };
loop {
let state = self.job_state(job).await;
let finished = state.as_ref().is_none_or(|j| j["finished"].as_bool() == Some(true));
if let Some(j) = &state {
let (done, total) = (j["done"].as_f64().unwrap_or(0.0), j["total"].as_f64().unwrap_or(0.0));
rep.report(done, total, j["status"].as_str().unwrap_or_default()).await;
}
if finished {
let result = state.map(|j| j["result"].clone()).unwrap_or(Value::Null);
if let Some(e) = result.get("error").and_then(Value::as_str) {
return CallToolResult::error(vec![Content::text(format!("export failed: {e}"))]);
}
let mut out = start;
if let Some(o) = out.as_object_mut() {
o.insert("result".into(), result);
}
return CallToolResult::success(vec![Content::text(serde_json::to_string_pretty(&out).unwrap_or_default())]);
}
tokio::select! {
_ = context.ct.cancelled() => break,
_ = tokio::time::sleep(POLL) => {}
}
}
// Cancelled: stop the encode at the next batch, wait for the worker, delete the partial file.
let _ = self.run("jobs.cancel", json!({"job": job})).await;
for _ in 0..600 {
if self.job_state(job).await.is_none_or(|j| j["finished"].as_bool() == Some(true)) {
break;
}
tokio::time::sleep(POLL).await;
}
remove_partial(&path, started);
CallToolResult::error(vec![Content::text("cancelled")])
}
/// The job's `jobs.list` entry.
async fn job_state(&self, job: u64) -> Option<Value> {
let jobs = self.run("jobs.list", json!({})).await.ok()?;
jobs.as_array()?.iter().find(|j| j["id"].as_u64() == Some(job)).cloned()
}
}
/// Delete what an interrupted export wrote, and nothing else: the output file itself, the numbered
/// stills of an image sequence (`<stem><digits>.<same extension>`) and the caption sidecar
/// (`<stem>.srt` / `<stem>.vtt`), each only if it was written since `since`. Other files that
/// merely share the name's stem (`<stem>.aep`, `<stem>2.psd`, …) are never touched, even when they
/// were saved during the export.
pub fn remove_partial(path: &str, since: SystemTime) {
let p = Path::new(path);
let (Some(dir), Some(stem), Some(file)) = (p.parent(), p.file_stem().and_then(|s| s.to_str()), p.file_name().and_then(|s| s.to_str())) else {
return;
};
let ext = p.extension().and_then(|s| s.to_str()).unwrap_or_default();
let dir = if dir.as_os_str().is_empty() { Path::new(".") } else { dir };
let Ok(entries) = std::fs::read_dir(dir) else { return };
// file times are coarser than the clock
let since = since.checked_sub(Duration::from_secs(1)).unwrap_or(since);
for e in entries.flatten() {
let name = e.file_name();
let Some(name) = name.to_str() else { continue };
if is_export_output(name, file, stem, ext) && e.metadata().is_ok_and(|m| m.is_file() && m.modified().is_ok_and(|t| t >= since)) {
let _ = std::fs::remove_file(e.path());
}
}
}
/// Whether `name` is a file an export to `file` (`stem` + `.ext`) writes.
fn is_export_output(name: &str, file: &str, stem: &str, ext: &str) -> bool {
if name == file {
return true;
}
let Some(rest) = name.strip_prefix(stem) else { return false };
if let Some(sidecar) = rest.strip_prefix('.') {
return sidecar.eq_ignore_ascii_case("srt") || sidecar.eq_ignore_ascii_case("vtt");
}
// image sequence: one or more digits, then the output's own extension
let digits = rest.len() - rest.trim_start_matches(|c: char| c.is_ascii_digit()).len();
digits > 0 && !ext.is_empty() && rest.get(digits..).is_some_and(|r| r.strip_prefix('.').is_some_and(|x| x == ext))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn only_the_exports_own_files_are_removed() {
let dir = std::env::temp_dir().join(format!("fc-remove-partial-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let started = SystemTime::now();
let ours = ["trailer.png", "trailer000.png", "trailer123.png", "trailer.srt", "trailer.vtt"];
// same stem, written during the export, but not the export's
let theirs = ["trailer.aep", "trailer2.psd", "trailer.docx", "trailer000.jpg", "trailers.png", "trailer.png.bak", "other.png"];
for f in ours.iter().chain(theirs.iter()) {
std::fs::write(dir.join(f), b"x").unwrap();
}
remove_partial(&dir.join("trailer.png").to_string_lossy(), started);
for f in ours {
assert!(!dir.join(f).exists(), "{f} should be removed");
}
for f in theirs {
assert!(dir.join(f).exists(), "{f} must not be removed");
}
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn files_older_than_the_export_are_kept() {
let dir = std::env::temp_dir().join(format!("fc-remove-partial-old-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("clip.mp4"), b"x").unwrap();
remove_partial(&dir.join("clip.mp4").to_string_lossy(), SystemTime::now() + Duration::from_secs(60));
assert!(dir.join("clip.mp4").exists());
let _ = std::fs::remove_dir_all(&dir);
}
}

View File

@ -1,6 +1,6 @@
//! The MCP server. Tool names are snake_case.
use std::sync::{Arc, Mutex};
use std::sync::{Arc, Mutex, PoisonError};
use base64::Engine as _;
use filmcraft_engine::Session;
@ -19,6 +19,19 @@ pub enum Backend {
Bridge(Arc<BridgeClient>),
}
/// A panic payload as text.
fn panic_message(p: Box<dyn std::any::Any + Send>) -> String {
p.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| p.downcast_ref::<String>().cloned()).unwrap_or_else(|| "panic".into())
}
/// A failed blocking task: a panic is reported as an internal error and the session stays usable.
fn join_error(e: tokio::task::JoinError) -> AutomationError {
match e.try_into_panic() {
Ok(p) => AutomationError::Other(format!("internal error: {}", panic_message(p))),
Err(e) => AutomationError::Other(e.to_string()),
}
}
#[derive(Clone)]
pub struct FilmcraftMcp {
backend: Arc<Backend>,
@ -44,6 +57,24 @@ pub struct RunParams {
pub params: Option<Value>,
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct BatchStep {
/// Command id.
pub id: String,
/// Parameters object.
#[serde(default)]
pub params: Option<Value>,
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct BatchParams {
/// Commands to run in order.
pub steps: Vec<BatchStep>,
/// Stop at the first failing step (default true).
#[serde(default)]
pub stop_on_error: Option<bool>,
}
#[derive(Debug, Default, Deserialize, JsonSchema)]
pub struct RenderParams {
/// Timeline time in seconds (default: the playhead).
@ -148,9 +179,60 @@ impl FilmcraftMcp {
}
pub async fn serve_stdio(self) -> Result<(), AutomationError> {
let running = self.serve(rmcp::transport::stdio()).await.map_err(|e| AutomationError::Other(format!("MCP init: {e}")))?;
running.waiting().await.map_err(|e| AutomationError::Other(e.to_string()))?;
Ok(())
self.serve_io(tokio::io::stdin(), tokio::io::stdout()).await
}
/// Serve MCP over any line stream. A line that isn't JSON gets a JSON-RPC parse error
/// (`-32700`, id null) instead of being dropped silently; the rest go to the rmcp service. One
/// writer task owns `output`, so replies never interleave.
pub async fn serve_io(
self,
input: impl tokio::io::AsyncRead + Unpin + Send + 'static,
output: impl tokio::io::AsyncWrite + Unpin + Send + 'static,
) -> Result<(), AutomationError> {
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
let (mut to_service, service_in) = tokio::io::duplex(1 << 20);
let (service_out, from_service) = tokio::io::duplex(1 << 20);
let (err_tx, mut err_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
let reader = tokio::spawn(async move {
let mut lines = BufReader::new(input).lines();
while let Ok(Some(line)) = lines.next_line().await {
if line.trim().is_empty() {
continue;
}
match serde_json::from_str::<Value>(&line) {
Ok(_) => {
if to_service.write_all(format!("{line}\n").as_bytes()).await.is_err() {
break;
}
}
Err(e) => {
let reply = json!({"jsonrpc": "2.0", "id": null, "error": {"code": -32700, "message": format!("parse error: {e}")}});
let _ = err_tx.send(reply.to_string());
}
}
}
// EOF: closing the pipe ends the service
let _ = to_service.shutdown().await;
});
let writer = tokio::spawn(async move {
let mut output = output;
let mut replies = BufReader::new(from_service).lines();
loop {
let line = tokio::select! {
l = replies.next_line() => match l { Ok(Some(l)) => l, _ => break },
Some(e) = err_rx.recv() => e,
};
if output.write_all(format!("{line}\n").as_bytes()).await.is_err() || output.flush().await.is_err() {
break;
}
}
});
let running = self.serve((service_in, service_out)).await.map_err(|e| AutomationError::Other(format!("MCP init: {e}")))?;
let r = running.waiting().await.map_err(|e| AutomationError::Other(e.to_string()));
reader.abort();
let _ = writer.await;
r.map(|_| ())
}
/// Run an engine command on whichever backend.
@ -160,16 +242,24 @@ impl FilmcraftMcp {
let s = s.clone();
let id = id.to_string();
tokio::task::spawn_blocking(move || {
let mut g = s.lock().map_err(|_| AutomationError::Other("session lock poisoned".into()))?;
// A command that panicked earlier poisoned the lock; the session is still usable.
let mut g = s.lock().unwrap_or_else(PoisonError::into_inner);
g.execute(&id, params).map_err(AutomationError::from)
})
.await
.map_err(|e| AutomationError::Other(e.to_string()))?
.map_err(join_error)?
}
Backend::Bridge(b) => b.call("engine.execute", json!({"command": id, "params": params})).await,
}
}
/// The project tree plus the active sequence (null when there is none).
async fn document(&self) -> Result<Value, AutomationError> {
let project = self.run("project.inspect", json!({})).await?;
let sequence = self.run("sequence.inspect", json!({})).await.ok();
Ok(json!({"project": project, "sequence": sequence}))
}
fn bridge_client(&self) -> Option<Arc<BridgeClient>> {
match &*self.backend {
Backend::Bridge(b) => Some(b.clone()),
@ -190,7 +280,9 @@ impl FilmcraftMcp {
#[tool_router]
impl FilmcraftMcp {
#[tool(
description = "List every command (id, label, menu path, shortcut, parameter doc, enabled now). Menus, shortcuts, panels and timeline gestures all map to these ids."
title = "List commands",
description = "List every command (id, label, menu path, shortcut, parameter doc, enabled now). Menus, shortcuts, panels and timeline gestures all map to these ids.",
annotations(read_only_hint = true, destructive_hint = false, idempotent_hint = true, open_world_hint = false)
)]
async fn command_list(&self, Parameters(p): Parameters<ListParams>) -> Result<CallToolResult, McpError> {
let r = self.run("command.list", json!({})).await.map(|v| {
@ -213,38 +305,99 @@ impl FilmcraftMcp {
}
#[tool(
description = "Run a command by id with JSON params, e.g. {\"id\":\"timeline.razor\",\"params\":{\"seconds\":3.5}} or {\"id\":\"effects.apply\",\"params\":{\"effect\":\"Gaussian Blur\"}}. Undoable edits land in History."
title = "Run a command",
description = "Run a command by id with JSON params, e.g. {\"id\":\"timeline.razor\",\"params\":{\"seconds\":3.5}} or {\"id\":\"effects.apply\",\"params\":{\"effect\":\"Gaussian Blur\"}}. Undoable edits land in History.",
annotations(read_only_hint = false, destructive_hint = true, idempotent_hint = false, open_world_hint = false)
)]
async fn command_run(&self, Parameters(p): Parameters<RunParams>) -> Result<CallToolResult, McpError> {
wrap(self.run(&p.id, p.params.unwrap_or(json!({}))).await)
}
#[tool(description = "Project tree (bins and items with ids, types, durations), active sequence, source clip.")]
#[tool(
title = "Run several commands",
description = "Run several commands in order: {steps: [{id, params?}], stop_on_error?: true}. Returns {completed, failed, results: [{ok, result | error}]}; each edit is its own undo step.",
annotations(read_only_hint = false, destructive_hint = true, idempotent_hint = false, open_world_hint = false)
)]
async fn command_batch(&self, Parameters(p): Parameters<BatchParams>) -> Result<CallToolResult, McpError> {
let stop = p.stop_on_error.unwrap_or(true);
let (mut completed, mut failed, mut results) = (0u32, 0u32, Vec::new());
for st in p.steps {
match self.run(&st.id, st.params.unwrap_or(json!({}))).await {
Ok(v) => {
completed += 1;
results.push(json!({"ok": true, "result": v}));
}
Err(e) => {
failed += 1;
results.push(json!({"ok": false, "error": e.to_string()}));
if stop {
break;
}
}
}
}
let v = json!({"completed": completed, "failed": failed, "results": results});
Ok(if failed > 0 { CallToolResult::error(vec![Content::text(v.to_string())]) } else { ok_json(&v) })
}
#[tool(
title = "Inspect the document",
description = "The project as JSON: the tree (as project_inspect) plus the active sequence (as sequence_inspect; null when there is none).",
annotations(read_only_hint = true, destructive_hint = false, idempotent_hint = true, open_world_hint = false)
)]
async fn doc_inspect(&self) -> Result<CallToolResult, McpError> {
wrap(self.document().await)
}
#[tool(
title = "Render a preview",
description = "The active sequence's frame at `seconds` (default: the playhead) as PNG, same as render_frame (bridge mode: Program monitor screenshot).",
annotations(read_only_hint = true, destructive_hint = false, idempotent_hint = true, open_world_hint = false)
)]
async fn render_preview(&self, p: Parameters<RenderParams>) -> Result<CallToolResult, McpError> {
self.render_frame(p).await
}
#[tool(
title = "Inspect the project tree",
description = "Project tree (bins and items with ids, types, durations), active sequence, source clip.",
annotations(read_only_hint = true, destructive_hint = false, idempotent_hint = true, open_world_hint = false)
)]
async fn project_inspect(&self) -> Result<CallToolResult, McpError> {
wrap(self.run("project.inspect", json!({})).await)
}
#[tool(
description = "The active sequence as JSON: settings, tracks, clips (ids, start/duration in ticks and frames, effects), transitions, markers, in/out, playhead, selection. 254016000000 ticks = 1 second."
title = "Inspect the active sequence",
description = "The active sequence as JSON: settings, tracks, clips (ids, start/duration in ticks and frames, effects), transitions, markers, in/out, playhead, selection. 254016000000 ticks = 1 second.",
annotations(read_only_hint = true, destructive_hint = false, idempotent_hint = true, open_world_hint = false)
)]
async fn sequence_inspect(&self) -> Result<CallToolResult, McpError> {
wrap(self.run("sequence.inspect", json!({})).await)
}
#[tool(description = "Import media files by absolute path (MP4/MOV, WAV/MP3/FLAC/AIFF/Ogg, PNG/JPEG/…).")]
#[tool(
title = "Import media",
description = "Import media files by absolute path (MP4/MOV, WAV/MP3/FLAC/AIFF/Ogg, PNG/JPEG/…).",
annotations(read_only_hint = false, destructive_hint = false, idempotent_hint = false, open_world_hint = false)
)]
async fn media_import(&self, Parameters(p): Parameters<TypeParams>) -> Result<CallToolResult, McpError> {
let paths: Vec<&str> = p.text.split('\n').map(str::trim).filter(|s| !s.is_empty()).collect();
wrap(self.run("file.import", json!({"paths": paths})).await)
}
#[tool(description = "Render the active sequence's frame (at `seconds` or the playhead) and return it as PNG.")]
#[tool(
title = "Render a frame",
description = "Render the active sequence's frame (at `seconds` or the playhead) and return it as PNG.",
annotations(read_only_hint = true, destructive_hint = false, idempotent_hint = true, open_world_hint = false)
)]
async fn render_frame(&self, Parameters(p): Parameters<RenderParams>) -> Result<CallToolResult, McpError> {
let max = p.max_side.unwrap_or(960);
match &*self.backend {
Backend::Headless(s) => {
let s = s.clone();
let r = tokio::task::spawn_blocking(move || -> Result<Vec<u8>, AutomationError> {
let mut g = s.lock().map_err(|_| AutomationError::Other("lock".into()))?;
let mut g = s.lock().unwrap_or_else(PoisonError::into_inner);
if let Some(sec) = p.seconds {
g.set_playhead(filmcraft_time::Tick::from_seconds_f64(sec));
}
@ -255,7 +408,7 @@ impl FilmcraftMcp {
png_rgba(img.w as u32, img.h as u32, img.over_black_rgba8(), max)
})
.await
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
.unwrap_or_else(|e| Err(join_error(e)));
Ok(match r {
Ok(png) => png_result(&png, "rendered frame"),
Err(e) => fail(e),
@ -272,17 +425,29 @@ impl FilmcraftMcp {
}
}
#[tool(description = "Live app: UI state (tool, workspace, panels, timeline zoom, playback, selection, fps).")]
#[tool(
title = "Live app: UI state",
description = "Live app: UI state (tool, workspace, panels, timeline zoom, playback, selection, fps).",
annotations(read_only_hint = true, destructive_hint = false, idempotent_hint = true, open_world_hint = false)
)]
async fn ui_inspect(&self) -> Result<CallToolResult, McpError> {
self.ui("ui.inspect", json!({})).await
}
#[tool(description = "Live app: every on-screen interactive element with id, label and rect (points). Use ids with ui_click/ui_drag.")]
#[tool(
title = "Live app: on-screen elements",
description = "Live app: every on-screen interactive element with id, label and rect (points). Use ids with ui_click/ui_drag.",
annotations(read_only_hint = true, destructive_hint = false, idempotent_hint = true, open_world_hint = false)
)]
async fn ui_elements(&self, Parameters(p): Parameters<ElementsParams>) -> Result<CallToolResult, McpError> {
self.ui("ui.elements", json!({"prefix": p.prefix.unwrap_or_default()})).await
}
#[tool(description = "Live app: click an element by id or at x,y (points). Supports right/middle button, double-click (count=2) and modifiers.")]
#[tool(
title = "Live app: click",
description = "Live app: click an element by id or at x,y (points). Supports right/middle button, double-click (count=2) and modifiers.",
annotations(read_only_hint = false, destructive_hint = true, idempotent_hint = false, open_world_hint = false)
)]
async fn ui_click(&self, Parameters(p): Parameters<ClickParams>) -> Result<CallToolResult, McpError> {
let mut v = json!({"button": p.button, "count": p.count, "modifiers": p.modifiers});
if let Some(id) = p.id {
@ -295,23 +460,37 @@ impl FilmcraftMcp {
}
#[tool(
description = "Live app: press-drag-release from one point/element to another (move clips, trim edges, scrub, resize panels, drop project items onto tracks)."
title = "Live app: drag",
description = "Live app: press-drag-release from one point/element to another (move clips, trim edges, scrub, resize panels, drop project items onto tracks).",
annotations(read_only_hint = false, destructive_hint = true, idempotent_hint = false, open_world_hint = false)
)]
async fn ui_drag(&self, Parameters(p): Parameters<DragParams>) -> Result<CallToolResult, McpError> {
self.ui("ui.drag", json!({"from": p.from, "to": p.to, "steps": p.steps, "modifiers": p.modifiers})).await
}
#[tool(description = "Live app: press a key or shortcut (e.g. `Space`, `L`, `Cmd+K`, `Shift+Delete`, `Cmd+Z`).")]
#[tool(
title = "Live app: press a key",
description = "Live app: press a key or shortcut (e.g. `Space`, `L`, `Cmd+K`, `Shift+Delete`, `Cmd+Z`).",
annotations(read_only_hint = false, destructive_hint = true, idempotent_hint = false, open_world_hint = false)
)]
async fn ui_key(&self, Parameters(p): Parameters<KeyParams>) -> Result<CallToolResult, McpError> {
self.ui("ui.key", json!({"key": p.key})).await
}
#[tool(description = "Live app: type text into the focused field.")]
#[tool(
title = "Live app: type text",
description = "Live app: type text into the focused field.",
annotations(read_only_hint = false, destructive_hint = true, idempotent_hint = false, open_world_hint = false)
)]
async fn ui_type(&self, Parameters(p): Parameters<TypeParams>) -> Result<CallToolResult, McpError> {
self.ui("ui.type", json!({"text": p.text})).await
}
#[tool(description = "Live app: screenshot of the window or one panel (PNG).")]
#[tool(
title = "Live app: screenshot",
description = "Live app: screenshot of the window or one panel (PNG).",
annotations(read_only_hint = true, destructive_hint = false, idempotent_hint = true, open_world_hint = false)
)]
async fn ui_screenshot(&self, Parameters(p): Parameters<ShotParams>) -> Result<CallToolResult, McpError> {
match self.bridge_client() {
Some(b) => self.screenshot(&b, p.panel, p.max_side.unwrap_or(1600)).await,
@ -320,7 +499,9 @@ impl FilmcraftMcp {
}
#[tool(
description = "Live app: call any control-channel method directly (ui.set, ui.scroll, ui.timeline.hit, ui.timeline.locate, ui.playback, ui.panel.show, ui.menu.list, …)."
title = "Live app: control-channel call",
description = "Live app: call any control-channel method directly (ui.set, ui.scroll, ui.timeline.hit, ui.timeline.locate, ui.playback, ui.panel.show, ui.menu.list, …).",
annotations(read_only_hint = false, destructive_hint = true, idempotent_hint = false, open_world_hint = false)
)]
async fn ui_control(&self, Parameters(p): Parameters<ControlParams>) -> Result<CallToolResult, McpError> {
self.ui(&p.method, p.params.unwrap_or(json!({}))).await
@ -350,12 +531,104 @@ impl FilmcraftMcp {
}
}
#[tool_handler(
router = self.tool_router,
name = "filmcraft",
instructions = "FilmCraft video editor (Premiere Pro-class). Every edit is an engine command: `command_list` to discover ids/params, `command_run` to execute (undoable). `project_inspect`/`sequence_inspect` return ids you can pass to commands; `render_frame` shows the result. In bridge mode the `ui_*` tools drive the live app: `ui_elements` lists clickable ids, `ui_click`/`ui_drag`/`ui_key` operate it, `ui_screenshot` shows it. Time is in ticks: 254016000000 per second (commands also accept `seconds`, `frame` or `timecode`)."
)]
impl ServerHandler for FilmcraftMcp {}
impl FilmcraftMcp {
/// The first argument `tool` doesn't take, as a message naming the ones it does.
fn unknown_arg(&self, tool: &str, args: Option<&rmcp::model::JsonObject>) -> Option<String> {
let def = self.tool_router.get(tool)?;
let props = def.input_schema.get("properties").and_then(Value::as_object).cloned().unwrap_or_default();
let bad = args?.keys().find(|k| !props.contains_key(*k))?;
let accepted: Vec<&str> = props.keys().map(String::as_str).collect();
let accepted = if accepted.is_empty() { "no arguments".to_string() } else { accepted.join(", ") };
Some(format!("unknown argument \"{bad}\" for {tool}; expected: {accepted}"))
}
}
const INSTRUCTIONS: &str = "FilmCraft video editor (Premiere Pro-class). Every edit is an engine command: `command_list` to discover ids/params, `command_run` to execute (undoable; `command_batch` runs several). `doc_inspect` (or `project_inspect`/`sequence_inspect`) returns ids you can pass to commands; `render_preview` shows the result. In bridge mode the `ui_*` tools drive the live app: `ui_elements` lists clickable ids, `ui_click`/`ui_drag`/`ui_key` operate it, `ui_screenshot` shows it. Time is in ticks: 254016000000 per second (commands also accept `seconds`, `frame` or `timecode`).";
/// Resources: the project (as `doc_inspect`) and the command catalog (as `command_list`).
const DOCUMENT_URI: &str = "filmcraft://document";
const COMMANDS_URI: &str = "filmcraft://commands";
/// MCP 2026-07-28 clients (current Claude Code) require `ttlMs` and `cacheScope` on list and read
/// results; rmcp fills `resultType` and leaves the cache hints to the server, as the generated
/// `tools/list` does. The resource list never changes; the document does, so reads aren't cached.
fn cache_hints(context: &rmcp::service::RequestContext<rmcp::RoleServer>, ttl_ms: u64) -> Option<(u64, rmcp::model::CacheScope)> {
context.protocol_version().is_some_and(|v| v >= rmcp::model::ProtocolVersion::V_2026_07_28).then_some((ttl_ms, rmcp::model::CacheScope::Private))
}
#[tool_handler(router = self.tool_router)]
impl ServerHandler for FilmcraftMcp {
fn get_info(&self) -> rmcp::model::ServerConfig {
rmcp::model::ServerConfig::new(rmcp::model::ServerCapabilities::builder().enable_tools().enable_resources().build())
.with_server_info(rmcp::model::Implementation::new("filmcraft", env!("CARGO_PKG_VERSION")))
.with_instructions(INSTRUCTIONS.to_string())
}
/// Strict arguments and long exports in front of the tool router.
async fn call_tool(
&self,
request: rmcp::model::CallToolRequestParams,
context: rmcp::service::RequestContext<rmcp::RoleServer>,
) -> Result<rmcp::model::CallToolResponse, McpError> {
if let Some(m) = self.unknown_arg(&request.name, request.arguments.as_ref()) {
return Err(McpError::invalid_params(m, None));
}
// A blocking export reports progress and can be cancelled (docs/agents.md § Long exports).
if request.name == "command_run"
&& matches!(&*self.backend, Backend::Headless(_))
&& let Some(a) = &request.arguments
&& let Some(id) = a.get("id").and_then(Value::as_str)
{
let params = a.get("params").cloned().filter(Value::is_object).unwrap_or_else(|| json!({}));
if crate::long_job::is_long(id, &params) {
return Ok(self.run_long(id, params, &context).await.into());
}
}
let tcc = rmcp::handler::server::tool::ToolCallContext::new(self, request, context);
self.tool_router.call(tcc).await
}
async fn list_resources(
&self,
_request: Option<rmcp::model::PaginatedRequestParams>,
context: rmcp::service::RequestContext<rmcp::RoleServer>,
) -> Result<rmcp::model::ListResourcesResult, McpError> {
use rmcp::model::Resource;
let mut r = rmcp::model::ListResourcesResult::with_all_items(vec![
Resource::new(DOCUMENT_URI, "document")
.with_title("Project")
.with_description("The project tree and the active sequence (same as doc_inspect).")
.with_mime_type("application/json"),
Resource::new(COMMANDS_URI, "commands")
.with_title("Command catalog")
.with_description("Every command with id, label, menu path, shortcut, params and enabled state (same as command_list).")
.with_mime_type("application/json"),
]);
if let Some((ttl, scope)) = cache_hints(&context, 600_000) {
r = r.with_ttl_ms(ttl).with_cache_scope(scope);
}
Ok(r)
}
async fn read_resource(
&self,
request: rmcp::model::ReadResourceRequestParams,
context: rmcp::service::RequestContext<rmcp::RoleServer>,
) -> Result<rmcp::model::ReadResourceResponse, McpError> {
let v = match request.uri.as_str() {
DOCUMENT_URI => self.document().await,
COMMANDS_URI => self.run("command.list", json!({})).await,
other => return Err(McpError::resource_not_found(format!("resource not found: {other}"), None)),
}
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
let text = serde_json::to_string_pretty(&v).map_err(|e| McpError::internal_error(e.to_string(), None))?;
let mut r = rmcp::model::ReadResourceResult::new(vec![rmcp::model::ResourceContents::text(text, request.uri).with_mime_type("application/json")]);
if let Some((ttl, scope)) = cache_hints(&context, 0) {
r = r.with_ttl_ms(ttl).with_cache_scope(scope);
}
Ok(r.into())
}
}
#[cfg(test)]
mod tests {
@ -371,4 +644,249 @@ mod tests {
let r = m.render_frame(Parameters(RenderParams { seconds: Some(1.0), max_side: Some(320) })).await.unwrap();
assert_ne!(r.is_error, Some(true));
}
/// One request/response exchange over `serve_io`, as an MCP client sees it.
struct Client {
tx: tokio::io::DuplexStream,
rx: tokio::io::Lines<tokio::io::BufReader<tokio::io::DuplexStream>>,
}
impl Client {
fn start(s: Session) -> Self {
use tokio::io::AsyncBufReadExt;
let (tx, server_in) = tokio::io::duplex(1 << 22);
let (server_out, rx) = tokio::io::duplex(1 << 22);
tokio::spawn(FilmcraftMcp::headless(s).serve_io(server_in, server_out));
Self { tx, rx: tokio::io::BufReader::new(rx).lines() }
}
async fn send(&mut self, line: &str) {
use tokio::io::AsyncWriteExt;
self.tx.write_all(format!("{line}\n").as_bytes()).await.unwrap();
}
async fn next(&mut self) -> Value {
serde_json::from_str(&self.rx.next_line().await.unwrap().unwrap()).unwrap()
}
async fn ask(&mut self, msg: Value) -> Value {
self.send(&msg.to_string()).await;
self.next().await
}
async fn init(&mut self) -> Value {
let r = self.ask(json!({"jsonrpc":"2.0","id":0,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"t","version":"1"}}})).await;
self.send(&json!({"jsonrpc":"2.0","method":"notifications/initialized"}).to_string()).await;
r
}
async fn call(&mut self, id: u64, name: &str, arguments: Value) -> Value {
self.ask(json!({"jsonrpc":"2.0","id":id,"method":"tools/call","params":{"name":name,"arguments":arguments}})).await
}
}
fn demo() -> Session {
let mut s = Session::default();
s.execute("file.openDemoProject", json!({})).unwrap();
s
}
/// Every listed tool has a title and the four hints, and the core tools are listed.
#[tokio::test(flavor = "multi_thread")]
async fn tools_are_titled_and_annotated() {
let mut c = Client::start(demo());
let init = c.init().await;
assert_eq!(init["result"]["serverInfo"]["name"], "filmcraft");
let tools = c.ask(json!({"jsonrpc":"2.0","id":1,"method":"tools/list"})).await["result"]["tools"].as_array().cloned().unwrap();
for t in &tools {
assert!(t["title"].is_string(), "{} has no title", t["name"]);
for h in ["readOnlyHint", "destructiveHint", "idempotentHint", "openWorldHint"] {
assert!(t["annotations"][h].is_boolean(), "{} lacks {h}", t["name"]);
}
}
let names: Vec<&str> = tools.iter().filter_map(|t| t["name"].as_str()).collect();
for want in ["command_list", "command_run", "command_batch", "doc_inspect", "render_preview", "project_inspect", "render_frame", "ui_click"] {
assert!(names.contains(&want), "{want} not listed");
}
let ro = |n: &str| tools.iter().find(|t| t["name"] == n).map(|t| t["annotations"]["readOnlyHint"].clone());
assert_eq!((ro("command_list"), ro("command_run")), (Some(json!(true)), Some(json!(false))));
}
/// Strict arguments, malformed JSON, batch, doc_inspect and render_preview over the raw line
/// protocol.
#[tokio::test(flavor = "multi_thread")]
async fn strict_arguments_and_parse_errors() {
let mut c = Client::start(demo());
c.init().await;
let r = c.call(1, "command_list", json!({"filter": "razor", "enabled_only": true})).await;
assert!(r["result"]["content"][0]["text"].as_str().unwrap().contains("timeline.razor"), "{r}");
// an unknown argument names itself and the accepted ones
let r = c.call(2, "command_list", json!({"filtr": "x"})).await;
assert_eq!(r["error"]["code"], -32602, "{r}");
let m = r["error"]["message"].as_str().unwrap();
assert!(m.contains("\"filtr\"") && m.contains("filter") && m.contains("enabled_only"), "{m}");
let r = c.call(3, "doc_inspect", json!({"verbose": true})).await;
assert_eq!(r["error"]["code"], -32602, "{r}");
// command params stay free-form
let r = c.call(4, "command_run", json!({"id": "playhead.set", "params": {"seconds": 0.5}})).await;
assert_eq!(r["result"]["isError"], false, "{r}");
// malformed JSON: a parse error, and the server keeps serving
c.send("{\"jsonrpc\": \"2.0\", \"id\": 5, BROKEN").await;
let r = c.next().await;
assert_eq!((r["error"]["code"].as_i64(), r["id"].is_null()), (Some(-32700), true), "{r}");
let r = c.ask(json!({"jsonrpc":"2.0","id":6,"method":"ping"})).await;
assert!(r["result"].is_object(), "{r}");
// batch: stops at the first failure and reports both
let r =
c.call(7, "command_batch", json!({"steps": [{"id": "playhead.set", "params": {"seconds": 0.5}}, {"id": "no.such"}, {"id": "playhead.set"}]})).await;
assert_eq!(r["result"]["isError"], true, "{r}");
let v: Value = serde_json::from_str(r["result"]["content"][0]["text"].as_str().unwrap()).unwrap();
assert_eq!((v["completed"].as_u64(), v["failed"].as_u64(), v["results"].as_array().map(Vec::len)), (Some(1), Some(1), Some(2)), "{v}");
let r = c.call(8, "doc_inspect", json!({})).await;
let v: Value = serde_json::from_str(r["result"]["content"][0]["text"].as_str().unwrap()).unwrap();
assert!(v["project"].is_object() && v["sequence"].is_object(), "{v}");
let r = c.call(9, "render_preview", json!({"max_side": 64})).await;
assert_eq!(r["result"]["content"][0]["type"], "image", "{r}");
}
/// `filmcraft://document` and `filmcraft://commands` are listed and readable as JSON.
#[tokio::test(flavor = "multi_thread")]
async fn resources_document_and_commands() {
let mut c = Client::start(demo());
let init = c.init().await;
assert!(init["result"]["capabilities"]["resources"].is_object(), "{init}");
assert!(init["result"]["instructions"].as_str().unwrap().contains("doc_inspect"), "{init}");
let r = c.ask(json!({"jsonrpc":"2.0","id":1,"method":"resources/list"})).await;
let uris: Vec<&str> = r["result"]["resources"].as_array().unwrap().iter().filter_map(|x| x["uri"].as_str()).collect();
assert_eq!(uris, [DOCUMENT_URI, COMMANDS_URI], "{r}");
let r = c.ask(json!({"jsonrpc":"2.0","id":2,"method":"resources/read","params":{"uri":DOCUMENT_URI}})).await;
assert_eq!(r["result"]["contents"][0]["mimeType"], "application/json", "{r}");
let v: Value = serde_json::from_str(r["result"]["contents"][0]["text"].as_str().unwrap()).unwrap();
assert!(v["project"].is_object() && v["sequence"].is_object(), "{v}");
let r = c.ask(json!({"jsonrpc":"2.0","id":3,"method":"resources/read","params":{"uri":COMMANDS_URI}})).await;
assert!(r["result"]["contents"][0]["text"].as_str().unwrap().contains("timeline.razor"), "{r}");
let r = c.ask(json!({"jsonrpc":"2.0","id":4,"method":"resources/read","params":{"uri":"filmcraft://nope"}})).await;
assert!(r["error"]["code"].is_i64(), "{r}");
}
/// MCP 2026-07-28 clients (current Claude Code) negotiate per request and reject list and read
/// results without `resultType`, `ttlMs` and `cacheScope`; sessions that negotiated an older
/// revision through `initialize` get the old shape.
#[tokio::test(flavor = "multi_thread")]
async fn modern_clients_get_result_type_and_cache_hints() {
let mut c = Client::start(Session::default());
let meta = json!({
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientInfo": {"name": "t", "version": "1"},
"io.modelcontextprotocol/clientCapabilities": {},
});
let r = c.ask(json!({"jsonrpc":"2.0","id":1,"method":"server/discover","params":{"_meta":meta}})).await;
assert!(r["result"]["supportedVersions"].as_array().unwrap().contains(&json!("2026-07-28")), "{r}");
for (id, method, params) in [
(2, "tools/list", json!({"_meta": meta})),
(3, "resources/list", json!({"_meta": meta})),
(4, "resources/read", json!({"_meta": meta, "uri": DOCUMENT_URI})),
] {
let r = c.ask(json!({"jsonrpc":"2.0","id":id,"method":method,"params":params})).await;
assert_eq!(r["result"]["resultType"], "complete", "{method}: {r}");
assert!(r["result"]["ttlMs"].is_u64(), "{method}: {r}");
assert!(matches!(r["result"]["cacheScope"].as_str(), Some("public" | "private")), "{method}: {r}");
}
let mut c = Client::start(Session::default());
c.init().await;
for (id, method, params) in [(2, "tools/list", json!({})), (3, "resources/list", json!({})), (4, "resources/read", json!({"uri": DOCUMENT_URI}))] {
let r = c.ask(json!({"jsonrpc":"2.0","id":id,"method":method,"params":params})).await;
assert!(r["result"].is_object() && r["result"].get("resultType").is_none() && r["result"].get("ttlMs").is_none(), "{method}: {r}");
}
}
/// docs/agents.md § Long exports: a blocking export reports `notifications/progress` for its
/// token (none without one), answers `ping` meanwhile, and `notifications/cancelled` stops
/// it, deletes the partial file and sends no response.
#[tokio::test(flavor = "multi_thread")]
async fn export_progress_and_cancel() {
let dir = std::env::temp_dir().join(format!("filmcraft-mcp-progress-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let mut c = Client::start(demo());
c.init().await;
let export = |id: u64, name: &str, token: Option<Value>, seconds: f64| {
let path = dir.join(name).to_string_lossy().to_string();
let params = json!({"path": path, "format": "h264", "width": 640, "height": 360, "audio": false, "range": "custom", "startSeconds": 0, "endSeconds": seconds, "wait": true});
let mut call =
json!({"jsonrpc":"2.0","id":id,"method":"tools/call","params":{"name":"command_run","arguments":{"id":"file.exportMedia","params":params}}});
if let Some(t) = token {
call["params"]["_meta"] = json!({"progressToken": t});
}
call.to_string()
};
// no token: no notifications, the same response as before
c.send(&export(1, "quiet.mp4", None, 0.5)).await;
let m = c.next().await;
assert_eq!((m["id"].as_u64(), &m["result"]["isError"]), (Some(1), &json!(false)), "{m}");
// progress, strictly increasing, with a total; ping answered while the export runs
c.send(&export(2, "a.mp4", Some(json!("tok")), 1.0)).await;
let (mut last, mut notes, mut pinged, mut pong) = (-1.0, 0, false, false);
let done = loop {
let m = c.next().await;
if m["method"] == "notifications/progress" {
assert_eq!(m["params"]["progressToken"], "tok", "{m}");
let p = m["params"]["progress"].as_f64().unwrap();
assert!(p > last && m["params"]["total"].as_f64().unwrap() >= p, "{m}");
last = p;
notes += 1;
if !pinged {
pinged = true;
c.send(r#"{"jsonrpc":"2.0","id":3,"method":"ping"}"#).await;
}
} else if m["id"] == 3 {
pong = true;
} else if m["id"] == 2 {
break m;
}
};
assert_eq!(done["result"]["isError"], false, "{done}");
assert!(notes >= 2 && pong, "{notes} notifications, ping answered during the export: {pong}");
assert!(dir.join("a.mp4").exists());
// cancel after the first notification: no response, no partial file
c.send(&export(4, "b.mp4", Some(json!(7)), 8.0)).await;
loop {
let m = c.next().await;
assert_ne!(m["id"], 4, "finished before the cancel: {m}");
if m["method"] == "notifications/progress" {
break;
}
}
c.send(r#"{"jsonrpc":"2.0","method":"notifications/cancelled","params":{"requestId":4}}"#).await;
let t0 = std::time::Instant::now();
while dir.join("b.mp4").exists() || t0.elapsed() < std::time::Duration::from_millis(500) {
assert!(t0.elapsed().as_secs() < 30, "partial file still there");
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
}
c.send(r#"{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"command_run","arguments":{"id":"jobs.list"}}}"#).await;
let m = loop {
let m = c.next().await;
assert_ne!(m["id"], 4, "no response for a cancelled request: {m}");
if m["id"] == 5 {
break m;
}
};
let jobs: Value = serde_json::from_str(m["result"]["content"][0]["text"].as_str().unwrap()).unwrap();
let last = jobs.as_array().and_then(|a| a.last()).cloned().unwrap_or_default();
assert_eq!(last["result"]["error"], "cancelled", "{jobs}");
assert!(!dir.join("b.mp4").exists());
let _ = std::fs::remove_dir_all(&dir);
}
/// A command that panicked used to poison the session lock, and every later call failed with
/// "session lock poisoned" until the server was restarted.
#[tokio::test(flavor = "multi_thread")]
async fn a_poisoned_session_lock_keeps_serving() {
let m = FilmcraftMcp::headless(demo());
let Backend::Headless(s) = &*m.backend else { panic!("headless backend") };
let s = s.clone();
let _ = std::thread::spawn(move || {
let _g = s.lock().unwrap();
panic!("a command panicked");
})
.join();
let v = m.run("sequence.inspect", json!({})).await.unwrap();
assert!(v.is_object(), "{v}");
let r = m.render_frame(Parameters(RenderParams { seconds: Some(0.5), max_side: Some(64) })).await.unwrap();
assert_ne!(r.is_error, Some(true));
}
}

View File

@ -39,6 +39,9 @@ claude mcp add filmcraft-headless -- /abs/path/filmcraft/target/release/filmcraf
|---|---|---|
| `command_list` | both | every command: id, label, menu, shortcut, params, enabled now (`filter`, `enabled_only`) |
| `command_run` | both | run a command `{id, params}`; edits are undoable |
| `command_batch` | both | run several commands in order `{steps: [{id, params}], stop_on_error}` → `{completed, failed, results}` |
| `doc_inspect` | both | the project tree and the active sequence in one call (`project_inspect` + `sequence_inspect`) |
| `render_preview` | both | same as `render_frame` |
| `project_inspect` | both | bins and items with ids, types, durations; active sequence |
| `sequence_inspect` | both | the active sequence: tracks, clips (ticks and frames), effects, transitions, markers, playhead, selection |
| `media_import` | both | import files by absolute path (`text`, one path per line) |
@ -55,6 +58,47 @@ claude mcp add filmcraft-headless -- /abs/path/filmcraft/target/release/filmcraf
Typical loop: `project_inspect` / `sequence_inspect` → get ids → `command_run` → `render_frame` or
`ui_screenshot` → look at the result → `edit.undo` if it's wrong.
### Conventions
The server follows a few conventions that make it predictable for agents. They are not
FilmCraft-specific: the sibling craft apps' MCP servers can follow the same ones, so an agent that has
driven one app can drive the others.
- **Core tools.** `command_list`, `command_run`, `command_batch`, `doc_inspect` and `render_preview`
work in both modes. The FilmCraft tools above stay as they are.
- **Titles and annotations.** Every tool has a `title` and the four MCP hints (`readOnlyHint`,
`destructiveHint`, `idempotentHint`, `openWorldHint`), so a client can let the read-only tools
(lists, inspects, renders, screenshots) run without asking and ask before edits. When you add a
tool, set them in its `#[tool(...)]` attribute; a test checks every listed tool.
- **Strict arguments.** An unknown tool argument is a JSON-RPC `-32602` error that names it and the
accepted ones (`unknown argument "filtr" for command_list; expected: enabled_only, filter`), not a
silently ignored key. `command_run.params` goes to the command unchanged.
- **Errors.** A failing command is an `isError` result with the engine's message. A line that is not
JSON gets a `-32700` parse error (id `null`) and the server keeps serving. A command that panics is
reported as `internal error: …`, and the session stays usable.
- **Resources.** `filmcraft://document` (the project tree and active sequence, as `doc_inspect`) and
`filmcraft://commands` (the command catalog, as `command_list`), both `application/json`. For MCP
2026-07-28 clients such as current Claude Code, list and read results carry the `ttlMs` and
`cacheScope` hints that revision requires.
### Long exports: progress and cancellation
`command_run {"id": "file.exportMedia", "params": {…, "wait": true}}` in headless mode blocks until
the file is written. It uses the MCP progress and cancellation utilities, so a client can show
progress and stop it:
- The export runs as the same background job `jobs.list` shows. The session is locked only for short
polls, so other requests (`ping`, `doc_inspect`, `jobs.list`, …) are answered while it encodes.
- With `"_meta": {"progressToken": T}` on the `tools/call`, the server sends
`notifications/progress` `{progressToken: T, progress: <frames done>, total: <frames>, message:
<job status>}` at most every 100 ms while frames advance. Without a token it sends none.
- `notifications/cancelled` for the request stops the encode at the next batch (`jobs.cancel`),
deletes the partial output (the movie, or the frames and caption sidecar written so far) and sends
no response, as the MCP cancellation utility asks.
- Without `wait` the command returns `{job, path}` at once, as before: poll `jobs.list` and stop it
with `jobs.cancel`. Other exports (`file.exportFrame`, interchange formats, …) finish quickly and
ignore the token. Bridge mode forwards the call unchanged; the app shows its own progress.
## 2. Control channel
`filmcraft --control 9876` (or `FILMCRAFT_CONTROL_PORT=9876`) listens on `127.0.0.1` only. Send one

View File

@ -103,7 +103,10 @@ Ctrl), `Ctrl` (macOS ⌃), `Alt`, `Shift`. The dialog (Edit ▸ Keyboard Shortcu
## MCP
`filmcraft-cli mcp` serves MCP on stdio: headless (in-process session; `--demo` / `--project p.fcproj`)
or `--bridge 127.0.0.1:9876` to drive the running app. Tools: `command_list`, `command_run`,
`project_inspect`, `sequence_inspect`, `media_import`, `render_frame`, `ui_inspect`, `ui_elements`,
`ui_click`, `ui_drag`, `ui_key`, `ui_type`, `ui_screenshot`, `ui_control`. `.mcp.json` registers both.
`command_batch`, `doc_inspect`, `render_preview`, `project_inspect`, `sequence_inspect`, `media_import`,
`render_frame`, `ui_inspect`, `ui_elements`, `ui_click`, `ui_drag`, `ui_key`, `ui_type`, `ui_screenshot`,
`ui_control`; resources `filmcraft://document` and `filmcraft://commands`. `.mcp.json` registers both.
Annotations, argument checking, errors and export progress / cancellation:
[agents.md § Conventions](agents.md#conventions).
Time is in ticks (254 016 000 000 per second); commands also accept `seconds`, `frame` or `timecode`.