* 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>
184 lines
5.8 KiB
TypeScript
184 lines
5.8 KiB
TypeScript
import { describe, expect, test } from 'bun:test'
|
|
import { Readable } from 'node:stream'
|
|
|
|
import {
|
|
DevMCPConfigurationSyntaxError,
|
|
DevMCPConfigurationTooLargeError,
|
|
configurationsMatch,
|
|
createAutomationEnvironment,
|
|
devMCPConfigurationErrorStatus,
|
|
readDevMCPConfiguration,
|
|
waitForAutomationHealth
|
|
} from '@/app/automation/bridge/vite-plugin'
|
|
import {
|
|
parseDevMCPConfiguration,
|
|
type DevMCPConfiguration
|
|
} from '@/app/automation/mcp/dev-control'
|
|
|
|
import { fetchStub } from '#tests/helpers/fetch'
|
|
|
|
describe('MCP Vite development server', () => {
|
|
test('passes an explicit empty auth token when authentication is disabled', () => {
|
|
const env = createAutomationEnvironment({
|
|
authToken: 'development-token',
|
|
baseEnv: { OPENPENCIL_MCP_AUTH_TOKEN: 'inherited-token' },
|
|
configuration: {
|
|
authenticationEnabled: false,
|
|
rootDirectory: '/designs',
|
|
disabledTools: ['eval', 'delete_node'],
|
|
scope: 'selection'
|
|
},
|
|
corsOrigin: 'http://localhost:1420',
|
|
discoveryPath: '/tmp/mcp.json',
|
|
httpPort: 7600,
|
|
socketPath: '/tmp/open-pencil.sock'
|
|
})
|
|
|
|
expect(env.OPENPENCIL_MCP_AUTH_TOKEN).toBe('')
|
|
expect(env.OPENPENCIL_MCP_ROOT).toBe('/designs')
|
|
expect(env.OPENPENCIL_MCP_DISABLED_TOOLS).toBe('eval,delete_node')
|
|
expect(env.OPENPENCIL_MCP_DISCOVERY_PATH).toBe('/tmp/mcp.json')
|
|
expect(env.OPENPENCIL_MCP_SCOPE).toBe('selection')
|
|
})
|
|
|
|
test('normalizes and validates typed disabled tool configuration', () => {
|
|
expect(
|
|
parseDevMCPConfiguration({
|
|
authenticationEnabled: true,
|
|
rootDirectory: '/designs',
|
|
disabledTools: [' eval ', 'delete_node', 'eval'],
|
|
scope: 'document'
|
|
})
|
|
).toEqual({
|
|
authenticationEnabled: true,
|
|
rootDirectory: '/designs',
|
|
disabledTools: ['eval', 'delete_node'],
|
|
scope: 'document'
|
|
})
|
|
expect(
|
|
parseDevMCPConfiguration({
|
|
authenticationEnabled: true,
|
|
rootDirectory: '',
|
|
disabledTools: ['invalid tool'],
|
|
scope: 'document'
|
|
})
|
|
).toBeNull()
|
|
})
|
|
|
|
test('decodes UTF-8 configuration split across request chunks', async () => {
|
|
const body = Buffer.from(
|
|
JSON.stringify({
|
|
authenticationEnabled: true,
|
|
rootDirectory: '/设计',
|
|
disabledTools: [],
|
|
scope: 'document'
|
|
})
|
|
)
|
|
const split = body.indexOf(Buffer.from('设')) + 1
|
|
const request = Readable.from([body.subarray(0, split), body.subarray(split)])
|
|
|
|
await expect(readDevMCPConfiguration(request as never)).resolves.toEqual({
|
|
authenticationEnabled: true,
|
|
rootDirectory: '/设计',
|
|
disabledTools: [],
|
|
scope: 'document'
|
|
})
|
|
})
|
|
|
|
test('does not restart MCP for unchanged configuration', () => {
|
|
const configuration: DevMCPConfiguration = {
|
|
authenticationEnabled: true,
|
|
rootDirectory: '/designs',
|
|
disabledTools: ['eval', 'delete_node'],
|
|
scope: 'document'
|
|
}
|
|
expect(configurationsMatch(configuration, structuredClone(configuration))).toBe(true)
|
|
expect(
|
|
configurationsMatch(configuration, { ...configuration, disabledTools: ['delete_node'] })
|
|
).toBe(false)
|
|
expect(configurationsMatch(configuration, { ...configuration, scope: 'selection' })).toBe(false)
|
|
})
|
|
|
|
test('waits through transient Portless responses until MCP is healthy', async () => {
|
|
const statuses = [404, 404, 200]
|
|
const requests: string[] = []
|
|
await waitForAutomationHealth(
|
|
'wss://feature.mcp.open-pencil.localhost',
|
|
fetchStub(async (input) => {
|
|
requests.push(String(input))
|
|
return new Response(null, { status: statuses.shift() ?? 500 })
|
|
})
|
|
)
|
|
|
|
expect(requests).toEqual([
|
|
'https://feature.mcp.open-pencil.localhost/health',
|
|
'https://feature.mcp.open-pencil.localhost/health',
|
|
'https://feature.mcp.open-pencil.localhost/health'
|
|
])
|
|
})
|
|
|
|
test('health probes do not send credentials', async () => {
|
|
await waitForAutomationHealth(
|
|
'ws://localhost:7682',
|
|
fetchStub(async (_input, init) => {
|
|
expect(new Headers(init?.headers).has('authorization')).toBe(false)
|
|
return new Response(null)
|
|
})
|
|
)
|
|
})
|
|
|
|
test('propagates child exit while the request is in flight', async () => {
|
|
let running = true
|
|
await expect(
|
|
waitForAutomationHealth(
|
|
'ws://localhost:7682',
|
|
fetchStub(async () => {
|
|
running = false
|
|
return new Response(null)
|
|
}),
|
|
{
|
|
assertRunning() {
|
|
if (!running) throw new Error('child exited in flight')
|
|
}
|
|
}
|
|
)
|
|
).rejects.toThrow('child exited in flight')
|
|
})
|
|
|
|
test('rejects a failed child before probing another endpoint', async () => {
|
|
let requested = false
|
|
await expect(
|
|
waitForAutomationHealth(
|
|
'ws://localhost:7682',
|
|
fetchStub(async () => {
|
|
requested = true
|
|
return new Response(null)
|
|
}),
|
|
{
|
|
assertRunning() {
|
|
throw new Error('child exited')
|
|
}
|
|
}
|
|
)
|
|
).rejects.toThrow('child exited')
|
|
expect(requested).toBe(false)
|
|
})
|
|
|
|
test('classifies malformed and oversized configuration requests', async () => {
|
|
const malformed = Readable.from(['{'])
|
|
const malformedError = await readDevMCPConfiguration(malformed as never).catch(
|
|
(error: unknown) => error
|
|
)
|
|
expect(malformedError).toBeInstanceOf(DevMCPConfigurationSyntaxError)
|
|
expect(devMCPConfigurationErrorStatus(malformedError)).toBe(400)
|
|
|
|
const oversized = Readable.from([Buffer.alloc(70_001)])
|
|
const oversizedError = await readDevMCPConfiguration(oversized as never).catch(
|
|
(error: unknown) => error
|
|
)
|
|
expect(oversizedError).toBeInstanceOf(DevMCPConfigurationTooLargeError)
|
|
expect(devMCPConfigurationErrorStatus(oversizedError)).toBe(413)
|
|
expect(devMCPConfigurationErrorStatus(new Error('restart failed'))).toBe(500)
|
|
})
|
|
})
|