diff --git a/CHANGELOG.md b/CHANGELOG.md index 035df2085..7c73f7dec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/packages/core/src/tools/read/selection.ts b/packages/core/src/tools/read/selection.ts index 8b580c594..d278c4334 100644 --- a/packages/core/src/tools/read/selection.ts +++ b/packages/core/src/tools/read/selection.ts @@ -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)) } } }) diff --git a/packages/docs/programmable/mcp-server.md b/packages/docs/programmable/mcp-server.md index ddbb0fb00..1c8e9fa83 100644 --- a/packages/docs/programmable/mcp-server.md +++ b/packages/docs/programmable/mcp-server.md @@ -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 | diff --git a/packages/mcp/package.json b/packages/mcp/package.json index 90df75c1d..3e4a0577e 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -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", diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts index 40a3324f0..cd265c05f 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -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 diff --git a/packages/mcp/src/server.ts b/packages/mcp/src/server.ts index 52432d3ee..53e0f9cf6 100644 --- a/packages/mcp/src/server.ts +++ b/packages/mcp/src/server.ts @@ -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 + /** 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 { 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 { 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 diff --git a/packages/mcp/src/stdio.ts b/packages/mcp/src/stdio.ts index 4acc03185..df01a7b3c 100644 --- a/packages/mcp/src/stdio.ts +++ b/packages/mcp/src/stdio.ts @@ -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 → diff --git a/packages/mcp/src/tool/index.ts b/packages/mcp/src/tool/index.ts index cc122df59..2a5d86740 100644 --- a/packages/mcp/src/tool/index.ts +++ b/packages/mcp/src/tool/index.ts @@ -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' diff --git a/packages/mcp/src/tool/metadata.ts b/packages/mcp/src/tool/metadata.ts index e8a19d9d8..4d62f629e 100644 --- a/packages/mcp/src/tool/metadata.ts +++ b/packages/mcp/src/tool/metadata.ts @@ -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 export interface ToolPolicy { allowEval: boolean disabledTools: string[] + /** What clients can reach; see `MCPToolScope`. */ + scope: MCPToolScope } export function parseToolDescriptor(value: unknown): ToolDescriptor | null { diff --git a/packages/mcp/src/tool/policy.ts b/packages/mcp/src/tool/policy.ts index 60368ad01..24861fc47 100644 --- a/packages/mcp/src/tool/policy.ts +++ b/packages/mcp/src/tool/policy.ts @@ -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) } } diff --git a/packages/mcp/src/tool/registration.ts b/packages/mcp/src/tool/registration.ts index 3cb1c8730..3687117f4 100644 --- a/packages/mcp/src/tool/registration.ts +++ b/packages/mcp/src/tool/registration.ts @@ -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) => Promise @@ -124,9 +125,19 @@ export function registerTools(mcpServer: McpServer, options: RegisterToolsOption async (args: Record) => { 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)) diff --git a/packages/mcp/src/tool/scope.ts b/packages/mcp/src/tool/scope.ts new file mode 100644 index 000000000..45cfd0339 --- /dev/null +++ b/packages/mcp/src/tool/scope.ts @@ -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> = { + 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 = new Set(['agent_session_closed']) + +type RPCSend = (msg: RPCJSONObject) => Promise + +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 } }) + } +} diff --git a/packages/mcp/src/transport/discovery.ts b/packages/mcp/src/transport/discovery.ts index 068fc8e7c..101b83412 100644 --- a/packages/mcp/src/transport/discovery.ts +++ b/packages/mcp/src/transport/discovery.ts @@ -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 diff --git a/packages/mcp/tests/tool/scope.test.ts b/packages/mcp/tests/tool/scope.test.ts new file mode 100644 index 000000000..e0e4da839 --- /dev/null +++ b/packages/mcp/tests/tool/scope.test.ts @@ -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') + }) +}) diff --git a/packages/mcp/tests/transport/discovery.test.ts b/packages/mcp/tests/transport/discovery.test.ts index a7b344af6..0fd3aa214 100644 --- a/packages/mcp/tests/transport/discovery.test.ts +++ b/packages/mcp/tests/transport/discovery.test.ts @@ -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([ diff --git a/packages/vue/src/i18n/locales/de/automation.json b/packages/vue/src/i18n/locales/de/automation.json index 8874f6510..cb37c66a2 100644 --- a/packages/vue/src/i18n/locales/de/automation.json +++ b/packages/vue/src/i18n/locales/de/automation.json @@ -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", diff --git a/packages/vue/src/i18n/locales/es/automation.json b/packages/vue/src/i18n/locales/es/automation.json index e092645e8..2c418d864 100644 --- a/packages/vue/src/i18n/locales/es/automation.json +++ b/packages/vue/src/i18n/locales/es/automation.json @@ -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", diff --git a/packages/vue/src/i18n/locales/fr/automation.json b/packages/vue/src/i18n/locales/fr/automation.json index 35a5294ec..c798398bb 100644 --- a/packages/vue/src/i18n/locales/fr/automation.json +++ b/packages/vue/src/i18n/locales/fr/automation.json @@ -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", diff --git a/packages/vue/src/i18n/locales/it/automation.json b/packages/vue/src/i18n/locales/it/automation.json index 49fc215f3..041dd9985 100644 --- a/packages/vue/src/i18n/locales/it/automation.json +++ b/packages/vue/src/i18n/locales/it/automation.json @@ -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", diff --git a/packages/vue/src/i18n/locales/ja/automation.json b/packages/vue/src/i18n/locales/ja/automation.json index 74e2aac68..e5f2b5f97 100644 --- a/packages/vue/src/i18n/locales/ja/automation.json +++ b/packages/vue/src/i18n/locales/ja/automation.json @@ -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": "フォルダーを選択", diff --git a/packages/vue/src/i18n/locales/pl/automation.json b/packages/vue/src/i18n/locales/pl/automation.json index 26f847da6..6f1d39884 100644 --- a/packages/vue/src/i18n/locales/pl/automation.json +++ b/packages/vue/src/i18n/locales/pl/automation.json @@ -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", diff --git a/packages/vue/src/i18n/locales/ru/automation.json b/packages/vue/src/i18n/locales/ru/automation.json index 74eb5caa9..84049e9a5 100644 --- a/packages/vue/src/i18n/locales/ru/automation.json +++ b/packages/vue/src/i18n/locales/ru/automation.json @@ -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": "Выбрать папку", diff --git a/packages/vue/src/i18n/locales/zh-cn/automation.json b/packages/vue/src/i18n/locales/zh-cn/automation.json index 62c5f4303..0cfd1ad2e 100644 --- a/packages/vue/src/i18n/locales/zh-cn/automation.json +++ b/packages/vue/src/i18n/locales/zh-cn/automation.json @@ -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": "选择文件夹", diff --git a/packages/vue/src/i18n/messages/automation.ts b/packages/vue/src/i18n/messages/automation.ts index 34417a34d..fea1bcd2f 100644 --- a/packages/vue/src/i18n/messages/automation.ts +++ b/packages/vue/src/i18n/messages/automation.ts @@ -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', diff --git a/src/app/automation/bridge/selection-scope.ts b/src/app/automation/bridge/selection-scope.ts new file mode 100644 index 000000000..4aa2727c1 --- /dev/null +++ b/src/app/automation/bridge/selection-scope.ts @@ -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 = 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 +): Record { + 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`) +} diff --git a/src/app/automation/bridge/tool-handlers.ts b/src/app/automation/bridge/tool-handlers.ts index 6bb907c40..f8766071e 100644 --- a/src/app/automation/bridge/tool-handlers.ts +++ b/src/app/automation/bridge/tool-handlers.ts @@ -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 { const toolName = (args as { name?: string }).name - const toolArgs = (args as { args?: Record }).args ?? {} + const requestedArgs = (args as { args?: Record }).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) diff --git a/src/app/automation/bridge/vite-plugin.ts b/src/app/automation/bridge/vite-plugin.ts index 1689ef17e..8319aa066 100644 --- a/src/app/automation/bridge/vite-plugin.ts +++ b/src/app/automation/bridge/vite-plugin.ts @@ -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): Promise { diff --git a/src/app/automation/mcp/dev-control.ts b/src/app/automation/mcp/dev-control.ts index 4d5997ace..b9755405d 100644 --- a/src/app/automation/mcp/dev-control.ts +++ b/src/app/automation/mcp/dev-control.ts @@ -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 } } diff --git a/src/app/automation/mcp/preferences.ts b/src/app/automation/mcp/preferences.ts index d6c4e48fc..84126e214 100644 --- a/src/app/automation/mcp/preferences.ts +++ b/src/app/automation/mcp/preferences.ts @@ -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([]) export const disabledMCPTools = useLocalStorage(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') diff --git a/src/app/automation/mcp/spawn.ts b/src/app/automation/mcp/spawn.ts index 69398bb55..17c10f1f3 100644 --- a/src/app/automation/mcp/spawn.ts +++ b/src/app/automation/mcp/spawn.ts @@ -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 { 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 + + +

