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>
This commit is contained in:
Victor Wads 2026-10-07 13:01:28 +00:00 committed by GitHub
parent 85d8c69ccc
commit 68ffd72839
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
37 changed files with 563 additions and 60 deletions

View file

@ -36,6 +36,7 @@
### Added
- Share only the selected layers with MCP clients: **Share only the selection** under **Settings → MCP → Local server**, or `OPENPENCIL_MCP_SCOPE=selection` for a server you start yourself, limits clients to `get_selection`, `get_node`, `get_page_tree`, `describe`, and `export_image` on the selected layers and what they hold, enforced by the server for MCP sessions, stdio clients, and `/rpc` alike.
- Set up AI in a few steps with guided setup. On first launch OpenPencil asks what AI should help with and what you already use: a coding agent on this computer (Claude Code, Codex, Gemini CLI, or Pi, in the desktop app), an API account (Anthropic, OpenAI, Google AI, OpenRouter, DeepSeek, Z.ai, or MiniMax), or a local or company server such as Ollama or LM Studio. Sign in to OpenRouter from the browser or the desktop app instead of pasting a key. In the desktop app, setup checks whether the agent, its OpenPencil adapter, OpenPencil's MCP server, and Pi's Harness companion are installed and match the app, installs or updates them with one click through npm, and links a [setup guide](https://openpencil.dev/programmable/coding-agents) or copies a prompt that asks the agent to set itself up. Setup tests each connection, proposes a model for each role that you can change, and keeps anything configured by hand. Skip it to start designing, or run it again from **Settings → AI & agents**.
- Print the installed version of OpenPencil's MCP server and Harness companion with `openpencil-mcp-http --version`, `openpencil-mcp --version`, and `openpencil-harness --version`. The desktop app asks it to check that they match the app on every platform, including npm `.cmd` shims on Windows and version managers such as Volta and mise.
- Make main components behave like real controls and try them in preview. The Behaviour section turns a component or component set into a Button, Text field, Textarea, Number field, Toggle, Switch, Checkbox, Radio, Radio group, Toggle group, Slider, Progress, Tabs, Collapsible, or Accordion, after Reka UI's primitives: its values bind to the component's variant, boolean, and text properties, a slider or number field keeps its own number range, its parts are its slots (a group's items slot holds its radios, toggles, or collapsibles), and a variant property can draw its default, hover, pressed, focus, and disabled states. Rows are named for the control, and an empty row creates what it needs: a text layer and text property, Off and On variants, a slot in every variant, or a variant for each interaction state, turning a lone component into a component set when it needs variants. View > Preview (⌥⌘↩) runs each top-level layer holding controls as live Reka UI components over the canvas, drawn by the component's variants: real inputs with the browser's caret, selection, and paste, keyboard focus and navigation, and layout that grows as collapsibles open, all without changing the document, its history, or what collaborators see; Esc or the pill returns to editing.
@ -80,6 +81,7 @@
### Changed
- The `get_selection` tool returns the selected layers with their direct children by default instead of their whole subtrees, and takes a `depth` for more or fewer levels, so agents can start from what the user selected without reading the full tree.
- The width and height menus in the properties panel offer Hug only for auto-layout frames and text, as in Figma; on a frame without auto layout it had no effect.
- Confirm deleting an AI conversation in the standard confirmation dialog, which names the conversation, instead of a prompt inside the chat panel.
- Run Pi with the providers you signed in to in the Pi CLI and Pi's default model, so an AI Gateway key and a model ID are optional. The Pi model editor shows whether the Harness companion and MCP server are installed and match the app, and a chat whose Harness companion is missing, or whose companion or MCP server does not match the app, says what to fix and offers guided setup instead of failing with a generic error, and the message and its attachments stay in the composer.

View file

@ -1,17 +1,35 @@
import * as v from 'valibot'
import type { FigmaNodeProxy } from '#core/figma-api'
import { toolNumber } from '#core/tools/input'
import { defineTool, nodeToResult } from '#core/tools/schema'
/** How far `get_selection` describes the selection's children unless asked for more. */
const SELECTION_DEPTH = 1
export const getSelection = defineTool({
name: 'get_selection',
description: 'Get details about currently selected nodes.',
description:
'Get the selected nodes: the place to start when the user points at layers. Returns each selected node with its direct children by default; children past the depth are counted as childCount. Use get_node or describe for deeper branches.',
execution: { kind: 'sync', mutation: 'none' },
exposure: { webmcp: false },
input: v.object({}),
execute: (figma) => {
input: v.object({
depth: v.optional(
toolNumber(
v.pipe(
v.number(),
v.integer(),
v.minValue(0),
v.description(
`Levels of children to include (0 = selected nodes only). Default: ${SELECTION_DEPTH}`
)
)
)
)
}),
execute: (figma, { depth = SELECTION_DEPTH }) => {
const selection = figma.currentPage.selection
return { selection: selection.map(nodeToResult) }
return { selection: selection.map((node) => nodeToResult(node, depth)) }
}
})

View file

@ -17,6 +17,12 @@ Use **Settings → Tool access** (select **Local MCP**) to search and toggle the
Restart the MCP server, then reconnect stdio clients, to apply changes. For an externally managed server, restart its owning process. The list reflects the tools discovered from the server; disabling a dedicated tool does not prevent an enabled script tool from performing the same operation. These switches are not a sandbox and do not configure remote MCP servers or WebMCP.
## Share only the selection {#selection-scope}
Turn on **Share only the selection** in **Settings → MCP → Local server** to let MCP clients read only the layers you select. Clients then get `get_selection`, `get_node`, `get_page_tree`, `describe`, and `export_image`, and no tools that edit, open files, list documents, or change settings. Every node a call names must be a selected layer or inside one; `describe` and `export_image` read the selection when given no IDs, and `get_page_tree` needs a `root_id` from the selection. `export_image` returns the image but cannot write it to a file. With nothing selected, calls fail and ask the user to select layers.
The server enforces this on every call it sends to the app, including `POST /rpc` and stdio clients, so a client cannot widen it. Restart the MCP server to apply the change. For a server you start yourself, set `OPENPENCIL_MCP_SCOPE=selection`; a stdio client can also set it to limit itself while the server shares the whole document.
## Browser-native WebMCP (experimental) {#webmcp}
WebMCP is **off by default**. Open **Settings → MCP → WebMCP** and choose **Inspect** for read-only access or **Edit** to also allow scoped, undoable changes. **Off** unregisters all browser tools; changing modes revokes the previous registrations immediately. This preference is independent of local MCP authentication, tool switches, and outbound connections.
@ -204,7 +210,7 @@ Settings tools never expose credentials, AI models, MCP connections, storage, or
| Tool | Description |
|------|-------------|
| `get_selection` | Get currently selected nodes |
| `get_selection` | Get the selected nodes, with their direct children by default; `depth` sets how many levels |
| `get_page_tree` | Get the full node tree of the current page |
| `get_current_page` | Get the current page name and ID |
| `get_node` | Get detailed properties of a node by ID |

View file

@ -2,7 +2,21 @@
"name": "@open-pencil/mcp",
"version": "0.15.1",
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/open-pencil/open-pencil.git",
"directory": "packages/mcp"
},
"bin": {
"openpencil-mcp": "./dist/stdio.mjs",
"openpencil-mcp-http": "./dist/index.mjs"
},
"files": [
"dist",
"src"
],
"type": "module",
"main": "./dist/server.mjs",
"imports": {
"#mcp/*": {
"types": "./src/*.ts",
@ -10,7 +24,6 @@
"default": "./dist/*.mjs"
}
},
"main": "./dist/server.mjs",
"exports": {
".": {
"types": "./dist/server.d.mts",
@ -41,27 +54,14 @@
"default": "./dist/tools.mjs"
}
},
"bin": {
"openpencil-mcp": "./dist/stdio.mjs",
"openpencil-mcp-http": "./dist/index.mjs"
},
"files": [
"dist",
"src"
],
"scripts": {
"build": "bunx tsdown --config tsdown.config.ts",
"prepublishOnly": "bun run build"
},
"repository": {
"type": "git",
"url": "git+https://github.com/open-pencil/open-pencil.git",
"directory": "packages/mcp"
},
"publishConfig": {
"access": "public",
"provenance": true
},
"scripts": {
"build": "bunx tsdown --config tsdown.config.ts",
"prepublishOnly": "bun run build"
},
"dependencies": {
"@hono/node-server": "^1.19.17",
"@modelcontextprotocol/server": "^2.0.0",

View file

@ -28,6 +28,8 @@ if (process.argv.includes('--help') || process.argv.includes('-h')) {
` OPENPENCIL_MCP_ROOT Allowed directory for file-scoped tools (default: home directory on Windows, current working directory elsewhere)\n` +
` OPENPENCIL_MCP_EVAL Set to 1 to enable the eval tool\n` +
` OPENPENCIL_MCP_DISABLED_TOOLS Comma-separated tool names to omit\n` +
` OPENPENCIL_MCP_SCOPE "selection" to let clients read only the selected layers\n` +
` (default: "document")\n` +
` OPENPENCIL_MCP_CORS_ORIGIN Allowed CORS origins, comma-separated.\n` +
` Defaults to the desktop app origin, since the app\n` +
` webview is the only view that calls this server.\n` +
@ -85,6 +87,7 @@ const handle = await startServer({
socketPath: process.env.OPENPENCIL_MCP_SOCKET?.trim() || null,
enableEval: toolPolicy.allowEval,
disabledTools: toolPolicy.disabledTools,
scope: toolPolicy.scope,
mcpRoot: resolveMCPRoot(process.env.OPENPENCIL_MCP_ROOT),
// Auth token: undefined → auto-generate, empty string → disable auth,
// non-empty → use trimmed value. Whitespace-only is rejected to prevent a

View file

@ -25,6 +25,7 @@ import {
type MCPAgentKind,
type MCPAgentSession
} from '#mcp/tool/registration'
import { scopeRPC, type MCPToolScope } from '#mcp/tool/scope'
import packageJSON from '../package.json' with { type: 'json' }
import {
@ -88,6 +89,8 @@ export interface ServerOptions {
enableEval?: boolean
/** Tool names omitted from every MCP session. */
disabledTools?: Iterable<string>
/** What clients can reach: the whole document (default) or only the user's selection. */
scope?: MCPToolScope
mcpRoot?: string | null
/** Auth token for /mcp and /rpc endpoints. Auto-generated (32-hex) when omitted. Pass null explicitly to disable auth. */
authToken?: string | null
@ -312,7 +315,8 @@ function buildServerContext(options: ServerOptions) {
const httpPort = options.httpPort ?? 7600
const toolPolicy: ToolPolicy = {
allowEval: options.enableEval ?? false,
disabledTools: [...new Set(options.disabledTools)]
disabledTools: [...new Set(options.disabledTools)],
scope: options.scope ?? 'document'
}
const mcpRoot = options.mcpRoot ?? null
// Auto-generated so all transports require auth by default. Override via OPENPENCIL_MCP_AUTH_TOKEN or authToken option.
@ -355,7 +359,8 @@ function buildServerContext(options: ServerOptions) {
onConnectionChange: mcpSessions.notifyToolsChanged,
appWaitTimeoutMs: options.appWaitTimeoutMs
})
const sendToBrowser = browserRPC.sendRPC
// Every call into the app, from MCP sessions and /rpc alike, stays within the scope.
const sendToBrowser = scopeRPC(browserRPC.sendRPC, toolPolicy.scope)
const toolDescriptors = applyToolPolicy(createToolDescriptors(mcpRoot !== null), toolPolicy)
const app = createHonoApp({
@ -377,7 +382,8 @@ function buildServerContext(options: ServerOptions) {
app,
wss,
authToken,
disabledTools: toolPolicy.disabledTools
disabledTools: toolPolicy.disabledTools,
scope: toolPolicy.scope
}
}
@ -499,6 +505,7 @@ export async function startServer(options: ServerOptions = {}): Promise<ServerHa
ctx.authToken,
MCP_VERSION,
ctx.disabledTools,
ctx.scope,
state
)
} catch (err) {

View file

@ -8,6 +8,7 @@ import { getRequestListener } from '@hono/node-server'
import type { Hono } from 'hono'
import type { WebSocketServer } from 'ws'
import type { MCPToolScope } from '#mcp/tool/scope'
import {
parseDiscoveryInfo,
removeDiscoveryFile,
@ -168,7 +169,8 @@ export async function writeDiscovery(
actualHttpPort: number,
authToken: string | null,
version: string,
disabledTools: string[]
disabledTools: string[],
scope: MCPToolScope
): Promise<string> {
const startedAt = new Date().toISOString()
await writeDiscoveryFile({
@ -179,7 +181,8 @@ export async function writeDiscovery(
authToken,
version,
startedAt,
disabledTools
disabledTools,
scope
})
return startedAt
}
@ -364,6 +367,7 @@ export async function tryWriteDiscovery(
authToken: string | null,
version: string,
disabledTools: string[],
scope: MCPToolScope,
state: ListenerState
): Promise<string> {
try {
@ -372,7 +376,8 @@ export async function tryWriteDiscovery(
actualHttpPort,
authToken,
version,
disabledTools
disabledTools,
scope
)
} catch (err) {
// If discovery file write fails after both listeners are up, tear down

View file

@ -9,6 +9,7 @@ import { MCP_VERSION, registerTools } from '#mcp/server'
import { createStdioRPCBridge } from '#mcp/stdio/bridge'
import type { ToolPolicy } from '#mcp/tool/metadata'
import { parseDisabledTools } from '#mcp/tool/policy'
import { narrowestScope, parseToolScope } from '#mcp/tool/scope'
import { readDiscoveryFile } from '#mcp/transport/discovery'
if (process.argv.includes('--version')) {
@ -33,17 +34,25 @@ if (process.argv.includes('--help') || process.argv.includes('-h')) {
` OPENPENCIL_MCP_ROOT Allowed directory for file-scoped tools\n` +
` (default: home directory on Windows, cwd elsewhere)\n` +
` OPENPENCIL_MCP_EVAL Set to 1 to enable the eval tool\n` +
` OPENPENCIL_MCP_DISABLED_TOOLS Comma-separated tool names to omit; defaults to the app setting\n`
` OPENPENCIL_MCP_DISABLED_TOOLS Comma-separated tool names to omit; defaults to the app setting\n` +
` OPENPENCIL_MCP_SCOPE "selection" to read only the selected layers; defaults to the app setting\n`
)
process.exit(0)
}
const discovery = await readDiscoveryFile()
const toolPolicy: ToolPolicy = {
allowEval: process.env.OPENPENCIL_MCP_EVAL === '1',
disabledTools:
process.env.OPENPENCIL_MCP_DISABLED_TOOLS === undefined
? ((await readDiscoveryFile())?.disabledTools ?? [])
: parseDisabledTools(process.env.OPENPENCIL_MCP_DISABLED_TOOLS)
? (discovery?.disabledTools ?? [])
: parseDisabledTools(process.env.OPENPENCIL_MCP_DISABLED_TOOLS),
// The server enforces its own scope on every call; this keeps the tool list in step with it,
// and lets the client limit itself further.
scope: narrowestScope(
discovery?.scope ?? 'document',
parseToolScope(process.env.OPENPENCIL_MCP_SCOPE)
)
}
const mcpRoot = resolveMCPRoot(process.env.OPENPENCIL_MCP_ROOT)
// Auth token: undefined → auto-discover from discovery file, empty string →

View file

@ -1,2 +1,11 @@
export * from './metadata'
export { serializeDisabledTools } from './policy'
export { isToolEnabled, serializeDisabledTools } from './policy'
export {
MCP_TOOL_SCOPES,
SELECTION_SCOPE_TOOLS,
isSelectionScopeTool,
narrowestScope,
SELECTION_SCOPE_FILE_OUTPUT_ERROR,
parseToolScope,
type MCPToolScope
} from './scope'

View file

@ -1,6 +1,8 @@
import * as v from 'valibot'
import type { ToolCapability } from '@open-pencil/core/tools'
import type { MCPToolScope } from '#mcp/tool/scope'
export type { ToolCapability } from '@open-pencil/core/tools'
const TOOL_EFFECTS = ['read', 'write'] as const
@ -33,6 +35,8 @@ export type ToolDescriptor = v.InferOutput<typeof toolDescriptorSchema>
export interface ToolPolicy {
allowEval: boolean
disabledTools: string[]
/** What clients can reach; see `MCPToolScope`. */
scope: MCPToolScope
}
export function parseToolDescriptor(value: unknown): ToolDescriptor | null {

View file

@ -1,6 +1,7 @@
import { compact, uniq } from 'es-toolkit/array'
import type { ToolDescriptor, ToolPolicy } from '#mcp/tool/metadata'
import { isSelectionScopeTool, parseToolScope } from '#mcp/tool/scope'
export function parseDisabledTools(value: string | undefined): string[] {
if (!value) return []
@ -8,6 +9,7 @@ export function parseDisabledTools(value: string | undefined): string[] {
}
export function isToolEnabled(descriptor: ToolDescriptor, policy: ToolPolicy): boolean {
if (policy.scope === 'selection' && !isSelectionScopeTool(descriptor.name)) return false
if (policy.disabledTools.includes(descriptor.name)) return false
return descriptor.availability !== 'eval' || policy.allowEval
}
@ -29,6 +31,7 @@ export function serializeDisabledTools(names: readonly string[]): string {
export function readToolPolicyFromEnv(env: NodeJS.ProcessEnv = process.env): ToolPolicy {
return {
allowEval: env.OPENPENCIL_MCP_EVAL === '1',
disabledTools: parseDisabledTools(env.OPENPENCIL_MCP_DISABLED_TOOLS)
disabledTools: parseDisabledTools(env.OPENPENCIL_MCP_DISABLED_TOOLS),
scope: parseToolScope(env.OPENPENCIL_MCP_SCOPE)
}
}

View file

@ -14,6 +14,7 @@ import { createToolDescriptors, getMCPToolDefinitions } from '#mcp/tool/manifest
import type { ToolDescriptor, ToolEffect, ToolPolicy } from '#mcp/tool/metadata'
import { resolveSafePath, writeToolOutput } from '#mcp/tool/output'
import { isToolEnabled } from '#mcp/tool/policy'
import { SELECTION_SCOPE_FILE_OUTPUT_ERROR } from '#mcp/tool/scope'
export type RPCSender = (body: Record<string, unknown>) => Promise<unknown>
@ -124,9 +125,19 @@ export function registerTools(mcpServer: McpServer, options: RegisterToolsOption
async (args: Record<string, unknown>) => {
try {
const { target, args: toolArgs } = splitAutomationTarget(args)
if (policy.scope === 'selection' && toolArgs.path !== undefined) {
return fail(new Error(SELECTION_SCOPE_FILE_OUTPUT_ERROR))
}
const result = await sendRPC({
command: 'tool',
args: { ...target, name: def.name, args: toolArgs, agent: agent() }
args: {
...target,
name: def.name,
args: toolArgs,
agent: agent(),
// A client limited to the selection says so, whatever the server's own scope.
...(policy.scope === 'selection' ? { scope: policy.scope } : {})
}
})
const res = result as { ok?: boolean; result?: unknown; error?: string }
if (res.ok === false) return fail(new Error(res.error))

View file

@ -0,0 +1,71 @@
import type { RPCJSONObject } from '#mcp/json'
/**
* What MCP clients can reach: the whole document, or only the layers the user selected. In
* selection scope the server exposes these read tools, stamps every call it sends to the app with
* the scope, and the app rejects node IDs outside the selected layers and their descendants.
*/
export const MCP_TOOL_SCOPES = ['document', 'selection'] as const
export type MCPToolScope = (typeof MCP_TOOL_SCOPES)[number]
/** The tools selection scope keeps, each with the arguments that name the nodes it reads. */
export const SELECTION_SCOPE_TOOLS: Readonly<Record<string, readonly string[]>> = {
get_selection: [],
get_node: ['id'],
get_page_tree: ['root_id'],
describe: ['id', 'ids'],
export_image: ['ids']
}
export function isSelectionScopeTool(name: string): boolean {
return Object.hasOwn(SELECTION_SCOPE_TOOLS, name)
}
export function parseToolScope(value: string | undefined): MCPToolScope {
const scope = value?.trim() || 'document'
if (scope === 'document' || scope === 'selection') return scope
throw new Error('OPENPENCIL_MCP_SCOPE must be "document" or "selection"')
}
/** The narrower of the scopes, so a client never offers more than the server it talks to. */
export function narrowestScope(...scopes: MCPToolScope[]): MCPToolScope {
return scopes.includes('selection') ? 'selection' : 'document'
}
/** Why a selection-scoped call cannot write files, such as an export with a `path`. */
export const SELECTION_SCOPE_FILE_OUTPUT_ERROR =
'Writing files is unavailable while OpenPencil shares only the selection; omit path'
/** Commands the app may receive in selection scope besides calls of the tools above. */
const SELECTION_SCOPE_COMMANDS: ReadonlySet<string> = new Set(['agent_session_closed'])
type RPCSend = (msg: RPCJSONObject) => Promise<unknown>
function toolName(msg: RPCJSONObject): string | null {
const args = msg.args
if (!args || typeof args !== 'object' || Array.isArray(args)) return null
const name = Reflect.get(args, 'name')
return typeof name === 'string' ? name : null
}
/**
* Sends to the app within the scope: in selection scope, only the selection tools and session
* notices go through, each tool call carrying the scope however the client called it, so neither
* `/rpc` nor a stale client can reach the rest of the document or the settings.
*/
export function scopeRPC(send: RPCSend, scope: MCPToolScope): RPCSend {
if (scope === 'document') return send
return async (msg) => {
const command = typeof msg.command === 'string' ? msg.command : ''
if (SELECTION_SCOPE_COMMANDS.has(command)) return send(msg)
const name = command === 'tool' ? toolName(msg) : null
if (!name || !isSelectionScopeTool(name)) {
return {
ok: false,
error: `${name ?? command} is unavailable while OpenPencil shares only the selection with MCP clients`
}
}
const args = msg.args as RPCJSONObject
return send({ ...msg, args: { ...args, scope } })
}
}

View file

@ -4,6 +4,7 @@ import { Socket } from 'node:net'
import * as v from 'valibot'
import { MCP_TOOL_SCOPES, type MCPToolScope } from '#mcp/tool/scope'
import { getDiscoveryPath, getSocketPath, platformHasUnixSockets } from '#mcp/transport/paths'
/**
@ -24,6 +25,8 @@ export interface DiscoveryInfo {
version: string
startedAt: string
disabledTools?: string[]
/** What MCP clients can reach; stdio clients started outside the app follow it. */
scope?: MCPToolScope
}
const DiscoveryInfoJSON = v.pipe(
@ -37,7 +40,8 @@ const DiscoveryInfoJSON = v.pipe(
startedAt: v.string(),
socketPath: v.nullable(v.pipe(v.string(), v.nonEmpty())),
authToken: v.nullable(v.string()),
disabledTools: v.optional(v.array(v.string()), () => [])
disabledTools: v.optional(v.array(v.string()), () => []),
scope: v.optional(v.picklist(MCP_TOOL_SCOPES), 'document')
})
) satisfies v.GenericSchema<string, DiscoveryInfo>

View file

@ -0,0 +1,43 @@
import { describe, expect, test } from 'bun:test'
import {
isToolEnabled,
narrowestScope,
parseToolScope,
type ToolDescriptor
} from '@open-pencil/mcp/tools'
function tool(name: string): ToolDescriptor {
return {
name,
description: name,
effect: 'read',
availability: 'default',
capabilities: ['document:read'],
enabled: true
}
}
describe('MCP tool scope', () => {
test('reads the scope, defaulting to the document', () => {
expect(parseToolScope(undefined)).toBe('document')
expect(parseToolScope('selection')).toBe('selection')
expect(parseToolScope(' ')).toBe('document')
expect(() => parseToolScope('page')).toThrow(/OPENPENCIL_MCP_SCOPE/)
})
test('keeps only the selection tools in selection scope', () => {
const policy = { allowEval: false, disabledTools: ['describe'], scope: 'selection' as const }
expect(isToolEnabled(tool('get_node'), policy)).toBe(true)
expect(isToolEnabled(tool('delete_node'), policy)).toBe(false)
expect(isToolEnabled(tool('list_documents'), policy)).toBe(false)
// Turning a tool off still applies inside the scope.
expect(isToolEnabled(tool('describe'), policy)).toBe(false)
})
test('a client follows the narrower of its own scope and the server scope', () => {
expect(narrowestScope('selection', 'document')).toBe('selection')
expect(narrowestScope('document', 'selection')).toBe('selection')
expect(narrowestScope('document', 'document')).toBe('document')
})
})

View file

@ -13,12 +13,15 @@ const VALID = {
}
describe('parseDiscoveryInfo', () => {
test('defaults disabledTools to an empty list', () => {
expect(parseDiscoveryInfo(JSON.stringify(VALID))).toEqual({ ...VALID, disabledTools: [] })
expect(parseDiscoveryInfo(JSON.stringify({ ...VALID, disabledTools: ['eval'] }))).toEqual({
test('defaults disabledTools to an empty list and the scope to the document', () => {
expect(parseDiscoveryInfo(JSON.stringify(VALID))).toEqual({
...VALID,
disabledTools: ['eval']
disabledTools: [],
scope: 'document'
})
expect(
parseDiscoveryInfo(JSON.stringify({ ...VALID, disabledTools: ['eval'], scope: 'selection' }))
).toEqual({ ...VALID, disabledTools: ['eval'], scope: 'selection' })
})
test.each([

View file

@ -30,6 +30,8 @@
"version": "Version",
"authentication": "Authentifizierung erforderlich",
"authenticationDescription": "Den lokalen MCP-Endpunkt mit einem Bearer-Token schützen. Nur auf vertrauenswürdigen Geräten deaktivieren. Server neu starten, um Änderungen anzuwenden.",
"selectionScope": "Nur die Auswahl teilen",
"selectionScopeDescription": "MCP-Clients nur die ausgewählten Ebenen lesen lassen, mit get_selection, get_node, get_page_tree, describe und export_image und ohne weitere Werkzeuge. Server neu starten, um Änderungen anzuwenden.",
"rootDirectory": "MCP-Stammverzeichnis",
"rootDirectoryDefault": "Standardverzeichnis des Servers",
"chooseRootDirectory": "Ordner auswählen",

View file

@ -30,6 +30,8 @@
"version": "Versión",
"authentication": "Requerir autenticación",
"authenticationDescription": "Protege el endpoint MCP local con un token Bearer. Desactívalo solo en un equipo de confianza. Reinicia el servidor para aplicar los cambios.",
"selectionScope": "Compartir solo la selección",
"selectionScopeDescription": "Permite que los clientes MCP lean solo las capas que selecciones, con get_selection, get_node, get_page_tree, describe y export_image, y sin otras herramientas. Reinicia el servidor para aplicar los cambios.",
"rootDirectory": "Directorio raíz MCP",
"rootDirectoryDefault": "Carpeta predeterminada del servidor",
"chooseRootDirectory": "Elegir carpeta",

View file

@ -30,6 +30,8 @@
"version": "Version",
"authentication": "Exiger une authentification",
"authenticationDescription": "Protégez le point de terminaison MCP local avec un jeton Bearer. Désactivez-le uniquement sur une machine de confiance. Redémarrez le serveur pour appliquer les modifications.",
"selectionScope": "Partager uniquement la sélection",
"selectionScopeDescription": "Les clients MCP ne lisent que les calques que vous sélectionnez, avec get_selection, get_node, get_page_tree, describe et export_image, et aucun autre outil. Redémarrez le serveur pour appliquer les modifications.",
"rootDirectory": "Répertoire racine MCP",
"rootDirectoryDefault": "Dossier par défaut du serveur",
"chooseRootDirectory": "Choisir un dossier",

View file

@ -30,6 +30,8 @@
"version": "Versione",
"authentication": "Richiedi autenticazione",
"authenticationDescription": "Proteggi l’endpoint MCP locale con un token Bearer. Disabilita l’autenticazione solo su un computer attendibile. Riavvia il server per applicare le modifiche.",
"selectionScope": "Condividi solo la selezione",
"selectionScopeDescription": "I client MCP leggono solo i livelli che selezioni, con get_selection, get_node, get_page_tree, describe ed export_image, e nessun altro strumento. Riavvia il server per applicare le modifiche.",
"rootDirectory": "Directory radice MCP",
"rootDirectoryDefault": "Cartella predefinita del server",
"chooseRootDirectory": "Scegli una cartella",

View file

@ -30,6 +30,8 @@
"version": "バージョン",
"authentication": "認証を要求",
"authenticationDescription": "ローカル MCP エンドポイントを Bearer トークンで保護します。信頼できる端末でのみ無効にしてください。変更を反映するにはサーバーを再起動します。",
"selectionScope": "選択範囲のみ共有",
"selectionScopeDescription": "MCP クライアントが読めるのは選択したレイヤーだけになります。使えるツールは get_selection、get_node、get_page_tree、describe、export_image のみです。変更を反映するにはサーバーを再起動します。",
"rootDirectory": "MCP ルートディレクトリ",
"rootDirectoryDefault": "サーバーの既定ディレクトリ",
"chooseRootDirectory": "フォルダーを選択",

View file

@ -30,6 +30,8 @@
"version": "Wersja",
"authentication": "Wymagaj uwierzytelnienia",
"authenticationDescription": "Chroń lokalny punkt MCP tokenem Bearer. Wyłączaj tylko na zaufanym urządzeniu. Uruchom serwer ponownie, aby zastosować zmiany.",
"selectionScope": "Udostępniaj tylko zaznaczenie",
"selectionScopeDescription": "Klienci MCP odczytują tylko zaznaczone warstwy, za pomocą get_selection, get_node, get_page_tree, describe i export_image, bez innych narzędzi. Uruchom serwer ponownie, aby zastosować zmiany.",
"rootDirectory": "Katalog główny MCP",
"rootDirectoryDefault": "Domyślny katalog serwera",
"chooseRootDirectory": "Wybierz folder",

View file

@ -30,6 +30,8 @@
"version": "Версия",
"authentication": "Требовать аутентификацию",
"authenticationDescription": "Защитите локальную конечную точку MCP с помощью Bearer-токена. Отключайте только на доверенном устройстве. Перезапустите сервер, чтобы применить изменения.",
"selectionScope": "Открывать только выделение",
"selectionScopeDescription": "MCP-клиенты читают только выделенные вами слои, через get_selection, get_node, get_page_tree, describe и export_image, без других инструментов. Перезапустите сервер, чтобы применить изменения.",
"rootDirectory": "Корневой каталог MCP",
"rootDirectoryDefault": "Каталог сервера по умолчанию",
"chooseRootDirectory": "Выбрать папку",

View file

@ -30,6 +30,8 @@
"version": "版本",
"authentication": "要求身份验证",
"authenticationDescription": "使用 Bearer 令牌保护本地 MCP 端点。仅在可信设备上禁用。重启服务器以应用更改。",
"selectionScope": "仅共享所选内容",
"selectionScopeDescription": "MCP 客户端只能读取你选中的图层,仅可使用 get_selection、get_node、get_page_tree、describe 和 export_image,不能使用其他工具。重启服务器以应用更改。",
"rootDirectory": "MCP 根目录",
"rootDirectoryDefault": "服务器默认目录",
"chooseRootDirectory": "选择文件夹",

View file

@ -83,6 +83,9 @@ export const automationMessageDefaults = {
authentication: 'Require authentication',
authenticationDescription:
'Protect the localhost MCP endpoint with a bearer token. Disable only on a trusted machine. Restart the server to apply changes.',
selectionScope: 'Share only the selection',
selectionScopeDescription:
'Let MCP clients read only the layers you select, with get_selection, get_node, get_page_tree, describe, and export_image, and no other tools. Restart the server to apply changes.',
rootDirectory: 'MCP root directory',
rootDirectoryDefault: 'Server default directory',
chooseRootDirectory: 'Choose folder',

View file

@ -0,0 +1,48 @@
import {
isSelectionScopeTool,
SELECTION_SCOPE_FILE_OUTPUT_ERROR,
SELECTION_SCOPE_TOOLS
} from '@open-pencil/mcp/tools'
import type { EditorStore } from '@/app/editor/active-store'
/** Tools that read the selection itself when a call names no nodes. */
const READS_SELECTION_BY_DEFAULT: ReadonlySet<string> = new Set(['describe', 'export_image'])
function namedIds(value: unknown): string[] {
if (typeof value === 'string') return [value]
if (Array.isArray(value)) return value.filter((item): item is string => typeof item === 'string')
return []
}
/**
* A tool call's arguments limited to the user's selection, for an MCP server that shares only the
* selection: every node the call names must be a selected layer or inside one, and `describe` and
* `export_image` read the selection when they name none. Throws when the call reaches further.
*/
export function limitToSelection(
store: EditorStore,
toolName: string,
toolArgs: Record<string, unknown>
): Record<string, unknown> {
if (!isSelectionScopeTool(toolName)) {
throw new Error(`${toolName} is unavailable while OpenPencil shares only the selection`)
}
// The MCP side writes a `path` to disk, which the selection does not extend to.
if (toolArgs.path !== undefined) throw new Error(SELECTION_SCOPE_FILE_OUTPUT_ERROR)
const fields = SELECTION_SCOPE_TOOLS[toolName]
const selected = [...store.state.selectedIds]
if (selected.length === 0) {
throw new Error('Nothing is selected in OpenPencil. Ask the user to select the layers to use.')
}
const ids = fields.flatMap((field) => namedIds(toolArgs[field]))
for (const id of ids) {
if (!selected.some((root) => store.graph.isDescendant(id, root))) {
throw new Error(`${id} is outside the selection OpenPencil shares; use get_selection`)
}
}
if (ids.length > 0 || fields.length === 0) return toolArgs
if (READS_SELECTION_BY_DEFAULT.has(toolName)) return { ...toolArgs, ids: selected }
throw new Error(`${toolName} needs ${fields.join(' or ')}: a node from get_selection`)
}

View file

@ -15,6 +15,7 @@ import {
readAgentSession,
touchedNodeIds
} from '@/app/automation/agents'
import { limitToSelection } from '@/app/automation/bridge/selection-scope'
import type { AutomationTarget } from '@/app/automation/bridge/target'
import {
AUTOMATION_UNDO_LABEL,
@ -58,8 +59,13 @@ export function createAutomationToolHandler(makeFigma: FigmaFactory) {
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 ?? {}
const requestedArgs = (args as { args?: Record<string, unknown> }).args ?? {}
if (!toolName) throw new Error('Missing "name" in args')
// An MCP server that shares only the selection marks its calls, and they stay inside it.
const toolArgs =
(args as { scope?: unknown }).scope === 'selection'
? limitToSelection(target.store, toolName, requestedArgs)
: requestedArgs
// A call from an MCP session shows as that session's agent, working where the call works.
const session = readAgentSession((args as { agent?: unknown }).agent)

View file

@ -42,7 +42,8 @@ export function createAutomationEnvironment(
OPENPENCIL_MCP_AUTH_TOKEN: configuration.authenticationEnabled ? (authToken ?? '') : '',
OPENPENCIL_MCP_CORS_ORIGIN: corsOrigin,
OPENPENCIL_MCP_ROOT: configuration.rootDirectory.trim() || process.cwd(),
OPENPENCIL_MCP_DISABLED_TOOLS: serializeDisabledTools(configuration.disabledTools)
OPENPENCIL_MCP_DISABLED_TOOLS: serializeDisabledTools(configuration.disabledTools),
OPENPENCIL_MCP_SCOPE: configuration.scope
}
if (socketPath) environment.OPENPENCIL_MCP_SOCKET = socketPath
if (discoveryPath) environment.OPENPENCIL_MCP_DISCOVERY_PATH = discoveryPath
@ -146,6 +147,7 @@ export function configurationsMatch(
return (
current.authenticationEnabled === next.authenticationEnabled &&
current.rootDirectory === next.rootDirectory &&
current.scope === next.scope &&
current.disabledTools.length === next.disabledTools.length &&
current.disabledTools.every((tool, index) => tool === next.disabledTools[index])
)
@ -161,7 +163,8 @@ export function automationPlugin(
let configuration: DevMCPConfiguration = {
authenticationEnabled: true,
rootDirectory: '',
disabledTools: []
disabledTools: [],
scope: 'document'
}
function enqueue(operation: () => Promise<void>): Promise<void> {

View file

@ -1,3 +1,5 @@
import { MCP_TOOL_SCOPES, type MCPToolScope } from '@open-pencil/mcp/tools'
export const DEV_MCP_RESTART_PATH = '/__openpencil/mcp/restart'
const MAX_ROOT_DIRECTORY_LENGTH = 4_096
@ -10,6 +12,11 @@ export interface DevMCPConfiguration {
authenticationEnabled: boolean
rootDirectory: string
disabledTools: string[]
scope: MCPToolScope
}
function parseScope(value: unknown): MCPToolScope | null {
return MCP_TOOL_SCOPES.find((scope) => scope === value) ?? null
}
function parseDisabledTools(value: unknown): string[] | null {
@ -35,13 +42,15 @@ export function parseDevMCPConfiguration(value: unknown): DevMCPConfiguration |
const authenticationEnabled = Reflect.get(value, 'authenticationEnabled')
const rootDirectory = Reflect.get(value, 'rootDirectory')
const disabledTools = parseDisabledTools(Reflect.get(value, 'disabledTools'))
const scope = parseScope(Reflect.get(value, 'scope'))
if (
typeof authenticationEnabled !== 'boolean' ||
typeof rootDirectory !== 'string' ||
rootDirectory.length > MAX_ROOT_DIRECTORY_LENGTH ||
!disabledTools
!disabledTools ||
!scope
) {
return null
}
return { authenticationEnabled, rootDirectory, disabledTools }
return { authenticationEnabled, rootDirectory, disabledTools, scope }
}

View file

@ -1,17 +1,24 @@
import { useLocalStorage } from '@vueuse/core'
import { ref } from 'vue'
import type { ToolDescriptor } from '@open-pencil/mcp/tools'
import type { MCPToolScope, ToolDescriptor } from '@open-pencil/mcp/tools'
const DISABLED_TOOLS_STORAGE_KEY = 'open-pencil:mcp:disabled-tools'
const ROOT_DIRECTORY_STORAGE_KEY = 'open-pencil:mcp:root-directory'
const AUTHENTICATION_ENABLED_STORAGE_KEY = 'open-pencil:mcp:authentication-enabled'
const SELECTION_ONLY_STORAGE_KEY = 'open-pencil:mcp:selection-only'
export const configurableMCPTools = ref<ToolDescriptor[]>([])
export const disabledMCPTools = useLocalStorage<string[]>(DISABLED_TOOLS_STORAGE_KEY, [])
export const mcpRootDirectory = useLocalStorage(ROOT_DIRECTORY_STORAGE_KEY, '')
export const mcpAuthenticationEnabled = useLocalStorage(AUTHENTICATION_ENABLED_STORAGE_KEY, true)
/** Whether MCP clients may read only the selected layers; see `MCPToolScope`. */
export const mcpSelectionOnly = useLocalStorage(SELECTION_ONLY_STORAGE_KEY, false)
export function mcpScope(): MCPToolScope {
return mcpSelectionOnly.value ? 'selection' : 'document'
}
export function setMCPToolDescriptors(tools: ToolDescriptor[]): void {
configurableMCPTools.value = tools.filter((tool) => tool.availability !== 'eval')

View file

@ -24,7 +24,12 @@ import {
MCPStartupError,
type MCPFailure
} from './failure'
import { disabledMCPTools, mcpAuthenticationEnabled, mcpRootDirectory } from './preferences'
import {
disabledMCPTools,
mcpAuthenticationEnabled,
mcpRootDirectory,
mcpScope
} from './preferences'
export interface AutomationHealth {
status: 'ok' | 'no_app'
@ -413,7 +418,8 @@ async function configureDevMCP(): Promise<AutomationServerHandle> {
const configuration: DevMCPConfiguration = {
authenticationEnabled: mcpAuthenticationEnabled.value,
rootDirectory: mcpRootDirectory.value,
disabledTools: [...disabledMCPTools.value]
disabledTools: [...disabledMCPTools.value],
scope: mcpScope()
}
const response = await fetch(DEV_MCP_RESTART_PATH, {
method: 'POST',
@ -472,7 +478,8 @@ async function startMCPIfNeeded(timing: MCPStartupTiming): Promise<AutomationSer
OPENPENCIL_MCP_TCP: '1',
OPENPENCIL_MCP_ROOT: mcpRoot,
OPENPENCIL_MCP_APP_TIMEOUT_MS: String(MCP_APP_ATTACH_TIMEOUT_MS),
OPENPENCIL_MCP_DISABLED_TOOLS: serializeDisabledTools(disabledMCPTools.value)
OPENPENCIL_MCP_DISABLED_TOOLS: serializeDisabledTools(disabledMCPTools.value),
OPENPENCIL_MCP_SCOPE: mcpScope()
}
})

View file

@ -3,7 +3,11 @@ import { computed } from 'vue'
import { useAutomationMessages, useCommonMessages, useSettingsMessages } from '@open-pencil/vue'
import { mcpAuthenticationEnabled, mcpRootDirectory } from '@/app/automation/mcp/preferences'
import {
mcpAuthenticationEnabled,
mcpRootDirectory,
mcpSelectionOnly
} from '@/app/automation/mcp/preferences'
import { mcpRuntime } from '@/app/automation/mcp/runtime'
import { useMCPSettings } from '@/app/automation/mcp/settings/use'
import { openToolAccessSettings } from '@/app/automation/tool-access/settings/use'
@ -63,6 +67,16 @@ const { restart, chooseRootDirectory } = useMCPSettings()
data-test-id="settings-mcp-authentication"
/>
</SettingsRow>
<SettingsRow
:label="automation.selectionScope"
:description="automation.selectionScopeDescription"
>
<AppSwitch
v-model="mcpSelectionOnly"
:label="automation.selectionScope"
data-test-id="settings-mcp-selection-scope"
/>
</SettingsRow>
<div class="flex flex-col gap-2 px-3 py-2.5">
<p class="text-xs font-medium text-surface">{{ automation.rootDirectory }}</p>
<p class="break-all font-mono text-xs text-surface">

View file

@ -0,0 +1,68 @@
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/)
})
})

View file

@ -10,7 +10,10 @@ import {
readDevMCPConfiguration,
waitForAutomationHealth
} from '@/app/automation/bridge/vite-plugin'
import { parseDevMCPConfiguration } from '@/app/automation/mcp/dev-control'
import {
parseDevMCPConfiguration,
type DevMCPConfiguration
} from '@/app/automation/mcp/dev-control'
import { fetchStub } from '#tests/helpers/fetch'
@ -22,7 +25,8 @@ describe('MCP Vite development server', () => {
configuration: {
authenticationEnabled: false,
rootDirectory: '/designs',
disabledTools: ['eval', 'delete_node']
disabledTools: ['eval', 'delete_node'],
scope: 'selection'
},
corsOrigin: 'http://localhost:1420',
discoveryPath: '/tmp/mcp.json',
@ -34,6 +38,7 @@ describe('MCP Vite development server', () => {
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', () => {
@ -41,18 +46,21 @@ describe('MCP Vite development server', () => {
parseDevMCPConfiguration({
authenticationEnabled: true,
rootDirectory: '/designs',
disabledTools: [' eval ', 'delete_node', 'eval']
disabledTools: [' eval ', 'delete_node', 'eval'],
scope: 'document'
})
).toEqual({
authenticationEnabled: true,
rootDirectory: '/designs',
disabledTools: ['eval', 'delete_node']
disabledTools: ['eval', 'delete_node'],
scope: 'document'
})
expect(
parseDevMCPConfiguration({
authenticationEnabled: true,
rootDirectory: '',
disabledTools: ['invalid tool']
disabledTools: ['invalid tool'],
scope: 'document'
})
).toBeNull()
})
@ -62,7 +70,8 @@ describe('MCP Vite development server', () => {
JSON.stringify({
authenticationEnabled: true,
rootDirectory: '/设计',
disabledTools: []
disabledTools: [],
scope: 'document'
})
)
const split = body.indexOf(Buffer.from('设')) + 1
@ -71,20 +80,23 @@ describe('MCP Vite development server', () => {
await expect(readDevMCPConfiguration(request as never)).resolves.toEqual({
authenticationEnabled: true,
rootDirectory: '/设计',
disabledTools: []
disabledTools: [],
scope: 'document'
})
})
test('does not restart MCP for unchanged configuration', () => {
const configuration = {
const configuration: DevMCPConfiguration = {
authenticationEnabled: true,
rootDirectory: '/designs',
disabledTools: ['eval', 'delete_node']
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 () => {

View file

@ -12,6 +12,7 @@ import { SceneGraph } from '@open-pencil/scene-graph'
import { startServer, type ServerHandle } from '#mcp/server'
import { createToolDescriptors, getMCPToolDefinitions } from '#mcp/tool/manifest'
import type { MCPToolScope } from '#mcp/tool/scope'
import { parseDiscoveryInfo } from '#mcp/transport/discovery'
import { expectDefined } from '#tests/helpers/assert'
@ -38,7 +39,7 @@ function testSocketPath(): string | null {
return join(SOCKET_DIR, `mcp-test-${process.pid}-${++testCounter}.sock`)
}
async function createTestClient(disabledTools: string[] = []) {
async function createTestClient(disabledTools: string[] = [], scope: MCPToolScope = 'document') {
if (isUnix) await mkdir(SOCKET_DIR, { recursive: true })
const authToken = TEST_CLIENT_AUTH_TOKEN
const handle = await startServer({
@ -47,6 +48,7 @@ async function createTestClient(disabledTools: string[] = []) {
socketPath: testSocketPath(),
authToken,
disabledTools,
scope,
enableEval: false,
mcpRoot: null
})
@ -84,6 +86,7 @@ async function createTestClient(disabledTools: string[] = []) {
const safeBrowser = browser
return {
client: safeClient,
browser: safeBrowser,
graph,
handle,
close: async () => {
@ -322,6 +325,72 @@ describe('MCP server', () => {
// mcpRoot tests
// ---------------------------------------------------------------------------
describe('MCP server sharing only the selection', () => {
let ctx: Awaited<ReturnType<typeof createTestClient>> | null = null
beforeEach(async () => {
ctx = await createTestClient([], 'selection')
})
afterEach(async () => {
await ctx?.close()
ctx = null
})
test('lists only the selection read tools', async () => {
const { tools } = await expectDefined(ctx, 'client').client.listTools()
expect(tools.map((tool) => tool.name).sort()).toEqual([
'describe',
'export_image',
'get_node',
'get_page_tree',
'get_selection'
])
})
test('marks every tool call with the scope for the app to enforce', async () => {
const { client, browser } = expectDefined(ctx, 'client')
await client.callTool({ name: 'get_selection', arguments: {} })
const call = browser.requests.find((request) => request.command === 'tool')
expect(call?.args).toMatchObject({ name: 'get_selection', scope: 'selection' })
})
test('refuses to write an export to a file', async () => {
const { client, browser } = expectDefined(ctx, 'client')
const result = await client.callTool({
name: 'export_image',
arguments: { path: 'selection.png' }
})
expect(result.isError).toBe(true)
expect(browser.requests.map((request) => request.command)).not.toContain('tool')
})
test('keeps raw RPC to the selection tools, so it cannot reach settings', async () => {
const { browser, handle } = expectDefined(ctx, 'client')
const rpc = (body: object) =>
fetch(`http://127.0.0.1:${handle.httpPort}/rpc`, {
method: 'POST',
headers: {
Authorization: `Bearer ${TEST_CLIENT_AUTH_TOKEN}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(body)
}).then((response) => response.json() as Promise<unknown>)
expect(await rpc({ command: 'update_settings', args: { mcp: {} } })).toMatchObject({
ok: false
})
expect(
await rpc({ command: 'tool', args: { name: 'delete_node', args: { id: '0:1' } } })
).toMatchObject({ ok: false })
expect(browser.requests.map((request) => request.command)).not.toContain('update_settings')
await rpc({ command: 'tool', args: { name: 'get_node', args: { id: '0:1' } } })
const call = browser.requests.find((request) => request.command === 'tool')
expect(call?.args).toMatchObject({ name: 'get_node', scope: 'selection' })
})
})
describe('MCP server with mcpRoot', () => {
async function withMCPRootServer(
mcpRoot: string | null,

View file

@ -224,3 +224,44 @@ describe('eval', () => {
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')
})
})

View file

@ -103,6 +103,7 @@ ja:automation.noConnections
ja:automation.restart
ja:automation.rootDirectory
ja:automation.rootDirectoryDescription
ja:automation.selectionScopeDescription
ja:automation.serverURL
ja:automation.serverURLHint
ja:automation.toolAccessDescription
@ -297,6 +298,7 @@ zh-cn:automation.noConnections
zh-cn:automation.restart
zh-cn:automation.rootDirectory
zh-cn:automation.rootDirectoryDescription
zh-cn:automation.selectionScopeDescription
zh-cn:automation.serverURL
zh-cn:automation.serverURLHint
zh-cn:automation.toolAccessDescription