Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 2 additions & 23 deletions src/cli-timeline.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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<ReturnType<typeof buildMediaMap>>,
): { 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<void> {
const [sub, ...rest] = args;
const { positional, flags } = parseArgs(rest);
Expand Down Expand Up @@ -263,7 +242,7 @@ export async function runTimeline(args: string[]): Promise<void> {

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),
Expand Down
68 changes: 59 additions & 9 deletions src/mcp/server.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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<CompositionSummary & { exportable: boolean; blockers: string[] }> {
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
Expand All @@ -37,22 +87,22 @@ 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(
'timeline_edit',
{
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) });
},
);

Expand Down
20 changes: 20 additions & 0 deletions src/timeline/compile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<MediaId, MediaInfo>,
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);
Expand Down
37 changes: 27 additions & 10 deletions src/timeline/verbs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<typeof CompositionVerbSchema>;
export type CompositionVerbKind = CompositionVerb['verb'];
Expand Down
20 changes: 7 additions & 13 deletions src/ui/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -213,18 +218,7 @@ export async function startUiServer(options: UiServerOptions = {}): Promise<UiSe
app.get('/api/timeline/exportable', async (c) => {
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()));
});

/**
Expand Down
13 changes: 12 additions & 1 deletion src/ui/timeline-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
38 changes: 38 additions & 0 deletions tests/mcp-server.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
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';
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;

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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();
}
});
});
Loading