fix(mcp): target live automation by document and page

This commit is contained in:
Danila Poyarkov 2026-07-03 17:05:04 +03:00
parent 2c69e0949b
commit a4272a17bf
39 changed files with 598 additions and 117 deletions

View file

@ -31,6 +31,7 @@
- Add overlap analysis exports for automation consumers, including `computeOverlaps`, `analyzeOverlaps`, overlap result types, and parameter parsers from core subpath exports.
- Add world-matrix visual bounds to overlap analysis, covering vector/stroke/text geometry, ancestor clipping, rotated clipping frames, and nested ancestor rotations.
- Add the `@open-pencil/core/package.json` subpath export for package metadata consumers.
- Add explicit document/page targeting for live MCP and CLI automation, including `list_documents` / `openpencil documents` discovery and `document_id` / `page_id` target fields.
### Fixes
@ -44,7 +45,7 @@
- Fix stale variable bindings not cleaned up when fills/strokes arrays shrink — any indexed sub-path is now handled, not just `/color`.
- Fix desktop "Share This File" links to use the public `https://app.openpencil.dev/share/{roomId}` URL instead of the internal `tauri://localhost` app scheme.
- Fix tooltips around inspector dropdowns/popovers without breaking floating menu anchoring.
- Harden MCP calls with bounded page-tree responses, oversized-result errors, JSON HTTP responses, and stale WebSocket cleanup.
- Harden MCP calls with bounded page-tree responses, oversized-result errors, JSON HTTP responses, stale WebSocket cleanup, and live document/page target resolution to avoid active-tab drift.
- Improve Figma boolean imports by preserving XOR operations as editable exclude nodes and falling back to imported fill geometry when boolean path reconstruction cannot produce a path.
- Preserve rotated Figma transform origins for imported vector nodes.
- Render complex text fills through vector glyph outlines so imported Figma text can use the normal fill pipeline for gradients, images, patterns, and other non-solid paints.

View file

@ -0,0 +1,27 @@
export type AppTargetCliArgs = {
'document-id'?: string
'page-id'?: string
}
export const appTargetOptions = {
'document-id': {
type: 'string',
description: 'Target OpenPencil document/tab ID when connected to the running app',
required: false
},
'page-id': {
type: 'string',
description: 'Target page ID when connected to the running app',
required: false
}
} as const
export function appTargetRpcArgs(args: AppTargetCliArgs): {
document_id?: string
page_id?: string
} {
return {
...(args['document-id'] ? { document_id: args['document-id'] } : {}),
...(args['page-id'] ? { page_id: args['page-id'] } : {})
}
}

View file

