* 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>
69 lines
2.5 KiB
TypeScript
69 lines
2.5 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
|
|
|
|
import { SceneGraph } from '@open-pencil/scene-graph'
|
|
|
|
import { limitToSelection } from '@/app/automation/bridge/selection-scope'
|
|
import { createEditorStore, type EditorStore } from '@/app/editor/session'
|
|
|
|
import { expectDefined } from '#tests/helpers/assert'
|
|
|
|
let store: EditorStore
|
|
let ids: { card: string; title: string; other: string }
|
|
|
|
beforeEach(() => {
|
|
store = createEditorStore(new SceneGraph())
|
|
const page = expectDefined(store.graph.getPages()[0], 'page').id
|
|
const card = store.graph.createNode('FRAME', page, { name: 'Card', width: 200, height: 200 })
|
|
const title = store.graph.createNode('TEXT', card.id, { name: 'Title', width: 100, height: 20 })
|
|
const other = store.graph.createNode('FRAME', page, {
|
|
name: 'Other',
|
|
x: 300,
|
|
width: 100,
|
|
height: 100
|
|
})
|
|
ids = { card: card.id, title: title.id, other: other.id }
|
|
store.select([card.id])
|
|
})
|
|
|
|
afterEach(() => {
|
|
store.preparationController.dispose()
|
|
})
|
|
|
|
describe('limitToSelection', () => {
|
|
test('lets a call reach the selected layers and what they hold', () => {
|
|
expect(limitToSelection(store, 'get_node', { id: ids.title })).toEqual({ id: ids.title })
|
|
expect(limitToSelection(store, 'get_page_tree', { root_id: ids.card })).toEqual({
|
|
root_id: ids.card
|
|
})
|
|
expect(limitToSelection(store, 'get_selection', {})).toEqual({})
|
|
})
|
|
|
|
test('rejects nodes outside the selection', () => {
|
|
expect(() => limitToSelection(store, 'get_node', { id: ids.other })).toThrow(/outside/)
|
|
expect(() => limitToSelection(store, 'describe', { ids: [ids.title, ids.other] })).toThrow(
|
|
/outside/
|
|
)
|
|
})
|
|
|
|
test('reads the selection when a call names no nodes', () => {
|
|
expect(limitToSelection(store, 'export_image', { scale: 2 })).toEqual({
|
|
scale: 2,
|
|
ids: [ids.card]
|
|
})
|
|
expect(limitToSelection(store, 'describe', {})).toEqual({ ids: [ids.card] })
|
|
expect(() => limitToSelection(store, 'get_page_tree', {})).toThrow(/root_id/)
|
|
})
|
|
|
|
test('rejects writing an export to a file', () => {
|
|
expect(() =>
|
|
limitToSelection(store, 'export_image', { ids: [ids.card], path: 'card.png' })
|
|
).toThrow(/Writing files/)
|
|
})
|
|
|
|
test('rejects other tools and calls without a selection', () => {
|
|
expect(() => limitToSelection(store, 'delete_node', { id: ids.title })).toThrow(/unavailable/)
|
|
store.clearSelection()
|
|
expect(() => limitToSelection(store, 'get_selection', {})).toThrow(/Nothing is selected/)
|
|
})
|
|
})
|