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
This commit is contained in:
Victor Wads 2026-08-19 22:49:51 -03:00
parent febdcef392
commit 6a78da9eb0
No known key found for this signature in database
9 changed files with 103 additions and 78 deletions

View file

@ -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<typeof createBrowserRPCBridge>
mcpSessions: ReturnType<typeof createMCPSessionManager>
sendToBrowser: (msg: RPCJSONObject) => Promise<unknown>
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 {

View file

@ -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<string, MCPToolCatalogEntry>
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 [

View file

@ -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<string, unknown>
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<string, z.ZodType> = {}
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<string, unknown>) => {
@ -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
}

View file

@ -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<MCPToolCatalogEntry[]>([])
export const disabledMCPTools = useLocalStorage<string[]>(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)

View file

@ -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<void> {
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<void> {
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<void> {
server = null
mcpRuntime.status = 'stopped'
mcpRuntime.version = null
setMCPToolCatalog([])
}
export async function restartMCPRuntime(): Promise<void> {

View file

@ -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 {

View file

@ -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)
)

View file

@ -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 () => {

View file

@ -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
}