feat(stage-*): basic mcp implementation (#1115)

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: LemonNeko <17664845+lemonnekogh@users.noreply.github.com>
This commit is contained in:
Doji
2026-03-05 00:01:33 +08:00
committed by GitHub
co-authored by autofix-ci[bot] LemonNeko
parent 7d934f9c67
commit 8f4909db3e
21 changed files with 810 additions and 68 deletions
@@ -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'),
+1
View File
@@ -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:^",
+9 -4
View File
@@ -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,
})
@@ -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<ElectronMcpStdioApplyResult>
listTools: () => Promise<ElectronMcpToolDescriptor[]>
callTool: (payload: ElectronMcpCallToolPayload) => Promise<ElectronMcpCallToolResult>
stopAll: () => Promise<void>
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<string, McpServerSession>()
const runtimeStatuses = new Map<string, ElectronMcpStdioServerRuntimeStatus>()
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<ElectronMcpStdioConfigFile> => {
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<ElectronMcpStdioApplyResult> => {
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<ElectronMcpToolDescriptor[]> => {
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<ElectronMcpToolDescriptor>(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<ElectronMcpCallToolResult> => {
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<Record<string, unknown>>
}
if ('structuredContent' in result && result.structuredContent && typeof result.structuredContent === 'object' && !Array.isArray(result.structuredContent)) {
normalized.structuredContent = result.structuredContent as Record<string, unknown>
}
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<typeof createContext>['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)
})
}
@@ -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
@@ -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' }))
}
@@ -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,
})
/**
@@ -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()))
@@ -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)
@@ -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) => {
+37 -23
View File
@@ -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()
})
</script>
<template>
@@ -0,0 +1,179 @@
<script setup lang="ts">
import type { ElectronMcpStdioRuntimeStatus } from '../../../../shared/eventa'
import { useElectronEventaInvoke } from '@proj-airi/electron-vueuse'
import { Button } from '@proj-airi/ui'
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import {
electronMcpApplyAndRestart,
electronMcpGetRuntimeStatus,
electronMcpOpenConfigFile,
} from '../../../../shared/eventa'
const { t } = useI18n()
const openConfigFile = useElectronEventaInvoke(electronMcpOpenConfigFile)
const applyAndRestart = useElectronEventaInvoke(electronMcpApplyAndRestart)
const getRuntimeStatus = useElectronEventaInvoke(electronMcpGetRuntimeStatus)
const isBusy = ref(false)
const status = ref<ElectronMcpStdioRuntimeStatus>()
const lastActionMessage = ref('')
const errorMessage = ref('')
const configPath = computed(() => status.value?.path ?? '')
async function refreshStatus() {
status.value = await getRuntimeStatus()
}
async function handleOpenConfigFile() {
isBusy.value = true
errorMessage.value = ''
try {
const result = await openConfigFile()
await refreshStatus()
lastActionMessage.value = t('settings.pages.modules.mcp-server.messages.opened', { path: result.path })
}
catch (error) {
errorMessage.value = error instanceof Error ? error.message : String(error)
}
finally {
isBusy.value = false
}
}
async function handleApplyAndRestart() {
isBusy.value = true
errorMessage.value = ''
try {
const result = await applyAndRestart()
await refreshStatus()
lastActionMessage.value = t('settings.pages.modules.mcp-server.messages.restarted', {
started: result.started.length,
failed: result.failed.length,
skipped: result.skipped.length,
})
}
catch (error) {
errorMessage.value = error instanceof Error ? error.message : String(error)
}
finally {
isBusy.value = false
}
}
onMounted(async () => {
await refreshStatus()
})
</script>
<template>
<div
:class="[
'rounded-xl p-4 md:p-6',
'border border-neutral-200/70 bg-neutral-50 dark:border-neutral-700 dark:bg-neutral-900/40',
'flex flex-col gap-4',
]"
>
<div class="flex flex-col gap-1">
<h2 class="text-lg font-semibold md:text-xl">
{{ t('settings.pages.modules.mcp-server.title') }}
</h2>
<p class="text-sm text-neutral-500">
{{ t('settings.pages.modules.mcp-server.description') }}
</p>
</div>
<div :class="['text-xs text-neutral-500', 'break-all']">
<span class="font-medium">{{ t('settings.pages.modules.mcp-server.config-path') }}:</span>
{{ configPath || '-' }}
</div>
<div :class="['flex flex-wrap gap-2']">
<Button
:disabled="isBusy"
variant="secondary"
@click="handleOpenConfigFile"
>
{{ t('settings.pages.modules.mcp-server.actions.open-config') }}
</Button>
<Button
:disabled="isBusy"
@click="handleApplyAndRestart"
>
{{ t('settings.pages.modules.mcp-server.actions.apply-and-restart') }}
</Button>
</div>
<div
v-if="lastActionMessage"
:class="[
'rounded-md border border-emerald-200 bg-emerald-50 px-3 py-2',
'text-sm text-emerald-700 dark:border-emerald-500/40 dark:bg-emerald-500/10 dark:text-emerald-300',
]"
>
{{ lastActionMessage }}
</div>
<div
v-if="errorMessage"
:class="[
'rounded-md border border-red-200 bg-red-50 px-3 py-2',
'text-sm text-red-700 dark:border-red-500/40 dark:bg-red-500/10 dark:text-red-300',
]"
>
{{ errorMessage }}
</div>
<div
v-if="status?.servers?.length"
:class="[
'rounded-md border border-neutral-200/80 bg-white/80 p-3',
'dark:border-neutral-700 dark:bg-neutral-950/50',
]"
>
<div class="mb-2 text-sm text-neutral-500">
{{ t('settings.pages.modules.mcp-server.runtime-title') }}
</div>
<ul class="space-y-2">
<li
v-for="server in status.servers"
:key="server.name"
:class="[
'rounded px-2 py-1 text-sm',
server.state === 'running'
? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-500/10 dark:text-emerald-300'
: server.state === 'error'
? 'bg-red-50 text-red-700 dark:bg-red-500/10 dark:text-red-300'
: 'bg-neutral-100 text-neutral-600 dark:bg-neutral-800/70 dark:text-neutral-300',
]"
>
<div class="font-medium">
{{ server.name }} ({{ server.state }})
</div>
<div class="text-xs opacity-80">
{{ server.command }} {{ server.args.join(' ') }}
</div>
<div v-if="server.lastError" class="text-xs">
{{ server.lastError }}
</div>
</li>
</ul>
</div>
</div>
</template>
<route lang="yaml">
meta:
layout: settings
titleKey: settings.pages.modules.mcp-server.title
subtitleKey: settings.title
stageTransition:
name: slide
</route>
@@ -126,6 +126,66 @@ export interface PluginHostDebugSnapshot {
refreshedAt: number
}
export interface ElectronMcpStdioServerConfig {
command: string
args?: string[]
env?: Record<string, string>
cwd?: string
enabled?: boolean
}
export interface ElectronMcpStdioConfigFile {
mcpServers: Record<string, ElectronMcpStdioServerConfig>
}
export interface ElectronMcpStdioApplyResult {
path: string
started: Array<{ name: string }>
failed: Array<{ name: string, error: string }>
skipped: Array<{ name: string, reason: string }>
}
export interface ElectronMcpStdioServerRuntimeStatus {
name: string
state: 'running' | 'stopped' | 'error'
command: string
args: string[]
pid: number | null
lastError?: string
}
export interface ElectronMcpStdioRuntimeStatus {
path: string
servers: ElectronMcpStdioServerRuntimeStatus[]
updatedAt: number
}
export interface ElectronMcpToolDescriptor {
serverName: string
name: string
toolName: string
description?: string
inputSchema: Record<string, unknown>
}
export interface ElectronMcpCallToolPayload {
name: string
arguments?: Record<string, unknown>
}
export interface ElectronMcpCallToolResult {
content?: Array<Record<string, unknown>>
structuredContent?: Record<string, unknown>
toolResult?: unknown
isError?: boolean
}
export const electronMcpOpenConfigFile = defineInvokeEventa<{ path: string }>('eventa:invoke:electron:mcp:open-config-file')
export const electronMcpApplyAndRestart = defineInvokeEventa<ElectronMcpStdioApplyResult>('eventa:invoke:electron:mcp:apply-and-restart')
export const electronMcpGetRuntimeStatus = defineInvokeEventa<ElectronMcpStdioRuntimeStatus>('eventa:invoke:electron:mcp:get-runtime-status')
export const electronMcpListTools = defineInvokeEventa<ElectronMcpToolDescriptor[]>('eventa:invoke:electron:mcp:list-tools')
export const electronMcpCallTool = defineInvokeEventa<ElectronMcpCallToolResult, ElectronMcpCallToolPayload>('eventa:invoke:electron:mcp:call-tool')
export const widgetsOpenWindow = defineInvokeEventa<void, { id?: string }>('eventa:invoke:electron:windows:widgets:open')
export const widgetsAdd = defineInvokeEventa<string | undefined, WidgetsAddPayload>('eventa:invoke:electron:windows:widgets:add')
export const widgetsRemove = defineInvokeEventa<void, { id: string }>('eventa:invoke:electron:windows:widgets:remove')