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:
parent
febdcef392
commit
6a78da9eb0
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 [
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue