openpencil/tests/engine/tools/read.test.ts
Victor Wads 68ffd72839
feat(mcp): let MCP clients read only the selection, with a compact get_selection (#732)
* feat(MCP): follow agent activity in canvas

* fix(fig): preserve imported design fidelity

Keep component overrides, variable-backed icon colors, page backgrounds, and fixed text sizing intact across lazy FIG materialization.

* feat: add selection-context MCP tools and mode

* chore: scope work branch to MCP selection and canvas follow

* fix: honor MCP-only tool contracts in CI

* refactor(mcp): drop the follow and selection-context tools this branch carried

Following agents landed in #725, through the agents registry and the
chat's follow toggle, so this branch's MCP follow setting and its
follow-agent module are superseded. The see_user_selection and
get_user_selection_details tools duplicated get_selection, get_node,
describe, get_page_tree, and export_image; the selection-only workflow
they served is rebuilt on those tools in the following commits.

Co-authored-by: Victor Wads <victor@wads.dev>

* feat(mcp): make get_selection the compact entry point with a depth

get_selection returned every selected layer's whole subtree, which is
too much as the first call when the user points at a large frame. It now
returns the selection with direct children by default, counts deeper
children as childCount, and takes a depth.

Co-authored-by: Victor Wads <victor@wads.dev>

* feat(mcp): share only the selection with MCP clients

A selection scope, set with Share only the selection in the local
server settings or OPENPENCIL_MCP_SCOPE=selection, limits MCP clients
to get_selection, get_node, get_page_tree, describe, and export_image
on the selected layers and what they hold.

The server enforces the scope on everything it sends to the app: MCP
sessions and /rpc, which stdio clients also go through, carry only
those tool calls and the session-closed notice, each stamped with the
scope, so a client cannot reach other tools or the settings that would
widen it. The app's bridge rejects node IDs outside the selection,
points describe and export_image at the selection when they name no
nodes, and asks get_page_tree for a root inside it. A stdio client can
ask for the scope itself while the server shares the whole document.

Co-authored-by: Victor Wads <victor@wads.dev>

* fix(mcp): keep selection-scoped clients from writing files or listing wider tools

export_image writes its result to a file when given a path and an MCP
root is set, which reaches past reading the selection. A path is now
refused in selection scope, by the tool registration before the call
and by the app's bridge, so a client with a stale scope cannot write
either; the image itself is still returned.

A stdio client follows the narrower of its own scope and the scope the
server records, instead of letting OPENPENCIL_MCP_SCOPE=document list
tools a selection-scoped server rejects.

Co-authored-by: Victor Wads <victor@wads.dev>

* test(mcp): name the selection scope's tools instead of reading the allowlist

The server test compared the listed tools with SELECTION_SCOPE_TOOLS,
the same list that decides registration, so a tool added to it by
mistake would still pass. It now names the five tools the scope offers.

Co-authored-by: Victor Wads <victor@wads.dev>

---------

Co-authored-by: Danila Poyarkov <dev@dannote.net>
2026-10-07 13:01:28 +00:00

268 lines
8.6 KiB
TypeScript

import { describe, expect, test } from 'bun:test'
import { DEFAULT_FONT_FAMILY, FigmaAPI, fontManager } from '@open-pencil/core'
import { expectDefined } from '#tests/helpers/assert'
import { getTool, setupToolTest, type ALL_TOOLS, type ToolResult } from '#tests/helpers/tools'
/** The `list_pages` tool answers with the document's pages. */
interface ListPagesResult extends ToolResult {
pages: Array<{ id: string; name: string }>
}
describe('find_nodes', () => {
test('finds by name', () => {
const { figma } = setupToolTest()
const rect = figma.createRectangle()
rect.name = 'Button Primary'
const text = figma.createText()
text.name = 'Label'
const tool = getTool('find_nodes')
const result = tool.execute(figma, { name: 'button' }) as ToolResult
expect(result.count).toBe(1)
expect(expectDefined(result.nodes, 'found nodes')[0].name).toBe('Button Primary')
})
test('finds by type', () => {
const { figma } = setupToolTest()
figma.createRectangle()
figma.createRectangle()
figma.createText()
const tool = getTool('find_nodes')
const result = tool.execute(figma, { type: 'RECTANGLE' }) as ToolResult
expect(result.count).toBe(2)
})
})
describe('query_nodes', () => {
test('finds all frames with //FRAME', async () => {
const { figma } = setupToolTest()
const f1 = figma.createFrame()
f1.resize(200, 200)
f1.name = 'Frame A'
const f2 = figma.createFrame()
f2.resize(300, 300)
f2.name = 'Frame B'
figma.createRectangle()
const tool = getTool('query_nodes')
const result = (await tool.execute(figma, { selector: '//FRAME' })) as ToolResult
expect(result.count).toBe(2)
expect(result.nodes?.every((n) => n.type === 'FRAME')).toBe(true)
})
test('finds by attribute //RECTANGLE[@width < 200]', async () => {
const { figma } = setupToolTest()
const small = figma.createRectangle()
small.resize(100, 50)
small.name = 'Small'
const big = figma.createRectangle()
big.resize(400, 400)
big.name = 'Big'
const tool = getTool('query_nodes')
const result = (await tool.execute(figma, {
selector: '//RECTANGLE[@width < 200]'
})) as ToolResult
expect(result.count).toBe(1)
expect(expectDefined(result.nodes, 'queried nodes')[0].name).toBe('Small')
})
test('finds by name with contains', async () => {
const { figma } = setupToolTest()
const t1 = figma.createText()
t1.name = 'Label Primary'
const t2 = figma.createText()
t2.name = 'Title'
const t3 = figma.createText()
t3.name = 'Label Secondary'
const tool = getTool('query_nodes')
const result = (await tool.execute(figma, {
selector: '//TEXT[contains(@name, "Label")]'
})) as ToolResult
expect(result.count).toBe(2)
expect(result.nodes?.every((n) => n.name.includes('Label'))).toBe(true)
})
test('returns error for invalid xpath', async () => {
const { figma } = setupToolTest()
const tool = getTool('query_nodes')
const result = (await tool.execute(figma, { selector: '///invalid[[[[' })) as ToolResult
expect(result.error).toBeTruthy()
expect(result.error).toContain('XPath error')
})
test('respects limit param', async () => {
const { figma } = setupToolTest()
for (let i = 0; i < 10; i++) {
const r = figma.createRectangle()
r.name = `Rect ${i}`
}
const tool = getTool('query_nodes')
const result = (await tool.execute(figma, { selector: '//RECTANGLE', limit: 3 })) as ToolResult
expect(result.count).toBe(3)
})
test('returns empty array when nothing matches', async () => {
const { figma } = setupToolTest()
figma.createRectangle()
const tool = getTool('query_nodes')
const result = (await tool.execute(figma, { selector: '//ELLIPSE' })) as ToolResult
expect(result.count).toBe(0)
expect(result.nodes).toEqual([])
})
})
describe('get_font_status', () => {
test('returns font fidelity diagnostics for agents', () => {
const { figma } = setupToolTest()
const text = figma.createText()
text.name = 'Missing label'
text.fontName = { family: 'Unavailable Sans', style: 'Regular' }
const tool = getTool('get_font_status')
const result = tool.execute(figma, {}) as {
faithful: boolean
issues: Array<{
family: string
style: string
status: string
nodeIds: string[]
}>
}
// The tool reads the process-wide font manager. A missing family is
// 'unresolved' in a pristine process and 'substituted' by the default font
// once any earlier suite in the shard has loaded it; both are issues.
const defaultLoaded = fontManager.loadedFontSource(DEFAULT_FONT_FAMILY, 'Regular') !== null
expect(result.faithful).toBe(false)
expect(result.issues).toEqual([
expect.objectContaining({
family: 'Unavailable Sans',
style: 'Regular',
status: defaultLoaded ? 'substituted' : 'unresolved',
substituteFamily: defaultLoaded ? DEFAULT_FONT_FAMILY : null,
nodeIds: [text.id]
})
])
})
})
describe('get_node', () => {
test('returns node details', () => {
const { figma } = setupToolTest()
const rect = figma.createRectangle()
rect.name = 'Test Rect'
rect.resize(100, 50)
const tool = getTool('get_node')
const result = tool.execute(figma, { id: rect.id }) as ToolResult
expect(result.name).toBe('Test Rect')
expect(result.width).toBe(100)
expect(result.height).toBe(50)
})
})
describe('page tools', () => {
test('list_pages returns pages', () => {
const { figma } = setupToolTest()
const tool = getTool('list_pages')
const result = tool.execute(figma, {}) as ListPagesResult
expect(result.pages.length).toBeGreaterThanOrEqual(1)
})
test('switch_page changes page', () => {
const { figma } = setupToolTest()
const page2 = figma.createPage()
page2.name = 'Page 2'
const tool = getTool('switch_page')
tool.execute(figma, { page: 'Page 2' })
expect(figma.currentPage.name).toBe('Page 2')
})
test('switch_page persists across separate FigmaAPI instances (RPC simulation)', () => {
const { graph } = setupToolTest()
const switchPage = getTool('switch_page')
const getCurrentPage = getTool('get_current_page')
const createPage = getTool('create_page')
let currentPageId = graph.getPages()[0].id
function rpcCall(tool: (typeof ALL_TOOLS)[number], args: Record<string, unknown>) {
const figma = new FigmaAPI(graph)
figma.currentPage = figma.wrapNode(currentPageId)
const result = tool.execute(figma, args)
if (figma.currentPageId !== currentPageId) {
currentPageId = figma.currentPageId
}
return result
}
rpcCall(createPage, { name: 'Second' })
rpcCall(switchPage, { page: 'Second' })
const result = rpcCall(getCurrentPage, {}) as { id: string; name: string }
expect(result.name).toBe('Second')
})
})
describe('eval', () => {
test('executes code with figma api', async () => {
const { figma } = setupToolTest()
const tool = getTool('eval')
const result = await tool.execute(figma, {
code: 'const r = figma.createRectangle(); r.name = "FromEval"; return r.name;'
})
expect(result).toBe('FromEval')
})
})
describe('get_selection', () => {
/** A card selected on the page, holding a row that holds a chip. */
function selectedCard() {
const { figma } = setupToolTest()
const card = figma.createFrame()
card.name = 'Card'
const row = figma.createFrame()
row.name = 'Row'
card.appendChild(row)
const chip = figma.createRectangle()
chip.name = 'Chip'
row.appendChild(chip)
figma.currentPage.selection = [card]
return figma
}
test('returns the selection with its direct children by default', () => {
const result = getTool('get_selection').execute(selectedCard(), {}) as {
selection: Array<{ name: string; children?: Array<{ name: string; childCount?: number }> }>
}
const [card] = result.selection
expect(card.name).toBe('Card')
expect(card.children?.map((child) => child.name)).toEqual(['Row'])
expect(card.children?.[0].childCount).toBe(1)
})
test('takes a depth', () => {
const tool = getTool('get_selection')
const shallow = tool.execute(selectedCard(), { depth: 0 }) as {
selection: Array<{ children?: unknown; childCount?: number }>
}
expect(shallow.selection[0].children).toBeUndefined()
expect(shallow.selection[0].childCount).toBe(1)
const deep = tool.execute(selectedCard(), { depth: 2 }) as {
selection: Array<{ children: Array<{ children: Array<{ name: string }> }> }>
}
expect(deep.selection[0].children[0].children[0].name).toBe('Chip')
})
})