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:
authored and GitHub committed 2026-03-05 00:01:33 +08:00
1 parent 7d934f9c67
commit 8f4909db3e
21 files changed
+810 -68

No files matched your search

@@ -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')
@@ -435,6 +435,14 @@ pages:
mcp-server:
description: Connect and manage MCP server and tools
title: MCP Server
config-path: Config file path
runtime-title: Runtime status
actions:
open-config: Open mcp.json
apply-and-restart: Save and restart stdio MCP
messages:
opened: Config file opened at {path}
restarted: MCP servers restarted. Started {started}, failed {failed}, skipped {skipped}
providers:
explained:
chat: Text generation model providers. e.g. OpenRouter, OpenAI, Ollama.
@@ -420,6 +420,14 @@ pages:
mcp-server:
description: 连接和管理 MCP 服务器及工具
title: MCP 服务器
config-path: 配置文件路径
runtime-title: 运行状态
actions:
open-config: 打开 mcp.json
apply-and-restart: 保存并重启 MCP
messages:
opened: 已打开配置文件:{path}
restarted: 已重启 MCP 服务。成功 {started},失败 {failed},跳过 {skipped}
providers:
explained:
chat: 文本生成模型服务来源,例如 OpenRouter, OpenAI, Ollama
-1
View File
@@ -69,7 +69,6 @@
"@proj-airi/stage-shared": "workspace:^",
"@proj-airi/stage-ui-live2d": "workspace:^",
"@proj-airi/stage-ui-three": "workspace:^",
"@proj-airi/tauri-plugin-mcp": "workspace:^",
"@proj-airi/ui": "workspace:^",
"@ricky0123/vad-web": "^0.0.30",
"@shikijs/rehype": "^3.23.0",
+3
View File
@@ -293,6 +293,9 @@ export const useChatOrchestratorStore = defineStore('chat-orchestrator', () => {
await llmStore.stream(options.model, options.chatProvider, newMessages as Message[], {
headers,
tools: options.tools,
// NOTICE: xsai stream may emit `finish` before tool steps continue, so keep waiting until
// the final non-tool finish to avoid ending the chat turn with no assistant reply.
waitForTools: true,
onStreamEvent: async (event: StreamEvent) => {
switch (event.type) {
case 'tool-call':
@@ -0,0 +1,42 @@
export interface McpToolDescriptor {
serverName: string
name: string
toolName: string
description?: string
inputSchema: Record<string, unknown>
}
export interface McpCallToolPayload {
name: string
arguments?: Record<string, unknown>
}
export interface McpCallToolResult {
content?: Array<Record<string, unknown>>
structuredContent?: Record<string, unknown>
toolResult?: unknown
isError?: boolean
}
interface McpToolBridge {
listTools: () => Promise<McpToolDescriptor[]>
callTool: (payload: McpCallToolPayload) => Promise<McpCallToolResult>
}
let bridge: McpToolBridge | undefined
export function setMcpToolBridge(nextBridge: McpToolBridge) {
bridge = nextBridge
}
export function clearMcpToolBridge() {
bridge = undefined
}
export function getMcpToolBridge(): McpToolBridge {
if (!bridge) {
throw new Error('MCP tool bridge is not available in this runtime.')
}
return bridge
}
-2
View File
@@ -9,8 +9,6 @@ describe('tools mcp schema', () => {
const tools = await mcp()
const toolNames = [
'mcp_list_tools',
'mcp_connect_server',
'mcp_disconnect_server',
'mcp_call_tool',
]
+37 -35
View File
@@ -1,34 +1,20 @@
import { callTool, connectServer, disconnectServer, listTools } from '@proj-airi/tauri-plugin-mcp'
import { tool } from '@xsai/tool'
import { z } from 'zod'
import { getMcpToolBridge } from '../stores/mcp-tool-bridge'
const tools = [
tool({
name: 'mcp_list_tools',
description: 'List all tools available on the MCP server',
description: 'List all tools available on the connected MCP servers',
execute: async (_, __) => {
return await listTools()
},
parameters: z.object({}).strict(),
}),
tool({
name: 'mcp_connect_server',
description: 'Connect to the MCP server. If "success", the connection to the MCP server is successful. Otherwise, the connection fails.',
execute: async ({ command, args }) => {
await connectServer(command, args)
return 'success'
},
parameters: z.object({
command: z.string().describe('The command to connect to the MCP server'),
args: z.array(z.string()).describe('The arguments to pass to the MCP server'),
}).strict(),
}),
tool({
name: 'mcp_disconnect_server',
description: 'Disconnect from the MCP server. If "success", the disconnection from the MCP server is successful. Otherwise, the disconnection fails.',
execute: async () => {
await disconnectServer()
return 'success'
try {
return await getMcpToolBridge().listTools()
}
catch (error) {
console.warn('[mcp_list_tools] failed to list tools:', error)
return []
}
},
parameters: z.object({}).strict(),
}),
@@ -36,23 +22,39 @@ const tools = [
name: 'mcp_call_tool',
description: 'Call a tool on the MCP server. The result is a list of content and a boolean indicating whether the tool call is an error.',
execute: async ({ name, parameters }) => {
const parametersObject = Object.fromEntries(parameters.map(({ name, value }) => [name, value]))
const result = await callTool(name, parametersObject)
return result satisfies {
content: {
type: string
text: string
}[]
isError: boolean
try {
const parametersObject = Object.fromEntries(parameters.map(({ name, value }) => [name, value]))
const result = await getMcpToolBridge().callTool({
name,
arguments: parametersObject,
})
return result satisfies {
content?: Record<string, unknown>[]
isError?: boolean
structuredContent?: Record<string, unknown>
toolResult?: unknown
}
}
catch (error) {
const message = error instanceof Error ? error.message : String(error)
return {
isError: true,
content: [
{
type: 'text',
text: message,
},
],
}
}
},
parameters: z.object({
name: z.string().describe('The name of the tool to call'),
name: z.string().describe('The qualified tool name to call. Use format "<serverName>::<toolName>"'),
parameters: z.array(z.object({
name: z.string().describe('The name of the parameter'),
value: z.union([z.string(), z.number(), z.boolean(), z.object({}).strict()]).describe('The value of the parameter, it can be a string, a number, a boolean, or an object'),
value: z.unknown().describe('The value of the parameter'),
}).strict()).describe('The parameters to pass to the tool'),
}),
}).strict(),
}),
]
+3 -3
View File
@@ -920,6 +920,9 @@ importers:
'@huggingface/transformers':
specifier: ^3.8.1
version: 3.8.1
'@modelcontextprotocol/sdk':
specifier: 'catalog:'
version: 1.27.1(@cfworker/json-schema@4.1.1)(zod@4.3.6)
'@moeru/eventa':
specifier: ^1.0.0-beta.1
version: 1.0.0-beta.1(electron@40.6.1)(h3@2.0.1-rc.5(crossws@0.4.4(srvx@0.11.8)))
@@ -2442,9 +2445,6 @@ importers:
'@proj-airi/stage-ui-three':
specifier: workspace:^
version: link:../stage-ui-three
'@proj-airi/tauri-plugin-mcp':
specifier: workspace:^
version: link:../tauri-plugin-mcp
'@proj-airi/ui':
specifier: workspace:^
version: link:../ui