diff --git a/apps/stage-tamagotchi/electron.vite.config.ts b/apps/stage-tamagotchi/electron.vite.config.ts index 7d5649eb4..e8af3c675 100644 --- a/apps/stage-tamagotchi/electron.vite.config.ts +++ b/apps/stage-tamagotchi/electron.vite.config.ts @@ -172,6 +172,7 @@ export default defineConfig({ exclude: base => [ ...base, '**/settings/system/general.vue', + '**/settings/modules/mcp.vue', ], }, resolve(import.meta.dirname, 'src', 'renderer', 'pages'), diff --git a/apps/stage-tamagotchi/package.json b/apps/stage-tamagotchi/package.json index 63b0f4941..e25fd3ee3 100644 --- a/apps/stage-tamagotchi/package.json +++ b/apps/stage-tamagotchi/package.json @@ -48,6 +48,7 @@ "@formkit/auto-animate": "^0.9.0", "@guiiai/logg": "catalog:", "@huggingface/transformers": "^3.8.1", + "@modelcontextprotocol/sdk": "catalog:", "@moeru/eventa": "^1.0.0-beta.1", "@moeru/std": "catalog:", "@proj-airi/audio": "workspace:^", diff --git a/apps/stage-tamagotchi/src/main/index.ts b/apps/stage-tamagotchi/src/main/index.ts index e9839453c..46a2e69e6 100644 --- a/apps/stage-tamagotchi/src/main/index.ts +++ b/apps/stage-tamagotchi/src/main/index.ts @@ -16,6 +16,7 @@ import { openDebugger, setupDebugger } from './app/debugger' import { emitAppBeforeQuit, emitAppReady, emitAppWindowAllClosed } from './libs/bootkit/lifecycle' import { setElectronMainDirname } from './libs/electron/location' import { setupServerChannel } from './services/airi/channel-server' +import { setupMcpStdioManager } from './services/airi/mcp-servers' import { setupPluginHost } from './services/airi/plugins' import { setupAutoUpdater } from './services/electron/auto-updater' import { setupTray } from './tray' @@ -83,6 +84,10 @@ app.whenReady().then(async () => { build: async () => setupServerChannel(), }) + const mcpStdioManager = injeca.provide('modules:mcp-stdio-manager', { + build: async () => setupMcpStdioManager(), + }) + const pluginHost = injeca.provide('modules:plugin-host', { dependsOn: { serverChannel }, build: () => setupPluginHost(), @@ -105,17 +110,17 @@ app.whenReady().then(async () => { }) const chatWindow = injeca.provide('windows:chat', { - dependsOn: { widgetsManager, serverChannel }, + dependsOn: { widgetsManager, serverChannel, mcpStdioManager }, build: ({ dependsOn }) => setupChatWindowReusableFunc(dependsOn), }) const settingsWindow = injeca.provide('windows:settings', { - dependsOn: { widgetsManager, beatSync, autoUpdater, devtoolsMarkdownStressWindow, serverChannel }, + dependsOn: { widgetsManager, beatSync, autoUpdater, devtoolsMarkdownStressWindow, serverChannel, mcpStdioManager }, build: async ({ dependsOn }) => setupSettingsWindowReusableFunc(dependsOn), }) const mainWindow = injeca.provide('windows:main', { - dependsOn: { settingsWindow, chatWindow, widgetsManager, noticeWindow, beatSync, autoUpdater, serverChannel }, + dependsOn: { settingsWindow, chatWindow, widgetsManager, noticeWindow, beatSync, autoUpdater, serverChannel, mcpStdioManager }, build: async ({ dependsOn }) => setupMainWindow(dependsOn), }) @@ -130,7 +135,7 @@ app.whenReady().then(async () => { }) injeca.invoke({ - dependsOn: { mainWindow, tray, serverChannel, pluginHost }, + dependsOn: { mainWindow, tray, serverChannel, pluginHost, mcpStdioManager }, callback: noop, }) 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 new file mode 100644 index 000000000..01fa1b110 --- /dev/null +++ b/apps/stage-tamagotchi/src/main/services/airi/mcp-servers/index.ts @@ -0,0 +1,401 @@ +import type { createContext } from '@moeru/eventa/adapters/electron/main' + +import type { + ElectronMcpCallToolPayload, + ElectronMcpCallToolResult, + ElectronMcpStdioApplyResult, + ElectronMcpStdioConfigFile, + ElectronMcpStdioRuntimeStatus, + ElectronMcpStdioServerConfig, + ElectronMcpStdioServerRuntimeStatus, + ElectronMcpToolDescriptor, +} from '../../../../shared/eventa' + +import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { join } from 'node:path' + +import { useLogg } from '@guiiai/logg' +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, + electronMcpCallTool, + electronMcpGetRuntimeStatus, + electronMcpListTools, + electronMcpOpenConfigFile, +} from '../../../../shared/eventa' +import { onAppBeforeQuit } from '../../../libs/bootkit/lifecycle' + +interface McpServerSession { + client: Client + transport: StdioClientTransport + config: ElectronMcpStdioServerConfig +} + +export interface McpStdioManager { + ensureConfigFile: () => Promise<{ path: string }> + openConfigFile: () => Promise<{ path: string }> + applyAndRestart: () => Promise + listTools: () => Promise + callTool: (payload: ElectronMcpCallToolPayload) => Promise + stopAll: () => Promise + getRuntimeStatus: () => ElectronMcpStdioRuntimeStatus +} + +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 + +function stringifyError(error: unknown) { + if (error instanceof Error) { + return error.message + } + + return String(error) +} + +function getConfigPath() { + return join(app.getPath('userData'), 'mcp.json') +} + +function parseQualifiedToolName(name: string) { + const separatorIndex = name.indexOf(toolNameSeparator) + if (separatorIndex <= 0 || separatorIndex === name.length - toolNameSeparator.length) { + throw new Error(`invalid qualified tool name: ${name}`) + } + + return { + serverName: name.slice(0, separatorIndex), + toolName: name.slice(separatorIndex + toolNameSeparator.length), + } +} + +function resolveFallbackToolName(toolName: string): string | undefined { + const normalizedTransportPrefix = toolName + .replace(/^\.(?:stdio|stdo)::/, '') + .replace(/^(?:stdio|stdo)::/, '') + if (normalizedTransportPrefix !== toolName) { + return normalizedTransportPrefix + } + + const lastSeparatorIndex = toolName.lastIndexOf(toolNameSeparator) + if (lastSeparatorIndex <= 0 || lastSeparatorIndex === toolName.length - toolNameSeparator.length) { + return undefined + } + + return toolName.slice(lastSeparatorIndex + toolNameSeparator.length) +} + +async function closeSession(session: McpServerSession) { + try { + await session.client.close() + } + catch { + await session.transport.close() + } +} + +export function createMcpStdioManager(): McpStdioManager { + const log = useLogg('main/mcp-stdio').useGlobalConfig() + const sessions = new Map() + const runtimeStatuses = new Map() + let updatedAt = Date.now() + + const setRuntimeStatus = (status: ElectronMcpStdioServerRuntimeStatus) => { + runtimeStatuses.set(status.name, status) + updatedAt = Date.now() + } + + const ensureConfigFile = async () => { + const path = getConfigPath() + await mkdir(app.getPath('userData'), { recursive: true }) + + try { + await readFile(path, 'utf-8') + } + catch { + await writeFile(path, `${JSON.stringify(defaultMcpConfig, null, 2)}\n`) + } + + return { path } + } + + const openConfigFile = async () => { + const { path } = await ensureConfigFile() + const openResult = await shell.openPath(path) + if (openResult) { + throw new Error(openResult) + } + 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 + } + + const stopAll = async () => { + const entries = [...sessions.entries()] + for (const [name, session] of entries) { + await closeSession(session) + setRuntimeStatus({ + name, + state: 'stopped', + command: session.config.command, + args: session.config.args ?? [], + pid: null, + }) + sessions.delete(name) + } + } + + const startServer = async (name: string, config: ElectronMcpStdioServerConfig) => { + const transport = new StdioClientTransport({ + command: config.command, + args: config.args ?? [], + env: config.env, + cwd: config.cwd, + stderr: 'pipe', + }) + const client = new Client({ + name: `proj-airi:stage-tamagotchi:mcp:${name}`, + version: app.getVersion(), + }) + + try { + await client.connect(transport) + transport.stderr?.on('data', (data) => { + const text = data.toString('utf-8').trim() + if (text) { + log.withFields({ serverName: name }).warn(text) + } + }) + sessions.set(name, { client, transport, config }) + setRuntimeStatus({ + name, + state: 'running', + command: config.command, + args: config.args ?? [], + pid: transport.pid, + }) + } + catch (error) { + await transport.close().catch(() => {}) + throw error + } + } + + const applyAndRestart = async (): Promise => { + const { path } = await ensureConfigFile() + const config = await readConfigFile(path) + + await stopAll() + runtimeStatuses.clear() + + const result: ElectronMcpStdioApplyResult = { + path, + started: [], + failed: [], + skipped: [], + } + + for (const [name, server] of Object.entries(config.mcpServers)) { + if (server.enabled === false) { + result.skipped.push({ name, reason: 'disabled' }) + setRuntimeStatus({ + name, + state: 'stopped', + command: server.command, + args: server.args ?? [], + pid: null, + }) + continue + } + + try { + await startServer(name, server) + result.started.push({ name }) + } + catch (error) { + const message = stringifyError(error) + result.failed.push({ name, error: message }) + setRuntimeStatus({ + name, + state: 'error', + command: server.command, + args: server.args ?? [], + pid: null, + lastError: message, + }) + } + } + + updatedAt = Date.now() + + return result + } + + const listTools = async (): Promise => { + const entries = [...sessions.entries()].sort(([left], [right]) => left.localeCompare(right)) + const listResult = await Promise.all(entries.map(async ([serverName, session]) => { + try { + const response = await session.client.listTools(undefined, { + timeout: mcpRequestTimeoutMsec, + maxTotalTimeout: mcpRequestMaxTotalTimeoutMsec, + }) + return response.tools.map(item => ({ + serverName, + name: `${serverName}${toolNameSeparator}${item.name}`, + toolName: item.name, + description: item.description, + inputSchema: item.inputSchema, + })) + } + catch (error) { + log.withFields({ serverName }).withError(error).warn('failed to list tools from mcp server') + return [] + } + })) + + return listResult.flat() + } + + const callTool = async (payload: ElectronMcpCallToolPayload): Promise => { + const { serverName, toolName } = parseQualifiedToolName(payload.name) + const session = sessions.get(serverName) + if (!session) { + throw new Error(`mcp server is not running: ${serverName}`) + } + + let result + try { + result = await session.client.callTool({ + name: toolName, + arguments: payload.arguments ?? {}, + }, undefined, { + timeout: mcpRequestTimeoutMsec, + maxTotalTimeout: mcpRequestMaxTotalTimeoutMsec, + }) + } + catch (error) { + const fallbackToolName = resolveFallbackToolName(toolName) + if (!fallbackToolName || fallbackToolName === toolName) { + throw error + } + + log.withFields({ + serverName, + requestedToolName: toolName, + fallbackToolName, + }).warn('retrying mcp tool call with normalized tool name') + + result = await session.client.callTool({ + name: fallbackToolName, + arguments: payload.arguments ?? {}, + }, undefined, { + timeout: mcpRequestTimeoutMsec, + maxTotalTimeout: mcpRequestMaxTotalTimeoutMsec, + }) + } + + const normalized: ElectronMcpCallToolResult = {} + if ('content' in result && Array.isArray(result.content)) { + normalized.content = result.content as Array> + } + if ('structuredContent' in result && result.structuredContent && typeof result.structuredContent === 'object' && !Array.isArray(result.structuredContent)) { + normalized.structuredContent = result.structuredContent as Record + } + if ('isError' in result && typeof result.isError === 'boolean') { + normalized.isError = result.isError + } + if ('toolResult' in result) { + normalized.toolResult = result.toolResult + } + + return normalized + } + + const getRuntimeStatus = (): ElectronMcpStdioRuntimeStatus => { + return { + path: getConfigPath(), + servers: [...runtimeStatuses.values()].sort((left, right) => left.name.localeCompare(right.name)), + updatedAt, + } + } + + return { + ensureConfigFile, + openConfigFile, + applyAndRestart, + listTools, + callTool, + stopAll, + getRuntimeStatus, + } +} + +export async function setupMcpStdioManager() { + const log = useLogg('main/mcp-stdio').useGlobalConfig() + const manager = createMcpStdioManager() + + onAppBeforeQuit(async () => { + await manager.stopAll() + }) + + await manager.ensureConfigFile() + + try { + await manager.applyAndRestart() + } + catch (error) { + log.withError(error).warn('failed to apply mcp stdio config during startup') + } + + return manager +} + +export function createMcpServersService(params: { context: ReturnType['context'], manager: McpStdioManager }) { + defineInvokeHandler(params.context, electronMcpOpenConfigFile, async () => { + return params.manager.openConfigFile() + }) + + defineInvokeHandler(params.context, electronMcpApplyAndRestart, async () => { + return params.manager.applyAndRestart() + }) + + defineInvokeHandler(params.context, electronMcpGetRuntimeStatus, async () => { + return params.manager.getRuntimeStatus() + }) + + defineInvokeHandler(params.context, electronMcpListTools, async () => { + return params.manager.listTools() + }) + + defineInvokeHandler(params.context, electronMcpCallTool, async (payload) => { + return params.manager.callTool(payload) + }) +} diff --git a/apps/stage-tamagotchi/src/main/windows/chat/index.ts b/apps/stage-tamagotchi/src/main/windows/chat/index.ts index 04a3b32c0..35800d40e 100644 --- a/apps/stage-tamagotchi/src/main/windows/chat/index.ts +++ b/apps/stage-tamagotchi/src/main/windows/chat/index.ts @@ -1,4 +1,5 @@ import type { ServerChannel } from '../../services/airi/channel-server' +import type { McpStdioManager } from '../../services/airi/mcp-servers' import type { WidgetsWindowManager } from '../widgets' import { join, resolve } from 'node:path' @@ -14,6 +15,7 @@ import { setupChatWindowElectronInvokes } from './rpc/index.electron' export function setupChatWindowReusableFunc(params: { widgetsManager: WidgetsWindowManager serverChannel: ServerChannel + mcpStdioManager: McpStdioManager }) { return createReusableWindow(async () => { const window = new BrowserWindow({ @@ -40,6 +42,7 @@ export function setupChatWindowReusableFunc(params: { window, widgetsManager: params.widgetsManager, serverChannel: params.serverChannel, + mcpStdioManager: params.mcpStdioManager, }) return window diff --git a/apps/stage-tamagotchi/src/main/windows/chat/rpc/index.electron.ts b/apps/stage-tamagotchi/src/main/windows/chat/rpc/index.electron.ts index 49bbde01b..368216d6a 100644 --- a/apps/stage-tamagotchi/src/main/windows/chat/rpc/index.electron.ts +++ b/apps/stage-tamagotchi/src/main/windows/chat/rpc/index.electron.ts @@ -1,6 +1,7 @@ import type { BrowserWindow } from 'electron' import type { ServerChannel } from '../../../services/airi/channel-server' +import type { McpStdioManager } from '../../../services/airi/mcp-servers' import type { WidgetsWindowManager } from '../../widgets' import { defineInvokeHandler } from '@moeru/eventa' @@ -9,6 +10,7 @@ import { ipcMain } from 'electron' import { electronOpenMainDevtools } from '../../../../shared/eventa' import { createServerChannelService } from '../../../services/airi/channel-server' +import { createMcpServersService } from '../../../services/airi/mcp-servers' import { createWidgetsService } from '../../../services/airi/widgets' import { createScreenService, createWindowService } from '../../../services/electron' @@ -16,6 +18,7 @@ export function setupChatWindowElectronInvokes(params: { window: BrowserWindow widgetsManager: WidgetsWindowManager serverChannel: ServerChannel + mcpStdioManager: McpStdioManager }) { // TODO: once we refactored eventa to support window-namespaced contexts, // we can remove the setMaxListeners call below since eventa will be able to dispatch and @@ -28,6 +31,7 @@ export function setupChatWindowElectronInvokes(params: { createWindowService({ context, window: params.window }) createWidgetsService({ context, widgetsManager: params.widgetsManager, window: params.window }) createServerChannelService({ serverChannel: params.serverChannel }) + createMcpServersService({ context, manager: params.mcpStdioManager }) defineInvokeHandler(context, electronOpenMainDevtools, () => params.window.webContents.openDevTools({ mode: 'detach' })) } diff --git a/apps/stage-tamagotchi/src/main/windows/main/index.ts b/apps/stage-tamagotchi/src/main/windows/main/index.ts index 7c31a6c79..aa87193c1 100644 --- a/apps/stage-tamagotchi/src/main/windows/main/index.ts +++ b/apps/stage-tamagotchi/src/main/windows/main/index.ts @@ -2,6 +2,7 @@ import type { Rectangle } from 'electron' import type { InferOutput } from 'valibot' import type { ServerChannel } from '../../services/airi/channel-server' +import type { McpStdioManager } from '../../services/airi/mcp-servers' import type { AutoUpdater } from '../../services/electron/auto-updater' import type { NoticeWindowManager } from '../notice' import type { WidgetsWindowManager } from '../widgets' @@ -50,6 +51,7 @@ export async function setupMainWindow(params: { autoUpdater: AutoUpdater onWindowCreated?: (window: BrowserWindow) => void serverChannel: ServerChannel + mcpStdioManager: McpStdioManager }) { const { setup: setupConfig, @@ -161,6 +163,7 @@ export async function setupMainWindow(params: { noticeWindow: params.noticeWindow, autoUpdater: params.autoUpdater, serverChannel: params.serverChannel, + mcpStdioManager: params.mcpStdioManager, }) /** diff --git a/apps/stage-tamagotchi/src/main/windows/main/rpc/index.electron.ts b/apps/stage-tamagotchi/src/main/windows/main/rpc/index.electron.ts index edd16baaa..e3dc76b52 100644 --- a/apps/stage-tamagotchi/src/main/windows/main/rpc/index.electron.ts +++ b/apps/stage-tamagotchi/src/main/windows/main/rpc/index.electron.ts @@ -1,6 +1,7 @@ import type { BrowserWindow } from 'electron' import type { ServerChannel } from '../../../services/airi/channel-server' +import type { McpStdioManager } from '../../../services/airi/mcp-servers' import type { AutoUpdater } from '../../../services/electron/auto-updater' import type { NoticeWindowManager } from '../../notice' import type { WidgetsWindowManager } from '../../widgets' @@ -11,6 +12,7 @@ import { ipcMain } from 'electron' import { electronOpenChat, electronOpenMainDevtools, electronOpenSettings, noticeWindowEventa } from '../../../../shared/eventa' import { createServerChannelService } from '../../../services/airi/channel-server' +import { createMcpServersService } from '../../../services/airi/mcp-servers' import { createWidgetsService } from '../../../services/airi/widgets' import { createAutoUpdaterService } from '../../../services/electron' import { toggleWindowShow } from '../../shared' @@ -24,6 +26,7 @@ export function setupMainWindowElectronInvokes(params: { noticeWindow: NoticeWindowManager autoUpdater: AutoUpdater serverChannel: ServerChannel + mcpStdioManager: McpStdioManager }) { // TODO: once we refactored eventa to support window-namespaced contexts, // we can remove the setMaxListeners call below since eventa will be able to dispatch and @@ -36,6 +39,7 @@ export function setupMainWindowElectronInvokes(params: { createWidgetsService({ context, widgetsManager: params.widgetsManager, window: params.window }) createAutoUpdaterService({ context, window: params.window, service: params.autoUpdater }) createServerChannelService({ serverChannel: params.serverChannel }) + createMcpServersService({ context, manager: params.mcpStdioManager }) defineInvokeHandler(context, electronOpenMainDevtools, () => params.window.webContents.openDevTools({ mode: 'detach' })) defineInvokeHandler(context, electronOpenSettings, async () => toggleWindowShow(await params.settingsWindow())) diff --git a/apps/stage-tamagotchi/src/main/windows/settings/index.ts b/apps/stage-tamagotchi/src/main/windows/settings/index.ts index 34e4f0187..abf7c83c9 100644 --- a/apps/stage-tamagotchi/src/main/windows/settings/index.ts +++ b/apps/stage-tamagotchi/src/main/windows/settings/index.ts @@ -1,4 +1,5 @@ import type { ServerChannel } from '../../services/airi/channel-server' +import type { McpStdioManager } from '../../services/airi/mcp-servers' import type { AutoUpdater } from '../../services/electron/auto-updater' import type { DevtoolsWindowManager } from '../devtools' import type { WidgetsWindowManager } from '../widgets' @@ -20,6 +21,7 @@ export function setupSettingsWindowReusableFunc(params: { devtoolsMarkdownStressWindow: DevtoolsWindowManager onWindowCreated?: (window: BrowserWindow) => void serverChannel: ServerChannel + mcpStdioManager: McpStdioManager }) { return createReusableWindow(async () => { const window = new BrowserWindow({ @@ -51,6 +53,7 @@ export function setupSettingsWindowReusableFunc(params: { autoUpdater: params.autoUpdater, devtoolsMarkdownStressWindow: params.devtoolsMarkdownStressWindow, serverChannel: params.serverChannel, + mcpStdioManager: params.mcpStdioManager, }) initScreenCaptureForWindow(window) diff --git a/apps/stage-tamagotchi/src/main/windows/settings/rpc/index.electron.ts b/apps/stage-tamagotchi/src/main/windows/settings/rpc/index.electron.ts index 7df14261b..0cb7da594 100644 --- a/apps/stage-tamagotchi/src/main/windows/settings/rpc/index.electron.ts +++ b/apps/stage-tamagotchi/src/main/windows/settings/rpc/index.electron.ts @@ -1,6 +1,7 @@ import type { BrowserWindow } from 'electron' import type { ServerChannel } from '../../../services/airi/channel-server' +import type { McpStdioManager } from '../../../services/airi/mcp-servers' import type { AutoUpdater } from '../../../services/electron/auto-updater' import type { DevtoolsWindowManager } from '../../devtools' import type { WidgetsWindowManager } from '../../widgets' @@ -11,6 +12,7 @@ import { ipcMain } from 'electron' import { electronOpenDevtoolsWindow, electronOpenSettingsDevtools } from '../../../../shared/eventa' import { createServerChannelService } from '../../../services/airi/channel-server' +import { createMcpServersService } from '../../../services/airi/mcp-servers' import { createWidgetsService } from '../../../services/airi/widgets' import { createAutoUpdaterService, createScreenService, createWindowService } from '../../../services/electron' @@ -20,6 +22,7 @@ export async function setupSettingsWindowInvokes(params: { autoUpdater: AutoUpdater devtoolsMarkdownStressWindow: DevtoolsWindowManager serverChannel: ServerChannel + mcpStdioManager: McpStdioManager }) { // TODO: once we refactored eventa to support window-namespaced contexts, // we can remove the setMaxListeners call below since eventa will be able to dispatch and @@ -33,6 +36,7 @@ export async function setupSettingsWindowInvokes(params: { createWidgetsService({ context, widgetsManager: params.widgetsManager, window: params.settingsWindow }) createAutoUpdaterService({ context, window: params.settingsWindow, service: params.autoUpdater }) createServerChannelService({ serverChannel: params.serverChannel }) + createMcpServersService({ context, manager: params.mcpStdioManager }) defineInvokeHandler(context, electronOpenSettingsDevtools, async () => params.settingsWindow.webContents.openDevTools({ mode: 'detach' })) defineInvokeHandler(context, electronOpenDevtoolsWindow, async (payload) => { diff --git a/apps/stage-tamagotchi/src/renderer/App.vue b/apps/stage-tamagotchi/src/renderer/App.vue index 9d8146080..86a3c9f90 100644 --- a/apps/stage-tamagotchi/src/renderer/App.vue +++ b/apps/stage-tamagotchi/src/renderer/App.vue @@ -8,6 +8,7 @@ import { useCharacterOrchestratorStore } from '@proj-airi/stage-ui/stores/charac import { useChatSessionStore } from '@proj-airi/stage-ui/stores/chat/session-store' import { usePluginHostInspectorStore } from '@proj-airi/stage-ui/stores/devtools/plugin-host-debug' import { useDisplayModelsStore } from '@proj-airi/stage-ui/stores/display-models' +import { clearMcpToolBridge, setMcpToolBridge } from '@proj-airi/stage-ui/stores/mcp-tool-bridge' import { useModsServerChannelStore } from '@proj-airi/stage-ui/stores/mods/api/channel-server' import { useContextBridgeStore } from '@proj-airi/stage-ui/stores/mods/api/context-bridge' import { useAiriCardStore } from '@proj-airi/stage-ui/stores/modules/airi-card' @@ -26,6 +27,8 @@ import ResizeHandler from './components/ResizeHandler.vue' import { electronGetServerChannelConfig, + electronMcpCallTool, + electronMcpListTools, electronOpenSettings, electronPluginInspect, electronPluginList, @@ -58,6 +61,36 @@ const analyticsStore = useSharedAnalyticsStore() const pluginHostInspectorStore = usePluginHostInspectorStore() usePerfTracerBridgeStore() +const context = useElectronEventaContext() +const getServerChannelConfig = useElectronEventaInvoke(electronGetServerChannelConfig) +const listPlugins = useElectronEventaInvoke(electronPluginList) +const setPluginEnabled = useElectronEventaInvoke(electronPluginSetEnabled) +const loadEnabledPlugins = useElectronEventaInvoke(electronPluginLoadEnabled) +const loadPlugin = useElectronEventaInvoke(electronPluginLoad) +const unloadPlugin = useElectronEventaInvoke(electronPluginUnload) +const inspectPluginHost = useElectronEventaInvoke(electronPluginInspect) +const startTrackingCursorPoint = useElectronEventaInvoke(electronStartTrackMousePosition) +const reportPluginCapability = useElectronEventaInvoke(electronPluginUpdateCapability) +const listMcpTools = useElectronEventaInvoke(electronMcpListTools) +const callMcpTool = useElectronEventaInvoke(electronMcpCallTool) + +// NOTICE: register plugin host bridge during setup to avoid race with pages using it in immediate watchers. +pluginHostInspectorStore.setBridge({ + list: () => listPlugins(), + setEnabled: payload => setPluginEnabled(payload), + loadEnabled: () => loadEnabledPlugins(), + load: payload => loadPlugin(payload), + unload: payload => unloadPlugin(payload), + inspect: () => inspectPluginHost(), +}) + +// NOTICE: MCP tools are declared from stage-ui and executed during model streaming. +// Register runtime bridge during setup to avoid missing bridge in early tool invocations. +setMcpToolBridge({ + listTools: () => listMcpTools(), + callTool: payload => callMcpTool(payload), +}) + watch(language, () => { i18n.locale.value = language.value }) @@ -68,25 +101,6 @@ watch(route, () => updateThemeColor(), { immediate: true }) onMounted(() => updateThemeColor()) onMounted(async () => { - const context = useElectronEventaContext() - const getServerChannelConfig = useElectronEventaInvoke(electronGetServerChannelConfig) - const listPlugins = useElectronEventaInvoke(electronPluginList) - const setPluginEnabled = useElectronEventaInvoke(electronPluginSetEnabled) - const loadEnabledPlugins = useElectronEventaInvoke(electronPluginLoadEnabled) - const loadPlugin = useElectronEventaInvoke(electronPluginLoad) - const unloadPlugin = useElectronEventaInvoke(electronPluginUnload) - const inspectPluginHost = useElectronEventaInvoke(electronPluginInspect) - - // NOTICE: register plugin host bridge before long async startup work so devtools pages can use it immediately. - pluginHostInspectorStore.setBridge({ - list: () => listPlugins(), - setEnabled: payload => setPluginEnabled(payload), - loadEnabled: () => loadEnabledPlugins(), - load: payload => loadPlugin(payload), - unload: payload => unloadPlugin(payload), - inspect: () => inspectPluginHost(), - }) - analyticsStore.initialize() cardStore.initialize() onboardingStore.initializeSetupCheck() @@ -101,9 +115,6 @@ onMounted(async () => { await serverChannelStore.initialize({ possibleEvents: ['ui:configure'] }).catch(err => console.error('Failed to initialize Mods Server Channel in App.vue:', err)) await contextBridgeStore.initialize() characterOrchestratorStore.initialize() - - const startTrackingCursorPoint = useElectronEventaInvoke(electronStartTrackMousePosition) - const reportPluginCapability = useElectronEventaInvoke(electronPluginUpdateCapability) await startTrackingCursorPoint() // Expose stage provider definitions to plugin host APIs. @@ -131,7 +142,10 @@ watch(themeColorsHueDynamic, () => { document.documentElement.classList.toggle('dynamic-hue', themeColorsHueDynamic.value) }, { immediate: true }) -onUnmounted(() => contextBridgeStore.dispose()) +onUnmounted(() => { + contextBridgeStore.dispose() + clearMcpToolBridge() +})