feat(mcp): add stdio env support and env editor component
This commit is contained in:
parent
2666b27fcb
commit
c1653a59c0
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -33,6 +33,7 @@ class CreateMcpServerRequest(BaseModel):
|
||||
url: str | None = Field(None, description="服务器 URL(sse/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:
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -341,6 +341,7 @@ class MCPServer(Base):
|
||||
url = Column(String(500), nullable=True, comment="服务器 URL(sse/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):
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -416,6 +416,7 @@ class MCPServer(Base):
|
||||
url = Column(String(500), nullable=True, comment="服务器 URL(sse/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):
|
||||
|
||||
145
web/src/components/McpEnvEditor.vue
Normal file
145
web/src/components/McpEnvEditor.vue
Normal 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>
|
||||
@ -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">
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user