{{ automation.rootDirectory }}

diff --git a/tests/app/automation/bridge/selection-scope.test.ts b/tests/app/automation/bridge/selection-scope.test.ts new file mode 100644 index 000000000..9bcc2805d --- /dev/null +++ b/tests/app/automation/bridge/selection-scope.test.ts @@ -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/) + }) +}) diff --git a/tests/engine/app/automation/mcp-vite-plugin.test.ts b/tests/engine/app/automation/mcp-vite-plugin.test.ts index 7a0798c60..2d6f16f55 100644 --- a/tests/engine/app/automation/mcp-vite-plugin.test.ts +++ b/tests/engine/app/automation/mcp-vite-plugin.test.ts @@ -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 () => { diff --git a/tests/engine/mcp/server/index.test.ts b/tests/engine/mcp/server/index.test.ts index 1b4b6fe51..dfc7c5d31 100644 --- a/tests/engine/mcp/server/index.test.ts +++ b/tests/engine/mcp/server/index.test.ts @@ -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> | 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) + + 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, diff --git a/tests/engine/tools/read.test.ts b/tests/engine/tools/read.test.ts index b65af07c8..3bc38f072 100644 --- a/tests/engine/tools/read.test.ts +++ b/tests/engine/tools/read.test.ts @@ -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') + }) +}) diff --git a/tools/checks/i18n/mixed-script-baseline.txt b/tools/checks/i18n/mixed-script-baseline.txt index acff2350f..7ffc1b88f 100644 --- a/tools/checks/i18n/mixed-script-baseline.txt +++ b/tools/checks/i18n/mixed-script-baseline.txt @@ -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