* 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>
268 lines
8.6 KiB
TypeScript
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')
|
|
})
|
|
})
|