feat(mcp): add automation settings controls
This commit is contained in:
parent
0abc4cc21b
commit
b43da1ecc3
|
|
@ -12,6 +12,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 and choose which 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)
|
||||
|
|
|
|||
|
|
@ -33,6 +33,12 @@
|
|||
"bun": "./src/transport/discovery.ts",
|
||||
"import": "./dist/discovery.mjs",
|
||||
"default": "./dist/discovery.mjs"
|
||||
},
|
||||
"./tools": {
|
||||
"types": "./dist/tools.d.mts",
|
||||
"bun": "./src/tool/catalog.ts",
|
||||
"import": "./dist/tools.mjs",
|
||||
"default": "./dist/tools.mjs"
|
||||
}
|
||||
},
|
||||
"bin": {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
#!/usr/bin/env node
|
||||
import { startServer } from '#mcp/server'
|
||||
import { parseDisabledTools } from '#mcp/tool/catalog'
|
||||
|
||||
if (process.argv.includes('--help') || process.argv.includes('-h')) {
|
||||
process.stdout.write(
|
||||
|
|
@ -18,6 +19,7 @@ if (process.argv.includes('--help') || process.argv.includes('-h')) {
|
|||
` OPENPENCIL_MCP_AUTH_TOKEN Bearer token for MCP and RPC auth\n` +
|
||||
` OPENPENCIL_MCP_ROOT Allowed directory for file-scoped tools (default: current working directory)\n` +
|
||||
` OPENPENCIL_MCP_EVAL Set to 1 to enable the eval tool\n` +
|
||||
` OPENPENCIL_MCP_DISABLED_TOOLS Comma-separated tool names to omit\n` +
|
||||
` OPENPENCIL_MCP_CORS_ORIGIN Allowed CORS origin\n` +
|
||||
` OPENPENCIL_MCP_APP_TIMEOUT_MS If set, close the server and remove its discovery\n` +
|
||||
` file after no app is attached for this many ms. The\n` +
|
||||
|
|
@ -70,6 +72,7 @@ const handle = await startServer({
|
|||
withTcp,
|
||||
socketPath: process.env.OPENPENCIL_MCP_SOCKET?.trim() || null,
|
||||
enableEval: process.env.OPENPENCIL_MCP_EVAL === '1',
|
||||
disabledTools: parseDisabledTools(process.env.OPENPENCIL_MCP_DISABLED_TOOLS),
|
||||
mcpRoot: process.env.OPENPENCIL_MCP_ROOT?.trim() || process.cwd(),
|
||||
// Auth token: undefined → auto-generate, empty string → disable auth,
|
||||
// non-empty → use trimmed value. Whitespace-only is rejected to prevent a
|
||||
|
|
|
|||
|
|
@ -66,6 +66,8 @@ export interface ServerOptions {
|
|||
/** Whether to also listen on TCP (in addition to the socket). API default is `false`; the CLI passes `true` by default (derived from PORT, default 7600). */
|
||||
withTcp?: boolean
|
||||
enableEval?: boolean
|
||||
/** Tool names omitted from every MCP session. */
|
||||
disabledTools?: Iterable<string>
|
||||
mcpRoot?: string | null
|
||||
/** Auth token for /mcp and /rpc endpoints. Auto-generated (32-hex) when omitted. Pass null explicitly to disable auth. */
|
||||
authToken?: string | null
|
||||
|
|
@ -276,6 +278,7 @@ function wireConnectionHandling(
|
|||
function buildServerContext(options: ServerOptions) {
|
||||
const httpPort = options.httpPort ?? 7600
|
||||
const enableEval = options.enableEval ?? false
|
||||
const disabledTools = [...new Set(options.disabledTools)]
|
||||
const mcpRoot = options.mcpRoot ?? null
|
||||
// Auto-generated so all transports require auth by default. Override via OPENPENCIL_MCP_AUTH_TOKEN or authToken option.
|
||||
// Pass authToken: null explicitly to disable auth entirely.
|
||||
|
|
@ -299,7 +302,7 @@ function buildServerContext(options: ServerOptions) {
|
|||
const mcpSessions = createMCPSessionManager({
|
||||
serverVersion: MCP_VERSION,
|
||||
registerTools: (mcpServer: McpServer) =>
|
||||
registerTools(mcpServer, { enableEval, mcpRoot, sendRPC: sendToBrowser })
|
||||
registerTools(mcpServer, { disabledTools, enableEval, mcpRoot, sendRPC: sendToBrowser })
|
||||
})
|
||||
const browserRPC = createBrowserRPCBridge({
|
||||
authToken,
|
||||
|
|
@ -310,7 +313,17 @@ function buildServerContext(options: ServerOptions) {
|
|||
const app = createHonoApp({ authToken, corsOrigin, browserRPC, mcpSessions, sendToBrowser })
|
||||
const wss = new WebSocketServer({ noServer: true })
|
||||
|
||||
return { httpPort, withTcp, mcpSessions, browserRPC, sendToBrowser, app, wss, authToken }
|
||||
return {
|
||||
httpPort,
|
||||
withTcp,
|
||||
mcpSessions,
|
||||
browserRPC,
|
||||
sendToBrowser,
|
||||
app,
|
||||
wss,
|
||||
authToken,
|
||||
disabledTools
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -429,6 +442,7 @@ export async function startServer(options: ServerOptions = {}): Promise<ServerHa
|
|||
actualHttpPort,
|
||||
ctx.authToken,
|
||||
MCP_VERSION,
|
||||
ctx.disabledTools,
|
||||
state
|
||||
)
|
||||
} catch (err) {
|
||||
|
|
|
|||
|
|
@ -166,7 +166,8 @@ export async function writeDiscovery(
|
|||
resolvedSocketPath: string | null,
|
||||
actualHttpPort: number,
|
||||
authToken: string | null,
|
||||
version: string
|
||||
version: string,
|
||||
disabledTools: string[]
|
||||
): Promise<string> {
|
||||
const startedAt = new Date().toISOString()
|
||||
await writeDiscoveryFile({
|
||||
|
|
@ -176,7 +177,8 @@ export async function writeDiscovery(
|
|||
authRequired: authToken !== null,
|
||||
authToken,
|
||||
version,
|
||||
startedAt
|
||||
startedAt,
|
||||
disabledTools
|
||||
})
|
||||
return startedAt
|
||||
}
|
||||
|
|
@ -367,10 +369,17 @@ export async function tryWriteDiscovery(
|
|||
actualHttpPort: number,
|
||||
authToken: string | null,
|
||||
version: string,
|
||||
disabledTools: string[],
|
||||
state: ListenerState
|
||||
): Promise<string> {
|
||||
try {
|
||||
return await writeDiscovery(resolvedSocketPath, actualHttpPort, authToken, version)
|
||||
return await writeDiscovery(
|
||||
resolvedSocketPath,
|
||||
actualHttpPort,
|
||||
authToken,
|
||||
version,
|
||||
disabledTools
|
||||
)
|
||||
} catch (err) {
|
||||
// If discovery file write fails after both listeners are up, tear down
|
||||
// both listeners so we never advertise a running server that clients
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
|||
|
||||
import { MCP_VERSION, registerTools } from '#mcp/server'
|
||||
import { createStdioRPCBridge } from '#mcp/stdio/bridge'
|
||||
import { parseDisabledTools } from '#mcp/tool/catalog'
|
||||
import { readDiscoveryFile } from '#mcp/transport/discovery'
|
||||
|
||||
if (process.argv.includes('--help') || process.argv.includes('-h')) {
|
||||
process.stdout.write(
|
||||
|
|
@ -20,12 +22,17 @@ if (process.argv.includes('--help') || process.argv.includes('-h')) {
|
|||
` OPENPENCIL_MCP_AUTH_TOKEN Bearer token for RPC auth\n` +
|
||||
` OPENPENCIL_MCP_ROOT Allowed directory for file-scoped tools\n` +
|
||||
` (default: cwd when run standalone, home directory when app-spawned)\n` +
|
||||
` OPENPENCIL_MCP_EVAL Set to 1 to enable the eval tool\n`
|
||||
` OPENPENCIL_MCP_EVAL Set to 1 to enable the eval tool\n` +
|
||||
` OPENPENCIL_MCP_DISABLED_TOOLS Comma-separated tool names to omit; defaults to the app setting\n`
|
||||
)
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
const enableEval = process.env.OPENPENCIL_MCP_EVAL === '1'
|
||||
const disabledTools =
|
||||
process.env.OPENPENCIL_MCP_DISABLED_TOOLS === undefined
|
||||
? ((await readDiscoveryFile())?.disabledTools ?? [])
|
||||
: parseDisabledTools(process.env.OPENPENCIL_MCP_DISABLED_TOOLS)
|
||||
const mcpRoot = process.env.OPENPENCIL_MCP_ROOT?.trim() || process.cwd()
|
||||
// Auth token: undefined → auto-discover from discovery file, empty string →
|
||||
// disable auth, whitespace-only → reject (same fail-fast as index.ts to catch
|
||||
|
|
@ -64,7 +71,7 @@ const bridge = createStdioRPCBridge({
|
|||
})
|
||||
|
||||
const mcpServer = new McpServer({ name: 'open-pencil', version: MCP_VERSION })
|
||||
registerTools(mcpServer, { enableEval, mcpRoot, sendRPC: bridge.sendRPC })
|
||||
registerTools(mcpServer, { disabledTools, enableEval, mcpRoot, sendRPC: bridge.sendRPC })
|
||||
|
||||
const transport = new StdioServerTransport()
|
||||
mcpServer.connect(transport).catch((err) => {
|
||||
|
|
|
|||
59
packages/mcp/src/tool/catalog.ts
Normal file
59
packages/mcp/src/tool/catalog.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import { ALL_TOOLS } from '@open-pencil/core/tools'
|
||||
|
||||
export type MCPToolRequirement = 'always' | 'eval' | 'root'
|
||||
|
||||
export type MCPToolCatalogEntry = {
|
||||
name: string
|
||||
description: string
|
||||
requirement: MCPToolRequirement
|
||||
}
|
||||
|
||||
export const MCP_ONLY_TOOLS = {
|
||||
listDocuments: {
|
||||
name: 'list_documents',
|
||||
description:
|
||||
'List open OpenPencil documents/tabs with their IDs, file paths, current pages, and pages.',
|
||||
requirement: 'always'
|
||||
},
|
||||
saveFile: {
|
||||
name: 'save_file',
|
||||
description: 'Save the current document to disk.',
|
||||
requirement: 'always'
|
||||
},
|
||||
openFile: {
|
||||
name: 'open_file',
|
||||
description: 'Open a .fig or .pen file from disk into a new tab.',
|
||||
requirement: 'root'
|
||||
},
|
||||
newDocument: {
|
||||
name: 'new_document',
|
||||
description: 'Create a new empty document.',
|
||||
requirement: 'root'
|
||||
},
|
||||
getCodegenPrompt: {
|
||||
name: 'get_codegen_prompt',
|
||||
description: 'Get design-to-code generation guidelines. Call before generating frontend code.',
|
||||
requirement: 'always'
|
||||
}
|
||||
} as const satisfies Record<string, MCPToolCatalogEntry>
|
||||
|
||||
export const MCP_TOOL_CATALOG: MCPToolCatalogEntry[] = [
|
||||
...ALL_TOOLS.map((tool) => ({
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
requirement: tool.name === 'eval' ? ('eval' as const) : ('always' as const)
|
||||
})),
|
||||
...Object.values(MCP_ONLY_TOOLS)
|
||||
]
|
||||
|
||||
export function parseDisabledTools(value: string | undefined): string[] {
|
||||
if (!value) return []
|
||||
return [
|
||||
...new Set(
|
||||
value
|
||||
.split(',')
|
||||
.map((name) => name.trim())
|
||||
.filter(Boolean)
|
||||
)
|
||||
]
|
||||
}
|
||||
|
|
@ -8,6 +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 { resolveSafePath, writeToolOutput } from '#mcp/tool/output'
|
||||
import { paramToZod } from '#mcp/tool/schema'
|
||||
|
||||
|
|
@ -33,6 +34,7 @@ function splitAutomationTarget(args: Record<string, unknown>): {
|
|||
}
|
||||
|
||||
export interface RegisterToolsOptions {
|
||||
disabledTools?: Iterable<string>
|
||||
enableEval: boolean
|
||||
mcpRoot?: string | null
|
||||
sendRPC: RPCSender
|
||||
|
|
@ -40,8 +42,13 @@ export interface RegisterToolsOptions {
|
|||
|
||||
export function registerTools(mcpServer: McpServer, options: RegisterToolsOptions) {
|
||||
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 register = mcpServer.registerTool.bind(mcpServer) as (...a: unknown[]) => void
|
||||
const registerTool = mcpServer.registerTool.bind(mcpServer) as (...a: unknown[]) => void
|
||||
const register = (name: string, ...args: unknown[]) => {
|
||||
if (!disabledTools.has(name)) registerTool(name, ...args)
|
||||
}
|
||||
|
||||
for (const def of ALL_TOOLS) {
|
||||
if (!enableEval && def.name === 'eval') continue
|
||||
|
|
@ -103,10 +110,9 @@ export function registerTools(mcpServer: McpServer, options: RegisterToolsOption
|
|||
}
|
||||
|
||||
register(
|
||||
'list_documents',
|
||||
listDocuments.name,
|
||||
{
|
||||
description:
|
||||
'List open OpenPencil documents/tabs with their IDs, file paths, current pages, and pages.',
|
||||
description: listDocuments.description,
|
||||
inputSchema: z.object({})
|
||||
},
|
||||
async () => {
|
||||
|
|
@ -122,11 +128,11 @@ export function registerTools(mcpServer: McpServer, options: RegisterToolsOption
|
|||
)
|
||||
|
||||
register(
|
||||
'save_file',
|
||||
saveFile.name,
|
||||
{
|
||||
description: resolvedRoot
|
||||
? `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.',
|
||||
? `${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.`,
|
||||
inputSchema: resolvedRoot
|
||||
? z.object({
|
||||
path: z
|
||||
|
|
@ -164,9 +170,9 @@ export function registerTools(mcpServer: McpServer, options: RegisterToolsOption
|
|||
|
||||
if (resolvedRoot) {
|
||||
register(
|
||||
'open_file',
|
||||
openFile.name,
|
||||
{
|
||||
description: `Open a .fig or .pen file from disk into a new tab. Path must be inside ${resolvedRoot}.`,
|
||||
description: `${openFile.description} Path must be inside ${resolvedRoot}.`,
|
||||
inputSchema: z.object({
|
||||
path: z
|
||||
.string()
|
||||
|
|
@ -193,9 +199,9 @@ export function registerTools(mcpServer: McpServer, options: RegisterToolsOption
|
|||
)
|
||||
|
||||
register(
|
||||
'new_document',
|
||||
newDocument.name,
|
||||
{
|
||||
description: `Create a new empty document. Optionally set a save path inside ${resolvedRoot}.`,
|
||||
description: `${newDocument.description} Optionally set a save path inside ${resolvedRoot}.`,
|
||||
inputSchema: z.object({
|
||||
path: z
|
||||
.string()
|
||||
|
|
@ -225,10 +231,9 @@ export function registerTools(mcpServer: McpServer, options: RegisterToolsOption
|
|||
}
|
||||
|
||||
register(
|
||||
'get_codegen_prompt',
|
||||
getCodegenPrompt.name,
|
||||
{
|
||||
description:
|
||||
'Get design-to-code generation guidelines. Call before generating frontend code.',
|
||||
description: getCodegenPrompt.description,
|
||||
inputSchema: z.object({})
|
||||
},
|
||||
async () => ok({ prompt: CODEGEN_PROMPT })
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ export interface DiscoveryInfo {
|
|||
authToken: string | null
|
||||
version: string
|
||||
startedAt: string
|
||||
disabledTools?: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -90,7 +91,8 @@ export async function readDiscoveryFile(): Promise<DiscoveryInfo | null> {
|
|||
}
|
||||
|
||||
function validateDiscoveryFields(obj: { [key: string]: unknown }): DiscoveryInfo | null {
|
||||
const { pid, version, httpPort, authRequired, startedAt, socketPath, authToken } = obj
|
||||
const { pid, version, httpPort, authRequired, startedAt, socketPath, authToken, disabledTools } =
|
||||
obj
|
||||
if (typeof pid !== 'number' || !Number.isInteger(pid) || pid <= 0) return null
|
||||
if (typeof version !== 'string') return null
|
||||
if (typeof httpPort !== 'number' || !Number.isInteger(httpPort)) return null
|
||||
|
|
@ -100,7 +102,22 @@ function validateDiscoveryFields(obj: { [key: string]: unknown }): DiscoveryInfo
|
|||
if (typeof socketPath !== 'string' && socketPath !== null) return null
|
||||
if (socketPath === '') return null
|
||||
if (authToken !== null && typeof authToken !== 'string') return null
|
||||
return { pid, version, httpPort, authRequired, startedAt, socketPath, authToken }
|
||||
if (
|
||||
disabledTools !== undefined &&
|
||||
(!Array.isArray(disabledTools) || disabledTools.some((name) => typeof name !== 'string'))
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
pid,
|
||||
version,
|
||||
httpPort,
|
||||
authRequired,
|
||||
startedAt,
|
||||
socketPath,
|
||||
authToken,
|
||||
disabledTools: disabledTools ?? []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -6,7 +6,8 @@ export default defineConfig({
|
|||
server: './src/server.ts',
|
||||
stdio: './src/stdio.ts',
|
||||
transport: './src/transport/paths.ts',
|
||||
discovery: './src/transport/discovery.ts'
|
||||
discovery: './src/transport/discovery.ts',
|
||||
tools: './src/tool/catalog.ts'
|
||||
},
|
||||
platform: 'node',
|
||||
format: ['esm'],
|
||||
|
|
|
|||
|
|
@ -247,6 +247,25 @@ export const dialogMessageDefaults = {
|
|||
noModel: 'None',
|
||||
back: 'Back',
|
||||
settingsMedia: 'Media',
|
||||
settingsMCP: 'MCP & automation',
|
||||
mcpDescription: 'Monitor and restart the local MCP server used by agents and automation.',
|
||||
mcpStatus: 'Status',
|
||||
mcpPort: 'Port',
|
||||
mcpAddress: 'Address',
|
||||
mcpVersion: 'Version',
|
||||
mcpTools: 'Available tools',
|
||||
mcpToolsEnabled: params('{enabled} of {total} enabled'),
|
||||
mcpEnableAllTools: 'Enable all',
|
||||
mcpSearchTools: 'Search MCP tools',
|
||||
mcpToolsRestartNotice:
|
||||
'Restart the MCP server, then reconnect stdio clients, to apply tool availability changes.',
|
||||
mcpRestart: 'Restart MCP server',
|
||||
mcpStarting: 'Starting…',
|
||||
mcpStatus_idle: 'Not initialized',
|
||||
mcpStatus_starting: 'Starting',
|
||||
mcpStatus_running: 'Running',
|
||||
mcpStatus_stopped: 'Stopped',
|
||||
mcpStatus_error: 'Error',
|
||||
vectorization: 'Image vectorization',
|
||||
vectorizationDescription:
|
||||
'Send image layers to Recraft or fal.ai and return editable vectors. Provider charges may apply.',
|
||||
|
|
|
|||
20
src/app/automation/mcp/preferences.ts
Normal file
20
src/app/automation/mcp/preferences.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { useLocalStorage } from '@vueuse/core'
|
||||
|
||||
import { MCP_TOOL_CATALOG } from '@open-pencil/mcp/tools'
|
||||
|
||||
const DISABLED_TOOLS_STORAGE_KEY = 'open-pencil:mcp:disabled-tools'
|
||||
|
||||
export const configurableMCPTools = MCP_TOOL_CATALOG.filter((tool) => tool.requirement !== 'eval')
|
||||
|
||||
export const disabledMCPTools = useLocalStorage<string[]>(DISABLED_TOOLS_STORAGE_KEY, [])
|
||||
|
||||
export function setMCPToolEnabled(name: string, enabled: boolean): void {
|
||||
const disabled = new Set(disabledMCPTools.value)
|
||||
if (enabled) disabled.delete(name)
|
||||
else disabled.add(name)
|
||||
disabledMCPTools.value = [...disabled]
|
||||
}
|
||||
|
||||
export function disabledMCPToolsCSV(): string {
|
||||
return disabledMCPTools.value.join(',')
|
||||
}
|
||||
87
src/app/automation/mcp/runtime.ts
Normal file
87
src/app/automation/mcp/runtime.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
import { reactive } from 'vue'
|
||||
|
||||
import { AUTOMATION_HTTP_PORT } from '@open-pencil/core/constants'
|
||||
|
||||
import { connectAutomation } from '@/app/automation/bridge/server'
|
||||
import type { EditorStore } from '@/app/editor/active-store'
|
||||
import { isTauri } from '@/app/tauri/env'
|
||||
|
||||
import { readAutomationHealth, spawnMCPIfNeeded, type AutomationServerHandle } from './spawn'
|
||||
|
||||
export type MCPRuntimeStatus = 'idle' | 'starting' | 'running' | 'stopped' | 'error'
|
||||
|
||||
export const mcpRuntime = reactive({
|
||||
status: 'idle' as MCPRuntimeStatus,
|
||||
port: AUTOMATION_HTTP_PORT,
|
||||
version: null as string | null,
|
||||
error: null as string | null,
|
||||
checking: false
|
||||
})
|
||||
|
||||
let server: AutomationServerHandle | null = null
|
||||
let disconnectAutomation: (() => void) | null = null
|
||||
let activeStore: (() => EditorStore) | null = null
|
||||
let operation: Promise<void> | null = null
|
||||
|
||||
export async function refreshMCPRuntime(): Promise<void> {
|
||||
mcpRuntime.checking = true
|
||||
try {
|
||||
const health = await readAutomationHealth()
|
||||
mcpRuntime.version = health?.version ?? null
|
||||
if (health) {
|
||||
mcpRuntime.status = 'running'
|
||||
mcpRuntime.error = null
|
||||
} else if (mcpRuntime.status !== 'starting' && mcpRuntime.status !== 'error') {
|
||||
mcpRuntime.status = 'stopped'
|
||||
}
|
||||
} finally {
|
||||
mcpRuntime.checking = false
|
||||
}
|
||||
}
|
||||
|
||||
async function start(): Promise<void> {
|
||||
mcpRuntime.status = 'starting'
|
||||
mcpRuntime.error = null
|
||||
try {
|
||||
server = await spawnMCPIfNeeded()
|
||||
if (activeStore && (import.meta.env.DEV || isTauri())) {
|
||||
disconnectAutomation = connectAutomation(activeStore, server?.authToken ?? null).disconnect
|
||||
}
|
||||
const health = await readAutomationHealth()
|
||||
if (!health) throw new Error('MCP server did not become healthy')
|
||||
mcpRuntime.version = health.version ?? null
|
||||
mcpRuntime.status = 'running'
|
||||
} catch (error) {
|
||||
mcpRuntime.status = 'error'
|
||||
mcpRuntime.error = error instanceof Error ? error.message : String(error)
|
||||
console.warn('[MCP]', error)
|
||||
}
|
||||
}
|
||||
|
||||
export function startMCPRuntime(getStore: () => EditorStore): Promise<void> {
|
||||
activeStore = getStore
|
||||
operation ??= start().finally(() => {
|
||||
operation = null
|
||||
})
|
||||
return operation
|
||||
}
|
||||
|
||||
export async function stopMCPRuntime(): Promise<void> {
|
||||
disconnectAutomation?.()
|
||||
disconnectAutomation = null
|
||||
await server?.disconnect()
|
||||
server = null
|
||||
mcpRuntime.status = 'stopped'
|
||||
mcpRuntime.version = null
|
||||
}
|
||||
|
||||
export async function restartMCPRuntime(): Promise<void> {
|
||||
if (operation) await operation
|
||||
await stopMCPRuntime()
|
||||
await startMCPRuntime(
|
||||
activeStore ??
|
||||
(() => {
|
||||
throw new Error('Editor is not ready')
|
||||
})
|
||||
)
|
||||
}
|
||||
|
|
@ -8,7 +8,9 @@ import { decodeTauriStderr } from '@/app/shell/ui'
|
|||
import { resolvePlatformCommand } from '@/app/tauri/command'
|
||||
import { isTauri } from '@/app/tauri/env'
|
||||
|
||||
interface AutomationHealth {
|
||||
import { disabledMCPToolsCSV } from './preferences'
|
||||
|
||||
export interface AutomationHealth {
|
||||
status: 'ok' | 'no_app'
|
||||
version?: string
|
||||
installCommand?: string
|
||||
|
|
@ -17,7 +19,7 @@ interface AutomationHealth {
|
|||
}
|
||||
|
||||
export interface AutomationServerHandle {
|
||||
disconnect: () => void
|
||||
disconnect: () => void | Promise<void>
|
||||
authToken: string | null
|
||||
}
|
||||
|
||||
|
|
@ -154,7 +156,7 @@ async function resolveDiscoveryPath(healthDiscoveryPath?: string): Promise<strin
|
|||
return expected
|
||||
}
|
||||
|
||||
async function readHealth(): Promise<AutomationHealth | null> {
|
||||
export async function readAutomationHealth(): Promise<AutomationHealth | null> {
|
||||
try {
|
||||
const res = await fetch(`http://127.0.0.1:${AUTOMATION_HTTP_PORT}/health`, {
|
||||
signal: AbortSignal.timeout(1000)
|
||||
|
|
@ -193,7 +195,7 @@ function assertCompatibleMCPVersion(health: AutomationHealth): void {
|
|||
async function pollHealth(retries: number, delayMs: number): Promise<AutomationHealth | null> {
|
||||
for (let i = 0; i < retries; i++) {
|
||||
await promiseTimeout(delayMs)
|
||||
const health = await readHealth()
|
||||
const health = await readAutomationHealth()
|
||||
if (health) return health
|
||||
}
|
||||
return null
|
||||
|
|
@ -201,7 +203,7 @@ async function pollHealth(retries: number, delayMs: number): Promise<AutomationH
|
|||
|
||||
export async function getAutomationAuthToken(): Promise<string | null> {
|
||||
if (runtimeAutomationAuthToken) return runtimeAutomationAuthToken
|
||||
const health = await readHealth()
|
||||
const health = await readAutomationHealth()
|
||||
if (!health) {
|
||||
if (runtimeAutomationStartupError) throw runtimeAutomationStartupError
|
||||
throw new Error(
|
||||
|
|
@ -245,7 +247,7 @@ async function startMCPIfNeeded(): Promise<AutomationServerHandle | null> {
|
|||
|
||||
const expectedDiscoveryPath = await computeExpectedDiscoveryPath()
|
||||
const hasDiscovery = await discoveryFileExists(expectedDiscoveryPath)
|
||||
const existing = hasDiscovery ? await readHealth() : null
|
||||
const existing = hasDiscovery ? await readAutomationHealth() : null
|
||||
if (existing) {
|
||||
assertCompatibleMCPVersion(existing)
|
||||
const discoveryPath = await resolveDiscoveryPath(existing.discoveryPath)
|
||||
|
|
@ -285,7 +287,8 @@ async function startMCPIfNeeded(): Promise<AutomationServerHandle | null> {
|
|||
OPENPENCIL_MCP_CORS_ORIGIN: window.location.origin,
|
||||
OPENPENCIL_MCP_TCP: '1',
|
||||
OPENPENCIL_MCP_ROOT: mcpRoot,
|
||||
OPENPENCIL_MCP_APP_TIMEOUT_MS: String(MCP_APP_ATTACH_TIMEOUT_MS)
|
||||
OPENPENCIL_MCP_APP_TIMEOUT_MS: String(MCP_APP_ATTACH_TIMEOUT_MS),
|
||||
OPENPENCIL_MCP_DISABLED_TOOLS: disabledMCPToolsCSV()
|
||||
}
|
||||
})
|
||||
|
||||
|
|
@ -335,8 +338,8 @@ async function startMCPIfNeeded(): Promise<AutomationServerHandle | null> {
|
|||
runtimeAutomationAuthToken = token
|
||||
runtimeAutomationStartupError = null
|
||||
return {
|
||||
disconnect: () => {
|
||||
void child.kill().catch((e) => {
|
||||
disconnect: async () => {
|
||||
await child.kill().catch((e) => {
|
||||
console.error('[MCP] Failed to kill server:', e)
|
||||
})
|
||||
if (runtimeAutomationAuthToken === token) {
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { appCredentialServices } from '@/app/settings/credentials/app'
|
|||
import { settingsDialogOpen, settingsDialogSection } from '@/app/settings/dialog'
|
||||
import GeneralSettingsPanel from '@/components/settings/general/GeneralSettingsPanel.vue'
|
||||
import MCPConnectionsSection from '@/components/settings/mcp/MCPConnectionsSection.vue'
|
||||
import MCPSettingsPanel from '@/components/settings/mcp/MCPSettingsPanel.vue'
|
||||
import ModelsPanel from '@/components/settings/models/ModelsPanel.vue'
|
||||
import StockPhotoKeysSection from '@/components/settings/provider/StockPhotoKeysSection.vue'
|
||||
import StorageSettingsPanel from '@/components/settings/storage/StorageSettingsPanel.vue'
|
||||
|
|
@ -87,7 +88,7 @@ const navigationClass =
|
|||
@click="settingsDialogSection = 'mcp'"
|
||||
>
|
||||
<icon-lucide-plug class="size-3.5" />
|
||||
{{ dialogs.mcpConnections }}
|
||||
{{ dialogs.settingsMCP }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -127,6 +128,7 @@ const navigationClass =
|
|||
class="flex flex-col"
|
||||
data-test-id="settings-mcp-panel"
|
||||
>
|
||||
<MCPSettingsPanel />
|
||||
<MCPConnectionsSection />
|
||||
</section>
|
||||
|
||||
|
|
|
|||
158
src/components/settings/mcp/MCPSettingsPanel.vue
Normal file
158
src/components/settings/mcp/MCPSettingsPanel.vue
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useI18n } from '@open-pencil/vue'
|
||||
|
||||
import {
|
||||
configurableMCPTools,
|
||||
disabledMCPTools,
|
||||
setMCPToolEnabled
|
||||
} from '@/app/automation/mcp/preferences'
|
||||
import { mcpRuntime, refreshMCPRuntime, restartMCPRuntime } from '@/app/automation/mcp/runtime'
|
||||
import AppInput from '@/components/ui/AppInput.vue'
|
||||
import AppSwitch from '@/components/ui/AppSwitch.vue'
|
||||
|
||||
const { dialogs } = useI18n()
|
||||
const toolSearch = ref('')
|
||||
const disabledToolNames = computed(() => new Set(disabledMCPTools.value))
|
||||
const enabledToolCount = computed(
|
||||
() => configurableMCPTools.filter((tool) => !disabledToolNames.value.has(tool.name)).length
|
||||
)
|
||||
const visibleTools = computed(() => {
|
||||
const query = toolSearch.value.trim().toLowerCase()
|
||||
if (!query) return configurableMCPTools
|
||||
return configurableMCPTools.filter(
|
||||
(tool) =>
|
||||
tool.name.toLowerCase().includes(query) || tool.description.toLowerCase().includes(query)
|
||||
)
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
void refreshMCPRuntime()
|
||||
})
|
||||
|
||||
function restart(): void {
|
||||
void restartMCPRuntime()
|
||||
}
|
||||
|
||||
function isToolEnabled(name: string): boolean {
|
||||
return !disabledToolNames.value.has(name)
|
||||
}
|
||||
|
||||
function enableAllTools(): void {
|
||||
disabledMCPTools.value = []
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="flex flex-col gap-4" data-test-id="settings-mcp-automation-panel">
|
||||
<div>
|
||||
<h3 class="text-xs font-semibold text-surface">{{ dialogs.settingsMCP }}</h3>
|
||||
<p class="mt-1 text-[11px] text-muted">{{ dialogs.mcpDescription }}</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded border border-border bg-panel p-3 text-[11px]">
|
||||
<dl class="grid grid-cols-[7rem_1fr] gap-x-3 gap-y-2">
|
||||
<dt class="text-muted">{{ dialogs.mcpStatus }}</dt>
|
||||
<dd class="flex items-center gap-2 text-surface">
|
||||
<span
|
||||
class="size-2 rounded-full"
|
||||
:class="
|
||||
mcpRuntime.status === 'running'
|
||||
? 'bg-green-500'
|
||||
: mcpRuntime.status === 'error'
|
||||
? 'bg-red-500'
|
||||
: 'bg-muted'
|
||||
"
|
||||
/>
|
||||
{{ dialogs[`mcpStatus_${mcpRuntime.status}`] }}
|
||||
</dd>
|
||||
<dt class="text-muted">{{ dialogs.mcpPort }}</dt>
|
||||
<dd class="font-mono text-surface">{{ mcpRuntime.port }}</dd>
|
||||
<dt class="text-muted">{{ dialogs.mcpAddress }}</dt>
|
||||
<dd class="select-all font-mono text-surface">127.0.0.1</dd>
|
||||
<template v-if="mcpRuntime.version">
|
||||
<dt class="text-muted">{{ dialogs.mcpVersion }}</dt>
|
||||
<dd class="font-mono text-surface">{{ mcpRuntime.version }}</dd>
|
||||
</template>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-if="mcpRuntime.error"
|
||||
class="rounded border border-red-500/30 bg-red-500/10 p-3 text-[11px] text-red-400"
|
||||
>
|
||||
{{ mcpRuntime.error }}
|
||||
</p>
|
||||
|
||||
<div class="overflow-hidden rounded border border-border bg-panel">
|
||||
<div class="flex items-start justify-between gap-4 border-b border-border p-3">
|
||||
<div>
|
||||
<h4 class="text-[11px] font-medium text-surface">{{ dialogs.mcpTools }}</h4>
|
||||
<p class="mt-0.5 text-[10px] text-muted">
|
||||
{{
|
||||
dialogs.mcpToolsEnabled({
|
||||
enabled: enabledToolCount,
|
||||
total: configurableMCPTools.length
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
v-if="disabledMCPTools.length"
|
||||
type="button"
|
||||
class="text-[10px] text-accent hover:underline"
|
||||
@click="enableAllTools"
|
||||
>
|
||||
{{ dialogs.mcpEnableAllTools }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="border-b border-border p-2">
|
||||
<AppInput
|
||||
v-model="toolSearch"
|
||||
type="search"
|
||||
tone="panel"
|
||||
size="sm"
|
||||
:placeholder="dialogs.search"
|
||||
:aria-label="dialogs.mcpSearchTools"
|
||||
data-test-id="settings-mcp-tool-search"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ul class="max-h-72 divide-y divide-border overflow-y-auto">
|
||||
<li v-for="tool in visibleTools" :key="tool.name" class="flex items-start gap-3 p-2.5">
|
||||
<div class="min-w-0 flex-1">
|
||||
<code class="text-[10px] font-medium text-surface">{{ tool.name }}</code>
|
||||
<p class="mt-0.5 text-[10px] leading-relaxed text-muted">{{ tool.description }}</p>
|
||||
</div>
|
||||
<AppSwitch
|
||||
:model-value="isToolEnabled(tool.name)"
|
||||
:label="tool.name"
|
||||
:data-test-id="`settings-mcp-tool-${tool.name}`"
|
||||
@update:model-value="setMCPToolEnabled(tool.name, $event)"
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<p class="border-t border-border px-3 py-2 text-[10px] text-muted">
|
||||
{{ dialogs.mcpToolsRestartNotice }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded bg-accent px-3 py-1.5 text-[11px] font-medium text-white hover:bg-accent/90 disabled:opacity-50"
|
||||
:disabled="mcpRuntime.status === 'starting'"
|
||||
data-test-id="settings-mcp-restart"
|
||||
@click="restart"
|
||||
>
|
||||
{{
|
||||
mcpRuntime.status === 'starting' || mcpRuntime.checking
|
||||
? dialogs.mcpStarting
|
||||
: dialogs.mcpRestart
|
||||
}}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
|
@ -11,9 +11,8 @@ import { useKeyboard } from '@/app/shell/keyboard/use'
|
|||
import { loadEditorLayout, saveEditorLayout } from '@/app/shell/layout-storage'
|
||||
import { openFileFromPath, useEditorMenu } from '@/app/shell/menu/use'
|
||||
import { useCollab, COLLAB_KEY } from '@/app/collab/use'
|
||||
import { connectAutomation } from '@/app/automation/bridge/server'
|
||||
import { startMCPRuntime, stopMCPRuntime } from '@/app/automation/mcp/runtime'
|
||||
import { exposeCollaborationActions } from '@/app/browser-bridge'
|
||||
import { spawnMCPIfNeeded } from '@/app/automation/mcp/spawn'
|
||||
import { isTauri } from '@/app/tauri/env'
|
||||
import { appMenuShortcut } from '@/app/shell/menu/shortcut'
|
||||
import { createDemoShapes } from '@/app/demo/document'
|
||||
|
|
@ -66,8 +65,6 @@ useEventListener(
|
|||
{ passive: false }
|
||||
)
|
||||
|
||||
const automationCleanup = ref<(() => void) | null>(null)
|
||||
const mcpCleanup = ref<(() => void) | null>(null)
|
||||
const fileAssociationCleanup = ref<(() => void) | null>(null)
|
||||
const initialEditorLayout = loadEditorLayout()
|
||||
const horizontalSplitterStyles = tv(splitterTheme)({ direction: 'horizontal' })
|
||||
|
|
@ -94,12 +91,7 @@ async function bindAssociatedFileOpen() {
|
|||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const mcp = await spawnMCPIfNeeded()
|
||||
mcpCleanup.value = mcp?.disconnect ?? null
|
||||
const tauri = isTauri()
|
||||
if (import.meta.env.DEV || (tauri && mcp)) {
|
||||
automationCleanup.value = connectAutomation(getActiveStore, mcp?.authToken ?? null).disconnect
|
||||
}
|
||||
await startMCPRuntime(getActiveStore)
|
||||
|
||||
try {
|
||||
await bindAssociatedFileOpen()
|
||||
|
|
@ -109,8 +101,7 @@ onMounted(async () => {
|
|||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
mcpCleanup.value?.()
|
||||
automationCleanup.value?.()
|
||||
void stopMCPRuntime()
|
||||
fileAssociationCleanup.value?.()
|
||||
})
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -74,6 +74,33 @@ test('MCP connections keep bearer tokens out of ordinary settings', async ({ pag
|
|||
await expect(section).toContainText('No external MCP connections configured')
|
||||
})
|
||||
|
||||
test('MCP automation settings filter and persist tool availability', async ({ page }) => {
|
||||
await page.goto('/?test')
|
||||
const canvas = new CanvasHelper(page)
|
||||
await canvas.waitForInit()
|
||||
|
||||
await page.getByTestId('app-settings-trigger').click()
|
||||
await page.getByTestId('settings-section-mcp').click()
|
||||
|
||||
const search = page.getByTestId('settings-mcp-tool-search')
|
||||
await search.fill('create_shape')
|
||||
await expect(search).toHaveValue('create_shape')
|
||||
await expect(page.getByTestId('settings-mcp-tool-create_shape')).toBeVisible()
|
||||
await expect(page.getByTestId('settings-mcp-tool-get_page_tree')).toBeHidden()
|
||||
|
||||
await page.getByTestId('settings-mcp-tool-create_shape').click()
|
||||
await page.reload()
|
||||
await canvas.waitForInit()
|
||||
await page.getByTestId('app-settings-trigger').click()
|
||||
await page.getByTestId('settings-section-mcp').click()
|
||||
await expect(page.getByTestId('settings-mcp-tool-create_shape')).toHaveAttribute(
|
||||
'data-state',
|
||||
'unchecked'
|
||||
)
|
||||
|
||||
await page.getByRole('button', { name: 'Enable all' }).click()
|
||||
})
|
||||
|
||||
test('model library keeps reusable profiles and role assignments', async ({ page }) => {
|
||||
await page.goto('/?test')
|
||||
const canvas = new CanvasHelper(page)
|
||||
|
|
|
|||
|
|
@ -9,6 +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 {
|
||||
connectMockBrowser,
|
||||
|
|
@ -32,7 +33,7 @@ function testSocketPath(): string | null {
|
|||
return join(SOCKET_DIR, `mcp-test-${process.pid}-${++testCounter}.sock`)
|
||||
}
|
||||
|
||||
async function createTestClient() {
|
||||
async function createTestClient(disabledTools: string[] = []) {
|
||||
if (isUnix) await mkdir(SOCKET_DIR, { recursive: true })
|
||||
const authToken = 'test-client-token'
|
||||
const handle = await startServer({
|
||||
|
|
@ -40,6 +41,7 @@ async function createTestClient() {
|
|||
withTcp: true,
|
||||
socketPath: testSocketPath(),
|
||||
authToken,
|
||||
disabledTools,
|
||||
enableEval: false,
|
||||
mcpRoot: null
|
||||
})
|
||||
|
|
@ -134,13 +136,26 @@ describe('MCP server', () => {
|
|||
|
||||
test('lists all registered tools', async () => {
|
||||
const { tools } = await client.listTools()
|
||||
const names = tools.map((t) => t.name)
|
||||
expect(names).toContain('create_shape')
|
||||
expect(names).toContain('set_fill')
|
||||
const expectedNames = MCP_TOOL_CATALOG.filter((tool) => tool.requirement === 'always')
|
||||
.map((tool) => tool.name)
|
||||
.sort()
|
||||
expect(tools.map((tool) => tool.name).sort()).toEqual(expectedNames)
|
||||
})
|
||||
|
||||
test('omits tools disabled in the generic registration filter', async () => {
|
||||
if (cleanup) await cleanup()
|
||||
cleanup = null
|
||||
|
||||
const ctx = await createTestClient(['create_shape', 'list_documents'])
|
||||
client = ctx.client
|
||||
graph = ctx.graph
|
||||
cleanup = ctx.close
|
||||
|
||||
const { tools } = await client.listTools()
|
||||
const names = tools.map((tool) => tool.name)
|
||||
expect(names).not.toContain('create_shape')
|
||||
expect(names).not.toContain('list_documents')
|
||||
expect(names).toContain('get_page_tree')
|
||||
expect(names).toContain('render')
|
||||
expect(names).toContain('get_codegen_prompt')
|
||||
expect(tools.length).toBeGreaterThan(30)
|
||||
})
|
||||
|
||||
test('tools have descriptions and input schemas', async () => {
|
||||
|
|
|
|||
|
|
@ -143,6 +143,7 @@ describe('MCP stdio transport', () => {
|
|||
withTcp: true,
|
||||
socketPath: SOCKET_PATH,
|
||||
authToken: AUTH_TOKEN,
|
||||
disabledTools: ['rename_node'],
|
||||
enableEval: false,
|
||||
mcpRoot: null
|
||||
})
|
||||
|
|
@ -201,6 +202,7 @@ describe('MCP stdio transport', () => {
|
|||
expect(names).toContain('save_file')
|
||||
expect(names).toContain('list_documents')
|
||||
expect(names).toContain('get_codegen_prompt')
|
||||
expect(names).not.toContain('rename_node')
|
||||
const createShape = expectDefined(
|
||||
tools.find((tool) => tool.name === 'create_shape'),
|
||||
'create_shape tool'
|
||||
|
|
|
|||
Loading…
Reference in a new issue