diff --git a/CHANGELOG.md b/CHANGELOG.md index 8fd30643e..cef95e534 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/packages/mcp/src/server.ts b/packages/mcp/src/server.ts index 46bf49e28..8f51a0015 100644 --- a/packages/mcp/src/server.ts +++ b/packages/mcp/src/server.ts @@ -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, diff --git a/packages/mcp/src/tool/manifest.ts b/packages/mcp/src/tool/manifest.ts index 42324e76f..fb7961df7 100644 --- a/packages/mcp/src/tool/manifest.ts +++ b/packages/mcp/src/tool/manifest.ts @@ -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 diff --git a/packages/mcp/src/tool/metadata.ts b/packages/mcp/src/tool/metadata.ts index 38cf5d686..cbd78fde5 100644 --- a/packages/mcp/src/tool/metadata.ts +++ b/packages/mcp/src/tool/metadata.ts @@ -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 = new Set([ 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 } } diff --git a/packages/mcp/src/tool/policy.ts b/packages/mcp/src/tool/policy.ts index 41df5d5ff..0c198a0e6 100644 --- a/packages/mcp/src/tool/policy.ts +++ b/packages/mcp/src/tool/policy.ts @@ -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', diff --git a/packages/mcp/src/tool/registration.ts b/packages/mcp/src/tool/registration.ts index 3111bfd3d..dc315f62f 100644 --- a/packages/mcp/src/tool/registration.ts +++ b/packages/mcp/src/tool/registration.ts @@ -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) => Promise @@ -54,7 +55,6 @@ function descriptorByName(descriptors: readonly ToolDescriptor[]): Map( @@ -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, { diff --git a/packages/vue/src/i18n/locales/de/dialogs.json b/packages/vue/src/i18n/locales/de/dialogs.json index 133b29556..b466050ed 100644 --- a/packages/vue/src/i18n/locales/de/dialogs.json +++ b/packages/vue/src/i18n/locales/de/dialogs.json @@ -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", diff --git a/packages/vue/src/i18n/locales/es/dialogs.json b/packages/vue/src/i18n/locales/es/dialogs.json index 01fda0b2e..59d5c763c 100644 --- a/packages/vue/src/i18n/locales/es/dialogs.json +++ b/packages/vue/src/i18n/locales/es/dialogs.json @@ -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", diff --git a/packages/vue/src/i18n/locales/fr/dialogs.json b/packages/vue/src/i18n/locales/fr/dialogs.json index 12f9fb4e7..98459402f 100644 --- a/packages/vue/src/i18n/locales/fr/dialogs.json +++ b/packages/vue/src/i18n/locales/fr/dialogs.json @@ -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", diff --git a/packages/vue/src/i18n/locales/it/dialogs.json b/packages/vue/src/i18n/locales/it/dialogs.json index 889ef3355..edc64bc7b 100644 --- a/packages/vue/src/i18n/locales/it/dialogs.json +++ b/packages/vue/src/i18n/locales/it/dialogs.json @@ -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", diff --git a/packages/vue/src/i18n/locales/ja/dialogs.json b/packages/vue/src/i18n/locales/ja/dialogs.json index 3f4008d13..1e5469680 100644 --- a/packages/vue/src/i18n/locales/ja/dialogs.json +++ b/packages/vue/src/i18n/locales/ja/dialogs.json @@ -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", diff --git a/packages/vue/src/i18n/locales/pl/dialogs.json b/packages/vue/src/i18n/locales/pl/dialogs.json index bb0e51dda..5e17611c0 100644 --- a/packages/vue/src/i18n/locales/pl/dialogs.json +++ b/packages/vue/src/i18n/locales/pl/dialogs.json @@ -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", diff --git a/packages/vue/src/i18n/locales/ru/dialogs.json b/packages/vue/src/i18n/locales/ru/dialogs.json index f2b02b549..522407276 100644 --- a/packages/vue/src/i18n/locales/ru/dialogs.json +++ b/packages/vue/src/i18n/locales/ru/dialogs.json @@ -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", diff --git a/packages/vue/src/i18n/locales/zh-cn/dialogs.json b/packages/vue/src/i18n/locales/zh-cn/dialogs.json index c789e690d..a6c15ecf3 100644 --- a/packages/vue/src/i18n/locales/zh-cn/dialogs.json +++ b/packages/vue/src/i18n/locales/zh-cn/dialogs.json @@ -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", diff --git a/packages/vue/src/i18n/messages/dialogs.ts b/packages/vue/src/i18n/messages/dialogs.ts index 272a1a13e..98da77012 100644 --- a/packages/vue/src/i18n/messages/dialogs.ts +++ b/packages/vue/src/i18n/messages/dialogs.ts @@ -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: diff --git a/src/app/automation/bridge/vite-plugin.ts b/src/app/automation/bridge/vite-plugin.ts index fa6989b46..5dc35743d 100644 --- a/src/app/automation/bridge/vite-plugin.ts +++ b/src/app/automation/bridge/vite-plugin.ts @@ -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 { 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)) } })() diff --git a/src/app/automation/mcp/runtime.ts b/src/app/automation/mcp/runtime.ts index 13ce60376..3fc3178a3 100644 --- a/src/app/automation/mcp/runtime.ts +++ b/src/app/automation/mcp/runtime.ts @@ -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' diff --git a/src/components/settings/mcp/MCPSettingsPanel.vue b/src/components/settings/mcp/MCPSettingsPanel.vue index 209603edc..7eddc973f 100644 --- a/src/components/settings/mcp/MCPSettingsPanel.vue +++ b/src/components/settings/mcp/MCPSettingsPanel.vue @@ -201,11 +201,11 @@ function enableAllTools(): void { />
- {{ dialogs.mcpDocumentWritingTools }} + {{ dialogs.mcpSideEffectTools }} diff --git a/tests/engine/app/automation/mcp-runtime.test.ts b/tests/engine/app/automation/mcp-runtime.test.ts index 1a0c85936..dfa39110a 100644 --- a/tests/engine/app/automation/mcp-runtime.test.ts +++ b/tests/engine/app/automation/mcp-runtime.test.ts @@ -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) diff --git a/tests/engine/app/automation/mcp-vite-plugin.test.ts b/tests/engine/app/automation/mcp-vite-plugin.test.ts index 1cd31d99a..485d2498c 100644 --- a/tests/engine/app/automation/mcp-vite-plugin.test.ts +++ b/tests/engine/app/automation/mcp-vite-plugin.test.ts @@ -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) }) }) diff --git a/tests/engine/mcp/server/index.test.ts b/tests/engine/mcp/server/index.test.ts index e9d8a05b4..f07fa8bc4 100644 --- a/tests/engine/mcp/server/index.test.ts +++ b/tests/engine/mcp/server/index.test.ts @@ -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 () => {