keyframe-mcp 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,80 +1,78 @@
1
1
  # keyframe-mcp
2
2
 
3
- An [MCP](https://modelcontextprotocol.io) server that lets Claude — or any MCP client —
4
- **rig, animate and export 2D skeletal animations** by driving
5
- [Keyframe.it](https://www.keyframe.it.com), a free browser-based animation editor for game
6
- sprites.
7
-
8
- It opens the editor in your own Chrome and exposes Keyframe.it's `window.keyframe` scripting
9
- API (150+ methods) as tools. Rendered frames, clips and assets come back as **real images**, so
10
- the model sees the art it's working on — and the finished rig is sitting in your browser,
11
- ready to keep editing.
12
-
13
- Exports are Spine-compatible bundles (3.8 / 4.2 — `.json` or binary `.skel` + `.atlas` + `.png`)
14
- that load in pixi-spine and other Spine runtimes. Free, no account, nothing uploaded.
3
+ Create, inspect and export 2D skeletal animations in [Keyframe.it](https://www.keyframe.it.com/) from any MCP client.
15
4
 
16
5
  ## Install
17
6
 
18
- **Claude Code**
7
+ Requires Node 18+ and Google Chrome. No browser download or Keyframe account is needed.
19
8
 
20
- ```bash
9
+ ```sh
21
10
  claude mcp add keyframe -- npx -y keyframe-mcp
22
11
  ```
23
12
 
24
- **Claude Desktop / Cursor / any MCP client** — add to your MCP config:
13
+ Other clients:
25
14
 
26
15
  ```json
27
- {
28
- "mcpServers": {
29
- "keyframe": {
30
- "command": "npx",
31
- "args": ["-y", "keyframe-mcp"]
32
- }
33
- }
34
- }
16
+ {"mcpServers":{"keyframe":{"command":"npx","args":["-y","keyframe-mcp"]}}}
35
17
  ```
36
18
 
37
- Requires **Node 18+** and **Google Chrome** installed. It drives your existing Chrome — no
38
- browser download.
19
+ Version 0.2.0 adds persistent projects, named tools with validated schemas, complete clip creation, animation checks, editable image layers and file exports. Restart the MCP client after updating. For a local checkout, run `npm ci` in `mcp/`, then configure `node` with the absolute path to `mcp/server.mjs`.
39
20
 
40
- ## Usage
21
+ ## Workflow
41
22
 
42
- Ask your assistant something like:
23
+ 1. `keyframe_open` resumes a dedicated Chrome profile and waits for saved projects to restore.
24
+ 2. Read `keyframe_methods`, `keyframe_call {method:"recipes"}` and the current document with `keyframe_call {method:"getState"}`.
25
+ 3. Build a complete clip with `keyframe_build_animation`. All poses are validated before the document changes. Partial transform values carry forward from setup. `loop:true` copies the first pose to the end; `replace:true` replaces all tracks in an existing clip. FPS applies to the entire project.
26
+ 4. Review `keyframe_contact_sheet` and `keyframe_check_animation`. Inspect motion with `keyframe_call {method:"renderClip",args:["idle"]}`. Warnings can be intentional; contact-sheet framing is fitted to the clip, and loop transform checks exclude springs.
27
+ 5. `keyframe_export {format:"project"}` writes an editable JSON file. Use `gif` for a preview or `spine` for a ZIP with skeleton, atlas, textures and test.html. Files return as MCP resource links plus local paths; supporting clients can read them with resources/read.
28
+ 6. `keyframe_open_result` saves and opens visible Chrome so the user can continue editing. The window lasts while the connector is running; saved projects persist after shutdown.
43
29
 
44
- > Open Keyframe.it, load the noir example, and make a 2-second idle animation where she
45
- > breathes and her hair sways. Show me the result.
30
+ Example clip tool arguments (use real bone names from the project):
31
+
32
+ ```json
33
+ {"spec":{"name":"idle","duration":2,"fps":24,"loop":true,"poses":[{"time":0,"bones":{"root":{"y":0,"scaleY":1}}},{"time":1,"bones":{"root":{"y":-6,"scaleY":1.03}}}]}}
34
+ ```
46
35
 
47
- The assistant calls `keyframe_open`, reads the API with `keyframe_methods`, then works through
48
- `keyframe_call`. For multi-step jobs it should read the built-in recipes first
49
- (`keyframe_call {method: "recipes"}`) — they encode the right order of operations for slicing
50
- sheets, rigging, meshing and weights, idle loops, walk cycles and win FX.
36
+ Read `keyframe_session` and pass its `projectId` and `revision` in `expected` to build/configure/layer edits to reject stale commands. Requests run sequentially. Changes are saved before the tool responds. If saving fails after a mutation, the error says `applied:true`; retry `keyframe_save` before closing.
51
37
 
52
38
  ## Tools
53
39
 
54
- | Tool | What it does |
40
+ | Tool | Purpose |
55
41
  | --- | --- |
56
- | `keyframe_open` `{url?}` | Open the editor (launches Chrome on first use, reuses the tab after). Returns an overview. **Call first.** |
57
- | `keyframe_methods` | List every scripting method with a one-line description. |
58
- | `keyframe_call` `{method, args?}` | Call any `window.keyframe` method. Image results (`renderFrame`, `renderClip`, `getAssetPng`) return as actual images. |
59
- | `keyframe_screenshot` | Screenshot the whole editor UI (rarely needed). |
60
-
61
- ## Environment
62
-
63
- | Variable | Default | Meaning |
42
+ | `keyframe_open` | Open/resume the editor, optional URL |
43
+ | `keyframe_methods` | Discover all scripting methods |
44
+ | `keyframe_call` | Call a method with positional JSON arguments; image results appear inline |
45
+ | `keyframe_session` | Active project and revision |
46
+ | `keyframe_list_projects` / `keyframe_open_project` | Find and reopen saved projects |
47
+ | `keyframe_save` | Wait for browser storage to commit |
48
+ | `keyframe_build_animation` | Create a complete validated clip in one undo step |
49
+ | `keyframe_configure_animation` | Duration and project FPS |
50
+ | `keyframe_apply_pose` | Batch named bone transforms in the active clip |
51
+ | `keyframe_contact_sheet` | Timestamped pose grid, empty/edge-frame checks |
52
+ | `keyframe_check_animation` | Loop, visibility and rig diagnostics |
53
+ | `keyframe_image_layers` / `keyframe_image_layer_png` | Inspect editable layers and pixels |
54
+ | `keyframe_edit_image_layers` | Atomic layer add/update/reorder/delete, including editable text |
55
+ | `keyframe_export` | Project JSON, GIF or Spine ZIP as files |
56
+ | `keyframe_open_result` | Save and show the editable result |
57
+ | `keyframe_screenshot` | Whole editor UI |
58
+
59
+ Every named tool advertises its JSON Schema. Errors use MCP `isError:true` and `{ok:false,error}`. The generic call tool remains available for rigging, slicing, meshes, physics and other advanced methods.
60
+
61
+ ## Storage and handoff
62
+
63
+ The connector uses **its own Chrome profile**, not existing personal Chrome tabs. Projects belong to that profile and site origin. Export project JSON and import it in your usual browser to transfer work. Existing projects from version 0.1.0's temporary browser profile cannot be recovered unless previously exported.
64
+
65
+ | Variable | Default | Purpose |
64
66
  | --- | --- | --- |
65
- | `KEYFRAME_URL` | `https://www.keyframe.it.com/` | Editor URL — set `http://localhost:5173` to drive a local dev build |
66
- | `KEYFRAME_HEADLESS` | `1` | Set `0` to watch the work happen in a visible Chrome window |
67
-
68
- Tip: `KEYFRAME_HEADLESS=0` is genuinely fun the first time — you watch bones and keyframes
69
- appear as the model works.
67
+ | `KEYFRAME_URL` | `https://www.keyframe.it.com/` | Editor URL; HTTP allowed for localhost development |
68
+ | `KEYFRAME_PROFILE_DIR` | `~/.keyframe-mcp/profile` | Persistent browser storage; use separate directories for concurrent instances |
69
+ | `KEYFRAME_HEADLESS` | `1` | Set `0` to watch work; open_result switches to visible Chrome |
70
+ | `KEYFRAME_EXPORT_DIR` | `~/.keyframe-mcp/exports` | Downloaded artifacts; files remain after shutdown |
70
71
 
71
- ## Docs
72
+ Close or Apply the human image editor before agent layer edits. Layers retain text, visibility, opacity, blend modes and locks. The composite stays compatible with existing rig/export tools. See the [full API reference](https://www.keyframe.it.com/llms.txt) for operation examples and limits.
72
73
 
73
- The scripting surface is documented in the editor (`window.keyframe.help()`) and at
74
- [keyframe.it.com/llms.txt](https://www.keyframe.it.com/llms.txt). For the whole product in one
75
- fetch, see [llms-full.txt](https://www.keyframe.it.com/llms-full.txt).
74
+ ## Development
76
75
 
77
- ## License
76
+ `npm test` runs schema, protocol, persistence, serialization, error and file-resource tests with a fake browser. `../e2e/agent-workflow.html` exercises the real editor API, canvas renders, layer compositing, exports and save/reload through a Vite dev server.
78
77
 
79
- MIT — see [LICENSE](./LICENSE). This covers the connector; the Keyframe.it editor is a separate
80
- hosted application.
78
+ MIT — see LICENSE. This license covers the connector; the editor is a separate hosted application.
package/package.json CHANGED
@@ -1,14 +1,27 @@
1
1
  {
2
2
  "name": "keyframe-mcp",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "MCP server for Keyframe.it — let Claude (or any MCP client) rig, animate and export 2D skeletal animations by driving the free Keyframe.it editor in your browser.",
5
5
  "type": "module",
6
- "bin": { "keyframe-mcp": "server.mjs" },
6
+ "bin": {
7
+ "keyframe-mcp": "server.mjs"
8
+ },
7
9
  "main": "server.mjs",
8
- "files": ["server.mjs", "README.md"],
9
- "engines": { "node": ">=18" },
10
+ "files": [
11
+ "server.mjs",
12
+ "session.mjs",
13
+ "runner.mjs",
14
+ "tools.mjs",
15
+ "README.md"
16
+ ],
17
+ "scripts": { "test": "node --test runner.test.mjs", "prepublishOnly": "npm test" },
18
+ "engines": {
19
+ "node": ">=18"
20
+ },
10
21
  "homepage": "https://www.keyframe.it.com/",
11
- "bugs": { "url": "https://www.keyframe.it.com/" },
22
+ "bugs": {
23
+ "url": "https://www.keyframe.it.com/"
24
+ },
12
25
  "author": "Keyframe.it",
13
26
  "license": "MIT",
14
27
  "keywords": [
@@ -30,6 +43,7 @@
30
43
  ],
31
44
  "dependencies": {
32
45
  "@modelcontextprotocol/sdk": "^1.12.0",
46
+ "ajv": "^8.20.0",
33
47
  "playwright-core": "^1.49.0"
34
48
  }
35
49
  }
package/runner.mjs ADDED
@@ -0,0 +1,70 @@
1
+ import Ajv from 'ajv'
2
+ import { mkdir, writeFile, readFile } from 'node:fs/promises'
3
+ import { homedir } from 'node:os'
4
+ import { join, resolve } from 'node:path'
5
+ import { pathToFileURL } from 'node:url'
6
+ import { randomUUID } from 'node:crypto'
7
+ import { definitions } from './tools.mjs'
8
+ const ajv = new Ajv({ allErrors: true, strict: false })
9
+ const registered = new Map(definitions.map(d => [d.name, { ...d, validate: ajv.compile(d.inputSchema) }]))
10
+ export function toContent(result) {
11
+ const image = result?.dataUrl?.match(/^data:(image\/[a-z+]+);base64,(.*)$/s)
12
+ if (image) {
13
+ const { dataUrl, ...metadata } = result
14
+ return { content: [{ type: 'image', mimeType: image[1], data: image[2] }, { type: 'text', text: JSON.stringify(metadata) }], structuredContent: metadata, ...(result.ok === false ? { isError: true } : {}) }
15
+ }
16
+ return { content: [{ type: 'text', text: JSON.stringify(result) ?? 'null' }], ...(result && typeof result === 'object' && !Array.isArray(result) ? { structuredContent: result } : {}), ...(result?.ok === false ? { isError: true } : {}) }
17
+ }
18
+ export class ToolRunner {
19
+ constructor(session, outputDir = process.env.KEYFRAME_EXPORT_DIR || join(homedir(), '.keyframe-mcp', 'exports')) {
20
+ this.session = session
21
+ this.outputDir = resolve(outputDir)
22
+ this.artifacts = new Map()
23
+ this.pending = Promise.resolve()
24
+ }
25
+ call(name, args) {
26
+ // Serialize requests so a second command cannot switch projects mid-edit.
27
+ const task = this.pending.then(() => this.dispatch(name, args)).catch(e => toContent({ ok: false, error: String(e?.message ?? e) }))
28
+ this.pending = task.then(() => {})
29
+ return task
30
+ }
31
+ async dispatch(name, args) {
32
+ const definition = registered.get(name)
33
+ if (!definition) throw new Error(`Unknown tool: ${name}`)
34
+ if (!definition.validate(args)) throw new Error(`Invalid arguments: ${ajv.errorsText(definition.validate.errors)}`)
35
+ const page = await this.session.open(name === 'keyframe_open' ? args.url : undefined)
36
+ if (name === 'keyframe_open_result') return toContent(await this.session.handoff())
37
+ if (name === 'keyframe_screenshot') return { content: [{ type: 'image', mimeType: 'image/png', data: (await page.screenshot({ type: 'png' })).toString('base64') }] }
38
+ if (name === 'keyframe_open') return toContent({ ...await this.session.invoke('describe'), profile: this.session.profile, visible: !this.session.headless, handoffTool: 'keyframe_open_result' })
39
+ const before = await this.session.invoke('sessionInfo')
40
+ const result = await this.session.invoke(name === 'keyframe_call' ? args.method : definition.method, name === 'keyframe_call' ? args.args ?? [] : definition.args(args))
41
+ const after = await this.session.invoke('sessionInfo')
42
+ if (before.revision !== after.revision || before.projectId !== after.projectId) {
43
+ try { await this.session.save() }
44
+ catch (e) { return toContent({ ok: false, error: `The command changed the project, but saving failed: ${e.message}. Retry keyframe_save before closing the editor.`, applied: true }) }
45
+ }
46
+ if (result?.artifact && result.ok !== false) return this.writeArtifact(result)
47
+ return toContent(result)
48
+ }
49
+ async writeArtifact(result) {
50
+ const artifact = result.artifact
51
+ const match = artifact.dataUrl?.match(/^data:([^;,]*)(?:;[^,]*)?;base64,(.*)$/s)
52
+ if (!match) throw new Error('Export did not return an embedded file')
53
+ const safeName = artifact.name.replace(/[^a-z0-9_.-]/gi, '_').replace(/^\.+/, '') || 'export'
54
+ await mkdir(this.outputDir, { recursive: true })
55
+ const path = join(this.outputDir, `${randomUUID()}-${safeName}`)
56
+ const bytes = Buffer.from(match[2], 'base64')
57
+ await writeFile(path, bytes, { flag: 'wx' })
58
+ const resource = { uri: pathToFileURL(path).href, name: safeName, mimeType: artifact.mimeType || match[1], size: bytes.length }
59
+ this.artifacts.set(resource.uri, { resource, path })
60
+ const metadata = { ok: true, projectId: result.projectId, path, ...resource }
61
+ return { content: [{ type: 'text', text: JSON.stringify(metadata) }, { type: 'resource_link', ...resource }], structuredContent: metadata }
62
+ }
63
+ resources() { return [...this.artifacts.values()].map(a => a.resource) }
64
+ async readResource(uri) {
65
+ const artifact = this.artifacts.get(uri)
66
+ if (!artifact) throw new Error('Unknown export resource; only files exported in this session can be read')
67
+ const bytes = await readFile(artifact.path)
68
+ return { contents: [{ uri, mimeType: artifact.resource.mimeType, blob: bytes.toString('base64') }] }
69
+ }
70
+ }
package/server.mjs CHANGED
@@ -1,131 +1,35 @@
1
1
  #!/usr/bin/env node
2
- // keyframe-mcp — MCP server that drives the Keyframe.it editor through its window.keyframe
3
- // scripting API. Launches the user's installed Chrome (playwright-core `channel: 'chrome'`,
4
- // no browser download) and proxies API calls as tools. Results carrying an image data URL
5
- // (renderFrame, renderClip, getAssetPng) come back as real MCP image content, so the model
6
- // SEES the art, not a base64 wall.
7
- //
8
- // Env: KEYFRAME_URL (default https://www.keyframe.it.com/), KEYFRAME_HEADLESS=0 to watch.
9
2
  import { Server } from '@modelcontextprotocol/sdk/server/index.js'
10
3
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
11
- import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'
4
+ import { CallToolRequestSchema, ListToolsRequestSchema, ListResourcesRequestSchema, ReadResourceRequestSchema } from '@modelcontextprotocol/sdk/types.js'
12
5
  import { chromium } from 'playwright-core'
13
-
14
- const DEFAULT_URL = process.env.KEYFRAME_URL || 'https://www.keyframe.it.com/'
15
- let browser = null
16
- let page = null
17
-
18
- async function ensurePage(url) {
19
- if (page && !page.isClosed()) return page
20
- if (!browser || !browser.isConnected()) {
21
- browser = await chromium.launch({
22
- channel: 'chrome',
23
- headless: process.env.KEYFRAME_HEADLESS !== '0',
24
- })
25
- }
26
- page = await browser.newPage({ viewport: { width: 1440, height: 900 } })
27
- await page.goto(url || DEFAULT_URL, { waitUntil: 'domcontentloaded' })
28
- await page.waitForFunction(() => !!globalThis.keyframe, undefined, { timeout: 30000 })
29
- return page
30
- }
31
-
32
- const text = (s) => ({ content: [{ type: 'text', text: typeof s === 'string' ? s : JSON.stringify(s, null, 1) }] })
33
-
34
- /** JSON result → MCP content; a data-URL image field becomes real image content. */
35
- function toContent(result) {
36
- if (result && typeof result === 'object' && typeof result.dataUrl === 'string' && result.dataUrl.startsWith('data:image/')) {
37
- const m = result.dataUrl.match(/^data:(image\/[a-z+]+);base64,(.*)$/s)
38
- if (m) {
39
- const { dataUrl, ...rest } = result
40
- return {
41
- content: [
42
- { type: 'image', mimeType: m[1], data: m[2] },
43
- { type: 'text', text: JSON.stringify({ ...rest, dataUrl: `(shown above, ${Math.round(m[2].length / 1366)}kB)` }, null, 1) },
44
- ],
45
- }
46
- }
47
- }
48
- return text(result)
6
+ import { pathToFileURL } from 'node:url'
7
+ import { EditorSession } from './session.mjs'
8
+ import { ToolRunner } from './runner.mjs'
9
+ import { advertisedTools } from './tools.mjs'
10
+
11
+ export function createServer(session = new EditorSession(chromium)) {
12
+ const runner = new ToolRunner(session)
13
+ const server = new Server({ name: 'keyframe-mcp', version: '0.2.0' }, { capabilities: { tools: {}, resources: {} } })
14
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: advertisedTools }))
15
+ server.setRequestHandler(CallToolRequestSchema, req => runner.call(req.params.name, req.params.arguments ?? {}))
16
+ server.setRequestHandler(ListResourcesRequestSchema, async () => ({ resources: runner.resources() }))
17
+ server.setRequestHandler(ReadResourceRequestSchema, req => runner.readResource(req.params.uri))
18
+ return { server, runner, session }
49
19
  }
50
20
 
51
- const TOOLS = [
52
- {
53
- name: 'keyframe_open',
54
- description:
55
- 'Open the Keyframe.it editor (launches Chrome on first use; reuses the tab afterwards). Optional url overrides the default (e.g. a local dev server). Returns the editor overview. Call this first.',
56
- inputSchema: { type: 'object', properties: { url: { type: 'string', description: 'editor URL (default: keyframe.it.com or $KEYFRAME_URL)' } } },
57
- },
58
- {
59
- name: 'keyframe_methods',
60
- description: 'List every window.keyframe API method with a one-line description. Call once after keyframe_open, before other calls.',
61
- inputSchema: { type: 'object', properties: {} },
62
- },
63
- {
64
- name: 'keyframe_call',
65
- description:
66
- "Call a window.keyframe API method by name, e.g. {method: 'getState'} or {method: 'poseBone', args: ['armL', {rotation: 20}]}. Results that contain an image (renderFrame, renderClip, getAssetPng) are returned as an actual image. Read keyframe_methods (and the matching recipes() entry for multi-step tasks) first.",
67
- inputSchema: {
68
- type: 'object',
69
- properties: {
70
- method: { type: 'string', description: 'API method name, e.g. getState, applyPose, renderFrame' },
71
- args: { type: 'array', description: 'positional arguments for the method (JSON values)' },
72
- },
73
- required: ['method'],
74
- },
75
- },
76
- {
77
- name: 'keyframe_screenshot',
78
- description: 'Screenshot the whole editor UI (rarely needed — prefer keyframe_call renderFrame/getAssetPng for art, getState for data).',
79
- inputSchema: { type: 'object', properties: {} },
80
- },
81
- ]
82
-
83
- const server = new Server({ name: 'keyframe-mcp', version: '0.1.0' }, { capabilities: { tools: {} } })
84
-
85
- server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }))
86
-
87
- server.setRequestHandler(CallToolRequestSchema, async (req) => {
88
- const { name, arguments: args = {} } = req.params
89
- try {
90
- if (name === 'keyframe_open') {
91
- const p = await ensurePage(args.url)
92
- const overview = await p.evaluate(() => globalThis.keyframe.describe())
93
- return text(overview)
94
- }
95
- if (name === 'keyframe_methods') {
96
- const p = await ensurePage()
97
- return text(await p.evaluate(() => globalThis.keyframe.help()))
98
- }
99
- if (name === 'keyframe_call') {
100
- const p = await ensurePage()
101
- const result = await p.evaluate(async ({ method, callArgs }) => {
102
- const fn = globalThis.keyframe?.[method]
103
- if (typeof fn !== 'function') {
104
- return { ok: false, error: `no method "${method}" — see keyframe_methods` }
105
- }
106
- try {
107
- const r = await fn.apply(globalThis.keyframe, callArgs)
108
- return r === undefined ? { ok: true } : r
109
- } catch (e) {
110
- return { ok: false, error: String(e?.message ?? e) }
111
- }
112
- }, { method: args.method, callArgs: Array.isArray(args.args) ? args.args : [] })
113
- return toContent(result)
114
- }
115
- if (name === 'keyframe_screenshot') {
116
- const p = await ensurePage()
117
- const buf = await p.screenshot({ type: 'png' })
118
- return { content: [{ type: 'image', mimeType: 'image/png', data: buf.toString('base64') }] }
119
- }
120
- return { ...text(`unknown tool "${name}"`), isError: true }
121
- } catch (e) {
122
- return { ...text(`error: ${String(e?.message ?? e)}`), isError: true }
21
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
22
+ const { server, runner, session } = createServer()
23
+ let closing = false
24
+ const shutdown = async () => {
25
+ if (closing) return
26
+ closing = true
27
+ try { await runner.pending; await session.close() }
28
+ catch (e) { console.error(`Could not save/close Keyframe: ${e.message}`) }
29
+ finally { await server.close(); process.exit(0) }
123
30
  }
124
- })
125
-
126
- process.on('SIGINT', async () => {
127
- await browser?.close().catch(() => {})
128
- process.exit(0)
129
- })
130
-
131
- await server.connect(new StdioServerTransport())
31
+ process.on('SIGINT', shutdown)
32
+ process.on('SIGTERM', shutdown)
33
+ process.stdin.on('end', shutdown)
34
+ await server.connect(new StdioServerTransport())
35
+ }
package/session.mjs ADDED
@@ -0,0 +1,55 @@
1
+ import { homedir } from 'node:os'
2
+ import { join, resolve } from 'node:path'
3
+ export function editorUrl(value) {
4
+ const url = new URL(value)
5
+ if (url.username || url.password || !(url.protocol === 'https:' || (url.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)))) throw new Error('Use HTTPS, or HTTP on localhost, for the editor URL')
6
+ return url.href
7
+ }
8
+ export class EditorSession {
9
+ constructor(chromium, env = process.env) {
10
+ this.chromium = chromium
11
+ this.url = editorUrl(env.KEYFRAME_URL || 'https://www.keyframe.it.com/')
12
+ this.profile = resolve(env.KEYFRAME_PROFILE_DIR || join(homedir(), '.keyframe-mcp', 'profile'))
13
+ this.headless = env.KEYFRAME_HEADLESS !== '0'
14
+ this.context = null
15
+ this.page = null
16
+ }
17
+ async open(url) {
18
+ const target = url ? editorUrl(url) : this.url
19
+ if (this.page && !this.page.isClosed() && this.page.url() !== target) await this.save()
20
+ if (!this.context) {
21
+ this.context = await this.chromium.launchPersistentContext(this.profile, { channel: 'chrome', headless: this.headless, viewport: { width: 1440, height: 900 } })
22
+ this.context.on('close', () => { this.context = null; this.page = null })
23
+ }
24
+ if (!this.page || this.page.isClosed()) this.page = this.context.pages().find(p => p.url() === target) ?? await this.context.newPage()
25
+ if (this.page.url() !== target) await this.page.goto(target, { waitUntil: 'domcontentloaded' })
26
+ this.url = target
27
+ await this.page.waitForFunction(() => !!globalThis.keyframe, undefined, { timeout: 30000 })
28
+ const status = await this.invoke('ready')
29
+ if (status?.ok === false) throw new Error(status.error)
30
+ this.url = this.page.url()
31
+ return this.page
32
+ }
33
+ async invoke(method, args = []) {
34
+ return this.page.evaluate(async ({ method, args }) => {
35
+ const api = globalThis.keyframe
36
+ if (!api || !Object.prototype.hasOwnProperty.call(api, method) || typeof api[method] !== 'function') return { ok: false, error: `Unknown API method ${method}; check keyframe_methods and update the editor if needed` }
37
+ try { return (await api[method](...args)) ?? { ok: true } }
38
+ catch (e) { return { ok: false, error: String(e?.message ?? e) } }
39
+ }, { method, args })
40
+ }
41
+ async save() {
42
+ if (!this.page || this.page.isClosed()) return
43
+ const saved = await this.invoke('saveProject')
44
+ if (saved?.ok === false) throw new Error(saved.error)
45
+ return saved
46
+ }
47
+ async handoff() {
48
+ await this.open()
49
+ const saved = await this.save()
50
+ if (this.headless) { await this.context.close(); this.headless = false; await this.open() }
51
+ await this.page.bringToFront()
52
+ return { ok: true, ...saved, url: this.url, visible: true, profile: this.profile, note: 'Continue editing in the opened Chrome window. This dedicated profile is separate from your usual browser. Export project JSON to transfer it.' }
53
+ }
54
+ async close() { try { await this.save() } finally { await this.context?.close() } }
55
+ }
package/tools.mjs ADDED
@@ -0,0 +1,45 @@
1
+ // Schemas are advertised to clients and validated before dispatch.
2
+ const str = { type: 'string', minLength: 1 }
3
+ const bool = { type: 'boolean' }
4
+ const number = (minimum, maximum) => ({ type: 'number', minimum, maximum })
5
+ const integer = (minimum, maximum) => ({ type: 'integer', minimum, maximum })
6
+ const object = (properties = {}, required = []) => ({ type: 'object', properties, required, additionalProperties: false })
7
+ const transform = { ...object(Object.fromEntries(['x', 'y', 'rotation', 'scaleX', 'scaleY', 'shearX', 'shearY'].map(k => [k, { type: 'number' }]))), minProperties: 1 }
8
+ const bones = { type: 'object', minProperties: 1, additionalProperties: transform }
9
+ const expected = object({ projectId: str, revision: integer(0, Number.MAX_SAFE_INTEGER) })
10
+ const textProperties = {
11
+ str, font: str, size: number(1, 1024), bold: bool, italic: bool, outline: number(0, 100), fill: str, stroke: str,
12
+ x: number(-16384, 16384), y: number(-16384, 16384), vertical: bool, distH: number(-100, 100), distV: number(-100, 100),
13
+ warp: object({ kind: { enum: ['arc', 'arch', 'bulge', 'shellL', 'shellU', 'flag', 'wave', 'fish', 'rise', 'fisheye', 'inflate', 'squeeze', 'twist'] }, bend: number(-100, 100) }, ['kind', 'bend']),
14
+ }
15
+ const text = object(textProperties, ['str', 'font', 'size', 'bold', 'italic', 'outline', 'fill', 'stroke', 'x', 'y'])
16
+ const layerChange = { oneOf: [
17
+ { ...object({ action: { const: 'add' }, name: str, src: str, text }, ['action', 'name']), oneOf: [{ required: ['src'], not: { required: ['text'] } }, { required: ['text'], not: { required: ['src'] } }] },
18
+ object({ action: { const: 'update' }, id: str, patch: { ...object({ name: str, visible: bool, locked: bool, opacity: number(0, 1), mode: { enum: ['source-over', 'multiply', 'screen', 'overlay', 'darken', 'lighten', 'color-dodge', 'color-burn', 'hard-light', 'soft-light', 'difference', 'hue', 'saturation', 'color', 'luminosity', 'lighter'] }, text: object(textProperties) }), minProperties: 1 } }, ['action', 'id', 'patch']),
19
+ object({ action: { const: 'move' }, id: str, targetId: str, above: bool }, ['action', 'id', 'targetId', 'above']),
20
+ object({ action: { const: 'delete' }, id: str }, ['action', 'id']),
21
+ ] }
22
+ const tool = (name, description, inputSchema = object(), method, args = () => []) => ({ name, description, inputSchema, method, args })
23
+ export const definitions = [
24
+ tool('keyframe_open', 'Open or resume a saved editor session in a dedicated Chrome profile. Call first; waits for project restoration.', object({ url: str })),
25
+ tool('keyframe_methods', 'List scripting methods; read recipes through keyframe_call for rigging workflows.', object(), 'help'),
26
+ tool('keyframe_call', 'Call a scripting method using positional arguments. Read keyframe_methods first. Mutations are saved; image results appear inline.', object({ method: str, args: { type: 'array' } }, ['method'])),
27
+ tool('keyframe_screenshot', 'Show the editor UI. Prefer contact sheets to assess animation.'),
28
+ tool('keyframe_session', 'Read active project, readiness and revision. Supply expected to edit tools to reject stale edits.', object(), 'sessionInfo'),
29
+ tool('keyframe_list_projects', 'List projects saved in this connector profile.', object(), 'listProjects'),
30
+ tool('keyframe_open_project', 'Save the active project and open a saved project by ID.', object({ projectId: str }, ['projectId']), 'openProject', a => [a.projectId]),
31
+ tool('keyframe_save', 'Wait for the active project to commit to browser storage.', object(), 'saveProject'),
32
+ tool('keyframe_build_animation', 'Build a complete clip atomically. Partial transforms carry forward from setup. loop:true copies the first pose to the end. replace:true replaces a clip and all its tracks. FPS is project-wide.', object({ spec: object({ name: str, duration: number(.05, 120), fps: integer(1, 60), loop: bool, replace: bool,
33
+ poses: { type: 'array', minItems: 1, maxItems: 2048, items: object({ time: number(0, 120), bones, easing: { enum: ['linear', 'step', 'easeIn', 'easeOut', 'easeInOut', 'bezier'] }, bezier: { type: 'array', items: { type: 'number' }, minItems: 4, maxItems: 4 } }, ['time', 'bones']) },
34
+ }, ['name', 'duration', 'poses']), expected }, ['spec']), 'buildAnimation', a => [a.spec, a.expected]),
35
+ tool('keyframe_configure_animation', 'Set clip duration and project FPS. Rejects shortening past keys/events.', object({ animation: str, duration: number(.05, 120), fps: integer(1, 60), expected }, ['animation']), 'configureAnimation', a => [a.animation, { duration: a.duration, fps: a.fps }, a.expected]),
36
+ tool('keyframe_apply_pose', 'Apply bone transforms at a time in the active clip. Validates all names and values before editing. Use build_animation for a whole clip.', object({ bones, time: number(0, 120) }, ['bones']), 'applyPose', a => [a.bones, a.time === undefined ? {} : { time: a.time }]),
37
+ tool('keyframe_contact_sheet', 'Show sampled frames with timestamps, empty frames and edge-touch warnings. Uses a fixed camera fitted to the clip; warnings indicate possible clipping.', object({ animation: str, samples: integer(2, 24), size: integer(64, 512) }), 'renderContactSheet', a => [a.animation, { samples: a.samples, size: a.size }]),
38
+ tool('keyframe_check_animation', 'Check endpoint discontinuities, sampled invisible parts and rig problems. Warnings can be intentional; also review rendered frames.', object({ animation: str, samples: integer(2, 60) }), 'inspectAnimation', a => [a.animation, { samples: a.samples }]),
39
+ tool('keyframe_export', 'Write an editable project JSON, animated GIF or Spine ZIP to disk. Returns an MCP resource and local path. Spine ZIP includes atlas, textures and a preview.', object({ format: { enum: ['project', 'gif', 'spine'] }, animation: str, version: { enum: ['3.8', '4.2'] }, binary: bool, size: integer(64, 1024), fps: integer(1, 60) }, ['format']), 'exportArtifact', a => [a]),
40
+ tool('keyframe_image_layers', 'List editable image layers and stable IDs in bottom-to-top order.', object({ asset: str }, ['asset']), 'getImageLayers', a => [a.asset]),
41
+ tool('keyframe_image_layer_png', 'Show one image layer as a PNG.', object({ asset: str, id: str }, ['asset', 'id']), 'getImageLayerPng', a => [a.asset, a.id]),
42
+ tool('keyframe_edit_image_layers', 'Atomically add text or embedded image layers, edit properties/text, reorder or delete layers. Updates composite and editable layers. Close the human image editor first.', object({ asset: str, operations: { type: 'array', minItems: 1, maxItems: 100, items: layerChange }, expected }, ['asset', 'operations']), 'editImageLayers', a => [a.asset, a.operations, a.expected]),
43
+ tool('keyframe_open_result', 'Save the project and show it in visible Chrome with the connector profile. Export project JSON to transfer to your usual browser.'),
44
+ ]
45
+ export const advertisedTools = definitions.map(({ method, args, ...definition }) => definition)