fix(mcp): 修复配置导入、凭据管理与协议边界
修复生命周期锁阻塞事件循环、旧连接回调误停新连接及超时契约不一致。 补齐 MCP JSON 兼容导入、密钥拆分与失败重试,修复 Header 大小写草稿丢失,迁移大小写敏感的环境变量凭据。 在 SSE 行拼接前限制缓冲大小,增加并发、迁移和流式输入回归测试;忽略本机 MCP 数据及 server.json/servers.json。 验证:后端 185 项、前端 54 项测试通过,前端生产构建、相关文件 Ruff 与暂存差异检查通过。
This commit is contained in:
@@ -92,4 +92,78 @@ describe('McpServersView', () => {
|
||||
expect(confirm).toHaveBeenCalledWith(expect.stringContaining('旧测试与授权会失效'))
|
||||
expect(service.updateMcpServer).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('saves an environment API key via the encrypted endpoint, not the config body', async () => {
|
||||
const wrapper = await render()
|
||||
vi.mocked(service.createMcpServer).mockResolvedValue({ ...server, server_id: 'new-server' })
|
||||
vi.mocked(service.putMcpServerSecret).mockResolvedValue({})
|
||||
await wrapper.findAll('button').find(button => button.text() === '新增服务器')!.trigger('click')
|
||||
await wrapper.findAll('button').find(button => button.text() === 'JSON 配置')!.trigger('click')
|
||||
await wrapper.get('.json-editor').setValue(JSON.stringify({ command: 'uvx', environment: { MINIMAX_API_KEY: 'synthetic-only' }, secret_environment_keys: ['MINIMAX_API_KEY'] }))
|
||||
await wrapper.get('form').trigger('submit')
|
||||
await flushPromises()
|
||||
expect(service.createMcpServer).toHaveBeenCalledWith(expect.objectContaining({ environment: {}, secret_environment_keys: ['MINIMAX_API_KEY'] }))
|
||||
expect(JSON.stringify(vi.mocked(service.createMcpServer).mock.calls)).not.toContain('synthetic-only')
|
||||
expect(service.putMcpServerSecret).toHaveBeenCalledWith('new-server', 'MINIMAX_API_KEY', 'synthetic-only', 'environment')
|
||||
expect(wrapper.find('.modal-backdrop').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('retains imported keys over mode switches and retries partial saves without duplicates', async () => {
|
||||
const wrapper = await render()
|
||||
vi.mocked(service.createMcpServer).mockResolvedValue({ ...server, server_id: 'new-server', version: 1 })
|
||||
vi.mocked(service.updateMcpServer).mockResolvedValue({ ...server, server_id: 'new-server', version: 2 })
|
||||
vi.mocked(service.putMcpServerSecret).mockRejectedValueOnce(new Error('credential store unavailable')).mockResolvedValue({})
|
||||
await wrapper.findAll('button').find(button => button.text() === '新增服务器')!.trigger('click')
|
||||
await wrapper.findAll('button').find(button => button.text() === 'JSON 配置')!.trigger('click')
|
||||
await wrapper.get('.json-editor').setValue(JSON.stringify({ command: 'uvx', env: { API_KEY: 'retry-value' } }))
|
||||
await wrapper.findAll('button').find(button => button.text() === '表单配置')!.trigger('click')
|
||||
expect(wrapper.text()).toContain('已识别 1 项密钥')
|
||||
await wrapper.findAll('button').find(button => button.text() === 'JSON 配置')!.trigger('click')
|
||||
expect((wrapper.get('.json-editor').element as HTMLTextAreaElement).value).not.toContain('retry-value')
|
||||
await wrapper.get('form').trigger('submit')
|
||||
await flushPromises()
|
||||
expect(wrapper.get('.modal-card [role="alert"]').text()).toContain('服务器配置已保存,但密钥保存失败')
|
||||
await wrapper.get('form').trigger('submit')
|
||||
await flushPromises()
|
||||
expect(service.createMcpServer).toHaveBeenCalledTimes(1)
|
||||
expect(service.updateMcpServer).toHaveBeenCalledWith('new-server', expect.objectContaining({ version: 1 }))
|
||||
expect(service.putMcpServerSecret).toHaveBeenCalledTimes(2)
|
||||
expect(wrapper.find('.modal-backdrop').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('clears staged keys on cancel and accepts minimal JSON while editing', async () => {
|
||||
const wrapper = await render([server])
|
||||
await wrapper.findAll('button').find(button => button.text() === '新增服务器')!.trigger('click')
|
||||
await wrapper.findAll('button').find(button => button.text() === 'JSON 配置')!.trigger('click')
|
||||
await wrapper.get('.json-editor').setValue('{"command":"uvx","env":{"API_KEY":"cancelled-value"}}')
|
||||
await wrapper.findAll('button').find(button => button.text() === '表单配置')!.trigger('click')
|
||||
await wrapper.findAll('button').find(button => button.text() === '取消')!.trigger('click')
|
||||
await wrapper.findAll('button').find(button => button.text().includes('编辑'))!.trigger('click')
|
||||
await wrapper.findAll('button').find(button => button.text() === 'JSON 配置')!.trigger('click')
|
||||
await wrapper.get('.json-editor').setValue('{"name":"Minimal","url":"https://example.test/mcp"}')
|
||||
vi.mocked(service.updateMcpServer).mockResolvedValue(server)
|
||||
await wrapper.get('form').trigger('submit')
|
||||
await flushPromises()
|
||||
expect(service.updateMcpServer).toHaveBeenCalledWith('server-1', expect.objectContaining({ version: 2, headers: {}, args: [] }))
|
||||
expect(service.putMcpServerSecret).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('saves an imported Header secret after a case-only declaration rename', async () => {
|
||||
const wrapper = await render()
|
||||
vi.mocked(service.createMcpServer).mockResolvedValue({ ...server, secret_headers: { authorization: false } })
|
||||
vi.mocked(service.putMcpServerSecret).mockResolvedValue({})
|
||||
await wrapper.findAll('button').find(button => button.text() === '新增服务器')!.trigger('click')
|
||||
await wrapper.findAll('button').find(button => button.text() === 'JSON 配置')!.trigger('click')
|
||||
await wrapper.get('.json-editor').setValue(JSON.stringify({ url: 'https://example.test/mcp', headers: { Authorization: 'synthetic-draft' } }))
|
||||
await wrapper.findAll('button').find(button => button.text() === '表单配置')!.trigger('click')
|
||||
await wrapper.get('textarea[placeholder="Authorization"]').setValue('authorization')
|
||||
await wrapper.findAll('button').find(button => button.text() === 'JSON 配置')!.trigger('click')
|
||||
expect(wrapper.text()).toContain('已识别 1 项密钥')
|
||||
expect((wrapper.get('.json-editor').element as HTMLTextAreaElement).value).not.toContain('synthetic-draft')
|
||||
await wrapper.get('form').trigger('submit')
|
||||
await flushPromises()
|
||||
expect(service.createMcpServer).toHaveBeenCalledWith(expect.objectContaining({ headers: {}, secret_header_keys: ['authorization'] }))
|
||||
expect(service.putMcpServerSecret).toHaveBeenCalledWith('server-1', 'authorization', 'synthetic-draft', 'header')
|
||||
expect(wrapper.find('.modal-backdrop').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,8 +4,7 @@ import { Connection, Delete, EditPen, Plus, Refresh, VideoPlay } from '@element-
|
||||
import AppIcon from '@/components/common/AppIcon.vue'
|
||||
import type { McpServer, McpServerInput, McpServerTransport } from '@/contracts'
|
||||
import * as service from '@/services/mcpServerService'
|
||||
|
||||
type SecretKind = 'environment' | 'header'
|
||||
import { emptyMcpConfig, mergeImportedSecrets, normalizeMcpConfig, parseMcpJson, type ImportedSecret, type SecretKind } from './configuration'
|
||||
|
||||
const servers = ref<McpServer[]>([])
|
||||
const busy = ref('')
|
||||
@@ -22,18 +21,11 @@ const secretHeaderKeysText = ref('')
|
||||
const permissionsText = ref('')
|
||||
const rawConfig = ref('')
|
||||
const secretDrafts = reactive<Record<string, string>>({})
|
||||
const form = reactive<McpServerInput>(emptyForm())
|
||||
const form = reactive<McpServerInput>(emptyMcpConfig())
|
||||
const importedSecrets = ref<ImportedSecret[]>([])
|
||||
|
||||
const dialogTitle = computed(() => editingId.value ? '编辑 MCP 服务器' : '新增 MCP 服务器')
|
||||
|
||||
function emptyForm(): McpServerInput {
|
||||
return {
|
||||
name: '', transport: 'stdio', command: '', args: [], url: null, headers: {},
|
||||
environment: {}, secret_environment_keys: [], secret_header_keys: [], permissions: [],
|
||||
startup_timeout_seconds: 15, tool_timeout_seconds: 30,
|
||||
}
|
||||
}
|
||||
|
||||
async function load() {
|
||||
error.value = ''
|
||||
try { servers.value = await service.listMcpServers() }
|
||||
@@ -41,7 +33,7 @@ async function load() {
|
||||
}
|
||||
|
||||
function resetEditor(input: McpServerInput) {
|
||||
Object.assign(form, input)
|
||||
Object.assign(form, emptyMcpConfig(), { version: undefined }, input)
|
||||
argsText.value = input.args.join('\n')
|
||||
environmentText.value = JSON.stringify(input.environment, null, 2)
|
||||
headersText.value = JSON.stringify(input.headers, null, 2)
|
||||
@@ -53,13 +45,19 @@ function resetEditor(input: McpServerInput) {
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
if (busy.value) return
|
||||
error.value = ''
|
||||
importedSecrets.value = []
|
||||
editingId.value = null
|
||||
editingOriginal.value = null
|
||||
resetEditor(emptyForm())
|
||||
resetEditor(emptyMcpConfig())
|
||||
dialogOpen.value = true
|
||||
}
|
||||
|
||||
function openEdit(server: McpServer) {
|
||||
if (busy.value) return
|
||||
error.value = ''
|
||||
importedSecrets.value = []
|
||||
editingId.value = server.server_id
|
||||
editingOriginal.value = server
|
||||
resetEditor({
|
||||
@@ -109,38 +107,69 @@ function formPayload(): McpServerInput {
|
||||
}
|
||||
}
|
||||
|
||||
function payload(): McpServerInput {
|
||||
if (editorMode.value === 'form') return formPayload()
|
||||
let parsed: unknown
|
||||
try { parsed = JSON.parse(rawConfig.value) } catch { throw new Error('服务器配置不是有效 JSON') }
|
||||
if (!parsed || Array.isArray(parsed) || typeof parsed !== 'object') throw new Error('服务器配置必须是 JSON 对象')
|
||||
const value = parsed as McpServerInput
|
||||
if (editingId.value) value.version = form.version
|
||||
return value
|
||||
function payload(requireConnection = true): McpServerInput {
|
||||
const { config, secrets } = editorMode.value === 'form'
|
||||
? normalizeMcpConfig(formPayload(), '', requireConnection) : parseMcpJson(rawConfig.value, form.name, requireConnection)
|
||||
// Keep only still-declared drafts. A mode switch must not discard imported keys,
|
||||
// and editing the declaration must not later send a removed key to the Secret API.
|
||||
importedSecrets.value = mergeImportedSecrets(config, importedSecrets.value, secrets)
|
||||
if (editingId.value) config.version = form.version
|
||||
if (editorMode.value === 'json') rawConfig.value = JSON.stringify(config, null, 2)
|
||||
else {
|
||||
environmentText.value = JSON.stringify(config.environment, null, 2)
|
||||
headersText.value = JSON.stringify(config.headers, null, 2)
|
||||
secretKeysText.value = config.secret_environment_keys.join('\n')
|
||||
secretHeaderKeysText.value = config.secret_header_keys.join('\n')
|
||||
}
|
||||
return config
|
||||
}
|
||||
|
||||
function switchMode(mode: 'form' | 'json') {
|
||||
try {
|
||||
if (mode === editorMode.value) return
|
||||
if (mode === 'json') rawConfig.value = JSON.stringify(formPayload(), null, 2)
|
||||
else resetEditor(payload())
|
||||
error.value = ''
|
||||
if (mode === 'json') rawConfig.value = JSON.stringify(payload(false), null, 2)
|
||||
else resetEditor(payload(false))
|
||||
editorMode.value = mode
|
||||
} catch (cause) { error.value = message(cause, '配置转换失败') }
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (busy.value) return
|
||||
let saved: McpServer | undefined
|
||||
try {
|
||||
error.value = ''
|
||||
const input = payload()
|
||||
if (!input.name || (input.transport === 'stdio' ? !input.command : !input.url)) throw new Error('请填写服务器名称和连接地址')
|
||||
if (editingOriginal.value && executionChanged(editingOriginal.value, input) && !confirm('连接命令、地址或认证配置已变化,保存后旧测试与授权会失效。是否保存?')) return
|
||||
busy.value = 'save'
|
||||
editingId.value ? await service.updateMcpServer(editingId.value, input) : await service.createMcpServer(input)
|
||||
dialogOpen.value = false
|
||||
saved = editingId.value ? await service.updateMcpServer(editingId.value, input) : await service.createMcpServer(input)
|
||||
// Commit the returned ID/version before saving secrets so a partial failure can
|
||||
// retry this server instead of creating a duplicate or sending a stale version.
|
||||
editingId.value = saved.server_id
|
||||
editingOriginal.value = saved
|
||||
resetEditor({ ...input, version: saved.version })
|
||||
for (const item of [...importedSecrets.value]) {
|
||||
await service.putMcpServerSecret(saved.server_id, item.key, item.value, item.kind)
|
||||
importedSecrets.value = importedSecrets.value.filter(candidate => candidate !== item)
|
||||
}
|
||||
closeEditor()
|
||||
await load()
|
||||
} catch (cause) { error.value = message(cause, '保存失败') }
|
||||
} catch (cause) {
|
||||
if (saved) await load()
|
||||
error.value = `${saved ? '服务器配置已保存,但密钥保存失败;可点击保存重试。' : ''}${message(cause, '保存失败')}`
|
||||
}
|
||||
finally { busy.value = '' }
|
||||
}
|
||||
|
||||
function closeEditor() {
|
||||
importedSecrets.value = []
|
||||
rawConfig.value = ''
|
||||
environmentText.value = '{}'
|
||||
headersText.value = '{}'
|
||||
dialogOpen.value = false
|
||||
}
|
||||
|
||||
function executionChanged(server: McpServer, input: McpServerInput) {
|
||||
const sortedEntries = (value: Record<string, string>) => Object.entries(value).sort(([left], [right]) => left.localeCompare(right))
|
||||
const current = [
|
||||
@@ -212,9 +241,12 @@ onMounted(load)
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div v-if="dialogOpen" class="modal-backdrop" @click.self="dialogOpen = false">
|
||||
<div v-if="dialogOpen" class="modal-backdrop" @click.self="!busy && closeEditor()">
|
||||
<form class="modal-card" @submit.prevent="save">
|
||||
<header><h2><AppIcon :icon="Plus" /> {{ dialogTitle }}</h2><button type="button" class="close" @click="dialogOpen = false">×</button></header>
|
||||
<fieldset :disabled="!!busy" class="editor-fields">
|
||||
<header><h2><AppIcon :icon="Plus" /> {{ dialogTitle }}</h2><button type="button" class="close" @click="closeEditor">×</button></header>
|
||||
<div v-if="error" class="error-banner" role="alert">{{ error }}</div>
|
||||
<div v-if="importedSecrets.length" class="notice-banner">已识别 {{ importedSecrets.length }} 项密钥,保存时将单独加密,不会写入普通服务器配置;取消将清除未保存密钥。</div>
|
||||
<div class="mode-tabs"><button type="button" :class="{ active: editorMode === 'form' }" @click="switchMode('form')">表单配置</button><button type="button" :class="{ active: editorMode === 'json' }" @click="switchMode('json')">JSON 配置</button></div>
|
||||
<template v-if="editorMode === 'form'">
|
||||
<label>服务器名称<input v-model="form.name" maxlength="80" placeholder="例如:文件系统工具"></label>
|
||||
@@ -224,14 +256,16 @@ onMounted(load)
|
||||
<label>声明权限(逗号分隔,可选)<input v-model="permissionsText" placeholder="network.request, notes.read"></label>
|
||||
<div class="two-columns"><label>启动超时(秒)<input v-model.number="form.startup_timeout_seconds" type="number" min="1" max="120"></label><label>工具超时(秒)<input v-model.number="form.tool_timeout_seconds" type="number" min="1" max="300"></label></div>
|
||||
</template>
|
||||
<label v-else>服务器 JSON 配置<textarea v-model="rawConfig" class="json-editor" rows="22" spellcheck="false"></textarea><small>Secret 只填写键名,明文请在保存后的服务器卡片中单独录入。</small></label>
|
||||
<footer><button type="button" class="button-secondary" @click="dialogOpen = false">取消</button><button class="button-primary" :disabled="busy === 'save'">保存</button></footer>
|
||||
<label v-else>服务器 JSON 配置<textarea v-model="rawConfig" class="json-editor" rows="22" spellcheck="false"></textarea><small>支持 NotesAgent 配置、command/args/env 和单服务器 mcpServers 配置。已声明的 Secret 及常见 API Key、Token、Authorization 会拆分后加密保存。其他敏感值请显式声明;不要把密钥放入命令或参数。</small><small>兼容导入 timeout 为启动超时,sse_read_timeout 为工具等待上限(不保留 SSE 读取超时语义)。</small></label>
|
||||
<footer><button type="button" class="button-secondary" @click="closeEditor">取消</button><button class="button-primary" :disabled="busy === 'save'">保存</button></footer>
|
||||
</fieldset>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.editor-fields { display: grid; gap: var(--space-lg); border: 0; padding: 0; margin: 0; min-width: 0; }
|
||||
.mcp-page { overflow: auto; }.notice-banner,.error-banner { margin-bottom: var(--space-lg); }.server-list { display: grid; gap: var(--space-lg); }.server-card { display: grid; gap: var(--space-md); }
|
||||
.server-main,.server-title,.metadata,.card-actions,.inline-actions,.template-row,.modal-card header,.modal-card footer { display: flex; align-items: center; gap: var(--space-sm); }.server-main { justify-content: space-between; }.server-title { align-items: flex-start; }.server-title h2 { margin-bottom: 4px; }.server-title code { color: var(--color-text-secondary); overflow-wrap: anywhere; }.metadata { flex-wrap: wrap; color: var(--color-text-tertiary); font-size: var(--font-size-sm); }.metadata span + span::before { content: '·'; margin-right: var(--space-sm); }.compact { margin: 0; }
|
||||
.card-actions { justify-content: flex-end; border-top: 1px solid var(--color-border-subtle); padding-top: var(--space-md); }.empty { text-align: center; place-items: center; display: grid; gap: var(--space-md); padding: 64px; }.secrets { border: 1px solid var(--color-border-subtle); border-radius: var(--radius-md); padding: var(--space-md); display: grid; gap: var(--space-sm); }.secrets label { display: grid; grid-template-columns: minmax(220px,.7fr) 1fr; align-items: center; gap: var(--space-md); }.secrets small,.modal-card small { color: var(--color-text-tertiary); }.secret-input { display: flex; gap: var(--space-sm); }.secret-input input { flex: 1; }
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { emptyMcpConfig, mergeImportedSecrets, normalizeMcpConfig, parseMcpJson } from './configuration'
|
||||
|
||||
describe('MCP configuration normalization', () => {
|
||||
it('retains renamed HTTP drafts with the latest spelling and value', () => {
|
||||
const config = { ...emptyMcpConfig(), secret_header_keys: ['authorization'] }
|
||||
const previous = [{ kind: 'header' as const, key: 'Authorization', value: 'old-value' }]
|
||||
expect(mergeImportedSecrets(config, previous, [])).toEqual([{ kind: 'header', key: 'authorization', value: 'old-value' }])
|
||||
expect(mergeImportedSecrets(config, previous, [{ kind: 'header', key: 'AUTHORIZATION', value: 'new-value' }])).toEqual([{ kind: 'header', key: 'authorization', value: 'new-value' }])
|
||||
expect(mergeImportedSecrets(emptyMcpConfig(), previous, [])).toEqual([])
|
||||
})
|
||||
|
||||
it('does not transfer an environment draft across a case-only rename', () => {
|
||||
const config = { ...emptyMcpConfig(), secret_environment_keys: ['TOKEN', 'token'] }
|
||||
const previous = [{ kind: 'environment' as const, key: 'TOKEN', value: 'upper' }, { kind: 'environment' as const, key: 'token', value: 'lower' }]
|
||||
expect(mergeImportedSecrets(config, previous, [])).toEqual(previous)
|
||||
expect(mergeImportedSecrets({ ...config, secret_environment_keys: ['token'] }, [previous[0]!], [])).toEqual([])
|
||||
})
|
||||
it('fills backend defaults for minimal JSON', () => {
|
||||
const { config } = parseMcpJson('{"name":"demo","command":"uvx"}')
|
||||
expect(config).toMatchObject({ transport: 'stdio', args: [], headers: {}, environment: {}, permissions: [], secret_header_keys: [] })
|
||||
})
|
||||
|
||||
it('extracts a key pasted into environment despite its existing secret declaration', () => {
|
||||
const { config, secrets } = normalizeMcpConfig({
|
||||
name: 'MiniMax', command: 'uvx', secret_environment_keys: ['MINIMAX_API_KEY'],
|
||||
environment: { MINIMAX_API_KEY: 'synthetic-key', MINIMAX_API_HOST: 'https://api.minimaxi.com' },
|
||||
})
|
||||
expect(config.environment).toEqual({ MINIMAX_API_HOST: 'https://api.minimaxi.com' })
|
||||
expect(config.secret_environment_keys).toEqual(['MINIMAX_API_KEY'])
|
||||
expect(JSON.stringify(config)).not.toContain('synthetic-key')
|
||||
expect(secrets).toEqual([{ kind: 'environment', key: 'MINIMAX_API_KEY', value: 'synthetic-key' }])
|
||||
})
|
||||
|
||||
it('imports a standard single-server wrapper and legacy timeouts', () => {
|
||||
const { config, secrets } = normalizeMcpConfig({ mcpServers: { MiniMax: {
|
||||
command: 'uvx', args: ['--with', 'mcp<2', 'minimax-coding-plan-mcp', '-y'],
|
||||
env: { MINIMAX_API_KEY: 'synthetic-key' }, timeout: 120, sse_read_timeout: 300,
|
||||
} } })
|
||||
expect(config).toMatchObject({ name: 'MiniMax', transport: 'stdio', environment: {}, startup_timeout_seconds: 120, tool_timeout_seconds: 300 })
|
||||
expect(secrets).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('extracts case-insensitive HTTP credentials without duplicate declarations', () => {
|
||||
const { config, secrets } = normalizeMcpConfig({ url: 'https://example.test/mcp', headers: { authorization: 'synthetic' }, secret_header_keys: ['Authorization'] })
|
||||
expect(config.headers).toEqual({})
|
||||
expect(config.secret_header_keys).toEqual(['Authorization'])
|
||||
expect(secrets[0]?.key).toBe('Authorization')
|
||||
})
|
||||
|
||||
it.each([
|
||||
[{ command: 'uvx', args: 'not-array' }, 'args'],
|
||||
[{ command: 'uvx', environment: [] }, 'environment'],
|
||||
[{ command: 'uvx', timeout: 121 }, '启动超时'],
|
||||
[{ command: 'uvx', args: ['[https://example.test](https://example.test)'] }, '纯 URL'],
|
||||
[{ command: 'uvx', api_key: 'do-not-echo' }, '顶层'],
|
||||
[{ command: 'uvx', env: {}, environment: {} }, '只保留一个'],
|
||||
[{ mcpServers: { one: {}, two: {} } }, '一次导入一个'],
|
||||
])('rejects invalid fields without leaking their values', (input, hint) => {
|
||||
expect(() => normalizeMcpConfig(input)).toThrow(hint)
|
||||
try { normalizeMcpConfig(input) } catch (error) { expect(String(error)).not.toContain('do-not-echo') }
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,139 @@
|
||||
import type { McpServerInput } from '@/contracts'
|
||||
|
||||
export type SecretKind = 'environment' | 'header'
|
||||
export interface ImportedSecret { kind: SecretKind; key: string; value: string }
|
||||
|
||||
export function mergeImportedSecrets(config: McpServerInput, previous: ImportedSecret[], incoming: ImportedSecret[]): ImportedSecret[] {
|
||||
const merged = new Map<string, ImportedSecret>()
|
||||
for (const item of [...previous, ...incoming]) {
|
||||
const normalize = (key: string) => item.kind === 'header' ? key.toLowerCase() : key
|
||||
const keys = item.kind === 'header' ? config.secret_header_keys : config.secret_environment_keys
|
||||
const declared = keys.find(key => normalize(key) === normalize(item.key))
|
||||
if (declared === undefined) continue
|
||||
// HTTP identity is case-insensitive, but the Secret API requires the current
|
||||
// declared spelling. New inline values replace older drafts of that identity.
|
||||
merged.set(`${item.kind}:${normalize(declared)}`, { ...item, key: declared })
|
||||
}
|
||||
return [...merged.values()]
|
||||
}
|
||||
|
||||
export function emptyMcpConfig(): McpServerInput {
|
||||
return {
|
||||
name: '', transport: 'stdio', command: '', args: [], url: null, headers: {},
|
||||
environment: {}, secret_environment_keys: [], secret_header_keys: [], permissions: [],
|
||||
startup_timeout_seconds: 15, tool_timeout_seconds: 30,
|
||||
}
|
||||
}
|
||||
|
||||
function object(value: unknown, label: string): Record<string, unknown> {
|
||||
if (!value || Array.isArray(value) || typeof value !== 'object') throw new Error(`${label}必须是 JSON 对象`)
|
||||
return value as Record<string, unknown>
|
||||
}
|
||||
|
||||
function strings(value: unknown, label: string): string[] {
|
||||
if (value === undefined) return []
|
||||
if (!Array.isArray(value) || value.some(item => typeof item !== 'string')) throw new Error(`${label}必须是字符串数组`)
|
||||
return [...value]
|
||||
}
|
||||
|
||||
function entries(value: unknown, label: string): Record<string, string> {
|
||||
if (value === undefined) return {}
|
||||
const result = object(value, label)
|
||||
if (Object.values(result).some(item => typeof item !== 'string')) throw new Error(`${label}必须是字符串键值 JSON 对象`)
|
||||
return { ...result } as Record<string, string>
|
||||
}
|
||||
|
||||
function timeout(value: unknown, fallback: number, max: number, label: string): number {
|
||||
if (value === undefined) return fallback
|
||||
if (typeof value !== 'number' || !Number.isFinite(value) || value < 1 || value > max) throw new Error(`${label}必须是 1–${max} 秒之间的数字`)
|
||||
return value
|
||||
}
|
||||
|
||||
// Do not silently rewrite executable arguments or secret values copied from chat.
|
||||
function checkUrl(value: string, label: string) {
|
||||
if (/^\[https?:\/\//i.test(value)) throw new Error(`${label}请填写纯 URL,不要粘贴 Markdown 链接`)
|
||||
}
|
||||
|
||||
export function parseMcpJson(raw: string, fallbackName = '', requireConnection = true) {
|
||||
let parsed: unknown
|
||||
try { parsed = JSON.parse(raw) }
|
||||
catch { throw new Error('服务器配置不是有效 JSON;请检查逗号、引号和无效的 \\_ 转义') }
|
||||
return normalizeMcpConfig(parsed, fallbackName, requireConnection)
|
||||
}
|
||||
|
||||
/** Normalize external client JSON before it reaches either the form or the API.
|
||||
* Inline secrets leave the public config here and are sent only to the Secret API.
|
||||
*/
|
||||
export function normalizeMcpConfig(parsed: unknown, fallbackName = '', requireConnection = true) {
|
||||
let raw = object(parsed, '服务器配置')
|
||||
if ('mcpServers' in raw) {
|
||||
const servers = Object.entries(object(raw.mcpServers, 'mcpServers'))
|
||||
if (servers.length !== 1) throw new Error('请一次导入一个 MCP 服务器')
|
||||
fallbackName = servers[0]![0]
|
||||
raw = object(servers[0]![1], '服务器配置')
|
||||
}
|
||||
const allowed = new Set([...Object.keys(emptyMcpConfig()), 'version', 'env', 'type', 'timeout', 'sse_read_timeout'])
|
||||
if (Object.keys(raw).some(key => !allowed.has(key))) {
|
||||
// Never echo arbitrary unknown keys: pasted secrets sometimes become JSON keys.
|
||||
throw new Error('服务器配置含不支持的字段;API Key 请放在 env/environment 的对应变量中,不要放在顶层')
|
||||
}
|
||||
if (raw.env !== undefined && raw.environment !== undefined) throw new Error('env 与 environment 请只保留一个,避免覆盖配置')
|
||||
const transport = raw.transport ?? raw.type ?? (raw.url ? 'streamable_http' : 'stdio')
|
||||
if (!['stdio', 'streamable_http', 'sse'].includes(transport as string)) throw new Error('transport 必须是 stdio、streamable_http 或 sse')
|
||||
const config = emptyMcpConfig()
|
||||
config.transport = transport as McpServerInput['transport']
|
||||
const name = raw.name ?? (fallbackName || (typeof raw.command === 'string' ? raw.command : 'MCP 服务器'))
|
||||
if (typeof name !== 'string' || (requireConnection && !name.trim()) || name.trim().length > 80) throw new Error('服务器名称必须为 1–80 个字符')
|
||||
config.name = name.trim()
|
||||
for (const key of ['command', 'url'] as const) {
|
||||
const value = raw[key]
|
||||
if (value !== undefined && value !== null && typeof value !== 'string') throw new Error(`${key}必须是字符串`)
|
||||
config[key] = typeof value === 'string' ? value.trim() : null
|
||||
}
|
||||
config.args = strings(raw.args, 'args')
|
||||
if (config.args.length > 64) throw new Error('args 最多允许 64 项')
|
||||
for (const value of config.args) checkUrl(value, 'args 中的地址')
|
||||
config.environment = entries(raw.environment ?? raw.env, 'environment/env')
|
||||
config.headers = entries(raw.headers, 'headers')
|
||||
config.secret_environment_keys = [...new Set(strings(raw.secret_environment_keys, 'secret_environment_keys'))]
|
||||
config.secret_header_keys = [...new Set(strings(raw.secret_header_keys, 'secret_header_keys'))]
|
||||
config.permissions = strings(raw.permissions, 'permissions')
|
||||
config.startup_timeout_seconds = timeout(raw.startup_timeout_seconds ?? raw.timeout, 15, 120, '启动超时')
|
||||
// Compatibility policy: legacy read timeout becomes the tool wait budget, not an SSE transport setting.
|
||||
config.tool_timeout_seconds = timeout(raw.tool_timeout_seconds ?? raw.sse_read_timeout, 30, 300, '工具超时')
|
||||
if (config.transport === 'stdio') {
|
||||
if (requireConnection && !config.command) throw new Error('stdio 配置必须填写 command')
|
||||
if (config.url || Object.keys(config.headers).length || config.secret_header_keys.length) throw new Error('stdio 配置不能包含 URL 或 HTTP Header')
|
||||
} else {
|
||||
if (requireConnection && !config.url) throw new Error('HTTP/SSE 配置必须填写 url')
|
||||
if (config.url) {
|
||||
checkUrl(config.url, 'url')
|
||||
let url: URL
|
||||
try { url = new URL(config.url) } catch { throw new Error('url 必须是有效的 HTTP(S) 地址') }
|
||||
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.hash) throw new Error('url 必须为不含账号密码或片段的 HTTP(S) 地址')
|
||||
}
|
||||
if (config.command || config.args.length || Object.keys(config.environment).length || config.secret_environment_keys.length) throw new Error('HTTP/SSE 配置不能包含 command、args 或环境变量')
|
||||
}
|
||||
const secrets: ImportedSecret[] = []
|
||||
for (const kind of ['environment', 'header'] as const) {
|
||||
const values = kind === 'environment' ? config.environment : config.headers
|
||||
const keys = kind === 'environment' ? config.secret_environment_keys : config.secret_header_keys
|
||||
const identity = (key: string) => kind === 'header' ? key.toLowerCase() : key
|
||||
const allKeys = [...Object.keys(values), ...keys]
|
||||
if (kind === 'header' && (new Set(keys.map(identity)).size !== keys.length || new Set(Object.keys(values).map(identity)).size !== Object.keys(values).length)) throw new Error('HTTP Header 名称不能仅大小写不同而重复声明')
|
||||
const validKey = kind === 'environment' ? /^[A-Za-z_][A-Za-z0-9_]{0,127}$/ : /^[!#$%&'*+.^_`|~0-9A-Za-z-]{1,128}$/
|
||||
if (allKeys.some(key => !validKey.test(key))) throw new Error(`${kind === 'environment' ? '环境变量' : 'Header'}名称无效;敏感变量名只能填名称,不能填密钥值`)
|
||||
for (const [key, value] of Object.entries(values)) {
|
||||
const declared = keys.find(item => identity(item) === identity(key))
|
||||
const sensitive = /api[_-]?key|token|secret|password|authorization|cookie|credential/i.test(key)
|
||||
if (declared || sensitive) {
|
||||
if (!value || value.length > 32768) throw new Error('密钥值必须为 1–32768 个字符')
|
||||
const secretKey = declared ?? key
|
||||
if (!declared) keys.push(key)
|
||||
secrets.push({ kind, key: secretKey, value })
|
||||
delete values[key]
|
||||
} else if (/host|url|endpoint/i.test(key)) checkUrl(value, '环境变量或 Header 地址')
|
||||
}
|
||||
}
|
||||
return { config, secrets }
|
||||
}
|
||||
Reference in New Issue
Block a user