diff --git a/backend/server/routers/mcp_router.py b/backend/server/routers/mcp_router.py index 34295486..4ea10e1d 100644 --- a/backend/server/routers/mcp_router.py +++ b/backend/server/routers/mcp_router.py @@ -1,7 +1,7 @@ """MCP 服务器管理路由""" from fastapi import APIRouter, Depends, HTTPException -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field from sqlalchemy.ext.asyncio import AsyncSession from yuxi.agents.mcp.service import ( @@ -28,6 +28,8 @@ mcp = APIRouter(prefix="/system/mcp-servers", tags=["mcp"]) class CreateMcpServerRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + slug: str = Field(..., description="稳定标识") name: str = Field(..., description="展示名称") transport: str = Field(..., description="传输类型:sse/streamable_http/stdio") @@ -44,6 +46,8 @@ class CreateMcpServerRequest(BaseModel): class UpdateMcpServerRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + name: str | None = Field(None, description="展示名称") transport: str | None = Field(None, description="传输类型") url: str | None = Field(None, description="服务器 URL") diff --git a/backend/test/unit/routers/test_mcp_router.py b/backend/test/unit/routers/test_mcp_router.py index 26078ecb..bdee5976 100644 --- a/backend/test/unit/routers/test_mcp_router.py +++ b/backend/test/unit/routers/test_mcp_router.py @@ -133,3 +133,34 @@ def test_get_mcp_servers_normal_user_is_stripped(monkeypatch): assert data_user["name"] == "test-mcp" assert data_user["description"] == "test mcp description" assert data_user["enabled"] is True + + +def test_create_mcp_server_rejects_extra_config_fields(): + client = TestClient(_build_app()) + resp = client.post( + "/api/system/mcp-servers", + json={ + "slug": "demo-mcp", + "name": "Demo MCP", + "transport": "streamable_http", + "url": "https://example.com/mcp", + "enabled": True, + }, + ) + + assert resp.status_code == 422, resp.text + + +def test_update_mcp_server_rejects_extra_config_fields(): + client = TestClient(_build_app()) + resp = client.put( + "/api/system/mcp-servers/demo-mcp", + json={ + "name": "Demo MCP", + "transport": "streamable_http", + "url": "https://example.com/mcp", + "slug": "renamed-mcp", + }, + ) + + assert resp.status_code == 422, resp.text diff --git a/docs/develop-guides/roadmap.md b/docs/develop-guides/roadmap.md index af1723a9..4401ce24 100644 --- a/docs/develop-guides/roadmap.md +++ b/docs/develop-guides/roadmap.md @@ -38,6 +38,7 @@ ### 0.7.0 开发记录 +- 收敛 MCP 创建与编辑入口:前端移除整段配置文本入口和模式切换器,仅保留表单字段提交;后端 MCP 创建/更新请求拒绝额外配置字段,避免绕过表单约束。 - 调整内置 MCP 默认项:移除 `sequentialthinking` 的系统内置同步,启动同步时清理历史系统内置记录,保留用户手动创建的同名 MCP。 - 图片生成能力迁移为 Skill:Qwen-Image 从内置 Python 生成工具迁移到内置 Skill `image-gen`,模型调用与图片下载在 Agent 沙盒中完成,生成结果保存到 outputs 并通过 `present_artifacts` 展示,为多图片生成模型接入复用同一产物展示链路。 - 降低知识库路由与工具模块复杂度:示例问题生成迁移到知识库 utils,文件上传统一 100 MB 限制,URL 预处理入库路径与旧 `content_type=url` 行为收敛,并修复 uid、导出 MIME 与异常透传等路由问题。 diff --git a/web/src/components/extensions/McpDetailView.vue b/web/src/components/extensions/McpDetailView.vue index 2f9293b9..54275c9d 100644 --- a/web/src/components/extensions/McpDetailView.vue +++ b/web/src/components/extensions/McpDetailView.vue @@ -69,33 +69,9 @@

编辑 MCP

修改后保存会立即更新当前 MCP 配置。

-
- - - -
@@ -233,23 +209,6 @@ -
-
- JSON 配置 - 适合批量调整完整 MCP 配置,保存前请确认 JSON 格式有效。 -
- -
- 格式化 - 解析到表单 -
-
-
@@ -461,9 +420,7 @@ import { Settings2, Wrench, Save, - X, - Rows3, - Braces + X } from 'lucide-vue-next' import { mcpApi } from '@/apis/mcp_api' import { formatFullDateTime } from '@/utils/time' @@ -486,8 +443,6 @@ const toggleToolLoading = ref(null) const isEditing = ref(false) const editLoading = ref(false) -const formMode = ref('form') -const jsonContent = ref('') const editForm = reactive({ slug: '', @@ -554,13 +509,11 @@ const resetEditForm = (data) => { tags: data?.tags || [], icon: data?.icon || '' }) - jsonContent.value = data ? JSON.stringify(data, null, 2) : '' } const startEdit = () => { if (!server.value) return detailTab.value = 'general' - formMode.value = 'form' resetEditForm(server.value) isEditing.value = true } @@ -570,36 +523,7 @@ const cancelEdit = () => { resetEditForm(server.value) } -const formatJson = () => { - try { - const obj = JSON.parse(jsonContent.value) - jsonContent.value = JSON.stringify(obj, null, 2) - } catch { - message.error('JSON 格式错误,无法格式化') - } -} - -const parseJsonToForm = () => { - try { - const obj = JSON.parse(jsonContent.value) - resetEditForm(obj) - formMode.value = 'form' - message.success('已解析到表单') - } catch { - message.error('JSON 格式错误') - } -} - const buildEditPayload = () => { - if (formMode.value === 'json') { - try { - return JSON.parse(jsonContent.value) - } catch { - message.error('JSON 格式错误') - return null - } - } - let headers = null if (editForm.headersText.trim()) { try { @@ -850,56 +774,6 @@ onMounted(() => { } } - .mode-slider { - position: relative; - display: inline-grid; - grid-template-columns: 1fr 1fr; - width: 72px; - height: 32px; - padding: 3px; - border: 1px solid var(--gray-150); - border-radius: 8px; - background: var(--gray-50); - flex-shrink: 0; - } - - .mode-slider-thumb { - position: absolute; - top: 3px; - left: 3px; - width: 32px; - height: 24px; - border-radius: 6px; - background: var(--gray-0); - box-shadow: 0 1px 4px rgba(15, 23, 42, 0.08); - transition: transform 0.18s ease; - } - - .mode-slider.is-json .mode-slider-thumb { - transform: translateX(34px); - } - - .mode-slider-btn { - position: relative; - z-index: 1; - display: inline-flex; - align-items: center; - justify-content: center; - width: 32px; - height: 24px; - padding: 0; - border: none; - border-radius: 6px; - background: transparent; - color: var(--gray-500); - cursor: pointer; - transition: color 0.15s ease; - - &.active { - color: var(--main-color); - } - } - .inline-edit-form { display: flex; flex-direction: column; @@ -931,8 +805,7 @@ onMounted(() => { } } - .form-section-title, - .json-mode-header { + .form-section-title { display: flex; flex-direction: column; gap: 3px; @@ -970,8 +843,7 @@ onMounted(() => { color: var(--gray-500); } - .config-textarea, - .json-textarea { + .config-textarea { font-family: @mono-font; font-size: 13px; line-height: 1.6; @@ -981,18 +853,6 @@ onMounted(() => { padding: 0; } - .json-mode { - .json-mode-header { - margin-bottom: 14px; - } - - .json-actions { - margin-top: 12px; - display: flex; - gap: 8px; - } - } - .edit-panel-actions { display: flex; justify-content: flex-end; diff --git a/web/src/components/extensions/McpFormModal.vue b/web/src/components/extensions/McpFormModal.vue index 2a1b4c63..3af39ade 100644 --- a/web/src/components/extensions/McpFormModal.vue +++ b/web/src/components/extensions/McpFormModal.vue @@ -9,14 +9,7 @@ width="560px" class="server-modal" > -
- - 表单模式 - JSON 模式 - -
- - + - -
- -
- 格式化 - 解析到表单 -
-
@@ -141,8 +121,6 @@ const visible = computed({ }) const formLoading = ref(false) -const formMode = ref('form') -const jsonContent = ref('') const form = reactive({ slug: '', @@ -171,7 +149,6 @@ watch( () => props.open, (val) => { if (val && props.editData) { - formMode.value = 'form' Object.assign(form, { slug: props.editData.slug || '', name: props.editData.name || '', @@ -187,9 +164,7 @@ watch( tags: props.editData.tags || [], icon: props.editData.icon || '' }) - jsonContent.value = props.editData ? JSON.stringify(props.editData, null, 2) : '' } else if (val && !props.editData) { - formMode.value = 'form' Object.assign(form, { slug: '', name: '', @@ -205,82 +180,37 @@ watch( tags: [], icon: '' }) - jsonContent.value = '' } }, { immediate: true } ) -const formatJson = () => { - try { - const obj = JSON.parse(jsonContent.value) - jsonContent.value = JSON.stringify(obj, null, 2) - } catch { - message.error('JSON 格式错误,无法格式化') - } -} - -const parseJsonToForm = () => { - try { - const obj = JSON.parse(jsonContent.value) - Object.assign(form, { - slug: obj.slug || '', - name: obj.name || '', - description: obj.description || '', - transport: obj.transport || 'streamable_http', - 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, - tags: obj.tags || [], - icon: obj.icon || '' - }) - formMode.value = 'form' - message.success('已解析到表单') - } catch { - message.error('JSON 格式错误') - } -} - const handleFormSubmit = async () => { try { formLoading.value = true - let data - if (formMode.value === 'json') { + let headers = null + if (form.headersText.trim()) { try { - data = JSON.parse(jsonContent.value) + headers = JSON.parse(form.headersText) } catch { - message.error('JSON 格式错误') + message.error('请求头 JSON 格式错误') return } - } else { - let headers = null - if (form.headersText.trim()) { - try { - headers = JSON.parse(form.headersText) - } catch { - message.error('请求头 JSON 格式错误') - return - } - } - data = { - slug: form.slug, - name: form.name, - description: form.description || null, - transport: form.transport, - 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, - tags: form.tags.length > 0 ? form.tags : null, - icon: form.icon || null - } + } + const data = { + slug: form.slug, + name: form.name, + description: form.description || null, + transport: form.transport, + 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, + tags: form.tags.length > 0 ? form.tags : null, + icon: form.icon || null } if (!data.slug?.trim()) { message.error('MCP 标识不能为空') @@ -308,7 +238,8 @@ const handleFormSubmit = async () => { } if (props.editMode) { - const result = await mcpApi.updateMcpServer(props.editData?.slug || data.slug, data) + const { slug, ...updateData } = data + const result = await mcpApi.updateMcpServer(props.editData?.slug || slug, updateData) if (result.success) { message.success('MCP 更新成功') } else { @@ -336,21 +267,4 @@ const handleFormSubmit = async () => {