fix(mcp): align runtime metadata and restart errors

- Publish explicit effective tool state while retaining disabled tools for Settings

- Classify filesystem writes as side effects and localize category labels

- Stop failed restarts and return precise development control status codes
This commit is contained in:
Danila Poyarkov 2026-08-20 16:27:11 +03:00
parent 7955207146
commit fad731b3e7
21 changed files with 145 additions and 46 deletions

View file

@ -10,7 +10,7 @@
- Run Pi through AI SDK HarnessAgent as a configurable desktop provider with multiple saved model profiles, secure credentials, existing MCP design tools, and per-profile thinking and permission settings.
- Open multiple selected design files in separate tabs.
- Let Figma API scripts and automation combine components into variant sets.
- Monitor and restart the local MCP server, configure its root directory or authentication, and choose individual, read-only, or document-writing tools it exposes from Settings.
- Monitor and restart the local MCP server, configure its root directory or authentication, and choose individual, read-only, or side-effecting tools it exposes from Settings.
- Add deterministic two-browser collaboration coverage for bidirectional edits, awareness, departure cleanup, partitioned-peer convergence, and reconnect synchronization without public network dependencies. (#530)
- Import, render, edit, resize, select, and export Figma text-on-path layers while preserving their curved glyph layout.
- Show Figma-style temporary distance measurements between selected and Option/Alt-hovered layers. (#491)

View file

@ -16,6 +16,7 @@ import { preprocessRPC } from '#mcp/jsx-preprocess'
import { createMCPSessionManager } from '#mcp/server/sessions'
import { createToolDescriptors } from '#mcp/tool/manifest'
import type { ToolDescriptor, ToolPolicy } from '#mcp/tool/metadata'
import { applyToolPolicy } from '#mcp/tool/policy'
import { registerTools } from '#mcp/tool/registration'
import packageJSON from '../package.json' with { type: 'json' }
@ -317,7 +318,7 @@ function buildServerContext(options: ServerOptions) {
onConnectionChange: mcpSessions.notifyToolsChanged
})
const sendToBrowser = browserRPC.sendRPC
const toolDescriptors = createToolDescriptors(mcpRoot !== null)
const toolDescriptors = applyToolPolicy(createToolDescriptors(mcpRoot !== null), toolPolicy)
const app = createHonoApp({
authToken,

View file

@ -36,7 +36,8 @@ function coreToolDescriptor(def: ToolDef): ToolDescriptor {
description: def.description,
effect: coreToolEffect(def),
availability: coreToolAvailability(def),
capabilities: coreToolCapabilities(def)
capabilities: coreToolCapabilities(def),
enabled: true
}
}
@ -49,15 +50,17 @@ export function createToolDescriptors(filesystemEnabled: boolean): ToolDescripto
'List open OpenPencil documents/tabs with their IDs, file paths, current pages, and pages.',
effect: 'read',
availability: 'default',
capabilities: ['document:read']
capabilities: ['document:read'],
enabled: true
},
{
name: 'save_file',
description:
'Save the current document to disk. An optional path must stay inside the configured MCP root.',
effect: 'read',
effect: 'write',
availability: 'default',
capabilities: ['document:read', 'filesystem:write']
capabilities: ['document:read', 'filesystem:write'],
enabled: true
},
...(filesystemEnabled
? [
@ -66,7 +69,8 @@ export function createToolDescriptors(filesystemEnabled: boolean): ToolDescripto
description: 'Open a .fig or .pen file from inside the configured MCP root.',
effect: 'write',
availability: 'filesystem',
capabilities: ['filesystem:read', 'document:write']
capabilities: ['filesystem:read', 'document:write'],
enabled: true
} satisfies ToolDescriptor,
{
name: 'new_document',
@ -74,7 +78,8 @@ export function createToolDescriptors(filesystemEnabled: boolean): ToolDescripto
'Create a new empty document with an optional save path inside the configured MCP root.',
effect: 'write',
availability: 'filesystem',
capabilities: ['document:write', 'filesystem:write']
capabilities: ['document:write', 'filesystem:write'],
enabled: true
} satisfies ToolDescriptor
]
: []),
@ -84,7 +89,8 @@ export function createToolDescriptors(filesystemEnabled: boolean): ToolDescripto
'Get design-to-code generation guidelines. Call before generating frontend code.',
effect: 'read',
availability: 'default',
capabilities: []
capabilities: [],
enabled: true
}
)
return descriptors

