feat(stage-tamagotchi,stage-ui): rewrite mcp server settings page and normalize tool registration (#1722)

This commit is contained in:
Doji
2026-05-06 03:39:50 +08:00
committed by GitHub
parent 5205f369bc
commit 81dbd71c31
15 changed files with 1577 additions and 234 deletions
@@ -0,0 +1,89 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const appMock = vi.hoisted(() => ({
getPath: vi.fn(),
getVersion: vi.fn(),
}))
const shellMock = vi.hoisted(() => ({
showItemInFolder: vi.fn(),
}))
const clientMocks = vi.hoisted(() => ({
close: vi.fn(),
connect: vi.fn(),
listTools: vi.fn(),
}))
vi.mock('electron', () => ({
app: appMock,
shell: shellMock,
}))
vi.mock('@guiiai/logg', () => ({
useLogg: vi.fn(() => ({
useGlobalConfig: () => ({
debug: vi.fn(),
warn: vi.fn(),
withError: vi.fn(() => ({ warn: vi.fn() })),
withFields: vi.fn(() => ({ debug: vi.fn(), warn: vi.fn() })),
}),
})),
}))
vi.mock('../../../libs/bootkit/lifecycle', () => ({
onAppBeforeQuit: vi.fn(),
}))
vi.mock('@modelcontextprotocol/sdk/client/index.js', () => ({
Client: class {
close = clientMocks.close
connect = clientMocks.connect
listTools = clientMocks.listTools
},
}))
vi.mock('@modelcontextprotocol/sdk/client/stdio.js', async () => {
const { PassThrough } = await import('node:stream')
return {
StdioClientTransport: class {
stderr = new PassThrough()
constructor(readonly server: unknown) {}
close = vi.fn(async () => undefined)
},
}
})
describe('createMcpStdioManager', () => {
beforeEach(() => {
vi.clearAllMocks()
appMock.getPath.mockReturnValue('/tmp/airi-user-data')
appMock.getVersion.mockReturnValue('0.10.0')
clientMocks.close.mockResolvedValue(undefined)
clientMocks.listTools.mockResolvedValue({ tools: [] })
})
it('includes stderr captured during connect failures in MCP server test results', async () => {
const { createMcpStdioManager } = await import('./index')
const manager = createMcpStdioManager()
clientMocks.connect.mockImplementationOnce(async (transport: { stderr: NodeJS.WritableStream }) => {
transport.stderr.write('Missing required environment variable: API_KEY\n')
throw new Error('connect failed')
})
const result = await manager.testServer({
name: 'broken-server',
config: {
command: 'broken-mcp-server',
},
})
expect(result.ok).toBe(false)
expect(result.error).toContain('connect failed')
expect(result.error).toContain('Missing required environment variable: API_KEY')
})
})
@@ -5,9 +5,12 @@ import type {
ElectronMcpCallToolResult,
ElectronMcpStdioApplyResult,
ElectronMcpStdioConfigFile,
ElectronMcpStdioConfigText,
ElectronMcpStdioRuntimeStatus,
ElectronMcpStdioServerConfig,
ElectronMcpStdioServerRuntimeStatus,
ElectronMcpStdioTestPayload,
ElectronMcpStdioTestResult,
ElectronMcpToolDescriptor,
} from '../../../../shared/eventa'
@@ -19,7 +22,6 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js'
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
import { defineInvokeHandler } from '@moeru/eventa'
import { app, shell } from 'electron'
import { z } from 'zod'
import {
electronMcpApplyAndRestart,
@@ -27,7 +29,11 @@ import {
electronMcpGetRuntimeStatus,
electronMcpListTools,
electronMcpOpenConfigFile,
electronMcpReadConfigText,
electronMcpTestServer,
electronMcpWriteConfigText,
} from '../../../../shared/eventa'
import { parseElectronMcpConfigText } from '../../../../shared/mcp-config'
import { onAppBeforeQuit } from '../../../libs/bootkit/lifecycle'
interface McpServerSession {
@@ -44,26 +50,18 @@ export interface McpStdioManager {
callTool: (payload: ElectronMcpCallToolPayload) => Promise<ElectronMcpCallToolResult>
stopAll: () => Promise<void>
getRuntimeStatus: () => ElectronMcpStdioRuntimeStatus
readConfigText: () => Promise<ElectronMcpStdioConfigText>
writeConfigText: (text: string) => Promise<ElectronMcpStdioConfigText>
testServer: (payload: ElectronMcpStdioTestPayload) => Promise<ElectronMcpStdioTestResult>
}
const mcpServerConfigSchema = z.object({
command: z.string().min(1),
args: z.array(z.string()).optional(),
env: z.record(z.string(), z.string()).optional(),
cwd: z.string().optional(),
enabled: z.boolean().optional(),
}).strict()
const mcpConfigSchema = z.object({
mcpServers: z.record(z.string(), mcpServerConfigSchema),
}).strict()
const defaultMcpConfig: ElectronMcpStdioConfigFile = {
mcpServers: {},
}
const toolNameSeparator = '::'
const mcpRequestTimeoutMsec = 10_000
const mcpRequestMaxTotalTimeoutMsec = 15_000
const mcpTestStderrMaxChars = 16_000
function stringifyError(error: unknown) {
if (error instanceof Error) {
@@ -141,21 +139,13 @@ export function createMcpStdioManager(): McpStdioManager {
const openConfigFile = async () => {
const { path } = await ensureConfigFile()
const openResult = await shell.openPath(path)
if (openResult) {
throw new Error(openResult)
}
shell.showItemInFolder(path)
return { path }
}
const readConfigFile = async (path: string): Promise<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
return parseElectronMcpConfigText(raw)
}
const stopAll = async () => {
@@ -347,6 +337,93 @@ export function createMcpStdioManager(): McpStdioManager {
}
}
const readConfigText = async (): Promise<ElectronMcpStdioConfigText> => {
const { path } = await ensureConfigFile()
const text = await readFile(path, 'utf-8')
return { path, text }
}
const writeConfigText = async (text: string): Promise<ElectronMcpStdioConfigText> => {
const { path } = await ensureConfigFile()
const validated = parseElectronMcpConfigText(text)
const normalized = `${JSON.stringify(validated, null, 2)}\n`
await writeFile(path, normalized)
return { path, text: normalized }
}
const testServer = async (payload: ElectronMcpStdioTestPayload): Promise<ElectronMcpStdioTestResult> => {
const startedAt = Date.now()
let transport: StdioClientTransport | null = null
let client: Client | null = null
const stderrChunks: string[] = []
const withDeadline = <V>(promise: Promise<V>, ms: number, label: string): Promise<V> => {
let timer: NodeJS.Timeout | undefined
const timeout = new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms)
})
return Promise.race([promise, timeout]).finally(() => {
if (timer)
clearTimeout(timer)
})
}
try {
transport = new StdioClientTransport({
command: payload.config.command,
args: payload.config.args ?? [],
env: payload.config.env,
cwd: payload.config.cwd,
stderr: 'pipe',
})
client = new Client({
name: `proj-airi:stage-tamagotchi:mcp:test:${payload.name}`,
version: app.getVersion(),
})
transport.stderr?.on('data', (data) => {
const text = data.toString('utf-8')
if (text)
stderrChunks.push(text)
})
await withDeadline(client.connect(transport), mcpRequestMaxTotalTimeoutMsec, 'connect')
const response = await client.listTools(undefined, {
timeout: mcpRequestTimeoutMsec,
maxTotalTimeout: mcpRequestMaxTotalTimeoutMsec,
})
if (stderrChunks.length > 0) {
log.withFields({ serverName: payload.name }).debug(stderrChunks.join('').trim())
}
return {
ok: true,
tools: response.tools.map(tool => tool.name),
durationMs: Date.now() - startedAt,
}
}
catch (error) {
const message = stringifyError(error)
// Keep only the tail so a noisy failed server cannot flood the settings UI.
const stderr = stderrChunks.join('').trim().slice(-mcpTestStderrMaxChars)
return {
ok: false,
error: stderr ? `${message}\n\n${stderr}` : message,
durationMs: Date.now() - startedAt,
}
}
finally {
if (client) {
await client.close().catch(() => {})
}
if (transport) {
await transport.close().catch(() => {})
}
}
}
return {
ensureConfigFile,
openConfigFile,
@@ -355,6 +432,9 @@ export function createMcpStdioManager(): McpStdioManager {
callTool,
stopAll,
getRuntimeStatus,
readConfigText,
writeConfigText,
testServer,
}
}
@@ -398,4 +478,16 @@ export function createMcpServersService(params: { context: ReturnType<typeof cre
defineInvokeHandler(params.context, electronMcpCallTool, async (payload) => {
return params.manager.callTool(payload)
})
defineInvokeHandler(params.context, electronMcpReadConfigText, async () => {
return params.manager.readConfigText()
})
defineInvokeHandler(params.context, electronMcpWriteConfigText, async (payload) => {
return params.manager.writeConfigText(payload.text)
})
defineInvokeHandler(params.context, electronMcpTestServer, async (payload) => {
return params.manager.testServer(payload)
})
}
@@ -0,0 +1,79 @@
<script setup lang="ts">
import type { ElectronMcpStdioTestResult } from '../../../../../shared/eventa'
import { Button, Callout, FieldSelect } from '@proj-airi/ui'
import { useI18n } from 'vue-i18n'
interface TestOption {
label: string
value: string
}
const props = defineProps<{
options: TestOption[]
result?: ElectronMcpStdioTestResult
running: boolean
}>()
const emit = defineEmits<{
test: []
}>()
const selectedRowId = defineModel<string>({ required: true })
const { t } = useI18n()
const tn = (key: string, params?: Record<string, unknown>) => t(`settings.pages.modules.mcp-server.${key}`, params ?? {})
const PANEL = 'flex flex-col gap-3 rounded-xl border-2 border-solid border-neutral-100 bg-white p-4 md:p-5 dark:border-neutral-900 dark:bg-neutral-900/30'
</script>
<template>
<section :class="PANEL">
<div flex="~ col gap-1">
<h3 class="text-sm font-semibold">
{{ tn('test.title') }}
</h3>
<p class="text-xs text-neutral-500 dark:text-neutral-400">
{{ tn('test.description') }}
</p>
</div>
<div class="flex flex-wrap items-end gap-2">
<FieldSelect
v-model="selectedRowId"
layout="vertical"
class="min-w-60 flex-1"
:label="tn('test.pick-server-label')"
:options="props.options"
:placeholder="tn('test.no-servers')"
:disabled="!props.options.length"
/>
<Button
variant="secondary"
size="md"
:loading="props.running"
:disabled="props.running || !props.options.length"
icon="i-solar:plug-circle-bold-duotone"
:label="tn('actions.test')"
@click="emit('test')"
/>
</div>
<Callout
v-if="props.result"
:theme="props.result.ok ? 'lime' : 'orange'"
:label="props.result.ok
? tn('test.success', { count: props.result.tools?.length ?? 0, ms: props.result.durationMs })
: tn('test.failure', { ms: props.result.durationMs })"
>
<div v-if="!props.result.ok && props.result.error" class="break-all text-xs">
{{ props.result.error }}
</div>
<div v-if="props.result.ok && props.result.tools?.length" class="flex flex-wrap gap-1 text-xs">
<span v-for="name in props.result.tools" :key="name" class="rounded-md bg-emerald-500/15 px-2 py-0.5 font-mono">
{{ name }}
</span>
</div>
</Callout>
</section>
</template>
@@ -0,0 +1,60 @@
<script setup lang="ts">
import { Button, Callout } from '@proj-airi/ui'
import { useI18n } from 'vue-i18n'
const props = defineProps<{
error: string
}>()
const emit = defineEmits<{
apply: []
close: []
format: []
openConfig: []
resetDraft: []
}>()
const draft = defineModel<string>({ required: true })
const { t } = useI18n()
const tn = (key: string) => t(`settings.pages.modules.mcp-server.${key}`)
const JSON_TEXTAREA = 'w-full rounded-lg border-2 border-solid border-primary-100 bg-white px-3 py-2 text-sm font-mono leading-relaxed shadow-sm outline-none transition-all duration-200 ease-in-out focus:border-primary-300 focus:bg-white dark:border-primary-900/60 dark:bg-neutral-950 dark:focus:border-primary-400/50 dark:focus:bg-neutral-900'
</script>
<template>
<section class="flex flex-col gap-3 border-2 border-primary-200 rounded-xl border-solid bg-primary-50/40 p-4 dark:border-primary-900/60 dark:bg-primary-900/10 md:p-5">
<div class="flex flex-wrap items-start justify-between gap-2">
<div flex="~ col gap-1">
<h3 class="text-sm font-semibold">
{{ tn('json.title') }}
</h3>
<p class="text-xs text-neutral-500 dark:text-neutral-400">
{{ tn('json.description') }}
</p>
</div>
<div class="flex flex-wrap items-center gap-2">
<Button variant="ghost" size="sm" icon="i-solar:folder-open-bold-duotone" :label="tn('actions.open-config')" @click="emit('openConfig')" />
<Button variant="ghost" size="sm" icon="i-solar:restart-line-duotone" :label="tn('actions.reset-draft')" @click="emit('resetDraft')" />
<Button variant="ghost" size="sm" icon="i-solar:magic-stick-3-bold-duotone" :label="tn('actions.format-json')" @click="emit('format')" />
</div>
</div>
<textarea
v-model="draft"
rows="18"
spellcheck="false"
:placeholder="tn('json.placeholder')"
:class="JSON_TEXTAREA"
/>
<Callout v-if="props.error" theme="orange" :label="tn('error-title')">
{{ props.error }}
</Callout>
<div class="flex flex-wrap items-center justify-end gap-2 pt-1">
<Button variant="secondary" size="sm" :label="tn('actions.cancel')" @click="emit('close')" />
<Button variant="primary" size="sm" icon="i-solar:check-circle-bold-duotone" :label="tn('actions.apply-json')" @click="emit('apply')" />
</div>
</section>
</template>
@@ -0,0 +1,74 @@
<script setup lang="ts">
import type { ServerForm } from '../mcp-config'
import { Button, FieldInput, FieldKeyValues } from '@proj-airi/ui'
import { useI18n } from 'vue-i18n'
defineEmits<{ remove: [] }>()
const model = defineModel<ServerForm>({ required: true })
const { t } = useI18n()
const tn = (k: string) => t(`settings.pages.modules.mcp-server.${k}`)
</script>
<template>
<div flex="~ col gap-4">
<FieldInput
v-model="model.identifier"
:label="tn('fields.identifier.label')"
:description="tn('fields.identifier.description')"
:placeholder="tn('fields.identifier.placeholder')"
required
/>
<FieldInput
v-model="model.command"
:label="tn('fields.command.label')"
:description="tn('fields.command.description')"
:placeholder="tn('fields.command.placeholder')"
required
/>
<FieldInput
v-model="model.argsText"
:single-line="false"
:label="tn('fields.args.label')"
:description="tn('fields.args.description')"
:placeholder="tn('fields.args.placeholder')"
input-class="font-mono"
/>
<FieldInput
v-model="model.cwd"
:label="tn('fields.cwd.label')"
:description="tn('fields.cwd.description')"
:placeholder="tn('fields.cwd.placeholder')"
input-class="font-mono"
:required="false"
/>
<div flex="~ col gap-2">
<FieldKeyValues
v-model="model.envEntries"
:label="tn('fields.env.label')"
:description="tn('fields.env.description')"
:key-placeholder="tn('fields.env.key-placeholder')"
:value-placeholder="tn('fields.env.value-placeholder')"
:required="false"
@remove="(i) => model.envEntries.splice(i, 1)"
/>
<div class="flex justify-end">
<Button
variant="ghost" size="sm"
icon="i-solar:add-circle-bold-duotone" :label="tn('actions.add-env')"
@click="model.envEntries.push({ key: '', value: '' })"
/>
</div>
</div>
<div class="flex justify-end border-t border-neutral-200/70 pt-2 dark:border-neutral-800">
<Button
variant="danger" size="sm"
icon="i-solar:trash-bin-2-bold-duotone" :label="tn('actions.remove')"
@click="$emit('remove')"
/>
</div>
</div>
</template>
@@ -0,0 +1,115 @@
import { errorMessageFrom } from '@moeru/std'
import { describe, expect, it } from 'vitest'
import { parseElectronMcpConfigText } from '../../../../shared/mcp-config'
import {
buildConfigFile,
buildServerConfig,
findServerIdentifierByRowId,
loadServerForms,
syncJsonDraftFromServers,
} from './mcp-config'
function translateMessage(key: string, params?: Record<string, unknown>) {
if (params?.name)
return `${key}:${String(params.name)}`
if (params?.index)
return `${key}:${String(params.index)}`
return key
}
describe('mcp-config helpers', () => {
it('preserves the selected server identity when rows are reloaded', () => {
const config = {
mcpServers: {
filesystem: { command: 'npx', args: ['-y', '@modelcontextprotocol/server-filesystem'] },
github: { command: 'npx', args: ['-y', '@modelcontextprotocol/server-github'] },
},
}
const initialLoad = loadServerForms(config)
const selectedRowId = initialLoad.servers[1]!.rowId
const selectedIdentifier = findServerIdentifierByRowId(initialLoad.servers, selectedRowId)
const reloaded = loadServerForms(config, { selectedIdentifier })
expect(selectedIdentifier).toBe('github')
expect(reloaded.selectedRowId).not.toBe(selectedRowId)
expect(reloaded.servers.find(server => server.rowId === reloaded.selectedRowId)?.identifier).toBe('github')
})
it('keeps cwd when converting form rows into MCP config', () => {
const server = {
rowId: 'mcp-static',
identifier: 'filesystem',
command: ' npx ',
argsText: '-y\n@modelcontextprotocol/server-filesystem',
envEntries: [{ key: ' ROOT ', value: '/tmp' }],
cwd: ' /Users/doji/dojiwork/airi ',
enabled: true,
}
expect(buildServerConfig(server)).toEqual({
command: 'npx',
args: ['-y', '@modelcontextprotocol/server-filesystem'],
env: { ROOT: '/tmp' },
cwd: '/Users/doji/dojiwork/airi',
})
expect(buildConfigFile([server], translateMessage)).toEqual({
mcpServers: {
filesystem: {
command: 'npx',
args: ['-y', '@modelcontextprotocol/server-filesystem'],
env: { ROOT: '/tmp' },
cwd: '/Users/doji/dojiwork/airi',
},
},
})
})
it('keeps the existing JSON draft when form rows are incomplete', () => {
const previousDraft = '{\n "mcpServers": {\n "saved": { "command": "npx" }\n }\n}\n'
const result = syncJsonDraftFromServers(
[{
rowId: 'pending',
identifier: '',
command: '',
argsText: '',
envEntries: [],
cwd: '',
enabled: true,
}],
previousDraft,
translateMessage,
error => errorMessageFrom(error) ?? 'Unknown error',
)
expect(result.draft).toBe(previousDraft)
expect(result.error).toBe('errors.empty-identifier:1')
})
it('rejects JSON drafts that violate the shared MCP schema', () => {
expect(() => parseElectronMcpConfigText(JSON.stringify({
mcpServers: {
filesystem: {
command: 'npx',
env: [],
},
},
}))).toThrow('mcpServers.filesystem.env: Invalid input: expected record, received array')
})
it('rejects unknown keys that the main process would reject too', () => {
expect(() => parseElectronMcpConfigText(JSON.stringify({
mcpServers: {
filesystem: {
command: 'npx',
extraField: true,
},
},
}))).toThrow('mcpServers.filesystem: Unrecognized key: "extraField"')
})
})
@@ -0,0 +1,161 @@
import type {
ElectronMcpStdioConfigFile,
ElectronMcpStdioServerConfig,
} from '../../../../shared/eventa'
type TranslateMcpMessage = (key: string, params?: Record<string, unknown>) => string
/** Editable MCP server form state used by the settings page. */
export interface ServerForm {
rowId: string
identifier: string
command: string
argsText: string
envEntries: { key: string, value: string }[]
cwd: string
enabled: boolean
}
/** Editable MCP server rows derived from persisted config. */
export interface LoadedServerForms {
servers: ServerForm[]
savedIds: Set<string>
selectedRowId: string
}
function makeRowId() {
return `mcp-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`
}
function splitArgsText(argsText: string) {
return argsText.split(/\r?\n/).map(line => line.trim()).filter(Boolean)
}
function envToObject(entries: { key: string, value: string }[]) {
const out: Record<string, string> = {}
for (const { key, value } of entries) {
const normalizedKey = key.trim()
if (normalizedKey)
out[normalizedKey] = value
}
return out
}
/** Creates a blank MCP server row for new entries. */
export function createServerForm(): ServerForm {
return {
rowId: makeRowId(),
identifier: '',
command: '',
argsText: '',
envEntries: [],
cwd: '',
enabled: true,
}
}
/** Resolves the persisted server identifier for a selected row. */
export function findServerIdentifierByRowId(servers: ServerForm[], rowId: string) {
return servers.find(server => server.rowId === rowId)?.identifier.trim() || undefined
}
/** Converts one editable server row into persisted MCP server config. */
export function buildServerConfig(server: ServerForm): ElectronMcpStdioServerConfig {
const config: ElectronMcpStdioServerConfig = {
command: server.command.trim(),
}
const args = splitArgsText(server.argsText)
if (args.length)
config.args = args
const env = envToObject(server.envEntries)
if (Object.keys(env).length)
config.env = env
if (server.cwd.trim())
config.cwd = server.cwd.trim()
if (!server.enabled)
config.enabled = false
return config
}
/** Builds the persisted MCP config file from editable rows. */
export function buildConfigFile(
servers: ServerForm[],
translateMessage: TranslateMcpMessage,
): ElectronMcpStdioConfigFile {
const config: ElectronMcpStdioConfigFile = { mcpServers: {} }
const seenIdentifiers = new Set<string>()
for (const [index, server] of servers.entries()) {
const identifier = server.identifier.trim()
if (!identifier)
throw new Error(translateMessage('errors.empty-identifier', { index: index + 1 }))
if (seenIdentifiers.has(identifier))
throw new Error(translateMessage('errors.duplicate-identifier', { name: identifier }))
if (!server.command.trim())
throw new Error(translateMessage('errors.empty-command', { name: identifier }))
seenIdentifiers.add(identifier)
config.mcpServers[identifier] = buildServerConfig(server)
}
return config
}
/** Builds the JSON editor draft while preserving the current draft when form validation fails. */
export function syncJsonDraftFromServers(
servers: ServerForm[],
previousDraft: string,
translateMessage: TranslateMcpMessage,
formatError: (error: unknown) => string,
) {
try {
return {
draft: `${JSON.stringify(buildConfigFile(servers, translateMessage), null, 2)}\n`,
error: '',
}
}
catch (error) {
return {
draft: previousDraft,
error: formatError(error),
}
}
}
/** Loads editable rows from persisted MCP config. */
export function loadServerForms(
config: ElectronMcpStdioConfigFile,
options: { selectedIdentifier?: string } = {},
): LoadedServerForms {
const servers = Object.entries(config.mcpServers ?? {}).map(([identifier, server]) => ({
rowId: makeRowId(),
identifier,
command: server.command,
argsText: (server.args ?? []).join('\n'),
envEntries: Object.entries(server.env ?? {}).map(([key, value]) => ({ key, value })),
cwd: server.cwd ?? '',
enabled: server.enabled !== false,
}))
const selectedRowId = options.selectedIdentifier
? (servers.find(server => server.identifier === options.selectedIdentifier)?.rowId ?? servers[0]?.rowId ?? '')
: (servers[0]?.rowId ?? '')
return {
servers,
savedIds: new Set(servers.map(server => server.rowId)),
selectedRowId,
}
}
/** Previews the command line assembled from one server row. */
export function previewServerCommand(server: ServerForm) {
return [server.command, ...splitArgsText(server.argsText)].join(' ')
}
@@ -1,171 +1,518 @@
<script setup lang="ts">
import type { ElectronMcpStdioRuntimeStatus } from '../../../../shared/eventa'
import type {
ElectronMcpStdioConfigFile,
ElectronMcpStdioRuntimeStatus,
ElectronMcpStdioTestResult,
} from '../../../../shared/eventa'
import type { ServerForm } from './mcp-config'
import { errorMessageFrom } from '@moeru/std'
import { useElectronEventaInvoke } from '@proj-airi/electron-vueuse'
import { Button } from '@proj-airi/ui'
import {
Button,
Callout,
Checkbox,
TransitionVertical,
} from '@proj-airi/ui'
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import McpConnectionTestPanel from './components/McpConnectionTestPanel.vue'
import McpJsonEditor from './components/McpJsonEditor.vue'
import McpServerForm from './components/McpServerForm.vue'
import {
electronMcpApplyAndRestart,
electronMcpGetRuntimeStatus,
electronMcpOpenConfigFile,
electronMcpReadConfigText,
electronMcpTestServer,
electronMcpWriteConfigText,
} from '../../../../shared/eventa'
import { parseElectronMcpConfigText } from '../../../../shared/mcp-config'
import {
buildConfigFile,
buildServerConfig,
createServerForm,
findServerIdentifierByRowId,
loadServerForms,
previewServerCommand,
syncJsonDraftFromServers,
} from './mcp-config'
const { t } = useI18n()
const tn = (key: string, params?: Record<string, unknown>) => t(`settings.pages.modules.mcp-server.${key}`, params ?? {})
const openConfigFile = useElectronEventaInvoke(electronMcpOpenConfigFile)
const applyAndRestart = useElectronEventaInvoke(electronMcpApplyAndRestart)
const getRuntimeStatus = useElectronEventaInvoke(electronMcpGetRuntimeStatus)
const invokeOpenConfigFile = useElectronEventaInvoke(electronMcpOpenConfigFile)
const invokeApplyAndRestart = useElectronEventaInvoke(electronMcpApplyAndRestart)
const invokeGetRuntimeStatus = useElectronEventaInvoke(electronMcpGetRuntimeStatus)
const invokeReadConfigText = useElectronEventaInvoke(electronMcpReadConfigText)
const invokeWriteConfigText = useElectronEventaInvoke(electronMcpWriteConfigText)
const invokeTestServer = useElectronEventaInvoke(electronMcpTestServer)
const isBusy = ref(false)
const status = ref<ElectronMcpStdioRuntimeStatus>()
const lastActionMessage = ref('')
const servers = ref<ServerForm[]>([])
const runtime = ref<ElectronMcpStdioRuntimeStatus>()
const infoMessage = ref('')
const errorMessage = ref('')
const isBusy = ref(false)
const configPath = computed(() => status.value?.path ?? '')
const jsonOpen = ref(false)
const jsonDraft = ref('')
const jsonError = ref('')
const emptyConfigSignature = JSON.stringify({ mcpServers: {} })
async function refreshStatus() {
status.value = await getRuntimeStatus()
const savedSig = ref('')
const savedIds = ref<Set<string>>(new Set())
const expandedIds = ref<Set<string>>(new Set())
const testRowId = ref('')
const testRunning = ref(false)
const testResult = ref<ElectronMcpStdioTestResult>()
function buildConfig() {
return buildConfigFile(servers.value, tn)
}
async function handleOpenConfigFile() {
isBusy.value = true
errorMessage.value = ''
function applyLoadedConfig(config: ElectronMcpStdioConfigFile) {
const selectedIdentifier = findServerIdentifierByRowId(servers.value, testRowId.value)
const loaded = loadServerForms(config, { selectedIdentifier })
servers.value = loaded.servers
savedIds.value = loaded.savedIds
expandedIds.value = new Set()
testRowId.value = loaded.selectedRowId
}
const savedServers = computed(() => servers.value.filter(s => savedIds.value.has(s.rowId)))
const pendingServers = computed(() => servers.value.filter(s => !savedIds.value.has(s.rowId)))
function isExpanded(id: string) {
return expandedIds.value.has(id)
}
function toggleExpanded(id: string) {
if (expandedIds.value.has(id))
expandedIds.value.delete(id)
else
expandedIds.value.add(id)
}
const isDirty = computed(() => {
try {
const result = await openConfigFile()
await refreshStatus()
lastActionMessage.value = t('settings.pages.modules.mcp-server.messages.opened', { path: result.path })
return JSON.stringify(buildConfig()) !== savedSig.value
}
catch (error) {
errorMessage.value = error instanceof Error ? error.message : String(error)
catch {
return true
}
finally {
isBusy.value = false
})
const restartActionLabel = computed(() => isDirty.value ? tn('actions.apply-and-restart') : tn('actions.restart'))
const testOptions = computed(() => servers.value.map((s) => {
const name = s.identifier.trim() || tn('test.untitled')
return {
label: s.enabled ? name : `${name} (${tn('test.disabled-suffix')})`,
value: s.rowId,
}
}))
const configPath = computed(() => runtime.value?.path ?? '')
function runtimeStateOf(name: string) {
return runtime.value?.servers.find(s => s.name === name)?.state
}
const RUNTIME_BADGE: Record<'running' | 'stopped' | 'error', string> = {
running: 'bg-emerald-500/15 text-emerald-700 dark:text-emerald-300',
error: 'bg-red-500/15 text-red-700 dark:text-red-300',
stopped: 'bg-neutral-400/20 text-neutral-600 dark:text-neutral-300',
}
function badgeClass(state: 'running' | 'stopped' | 'error' | undefined) {
return RUNTIME_BADGE[state ?? 'stopped']
}
function commandPreview(s: ServerForm) {
return previewServerCommand(s)
}
const PANEL = 'flex flex-col gap-3 rounded-xl border-2 border-solid border-neutral-100 bg-white p-4 md:p-5 dark:border-neutral-900 dark:bg-neutral-900/30'
const CARD_PRIMARY = 'flex flex-col gap-3 rounded-xl border-2 border-solid border-primary-100 bg-primary-50/50 p-3 transition-all duration-200 ease-in-out hover:border-primary-500/30 md:p-4 dark:border-primary-900/60 dark:bg-primary-900/10 dark:hover:border-primary-400/30'
const CARD_MUTED = 'flex flex-col gap-3 rounded-xl border-2 border-solid border-neutral-100 bg-neutral-50/60 p-3 transition-all duration-200 ease-in-out hover:border-primary-500/30 md:p-4 dark:border-neutral-900 dark:bg-neutral-900/30 dark:hover:border-primary-400/30'
async function refreshRuntime() {
runtime.value = await invokeGetRuntimeStatus()
}
async function loadFromDisk() {
const { text } = await invokeReadConfigText()
try {
const parsed = parseElectronMcpConfigText(text)
applyLoadedConfig(parsed)
savedSig.value = JSON.stringify(parsed)
jsonOpen.value = false
jsonDraft.value = ''
jsonError.value = ''
}
catch (e) {
const message = errorMessageFrom(e) ?? 'Unknown error'
errorMessage.value = message
jsonDraft.value = text
jsonError.value = message
jsonOpen.value = true
servers.value = []
savedIds.value = new Set()
expandedIds.value = new Set()
savedSig.value = emptyConfigSignature
testRowId.value = ''
}
}
async function handleApplyAndRestart() {
function syncJsonDraft() {
const result = syncJsonDraftFromServers(
servers.value,
jsonDraft.value,
tn,
error => errorMessageFrom(error) ?? 'Unknown error',
)
jsonDraft.value = result.draft
jsonError.value = result.error
}
function toggleJsonPanel() {
if (jsonOpen.value) {
jsonOpen.value = false
jsonError.value = ''
}
else {
syncJsonDraft()
jsonOpen.value = true
}
}
function applyJsonDraft() {
try {
const parsed = parseElectronMcpConfigText(jsonDraft.value)
applyLoadedConfig(parsed)
jsonError.value = ''
jsonOpen.value = false
infoMessage.value = tn('messages.json-applied')
}
catch (e) {
jsonError.value = errorMessageFrom(e) ?? 'Unknown error'
}
}
function formatJsonDraft() {
try {
jsonDraft.value = `${JSON.stringify(JSON.parse(jsonDraft.value), null, 2)}\n`
jsonError.value = ''
}
catch (e) {
jsonError.value = errorMessageFrom(e) ?? 'Unknown error'
}
}
function addServer() {
const server = createServerForm()
servers.value.push(server)
if (!testRowId.value)
testRowId.value = server.rowId
}
function removeServer(rowId: string) {
const i = servers.value.findIndex(s => s.rowId === rowId)
if (i >= 0)
servers.value.splice(i, 1)
savedIds.value.delete(rowId)
expandedIds.value.delete(rowId)
if (testRowId.value === rowId)
testRowId.value = servers.value[0]?.rowId ?? ''
}
async function saveAndRestart() {
isBusy.value = true
errorMessage.value = ''
infoMessage.value = ''
try {
const result = await applyAndRestart()
await refreshStatus()
lastActionMessage.value = t('settings.pages.modules.mcp-server.messages.restarted', {
const text = `${JSON.stringify(buildConfig(), null, 2)}\n`
const written = await invokeWriteConfigText({ text })
const parsed = parseElectronMcpConfigText(written.text)
applyLoadedConfig(parsed)
savedSig.value = JSON.stringify(parsed)
const result = await invokeApplyAndRestart()
await refreshRuntime()
infoMessage.value = tn('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)
catch (e) {
errorMessage.value = errorMessageFrom(e) ?? 'Unknown error'
}
finally {
isBusy.value = false
}
}
async function restartServers() {
isBusy.value = true
errorMessage.value = ''
infoMessage.value = ''
try {
const result = await invokeApplyAndRestart()
await refreshRuntime()
infoMessage.value = tn('messages.restarted', {
started: result.started.length,
failed: result.failed.length,
skipped: result.skipped.length,
})
}
catch (e) {
errorMessage.value = errorMessageFrom(e) ?? 'Unknown error'
}
finally {
isBusy.value = false
}
}
async function applyRestartAction() {
if (isDirty.value) {
await saveAndRestart()
return
}
await restartServers()
}
async function openConfigInSystem() {
errorMessage.value = ''
try {
const { path } = await invokeOpenConfigFile()
infoMessage.value = tn('messages.opened', { path })
}
catch (e) {
errorMessage.value = errorMessageFrom(e) ?? 'Unknown error'
}
}
async function runConnectionTest() {
const target = servers.value.find(s => s.rowId === testRowId.value)
if (!target) {
testResult.value = { ok: false, error: tn('test.no-server-selected'), durationMs: 0 }
return
}
if (!target.enabled) {
testResult.value = { ok: false, error: tn('test.server-disabled', { name: target.identifier || '?' }), durationMs: 0 }
return
}
if (!target.command.trim()) {
testResult.value = { ok: false, error: tn('errors.empty-command', { name: target.identifier || '?' }), durationMs: 0 }
return
}
testRunning.value = true
testResult.value = undefined
try {
testResult.value = await invokeTestServer({
name: target.identifier.trim() || 'untitled',
config: buildServerConfig(target),
})
}
catch (e) {
testResult.value = { ok: false, error: errorMessageFrom(e) ?? 'Unknown error', durationMs: 0 }
}
finally {
testRunning.value = false
}
}
onMounted(async () => {
await refreshStatus()
const results = await Promise.allSettled([refreshRuntime(), loadFromDisk()])
const reasons = results
.filter((r): r is PromiseRejectedResult => r.status === 'rejected')
.map(r => errorMessageFrom(r.reason) ?? 'Unknown error')
if (reasons.length)
errorMessage.value = reasons.join('; ')
if (!testRowId.value && servers.value[0])
testRowId.value = servers.value[0].rowId
})
</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',
]"
>
<div flex="~ col gap-4">
<Callout v-if="errorMessage" theme="orange" :label="tn('error-title')">
{{ errorMessage }}
</div>
</Callout>
<Callout v-if="infoMessage" theme="lime" :label="tn('success-title')">
{{ infoMessage }}
</Callout>
<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') }}
<section :class="PANEL">
<p class="text-sm text-neutral-500 dark:text-neutral-400">
{{ tn('description') }}
</p>
<div class="break-all text-xs text-neutral-500 dark:text-neutral-400">
<span class="font-medium">{{ tn('config-path') }}:</span> {{ configPath || '-' }}
</div>
<ul class="space-y-2">
<div class="flex justify-end">
<Button
variant="secondary" size="sm" :toggled="jsonOpen"
:icon="jsonOpen ? 'i-solar:close-square-bold-duotone' : 'i-solar:document-text-bold-duotone'"
:label="jsonOpen ? tn('actions.close-json') : tn('actions.edit-json')"
@click="toggleJsonPanel"
/>
</div>
</section>
<TransitionVertical>
<McpJsonEditor
v-if="jsonOpen"
v-model="jsonDraft"
:error="jsonError"
@apply="applyJsonDraft"
@close="jsonOpen = false"
@format="formatJsonDraft"
@open-config="openConfigInSystem"
@reset-draft="syncJsonDraft"
/>
</TransitionVertical>
<section :class="PANEL">
<div flex="~ col gap-1">
<div class="flex items-center justify-between gap-2">
<h3 class="text-sm font-semibold">
{{ tn('existing.title') }}
</h3>
<span class="rounded-full bg-neutral-200/60 px-2 py-0.5 text-xs text-neutral-600 dark:bg-neutral-800 dark:text-neutral-300">
{{ savedServers.length }}
</span>
</div>
<p class="text-xs text-neutral-500 dark:text-neutral-400">
{{ tn('existing.description') }}
</p>
</div>
<div v-if="!savedServers.length" class="border-2 border-neutral-200 rounded-lg border-dashed p-6 text-center text-xs text-neutral-500 dark:border-neutral-800">
{{ tn('existing.empty') }}
</div>
<article
v-for="server in savedServers"
:key="server.rowId"
:class="server.enabled ? CARD_PRIMARY : CARD_MUTED"
>
<div class="flex items-center gap-3">
<button
type="button"
class="flex items-center justify-center rounded-md p-1 text-neutral-500 transition-colors hover:bg-neutral-200/60 hover:text-neutral-800 dark:hover:bg-neutral-800 dark:hover:text-neutral-100"
:aria-label="isExpanded(server.rowId) ? tn('actions.collapse') : tn('actions.expand')"
@click="toggleExpanded(server.rowId)"
>
<span :class="isExpanded(server.rowId) ? 'i-solar:alt-arrow-down-line-duotone' : 'i-solar:alt-arrow-right-line-duotone'" class="block size-4" />
</button>
<div class="min-w-0 flex flex-1 flex-col gap-0.5">
<div class="flex items-center gap-2">
<span class="truncate text-sm font-medium">
{{ server.identifier || tn('test.untitled') }}
</span>
<span
class="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-medium tracking-wide uppercase"
:class="badgeClass(runtimeStateOf(server.identifier))"
>
<span class="size-1 rounded-full bg-current opacity-80" />
{{ runtimeStateOf(server.identifier) ?? tn('status.unknown') }}
</span>
</div>
<div class="truncate text-xs text-neutral-500 font-mono dark:text-neutral-400">
{{ commandPreview(server) || '-' }}
</div>
</div>
<label class="flex shrink-0 cursor-pointer items-center gap-2 text-xs text-neutral-600 dark:text-neutral-300">
<span>{{ tn('fields.enabled.label') }}</span>
<Checkbox v-model="server.enabled" />
</label>
</div>
<TransitionVertical>
<div v-if="isExpanded(server.rowId)" class="border-t border-neutral-200/70 pt-3 dark:border-neutral-800">
<McpServerForm :model-value="server" @remove="removeServer(server.rowId)" />
</div>
</TransitionVertical>
</article>
</section>
<section :class="PANEL">
<div flex="~ col gap-1">
<h3 class="text-sm font-semibold">
{{ tn('add.title') }}
</h3>
<p class="text-xs text-neutral-500 dark:text-neutral-400">
{{ tn('add.description') }}
</p>
</div>
<article
v-for="server in pendingServers"
:key="server.rowId"
:class="CARD_PRIMARY"
>
<div class="flex items-center justify-between gap-2">
<span class="rounded-full bg-primary-500/15 px-2 py-0.5 text-[10px] text-primary-700 font-medium tracking-wide uppercase dark:text-primary-300">
{{ tn('add.pending-badge') }}
</span>
<label class="flex shrink-0 cursor-pointer items-center gap-2 text-xs text-neutral-600 dark:text-neutral-300">
<span>{{ tn('fields.enabled.label') }}</span>
<Checkbox v-model="server.enabled" />
</label>
</div>
<McpServerForm :model-value="server" @remove="removeServer(server.rowId)" />
</article>
<Button
variant="secondary-muted" size="md" block :disabled="isBusy"
icon="i-solar:add-circle-bold-duotone" :label="tn('actions.add-server')"
@click="addServer"
/>
</section>
<Button
variant="primary" size="md" block :disabled="isBusy" :loading="isBusy"
icon="i-solar:rocket-2-bold-duotone" :label="restartActionLabel"
@click="applyRestartAction"
/>
<McpConnectionTestPanel
v-model="testRowId"
:options="testOptions"
:result="testResult"
:running="testRunning"
@test="runConnectionTest"
/>
<section v-if="runtime?.servers?.length" :class="PANEL">
<div class="text-sm font-semibold">
{{ tn('runtime-title') }}
</div>
<ul flex="~ col gap-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',
]"
v-for="s in runtime.servers" :key="s.name"
class="flex flex-col gap-1 rounded-md px-3 py-2"
:class="badgeClass(s.state)"
>
<div class="font-medium">
{{ server.name }} ({{ server.state }})
<div class="flex items-center justify-between gap-2 text-sm font-medium">
<span>{{ s.name }}</span>
<span class="text-xs tracking-wide uppercase opacity-80">{{ s.state }}</span>
</div>
<div class="text-xs opacity-80">
{{ server.command }} {{ server.args.join(' ') }}
<div class="break-all text-xs font-mono opacity-80">
{{ s.command }} {{ s.args.join(' ') }}
</div>
<div v-if="server.lastError" class="text-xs">
{{ server.lastError }}
<div v-if="s.lastError" class="break-all text-xs">
{{ s.lastError }}
</div>
</li>
</ul>
</div>
</section>
</div>
</template>
@@ -213,6 +213,7 @@ describe('widgets tool helpers', () => {
const stageWidgetsTool = await getStageWidgetsTool()
const schema = stageWidgetsTool.function.parameters as JsonSchema
const windowSize = getObjectSchema(schema.properties?.windowSize as JsonSchema | undefined)
const windowSizeProperties = windowSize?.properties ?? {}
// ROOT CAUSE:
//
@@ -244,7 +245,15 @@ describe('widgets tool helpers', () => {
'maxWidth',
'maxHeight',
])
expect(windowSize?.required).toEqual(Object.keys(windowSize?.properties ?? {}))
expect(windowSize?.required).toEqual(Object.keys(windowSizeProperties))
expect((windowSizeProperties.minWidth as JsonSchema).type).toEqual(['number', 'null'])
expect((windowSizeProperties.minHeight as JsonSchema).type).toEqual(['number', 'null'])
expect((windowSizeProperties.maxWidth as JsonSchema).type).toEqual(['number', 'null'])
expect((windowSizeProperties.maxHeight as JsonSchema).type).toEqual(['number', 'null'])
expect((windowSizeProperties.minWidth as JsonSchema).exclusiveMinimum).toBe(0)
expect((windowSizeProperties.minHeight as JsonSchema).exclusiveMinimum).toBe(0)
expect((windowSizeProperties.maxWidth as JsonSchema).exclusiveMinimum).toBe(0)
expect((windowSizeProperties.maxHeight as JsonSchema).exclusiveMinimum).toBe(0)
})
describe('live AIHubMix repro', () => {
@@ -6,6 +6,8 @@ import type { WidgetWindowSize } from '../../../../shared/eventa'
import { defineInvoke } from '@moeru/eventa'
import { createContext } from '@moeru/eventa/adapters/electron/renderer'
import { rawTool } from '@xsai/tool'
import { toJsonSchema } from 'xsschema'
import { z } from 'zod'
import { widgetsAdd, widgetsClear, widgetsOpenWindow, widgetsPrepareWindow, widgetsRemove, widgetsUpdate } from '../../../../shared/eventa'
import { normalizeWidgetWindowSize } from '../../../../shared/utils/electron/windows/window-size'
@@ -63,6 +65,7 @@ type WidgetActionInput
export type WidgetInvokers = ReturnType<typeof createInvokers>
let cachedInvokers: WidgetInvokers | undefined
const JSON_SCHEMA_NULLABLE_SCALAR_TYPES = new Set(['string', 'number', 'integer', 'boolean', 'null'])
function createInvokers() {
const { context } = createContext(window.electron.ipcRenderer)
@@ -85,82 +88,111 @@ function resolveInvokers(override?: WidgetInvokers): WidgetInvokers {
return cachedInvokers
}
const nullablePositiveNumberSchema = {
type: ['number', 'null'],
exclusiveMinimum: 0,
} satisfies JsonSchema
const widgetWindowSizeParams = z.object({
width: z.number().positive(),
height: z.number().positive(),
// NOTICE: OpenAI-compatible tool validators reject strict object schemas when
// some nested properties are omitted from `required`. Keep these fields
// required-but-nullable for the provider, then collapse `null` back to omitted
// runtime fields before dispatching widget window updates.
minWidth: z.union([z.number().positive(), z.null()]),
minHeight: z.union([z.number().positive(), z.null()]),
maxWidth: z.union([z.number().positive(), z.null()]),
maxHeight: z.union([z.number().positive(), z.null()]),
}).strict()
const widgetWindowSizeParams = {
description: 'Optional pixel window size and constraints, e.g. {"width":620,"height":760,"minWidth":480}',
type: ['object', 'null'],
properties: {
width: {
type: 'number',
exclusiveMinimum: 0,
},
height: {
type: 'number',
exclusiveMinimum: 0,
},
minWidth: nullablePositiveNumberSchema,
minHeight: nullablePositiveNumberSchema,
maxWidth: nullablePositiveNumberSchema,
maxHeight: nullablePositiveNumberSchema,
},
required: [
'width',
'height',
'minWidth',
'minHeight',
'maxWidth',
'maxHeight',
],
additionalProperties: false,
} satisfies JsonSchema
const widgetParams = z.object({
action: z.enum(['spawn', 'update', 'remove', 'clear', 'open']).describe('Choose one: spawn, update, remove, clear, open'),
id: z.string().describe('Widget id; required for update/remove, optional for spawn/open'),
componentName: z.string().describe('Widget component to render, e.g. weather (required for spawn)'),
componentProps: z.string().describe('Widget props as JSON string (e.g. {"city":"Tokyo"})'),
size: z.enum(['s', 'm', 'l']),
windowSize: z.union([widgetWindowSizeParams, z.null()]).describe('Optional pixel window size and constraints, e.g. {"width":620,"height":760,"minWidth":480}'),
ttlSeconds: z.number().int().nonnegative().describe('Auto-close timer in seconds (spawn only)'),
}).strict()
const widgetParams = {
type: 'object',
properties: {
action: {
type: 'string',
enum: ['spawn', 'update', 'remove', 'clear', 'open'],
description: 'Choose one: spawn, update, remove, clear, open',
},
id: {
type: 'string',
description: 'Widget id; required for update/remove, optional for spawn/open',
},
componentName: {
type: 'string',
description: 'Widget component to render, e.g. weather (required for spawn)',
},
componentProps: {
type: 'string',
description: 'Widget props as JSON string (e.g. {"city":"Tokyo"})',
},
size: {
type: 'string',
enum: ['s', 'm', 'l'],
},
windowSize: widgetWindowSizeParams,
ttlSeconds: {
type: 'integer',
minimum: 0,
maximum: Number.MAX_SAFE_INTEGER,
description: 'Auto-close timer in seconds (spawn only)',
},
},
required: [
'action',
'id',
'componentName',
'componentProps',
'size',
'windowSize',
'ttlSeconds',
],
additionalProperties: false,
} satisfies JsonSchema
type WidgetToolInput = z.infer<typeof widgetParams>
function isJsonSchema(value: JsonSchema | boolean | JsonSchema[] | undefined): value is JsonSchema {
return Boolean(value && !Array.isArray(value) && typeof value === 'object')
}
function normalizeNullableAnyOf(schema: JsonSchema): JsonSchema {
const next: JsonSchema = { ...schema }
if (next.properties) {
next.properties = Object.fromEntries(
Object.entries(next.properties).map(([key, value]) => {
if (!isJsonSchema(value))
return [key, value]
return [key, normalizeNullableAnyOf(value)]
}),
)
}
if (Array.isArray(next.items)) {
next.items = next.items.map(item => isJsonSchema(item) ? normalizeNullableAnyOf(item) : item)
}
else if (isJsonSchema(next.items)) {
next.items = normalizeNullableAnyOf(next.items)
}
if (next.anyOf) {
next.anyOf = next.anyOf.map(value => isJsonSchema(value) ? normalizeNullableAnyOf(value) : value)
const normalizedEntries = next.anyOf.filter(isJsonSchema)
const primitiveTypes = normalizedEntries
.map(entry => entry.type)
.filter((type): type is Exclude<JsonSchema['type'], JsonSchema['type'][]> => typeof type === 'string')
const dedupedPrimitiveTypes = [...new Set(primitiveTypes)]
if (
primitiveTypes.length === normalizedEntries.length
&& dedupedPrimitiveTypes.length > 0
&& dedupedPrimitiveTypes.every(type => type !== undefined && JSON_SCHEMA_NULLABLE_SCALAR_TYPES.has(type))
) {
for (const entry of normalizedEntries) {
if (entry.type !== 'number' && entry.type !== 'integer')
continue
next.multipleOf ??= entry.multipleOf
next.minimum ??= entry.minimum
next.maximum ??= entry.maximum
next.exclusiveMinimum ??= entry.exclusiveMinimum
next.exclusiveMaximum ??= entry.exclusiveMaximum
}
delete next.anyOf
next.type = dedupedPrimitiveTypes as JsonSchema['type']
}
}
if (next.oneOf) {
next.oneOf = next.oneOf.map(value => isJsonSchema(value) ? normalizeNullableAnyOf(value) : value)
}
return next
}
function normalizeWidgetWindowSizeInput(windowSize: WidgetToolInput['windowSize']): WidgetWindowSize | undefined {
if (!windowSize)
return undefined
return {
width: windowSize.width,
height: windowSize.height,
...(windowSize.minWidth == null ? {} : { minWidth: windowSize.minWidth }),
...(windowSize.minHeight == null ? {} : { minHeight: windowSize.minHeight }),
...(windowSize.maxWidth == null ? {} : { maxWidth: windowSize.maxWidth }),
...(windowSize.maxHeight == null ? {} : { maxHeight: windowSize.maxHeight }),
}
}
function normalizeWidgetToolInput(input: WidgetToolInput): WidgetActionInput {
return {
...input,
windowSize: normalizeWidgetWindowSizeInput(input.windowSize),
}
}
export function normalizeComponentProps(raw?: string | Record<string, any>) {
if (raw === undefined || raw === null)
@@ -267,13 +299,13 @@ export async function executeWidgetAction(input: WidgetActionInput, deps?: { inv
}
}
const tools: Tool[] = [
rawTool({
const tools: Promise<Tool>[] = [
(async () => rawTool({
name: 'stage_widgets',
description: 'Manage overlay widgets in the Stage desktop app (spawn, update, remove, clear, or open the widgets window).',
execute: params => executeWidgetAction(params as WidgetActionInput),
parameters: widgetParams,
}),
execute: params => executeWidgetAction(normalizeWidgetToolInput(params as WidgetToolInput)),
parameters: normalizeNullableAnyOf(await toJsonSchema(widgetParams) as JsonSchema),
}))(),
]
export const widgetsTools = async () => tools
export const widgetsTools = async () => Promise.all(tools)
@@ -229,11 +229,31 @@ export interface ElectronMcpCallToolResult {
isError?: boolean
}
export interface ElectronMcpStdioConfigText {
path: string
text: string
}
export interface ElectronMcpStdioTestResult {
ok: boolean
error?: string
tools?: string[]
durationMs: number
}
export interface ElectronMcpStdioTestPayload {
name: string
config: ElectronMcpStdioServerConfig
}
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 electronMcpReadConfigText = defineInvokeEventa<ElectronMcpStdioConfigText>('eventa:invoke:electron:mcp:read-config-text')
export const electronMcpWriteConfigText = defineInvokeEventa<ElectronMcpStdioConfigText, { text: string }>('eventa:invoke:electron:mcp:write-config-text')
export const electronMcpTestServer = defineInvokeEventa<ElectronMcpStdioTestResult, ElectronMcpStdioTestPayload>('eventa:invoke:electron:mcp:test-server')
export const widgetsOpenWindow = defineInvokeEventa<void, { id?: string }>('eventa:invoke:electron:windows:widgets:open')
export const widgetsHideWindow = defineInvokeEventa<void, { id?: string }>('eventa:invoke:electron:windows:widgets:hide')
@@ -0,0 +1,124 @@
import type {
ElectronMcpStdioConfigFile,
ElectronMcpStdioServerConfig,
} from './eventa'
import { z } from 'zod'
function stringifyError(error: unknown) {
if (error instanceof Error) {
return error.message
}
return String(error)
}
/**
* Shared runtime-safe schema for one MCP stdio server definition.
*
* Use when:
* - Validating `mcp.json` in the main process
* - Validating JSON drafts before the renderer loads them into the form
*
* Expects:
* - `command` is a non-empty string
* - Optional fields must already conform to the persisted wire format
*
* Returns:
* - A strict Zod schema matching the persisted MCP server shape
*/
export const electronMcpStdioServerConfigSchema = 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() satisfies z.ZodType<ElectronMcpStdioServerConfig>
/**
* Shared runtime-safe schema for the persisted MCP config file.
*
* Use when:
* - Parsing `mcp.json` from disk
* - Parsing JSON drafts in the settings page
*
* Expects:
* - The root object contains only `mcpServers`
* - Each server entry matches {@link electronMcpStdioServerConfigSchema}
*
* Returns:
* - A strict Zod schema for the full MCP config file
*/
export const electronMcpConfigSchema = z.object({
mcpServers: z.record(z.string(), electronMcpStdioServerConfigSchema),
}).strict() satisfies z.ZodType<ElectronMcpStdioConfigFile>
/**
* Formats schema validation issues into one user-facing error string.
*
* Before:
* - `[{ path: ['mcpServers', 'fs', 'command'], message: 'Too small...' }]`
*
* After:
* - `"mcpServers.fs.command: Too small..."`
*
* Use when:
* - Returning validation failures to the main process or renderer UI
*
* Expects:
* - Issues come from Zod validation of the MCP config schema
*
* Returns:
* - A semicolon-delimited message preserving issue paths
*/
export function formatElectronMcpConfigIssues(issues: z.ZodIssue[]) {
return issues.map(issue => `${issue.path.join('.') || '<root>'}: ${issue.message}`).join('; ')
}
/**
* Parses a plain object into a validated MCP config file.
*
* Use when:
* - JSON text has already been parsed
* - Main and renderer need one shared validation entrypoint
*
* Expects:
* - `value` is the result of `JSON.parse` or another plain object source
*
* Returns:
* - A validated `ElectronMcpStdioConfigFile`
*/
export function parseElectronMcpConfig(value: unknown): ElectronMcpStdioConfigFile {
const validated = electronMcpConfigSchema.safeParse(value)
if (!validated.success) {
throw new Error(formatElectronMcpConfigIssues(validated.error.issues))
}
return validated.data
}
/**
* Parses JSON text into a validated MCP config file.
*
* Use when:
* - Reading `mcp.json` from disk
* - Applying raw JSON drafts in the renderer
*
* Expects:
* - `text` contains JSON text for an MCP config file
*
* Returns:
* - A validated `ElectronMcpStdioConfigFile`
*/
export function parseElectronMcpConfigText(text: string): ElectronMcpStdioConfigFile {
let parsed: unknown
try {
parsed = JSON.parse(text)
}
catch (error) {
throw new Error(`invalid JSON: ${stringifyError(error)}`)
}
return parseElectronMcpConfig(parsed)
}
+79 -9
View File
@@ -703,18 +703,88 @@ pages:
access-token-secret-placeholder: Enter your X / Twitter access token secret
configured: X / Twitter is properly configured!
mcp-server:
description: Connect and manage MCP server and tools
title: MCP Server
config-path: Config file path
title: MCP servers
description: Configure MCP servers and bring their tools into AIRI.
config-path: Config file
runtime-title: Runtime status
empty: No MCP servers yet — click "Add server" below to create one, or paste a config via "Edit JSON".
error-title: Oops
success-title: Done!
existing:
title: Configured
description: Servers recorded in mcp.json. Click any row to expand and edit it.
empty: Nothing here yet. Add one below!
add:
title: New
description: "Fill in a new server, then click \"Save and restart\" — it'll move into \"Configured\" above."
pending-badge: Unsaved
status:
unknown: not loaded
actions:
open-config: Open mcp.json
apply-and-restart: Save and restart stdio MCP
open-config: Reveal in file manager
apply-and-restart: Save and restart
restart: Restart MCP
add-server: Add server
add-env: Add var
remove: Remove
test: Test
edit-json: Edit JSON
close-json: Close
format-json: Format
reset-draft: Reset
apply-json: Apply
cancel: Cancel
expand: Expand
collapse: Collapse
fields:
identifier:
label: Identifier
description: Unique name; becomes the key in mcp.json.
placeholder: e.g. filesystem
command:
label: Command
description: Executable spawned over stdio.
placeholder: e.g. npx
args:
label: Arguments
description: One per line, in order.
placeholder: "-y\nsome-package-name\n/tmp"
cwd:
label: Working directory
description: Optional directory used as the process working directory.
placeholder: e.g. /Users/you/project
env:
label: Environment
description: Vars passed to the child process.
key-placeholder: KEY
value-placeholder: value
enabled:
label: Enabled
json:
title: Edit mcp.json
description: 'Edit the raw JSON directly. Click "Apply" to load it into the form, then "Save and restart" to write it to disk.'
placeholder: Paste mcp.json here…
test:
title: Test connection
description: We'll spawn the server briefly to check the command works and see what tools it exposes.
pick-server-label: Server
no-servers: Nothing to test yet
no-server-selected: Pick a server first.
server-disabled: '"{name}" is disabled — enable it on the card first.'
disabled-suffix: disabled
untitled: untitled
running: Testing…
success: Connected! Found {count} tools in {ms}ms.
failure: Connection failed after {ms}ms
errors:
empty-identifier: Server # {index} is missing an identifier.
duplicate-identifier: Identifier "{name}" is used more than once — names need to be unique.
empty-command: '"{name}" is missing a command.'
invalid-shape: That doesn't look like a valid mcp.json — the root needs an "mcpServers" field.
messages:
opened: Config file opened at {path}
restarted: >-
MCP servers restarted. Started {started}, failed {failed}, skipped
{skipped}
opened: Opened {path} in your file manager
json-applied: JSON applied to the form. Click "Save and restart" to persist it to disk.
restarted: All restarted! {started} started, {failed} failed, {skipped} skipped.
flux:
title: Flux
buy: Charge
@@ -672,17 +672,88 @@ pages:
access-token-secret-placeholder: 输入您的X / Twitter访问令牌密文
configured: X / Twitter已正确配置!
mcp-server:
description: 连接和管理 MCP 服务器及工具
title: MCP 服务器
config-path: 配置文件路径
description: 配置 MCP 服务器,把它们的工具接入 AIRI 一起用吧
config-path: 配置文件
runtime-title: 运行状态
empty: 还没有配置 MCP 服务器,点下方「添加服务器」加一个,或者「编辑 JSON」直接粘贴一段配置吧~
error-title: 出错啦
success-title: 完成啦
existing:
title: 已配置
description: mcp.json 里已经登记的服务器,点击行就能展开编辑。
empty: 还没有,下方添加一个吧。
add:
title: 新增
description: 在这里填好新的服务器,「保存并重启」之后就会归入上方「已配置」啦。
pending-badge: 未保存
status:
unknown: 未加载
actions:
open-config: 打开 mcp.json
apply-and-restart: 保存并重启 Stdio 协议的 MCP
open-config: 在文件管理器中打开
apply-and-restart: 保存并重启
restart: 重启 MCP
add-server: 添加服务器
add-env: 添加变量
remove: 删除
test: 测试
edit-json: 编辑 JSON
close-json: 收起
format-json: 格式化
reset-draft: 重置
apply-json: 应用
cancel: 取消
expand: 展开
collapse: 折叠
fields:
identifier:
label: 标识符
description: 唯一名称,作为 mcp.json 中的键。
placeholder: 例如 filesystem
command:
label: 启动命令
description: 通过 stdio 启动的可执行程序。
placeholder: 例如 npx
args:
label: 命令参数
description: 每行一个,按顺序传入。
placeholder: "-y\nsome-package-name\n/tmp"
cwd:
label: 工作目录
description: 可选,作为启动进程时使用的当前工作目录。
placeholder: 例如 /Users/you/project
env:
label: 环境变量
description: 注入到子进程的环境变量。
key-placeholder: 名称
value-placeholder:
enabled:
label: 启用
json:
title: 编辑 mcp.json
description: 直接改 JSON 也行,点「应用」会把它回填到表单里,再点「保存并重启」就写到磁盘上啦。
placeholder: 粘贴 mcp.json 内容…
test:
title: 连接测试
description: 我们会临时把它启动一次,看看命令能不能跑、能列出哪些工具。
pick-server-label: 服务器
no-servers: 还没有可以测试的服务器
no-server-selected: 先选一个服务器吧。
server-disabled: '"{name}" 还是禁用状态哦,先在卡片上启用再测试吧。'
disabled-suffix: 已禁用
untitled: 未命名
running: 正在测试…
success: 连接成功!发现 {count} 个工具,耗时 {ms}ms
failure: 连接失败了,耗时 {ms}ms
errors:
empty-identifier: 第 {index} 个服务器还没填标识符呢。
duplicate-identifier: 标识符 "{name}" 重复了,每个服务器要用不同的名字。
empty-command: '"{name}" 还没填启动命令。'
invalid-shape: mcp.json 结构不对,最外层得有 "mcpServers" 这个字段。
messages:
opened: 配置文件已于 {path} 打开
restarted: >-
MCP 服务已重新启动,启动了 {started},未启动 {failed},跳过 {skipped}
opened: 已经在文件管理器里打开 {path}
json-applied: JSON 已经回填到表单了,点「保存并重启」就写到磁盘上啦。
restarted: 重启好啦!启动了 {started} 个,失败 {failed},跳过 {skipped}
flux:
title: Flux
buy: 充值
+2 -2
View File
@@ -16,12 +16,12 @@ const {
streamTextMock: vi.fn(),
mcpMock: vi.fn(async (): Promise<Tool[]> => []),
debugMock: vi.fn(async (): Promise<Tool[]> => []),
createSparkCommandToolMock: vi.fn(async (): Promise<unknown> => ({
createSparkCommandToolMock: vi.fn(async (): Promise<unknown> => [{
name: 'spark',
description: '',
parameters: {},
execute: vi.fn(),
})),
}]),
}))
vi.mock('@xsai/model', () => ({