refactor: 移除 MCP 配置文本入口

This commit is contained in:
Wenjie Zhang 2026-06-03 23:33:30 +08:00
parent 5a8ecb8e02
commit b481034a21
5 changed files with 62 additions and 252 deletions

View File

@ -1,7 +1,7 @@
"""MCP 服务器管理路由""" """MCP 服务器管理路由"""
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from yuxi.agents.mcp.service import ( from yuxi.agents.mcp.service import (
@ -28,6 +28,8 @@ mcp = APIRouter(prefix="/system/mcp-servers", tags=["mcp"])
class CreateMcpServerRequest(BaseModel): class CreateMcpServerRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
slug: str = Field(..., description="稳定标识") slug: str = Field(..., description="稳定标识")
name: str = Field(..., description="展示名称") name: str = Field(..., description="展示名称")
transport: str = Field(..., description="传输类型sse/streamable_http/stdio") transport: str = Field(..., description="传输类型sse/streamable_http/stdio")
@ -44,6 +46,8 @@ class CreateMcpServerRequest(BaseModel):
class UpdateMcpServerRequest(BaseModel): class UpdateMcpServerRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
name: str | None = Field(None, description="展示名称") name: str | None = Field(None, description="展示名称")
transport: str | None = Field(None, description="传输类型") transport: str | None = Field(None, description="传输类型")
url: str | None = Field(None, description="服务器 URL") url: str | None = Field(None, description="服务器 URL")

View File

@ -133,3 +133,34 @@ def test_get_mcp_servers_normal_user_is_stripped(monkeypatch):
assert data_user["name"] == "test-mcp" assert data_user["name"] == "test-mcp"
assert data_user["description"] == "test mcp description" assert data_user["description"] == "test mcp description"
assert data_user["enabled"] is True 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

View File

@ -38,6 +38,7 @@
### 0.7.0 开发记录 ### 0.7.0 开发记录
<!-- 0.7.0 的内容请放在这里 --> <!-- 0.7.0 的内容请放在这里 -->
- 收敛 MCP 创建与编辑入口:前端移除整段配置文本入口和模式切换器,仅保留表单字段提交;后端 MCP 创建/更新请求拒绝额外配置字段,避免绕过表单约束。
- 调整内置 MCP 默认项:移除 `sequentialthinking` 的系统内置同步,启动同步时清理历史系统内置记录,保留用户手动创建的同名 MCP。 - 调整内置 MCP 默认项:移除 `sequentialthinking` 的系统内置同步,启动同步时清理历史系统内置记录,保留用户手动创建的同名 MCP。
- 图片生成能力迁移为 SkillQwen-Image 从内置 Python 生成工具迁移到内置 Skill `image-gen`,模型调用与图片下载在 Agent 沙盒中完成,生成结果保存到 outputs 并通过 `present_artifacts` 展示,为多图片生成模型接入复用同一产物展示链路。 - 图片生成能力迁移为 SkillQwen-Image 从内置 Python 生成工具迁移到内置 Skill `image-gen`,模型调用与图片下载在 Agent 沙盒中完成,生成结果保存到 outputs 并通过 `present_artifacts` 展示,为多图片生成模型接入复用同一产物展示链路。
- 降低知识库路由与工具模块复杂度:示例问题生成迁移到知识库 utils文件上传统一 100 MB 限制URL 预处理入库路径与旧 `content_type=url` 行为收敛,并修复 uid、导出 MIME 与异常透传等路由问题。 - 降低知识库路由与工具模块复杂度:示例问题生成迁移到知识库 utils文件上传统一 100 MB 限制URL 预处理入库路径与旧 `content_type=url` 行为收敛,并修复 uid、导出 MIME 与异常透传等路由问题。

View File

@ -69,33 +69,9 @@
<h3>编辑 MCP</h3> <h3>编辑 MCP</h3>
<p>修改后保存会立即更新当前 MCP 配置</p> <p>修改后保存会立即更新当前 MCP 配置</p>
</div> </div>
<div class="mode-slider" :class="{ 'is-json': formMode === 'json' }">
<span class="mode-slider-thumb"></span>
<button
type="button"
class="lucide-icon-btn mode-slider-btn"
:class="{ active: formMode === 'form' }"
title="表单模式"
aria-label="切换到表单模式"
@click="formMode = 'form'"
>
<Rows3 :size="14" />
</button>
<button
type="button"
class="lucide-icon-btn mode-slider-btn"
:class="{ active: formMode === 'json' }"
title="JSON 模式"
aria-label="切换到 JSON 模式"
@click="formMode = 'json'"
>
<Braces :size="14" />
</button>
</div>
</div> </div>
<a-form <a-form
v-if="formMode === 'form'"
layout="vertical" layout="vertical"
class="extension-form inline-edit-form" class="extension-form inline-edit-form"
> >
@ -233,23 +209,6 @@
</section> </section>
</a-form> </a-form>
<div v-else class="json-mode">
<div class="json-mode-header">
<span>JSON 配置</span>
<small>适合批量调整完整 MCP 配置保存前请确认 JSON 格式有效</small>
</div>
<a-textarea
v-model:value="jsonContent"
:rows="15"
placeholder="请输入 JSON 配置"
class="json-textarea"
/>
<div class="json-actions">
<a-button size="small" @click="formatJson">格式化</a-button>
<a-button size="small" @click="parseJsonToForm">解析到表单</a-button>
</div>
</div>
<div class="edit-panel-actions"> <div class="edit-panel-actions">
<a-button @click="cancelEdit" :disabled="editLoading" class="lucide-icon-btn"> <a-button @click="cancelEdit" :disabled="editLoading" class="lucide-icon-btn">
<template #icon><X :size="14" /></template> <template #icon><X :size="14" /></template>
@ -461,9 +420,7 @@ import {
Settings2, Settings2,
Wrench, Wrench,
Save, Save,
X, X
Rows3,
Braces
} from 'lucide-vue-next' } from 'lucide-vue-next'
import { mcpApi } from '@/apis/mcp_api' import { mcpApi } from '@/apis/mcp_api'
import { formatFullDateTime } from '@/utils/time' import { formatFullDateTime } from '@/utils/time'
@ -486,8 +443,6 @@ const toggleToolLoading = ref(null)
const isEditing = ref(false) const isEditing = ref(false)
const editLoading = ref(false) const editLoading = ref(false)
const formMode = ref('form')
const jsonContent = ref('')
const editForm = reactive({ const editForm = reactive({
slug: '', slug: '',
@ -554,13 +509,11 @@ const resetEditForm = (data) => {
tags: data?.tags || [], tags: data?.tags || [],
icon: data?.icon || '' icon: data?.icon || ''
}) })
jsonContent.value = data ? JSON.stringify(data, null, 2) : ''
} }
const startEdit = () => { const startEdit = () => {
if (!server.value) return if (!server.value) return
detailTab.value = 'general' detailTab.value = 'general'
formMode.value = 'form'
resetEditForm(server.value) resetEditForm(server.value)
isEditing.value = true isEditing.value = true
} }
@ -570,36 +523,7 @@ const cancelEdit = () => {
resetEditForm(server.value) 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 = () => { const buildEditPayload = () => {
if (formMode.value === 'json') {
try {
return JSON.parse(jsonContent.value)
} catch {
message.error('JSON 格式错误')
return null
}
}
let headers = null let headers = null
if (editForm.headersText.trim()) { if (editForm.headersText.trim()) {
try { 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 { .inline-edit-form {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -931,8 +805,7 @@ onMounted(() => {
} }
} }
.form-section-title, .form-section-title {
.json-mode-header {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 3px; gap: 3px;
@ -970,8 +843,7 @@ onMounted(() => {
color: var(--gray-500); color: var(--gray-500);
} }
.config-textarea, .config-textarea {
.json-textarea {
font-family: @mono-font; font-family: @mono-font;
font-size: 13px; font-size: 13px;
line-height: 1.6; line-height: 1.6;
@ -981,18 +853,6 @@ onMounted(() => {
padding: 0; padding: 0;
} }
.json-mode {
.json-mode-header {
margin-bottom: 14px;
}
.json-actions {
margin-top: 12px;
display: flex;
gap: 8px;
}
}
.edit-panel-actions { .edit-panel-actions {
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;

View File

@ -9,14 +9,7 @@
width="560px" width="560px"
class="server-modal" class="server-modal"
> >
<div class="mode-switch"> <a-form layout="vertical" class="extension-form">
<a-radio-group v-model:value="formMode" button-style="solid" size="small">
<a-radio-button value="form">表单模式</a-radio-button>
<a-radio-button value="json">JSON 模式</a-radio-button>
</a-radio-group>
</div>
<a-form v-if="formMode === 'form'" layout="vertical" class="extension-form">
<a-form-item label="MCP 标识" required class="form-item"> <a-form-item label="MCP 标识" required class="form-item">
<a-input <a-input
v-model:value="form.slug" v-model:value="form.slug"
@ -105,19 +98,6 @@
/> />
</a-form-item> </a-form-item>
</a-form> </a-form>
<div v-else class="json-mode">
<a-textarea
v-model:value="jsonContent"
:rows="15"
placeholder="请输入 JSON 配置"
class="json-textarea"
/>
<div class="json-actions">
<a-button size="small" @click="formatJson">格式化</a-button>
<a-button size="small" @click="parseJsonToForm">解析到表单</a-button>
</div>
</div>
</a-modal> </a-modal>
</template> </template>
@ -141,8 +121,6 @@ const visible = computed({
}) })
const formLoading = ref(false) const formLoading = ref(false)
const formMode = ref('form')
const jsonContent = ref('')
const form = reactive({ const form = reactive({
slug: '', slug: '',
@ -171,7 +149,6 @@ watch(
() => props.open, () => props.open,
(val) => { (val) => {
if (val && props.editData) { if (val && props.editData) {
formMode.value = 'form'
Object.assign(form, { Object.assign(form, {
slug: props.editData.slug || '', slug: props.editData.slug || '',
name: props.editData.name || '', name: props.editData.name || '',
@ -187,9 +164,7 @@ watch(
tags: props.editData.tags || [], tags: props.editData.tags || [],
icon: props.editData.icon || '' icon: props.editData.icon || ''
}) })
jsonContent.value = props.editData ? JSON.stringify(props.editData, null, 2) : ''
} else if (val && !props.editData) { } else if (val && !props.editData) {
formMode.value = 'form'
Object.assign(form, { Object.assign(form, {
slug: '', slug: '',
name: '', name: '',
@ -205,82 +180,37 @@ watch(
tags: [], tags: [],
icon: '' icon: ''
}) })
jsonContent.value = ''
} }
}, },
{ immediate: true } { 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 () => { const handleFormSubmit = async () => {
try { try {
formLoading.value = true formLoading.value = true
let data let headers = null
if (formMode.value === 'json') { if (form.headersText.trim()) {
try { try {
data = JSON.parse(jsonContent.value) headers = JSON.parse(form.headersText)
} catch { } catch {
message.error('JSON 格式错误') message.error('请求头 JSON 格式错误')
return return
} }
} else { }
let headers = null const data = {
if (form.headersText.trim()) { slug: form.slug,
try { name: form.name,
headers = JSON.parse(form.headersText) description: form.description || null,
} catch { transport: form.transport,
message.error('请求头 JSON 格式错误') url: form.url || null,
return command: form.command || null,
} args: form.args.length > 0 ? form.args : null,
} env: form.env,
data = { headers,
slug: form.slug, timeout: form.timeout || null,
name: form.name, sse_read_timeout: form.sse_read_timeout || null,
description: form.description || null, tags: form.tags.length > 0 ? form.tags : null,
transport: form.transport, icon: form.icon || null
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()) { if (!data.slug?.trim()) {
message.error('MCP 标识不能为空') message.error('MCP 标识不能为空')
@ -308,7 +238,8 @@ const handleFormSubmit = async () => {
} }
if (props.editMode) { 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) { if (result.success) {
message.success('MCP 更新成功') message.success('MCP 更新成功')
} else { } else {
@ -336,21 +267,4 @@ const handleFormSubmit = async () => {
<style lang="less" scoped> <style lang="less" scoped>
@import '@/assets/css/extensions.less'; @import '@/assets/css/extensions.less';
.mode-switch {
margin-bottom: 16px;
text-align: right;
}
.json-mode {
.json-textarea {
font-family: 'Monaco', 'Consolas', monospace;
font-size: 13px;
}
.json-actions {
margin-top: 12px;
display: flex;
gap: 8px;
}
}
</style> </style>