@ -3,6 +3,7 @@ import { defineCommand } from 'citty'
import type { AnalyzeClustersResult } from '@open-pencil/core/rpc'
import { calcClusterConfidence } from '@open-pencil/core/tools'
import { appTargetOptions } from '#cli/app-target'
import { bold, fmtList, fmtSummary } from '#cli/format'
import { loadRpcData } from '#cli/rpc-data'
@ -34,14 +35,20 @@ export default defineCommand({
limit: { type: 'string', description: 'Max clusters to show', default: '20' },
'min-size': { type: 'string', description: 'Min node size in px', default: '30' },
'min-count': { type: 'string', description: 'Min instances to form cluster', default: '2' },
...appTargetOptions,
json: { type: 'boolean', description: 'Output as JSON' }
},
async run({ args }) {
const data = await loadRpcData<AnalyzeClustersResult>(args.file, 'analyze_clusters', {
limit: Number(args.limit),
minSize: Number(args['min-size']),
minCount: Number(args['min-count'])
})
const data = await loadRpcData<AnalyzeClustersResult>(
args.file,
'analyze_clusters',
{
limit: Number(args.limit),
minSize: Number(args['min-size']),
minCount: Number(args['min-count'])
},
args
)
if (args.json) {
console.log(JSON.stringify(data, null, 2))

View file

@ -2,6 +2,7 @@ import { defineCommand } from 'citty'
import type { AnalyzeColorsResult } from '@open-pencil/core/rpc'
import { appTargetOptions } from '#cli/app-target'
import { bold, fmtHistogram, fmtList, fmtSummary } from '#cli/format'
import { loadRpcData } from '#cli/rpc-data'
@ -20,13 +21,19 @@ export default defineCommand({
default: '15'
},
similar: { type: 'boolean', description: 'Show similar color clusters' },
...appTargetOptions,
json: { type: 'boolean', description: 'Output as JSON' }
},
async run({ args }) {
const data = await loadRpcData<AnalyzeColorsResult>(args.file, 'analyze_colors', {
threshold: Number(args.threshold),
similar: args.similar
})
const data = await loadRpcData<AnalyzeColorsResult>(
args.file,
'analyze_colors',
{
threshold: Number(args.threshold),
similar: args.similar
},
args
)
const limit = Number(args.limit)
if (args.json) {

View file

@ -176,6 +176,11 @@ export default defineCommand({
type: 'string',
description: 'Limit analysis to nodes on the page with this stable ID'
},
'document-id': {
type: 'string',
description: 'Target OpenPencil document/tab ID when connected to the running app',
required: false
},
type: {
type: 'string',
description: 'Comma-separated node types to analyze, e.g. FRAME,TEXT'
@ -194,7 +199,12 @@ export default defineCommand({
process.exit(1)
}
const data = await loadRpcData<AnalyzeOverlapsResult>(args.file, 'analyze_overlaps', rpcArgs)
const data = await loadRpcData<AnalyzeOverlapsResult>(
args.file,
'analyze_overlaps',
rpcArgs,
args
)
if (args.json) {
console.log(JSON.stringify(data, null, 2))

View file

@ -2,6 +2,7 @@ import { defineCommand } from 'citty'
import type { AnalyzeSpacingResult } from '@open-pencil/core/rpc'
import { appTargetOptions } from '#cli/app-target'
import { bold, kv, fmtHistogram, fmtSummary } from '#cli/format'
import { loadRpcData } from '#cli/rpc-data'
@ -14,10 +15,16 @@ export default defineCommand({
required: false
},
grid: { type: 'string', description: 'Base grid size to check against', default: '8' },
...appTargetOptions,
json: { type: 'boolean', description: 'Output as JSON' }
},
async run({ args }) {
const data = await loadRpcData<AnalyzeSpacingResult>(args.file, 'analyze_spacing')
const data = await loadRpcData<AnalyzeSpacingResult>(
args.file,
'analyze_spacing',
undefined,
args
)
const gridSize = Number(args.grid)
if (args.json) {

View file

@ -2,6 +2,7 @@ import { defineCommand } from 'citty'
import type { AnalyzeTypographyResult } from '@open-pencil/core/rpc'
import { appTargetOptions } from '#cli/app-target'
import { bold, fmtHistogram, fmtSummary } from '#cli/format'
import { loadRpcData } from '#cli/rpc-data'
@ -30,10 +31,16 @@ export default defineCommand({
description: 'Group by: family, size, weight (default: show all styles)'
},
limit: { type: 'string', description: 'Max styles to show', default: '30' },
...appTargetOptions,
json: { type: 'boolean', description: 'Output as JSON' }
},
async run({ args }) {
const data = await loadRpcData<AnalyzeTypographyResult>(args.file, 'analyze_typography', {})
const data = await loadRpcData<AnalyzeTypographyResult>(
args.file,
'analyze_typography',
{},
args
)
const limit = Number(args.limit)
const groupBy = args['group-by']

View file

@ -0,0 +1,47 @@
import { defineCommand } from 'citty'
import type { AutomationDocumentSummary } from '@open-pencil/core/rpc'
import { rpc } from '#cli/app-client'
import { bold, entity, fmtList, kv, printError } from '#cli/format'
export default defineCommand({
meta: { description: 'List open documents in the running app' },
args: {
json: { type: 'boolean', description: 'Output as JSON' }
},
async run({ args }) {
try {
const data = await rpc<{ documents: AutomationDocumentSummary[] }>('list_documents')
const documents = data.documents
if (args.json) {
console.log(JSON.stringify(documents, null, 2))
return
}
console.log('')
console.log(bold(` ${documents.length} open document${documents.length !== 1 ? 's' : ''}`))
console.log('')
console.log(
fmtList(
documents.map((doc) => ({
header: `${entity('document', doc.name, doc.id)}${doc.active ? ' [active]' : ''}`,
details: {
...(doc.path ? { path: doc.path } : {}),
current: `${doc.current_page_name} (${doc.current_page_id})`,
pages: doc.pages.map((page) => `${page.name} (${page.id})`).join(', ')
}
})),
{ compact: true }
)
)
console.log('')
console.log(kv('target flags', '--document-id <id> --page-id <id>'))
console.log('')
} catch (error) {
printError(error)
process.exit(1)
}
}
})

View file

@ -5,6 +5,7 @@ import { defineCommand } from 'citty'
import { FigmaAPI } from '@open-pencil/core/figma-api'
import { isAppMode, requireFile, rpc } from '#cli/app-client'
import { appTargetOptions, appTargetRpcArgs } from '#cli/app-target'
import { printError } from '#cli/format'
import { loadDocument } from '#cli/headless'
@ -42,6 +43,7 @@ export default defineCommand({
description: 'Write to a different file',
required: false
},
...appTargetOptions,
json: { type: 'boolean', description: 'Output as JSON' },
quiet: { type: 'boolean', alias: 'q', description: 'Suppress output' }
},
@ -60,7 +62,7 @@ export default defineCommand({
}
if (isAppMode(args.file)) {
const result = await rpc('eval', { code })
const result = await rpc('eval', { code, ...appTargetRpcArgs(args) })
if (!args.quiet && result !== undefined && result !== null) {
printResult(result, !!args.json)
}

View file

@ -7,6 +7,7 @@ import { BUILTIN_IO_FORMATS, IORegistry } from '@open-pencil/core/io'
import type { RasterExportFormat } from '@open-pencil/core/io'
import { isAppMode, requireFile, rpc } from '#cli/app-client'
import { appTargetOptions, appTargetRpcArgs } from '#cli/app-target'
import { ok, printError } from '#cli/format'
import { loadDocument } from '#cli/headless'
@ -27,6 +28,8 @@ interface ExportArgs {
thumbnail?: boolean
width: string
height: string
'document-id'?: string
'page-id'?: string
}
async function writeAndLog(path: string, content: string | Uint8Array) {
@ -36,8 +39,10 @@ async function writeAndLog(path: string, content: string | Uint8Array) {
}
async function exportViaApp(format: string, args: ExportArgs) {
const targetArgs = appTargetRpcArgs(args)
if (format === 'SVG') {
const result = await rpc<{ svg: string }>('tool', {
...targetArgs,
name: 'export_svg',
args: { ids: args.node ? [args.node] : undefined }
})
@ -51,6 +56,7 @@ async function exportViaApp(format: string, args: ExportArgs) {
if (format === 'PDF') {
const result = await rpc<{ base64: string }>('tool', {
...targetArgs,
name: 'export_pdf',
args: { ids: args.node ? [args.node] : undefined }
})
@ -69,6 +75,7 @@ async function exportViaApp(format: string, args: ExportArgs) {
}
const result = await rpc<{ base64: string }>('export', {
...targetArgs,
nodeIds: args.node ? [args.node] : undefined,
scale: Number(args.scale),
format: format.toLowerCase()
@ -192,7 +199,8 @@ export default defineCommand({
},
thumbnail: { type: 'boolean', description: 'Export page thumbnail instead of full render' },
width: { type: 'string', description: 'Thumbnail width (default: 1920)', default: '1920' },
height: { type: 'string', description: 'Thumbnail height (default: 1080)', default: '1080' }
height: { type: 'string', description: 'Thumbnail height (default: 1080)', default: '1080' },
...appTargetOptions
},
async run({ args }) {
const format = args.format.toUpperCase() as RasterExportFormat | 'SVG' | 'JSX' | 'FIG'

View file

@ -2,6 +2,7 @@ import { defineCommand } from 'citty'
import type { FindNodeResult } from '@open-pencil/core/rpc'
import { appTargetOptions } from '#cli/app-target'
import { printNodeResults } from '#cli/format'
import { loadRpcData } from '#cli/rpc-data'
@ -17,15 +18,21 @@ export default defineCommand({
type: { type: 'string', description: 'Node type: FRAME, TEXT, RECTANGLE, INSTANCE, etc.' },
page: { type: 'string', description: 'Page name (default: all pages)' },
limit: { type: 'string', description: 'Max results (default: 100)', default: '100' },
...appTargetOptions,
json: { type: 'boolean', description: 'Output as JSON' }
},
async run({ args }) {
const results = await loadRpcData<FindNodeResult[]>(args.file, 'find', {
name: args.name,
type: args.type,
page: args.page,
limit: args.limit ? Number(args.limit) : undefined
})
const results = await loadRpcData<FindNodeResult[]>(
args.file,
'find',
{
name: args.name,
type: args.type,
page: args.page,
limit: args.limit ? Number(args.limit) : undefined
},
args
)
if (args.json) {
console.log(JSON.stringify(results, null, 2))

View file

@ -2,6 +2,7 @@ import { defineCommand } from 'citty'
import type { InfoResult } from '@open-pencil/core/rpc'
import { appTargetOptions } from '#cli/app-target'
import { bold, fmtHistogram, fmtSummary, kv } from '#cli/format'
import { loadRpcData } from '#cli/rpc-data'
@ -13,10 +14,11 @@ export default defineCommand({
description: 'Document file path (omit to connect to running app)',
required: false
},
...appTargetOptions,
json: { type: 'boolean', description: 'Output as JSON' }
},
async run({ args }) {
const data = await loadRpcData<InfoResult>(args.file, 'info')
const data = await loadRpcData<InfoResult>(args.file, 'info', undefined, args)
if (args.json) {
console.log(JSON.stringify(data, null, 2))

View file

@ -4,6 +4,7 @@ import { colorToHex } from '@open-pencil/core/color'
import type { NodeResult } from '@open-pencil/core/rpc'
import type { Color } from '@open-pencil/scene-graph/primitives'
import { appTargetOptions } from '#cli/app-target'
import { fmtNode, printError, formatType } from '#cli/format'
import { loadRpcData } from '#cli/rpc-data'
@ -16,12 +17,18 @@ export default defineCommand({
required: false
},
id: { type: 'string', description: 'Node ID', required: true },
...appTargetOptions,
json: { type: 'boolean', description: 'Output as JSON' }
},
async run({ args }) {
const data = await loadRpcData<NodeResult | { error: string }>(args.file, 'node', {
id: args.id
})
const data = await loadRpcData<NodeResult | { error: string }>(
args.file,
'node',
{
id: args.id
},
args
)
if ('error' in data) {
printError(data.error)

View file

@ -2,6 +2,7 @@ import { defineCommand } from 'citty'
import type { PageItem } from '@open-pencil/core/rpc'
import { appTargetOptions } from '#cli/app-target'
import { bold, fmtList, entity } from '#cli/format'
import { loadRpcData } from '#cli/rpc-data'
@ -13,10 +14,11 @@ export default defineCommand({
description: 'Document file path (omit to connect to running app)',
required: false
},
...appTargetOptions,
json: { type: 'boolean', description: 'Output as JSON' }
},
async run({ args }) {
const pages = await loadRpcData<PageItem[]>(args.file, 'pages')
const pages = await loadRpcData<PageItem[]>(args.file, 'pages', undefined, args)
if (args.json) {
console.log(JSON.stringify(pages, null, 2))

View file

@ -2,6 +2,7 @@ import { defineCommand } from 'citty'
import type { QueryNodeResult } from '@open-pencil/core/rpc'
import { appTargetOptions } from '#cli/app-target'
import { printNodeResults, printError } from '#cli/format'
import { loadRpcData } from '#cli/rpc-data'
@ -22,14 +23,20 @@ export default defineCommand({
},
page: { type: 'string', description: 'Page name (default: all pages)' },
limit: { type: 'string', description: 'Max results (default: 1000)', default: '1000' },
...appTargetOptions,
json: { type: 'boolean', description: 'Output as JSON' }
},
async run({ args }) {
const results = await loadRpcData<QueryNodeResult[] | { error: string }>(args.file, 'query', {
selector: args.selector,
page: args.page,
limit: args.limit ? Number(args.limit) : undefined
})
const results = await loadRpcData<QueryNodeResult[] | { error: string }>(
args.file,
'query',
{
selector: args.selector,
page: args.page,
limit: args.limit ? Number(args.limit) : undefined
},
args
)
if ('error' in results) {
printError(results.error)

View file

@ -1,6 +1,7 @@
import { defineCommand } from 'citty'
import { rpc } from '#cli/app-client'
import { appTargetOptions, appTargetRpcArgs } from '#cli/app-target'
import { bold, entity, fmtList, formatType, printError } from '#cli/format'
interface SelectionNode {
@ -15,11 +16,12 @@ interface SelectionNode {
export default defineCommand({
meta: { description: 'Get current selection from the running app' },
args: {
...appTargetOptions,
json: { type: 'boolean', description: 'Output as JSON' }
},
async run({ args }) {
try {
const nodes = await rpc<SelectionNode[]>('selection')
const nodes = await rpc<SelectionNode[]>('selection', appTargetRpcArgs(args))
if (args.json) {
console.log(JSON.stringify(nodes, null, 2))

View file

@ -3,6 +3,7 @@ import { defineCommand } from 'citty'
import type { TreeNodeResult, TreeResult } from '@open-pencil/core/rpc'
import { appTargetOptions } from '#cli/app-target'
import { fmtTree, printError, entity, formatType } from '#cli/format'
import { loadRpcData } from '#cli/rpc-data'
@ -26,13 +27,19 @@ export default defineCommand({
},
page: { type: 'string', description: 'Page name (default: first page)' },
depth: { type: 'string', description: 'Max depth (default: unlimited)' },
...appTargetOptions,
json: { type: 'boolean', description: 'Output as JSON' }
},
async run({ args }) {
const data = await loadRpcData<TreeResult | { error: string }>(args.file, 'tree', {
page: args.page,
depth: args.depth ? Number(args.depth) : undefined
})
const data = await loadRpcData<TreeResult | { error: string }>(
args.file,
'tree',
{
page: args.page,
depth: args.depth ? Number(args.depth) : undefined
},
args
)
const maxDepth = args.depth ? Number(args.depth) : Infinity
if ('error' in data) {

View file

@ -2,6 +2,7 @@ import { defineCommand } from 'citty'
import type { VariablesResult } from '@open-pencil/core/rpc'
import { appTargetOptions } from '#cli/app-target'
import { bold, entity, fmtList, fmtSummary } from '#cli/format'
import { loadRpcData } from '#cli/rpc-data'
@ -15,13 +16,19 @@ export default defineCommand({
},
collection: { type: 'string', description: 'Filter by collection name' },
type: { type: 'string', description: 'Filter by type: COLOR, FLOAT, STRING, BOOLEAN' },
...appTargetOptions,
json: { type: 'boolean', description: 'Output as JSON' }
},
async run({ args }) {
const data = await loadRpcData<VariablesResult>(args.file, 'variables', {
collection: args.collection,
type: args.type
})
const data = await loadRpcData<VariablesResult>(
args.file,
'variables',
{
collection: args.collection,
type: args.type
},
args
)
if (data.totalVariables === 0) {
console.log('No variables found.')

View file

@ -3,6 +3,7 @@ import { defineCommand, runMain } from 'citty'
import analyze from './commands/analyze'
import convert from './commands/convert'
import documents from './commands/documents'
import dom from './commands/dom'
import evalCmd from './commands/eval'
import exportCmd from './commands/export'
@ -28,6 +29,7 @@ const main = defineCommand({
subCommands: {
analyze,
convert,
documents,
dom,
eval: evalCmd,
export: exportCmd,

View file

@ -1,14 +1,27 @@
import { executeRpcCommand } from '@open-pencil/core/rpc'
import { isAppMode, requireFile, rpc } from '#cli/app-client'
import { appTargetRpcArgs, type AppTargetCliArgs } from '#cli/app-target'
import { loadDocument } from '#cli/headless'
type RpcArgs = { [key: string]: unknown }
function isRpcArgs(value: unknown): value is RpcArgs {
return Boolean(value && typeof value === 'object' && !Array.isArray(value))
}
export async function loadRpcData<Result>(
file: string | undefined,
command: string,
args?: unknown
args?: unknown,
targetArgs?: AppTargetCliArgs
): Promise<Result> {
if (isAppMode(file)) return rpc<Result>(command, args)
if (isAppMode(file)) {
return rpc<Result>(command, {
...(isRpcArgs(args) ? args : {}),
...(targetArgs ? appTargetRpcArgs(targetArgs) : {})
})
}
const graph = await loadDocument(requireFile(file))
return executeRpcCommand(graph, command, args) as Result
}

View file

@ -18,6 +18,16 @@ import {
import type { RpcCommand } from './types'
import { variablesCommand } from './variables-command'
export type AutomationDocumentSummary = {
id: string
name: string
path?: string
active: boolean
current_page_id: string
current_page_name: string
pages: Array<{ id: string; name: string }>
}
export type { RpcCommand } from './types'
export * from './read-commands'
export * from './variables-command'

View file

@ -23,5 +23,6 @@ export type {
AnalyzeClustersResult,
AnalyzeOverlapsArgs,
AnalyzeOverlapsResult,
TypographyStyle
TypographyStyle,
AutomationDocumentSummary
} from './commands'

View file

@ -144,10 +144,14 @@ openpencil variables design.fig
When the desktop app is running, omit the file argument — the CLI connects via RPC and operates on the live canvas:
```sh
openpencil tree # inspect the live document
openpencil eval -c "..." # query the editor
openpencil documents # list open document/page IDs
openpencil tree # inspect the active live document
openpencil tree --document-id tab-123 --page-id 0:1
openpencil eval --document-id tab-123 --page-id 0:1 -c "..."
```
Use `openpencil documents --json` in agent workflows, then pass `--document-id` and `--page-id` explicitly instead of relying on the visible active tab/page.
## Lint Designs
Check documents for naming, layout, structure, and accessibility issues:

View file

@ -116,12 +116,15 @@ Server starts on port 7600 (override with `PORT` env var). Endpoints:
## Workflow
1. **Open**`open_file` to load an existing `.fig`, or `new_document` for a blank canvas
2. **Read**`get_page_tree`, `find_nodes`, `get_node`, `list_pages`
3. **Create**`create_shape`, `render` (JSX)
4. **Modify**`set_fill`, `set_stroke`, `set_layout`, `update_node`, `set_effects`
5. **Structure**`reparent_node`, `group_nodes`, `clone_node`, `delete_node`
6. **Save**`save_file` to write back to `.fig`
1. **Discover targets** — call `list_documents` first when more than one document or page may be open. It returns stable `document_id` and page IDs.
2. **Open**`open_file` to load an existing `.fig`, or `new_document` for a blank canvas. These return target metadata for the opened or created document.
3. **Read**`get_page_tree`, `find_nodes`, `get_node`, `list_pages`
4. **Create**`create_shape`, `render` (JSX)
5. **Modify**`set_fill`, `set_stroke`, `set_layout`, `update_node`, `set_effects`
6. **Structure**`reparent_node`, `group_nodes`, `clone_node`, `delete_node`
7. **Save**`save_file` to write back to `.fig`
Most tools accept optional `document_id` and `page_id` fields. Pass them explicitly for agent workflows instead of relying on the visible active tab/page. `create_page` only creates a page; call `switch_page` separately when the workflow should change the active page.
## AI Agent Skill
@ -133,7 +136,7 @@ npx skills add open-pencil/skills@open-pencil
Works with Claude Code, Cursor, Windsurf, Codex, and any agent that supports [skills](https://skills.sh). The skill covers the CLI, MCP tools, JSX rendering, eval, and the running app's automation bridge.
## Tools (90)
## Tools (91)
### Document
@ -142,6 +145,7 @@ Works with Claude Code, Cursor, Windsurf, Codex, and any agent that supports [sk
| `open_file` | Open a `.fig` file for editing |
| `save_file` | Save the current document to a `.fig` file |
| `new_document` | Create a new empty document |
| `list_documents` | List open app documents/tabs and their pages |
### Read

View file

@ -14,6 +14,25 @@ import { paramToZod } from './schema'
export type RpcSender = (body: Record<string, unknown>) => Promise<unknown>
const automationTargetSchema = {
document_id: z.string().describe('Optional OpenPencil document/tab ID to target').optional(),
page_id: z.string().describe('Optional page ID to target within the document').optional()
}
function splitAutomationTarget(args: Record<string, unknown>): {
target: { document_id?: string; page_id?: string }
args: Record<string, unknown>
} {
const { document_id, page_id, ...rest } = args
return {
target: {
...(typeof document_id === 'string' ? { document_id } : {}),
...(typeof page_id === 'string' ? { page_id } : {})
},
args: rest
}
}
export interface RegisterToolsOptions {
enableEval: boolean
mcpRoot?: string | null
@ -33,14 +52,21 @@ export function registerTools(mcpServer: McpServer, options: RegisterToolsOption
}
register(
def.name,
{ description: def.description, inputSchema: z.object(shape) },
{
description: def.description,
inputSchema: z.object({ ...shape, ...automationTargetSchema })
},
async (args: Record<string, unknown>) => {
try {
const result = await sendRpc({ command: 'tool', args: { name: def.name, args } })
const { target, args: toolArgs } = splitAutomationTarget(args)
const result = await sendRpc({
command: 'tool',
args: { ...target, name: def.name, args: toolArgs }
})
const res = result as { ok?: boolean; result?: unknown; error?: string }
if (res.ok === false) return fail(new Error(res.error))
const r = res.result as RpcJsonObject | undefined
const filePath = typeof args.path === 'string' ? args.path : null
const filePath = typeof toolArgs.path === 'string' ? toolArgs.path : null
if (r && filePath && resolvedRoot) {
const written = await writeToolOutput(def.name, r, filePath, resolvedRoot)
if (written) return written
@ -77,6 +103,25 @@ export function registerTools(mcpServer: McpServer, options: RegisterToolsOption
)
}
register(
'list_documents',
{
description:
'List open OpenPencil documents/tabs with their IDs, file paths, current pages, and pages.',
inputSchema: z.object({})
},
async () => {
try {
const result = await sendRpc({ command: 'list_documents', args: {} })
const res = result as { ok?: boolean; result?: unknown; error?: string }
if (res.ok === false) return fail(new Error(res.error))
return ok(res.result ?? {})
} catch (e) {
return fail(e)
}
}
)
register(
'save_file',
{
@ -85,18 +130,24 @@ export function registerTools(mcpServer: McpServer, options: RegisterToolsOption
: 'Save the current document to disk. Uses the existing file path if available, otherwise prompts for a location.',
inputSchema: resolvedRoot
? z.object({
path: z.string().describe('Optional absolute path for the .fig file').optional()
path: z.string().describe('Optional absolute path for the .fig file').optional(),
...automationTargetSchema
})
: z.object({})
: z.object({ ...automationTargetSchema })
},
async (args: { path?: string }) => {
async (args: { path?: string; document_id?: string; page_id?: string }) => {
try {
const safePath =
args.path && resolvedRoot ? resolveSafePath(args.path, resolvedRoot) : undefined
const result = await sendRpc({ command: 'save_file', args: { path: safePath } })
const res = result as { ok?: boolean; error?: string }
const { target } = splitAutomationTarget(args)
const result = await sendRpc({ command: 'save_file', args: { ...target, path: safePath } })
const res = result as { ok?: boolean; result?: unknown; target?: unknown; error?: string }
if (res.ok === false) return fail(new Error(res.error))
return ok({ saved: true, ...(safePath ? { path: safePath } : {}) })
return ok({
saved: true,
...(safePath ? { path: safePath } : {}),
...(res.target ? { target: res.target } : {})
})
} catch (e) {
return fail(e)
}
@ -109,16 +160,18 @@ export function registerTools(mcpServer: McpServer, options: RegisterToolsOption
{
description: `Open a .fig or .pen file from disk into a new tab. Path must be inside ${resolvedRoot}.`,
inputSchema: z.object({
path: z.string().describe('Absolute path to the design file')
path: z.string().describe('Absolute path to the design file'),
...automationTargetSchema
})
},
async (args: { path: string }) => {
async (args: { path: string; document_id?: string; page_id?: string }) => {
try {
const safe = resolveSafePath(args.path, resolvedRoot)
const result = await sendRpc({ command: 'open_file', args: { path: safe } })
const res = result as { ok?: boolean; error?: string }
const { target } = splitAutomationTarget(args)
const result = await sendRpc({ command: 'open_file', args: { ...target, path: safe } })
const res = result as { ok?: boolean; result?: unknown; target?: unknown; error?: string }
if (res.ok === false) return fail(new Error(res.error))
return ok({ opened: true })
return ok({ opened: true, ...(res.target ? { target: res.target } : {}) })
} catch (e) {
return fail(e)
}
@ -130,16 +183,21 @@ export function registerTools(mcpServer: McpServer, options: RegisterToolsOption
{
description: `Create a new empty document. Optionally set a save path inside ${resolvedRoot}.`,
inputSchema: z.object({
path: z.string().describe('Optional absolute path for the new file').optional()
path: z.string().describe('Optional absolute path for the new file').optional(),
...automationTargetSchema
})
},
async (args: { path?: string }) => {
async (args: { path?: string; document_id?: string; page_id?: string }) => {
try {
const safePath = args.path ? resolveSafePath(args.path, resolvedRoot) : undefined
const result = await sendRpc({ command: 'new_document', args: { path: safePath } })
const res = result as { ok?: boolean; error?: string }
const { target } = splitAutomationTarget(args)
const result = await sendRpc({
command: 'new_document',
args: { ...target, path: safePath }
})
const res = result as { ok?: boolean; result?: unknown; target?: unknown; error?: string }
if (res.ok === false) return fail(new Error(res.error))
return ok({ created: true })
return ok({ created: true, ...(res.target ? { target: res.target } : {}) })
} catch (e) {
return fail(e)
}

View file

@ -1,21 +1,21 @@
import type { FigmaAPI } from '@open-pencil/core/figma-api'
import { wrapEvalCode } from '@open-pencil/core/tools'
import type { EditorStore } from '@/app/editor/active-store'
import type { AutomationTarget } from '@/app/automation/bridge/target'
type FigmaFactory = () => FigmaAPI
type FigmaFactory = (store: AutomationTarget['store'], pageId?: string) => FigmaAPI
export function createAutomationEvalHandler(makeFigma: FigmaFactory) {
return async function handleEval(store: EditorStore, args: unknown): Promise<unknown> {
return async function handleEval(target: AutomationTarget, args: unknown): Promise<unknown> {
const code = (args as { code?: string }).code
if (!code) throw new Error('Missing "code" in args')
const figma = makeFigma()
const figma = makeFigma(target.store, target.pageId)
const AsyncFunction = Object.getPrototypeOf(async function () {
/* noop */
}).constructor
const fn = new AsyncFunction('figma', wrapEvalCode(code))
const result = await fn(figma)
store.requestRender()
target.store.requestRender()
return { ok: true, result: result ?? null }
}
}

View file

@ -1,8 +1,9 @@
import { selectionToJSX, sceneNodeToJSX, type RasterExportFormat } from '@open-pencil/core/io'
import type { EditorStore } from '@/app/editor/active-store'
import type { AutomationTarget } from '@/app/automation/bridge/target'
export async function handleExport(store: EditorStore, args: unknown): Promise<unknown> {
export async function handleExport(target: AutomationTarget, args: unknown): Promise<unknown> {
const store = target.store
const exportArgs = args as { nodeIds?: string[]; scale?: number; format?: string } | undefined
const nodeIds = exportArgs?.nodeIds ?? [...store.state.selectedIds]
if (nodeIds.length === 0) throw new Error('No nodes to export')
@ -21,10 +22,11 @@ export async function handleExport(store: EditorStore, args: unknown): Promise<u
}
}
export async function handleExportJsx(store: EditorStore, args: unknown): Promise<unknown> {
export async function handleExportJsx(target: AutomationTarget, args: unknown): Promise<unknown> {
const store = target.store
const jsxArgs = args as { nodeIds?: string[]; style?: string } | undefined
const style = (jsxArgs?.style ?? 'openpencil') as 'openpencil' | 'tailwind'
const currentPage = store.graph.getNode(store.state.currentPageId)
const currentPage = store.graph.getNode(target.pageId)
const nodeIds = jsxArgs?.nodeIds ?? currentPage?.childIds ?? []
const jsx =
nodeIds.length === 1

View file

@ -3,10 +3,13 @@ import { FigmaAPI } from '@open-pencil/core/figma-api'
import type { EditorStore } from '@/app/editor/active-store'
import { listFamilies, listFonts } from '@/app/editor/fonts'
export function makeFigmaFromStore(store: EditorStore): FigmaAPI {
export function makeFigmaFromStore(
store: EditorStore,
pageId = store.state.currentPageId
): FigmaAPI {
const api = new FigmaAPI(store.graph)
api.setRenderer(store.renderer ?? null)
api.currentPage = api.wrapNode(store.state.currentPageId)
api.currentPage = api.wrapNode(pageId)
api.currentPage.selection = [...store.state.selectedIds]
.map((id) => api.getNodeById(id))
.filter((n): n is NonNullable<typeof n> => n !== null)

View file

@ -1,9 +1,14 @@
import type { EditorStore } from '@/app/editor/active-store'
import {
resolveAutomationTarget,
responseWithTarget,
type AutomationTarget
} from '@/app/automation/bridge/target'
import { openFileFromPath } from '@/app/shell/menu/use'
import { createTab, openFileInNewTab } from '@/app/tabs'
import { createTab, getActiveStore, openFileInNewTab } from '@/app/tabs'
import { isTauri } from '@/app/tauri/env'
export async function handleSaveFile(store: EditorStore, args: unknown): Promise<unknown> {
export async function handleSaveFile(target: AutomationTarget, args: unknown): Promise<unknown> {
const store = target.store
const path = (args as { path?: string }).path
if (path) {
store.setPlannedFilePath(path)
@ -25,7 +30,10 @@ export async function ensureTauriParentDirectory(path: string): Promise<void> {
await mkdir(dir, { recursive: true })
}
export async function handleNewDocument(_store: EditorStore, args: unknown): Promise<unknown> {
export async function handleNewDocument(
_target: AutomationTarget,
args: unknown
): Promise<unknown> {
const path = (args as { path?: string }).path
const tab = createTab()
if (path) {
@ -34,10 +42,11 @@ export async function handleNewDocument(_store: EditorStore, args: unknown): Pro
await tab.store.saveFigFile()
tab.store.startWatchingCurrentFile()
}
return { ok: true }
const target = resolveAutomationTarget(tab.store, { document_id: tab.id })
return responseWithTarget({ ok: true, result: { created: true } }, target)
}
export async function handleOpenFile(_store: EditorStore, args: unknown): Promise<unknown> {
export async function handleOpenFile(_target: AutomationTarget, args: unknown): Promise<unknown> {
const path = (args as { path?: string }).path
if (!path) throw new Error('Missing "path" in args')
if (isTauri()) {
@ -49,5 +58,6 @@ export async function handleOpenFile(_store: EditorStore, args: unknown): Promis
const file = new File([await response.blob()], name)
await openFileInNewTab(file, undefined, path)
}
return { ok: true }
const target = resolveAutomationTarget(getActiveStore(), undefined)
return responseWithTarget({ ok: true, result: { opened: true } }, target)
}

View file

@ -9,12 +9,22 @@ import {
} from '@/app/automation/bridge/file-handlers'
import { handleRpcFallback } from '@/app/automation/bridge/rpc-handler'
import { handleSelection } from '@/app/automation/bridge/selection-handler'
import {
isUnknownRecord,
listAutomationDocuments,
resolveAutomationTarget,
responseWithTarget,
stripAutomationTargetArgs
} from '@/app/automation/bridge/target'
import { createAutomationToolHandler } from '@/app/automation/bridge/tool-handlers'
import type { EditorStore } from '@/app/editor/active-store'
type FigmaFactory = () => FigmaAPI
type FigmaFactory = (store: EditorStore, pageId?: string) => FigmaAPI
type CommandHandler = (store: EditorStore, args: unknown) => Promise<unknown>
type CommandHandler = (
target: ReturnType<typeof resolveAutomationTarget>,
args: unknown
) => Promise<unknown>
export function createAutomationCommandHandlers(makeFigma: FigmaFactory) {
const handleEval = createAutomationEvalHandler(makeFigma)
@ -36,9 +46,23 @@ export function createAutomationCommandHandlers(makeFigma: FigmaFactory) {
command: string,
args: unknown
): Promise<unknown> {
if (command === 'list_documents') {
return { ok: true, result: { documents: listAutomationDocuments(store) } }
}
if (command === 'open_file' || command === 'new_document') {
const handler = commandHandlers[command]
if (handler) return handler(resolveAutomationTarget(store, undefined), args)
}
const rawArgs = isUnknownRecord(args) ? args : {}
const target = resolveAutomationTarget(store, rawArgs)
const targetArgs = stripAutomationTargetArgs(rawArgs)
const handler = commandHandlers[command]
if (handler) return handler(store, args)
return handleRpcFallback(store, command, args)
const result = handler
? await handler(target, targetArgs)
: await handleRpcFallback(target, command, targetArgs)
return responseWithTarget(result, target)
}
return { handleRequest }

View file

@ -1,12 +1,12 @@
import { executeRpcCommand } from '@open-pencil/core/rpc'
import type { EditorStore } from '@/app/editor/active-store'
import type { AutomationTarget } from '@/app/automation/bridge/target'
export async function handleRpcFallback(
store: EditorStore,
target: AutomationTarget,
command: string,
args: unknown
): Promise<unknown> {
const result = executeRpcCommand(store.graph, command, args ?? {})
const result = executeRpcCommand(target.store.graph, command, args ?? {})
return { ok: true, result }
}

View file

@ -1,8 +1,9 @@
import { nodeToXPath } from '@open-pencil/core/xpath'
import type { EditorStore } from '@/app/editor/active-store'
import type { AutomationTarget } from '@/app/automation/bridge/target'
export async function handleSelection(store: EditorStore): Promise<unknown> {
export async function handleSelection(target: AutomationTarget): Promise<unknown> {
const store = target.store
const ids = [...store.state.selectedIds]
const nodes = ids
.map((id) => store.graph.getNode(id))

View file

@ -10,17 +10,15 @@ import { randomHex } from '@open-pencil/core/random'
import { makeFigmaFromStore } from '@/app/automation/bridge/figma-factory'
import { createAutomationCommandHandlers } from '@/app/automation/bridge/handlers'
import type { EditorStore } from '@/app/editor/active-store'
export function connectAutomation(getStore: () => EditorStore, authToken: string | null = null) {
const token = authToken ?? randomHex(32)
let ws: WebSocket | null = null
let reconnectTimer: ReturnType<typeof setTimeout> | undefined
let intentionalDisconnect = false
function makeFigma() {
return makeFigmaFromStore(getStore())
}
const { handleRequest: handleAutomationRequest } = createAutomationCommandHandlers(makeFigma)
const { handleRequest: handleAutomationRequest } =
createAutomationCommandHandlers(makeFigmaFromStore)
async function handleRequest(_id: string, command: string, args: unknown): Promise<unknown> {
return handleAutomationRequest(getStore(), command, args)

View file

@ -0,0 +1,106 @@
import type { AutomationDocumentSummary } from '@open-pencil/core/rpc'
import type { EditorStore } from '@/app/editor/active-store'
import { getTabById, getTabForStore, getTabsSnapshot } from '@/app/tabs'
export type UnknownRecord = { [key: string]: unknown }
export type AutomationTargetArgs = {
document_id?: unknown
page_id?: unknown
}
export function isUnknownRecord(value: unknown): value is UnknownRecord {
return Boolean(value && typeof value === 'object' && !Array.isArray(value))
}
export type AutomationTarget = {
store: EditorStore
documentId: string
documentName: string
path?: string
pageId: string
pageName: string
}
export type AutomationTargetResult = Omit<AutomationTarget, 'store'>
export function stripAutomationTargetArgs(args: UnknownRecord): UnknownRecord {
const { document_id: _documentId, page_id: _pageId, ...rest } = args
return rest
}
export function targetToResult(target: AutomationTarget): AutomationTargetResult {
return {
documentId: target.documentId,
documentName: target.documentName,
...(target.path ? { path: target.path } : {}),
pageId: target.pageId,
pageName: target.pageName
}
}
export function responseWithTarget(
body: unknown,
target: AutomationTarget
): Record<string, unknown> {
const targetResult = targetToResult(target)
if (isUnknownRecord(body)) {
return { ...body, target: targetResult }
}
return { ok: true, result: body, target: targetResult }
}
export function listAutomationDocuments(activeStore: EditorStore): AutomationDocumentSummary[] {
const activeTab = getTabForStore(activeStore)
return getTabsSnapshot().map((tab) => {
const pages = tab.store.graph.getPages().map((page) => ({ id: page.id, name: page.name }))
const currentPage = tab.store.graph.getNode(tab.store.state.currentPageId)
const path = tab.store.getDocumentFilePath()
return {
id: tab.id,
name: tab.store.state.documentName,
...(path ? { path } : {}),
active: tab.id === activeTab?.id,
current_page_id: tab.store.state.currentPageId,
current_page_name: currentPage?.name ?? '',
pages
}
})
}
function readString(value: unknown): string | undefined {
return typeof value === 'string' && value.length > 0 ? value : undefined
}
export function resolveAutomationTarget(
activeStore: EditorStore,
args: AutomationTargetArgs | undefined
): AutomationTarget {
const requestedDocumentId = readString(args?.document_id)
const tab = requestedDocumentId ? getTabById(requestedDocumentId) : getTabForStore(activeStore)
if (!tab) {
throw new Error(
requestedDocumentId
? `Document "${requestedDocumentId}" not found`
: 'No active OpenPencil document'
)
}
const requestedPageId = readString(args?.page_id)
const pageId = requestedPageId ?? tab.store.state.currentPageId
const page = tab.store.graph.getNode(pageId)
if (page?.type !== 'CANVAS') {
throw new Error(`Page "${pageId}" not found in document "${tab.id}"`)
}
const path = tab.store.getDocumentFilePath()
return {
store: tab.store,
documentId: tab.id,
documentName: tab.store.state.documentName,
...(path ? { path } : {}),
pageId,
pageName: page.name
}
}

View file

@ -4,24 +4,25 @@ import { computeAllLayouts } from '@open-pencil/core/layout'
import { ALL_TOOLS } from '@open-pencil/core/tools'
import type { JsonObject } from '@open-pencil/scene-graph/primitives'
import type { EditorStore } from '@/app/editor/active-store'
import type { AutomationTarget } from '@/app/automation/bridge/target'
import { ensureGraphFonts } from '@/app/editor/fonts'
type FigmaFactory = () => FigmaAPI
type FigmaFactory = (store: AutomationTarget['store'], pageId?: string) => FigmaAPI
export function createAutomationToolHandler(makeFigma: FigmaFactory) {
async function handleToolRender(
store: EditorStore,
target: AutomationTarget,
toolArgs: Record<string, unknown>
): Promise<unknown> {
const store = target.store
const tree = toolArgs.tree as Parameters<typeof renderTreeNode>[1]
const result = await renderTreeNode(store.graph, tree, {
parentId: (toolArgs.parent_id as string | undefined) ?? store.state.currentPageId,
parentId: (toolArgs.parent_id as string | undefined) ?? target.pageId,
x: toolArgs.x as number | undefined,
y: toolArgs.y as number | undefined
})
await ensureGraphFonts(store.graph, [result.id])
computeAllLayouts(store.graph, store.state.currentPageId)
computeAllLayouts(store.graph, target.pageId)
store.requestRender()
store.flashNodes([result.id])
return {
@ -30,28 +31,25 @@ export function createAutomationToolHandler(makeFigma: FigmaFactory) {
}
}
return async function handleTool(store: EditorStore, args: unknown): Promise<unknown> {
return async function handleTool(target: AutomationTarget, args: unknown): Promise<unknown> {
const toolName = (args as { name?: string }).name
const toolArgs = (args as { args?: Record<string, unknown> }).args ?? {}
if (!toolName) throw new Error('Missing "name" in args')
if (toolName === 'render' && toolArgs.tree) {
return handleToolRender(store, toolArgs)
return handleToolRender(target, toolArgs)
}
const def = ALL_TOOLS.find((t) => t.name === toolName)
if (!def) throw new Error(`Unknown tool: ${toolName}`)
const figma = makeFigma()
const store = target.store
const figma = makeFigma(store, target.pageId)
const result = await def.execute(figma, toolArgs)
if (figma.currentPageId !== store.state.currentPageId) {
void store.switchPage(figma.currentPageId)
}
if (def.mutates) {
const pageNode = store.graph.getNode(store.state.currentPageId)
const pageNode = store.graph.getNode(figma.currentPageId)
if (pageNode) await ensureGraphFonts(store.graph, pageNode.childIds)
computeAllLayouts(store.graph, store.state.currentPageId)
computeAllLayouts(store.graph, figma.currentPageId)
store.requestRender()
store.flashNodes(extractNodeIds(result))
}

View file

@ -67,6 +67,7 @@ export function createDocumentIOActions(
downloadBlob,
setViewportSize,
fitCurrentPageToViewport,
getDocumentFilePath: sourceState.getFilePath,
setDocumentSource: sourceActions.setDocumentSource,
setPlannedFilePath: sourceActions.setPlannedFilePath,
startWatchingCurrentFile: sourceActions.startWatchingCurrentFile,

View file

@ -75,6 +75,7 @@ export function createEditorStoreModules(
fitCurrentPageToViewport: documentIO.fitCurrentPageToViewport,
saveFigFile: documentIO.saveFigFile,
saveFigFileAs: documentIO.saveFigFileAs,
getDocumentFilePath: documentIO.getDocumentFilePath,
setDocumentSource: documentIO.setDocumentSource,
setPlannedFilePath: documentIO.setPlannedFilePath,
startWatchingCurrentFile: documentIO.startWatchingCurrentFile,

View file

@ -42,6 +42,22 @@ export function getActiveStore(): EditorStore {
return tab.store
}
export function getActiveTabId(): string {
return activeTabId.value
}
export function getTabById(tabId: string): Tab | undefined {
return tabsRef.value.find((tab) => tab.id === tabId)
}
export function getTabForStore(store: EditorStore): Tab | undefined {
return tabsRef.value.find((tab) => tab.store === store)
}
export function getTabsSnapshot(): Tab[] {
return [...tabsRef.value]
}
export function createTab(store?: EditorStore, initialGraph?: SceneGraph): Tab {
const s = store ?? createEditorStore(initialGraph)
const tab: Tab = { id: generateTabId(), store: s }
@ -150,6 +166,10 @@ export function useTabsStore() {
createTab,
switchTab,
closeTab,
getActiveTabId,
getTabById,
getTabForStore,
getTabsSnapshot,
openFileInNewTab,
getActiveStore,
tabCount

View file

@ -19,6 +19,10 @@ function createMockApp() {
const graph = new SceneGraph()
const wss = new WebSocketServer({ port: 0, host: '127.0.0.1' })
let clientWs: WebSocket | null = null
const requests: Array<{
command: string
args?: { name?: string; document_id?: string; page_id?: string; args?: Record<string, unknown> }
}> = []
wss.on('connection', (ws) => {
clientWs = ws
@ -32,6 +36,7 @@ function createMockApp() {
args?: { name?: string; args?: Record<string, unknown> }
}
if (msg.type !== 'request') return
requests.push({ command: msg.command, args: msg.args })
try {
let result: unknown
@ -45,6 +50,19 @@ function createMockApp() {
if (def.mutates) computeAllLayouts(graph)
} else if (msg.command === 'save_file') {
result = { ok: true }
} else if (msg.command === 'list_documents') {
result = {
documents: [
{
id: 'doc-1',
name: 'Mock document',
active: true,
current_page_id: graph.getPages()[0].id,
current_page_name: graph.getPages()[0].name,
pages: graph.getPages().map((page) => ({ id: page.id, name: page.name }))
}
]
}
} else {
result = executeRpcCommand(graph, msg.command, msg.args ?? {})
}
@ -69,6 +87,7 @@ function createMockApp() {
return {
graph,
requests,
wss,
port,
close: () => {
@ -144,7 +163,14 @@ describe('MCP stdio transport', () => {
expect(names).toContain('create_shape')
expect(names).toContain('get_page_tree')
expect(names).toContain('save_file')
expect(names).toContain('list_documents')
expect(names).toContain('get_codegen_prompt')
const createShape = expectDefined(
tools.find((tool) => tool.name === 'create_shape'),
'create_shape tool'
)
expect(JSON.stringify(createShape.inputSchema)).toContain('document_id')
expect(JSON.stringify(createShape.inputSchema)).toContain('page_id')
expect(tools.length).toBeGreaterThan(30)
})
@ -165,6 +191,41 @@ describe('MCP stdio transport', () => {
expect(getNodeOrThrow(app.graph, data.id).width).toBe(200)
})
test('tool target fields are sent in the app RPC envelope', async () => {
const result = await client.callTool({
name: 'create_shape',
arguments: {
document_id: 'doc-1',
page_id: 'page-1',
type: 'FRAME',
x: 10,
y: 20,
width: 200,
height: 100,
name: 'TargetedFrame'
}
})
expect(result.isError).not.toBe(true)
const request = expectDefined(
app.requests.find((item) => item.command === 'tool' && item.args?.name === 'create_shape'),
'tool request'
)
expect(request.args?.document_id).toBe('doc-1')
expect(request.args?.page_id).toBe('page-1')
expect(request.args?.args?.document_id).toBeUndefined()
expect(request.args?.args?.page_id).toBeUndefined()
})
test('list_documents via stdio returns open documents', async () => {
const result = await client.callTool({ name: 'list_documents', arguments: {} })
expect(result.isError).not.toBe(true)
const data = JSON.parse(textContent(result.content)) as {
documents: Array<{ id: string; current_page_id: string }>
}
expect(data.documents[0].id).toBe('doc-1')
expect(data.documents[0].current_page_id).toBe(app.graph.getPages()[0].id)
})
test('save_file via stdio succeeds', async () => {
const result = await client.callTool({ name: 'save_file', arguments: {} })
expect(result.isError).not.toBe(true)