feat(mcp): add automation settings controls

This commit is contained in:
Victor Wads 2026-08-19 19:00:09 -03:00
parent 0abc4cc21b
commit b43da1ecc3
No known key found for this signature in database
20 changed files with 499 additions and 53 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View 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)
)
]
}

View file

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

View file

@ -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 ?? []
}
}
/**

View file

@ -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'],

View file

@ -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.',

View 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(',')
}

View 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')
})
)
}

View file

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

View file

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

View 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>

View file

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

View file

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

View file

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

View file

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