From 6a78da9eb0fecc15c3f8e516082ebbb0e99beaf0 Mon Sep 17 00:00:00 2001 From: Victor Wads Date: Wed, 19 Aug 2026 22:49:51 -0300 Subject: [PATCH] refactor(mcp): derive catalog from registration - Collect tool metadata through the existing registration wrapper - Expose the runtime catalog to Settings without a parallel MCP-only list - Keep disabled tools discoverable so they can be re-enabled --- packages/mcp/src/server.ts | 22 ++++++- packages/mcp/src/tool/catalog.ts | 45 -------------- packages/mcp/src/tool/registration.ts | 62 +++++++++++++------ src/app/automation/mcp/preferences.ts | 11 +++- src/app/automation/mcp/runtime.ts | 4 ++ src/app/automation/mcp/spawn.ts | 2 + .../settings/mcp/MCPSettingsPanel.vue | 8 +-- tests/engine/mcp/server/index.test.ts | 25 ++++++-- tests/helpers/mcp/server.ts | 2 + 9 files changed, 103 insertions(+), 78 deletions(-) diff --git a/packages/mcp/src/server.ts b/packages/mcp/src/server.ts index 2a68b3478..fba8ce5d8 100644 --- a/packages/mcp/src/server.ts +++ b/packages/mcp/src/server.ts @@ -14,6 +14,7 @@ import { MCP_CORS_HEADERS, MCP_CORS_METHODS, MCP_EXPOSED_HEADERS } from '#mcp/ht import type { RPCJSONObject } from '#mcp/json' import { preprocessRPC } from '#mcp/jsx-preprocess' import { createMCPSessionManager } from '#mcp/server/sessions' +import type { MCPToolCatalogEntry } from '#mcp/tool/catalog' import { registerTools } from '#mcp/tool/registration' import packageJSON from '../package.json' with { type: 'json' } @@ -105,8 +106,9 @@ function createHonoApp(options: { browserRPC: ReturnType mcpSessions: ReturnType sendToBrowser: (msg: RPCJSONObject) => Promise + toolCatalog: MCPToolCatalogEntry[] }): Hono { - const { authToken, corsOrigin, browserRPC, mcpSessions, sendToBrowser } = options + const { authToken, corsOrigin, browserRPC, mcpSessions, sendToBrowser, toolCatalog } = options const app = new Hono() @@ -127,7 +129,8 @@ function createHonoApp(options: { status: browserRPC.isConnected() ? 'ok' : 'no_app', version: MCP_VERSION, installCommand: await mcpInstallCommand(), - authRequired: authToken !== null + authRequired: authToken !== null, + tools: toolCatalog }) ) @@ -309,8 +312,21 @@ function buildServerContext(options: ServerOptions) { onConnectionChange: mcpSessions.notifyToolsChanged }) const sendToBrowser = browserRPC.sendRPC + const toolCatalog = registerTools(null, { + disabledTools, + enableEval, + mcpRoot, + sendRPC: sendToBrowser + }) - const app = createHonoApp({ authToken, corsOrigin, browserRPC, mcpSessions, sendToBrowser }) + const app = createHonoApp({ + authToken, + corsOrigin, + browserRPC, + mcpSessions, + sendToBrowser, + toolCatalog + }) const wss = new WebSocketServer({ noServer: true }) return { diff --git a/packages/mcp/src/tool/catalog.ts b/packages/mcp/src/tool/catalog.ts index 166fb80e6..29876f88d 100644 --- a/packages/mcp/src/tool/catalog.ts +++ b/packages/mcp/src/tool/catalog.ts @@ -1,4 +1,3 @@ -import { ALL_TOOLS } from '@open-pencil/core/tools' import type { DocumentAccess } from '@open-pencil/core/tools' export type MCPToolAvailability = 'default' | 'eval' | 'filesystem' @@ -10,50 +9,6 @@ export type MCPToolCatalogEntry = { documentAccess: DocumentAccess } -export const MCP_ONLY_TOOLS = { - listDocuments: { - name: 'list_documents', - description: - 'List open OpenPencil documents/tabs with their IDs, file paths, current pages, and pages.', - availability: 'default', - documentAccess: 'inspect' - }, - saveFile: { - name: 'save_file', - description: 'Save the current document to disk.', - availability: 'default', - documentAccess: 'inspect' - }, - openFile: { - name: 'open_file', - description: 'Open a .fig or .pen file from disk into a new tab.', - availability: 'filesystem', - documentAccess: 'inspect' - }, - newDocument: { - name: 'new_document', - description: 'Create a new empty document.', - availability: 'filesystem', - documentAccess: 'modify' - }, - getCodegenPrompt: { - name: 'get_codegen_prompt', - description: 'Get design-to-code generation guidelines. Call before generating frontend code.', - availability: 'default', - documentAccess: 'inspect' - } -} as const satisfies Record - -export const MCP_TOOL_CATALOG: MCPToolCatalogEntry[] = [ - ...ALL_TOOLS.map((tool) => ({ - name: tool.name, - description: tool.description, - availability: tool.name === 'eval' ? ('eval' as const) : ('default' as const), - documentAccess: tool.documentAccess - })), - ...Object.values(MCP_ONLY_TOOLS) -] - export function parseDisabledTools(value: string | undefined): string[] { if (!value) return [] return [ diff --git a/packages/mcp/src/tool/registration.ts b/packages/mcp/src/tool/registration.ts index 1540cd259..f5c41b53c 100644 --- a/packages/mcp/src/tool/registration.ts +++ b/packages/mcp/src/tool/registration.ts @@ -8,7 +8,7 @@ import { ALL_TOOLS, CODEGEN_PROMPT } from '@open-pencil/core/tools' import type { RPCJSONObject } from '#mcp/json' import { MAX_RESULT_BYTES, fail, ok, resultTooLargeMessage } from '#mcp/result' -import { MCP_ONLY_TOOLS } from '#mcp/tool/catalog' +import type { MCPToolAvailability, MCPToolCatalogEntry } from '#mcp/tool/catalog' import { resolveSafePath, writeToolOutput } from '#mcp/tool/output' import { paramToZod } from '#mcp/tool/schema' @@ -40,18 +40,31 @@ export interface RegisterToolsOptions { sendRPC: RPCSender } -export function registerTools(mcpServer: McpServer, options: RegisterToolsOptions) { +type CatalogToolOptions = { + description: string + documentAccess: MCPToolCatalogEntry['documentAccess'] + availability?: MCPToolAvailability +} & Record + +export function registerTools( + mcpServer: McpServer | null, + options: RegisterToolsOptions +): MCPToolCatalogEntry[] { const { enableEval, sendRPC } = options - const { getCodegenPrompt, listDocuments, newDocument, openFile, saveFile } = MCP_ONLY_TOOLS const disabledTools = new Set(options.disabledTools) const resolvedRoot = options.mcpRoot ? resolve(options.mcpRoot) : null - const registerTool = mcpServer.registerTool.bind(mcpServer) as (...a: unknown[]) => void - const register = (name: string, ...args: unknown[]) => { - if (!disabledTools.has(name)) registerTool(name, ...args) + const catalog: MCPToolCatalogEntry[] = [] + const register = (name: string, toolOptions: CatalogToolOptions, ...args: unknown[]) => { + const { documentAccess, availability = 'default', ...sdkOptions } = toolOptions + catalog.push({ name, description: toolOptions.description, documentAccess, availability }) + if (!mcpServer || disabledTools.has(name)) return + if (availability === 'eval' && !enableEval) return + if (availability === 'filesystem' && !resolvedRoot) return + const registerTool = mcpServer.registerTool.bind(mcpServer) as (...a: unknown[]) => void + registerTool(name, sdkOptions, ...args) } for (const def of ALL_TOOLS) { - if (!enableEval && def.name === 'eval') continue const shape: Record = {} for (const [key, param] of Object.entries(def.params)) { shape[key] = paramToZod(param) @@ -60,6 +73,8 @@ export function registerTools(mcpServer: McpServer, options: RegisterToolsOption def.name, { description: def.description, + documentAccess: def.documentAccess, + availability: def.name === 'eval' ? 'eval' : 'default', inputSchema: z.object({ ...shape, ...automationTargetSchema }) }, async (args: Record) => { @@ -110,9 +125,11 @@ export function registerTools(mcpServer: McpServer, options: RegisterToolsOption } register( - listDocuments.name, + 'list_documents', { - description: listDocuments.description, + description: + 'List open OpenPencil documents/tabs with their IDs, file paths, current pages, and pages.', + documentAccess: 'inspect', inputSchema: z.object({}) }, async () => { @@ -128,11 +145,12 @@ export function registerTools(mcpServer: McpServer, options: RegisterToolsOption ) register( - saveFile.name, + 'save_file', { description: resolvedRoot - ? `${saveFile.description} If path is provided, it must be inside ${resolvedRoot}.` - : `${saveFile.description} Uses the existing file path if available, otherwise prompts for a location.`, + ? `Save the current document to disk. If path is provided, it must be inside ${resolvedRoot}.` + : 'Save the current document to disk. Uses the existing file path if available, otherwise prompts for a location.', + documentAccess: 'inspect', inputSchema: resolvedRoot ? z.object({ path: z @@ -170,9 +188,11 @@ export function registerTools(mcpServer: McpServer, options: RegisterToolsOption if (resolvedRoot) { register( - openFile.name, + 'open_file', { - description: `${openFile.description} Path must be inside ${resolvedRoot}.`, + description: `Open a .fig or .pen file from disk into a new tab. Path must be inside ${resolvedRoot}.`, + documentAccess: 'inspect', + availability: 'filesystem', inputSchema: z.object({ path: z .string() @@ -199,9 +219,11 @@ export function registerTools(mcpServer: McpServer, options: RegisterToolsOption ) register( - newDocument.name, + 'new_document', { - description: `${newDocument.description} Optionally set a save path inside ${resolvedRoot}.`, + description: `Create a new empty document. Optionally set a save path inside ${resolvedRoot}.`, + documentAccess: 'modify', + availability: 'filesystem', inputSchema: z.object({ path: z .string() @@ -231,11 +253,15 @@ export function registerTools(mcpServer: McpServer, options: RegisterToolsOption } register( - getCodegenPrompt.name, + 'get_codegen_prompt', { - description: getCodegenPrompt.description, + description: + 'Get design-to-code generation guidelines. Call before generating frontend code.', + documentAccess: 'inspect', inputSchema: z.object({}) }, async () => ok({ prompt: CODEGEN_PROMPT }) ) + + return catalog } diff --git a/src/app/automation/mcp/preferences.ts b/src/app/automation/mcp/preferences.ts index 020fb687d..cc38476e7 100644 --- a/src/app/automation/mcp/preferences.ts +++ b/src/app/automation/mcp/preferences.ts @@ -1,15 +1,20 @@ import { useLocalStorage } from '@vueuse/core' +import { ref } from 'vue' -import { MCP_TOOL_CATALOG, type MCPToolCatalogEntry } from '@open-pencil/mcp/tools' +import type { MCPToolCatalogEntry } 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' -export const configurableMCPTools = MCP_TOOL_CATALOG.filter((tool) => tool.availability !== 'eval') +export const configurableMCPTools = ref([]) export const disabledMCPTools = useLocalStorage(DISABLED_TOOLS_STORAGE_KEY, []) export const mcpRootDirectory = useLocalStorage(ROOT_DIRECTORY_STORAGE_KEY, '') +export function setMCPToolCatalog(tools: MCPToolCatalogEntry[]): void { + configurableMCPTools.value = tools.filter((tool) => tool.availability !== 'eval') +} + export function setMCPToolEnabled(name: string, enabled: boolean): void { const disabled = new Set(disabledMCPTools.value) if (enabled) disabled.delete(name) @@ -22,7 +27,7 @@ export function setMCPToolCategoryEnabled( enabled: boolean ): void { const disabled = new Set(disabledMCPTools.value) - for (const tool of configurableMCPTools) { + for (const tool of configurableMCPTools.value) { if (tool.documentAccess !== documentAccess) continue if (enabled) disabled.delete(tool.name) else disabled.add(tool.name) diff --git a/src/app/automation/mcp/runtime.ts b/src/app/automation/mcp/runtime.ts index 42e9c51ad..078d690d7 100644 --- a/src/app/automation/mcp/runtime.ts +++ b/src/app/automation/mcp/runtime.ts @@ -6,6 +6,7 @@ import { connectAutomation } from '@/app/automation/bridge/server' import type { EditorStore } from '@/app/editor/active-store' import { isTauri } from '@/app/tauri/env' +import { setMCPToolCatalog } from './preferences' import { readAutomationHealth, spawnMCPIfNeeded, type AutomationServerHandle } from './spawn' export type MCPRuntimeStatus = 'idle' | 'starting' | 'running' | 'stopped' | 'error' @@ -28,6 +29,7 @@ export async function refreshMCPRuntime(): Promise { try { const health = await readAutomationHealth() mcpRuntime.version = health?.version ?? null + setMCPToolCatalog(health?.tools ?? []) if (health) { mcpRuntime.status = 'running' mcpRuntime.error = null @@ -50,6 +52,7 @@ async function start(): Promise { const health = await readAutomationHealth() if (!health) throw new Error('MCP server did not become healthy') mcpRuntime.version = health.version ?? null + setMCPToolCatalog(health.tools ?? []) mcpRuntime.status = 'running' } catch (error) { mcpRuntime.status = 'error' @@ -73,6 +76,7 @@ export async function stopMCPRuntime(): Promise { server = null mcpRuntime.status = 'stopped' mcpRuntime.version = null + setMCPToolCatalog([]) } export async function restartMCPRuntime(): Promise { diff --git a/src/app/automation/mcp/spawn.ts b/src/app/automation/mcp/spawn.ts index c5eea38ca..c7087e423 100644 --- a/src/app/automation/mcp/spawn.ts +++ b/src/app/automation/mcp/spawn.ts @@ -3,6 +3,7 @@ import { promiseTimeout } from '@vueuse/core' import { AUTOMATION_HTTP_PORT } from '@open-pencil/core/constants' import { randomHex } from '@open-pencil/core/random' import type { DiscoveryInfo } from '@open-pencil/mcp/discovery' +import type { MCPToolCatalogEntry } from '@open-pencil/mcp/tools' import { decodeTauriStderr } from '@/app/shell/ui' import { resolvePlatformCommand } from '@/app/tauri/command' @@ -16,6 +17,7 @@ export interface AutomationHealth { installCommand?: string authRequired?: boolean discoveryPath?: string + tools?: MCPToolCatalogEntry[] } export interface AutomationServerHandle { diff --git a/src/components/settings/mcp/MCPSettingsPanel.vue b/src/components/settings/mcp/MCPSettingsPanel.vue index 7099bed65..d627e0b72 100644 --- a/src/components/settings/mcp/MCPSettingsPanel.vue +++ b/src/components/settings/mcp/MCPSettingsPanel.vue @@ -17,7 +17,7 @@ const { dialogs } = useI18n() const toolSearch = ref('') const disabledToolNames = computed(() => new Set(disabledMCPTools.value)) function categoryStatus(documentAccess: 'inspect' | 'modify') { - const tools = configurableMCPTools.filter((tool) => tool.documentAccess === documentAccess) + const tools = configurableMCPTools.value.filter((tool) => tool.documentAccess === documentAccess) const enabled = tools.filter((tool) => !disabledToolNames.value.has(tool.name)).length return { enabled: enabled > 0, @@ -27,12 +27,12 @@ function categoryStatus(documentAccess: 'inspect' | 'modify') { const inspectionToolsStatus = computed(() => categoryStatus('inspect')) const modificationToolsStatus = computed(() => categoryStatus('modify')) const enabledToolCount = computed( - () => configurableMCPTools.filter((tool) => !disabledToolNames.value.has(tool.name)).length + () => configurableMCPTools.value.filter((tool) => !disabledToolNames.value.has(tool.name)).length ) const visibleTools = computed(() => { const query = toolSearch.value.trim().toLowerCase() - if (!query) return configurableMCPTools - return configurableMCPTools.filter( + if (!query) return configurableMCPTools.value + return configurableMCPTools.value.filter( (tool) => tool.name.toLowerCase().includes(query) || tool.description.toLowerCase().includes(query) ) diff --git a/tests/engine/mcp/server/index.test.ts b/tests/engine/mcp/server/index.test.ts index 9123c1932..e9b5b8261 100644 --- a/tests/engine/mcp/server/index.test.ts +++ b/tests/engine/mcp/server/index.test.ts @@ -9,7 +9,7 @@ import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/ import { SceneGraph } from '@open-pencil/scene-graph' import { startServer } from '#mcp/server' -import { MCP_TOOL_CATALOG } from '#mcp/tool/catalog' +import { registerTools } from '#mcp/tool/registration' import { connectMockBrowser, @@ -136,16 +136,25 @@ describe('MCP server', () => { test('lists all registered tools', async () => { const { tools } = await client.listTools() - const expectedNames = MCP_TOOL_CATALOG.filter((tool) => tool.availability === 'default') + const catalog = registerTools(null, { + enableEval: false, + mcpRoot: null, + sendRPC: async () => ({}) + }) + const expectedNames = catalog + .filter((tool) => tool.availability === 'default') .map((tool) => tool.name) .sort() expect(tools.map((tool) => tool.name).sort()).toEqual(expectedNames) }) test('classifies document access independently from runtime state mutation', () => { - const accessByName = new Map( - MCP_TOOL_CATALOG.map((tool) => [tool.name, tool.documentAccess] as const) - ) + const catalog = registerTools(null, { + enableEval: true, + mcpRoot: TEST_MCP_ROOT, + sendRPC: async () => ({}) + }) + const accessByName = new Map(catalog.map((tool) => [tool.name, tool.documentAccess] as const)) expect(accessByName.get('get_page_tree')).toBe('inspect') expect(accessByName.get('switch_page')).toBe('inspect') expect(accessByName.get('viewport_set')).toBe('inspect') @@ -169,6 +178,12 @@ describe('MCP server', () => { expect(names).not.toContain('create_shape') expect(names).not.toContain('list_documents') expect(names).toContain('get_page_tree') + + const healthResponse = await fetch(`http://127.0.0.1:${ctx.handle.httpPort}/health`) + 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') }) test('tools have descriptions and input schemas', async () => { diff --git a/tests/helpers/mcp/server.ts b/tests/helpers/mcp/server.ts index 18781f7d9..08ce946a1 100644 --- a/tests/helpers/mcp/server.ts +++ b/tests/helpers/mcp/server.ts @@ -9,11 +9,13 @@ import { computeAllLayouts, executeRPCCommand } from '@open-pencil/core' +import type { MCPToolCatalogEntry } from '@open-pencil/mcp/tools' export interface HealthResponse { status: string version: string authRequired: boolean + tools: MCPToolCatalogEntry[] discoveryPath?: string }