fix(mcp): 修复配置导入、凭据管理与协议边界

修复生命周期锁阻塞事件循环、旧连接回调误停新连接及超时契约不一致。

补齐 MCP JSON 兼容导入、密钥拆分与失败重试,修复 Header 大小写草稿丢失,迁移大小写敏感的环境变量凭据。

在 SSE 行拼接前限制缓冲大小,增加并发、迁移和流式输入回归测试;忽略本机 MCP 数据及 server.json/servers.json。

验证:后端 185 项、前端 54 项测试通过,前端生产构建、相关文件 Ruff 与暂存差异检查通过。
This commit is contained in:
2026-09-03 22:38:35 +08:00
parent 2f7066aa92
commit 9b8b10cdb1
13 changed files with 1097 additions and 80 deletions
@@ -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)
})
})
+64 -30
View File
@@ -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 KeyTokenAuthorization 会拆分后加密保存其他敏感值请显式声明不要把密钥放入命令或参数</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') }
})
})
+139
View File
@@ -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('服务器名称必须为 180 个字符')
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('密钥值必须为 132768 个字符')
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 }
}