feat(mcp): add stdio env support and env editor component

This commit is contained in:
肖泽涛 2026-02-22 18:57:30 +08:00
parent 2666b27fcb
commit c1653a59c0
10 changed files with 219 additions and 3 deletions

View File

@ -658,6 +658,7 @@ class MigrationRunner:
url=sqlite_server.url,
command=sqlite_server.command,
args=sqlite_server.args,
env=getattr(sqlite_server, "env", None),
headers=sqlite_server.headers,
timeout=sqlite_server.timeout,
sse_read_timeout=sqlite_server.sse_read_timeout,

View File

@ -532,6 +532,7 @@ async def migrate_mcp_servers(sqlite_reader: SQLiteReader, dry_run: bool, execut
url=sqlite_server.url,
command=sqlite_server.command,
args=sqlite_server.args,
env=getattr(sqlite_server, "env", None),
headers=sqlite_server.headers,
timeout=sqlite_server.timeout,
sse_read_timeout=sqlite_server.sse_read_timeout,

View File

@ -33,6 +33,7 @@ class CreateMcpServerRequest(BaseModel):
url: str | None = Field(None, description="服务器 URLsse/streamable_http")
command: str | None = Field(None, description="命令stdio")
args: list | None = Field(None, description="命令参数数组stdio")
env: dict | None = Field(None, description="环境变量stdio")
description: str | None = Field(None, description="描述")
headers: dict | None = Field(None, description="HTTP 请求头")
timeout: int | None = Field(None, description="HTTP 超时时间(秒)")
@ -46,6 +47,7 @@ class UpdateMcpServerRequest(BaseModel):
url: str | None = Field(None, description="服务器 URL")
command: str | None = Field(None, description="命令stdio")
args: list | None = Field(None, description="命令参数数组stdio")
env: dict | None = Field(None, description="环境变量stdio")
description: str | None = Field(None, description="描述")
headers: dict | None = Field(None, description="HTTP 请求头")
timeout: int | None = Field(None, description="HTTP 超时时间(秒)")
@ -112,6 +114,7 @@ async def create_mcp_server_route(
url=request.url,
command=request.command,
args=request.args,
env=request.env,
description=request.description,
headers=request.headers,
timeout=request.timeout,
@ -159,6 +162,11 @@ async def update_mcp_server_route(
raise HTTPException(status_code=400, detail=f"传输类型必须是 {', '.join(valid_transports)} 之一")
try:
fields_set = getattr(request, "model_fields_set", getattr(request, "__fields_set__", set()))
update_kwargs = {}
if "env" in fields_set:
update_kwargs["env"] = request.env
server = await update_mcp_server(
db,
name=name,
@ -173,6 +181,7 @@ async def update_mcp_server_route(
tags=request.tags,
icon=request.icon,
updated_by=current_user.username,
**update_kwargs,
)
return {"success": True, "data": server.to_dict()}
except ValueError as ve:

View File

@ -35,6 +35,7 @@ _mcp_tools_stats: dict[str, dict[str, int]] = {}
# MCP Server configurations (Runtime cache, loaded from DB)
MCP_SERVERS: dict[str, dict[str, Any]] = {}
_UNSET = object()
# Default MCP Server configurations (Imported to DB on first run)
_DEFAULT_MCP_SERVERS = {
@ -130,6 +131,7 @@ async def init_mcp_servers() -> None:
url=config.get("url"),
command=config.get("command"),
args=config.get("args"),
env=config.get("env"),
headers=config.get("headers"),
timeout=config.get("timeout"),
sse_read_timeout=config.get("sse_read_timeout"),
@ -155,6 +157,7 @@ async def init_mcp_servers() -> None:
url=config.get("url"),
command=config.get("command"),
args=config.get("args"),
env=config.get("env"),
headers=config.get("headers"),
timeout=config.get("timeout"),
sse_read_timeout=config.get("sse_read_timeout"),
@ -368,6 +371,7 @@ async def create_mcp_server(
url: str = None,
command: str = None,
args: list = None,
env: dict = None,
description: str = None,
headers: dict = None,
timeout: int = None,
@ -389,6 +393,7 @@ async def create_mcp_server(
url=url,
command=command,
args=args,
env=env,
headers=headers,
timeout=timeout,
sse_read_timeout=sse_read_timeout,
@ -417,6 +422,7 @@ async def update_mcp_server(
url: str = None,
command: str = None,
args: list = None,
env: Any = _UNSET,
headers: dict = None,
timeout: int = None,
sse_read_timeout: int = None,
@ -439,6 +445,8 @@ async def update_mcp_server(
server.command = command
if args is not None:
server.args = args
if env is not _UNSET:
server.env = env
if headers is not None:
server.headers = headers
if timeout is not None:

View File

@ -341,6 +341,7 @@ class MCPServer(Base):
url = Column(String(500), nullable=True, comment="服务器 URLsse/streamable_http")
command = Column(String(500), nullable=True, comment="命令stdio")
args = Column(JSON, nullable=True, comment="命令参数数组stdio")
env = Column(JSON, nullable=True, comment="环境变量stdio")
headers = Column(JSON, nullable=True, comment="HTTP 请求头")
timeout = Column(Integer, nullable=True, comment="HTTP 超时时间(秒)")
sse_read_timeout = Column(Integer, nullable=True, comment="SSE 读取超时(秒)")
@ -369,6 +370,7 @@ class MCPServer(Base):
"url": self.url,
"command": self.command,
"args": self.args or [],
"env": self.env or {},
"headers": self.headers or {},
"timeout": self.timeout,
"sse_read_timeout": self.sse_read_timeout,
@ -402,6 +404,14 @@ class MCPServer(Base):
config["args"] = json.loads(self.args)
except json.JSONDecodeError:
pass
if self.transport == "stdio" and self.env:
if isinstance(self.env, dict):
config["env"] = self.env
elif isinstance(self.env, str):
try:
config["env"] = json.loads(self.env)
except json.JSONDecodeError:
pass
# headers 只用于 sse/streamable_http 传输类型
if self.transport in ("sse", "streamable_http") and self.headers:
if isinstance(self.headers, dict):

View File

@ -171,6 +171,7 @@ class PostgresManager(metaclass=SingletonMeta):
"ALTER TABLE IF EXISTS skills ADD COLUMN IF NOT EXISTS tool_dependencies JSONB DEFAULT '[]'::jsonb",
"ALTER TABLE IF EXISTS skills ADD COLUMN IF NOT EXISTS mcp_dependencies JSONB DEFAULT '[]'::jsonb",
"ALTER TABLE IF EXISTS skills ADD COLUMN IF NOT EXISTS skill_dependencies JSONB DEFAULT '[]'::jsonb",
"ALTER TABLE IF EXISTS mcp_servers ADD COLUMN IF NOT EXISTS env JSONB",
]
async with self.async_engine.begin() as conn:
for stmt in stmts:

View File

@ -416,6 +416,7 @@ class MCPServer(Base):
url = Column(String(500), nullable=True, comment="服务器 URLsse/streamable_http")
command = Column(String(500), nullable=True, comment="命令stdio")
args = Column(JSON, nullable=True, comment="命令参数数组stdio")
env = Column(JSON, nullable=True, comment="环境变量stdio")
headers = Column(JSON, nullable=True, comment="HTTP 请求头")
timeout = Column(Integer, nullable=True, comment="HTTP 超时时间(秒)")
sse_read_timeout = Column(Integer, nullable=True, comment="SSE 读取超时(秒)")
@ -444,6 +445,7 @@ class MCPServer(Base):
"url": self.url,
"command": self.command,
"args": self.args or [],
"env": self.env or {},
"headers": self.headers or {},
"timeout": self.timeout,
"sse_read_timeout": self.sse_read_timeout,
@ -475,6 +477,14 @@ class MCPServer(Base):
config["args"] = json.loads(self.args)
except json.JSONDecodeError:
pass
if self.transport == "stdio" and self.env:
if isinstance(self.env, dict):
config["env"] = self.env
elif isinstance(self.env, str):
try:
config["env"] = json.loads(self.env)
except json.JSONDecodeError:
pass
# headers 只用于 sse/streamable_http 传输类型
if self.transport in ("sse", "streamable_http") and self.headers:
if isinstance(self.headers, dict):

View File

@ -0,0 +1,145 @@
<template>
<div class="env-editor">
<div v-for="(row, index) in rows" :key="index" class="env-row">
<a-input v-model:value="row.key" placeholder="Key" class="env-key-input" />
<a-input v-model:value="row.value" placeholder="Value" class="env-value-input" />
<a-button
size="small"
type="text"
danger
@click="removeRow(index)"
:disabled="rows.length === 1"
>
删除
</a-button>
</div>
<a-button size="small" @click="addRow">
<template #icon><PlusOutlined /></template>
添加变量
</a-button>
</div>
</template>
<script setup>
import { ref, watch } from 'vue'
import { PlusOutlined } from '@ant-design/icons-vue'
const props = defineProps({
modelValue: {
type: Object,
default: null
}
})
const emit = defineEmits(['update:modelValue'])
const rows = ref([{ key: '', value: '' }])
const syncingFromObject = ref(false)
const objectToRows = (envObj) => {
if (!envObj || typeof envObj !== 'object') {
return [{ key: '', value: '' }]
}
const entries = Object.entries(envObj)
if (entries.length === 0) {
return [{ key: '', value: '' }]
}
return entries.map(([key, value]) => ({
key,
value: value == null ? '' : String(value)
}))
}
const normalizeEnvObject = (value) => {
if (value == null) {
return null
}
if (typeof value === 'string') {
try {
const parsed = JSON.parse(value)
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
return parsed
}
} catch {
return null
}
return null
}
if (typeof value === 'object' && !Array.isArray(value)) {
return value
}
return null
}
const rowsToObject = (rowsValue) => {
const entries = rowsValue
.map((row) => ({
key: row.key.trim(),
value: row.value
}))
.filter((row) => row.key)
if (entries.length === 0) {
return null
}
return Object.fromEntries(entries.map((row) => [row.key, row.value]))
}
const addRow = () => {
rows.value.push({ key: '', value: '' })
}
const removeRow = (index) => {
if (rows.value.length === 1) {
rows.value[0].key = ''
rows.value[0].value = ''
return
}
rows.value.splice(index, 1)
}
watch(
() => props.modelValue,
(value) => {
syncingFromObject.value = true
const normalized = normalizeEnvObject(value)
if (!normalized) {
rows.value = [{ key: '', value: '' }]
} else {
rows.value = objectToRows(normalized)
}
syncingFromObject.value = false
},
{ immediate: true }
)
watch(
rows,
(value) => {
if (syncingFromObject.value) {
return
}
const obj = rowsToObject(value)
emit('update:modelValue', obj)
},
{ deep: true }
)
</script>
<style lang="less" scoped>
.env-editor {
display: flex;
flex-direction: column;
gap: 8px;
.env-row {
display: flex;
gap: 8px;
align-items: center;
.env-key-input,
.env-value-input {
flex: 1;
}
}
}
</style>

View File

@ -78,6 +78,10 @@
</a-tag>
</span>
</div>
<div class="info-item" v-if="server.env && Object.keys(server.env).length > 0">
<label>环境变量</label>
<pre class="headers-pre">{{ JSON.stringify(server.env, null, 2) }}</pre>
</div>
</template>
<div class="info-item" v-if="server.description">

View File

@ -214,7 +214,7 @@
</template>
<!-- StdIO 类型 -->
<template v-if="form.transport === 'stdio'">
<template v-if="isStdioTransport">
<a-form-item label="命令" required class="form-item">
<a-input v-model:value="form.command" placeholder="例如npx 或 /path/to/server" />
</a-form-item>
@ -227,6 +227,10 @@
style="width: 100%"
/>
</a-form-item>
<a-form-item label="环境变量" class="form-item">
<McpEnvEditor v-model="form.env" />
</a-form-item>
</template>
<a-form-item label="标签" class="form-item">
@ -283,6 +287,7 @@ import {
} from '@ant-design/icons-vue'
import { mcpApi } from '@/apis/mcp_api'
import McpServerDetailModal from './McpServerDetailModal.vue'
import McpEnvEditor from './McpEnvEditor.vue'
//
const loading = ref(false)
@ -304,6 +309,7 @@ const form = reactive({
url: '',
command: '',
args: [],
env: null,
headersText: '',
timeout: null,
sse_read_timeout: null,
@ -321,6 +327,10 @@ const httpCount = computed(
)
const sseCount = computed(() => servers.value.filter((s) => s.transport === 'sse').length)
const stdioCount = computed(() => servers.value.filter((s) => s.transport === 'stdio').length)
const envEditorKey = computed(() => `${form.name}-${form.transport}`)
const isStdioTransport = computed(
() => String(form.transport || '').trim().toLowerCase() === 'stdio'
)
//
const fetchServers = async () => {
@ -352,6 +362,7 @@ const showAddModal = () => {
url: '',
command: '',
args: [],
env: null,
headersText: '',
timeout: null,
sse_read_timeout: null,
@ -362,8 +373,7 @@ const showAddModal = () => {
formModalVisible.value = true
}
//
const showEditModal = (server) => {
const applyServerToForm = (server) => {
editMode.value = true
formMode.value = 'form'
Object.assign(form, {
@ -373,6 +383,7 @@ const showEditModal = (server) => {
url: server.url || '',
command: server.command || '',
args: server.args || [],
env: server.env || null,
headersText: server.headers ? JSON.stringify(server.headers, null, 2) : '',
timeout: server.timeout,
sse_read_timeout: server.sse_read_timeout,
@ -382,6 +393,20 @@ const showEditModal = (server) => {
formModalVisible.value = true
}
//
const showEditModal = async (server) => {
try {
const result = await mcpApi.getMcpServer(server.name)
if (result.success && result.data) {
applyServerToForm(result.data)
return
}
} catch (err) {
console.error('获取服务器详情失败,回退使用列表数据:', err)
}
applyServerToForm(server)
}
//
const showDetailModal = (server) => {
selectedServer.value = server
@ -420,6 +445,7 @@ const handleFormSubmit = async () => {
url: form.url || null,
command: form.command || null,
args: form.args.length > 0 ? form.args : null,
env: form.env,
headers,
timeout: form.timeout || null,
sse_read_timeout: form.sse_read_timeout || null,
@ -577,6 +603,7 @@ const parseJsonToForm = () => {
url: obj.url || '',
command: obj.command || '',
args: obj.args || [],
env: obj.env || null,
headersText: obj.headers ? JSON.stringify(obj.headers, null, 2) : '',
timeout: obj.timeout || null,
sse_read_timeout: obj.sse_read_timeout || null,