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 +52 -54
- package/package.json +19 -5
- package/runner.mjs +70 -0
- package/server.mjs +28 -124
- package/session.mjs +55 -0
- package/tools.mjs +45 -0
package/README.md
CHANGED
|
@@ -1,80 +1,78 @@
|
|
|
1
1
|
# keyframe-mcp
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
|
|
7
|
+
Requires Node 18+ and Google Chrome. No browser download or Keyframe account is needed.
|
|
19
8
|
|
|
20
|
-
```
|
|
9
|
+
```sh
|
|
21
10
|
claude mcp add keyframe -- npx -y keyframe-mcp
|
|
22
11
|
```
|
|
23
12
|
|
|
24
|
-
|
|
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
|
-
|
|
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
|
-
##
|
|
21
|
+
## Workflow
|
|
41
22
|
|
|
42
|
-
|
|
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
|
-
|
|
45
|
-
|
|
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
|
-
|
|
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 |
|
|
40
|
+
| Tool | Purpose |
|
|
55
41
|
| --- | --- |
|
|
56
|
-
| `keyframe_open`
|
|
57
|
-
| `keyframe_methods` |
|
|
58
|
-
| `keyframe_call`
|
|
59
|
-
| `
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
|
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
|
|
66
|
-
| `
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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": {
|
|
6
|
+
"bin": {
|
|
7
|
+
"keyframe-mcp": "server.mjs"
|
|
8
|
+
},
|
|
7
9
|
"main": "server.mjs",
|
|
8
|
-
"files": [
|
|
9
|
-
|
|
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": {
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
52
|
-
{
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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('
|
|
127
|
-
await
|
|
128
|
-
|
|
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)
|