From 81dbd71c3172a04231f7c38fda378844f4266640 Mon Sep 17 00:00:00 2001 From: Doji Date: Wed, 6 May 2026 03:39:50 +0800 Subject: [PATCH] feat(stage-tamagotchi,stage-ui): rewrite mcp server settings page and normalize tool registration (#1722) --- .../services/airi/mcp-servers/index.test.ts | 89 +++ .../main/services/airi/mcp-servers/index.ts | 138 ++++- .../components/McpConnectionTestPanel.vue | 79 +++ .../modules/components/McpJsonEditor.vue | 60 ++ .../modules/components/McpServerForm.vue | 74 +++ .../pages/settings/modules/mcp-config.test.ts | 115 ++++ .../pages/settings/modules/mcp-config.ts | 161 +++++ .../renderer/pages/settings/modules/mcp.vue | 571 ++++++++++++++---- .../stores/tools/builtin/widgets.test.ts | 11 +- .../renderer/stores/tools/builtin/widgets.ts | 192 +++--- .../src/shared/eventa/index.ts | 20 + .../stage-tamagotchi/src/shared/mcp-config.ts | 124 ++++ packages/i18n/src/locales/en/settings.yaml | 88 ++- .../i18n/src/locales/zh-Hans/settings.yaml | 85 ++- packages/stage-ui/src/stores/llm.test.ts | 4 +- 15 files changed, 1577 insertions(+), 234 deletions(-) create mode 100644 apps/stage-tamagotchi/src/main/services/airi/mcp-servers/index.test.ts create mode 100644 apps/stage-tamagotchi/src/renderer/pages/settings/modules/components/McpConnectionTestPanel.vue create mode 100644 apps/stage-tamagotchi/src/renderer/pages/settings/modules/components/McpJsonEditor.vue create mode 100644 apps/stage-tamagotchi/src/renderer/pages/settings/modules/components/McpServerForm.vue create mode 100644 apps/stage-tamagotchi/src/renderer/pages/settings/modules/mcp-config.test.ts create mode 100644 apps/stage-tamagotchi/src/renderer/pages/settings/modules/mcp-config.ts create mode 100644 apps/stage-tamagotchi/src/shared/mcp-config.ts diff --git a/apps/stage-tamagotchi/src/main/services/airi/mcp-servers/index.test.ts b/apps/stage-tamagotchi/src/main/services/airi/mcp-servers/index.test.ts new file mode 100644 index 000000000..1508d6013 --- /dev/null +++ b/apps/stage-tamagotchi/src/main/services/airi/mcp-servers/index.test.ts @@ -0,0 +1,89 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const appMock = vi.hoisted(() => ({ + getPath: vi.fn(), + getVersion: vi.fn(), +})) + +const shellMock = vi.hoisted(() => ({ + showItemInFolder: vi.fn(), +})) + +const clientMocks = vi.hoisted(() => ({ + close: vi.fn(), + connect: vi.fn(), + listTools: vi.fn(), +})) + +vi.mock('electron', () => ({ + app: appMock, + shell: shellMock, +})) + +vi.mock('@guiiai/logg', () => ({ + useLogg: vi.fn(() => ({ + useGlobalConfig: () => ({ + debug: vi.fn(), + warn: vi.fn(), + withError: vi.fn(() => ({ warn: vi.fn() })), + withFields: vi.fn(() => ({ debug: vi.fn(), warn: vi.fn() })), + }), + })), +})) + +vi.mock('../../../libs/bootkit/lifecycle', () => ({ + onAppBeforeQuit: vi.fn(), +})) + +vi.mock('@modelcontextprotocol/sdk/client/index.js', () => ({ + Client: class { + close = clientMocks.close + connect = clientMocks.connect + listTools = clientMocks.listTools + }, +})) + +vi.mock('@modelcontextprotocol/sdk/client/stdio.js', async () => { + const { PassThrough } = await import('node:stream') + + return { + StdioClientTransport: class { + stderr = new PassThrough() + + constructor(readonly server: unknown) {} + + close = vi.fn(async () => undefined) + }, + } +}) + +describe('createMcpStdioManager', () => { + beforeEach(() => { + vi.clearAllMocks() + appMock.getPath.mockReturnValue('/tmp/airi-user-data') + appMock.getVersion.mockReturnValue('0.10.0') + clientMocks.close.mockResolvedValue(undefined) + clientMocks.listTools.mockResolvedValue({ tools: [] }) + }) + + it('includes stderr captured during connect failures in MCP server test results', async () => { + const { createMcpStdioManager } = await import('./index') + const manager = createMcpStdioManager() + + clientMocks.connect.mockImplementationOnce(async (transport: { stderr: NodeJS.WritableStream }) => { + transport.stderr.write('Missing required environment variable: API_KEY\n') + throw new Error('connect failed') + }) + + const result = await manager.testServer({ + name: 'broken-server', + config: { + command: 'broken-mcp-server', + }, + }) + + expect(result.ok).toBe(false) + expect(result.error).toContain('connect failed') + expect(result.error).toContain('Missing required environment variable: API_KEY') + }) +}) diff --git a/apps/stage-tamagotchi/src/main/services/airi/mcp-servers/index.ts b/apps/stage-tamagotchi/src/main/services/airi/mcp-servers/index.ts index 01fa1b110..e6ed20685 100644 --- a/apps/stage-tamagotchi/src/main/services/airi/mcp-servers/index.ts +++ b/apps/stage-tamagotchi/src/main/services/airi/mcp-servers/index.ts @@ -5,9 +5,12 @@ import type { ElectronMcpCallToolResult, ElectronMcpStdioApplyResult, ElectronMcpStdioConfigFile, + ElectronMcpStdioConfigText, ElectronMcpStdioRuntimeStatus, ElectronMcpStdioServerConfig, ElectronMcpStdioServerRuntimeStatus, + ElectronMcpStdioTestPayload, + ElectronMcpStdioTestResult, ElectronMcpToolDescriptor, } from '../../../../shared/eventa' @@ -19,7 +22,6 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js' import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js' import { defineInvokeHandler } from '@moeru/eventa' import { app, shell } from 'electron' -import { z } from 'zod' import { electronMcpApplyAndRestart, @@ -27,7 +29,11 @@ import { electronMcpGetRuntimeStatus, electronMcpListTools, electronMcpOpenConfigFile, + electronMcpReadConfigText, + electronMcpTestServer, + electronMcpWriteConfigText, } from '../../../../shared/eventa' +import { parseElectronMcpConfigText } from '../../../../shared/mcp-config' import { onAppBeforeQuit } from '../../../libs/bootkit/lifecycle' interface McpServerSession { @@ -44,26 +50,18 @@ export interface McpStdioManager { callTool: (payload: ElectronMcpCallToolPayload) => Promise stopAll: () => Promise getRuntimeStatus: () => ElectronMcpStdioRuntimeStatus + readConfigText: () => Promise + writeConfigText: (text: string) => Promise + testServer: (payload: ElectronMcpStdioTestPayload) => Promise } -const mcpServerConfigSchema = z.object({ - command: z.string().min(1), - args: z.array(z.string()).optional(), - env: z.record(z.string(), z.string()).optional(), - cwd: z.string().optional(), - enabled: z.boolean().optional(), -}).strict() - -const mcpConfigSchema = z.object({ - mcpServers: z.record(z.string(), mcpServerConfigSchema), -}).strict() - const defaultMcpConfig: ElectronMcpStdioConfigFile = { mcpServers: {}, } const toolNameSeparator = '::' const mcpRequestTimeoutMsec = 10_000 const mcpRequestMaxTotalTimeoutMsec = 15_000 +const mcpTestStderrMaxChars = 16_000 function stringifyError(error: unknown) { if (error instanceof Error) { @@ -141,21 +139,13 @@ export function createMcpStdioManager(): McpStdioManager { const openConfigFile = async () => { const { path } = await ensureConfigFile() - const openResult = await shell.openPath(path) - if (openResult) { - throw new Error(openResult) - } + shell.showItemInFolder(path) return { path } } const readConfigFile = async (path: string): Promise => { const raw = await readFile(path, 'utf-8') - const parsed = JSON.parse(raw) as unknown - const validated = mcpConfigSchema.safeParse(parsed) - if (!validated.success) { - throw new Error(validated.error.issues.map(issue => issue.message).join('; ')) - } - return validated.data + return parseElectronMcpConfigText(raw) } const stopAll = async () => { @@ -347,6 +337,93 @@ export function createMcpStdioManager(): McpStdioManager { } } + const readConfigText = async (): Promise => { + const { path } = await ensureConfigFile() + const text = await readFile(path, 'utf-8') + return { path, text } + } + + const writeConfigText = async (text: string): Promise => { + const { path } = await ensureConfigFile() + const validated = parseElectronMcpConfigText(text) + const normalized = `${JSON.stringify(validated, null, 2)}\n` + await writeFile(path, normalized) + return { path, text: normalized } + } + + const testServer = async (payload: ElectronMcpStdioTestPayload): Promise => { + const startedAt = Date.now() + let transport: StdioClientTransport | null = null + let client: Client | null = null + const stderrChunks: string[] = [] + + const withDeadline = (promise: Promise, ms: number, label: string): Promise => { + let timer: NodeJS.Timeout | undefined + const timeout = new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms) + }) + return Promise.race([promise, timeout]).finally(() => { + if (timer) + clearTimeout(timer) + }) + } + + try { + transport = new StdioClientTransport({ + command: payload.config.command, + args: payload.config.args ?? [], + env: payload.config.env, + cwd: payload.config.cwd, + stderr: 'pipe', + }) + client = new Client({ + name: `proj-airi:stage-tamagotchi:mcp:test:${payload.name}`, + version: app.getVersion(), + }) + + transport.stderr?.on('data', (data) => { + const text = data.toString('utf-8') + if (text) + stderrChunks.push(text) + }) + + await withDeadline(client.connect(transport), mcpRequestMaxTotalTimeoutMsec, 'connect') + + const response = await client.listTools(undefined, { + timeout: mcpRequestTimeoutMsec, + maxTotalTimeout: mcpRequestMaxTotalTimeoutMsec, + }) + + if (stderrChunks.length > 0) { + log.withFields({ serverName: payload.name }).debug(stderrChunks.join('').trim()) + } + + return { + ok: true, + tools: response.tools.map(tool => tool.name), + durationMs: Date.now() - startedAt, + } + } + catch (error) { + const message = stringifyError(error) + // Keep only the tail so a noisy failed server cannot flood the settings UI. + const stderr = stderrChunks.join('').trim().slice(-mcpTestStderrMaxChars) + return { + ok: false, + error: stderr ? `${message}\n\n${stderr}` : message, + durationMs: Date.now() - startedAt, + } + } + finally { + if (client) { + await client.close().catch(() => {}) + } + if (transport) { + await transport.close().catch(() => {}) + } + } + } + return { ensureConfigFile, openConfigFile, @@ -355,6 +432,9 @@ export function createMcpStdioManager(): McpStdioManager { callTool, stopAll, getRuntimeStatus, + readConfigText, + writeConfigText, + testServer, } } @@ -398,4 +478,16 @@ export function createMcpServersService(params: { context: ReturnType { return params.manager.callTool(payload) }) + + defineInvokeHandler(params.context, electronMcpReadConfigText, async () => { + return params.manager.readConfigText() + }) + + defineInvokeHandler(params.context, electronMcpWriteConfigText, async (payload) => { + return params.manager.writeConfigText(payload.text) + }) + + defineInvokeHandler(params.context, electronMcpTestServer, async (payload) => { + return params.manager.testServer(payload) + }) } diff --git a/apps/stage-tamagotchi/src/renderer/pages/settings/modules/components/McpConnectionTestPanel.vue b/apps/stage-tamagotchi/src/renderer/pages/settings/modules/components/McpConnectionTestPanel.vue new file mode 100644 index 000000000..7ba12be47 --- /dev/null +++ b/apps/stage-tamagotchi/src/renderer/pages/settings/modules/components/McpConnectionTestPanel.vue @@ -0,0 +1,79 @@ + + + diff --git a/apps/stage-tamagotchi/src/renderer/pages/settings/modules/components/McpJsonEditor.vue b/apps/stage-tamagotchi/src/renderer/pages/settings/modules/components/McpJsonEditor.vue new file mode 100644 index 000000000..20bdea2ba --- /dev/null +++ b/apps/stage-tamagotchi/src/renderer/pages/settings/modules/components/McpJsonEditor.vue @@ -0,0 +1,60 @@ + + +