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:
parent
85d8c69ccc
commit
68ffd72839
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)) }
|
||||
}
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 →
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
71
packages/mcp/src/tool/scope.ts
Normal file
71
packages/mcp/src/tool/scope.ts
Normal 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 } })
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
43
packages/mcp/tests/tool/scope.test.ts
Normal file
43
packages/mcp/tests/tool/scope.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": "フォルダーを選択",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": "Выбрать папку",
|
||||
|
|
|
|||
|
|
@ -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": "选择文件夹",
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
48
src/app/automation/bridge/selection-scope.ts
Normal file
48
src/app/automation/bridge/selection-scope.ts
Normal 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`)
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
68
tests/app/automation/bridge/selection-scope.test.ts
Normal file
68
tests/app/automation/bridge/selection-scope.test.ts
Normal 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/)
|
||||
})
|
||||
})
|
||||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue