diff --git a/src/cli-timeline.ts b/src/cli-timeline.ts index 5228de6..6959433 100644 --- a/src/cli-timeline.ts +++ b/src/cli-timeline.ts @@ -1,9 +1,7 @@ -import { resolve } from 'node:path'; import { appendOp } from './session/store.js'; -import { buildFrameAtPlan, CompileError, compileTimeline } from './timeline/compile.js'; +import { buildFrameAtPlan, checkExportable, compileTimeline } from './timeline/compile.js'; import { type Clip, - type Composition, clipDuration, clipEndSec, clipsAtTime, @@ -89,25 +87,6 @@ function clipLabel(clip: Clip): string { } } -/** Dry-run the export compiler (pure — no FFmpeg runs) to report whether the - * document can export and, if not, the first blocker. */ -function checkExportable( - comp: Composition, - media: Awaited>, -): { exportable: boolean; blockers: string[] } { - try { - compileTimeline(comp, { - media, - dir: getWorkspace(), - output: resolve(getWorkspace(), '.probe.mp4'), - }); - return { exportable: true, blockers: [] }; - } catch (err) { - if (err instanceof CompileError) return { exportable: false, blockers: [err.message] }; - throw err; - } -} - export async function runTimeline(args: string[]): Promise { const [sub, ...rest] = args; const { positional, flags } = parseArgs(rest); @@ -263,7 +242,7 @@ export async function runTimeline(args: string[]): Promise { case 'show': { const comp = await readComposition(); - const { exportable, blockers } = checkExportable(comp, await buildMediaMap()); + const { exportable, blockers } = checkExportable(comp, await buildMediaMap(), getWorkspace()); out({ rev: comp.rev, durationSec: compositionDuration(comp), diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 354c826..f9d15b0 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -1,7 +1,11 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { z } from 'zod'; -import { CompileError, compileTimeline } from '../timeline/compile.js'; +import { CompileError, checkExportable, compileTimeline } from '../timeline/compile.js'; +import type { Composition } from '../timeline/composition.js'; import { applyVerbs, readComposition, @@ -10,18 +14,64 @@ import { } from '../timeline/document-store.js'; import { buildMediaMap } from '../timeline/media-registry.js'; import { runPlan } from '../timeline/run-plan.js'; -import { CompositionVerbSchema } from '../timeline/verbs.js'; -import { makeVerbContext, summarizeComposition } from '../ui/timeline-tools.js'; +import { EditableVerbSchema } from '../timeline/verbs.js'; +import { + type CompositionSummary, + makeVerbContext, + summarizeComposition, +} from '../ui/timeline-tools.js'; import { ensureWorkspace, getWorkspace, newOutputPath } from '../workspace.js'; const MCP_NAME = 'makemyclip-editor'; -const MCP_VERSION = '0.3.0'; + +/** + * Read the package's own version, walking up from this module so it resolves both + * from source (src/mcp/server.ts under vitest) and bundled (dist/cli.js at runtime) + * — the depth to package.json differs between the two, so a fixed relative path + * would break one of them. + */ +function resolvePackageVersion(): string { + let dir = dirname(fileURLToPath(import.meta.url)); + for (let i = 0; i < 8; i++) { + const pkgPath = join(dir, 'package.json'); + if (existsSync(pkgPath)) { + try { + const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) as { + name?: string; + version?: string; + }; + if (pkg.name === '@makemyclip/editor' && pkg.version) return pkg.version; + } catch { + // not our package.json (or unreadable) — keep walking up + } + } + const parent = dirname(dir); + if (parent === dir) break; + dir = parent; + } + return '0.0.0'; +} + +const MCP_VERSION = resolvePackageVersion(); /** Wrap any JSON-able value as an MCP text result. */ function jsonResult(value: unknown): { content: { type: 'text'; text: string }[] } { return { content: [{ type: 'text', text: JSON.stringify(value, null, 2) }] }; } +/** + * The document summary plus whether it can render — the SAME renderability signal + * the CLI `timeline show` and the `clip ui` give, so the MCP agent isn't blind to + * a gap/blocker until it tries to export. + */ +async function describeComposition( + comp: Composition, +): Promise { + const media = await buildMediaMap(); + const { exportable, blockers } = checkExportable(comp, media, getWorkspace()); + return { ...summarizeComposition(comp), exportable, blockers }; +} + /** * Build the MakeMyClip Editor MCP server — a second front door onto the SAME * op-aware, undoable CompositionDoc the CLI, the `clip ui`, and the Claude Code @@ -37,9 +87,9 @@ export function buildMcpServer(): McpServer { { title: 'Show the timeline', description: - 'Read the current timeline document — tracks, clips, and timings. Call this to ground yourself BEFORE editing and to VERIFY a change AFTER.', + 'Read the current timeline document — tracks, clips, timings, and whether it can render (exportable + any blockers). Call this to ground yourself BEFORE editing and to VERIFY a change AFTER.', }, - async () => jsonResult(summarizeComposition(await readComposition())), + async () => jsonResult(await describeComposition(await readComposition())), ); server.registerTool( @@ -47,12 +97,12 @@ export function buildMcpServer(): McpServer { { title: 'Edit the timeline', description: - 'Apply one or more editing verbs to the document as ONE undoable change. Verbs: add_media, add_text, add_color, trim, move, split, remove, transition. Batch related verbs into a single call. Media paths must be inside the workspace. Returns the updated document summary.', - inputSchema: { verbs: z.array(CompositionVerbSchema).min(1) }, + 'Apply one or more editing verbs to the document as ONE undoable change. Verbs: add_media, add_text, add_color, trim, move, split, remove, transition. Batch related verbs into a single call. Media paths must be inside the workspace. Returns the updated document summary, including whether it can now render.', + inputSchema: { verbs: z.array(EditableVerbSchema).min(1) }, }, async ({ verbs }) => { const { doc, ops } = await applyVerbs(verbs, makeVerbContext()); - return jsonResult({ applied: ops.length, document: summarizeComposition(doc) }); + return jsonResult({ applied: ops.length, document: await describeComposition(doc) }); }, ); diff --git a/src/timeline/compile.ts b/src/timeline/compile.ts index 6392b16..5dcb5b4 100644 --- a/src/timeline/compile.ts +++ b/src/timeline/compile.ts @@ -504,6 +504,26 @@ export function buildFrameAtPlan( return { steps: [segStep, frameStep], output: ctx.output, durationSec: 0 }; } +/** + * Dry-run the export compiler (pure — no FFmpeg runs) to report whether the + * document can render and, if not, the first blocking reason. Shared by the CLI + * `timeline show`, the `clip ui` exportable route, and the MCP `timeline_show` + * so every surface tells the agent the SAME thing about renderability. + */ +export function checkExportable( + comp: Composition, + media: Map, + dir: string, +): { exportable: boolean; blockers: string[] } { + try { + compileTimeline(comp, { media, dir, output: resolve(dir, '.probe.mp4') }); + return { exportable: true, blockers: [] }; + } catch (err) { + if (err instanceof CompileError) return { exportable: false, blockers: [err.message] }; + throw err; + } +} + export function compileTimeline(comp: Composition, ctx: CompileContext): FfmpegPlan { const track = selectVideoTrack(comp); const clips = [...track.clips].sort((a, b) => a.startSec - b.startSec); diff --git a/src/timeline/verbs.ts b/src/timeline/verbs.ts index a94ab0f..ce58c1e 100644 --- a/src/timeline/verbs.ts +++ b/src/timeline/verbs.ts @@ -23,7 +23,7 @@ import type { MediaId } from './schema.js'; * Per-field `.describe()` text is surfaced to the model by the AI SDK, so the * verb schema doubles as the agent's tool documentation. */ -export const CompositionVerbSchema = z.discriminatedUnion('verb', [ +const EDITABLE_VERB_SCHEMAS = [ z.object({ verb: z.literal('add_media'), id: z @@ -118,15 +118,32 @@ export const CompositionVerbSchema = z.discriminatedUnion('verb', [ durationSec: z.number().positive().max(10).optional(), track: z.string().optional(), }), - z.object({ - verb: z.literal('set_transform'), - clipId: z.string(), - scale: z.number().positive().optional(), - x: z.number().optional().describe('Horizontal center in [0,1].'), - y: z.number().optional().describe('Vertical center in [0,1].'), - rotationDeg: z.number().optional(), - opacity: z.number().min(0).max(1).optional(), - }), +] as const; + +/** + * `set_transform` is split out from the editable set: the compiler rejects a + * non-identity transform (see `assertCompilable` in compile.ts), so surfaces that + * expose only renderable edits — the `clip ui` inspector and the MCP server — omit + * it. The CLI and the op layer still accept it. + */ +const TRANSFORM_VERB_SCHEMA = z.object({ + verb: z.literal('set_transform'), + clipId: z.string(), + scale: z.number().positive().optional(), + x: z.number().optional().describe('Horizontal center in [0,1].'), + y: z.number().optional().describe('Vertical center in [0,1].'), + rotationDeg: z.number().optional(), + opacity: z.number().min(0).max(1).optional(), +}); + +/** Renderable editing verbs only — no `set_transform`. What the `clip ui` and the + * MCP `timeline_edit` accept, so neither can author a doc the compiler refuses. */ +export const EditableVerbSchema = z.discriminatedUnion('verb', EDITABLE_VERB_SCHEMAS); + +/** The full verb vocabulary, including `set_transform` (accepted by the CLI/op layer). */ +export const CompositionVerbSchema = z.discriminatedUnion('verb', [ + ...EDITABLE_VERB_SCHEMAS, + TRANSFORM_VERB_SCHEMA, ]); export type CompositionVerb = z.infer; export type CompositionVerbKind = CompositionVerb['verb']; diff --git a/src/ui/server.ts b/src/ui/server.ts index 9af6d10..232cfd3 100644 --- a/src/ui/server.ts +++ b/src/ui/server.ts @@ -14,7 +14,12 @@ import { ZodError, z } from 'zod'; import { probe } from '../ffmpeg/probe.js'; import { appendOp, readSession, SessionCorruptError, snapshotsDir } from '../session/store.js'; import type { SessionEntry } from '../session/types.js'; -import { buildFrameAtPlan, CompileError, compileTimeline } from '../timeline/compile.js'; +import { + buildFrameAtPlan, + CompileError, + checkExportable, + compileTimeline, +} from '../timeline/compile.js'; import { applyVerbs, CompositionConflictError, @@ -213,18 +218,7 @@ export async function startUiServer(options: UiServerOptions = {}): Promise { const comp = await readComposition(); const media = await buildMediaMap(); - try { - compileTimeline(comp, { - media, - dir: getWorkspace(), - output: resolve(getWorkspace(), '.probe.mp4'), - }); - return c.json({ exportable: true, blockers: [] as string[] }); - } catch (err) { - if (err instanceof CompileError) - return c.json({ exportable: false, blockers: [err.message] }); - throw err; - } + return c.json(checkExportable(comp, media, getWorkspace())); }); /** diff --git a/src/ui/timeline-tools.ts b/src/ui/timeline-tools.ts index 0918b3b..d9ce0d9 100644 --- a/src/ui/timeline-tools.ts +++ b/src/ui/timeline-tools.ts @@ -24,8 +24,19 @@ export function makeVerbContext(): VerbContext { }; } +export interface CompositionSummary { + rev: number; + durationSec: number; + canvas: { width: number; height: number; fps: number }; + tracks: { + id: string; + kind: string; + clips: { id: string; kind: string; startSec: number; endSec: number }[]; + }[]; +} + /** Compact, agent-readable view of the document — the textual "eyes". */ -export function summarizeComposition(comp: Composition): unknown { +export function summarizeComposition(comp: Composition): CompositionSummary { return { rev: comp.rev, durationSec: compositionDuration(comp), diff --git a/tests/mcp-server.test.ts b/tests/mcp-server.test.ts index 77a695d..afb1a1b 100644 --- a/tests/mcp-server.test.ts +++ b/tests/mcp-server.test.ts @@ -1,4 +1,5 @@ import { mkdtemp, rm } from 'node:fs/promises'; +import { createRequire } from 'node:module'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; @@ -6,6 +7,8 @@ import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { buildMcpServer } from '../src/mcp/server.js'; +const pkg = createRequire(import.meta.url)('../package.json') as { version: string }; + let workspace: string; let saved: string | undefined; @@ -60,6 +63,28 @@ describe('MCP server', () => { } }); + it('advertises its package version in the handshake', async () => { + const { client, close } = await connect(); + try { + expect(client.getServerVersion()?.version).toBe(pkg.version); + } finally { + await close(); + } + }); + + it('reports renderability (exportable + blockers) in timeline_show', async () => { + const { client, close } = await connect(); + try { + const show = payload(await client.callTool({ name: 'timeline_show', arguments: {} })); + // An empty doc isn't renderable, but show must still answer — never throw. + expect(show.exportable).toBe(false); + expect(Array.isArray(show.blockers)).toBe(true); + expect((show.blockers as string[]).length).toBeGreaterThan(0); + } finally { + await close(); + } + }); + it('edits the document through verbs and is undoable — the same op-aware path', async () => { const { client, close } = await connect(); try { @@ -107,4 +132,17 @@ describe('MCP server', () => { await close(); } }); + + it('rejects set_transform — the MCP exposes only renderable verbs', async () => { + const { client, close } = await connect(); + try { + const res = await client.callTool({ + name: 'timeline_edit', + arguments: { verbs: [{ verb: 'set_transform', clipId: 'c1', scale: 2 }] }, + }); + expect((res as { isError?: boolean }).isError).toBe(true); + } finally { + await close(); + } + }); });