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:
co-authored by
autofix-ci[bot]
LemonNeko
parent
7d934f9c67
commit
8f4909db3e
@@ -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'),
|
||||
|
||||
@@ -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:^",
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user