View file

@ -14,6 +14,7 @@ export interface ToolDescriptor {
effect: ToolEffect
availability: ToolAvailability
capabilities: ToolCapability[]
enabled: boolean
}
export interface ToolPolicy {
@ -42,11 +43,12 @@ const TOOL_CAPABILITIES: ReadonlySet<string> = new Set<ToolCapability>([
export function parseToolDescriptor(value: unknown): ToolDescriptor | null {
if (!isRecord(value)) return null
const { name, description, effect, availability, capabilities } = value
const { name, description, effect, availability, capabilities, enabled } = value
if (typeof name !== 'string' || !name) return null
if (typeof description !== 'string') return null
if (typeof effect !== 'string' || !TOOL_EFFECTS.has(effect)) return null
if (typeof availability !== 'string' || !TOOL_AVAILABILITIES.has(availability)) return null
if (typeof enabled !== 'boolean') return null
if (
!Array.isArray(capabilities) ||
capabilities.some(
@ -60,6 +62,7 @@ export function parseToolDescriptor(value: unknown): ToolDescriptor | null {
description,
effect: effect as ToolEffect,
availability: availability as ToolAvailability,
capabilities: capabilities as ToolCapability[]
capabilities: capabilities as ToolCapability[],
enabled
}
}

View file

@ -1,4 +1,4 @@
import type { ToolPolicy } from '#mcp/tool/metadata'
import type { ToolDescriptor, ToolPolicy } from '#mcp/tool/metadata'
export function parseDisabledTools(value: string | undefined): string[] {
if (!value) return []
@ -12,6 +12,21 @@ export function parseDisabledTools(value: string | undefined): string[] {
]
}
export function isToolEnabled(descriptor: ToolDescriptor, policy: ToolPolicy): boolean {
if (policy.disabledTools.includes(descriptor.name)) return false
return descriptor.availability !== 'eval' || policy.allowEval
}
export function applyToolPolicy(
descriptors: readonly ToolDescriptor[],
policy: ToolPolicy
): ToolDescriptor[] {
return descriptors.map((descriptor) => ({
...descriptor,
enabled: isToolEnabled(descriptor, policy)
}))
}
export function readToolPolicyFromEnv(env: NodeJS.ProcessEnv = process.env): ToolPolicy {
return {
allowEval: env.OPENPENCIL_MCP_EVAL === '1',

View file

@ -12,6 +12,7 @@ import { MAX_RESULT_BYTES, fail, ok, resultTooLargeMessage } from '#mcp/result'
import { createToolDescriptors } 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 { paramToZod } from '#mcp/tool/schema'
export type RPCSender = (body: Record<string, unknown>) => Promise<unknown>
@ -54,7 +55,6 @@ function descriptorByName(descriptors: readonly ToolDescriptor[]): Map<string, T
export function registerTools(mcpServer: McpServer, options: RegisterToolsOptions): void {
const { policy, sendRPC } = options
const disabledTools = new Set(policy.disabledTools)
const resolvedRoot = options.mcpRoot ? resolve(options.mcpRoot) : null
const descriptors = descriptorByName(createToolDescriptors(resolvedRoot !== null))
const register = <InputArgs extends z.ZodObject>(
@ -64,8 +64,7 @@ export function registerTools(mcpServer: McpServer, options: RegisterToolsOption
) => {
const descriptor = descriptors.get(name)
if (!descriptor) throw new Error(`Missing MCP tool descriptor for "${name}"`)
if (disabledTools.has(name)) return
if (descriptor.availability === 'eval' && !policy.allowEval) return
if (!isToolEnabled(descriptor, policy)) return
mcpServer.registerTool(
name,
{

View file

@ -323,8 +323,8 @@
"mcpUseDefaultRoot": "Use default",
"mcpRootDirectoryDescription": "File tools are limited to this folder. Restart the MCP server to apply changes.",
"mcpTools": "Available tools",
"mcpReadOnlyTools": "Read-only tools",
"mcpDocumentWritingTools": "Document-writing tools",
"mcpReadOnlyTools": "Nur-Lese-Werkzeuge",
"mcpSideEffectTools": "Werkzeuge mit Nebenwirkungen",
"mcpToolsEnabled": "{enabled} of {total} enabled",
"mcpEnableAllTools": "Enable all",
"mcpSearchTools": "Search MCP tools",

View file

@ -323,8 +323,8 @@
"mcpUseDefaultRoot": "Use default",
"mcpRootDirectoryDescription": "File tools are limited to this folder. Restart the MCP server to apply changes.",
"mcpTools": "Available tools",
"mcpReadOnlyTools": "Read-only tools",
"mcpDocumentWritingTools": "Document-writing tools",
"mcpReadOnlyTools": "Herramientas de solo lectura",
"mcpSideEffectTools": "Herramientas con efectos secundarios",
"mcpToolsEnabled": "{enabled} of {total} enabled",
"mcpEnableAllTools": "Enable all",
"mcpSearchTools": "Search MCP tools",

View file

@ -323,8 +323,8 @@
"mcpUseDefaultRoot": "Use default",
"mcpRootDirectoryDescription": "File tools are limited to this folder. Restart the MCP server to apply changes.",
"mcpTools": "Available tools",
"mcpReadOnlyTools": "Read-only tools",
"mcpDocumentWritingTools": "Document-writing tools",
"mcpReadOnlyTools": "Outils en lecture seule",
"mcpSideEffectTools": "Outils avec effets secondaires",
"mcpToolsEnabled": "{enabled} of {total} enabled",
"mcpEnableAllTools": "Enable all",
"mcpSearchTools": "Search MCP tools",

View file

@ -323,8 +323,8 @@
"mcpUseDefaultRoot": "Use default",
"mcpRootDirectoryDescription": "File tools are limited to this folder. Restart the MCP server to apply changes.",
"mcpTools": "Available tools",
"mcpReadOnlyTools": "Read-only tools",
"mcpDocumentWritingTools": "Document-writing tools",
"mcpReadOnlyTools": "Strumenti di sola lettura",
"mcpSideEffectTools": "Strumenti con effetti collaterali",
"mcpToolsEnabled": "{enabled} of {total} enabled",
"mcpEnableAllTools": "Enable all",
"mcpSearchTools": "Search MCP tools",

View file

@ -323,8 +323,8 @@
"mcpUseDefaultRoot": "Use default",
"mcpRootDirectoryDescription": "File tools are limited to this folder. Restart the MCP server to apply changes.",
"mcpTools": "Available tools",
"mcpReadOnlyTools": "Read-only tools",
"mcpDocumentWritingTools": "Document-writing tools",
"mcpReadOnlyTools": "読み取り専用ツール",
"mcpSideEffectTools": "副作用のあるツール",
"mcpToolsEnabled": "{enabled} of {total} enabled",
"mcpEnableAllTools": "Enable all",
"mcpSearchTools": "Search MCP tools",

View file

@ -323,8 +323,8 @@
"mcpUseDefaultRoot": "Use default",
"mcpRootDirectoryDescription": "File tools are limited to this folder. Restart the MCP server to apply changes.",
"mcpTools": "Available tools",
"mcpReadOnlyTools": "Read-only tools",
"mcpDocumentWritingTools": "Document-writing tools",
"mcpReadOnlyTools": "Narzędzia tylko do odczytu",
"mcpSideEffectTools": "Narzędzia z efektami ubocznymi",
"mcpToolsEnabled": "{enabled} of {total} enabled",
"mcpEnableAllTools": "Enable all",
"mcpSearchTools": "Search MCP tools",

View file

@ -323,8 +323,8 @@
"mcpUseDefaultRoot": "Use default",
"mcpRootDirectoryDescription": "File tools are limited to this folder. Restart the MCP server to apply changes.",
"mcpTools": "Available tools",
"mcpReadOnlyTools": "Read-only tools",
"mcpDocumentWritingTools": "Document-writing tools",
"mcpReadOnlyTools": "Инструменты только для чтения",
"mcpSideEffectTools": "Инструменты с побочными эффектами",
"mcpToolsEnabled": "{enabled} of {total} enabled",
"mcpEnableAllTools": "Enable all",
"mcpSearchTools": "Search MCP tools",

View file

@ -323,8 +323,8 @@
"mcpUseDefaultRoot": "Use default",
"mcpRootDirectoryDescription": "File tools are limited to this folder. Restart the MCP server to apply changes.",
"mcpTools": "Available tools",
"mcpReadOnlyTools": "Read-only tools",
"mcpDocumentWritingTools": "Document-writing tools",
"mcpReadOnlyTools": "只读工具",
"mcpSideEffectTools": "有副作用的工具",
"mcpToolsEnabled": "{enabled} of {total} enabled",
"mcpEnableAllTools": "Enable all",
"mcpSearchTools": "Search MCP tools",

View file

@ -267,7 +267,7 @@ export const dialogMessageDefaults = {
mcpEnableAllTools: 'Enable all',
mcpSearchTools: 'Search MCP tools',
mcpReadOnlyTools: 'Read-only tools',
mcpDocumentWritingTools: 'Document-writing tools',
mcpSideEffectTools: 'Tools with side effects',
mcpToolsRestartNotice:
'Restart the MCP server, then reconnect stdio clients, to apply tool availability changes.',
mcpExternalRestartNotice:

View file

@ -42,16 +42,51 @@ export function createAutomationEnvironment(
const MAX_CONFIGURATION_BYTES = 70_000
const CHILD_EXIT_TIMEOUT_MS = 2_000
type DevMCPConfigurationErrorStatus = 400 | 413
class DevMCPConfigurationRequestError extends Error {
constructor(
message: string,
readonly statusCode: DevMCPConfigurationErrorStatus,
options?: ErrorOptions
) {
super(message, options)
this.name = 'DevMCPConfigurationRequestError'
}
}
export class DevMCPConfigurationTooLargeError extends DevMCPConfigurationRequestError {
constructor() {
super('Request body is too large', 413)
this.name = 'DevMCPConfigurationTooLargeError'
}
}
export class DevMCPConfigurationSyntaxError extends DevMCPConfigurationRequestError {
constructor(cause: unknown) {
super('Malformed JSON configuration', 400, { cause })
this.name = 'DevMCPConfigurationSyntaxError'
}
}
export function devMCPConfigurationErrorStatus(error: unknown): 400 | 413 | 500 {
return error instanceof DevMCPConfigurationRequestError ? error.statusCode : 500
}
export async function readDevMCPConfiguration(request: IncomingMessage): Promise<unknown> {
const chunks: Buffer[] = []
let byteLength = 0
for await (const chunk of request) {
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
byteLength += buffer.byteLength
if (byteLength > MAX_CONFIGURATION_BYTES) throw new Error('Request body is too large')
if (byteLength > MAX_CONFIGURATION_BYTES) throw new DevMCPConfigurationTooLargeError()
chunks.push(buffer)
}
return JSON.parse(Buffer.concat(chunks).toString('utf8'))
try {
return JSON.parse(Buffer.concat(chunks).toString('utf8'))
} catch (error) {
throw new DevMCPConfigurationSyntaxError(error)
}
}
// TODO: production — bundle MCP server as Tauri sidecar or spawn via shell plugin
@ -159,7 +194,7 @@ export function automationPlugin(authToken: string | null, corsOrigin: string):
response.statusCode = 204
response.end()
} catch (error) {
response.statusCode = 500
response.statusCode = devMCPConfigurationErrorStatus(error)
response.end(error instanceof Error ? error.message : String(error))
}
})()

View file

@ -152,7 +152,8 @@ export function createMCPRuntimeService(dependencies: MCPRuntimeDependencies) {
stop: () => enqueue(() => stopOperation(true)),
restart: () =>
enqueue(async () => {
await stopOperation(false)
const stopResult = await stopOperation(false)
if (!stopResult.ok) return stopResult
if (!activeStore) {
const error = new Error('Editor is not ready')
state.status = 'error'

View file

@ -201,11 +201,11 @@ function enableAllTools(): void {
/>
</div>
<div class="flex items-center justify-between gap-2 rounded bg-input px-2.5 py-2">
<span class="text-[10px] text-surface">{{ dialogs.mcpDocumentWritingTools }}</span>
<span class="text-[10px] text-surface">{{ dialogs.mcpSideEffectTools }}</span>
<AppSwitch
:model-value="modificationToolsStatus.enabled"
:state="modificationToolsStatus.state"
:label="dialogs.mcpDocumentWritingTools"
:label="dialogs.mcpSideEffectTools"
data-test-id="settings-mcp-modification-tools"
@update:model-value="setMCPToolCategoryEnabled('write', $event)"
/>

View file

@ -10,7 +10,8 @@ function descriptor(name = 'get_page_tree'): ToolDescriptor {
description: name,
effect: 'read',
availability: 'default',
capabilities: ['document:read']
capabilities: ['document:read'],
enabled: true
}
}
@ -104,6 +105,29 @@ describe('MCP runtime service', () => {
expect(catalogs.at(-1)).toEqual([])
})
test('does not start a replacement when shutdown fails', async () => {
let spawnCalls = 0
const { service } = setup({
spawn: async () => {
spawnCalls++
return {
authToken: 'token',
managed: true,
disconnect: () => {
throw new Error('shutdown failed')
}
}
}
})
await service.start(getStore)
const result = await service.restart()
expect(result.ok).toBe(false)
expect(spawnCalls).toBe(1)
expect(service.state.status).toBe('error')
})
test('restarts with the retained editor store inside one lifecycle operation', async () => {
const { calls, service } = setup()
await service.start(getStore)

View file

@ -2,7 +2,10 @@ import { describe, expect, test } from 'bun:test'
import { Readable } from 'node:stream'
import {
DevMCPConfigurationSyntaxError,
DevMCPConfigurationTooLargeError,
createAutomationEnvironment,
devMCPConfigurationErrorStatus,
readDevMCPConfiguration
} from '@/app/automation/bridge/vite-plugin'
@ -43,10 +46,20 @@ describe('MCP Vite development server', () => {
})
})
test('rejects configuration bodies above the byte limit', async () => {
const request = Readable.from([Buffer.alloc(70_001)])
await expect(readDevMCPConfiguration(request as never)).rejects.toThrow(
'Request body is too large'
test('classifies malformed and oversized configuration requests', async () => {
const malformed = Readable.from(['{'])
const malformedError = await readDevMCPConfiguration(malformed as never).catch(
(error: unknown) => error
)
expect(malformedError).toBeInstanceOf(DevMCPConfigurationSyntaxError)
expect(devMCPConfigurationErrorStatus(malformedError)).toBe(400)
const oversized = Readable.from([Buffer.alloc(70_001)])
const oversizedError = await readDevMCPConfiguration(oversized as never).catch(
(error: unknown) => error
)
expect(oversizedError).toBeInstanceOf(DevMCPConfigurationTooLargeError)
expect(devMCPConfigurationErrorStatus(oversizedError)).toBe(413)
expect(devMCPConfigurationErrorStatus(new Error('restart failed'))).toBe(500)
})
})

View file

@ -151,6 +151,7 @@ describe('MCP server', () => {
expect(byName.get('switch_page')?.effect).toBe('read')
expect(byName.get('viewport_set')?.effect).toBe('read')
expect(byName.get('export_image')?.effect).toBe('read')
expect(byName.get('save_file')?.effect).toBe('write')
expect(byName.get('update_node')?.effect).toBe('write')
expect(byName.get('new_document')?.capabilities).toEqual(['document:write', 'filesystem:write'])
expect(byName.get('eval')?.availability).toBe('eval')
@ -176,9 +177,10 @@ describe('MCP server', () => {
headers: { Authorization: `Bearer ${TEST_CLIENT_AUTH_TOKEN}` }
})
const health = (await healthResponse.json()) as HealthResponse
const catalogNames = (health.tools ?? []).map((tool) => tool.name)
expect(catalogNames).toContain('create_shape')
expect(catalogNames).toContain('list_documents')
const descriptors = health.tools ?? []
expect(descriptors.find((tool) => tool.name === 'create_shape')?.enabled).toBe(false)
expect(descriptors.find((tool) => tool.name === 'list_documents')?.enabled).toBe(false)
expect(descriptors.find((tool) => tool.name === 'get_page_tree')?.enabled).toBe(true)
})
test('tools expose standard MCP effect annotations', async () => {