feat(stage-tamagotchi,stage-ui): rewrite mcp server settings page and normalize tool registration (#1722)
This commit is contained in:
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
+79
@@ -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>
|
||||
+60
@@ -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>
|
||||
+74
@@ -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)
|
||||
}
|
||||
@@ -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: 充值
|
||||
|
||||
@@ -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', () => ({
|
||||
|
||||
Reference in New Issue
Block a user