diff --git a/apps/admin/app/components/McpToolPicker.vue b/apps/admin/app/components/McpToolPicker.vue
new file mode 100644
index 00000000..bcca13e3
--- /dev/null
+++ b/apps/admin/app/components/McpToolPicker.vue
@@ -0,0 +1,156 @@
+
+
+
+
+
+
+
+
diff --git a/apps/admin/app/pages/agents.vue b/apps/admin/app/pages/agents.vue
index ee5bf918..5e033799 100644
--- a/apps/admin/app/pages/agents.vue
+++ b/apps/admin/app/pages/agents.vue
@@ -92,7 +92,11 @@
-
+
{{ s.slot }}{{ svcName(s.id) }}
›
@@ -102,7 +106,7 @@
🌐 {{ langCount(a) || '—' }} 语言
🎙 {{ (a.voice_map || []).length || '—' }} 音色
- 🧩 {{ (a.mcp_svc_ids || []).length || '—' }} MCP
+ 🧩 {{ (a.mcp_svc_ids || []).length || '—' }} MCP
🔤 {{ (a.variables || []).length || '—' }} 变量
@@ -258,13 +262,14 @@
→
+ ⚠ {{ slotCredWarn }}
候选 = 本应用已启用的服务 + 「全局兜底」的服务(标注了的那些,来自全局默认作用域)。
引用全局服务是允许的:应用没单独配时本来就走它。其它应用的服务不会出现在这里。
@@ -397,27 +402,36 @@
MCP 服务绑定(给 Agent 挂工具)
当前作用域还没有已启用的 MCP 服务,先去「第三方服务配置 → MCP 服务」添加。
- 勾选后,客户端会把这些 MCP 服务的工具挂给该 Agent;服务地址与工具白名单在第三方服务配置里维护,这里只存引用。
-
-
@@ -449,6 +463,7 @@ import { useApi } from '~/composables/useApi'
import { useToast } from '~/composables/useToast'
import { LANG_OPTIONS } from '~/composables/useLangOptions'
import LangSelect from '~/components/LangSelect.vue'
+import McpToolPicker from '~/components/McpToolPicker.vue'
definePageMeta({ title: 'Agent 配置' })
@@ -494,6 +509,7 @@ interface Agent {
default_lang: string
voice_map: AgentVoice[]
mcp_svc_ids: string[]
+ mcp_tools: Record
variables: AgentVariable[]
sort: number
}
@@ -574,6 +590,9 @@ const scopeApps = ref([])
const agents = ref([])
const allSvcs = ref([]) // 当前作用域(应用/全局)自己的第三方服务
const globalSvcs = ref([]) // 全局默认作用域的服务:应用作用域下作为兜底候选
+// svc_id → 一个值都没有的凭据字段(后端 getsvcconfigs 算好:基础行与所有区域分叉都空才算缺)。
+// 引用了这种服务的 agent,配置下发到客户端就是一串空字符串,运行时必然鉴权失败。
+const credMissing = ref>({})
const ttsVoices = ref([])
const keyword = ref('')
const filterType = ref('')
@@ -588,7 +607,7 @@ function emptyForm(): Agent {
app_name: '', id: '', name: '', type: 'chat', description: '', avatar_url: '', enable: true,
stt_svc_id: '', llm_svc_id: '', tts_svc_id: '', mt_svc_id: '', sts_svc_id: '', ast_svc_id: '',
system_prompt: '', gender: 0, greeting_enabled: false, greeting_text: '',
- support_langs: '', default_lang: '', voice_map: [], mcp_svc_ids: [], variables: [], sort: 0,
+ support_langs: '', default_lang: '', voice_map: [], mcp_svc_ids: [], mcp_tools: {}, variables: [], sort: 0,
}
}
const form = reactive(emptyForm())
@@ -625,6 +644,15 @@ const grouped = computed(() => {
}
return groups
})
+// 卡片上 MCP 数字的悬浮说明:哪些服务、各自收了几个工具
+function mcpStatTitle(a: Agent): string {
+ const ids = a.mcp_svc_ids ?? []
+ if (!ids.length) return '未绑定 MCP 服务'
+ return ids.map((id) => {
+ const n = (a.mcp_tools ?? {})[id]?.length ?? 0
+ return `${svcName(id)}:${n ? n + ' 个工具' : '全部工具'}`
+ }).join('\n')
+}
function langCount(a: Agent): number {
return (a.support_langs || '').split(',').map((x) => x.trim()).filter(Boolean).length
}
@@ -657,18 +685,22 @@ async function loadScopeApps() {
// 只是不能引用「别的应用」的服务——那些本就不在这两次查询的结果里。
async function loadSvcs() {
const app = scopeApp.value
+ type SvcResp = { items: SvcConfig[]; cred_missing?: Record }
const [own, global] = await Promise.all([
- webApi<{ items: SvcConfig[] }>('getsvcconfigs', { app_name: app }).catch(() => null),
- app ? webApi<{ items: SvcConfig[] }>('getsvcconfigs', { app_name: '' }).catch(() => null) : Promise.resolve(null),
+ webApi('getsvcconfigs', { app_name: app }).catch(() => null),
+ app ? webApi('getsvcconfigs', { app_name: '' }).catch(() => null) : Promise.resolve(null),
])
allSvcs.value = own?.items ?? []
globalSvcs.value = global?.items ?? []
+ // 应用内同 id 的服务覆盖全局,缺凭据判定也按这个顺序:先铺全局,再让应用作用域的结论盖上去
+ credMissing.value = { ...(global?.cred_missing ?? {}), ...(own?.cred_missing ?? {}) }
}
async function loadAgents() {
try {
const d = await webApi<{ items: Agent[] }>('getagents', { app_name: scopeApp.value })
agents.value = (d?.items ?? []).map((a) => ({
- ...a, voice_map: a.voice_map ?? [], mcp_svc_ids: a.mcp_svc_ids ?? [], variables: a.variables ?? [],
+ ...a, voice_map: a.voice_map ?? [], mcp_svc_ids: a.mcp_svc_ids ?? [],
+ mcp_tools: a.mcp_tools ?? {}, variables: a.variables ?? [],
}))
} catch (e: any) {
error(e?.message ?? '加载失败')
@@ -748,7 +780,20 @@ function addVoice() {
const mcpSvcs = computed(() => svcsByCats([CAT_MCP]))
function toggleMcp(id: string) {
const i = form.mcp_svc_ids.indexOf(id)
- i >= 0 ? form.mcp_svc_ids.splice(i, 1) : form.mcp_svc_ids.push(id)
+ if (i >= 0) {
+ form.mcp_svc_ids.splice(i, 1)
+ delete form.mcp_tools[id] // 解绑就把工具白名单一起丢掉,免得重新绑上时冒出上次的残留
+ } else {
+ form.mcp_svc_ids.push(id)
+ }
+}
+// 某个 MCP 服务在本 agent 下允许的工具;空数组=不设白名单(该服务的工具全放开)
+function mcpToolsOf(id: string): string[] {
+ return form.mcp_tools[id] ?? []
+}
+function setMcpTools(id: string, tools: string[]) {
+ if (tools.length) form.mcp_tools[id] = tools
+ else delete form.mcp_tools[id]
}
// ===== 变量 =====
@@ -794,6 +839,10 @@ function svcsByCats(cats: number[]): SvcConfig[] {
function isVisionSvc(s: SvcConfig): boolean {
return svcCats(s).includes(CAT_LLM_VISION)
}
+// 该服务的凭据是不是一个都没配(配了才有的下拉/胶囊红标)
+function credMiss(id?: string): string[] {
+ return (id && credMissing.value[id]) || []
+}
function svcName(id?: string): string {
if (!id) return '未配置'
const s = svcPool.value.find((x) => x.id === id)
@@ -838,6 +887,18 @@ function tabBadge(key: string): string {
if (key === 'svc') return form.voice_map.length ? String(form.voice_map.length) : ''
return ''
}
+// 服务编排页签的凭据提示:选中的服务里有「凭据一个都没配」的,直接点名——
+// 这种配置存得下、下发得出去,但客户端拿到的是空字符串,跑起来必然鉴权失败。
+const slotCredWarn = computed(() => {
+ const bad = slotsOfType(form.type)
+ .map((s) => ({ slot: s.slot, id: ((form as any)[s.key] || '').trim() }))
+ .filter((x) => credMiss(x.id).length)
+ if (!bad.length) return ''
+ return bad
+ .map((x) => `${x.slot} 选的「${svcName(x.id)}」还没配凭据(${credMiss(x.id).join('、')})`)
+ .join(';') + ' —— 去「第三方服务配置」补齐,否则客户端拿到的是空值'
+})
+
// 保存前的缺项提示:直接显示在底部,省得点了保存才知道哪没填
const missingHint = computed(() => {
if (!form.name.trim()) return '还差:名称'
@@ -849,6 +910,7 @@ function openModal(a: Agent | null, editing: boolean) {
Object.assign(form, emptyForm(), a ?? {}, {
voice_map: (a?.voice_map ?? []).map((v) => ({ ...v })),
mcp_svc_ids: [...(a?.mcp_svc_ids ?? [])],
+ mcp_tools: JSON.parse(JSON.stringify(a?.mcp_tools ?? {})),
variables: (a?.variables ?? []).map((v) => ({ ...v })),
})
isEditing.value = editing
@@ -892,6 +954,9 @@ function buildPayload(a: Agent): Agent {
}
payload.voice_map = (a.voice_map ?? []).filter((v) => v.language && v.voice)
payload.mcp_svc_ids = (a.mcp_svc_ids ?? []).filter(Boolean)
+ payload.mcp_tools = Object.fromEntries(
+ payload.mcp_svc_ids.filter((id) => (a.mcp_tools ?? {})[id]?.length).map((id) => [id, a.mcp_tools[id]]),
+ )
payload.variables = (a.variables ?? []).filter((v) => (v.key || '').trim())
return payload
}
@@ -1017,6 +1082,10 @@ onMounted(async () => {
}
.svc-pill b { font-size: 9.5px; font-weight: 700; letter-spacing: .03em; color: var(--accent); }
.svc-pill.miss { color: #dc2626; background: #fef2f2; border-color: #fecaca; }
+/* 服务选了,但它的凭据一个都没配——下发到客户端就是空字符串,跟没选一样跑不起来 */
+.svc-pill.nocred { color: #b45309; background: #fffbeb; border-color: #fde68a; }
+.svc-pill.nocred b { color: #b45309; }
+.ed-warn { font-size: 11.5px; color: #b45309; background: #fffbeb; border: 1px solid #fde68a; border-radius: 8px; padding: 6px 9px; margin-top: 8px; line-height: 1.6; }
.pipe-arrow { color: #cbd5e1; font-size: 13px; }
.card-stats { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 10px; font-size: 10.5px; color: #94a3b8; }
@@ -1090,6 +1159,14 @@ onMounted(async () => {
.var-head, .var-row { display: grid; grid-template-columns: 1.1fr 1fr 1.2fr 1.2fr 52px; gap: 8px; align-items: center; }
.var-head span { font-size: 10px; color: #cbd5e1; letter-spacing: .03em; }
+.mcp-list { display: flex; flex-direction: column; gap: 10px; }
+.mcp-card { border: 1.5px solid #e2e8f0; border-radius: 12px; overflow: hidden; transition: all .15s; }
+.mcp-card:hover { border-color: #c7d2fe; }
+.mcp-card.on { border-color: #6366f1; }
+.mcp-card-head { display: flex; align-items: center; gap: 9px; padding: 10px 12px; font-size: 12.5px; color: #475569; cursor: pointer; }
+.mcp-card.on .mcp-card-head { background: #eef2ff; color: #4338ca; }
+.mcp-count { font-size: 10.5px; color: #94a3b8; flex-shrink: 0; }
+.mcp-card-body { padding: 0 12px 12px; }
.mcp-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(210px, 1fr)); gap: 8px; }
.mcp-item { display: flex; align-items: center; gap: 8px; min-width: 0; padding: 9px 11px; border: 1.5px solid #e2e8f0; border-radius: 10px; font-size: 12.5px; color: #475569; cursor: pointer; transition: all .15s; }
.mcp-item:hover { border-color: #c7d2fe; background: #f8fafc; }
diff --git a/apps/admin/app/pages/serviceconfig.vue b/apps/admin/app/pages/serviceconfig.vue
index b46b116b..c9bc4cfa 100644
--- a/apps/admin/app/pages/serviceconfig.vue
+++ b/apps/admin/app/pages/serviceconfig.vue
@@ -58,6 +58,11 @@
{{ s.provider }}
{{ (s.fields || []).length }} 字段
🔒{{ encCount(s) }}
+ ⚠ 缺凭据
{{ regionChip(s).text }}
@@ -148,7 +153,10 @@
音色本身是代码里的常量表(后端 comm/ttsvoice.go),实际用哪个在 Agent 里按语言+性别选,同组第一个是默认。
- 配置字段(全区域共用)
+
+ 配置字段(全区域共用)
+
+
✦ MCP 服务与其他第三方服务同表存储(svc_config):这里填的 url/type/tools 为全区域默认;
只在某些区域生效的地址,切到对应区域做分叉即可,卡片上会显示已分叉的区域数。
@@ -190,6 +198,19 @@
+
+
+
+
+ 工具白名单
+ 勾选结果写入上面的 tools 字段(逗号分隔,空=全部)
+
+
+
@@ -341,6 +362,19 @@
+
+
+
+
+ 工具白名单
+ 勾选结果写入上面的 tools 字段(逗号分隔,空=全部)
+
+
+
@@ -407,6 +441,20 @@
+
+
+
+ 工具白名单({{ curRegionLabel }})
+ 写入本区域 tools 覆盖值;全清空=留空=继承默认
+
+
+
+
{{ curRegionLabel }} 专属字段
@@ -671,7 +719,7 @@ interface RegionExtraField {
}
const { webApi, consoleApi } = useApi()
-const { success, error } = useToast()
+const { success, error, warn } = useToast()
// ===== 作用域:全局默认('') 或 某应用(app_name) =====
// 第三方服务/区域覆盖均按应用分离:应用有自己的就用应用的、没有回退全局。此选择器切换正在管理的作用域。
@@ -694,6 +742,81 @@ function scatInfo(v: number) {
return SCAT.find((s) => s.v === v) || { short: '?', color: '#999', bg: '#eee', v: 0, label: '' }
}
// 当前编辑的是否 MCP 服务:新增向导看 editType,默认/区域编辑看当前服务 categories。
+// ── 按模板补齐字段 ──
+// 内置模板后来新增的字段(如 llm_qwen 的 base_url)只会补进模板表,已经建好的服务实例不会自动长出来,
+// 于是客户端拿到的这条服务就缺字段。这里按模板把缺的字段补进当前编辑中的字段表:
+// 只加不改——已有字段的值/描述/加密标记一律保留,补进来的带模板默认值,确认后照常点保存生效。
+const fillFromTemplateTip = '内置模板新增的字段不会自动补到已建好的服务上,点这里按模板补齐(只加不改,需保存)'
+
+// 服务实例与模板的对应:优先按同 id(向导建的服务 id 就取自模板 id),否则按「服务商 + 类别」找。
+function matchTemplate(): SvcTemplate | undefined {
+ const byId = templates.value.find((t) => t.id === editForm.id)
+ if (byId) return byId
+ const cat = editType.value || parseCats(curSvc.value?.categories || '')[0]
+ return templates.value.find((t) => t.provider === editForm.provider && t.category === cat)
+}
+
+function fillFieldsFromTemplate() {
+ const tpl = matchTemplate()
+ if (!tpl) {
+ warn('没找到对应的服务商模板(可到「服务商模板」里补一个,或手动 + 添加字段)')
+ return
+ }
+ const have = new Set(editFields.value.map((f) => f.key))
+ const added: string[] = []
+ for (const f of tpl.fields || []) {
+ if (have.has(f.key)) continue
+ editFields.value.push({ ...f, sort: editFields.value.length, _show: false })
+ added.push(f.key)
+ }
+ if (!added.length) {
+ success(`字段已与模板「${tpl.name}」一致,无需补齐`)
+ return
+ }
+ success(`已按模板「${tpl.name}」补上 ${added.join('、')},确认无误后点保存`)
+}
+
+// ── MCP 工具白名单 ──
+// tools 字段存的是逗号分隔字符串,McpToolPicker 按数组来,这里做两个方向的转换。
+// url/type 取当前正在编辑的那份值:默认视图看 def_value,区域分叉看「覆盖值 || 继承的默认值」。
+function pickField
(list: T[], key: string): T | undefined {
+ return list.find((f) => f.key === key)
+}
+function csvToArr(v?: string): string[] {
+ return (v || '').split(',').map((x) => x.trim()).filter(Boolean)
+}
+
+const mcpEditUrl = computed(() => (pickField(editFields.value, 'url')?.def_value || '').trim())
+const mcpEditType = computed(() => (pickField(editFields.value, 'type')?.def_value || '0').trim())
+const mcpEditTools = computed({
+ get: () => csvToArr(pickField(editFields.value, 'tools')?.def_value),
+ set: (v) => {
+ const f = pickField(editFields.value, 'tools')
+ if (f) f.def_value = v.join(',')
+ // 模板里本来就带 tools 字段;万一被删了就补一条回去,否则勾选结果无处可存
+ else editFields.value.push({ key: 'tools', description: '启用工具(逗号分隔,空=全部)', encrypted: false, def_value: v.join(','), sort: editFields.value.length } as SvcField)
+ },
+})
+
+const mcpRgnUrl = computed(() => {
+ const f = pickField(overrideFields.value, 'url')
+ return ((f?._input || f?.value) || '').trim()
+})
+const mcpRgnType = computed(() => {
+ const f = pickField(overrideFields.value, 'type')
+ return ((f?._input || f?.value) || '0').trim()
+})
+const mcpRgnTools = computed({
+ get: () => {
+ const f = pickField(overrideFields.value, 'tools')
+ return csvToArr(f?._input || f?.value)
+ },
+ set: (v) => {
+ const f = pickField(overrideFields.value, 'tools')
+ if (f) f._input = v.join(',')
+ },
+})
+
function isMcpType(): boolean {
if (editType.value === SCAT_MCP) return true
return !!curSvc.value && parseCats(curSvc.value.categories).includes(SCAT_MCP)
@@ -791,6 +914,9 @@ const allSvcs = ref([])
// svc_id → 已建区域分叉的区域枚举(后端 getsvcconfigs 一并返回)。卡片据此显示区域标识:
// MCP 这类值全部落在区域分叉上的服务,不显示的话在默认视图里看着像「什么都没配」。
const svcRegions = ref>({})
+// svc_id → 一个值都没有的凭据字段(后端 getsvcconfigs 一并算好:基础行与所有区域分叉都空才算缺)。
+// 前端自己判不了——加密字段返回的是脱敏值,且凭据可能只配在某个区域分叉上。
+const credMissing = ref>({})
const curSvc = ref(null)
const curRegion = ref(0)
const svcModalVisible = ref(false)
@@ -854,14 +980,16 @@ const templatesByCat = computed(() => {
// ===== 加载服务列表 =====
async function loadSvcs() {
try {
- const data = await webApi<{ items: ThirdSvcConfig[]; regions?: Record }>('getsvcconfigs', { app_name: scopeApp.value })
+ const data = await webApi<{ items: ThirdSvcConfig[]; regions?: Record; cred_missing?: Record }>('getsvcconfigs', { app_name: scopeApp.value })
allSvcs.value = data?.items ?? []
svcRegions.value = data?.regions ?? {}
+ credMissing.value = data?.cred_missing ?? {}
backendReady.value = true
} catch {
backendReady.value = false
allSvcs.value = []
svcRegions.value = {}
+ credMissing.value = {}
return
}
if (curSvc.value) {
@@ -1048,12 +1176,27 @@ function collectOverrideData() {
return { overrides, disabled_keys, extra_fields }
}
+// 保存前的空字段检查:凭据为空由后端硬拦(启用中的服务存不下去),这里管的是其余字段——
+// base_url / languages / region 这类留空往往是有意的(走默认/不限),拦死会误伤,所以只列出来让人确认一次。
+// 之前 stt_azure 就是三个字段全空却一路存了下去,客户端拿到空串才发现。
+function confirmEmptyFields(): boolean {
+ const empty = editFields.value
+ .filter((f) => (f.key || '').trim() && !(f.def_value || '').trim())
+ .map((f) => `${f.key}${f.description ? `(${f.description})` : ''}`)
+ if (!empty.length) return true
+ return window.confirm(
+ `以下字段没有填值:\n\n${empty.join('\n')}\n\n` +
+ '这些值会原样下发给客户端(空字符串)。确认它们本来就该留空吗?',
+ )
+}
+
// ===== 保存 =====
async function performSave(isNew: boolean): Promise {
const isNew2 = isNew || !curSvc.value
if (curRegion.value === 0) {
const data = collectDefaultData(isNew2)
if (!data.id || !data.name) { error('服务ID 和名称 必填'); return false }
+ if (!confirmEmptyFields()) return false
const method = isNew2 ? 'addsvcconfig' : 'updatesvcconfig'
try {
const res = await webApi(method, data)
@@ -1375,6 +1518,17 @@ onMounted(() => {
.meta-row > .lock-badge {
flex-shrink: 0;
}
+/* 凭据一个都没配的服务:卡片上直接标红,别等客户端拿到空字符串才发现 */
+.meta-row > .cred-badge {
+ flex-shrink: 0;
+ font-size: 10px;
+ font-weight: 700;
+ padding: 1px 6px;
+ border-radius: 5px;
+ background: #fef2f2;
+ color: #b91c1c;
+ border: 1px solid #fecaca;
+}
/* 标签下行:区域标识独占一行,长文案(如「本区域已分叉」)不会被上行挤掉 */
.region-row {
display: flex;
diff --git a/apps/proto/user/user_msg_v3.proto b/apps/proto/user/user_msg_v3.proto
index 61434021..e69bbee4 100644
--- a/apps/proto/user/user_msg_v3.proto
+++ b/apps/proto/user/user_msg_v3.proto
@@ -8,6 +8,9 @@ import "db.proto";
// - 不再单独下发 mcps:MCP 已并入第三方服务配置(svc_config 的 MCP 类别),在 thirdsvcs 里,
// 按 categories 含 "10" 识别;url/type/tools 在 fields 里。
// - 新增 meet:会议记录支持的语言与可选总结大模型,来自后台「会议记录服务」编排。
+// - agents 换成 AgentItemV3(与 user_getagents_v3 同一结构):带 type(chat|sts-chat) 与服务编排
+// (stt/llm/tts/sts_svc_id)、MCP 绑定、变量、已补全的音色表。v2 下发的旧 DBAgent 结构没有这些,
+// 客户端拿到 agent 却不知道该走哪条链路、调哪个服务。
// 整体响应经网关 AES-CBC 加密(响应头 X-Encrypted:1),故 fields 里的凭据不再单独加密。
message UserGetAppConfigV3Req{
@@ -32,7 +35,7 @@ message MeetCapabilitiesItem{
message UserGetAppConfigV3Resp{
map env = 1; //@go_tags(`json:"env"`) 应用环境变量(app_config + 本区域 global_config)
- repeated DBAgent agents = 2; //@go_tags(`json:"agents"`) 智能体列表
+ repeated AgentItemV3 agents = 2; //@go_tags(`json:"agents"`) 智能体列表(结构同 user_getagents_v3,含 type 与服务编排)
repeated DBProduct products = 3; //@go_tags(`json:"products"`) 产品列表
repeated ThirdSvcItem thirdsvcs = 4; //@go_tags(`json:"thirdsvcs"`) 第三方服务(含 MCP 类,按用户区域合并、凭据已解密为明文)
MeetCapabilitiesItem meet = 5; //@go_tags(`json:"meet"`) 会议记录能力(语言 + 总结大模型)
@@ -53,6 +56,14 @@ message AgentVariableItem{
string description = 4; //@go_tags(`json:"description"`) 备注
}
+//agent 的 MCP 绑定项:绑了哪个 MCP 服务 + 这个 agent 允许它调用的工具。
+//同一个 agent 挂多个 MCP 时工具会互相干扰(同名工具、用不上的工具挤占模型注意力),
+//故除服务自身的 tools 白名单外,再按 agent 收一次口。
+message AgentMcpBindingItem{
+ string svc_id = 1; //@go_tags(`json:"svc_id"`) MCP 服务 id(详情在 getappconfig_v3 的 thirdsvcs 里)
+ repeated string tools = 2; //@go_tags(`json:"tools"`) 允许调用的工具名;空=该服务的工具全放开(仍受服务自身 tools 白名单约束)
+}
+
message AgentItemV3{
string id = 1; //@go_tags(`json:"id"`) agent id
string name = 2; //@go_tags(`json:"name"`) 名称
@@ -73,6 +84,7 @@ message AgentItemV3{
repeated string mcp_svc_ids = 17; //@go_tags(`json:"mcp_svc_ids"`) 绑定的 MCP 服务 id(详情在 getappconfig_v3 的 thirdsvcs 里)
repeated AgentVariableItem variables = 18; //@go_tags(`json:"variables"`) 变量定义
int32 sort = 19; //@go_tags(`json:"sort"`) 排序
+ repeated AgentMcpBindingItem mcps = 20; //@go_tags(`json:"mcps"`) MCP 绑定(含每个服务允许的工具);mcp_svc_ids 是它的 id 平铺,保留给老客户端
}
message UserGetAgentsV3Resp{
diff --git a/apps/services/cmd/syncgoods/main.go b/apps/services/cmd/syncgoods/main.go
new file mode 100644
index 00000000..75098530
--- /dev/null
+++ b/apps/services/cmd/syncgoods/main.go
@@ -0,0 +1,110 @@
+// 一次性工具:把某个应用业务库 goods 表导出的 JSON 同步进 console 主库的 app_goods 表。
+// 用法: go run ./cmd/syncgoods -dsn -file goods.json -app Voitrans-Test [-apply]
+package main
+
+import (
+ "encoding/json"
+ "flag"
+ "fmt"
+ "os"
+ "sort"
+ "time"
+
+ "yunyan/comm"
+
+ "gorm.io/driver/postgres"
+ "gorm.io/gorm"
+ "gorm.io/gorm/logger"
+)
+
+type srcGoods struct {
+ Id string `json:"id"`
+ Enable int `json:"enable"`
+ Name string `json:"name"`
+ Localname string `json:"localname"`
+ Price int32 `json:"price"`
+ Usagetype int32 `json:"usagetype"`
+ Ainum int64 `json:"ainum"`
+ Meetnum int64 `json:"meetnum"`
+ Tradenum int64 `json:"tradenum"`
+ Viptime int32 `json:"viptime"`
+}
+
+func main() {
+ dsn := flag.String("dsn", "", "console 主库 postgres dsn")
+ file := flag.String("file", "", "源 goods JSON 文件")
+ app := flag.String("app", "", "目标应用名 app_goods.app_name")
+ remark := flag.String("remark", "", "备注")
+ apply := flag.Bool("apply", false, "真正写入(默认只做 dry-run 打印)")
+ flag.Parse()
+ if *dsn == "" || *file == "" || *app == "" {
+ fmt.Println("缺少参数 -dsn/-file/-app")
+ os.Exit(1)
+ }
+
+ buf, err := os.ReadFile(*file)
+ if err != nil {
+ panic(err)
+ }
+ var src []*srcGoods
+ if err = json.Unmarshal(buf, &src); err != nil {
+ panic(err)
+ }
+ sort.Slice(src, func(i, j int) bool { return src[i].Id < src[j].Id })
+
+ db, err := gorm.Open(postgres.Open(*dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Error)})
+ if err != nil {
+ panic(err)
+ }
+ if err = db.Table(comm.TableAppGoods).AutoMigrate(&comm.AppGoods{}); err != nil {
+ panic(err)
+ }
+ fmt.Printf("表 %s 就绪,源记录 %d 条,目标应用 %s,apply=%v\n", comm.TableAppGoods, len(src), *app, *apply)
+
+ now := time.Now().Unix()
+ var ins, upd int
+ for i, g := range src {
+ row := &comm.AppGoods{
+ AppName: *app,
+ GoodsId: g.Id,
+ Enable: g.Enable != 0,
+ Name: g.Name,
+ Price: g.Price,
+ Usagetype: g.Usagetype,
+ Ainum: g.Ainum,
+ Meetnum: g.Meetnum,
+ Tradenum: g.Tradenum,
+ Viptime: g.Viptime,
+ Localname: g.Localname,
+ Sort: int32((i + 1) * 10),
+ Remark: *remark,
+ Createtime: now,
+ Updatetime: now,
+ }
+ old := &comm.AppGoods{}
+ err := db.Table(comm.TableAppGoods).Where("app_name=? AND goods_id=?", *app, g.Id).Take(old).Error
+ switch {
+ case err == gorm.ErrRecordNotFound:
+ ins++
+ fmt.Printf(" + %-22s %-16s price=%d type=%d enable=%v\n", g.Id, g.Name, g.Price, g.Usagetype, row.Enable)
+ if *apply {
+ if err = db.Table(comm.TableAppGoods).Create(row).Error; err != nil {
+ panic(err)
+ }
+ }
+ case err != nil:
+ panic(err)
+ default:
+ upd++
+ row.Id = old.Id
+ row.Createtime = old.Createtime
+ fmt.Printf(" ~ %-22s %-16s (覆盖已有 id=%d)\n", g.Id, g.Name, old.Id)
+ if *apply {
+ if err = db.Table(comm.TableAppGoods).Where("id=?", old.Id).Updates(row).Error; err != nil {
+ panic(err)
+ }
+ }
+ }
+ }
+ fmt.Printf("完成:新增 %d,更新 %d%s\n", ins, upd, map[bool]string{false: "(dry-run,未写库)", true: ""}[*apply])
+}
diff --git a/apps/services/comm/agentcfg.go b/apps/services/comm/agentcfg.go
index 03b188ce..30b85a6b 100644
--- a/apps/services/comm/agentcfg.go
+++ b/apps/services/comm/agentcfg.go
@@ -78,6 +78,11 @@ type AgentConfig struct {
// McpSvcIds 绑定的 MCP 服务(引用 ThirdSvcConfig.Id,类别须含 SvcCatMCP)。
// 空=不给该 agent 挂工具。MCP 服务本身的 url/工具白名单在第三方服务配置里,这里只存引用。
McpSvcIds []string `gorm:"column:mcp_svc_ids;type:jsonb;serializer:json" json:"mcp_svc_ids"`
+ // McpTools 每个已绑定 MCP 服务「这个 agent 能用哪些工具」:key=MCP 服务 id,value=工具名列表。
+ // 缺 key 或列表为空 = 该服务的工具全放开(与服务侧 tools 白名单再取交集)。
+ // 一个 agent 挂多个 MCP 时,工具名会互相干扰(同名工具、用不上的工具挤占模型注意力),
+ // 所以按 agent 再收一次口,而不是只有服务级一份白名单。
+ McpTools map[string][]string `gorm:"column:mcp_tools;type:jsonb;serializer:json" json:"mcp_tools"`
// Variables 提示词/开场白里的变量占位({{key}}),见 AgentVariable。
Variables []AgentVariable `gorm:"column:variables;type:jsonb;serializer:json" json:"variables"`
Sort int32 `gorm:"column:sort" json:"sort"`
diff --git a/apps/services/modules/console/api_agent.go b/apps/services/modules/console/api_agent.go
index 817c2bbf..b1b930a0 100644
--- a/apps/services/modules/console/api_agent.go
+++ b/apps/services/modules/console/api_agent.go
@@ -143,6 +143,23 @@ func normalizeAgent(a *AgentConfig) {
}
}
a.McpSvcIds = ids
+ // 工具白名单只保留仍绑着的服务:取消绑定后再重新勾上,不该冒出上次的残留选择。
+ // 某服务的列表为空就整条删掉——空列表与「没这个 key」同义(全放开),留着只会让下发数据变脏。
+ tools := make(map[string][]string, len(a.McpTools))
+ for _, id := range ids {
+ names := make([]string, 0, len(a.McpTools[id]))
+ dup := map[string]bool{}
+ for _, n := range a.McpTools[id] {
+ if n = strings.TrimSpace(n); n != "" && !dup[n] {
+ dup[n] = true
+ names = append(names, n)
+ }
+ }
+ if len(names) > 0 {
+ tools[id] = names
+ }
+ }
+ a.McpTools = tools
vars := make([]comm.AgentVariable, 0, len(a.Variables))
for _, v := range a.Variables {
if v.Key = strings.TrimSpace(v.Key); v.Key != "" {
diff --git a/apps/services/modules/console/api_agent_test.go b/apps/services/modules/console/api_agent_test.go
new file mode 100644
index 00000000..01386d78
--- /dev/null
+++ b/apps/services/modules/console/api_agent_test.go
@@ -0,0 +1,58 @@
+package console
+
+import (
+ "testing"
+
+ "yunyan/comm"
+)
+
+// normalizeAgent 是落库前最后一道清洗:解绑过的 MCP 服务不能把工具白名单留在库里,
+// 否则重新勾上该服务时会冒出上一轮的选择,运营看不见、客户端却会照着执行。
+func TestNormalizeAgentMcpTools(t *testing.T) {
+ a := &AgentConfig{
+ McpSvcIds: []string{" mcp_map ", "mcp_music", "mcp_map", ""},
+ McpTools: map[string][]string{
+ "mcp_map": {"geocode", " geocode ", "", "route"},
+ "mcp_music": {}, // 空列表 = 全放开,不该留在库里
+ "mcp_gone": {"search"}, // 已解绑的服务,残留必须清掉
+ },
+ }
+ normalizeAgent(a)
+
+ if len(a.McpSvcIds) != 2 || a.McpSvcIds[0] != "mcp_map" || a.McpSvcIds[1] != "mcp_music" {
+ t.Fatalf("MCP 引用清洗错:%v", a.McpSvcIds)
+ }
+ if _, ok := a.McpTools["mcp_gone"]; ok {
+ t.Fatalf("解绑服务的工具白名单没清掉:%v", a.McpTools)
+ }
+ if _, ok := a.McpTools["mcp_music"]; ok {
+ t.Fatalf("空白名单应整条删掉(空=全放开):%v", a.McpTools)
+ }
+ got := a.McpTools["mcp_map"]
+ if len(got) != 2 || got[0] != "geocode" || got[1] != "route" {
+ t.Fatalf("工具名去空白/去重错:%v", got)
+ }
+}
+
+// 没配过工具白名单的老 agent(mcp_tools 为 nil)不能因为清洗炸掉,也不该凭空长出条目。
+func TestNormalizeAgentMcpToolsNil(t *testing.T) {
+ a := &AgentConfig{McpSvcIds: []string{"mcp_map"}}
+ normalizeAgent(a)
+ if a.McpTools == nil || len(a.McpTools) != 0 {
+ t.Fatalf("nil 白名单应清洗成空 map:%v", a.McpTools)
+ }
+}
+
+// id 前缀按类型走,肉眼能看出这是哪类 agent;未知类型兜底成 agent。
+func TestAgentIdPrefix(t *testing.T) {
+ for typ, want := range map[string]string{
+ comm.AgentTypeChat: "chat",
+ comm.AgentTypeStsChat: "sts",
+ "": "agent",
+ "whatever": "agent",
+ } {
+ if got := agentIdPrefix(typ); got != want {
+ t.Fatalf("type=%q 前缀应为 %s,实际 %s", typ, want, got)
+ }
+ }
+}
diff --git a/apps/services/modules/console/api_config.go b/apps/services/modules/console/api_config.go
index 668e9a3c..5588d243 100644
--- a/apps/services/modules/console/api_config.go
+++ b/apps/services/modules/console/api_config.go
@@ -96,6 +96,7 @@ func ensureConfigTables() error {
for _, s := range []string{
"ALTER TABLE " + comm.TableAgentConfig + " ADD COLUMN IF NOT EXISTS mcp_svc_ids jsonb",
"ALTER TABLE " + comm.TableAgentConfig + " ADD COLUMN IF NOT EXISTS variables jsonb",
+ "ALTER TABLE " + comm.TableAgentConfig + " ADD COLUMN IF NOT EXISTS mcp_tools jsonb",
} {
if res := postgres.Exec(s); res.Error != nil {
return res.Error
diff --git a/apps/services/modules/console/api_mcptools.go b/apps/services/modules/console/api_mcptools.go
new file mode 100644
index 00000000..8d030593
--- /dev/null
+++ b/apps/services/modules/console/api_mcptools.go
@@ -0,0 +1,161 @@
+package console
+
+// ============================ MCP 工具发现 (mcptools) ============================
+//
+// 后台配 MCP 服务时,「这个服务有哪些工具」只有服务端自己知道。以前运营得去问对接方、
+// 再手抄工具名填进 tools 白名单,抄错了要到线上调用失败才发现。这里直接连上去问:
+// 走标准 MCP 握手(initialize → tools/list),把工具名与说明列出来给人勾。
+//
+// 两种入参(二选一):
+// {url, type} —— 新建/编辑服务时还没落库,按当前填的地址现探
+// {app_name, id, region} —— 已落库的服务(agent 绑定 MCP 时用,只有 svc id);
+// 基础行没配 url 时自动找一个配了 url 的区域分叉来探
+//
+// 传输类型 type:0=HTTP(Streamable HTTP),1=SSE,与 comm.McpBaseFields / DBMcpServer.Stype 一致。
+
+import (
+ "context"
+ "strconv"
+ "strings"
+ "time"
+
+ "yunyan/comm"
+ "yunyan/lego/sys/postgres"
+ "yunyan/pb"
+
+ "github.com/gin-gonic/gin"
+ mcpclient "github.com/mark3labs/mcp-go/client"
+ mcpproto "github.com/mark3labs/mcp-go/mcp"
+)
+
+// mcpToolsTimeout 单次探测的总超时:握手 + 拉列表。后台点一下要等的时间,别设太长。
+const mcpToolsTimeout = 15 * time.Second
+
+// mcpToolItem 下发给后台前端的工具项。
+type mcpToolItem struct {
+ Name string `json:"name"`
+ Description string `json:"description"`
+}
+
+// getMcpTools 连上 MCP 服务列出它暴露的全部工具。
+func (this *serverComp) getMcpTools(c *gin.Context) {
+ var req struct {
+ Url string `json:"url"`
+ Type string `json:"type"` // "0"=HTTP "1"=SSE
+ AppName string `json:"app_name"`
+ Id string `json:"id"`
+ Region int32 `json:"region"`
+ }
+ _ = c.ShouldBindJSON(&req)
+
+ url := strings.TrimSpace(req.Url)
+ stype := strings.TrimSpace(req.Type)
+ from := "" // 说明 url 是从哪解析来的,前端提示用
+ if url == "" {
+ if strings.TrimSpace(req.Id) == "" {
+ writeErr(c, pb.ErrorCode_ReqParameterError, "请先填写 MCP 服务地址(url),或指定已保存的服务 id")
+ return
+ }
+ var msg string
+ if url, stype, from, msg = this.resolveMcpEndpoint(req.AppName, req.Id, req.Region); msg != "" {
+ writeErr(c, pb.ErrorCode_ReqParameterError, msg)
+ return
+ }
+ }
+
+ tools, err := fetchMcpTools(url, stype == "1")
+ if err != nil {
+ writeErr(c, pb.ErrorCode_SystemError, "连接 MCP 服务失败: "+err.Error())
+ return
+ }
+ writeOK(c, gin.H{"tools": tools, "count": len(tools), "url": url, "from": from})
+}
+
+// resolveMcpEndpoint 解析已落库 MCP 服务的 url/type:先按应用作用域找服务(未命中回退全局),
+// 再取指定区域的分叉;基础行与该区域都没有 url 时,退而找任意一个配了 url 的区域分叉
+// ——MCP 的地址常常只配在区域分叉上(老 mcp 表一行绑一个区域,迁移后就是这形态)。
+// 返回 (url, type, 来源说明, 错误说明);错误说明非空即失败。
+func (this *serverComp) resolveMcpEndpoint(appName, id string, region int32) (string, string, string, string) {
+ svc := &ThirdSvcConfig{}
+ err := postgres.FindOne(comm.TableSvcConfig, svc, "app_name=? AND id=?", appName, id)
+ if err != nil && appName != "" {
+ err = postgres.FindOne(comm.TableSvcConfig, svc, "app_name=? AND id=?", "", id)
+ }
+ if err != nil {
+ return "", "", "", "MCP 服务不存在: " + id
+ }
+ if !comm.CategoriesHasMCP(svc.Categories) {
+ return "", "", "", "该服务不是 MCP 类型: " + id
+ }
+
+ ovrs := make([]*SvcRegionOverride, 0)
+ _ = postgres.Find(comm.TableSvcRegionOverride, &ovrs, "app_name=? AND svc_id=?", svc.AppName, svc.Id)
+ pick := func(r int32) *SvcRegionOverride {
+ for _, o := range ovrs {
+ if o.Region == r {
+ return o
+ }
+ }
+ return nil
+ }
+ // ① 指定区域(含 0=全区域默认)的解析结果
+ if m := comm.ResolveMcpServer(svc, pick(region), region); m != nil {
+ return m.Url, strconv.Itoa(int(m.Stype)), regionSourceLabel(region), ""
+ }
+ // ② 该区域没 url:找第一个配了 url 的分叉,探它总比直接报「没配」有用
+ for _, o := range ovrs {
+ if m := comm.ResolveMcpServer(svc, o, o.Region); m != nil {
+ return m.Url, strconv.Itoa(int(m.Stype)), regionSourceLabel(o.Region), ""
+ }
+ }
+ return "", "", "", "MCP 服务「" + svc.Name + "」还没配 url(基础行与各区域分叉都没有)"
+}
+
+// regionSourceLabel 给前端说明这次探的是哪一份配置。
+func regionSourceLabel(region int32) string {
+ if region <= 0 {
+ return "全区域默认"
+ }
+ return "区域 " + strconv.Itoa(int(region)) + " 分叉"
+}
+
+// fetchMcpTools 走标准 MCP 协议握手并列出工具。
+// 用官方客户端而不是手拼 JSON-RPC:session id、SSE 事件流、initialized 通知这些细节由它兜着,
+// 否则 SSE 类服务根本拿不到 tools/list 的响应(inspect 那边只做连通性探测就是这个原因)。
+func fetchMcpTools(url string, sse bool) ([]mcpToolItem, error) {
+ ctx, cancel := context.WithTimeout(context.Background(), mcpToolsTimeout)
+ defer cancel()
+
+ var (
+ cli *mcpclient.Client
+ err error
+ )
+ if sse {
+ cli, err = mcpclient.NewSSEMCPClient(url)
+ } else {
+ cli, err = mcpclient.NewStreamableHttpClient(url)
+ }
+ if err != nil {
+ return nil, err
+ }
+ defer cli.Close()
+
+ if err = cli.Start(ctx); err != nil {
+ return nil, err
+ }
+ initReq := mcpproto.InitializeRequest{}
+ initReq.Params.ProtocolVersion = mcpproto.LATEST_PROTOCOL_VERSION
+ initReq.Params.ClientInfo = mcpproto.Implementation{Name: "starpivot-console", Version: "1.0"}
+ if _, err = cli.Initialize(ctx, initReq); err != nil {
+ return nil, err
+ }
+ res, err := cli.ListTools(ctx, mcpproto.ListToolsRequest{})
+ if err != nil {
+ return nil, err
+ }
+ out := make([]mcpToolItem, 0, len(res.Tools))
+ for _, t := range res.Tools {
+ out = append(out, mcpToolItem{Name: t.Name, Description: strings.TrimSpace(t.Description)})
+ }
+ return out, nil
+}
diff --git a/apps/services/modules/console/api_svcconfig.go b/apps/services/modules/console/api_svcconfig.go
index 31b9eb51..85af1b74 100644
--- a/apps/services/modules/console/api_svcconfig.go
+++ b/apps/services/modules/console/api_svcconfig.go
@@ -94,10 +94,54 @@ func (this *serverComp) getSvcConfigs(c *gin.Context) {
writeErr(c, pb.ErrorCode_DBError, err.Error())
return
}
+ // 凭据缺失清单要在打码前算:maskSvcFields 只把**非空**密文替换成 [encrypted],空值原样是 ""。
+ missing := svcCredMissing(req.AppName, items)
for _, item := range items {
maskSvcFields(item.Fields)
}
- writeOK(c, gin.H{"items": items, "regions": svcRegionMap(req.AppName)})
+ writeOK(c, gin.H{"items": items, "regions": svcRegionMap(req.AppName), "cred_missing": missing})
+}
+
+// svcCredMissing 算出每个服务「一个值都没有的凭据字段」,供后台把这类服务标出来。
+// 判定口径与巡检 checkSvcConfig 一致(凭据=加密字段,空即运行时必然鉴权失败),
+// 但要把区域分叉算进去:凭据只配在某个区域分叉上是合法形态(老 mcp 表迁过来的服务就这样),
+// 基础行空不代表没配——只有基础行与所有分叉都没给值,才算真的缺。
+// 返回 {svcId: [缺的字段名]},不缺的服务不出现在 map 里。
+func svcCredMissing(appName string, items []*ThirdSvcConfig) map[string][]string {
+ ovrs := make([]*SvcRegionOverride, 0)
+ _ = postgres.Find(comm.TableSvcRegionOverride, &ovrs, "app_name=?", appName)
+ bySvc := make(map[string][]*SvcRegionOverride, len(ovrs))
+ for _, o := range ovrs {
+ bySvc[o.SvcId] = append(bySvc[o.SvcId], o)
+ }
+ return svcCredMissingWith(items, bySvc)
+}
+
+// svcCredMissingWith 是上面的纯逻辑部分(区域分叉按 svc_id 归好传进来),便于单测。
+func svcCredMissingWith(items []*ThirdSvcConfig, bySvc map[string][]*SvcRegionOverride) map[string][]string {
+ out := make(map[string][]string, 4)
+ for _, s := range items {
+ miss := make([]string, 0, 2)
+ for _, f := range s.Fields {
+ if !f.Encrypted || strings.TrimSpace(f.DefValue) != "" {
+ continue
+ }
+ filled := false
+ for _, o := range bySvc[s.Id] {
+ if strings.TrimSpace(o.Overrides[f.Key]) != "" {
+ filled = true
+ break
+ }
+ }
+ if !filled {
+ miss = append(miss, f.Key)
+ }
+ }
+ if len(miss) > 0 {
+ out[s.Id] = miss
+ }
+ }
+ return out
}
// svcRegionMap 汇总某作用域下每个服务已建分叉的区域列表。空壳分叉(既没覆盖值、也没区域专属
@@ -121,6 +165,58 @@ func svcRegionMap(appName string) map[string][]int32 {
}
// addSvcConfig 新增依赖服务。encrypted=true 的字段值加密存储。
+// checkSvcRequiredFields 保存服务前的必填校验:只拦「一定会出问题」的空字段,口径同巡检 checkSvcConfig。
+//
+// 凭据字段(encrypted) 为空 → 运行时必然鉴权失败;
+// MCP 服务 url 为空 → 根本连不上。
+//
+// 三条豁免:
+// 1. 停用中的服务不校验——还没配好的先停用保存是正常流程,停用的服务不下发也不会被编排选中;
+// 2. 值配在区域分叉里的不算空——凭据按区域分别给是合法形态(老 mcp 表迁过来的服务就这样);
+// 3. 非加密字段一律不拦——base_url/language/tools 这类留空往往是有意的(走默认/全部),拦了只会误伤。
+//
+// 传入的 fields 必须是**加密合并后**的值(update 时 "" / "[encrypted]" 已还原成旧密文),
+// 否则「保留原值」的提交会被误判成空。返回错误说明(空串=通过)。
+func checkSvcRequiredFields(m *ThirdSvcConfig) string {
+ if !m.Enable {
+ return "" // 停用的服务先不查库,草稿态直接放行
+ }
+ ovrs := make([]*SvcRegionOverride, 0)
+ _ = postgres.Find(comm.TableSvcRegionOverride, &ovrs, "app_name=? AND svc_id=?", m.AppName, m.Id)
+ return checkSvcRequiredFieldsWith(m, ovrs)
+}
+
+// checkSvcRequiredFieldsWith 是上面的纯逻辑部分(区域分叉由调用方读好传进来),便于单测。
+func checkSvcRequiredFieldsWith(m *ThirdSvcConfig, ovrs []*SvcRegionOverride) string {
+ if !m.Enable {
+ return ""
+ }
+ filledInSomeRegion := func(key string) bool {
+ for _, o := range ovrs {
+ if strings.TrimSpace(o.Overrides[key]) != "" {
+ return true
+ }
+ }
+ return false
+ }
+ empty := make([]string, 0, 2)
+ for _, f := range m.Fields {
+ if !f.Encrypted || strings.TrimSpace(f.DefValue) != "" || filledInSomeRegion(f.Key) {
+ continue
+ }
+ empty = append(empty, f.Key)
+ }
+ if len(empty) > 0 {
+ sort.Strings(empty)
+ return "凭据字段不能为空:" + strings.Join(empty, "、") +
+ "。若该字段按区域分别配置,请先在区域分叉里填值;还没拿到凭据的服务,请先「停用」再保存。"
+ }
+ if comm.CategoriesHasMCP(m.Categories) && strings.TrimSpace(svcFieldValue(m, "url")) == "" && !filledInSomeRegion("url") {
+ return "MCP 服务必须填写 url(或在某个区域分叉里填),否则客户端连不上。"
+ }
+ return ""
+}
+
func (this *serverComp) addSvcConfig(c *gin.Context) {
var m ThirdSvcConfig
if err := c.ShouldBindJSON(&m); err != nil {
@@ -142,6 +238,10 @@ func (this *serverComp) addSvcConfig(c *gin.Context) {
writeErr(c, pb.ErrorCode_SystemError, "字段加密失败: "+err.Error())
return
}
+ if msg := checkSvcRequiredFields(&m); msg != "" {
+ writeErr(c, pb.ErrorCode_ReqParameterError, msg)
+ return
+ }
m.RowId = 0 // 代理主键由库自增回填
now := time.Now().UnixMilli()
m.Createtime = now
@@ -180,6 +280,10 @@ func (this *serverComp) updateSvcConfig(c *gin.Context) {
writeErr(c, pb.ErrorCode_SystemError, "字段加密失败: "+err.Error())
return
}
+ if msg := checkSvcRequiredFields(&m); msg != "" {
+ writeErr(c, pb.ErrorCode_ReqParameterError, msg)
+ return
+ }
m.RowId = old.RowId // 按代理主键更新本行
m.Createtime = old.Createtime
m.Updatetime = time.Now().UnixMilli()
diff --git a/apps/services/modules/console/api_svcconfig_test.go b/apps/services/modules/console/api_svcconfig_test.go
new file mode 100644
index 00000000..27c45284
--- /dev/null
+++ b/apps/services/modules/console/api_svcconfig_test.go
@@ -0,0 +1,71 @@
+package console
+
+import "testing"
+
+// 保存服务时的必填校验:启用中的服务不许留空凭据——之前 stt_azure 三个字段全空也存下去了,
+// 客户端拿到一串空字符串才暴露。停用/区域分叉两条豁免不能误伤。
+func TestCheckSvcRequiredFields(t *testing.T) {
+ enc := func(key, val string) SvcField {
+ return SvcField{Key: key, Encrypted: true, DefValue: val}
+ }
+ plain := func(key, val string) SvcField {
+ return SvcField{Key: key, DefValue: val}
+ }
+
+ t.Run("启用+凭据为空 → 拦下", func(t *testing.T) {
+ m := &ThirdSvcConfig{Id: "stt_azure", Enable: true, Categories: "1",
+ Fields: []SvcField{enc("subscription_key", ""), plain("region", "")}}
+ if msg := checkSvcRequiredFieldsWith(m, nil); msg == "" {
+ t.Fatal("凭据为空必须拦下")
+ }
+ })
+
+ t.Run("停用的服务放行", func(t *testing.T) {
+ m := &ThirdSvcConfig{Id: "stt_azure", Categories: "1", Fields: []SvcField{enc("subscription_key", "")}}
+ if msg := checkSvcRequiredFieldsWith(m, nil); msg != "" {
+ t.Fatalf("停用的服务是草稿态,应放行:%s", msg)
+ }
+ })
+
+ t.Run("非加密字段留空不拦", func(t *testing.T) {
+ m := &ThirdSvcConfig{Id: "llm_qwen", Enable: true, Categories: "5",
+ Fields: []SvcField{enc("api_key", "sk-x"), plain("base_url", "")}}
+ if msg := checkSvcRequiredFieldsWith(m, nil); msg != "" {
+ t.Fatalf("base_url 这类可选字段不该拦:%s", msg)
+ }
+ })
+
+ t.Run("MCP 缺 url → 拦下", func(t *testing.T) {
+ m := &ThirdSvcConfig{Id: "mcp_x", Enable: true, Categories: "10", Fields: []SvcField{plain("url", "")}}
+ if msg := checkSvcRequiredFieldsWith(m, nil); msg == "" {
+ t.Fatal("MCP 没有 url 客户端连不上,必须拦下")
+ }
+ })
+
+ t.Run("凭据齐全放行", func(t *testing.T) {
+ m := &ThirdSvcConfig{Id: "tts_azure", Enable: true, Categories: "2",
+ Fields: []SvcField{enc("subscription_key", "cipher"), plain("region", "southeastasia")}}
+ if msg := checkSvcRequiredFieldsWith(m, nil); msg != "" {
+ t.Fatalf("配齐了不该拦:%s", msg)
+ }
+ })
+}
+
+// 列表接口给前端的「缺凭据」清单:只认「基础行与所有区域分叉都没值」,别把按区域配的服务误标。
+func TestSvcCredMissing(t *testing.T) {
+ items := []*ThirdSvcConfig{
+ {Id: "a", Fields: []SvcField{{Key: "k1", Encrypted: true, DefValue: ""}, {Key: "k2", Encrypted: true, DefValue: "cipher"}}},
+ {Id: "b", Fields: []SvcField{{Key: "k", Encrypted: true, DefValue: "cipher"}}},
+ {Id: "c", Fields: []SvcField{{Key: "plain", DefValue: ""}}},
+ }
+ got := svcCredMissingWith(items, nil) // 没有任何区域覆盖
+ if len(got["a"]) != 1 || got["a"][0] != "k1" {
+ t.Fatalf("a 应只缺 k1:%v", got["a"])
+ }
+ if _, ok := got["b"]; ok {
+ t.Fatalf("b 凭据齐全不该出现:%v", got)
+ }
+ if _, ok := got["c"]; ok {
+ t.Fatalf("c 只有非加密字段,不算缺凭据:%v", got)
+ }
+}
diff --git a/apps/services/modules/console/api_svctemplate.go b/apps/services/modules/console/api_svctemplate.go
index cca8d549..d52bf82e 100644
--- a/apps/services/modules/console/api_svctemplate.go
+++ b/apps/services/modules/console/api_svctemplate.go
@@ -238,11 +238,15 @@ func builtinSvcTemplates() []ThirdSvcTemplate {
tpl("llm_doubao", "doubao", "豆包(火山引擎)", svcCatLLM, 1,
sf("api_key", "API Key", true), sf("endpoint_id", "推理接入点 ID", false), sf("base_url", "接口地址", false)),
tpl("llm_anthropic", "anthropic", "Anthropic Claude", svcCatLLM, 2,
- sf("api_key", "API Key", true), sf("model", "模型,如 claude-sonnet-4", false)),
+ sf("api_key", "API Key", true), sf("model", "模型,如 claude-sonnet-4", false),
+ sfd("base_url", "接口地址", false, "https://api.anthropic.com/v1")),
tpl("llm_deepseek", "deepseek", "DeepSeek", svcCatLLM, 3,
sf("api_key", "API Key", true), sf("model", "模型,如 deepseek-chat", false), sf("base_url", "接口地址", false)),
+ // base_url 带默认值:客户端拿到配置就得能直接发请求,缺地址等于这条服务下发了个半成品
+ // (同厂商的 llmv_qwen 一直有 base_url,llm_qwen 漏了,客户端只能自己写死 dashscope 地址)。
tpl("llm_qwen", "qwen", "通义千问(阿里)", svcCatLLM, 4,
- sf("api_key", "API Key", true), sf("model", "模型,如 qwen-max", false)),
+ sf("api_key", "API Key", true), sf("model", "模型,如 qwen-max", false),
+ sfd("base_url", "接口地址", false, "https://dashscope.aliyuncs.com/compatible-mode/v1")),
// 录音文件识别(会议记录离线转写;字段对齐 modules/echomeet 各 provider 的运行时凭据)
//
diff --git a/apps/services/modules/console/api_svctemplate_test.go b/apps/services/modules/console/api_svctemplate_test.go
index b390e357..2d54c63a 100644
--- a/apps/services/modules/console/api_svctemplate_test.go
+++ b/apps/services/modules/console/api_svctemplate_test.go
@@ -145,3 +145,53 @@ func TestCategoriesHas(t *testing.T) {
}
}
}
+
+// 大模型类服务必须给出接口地址:客户端拿配置就得能直接发请求,缺地址等于下发了个半成品。
+// llm_qwen 就漏过一次(同厂商的 llmv_qwen 一直有 base_url),客户端只能自己写死 dashscope 地址。
+// 新增大模型模板时这条会先炸,而不是等客户端反馈「阿里的 LLM 没有 base_url」。
+func TestLLMTemplatesCarryEndpoint(t *testing.T) {
+ // 地址字段的几种写法:base_url(OpenAI 兼容) / endpoint(Google 自定义端点)
+ addrKeys := map[string]bool{"base_url": true, "endpoint": true}
+ for _, tpl := range builtinSvcTemplates() {
+ if tpl.Category != svcCatLLM && tpl.Category != svcCatLLMVision {
+ continue
+ }
+ ok := false
+ for _, f := range tpl.Fields {
+ if addrKeys[f.Key] {
+ ok = true
+ break
+ }
+ }
+ if !ok {
+ t.Errorf("大模型模板 %s(%s) 没有接口地址字段(base_url/endpoint),客户端不知道往哪发请求", tpl.Id, tpl.Name)
+ }
+ }
+}
+
+// 带默认值的地址字段不能写错——默认值是直接下发给客户端的,写错比不写更难查。
+func TestQwenTemplateBaseURL(t *testing.T) {
+ want := map[string]string{
+ "llm_qwen": "https://dashscope.aliyuncs.com/compatible-mode/v1",
+ "llmv_qwen": "", // 视觉模板的 base_url 是可选项,不设默认值,只要求字段在
+ }
+ for _, tpl := range builtinSvcTemplates() {
+ exp, care := want[tpl.Id]
+ if !care {
+ continue
+ }
+ var got string
+ found := false
+ for _, f := range tpl.Fields {
+ if f.Key == "base_url" {
+ got, found = f.DefValue, true
+ }
+ }
+ if !found {
+ t.Fatalf("%s 缺 base_url 字段", tpl.Id)
+ }
+ if exp != "" && got != exp {
+ t.Fatalf("%s 的 base_url 默认值应为 %s,实际 %q", tpl.Id, exp, got)
+ }
+ }
+}
diff --git a/apps/services/modules/console/server.go b/apps/services/modules/console/server.go
index f7092e9b..1bdcc2a9 100644
--- a/apps/services/modules/console/server.go
+++ b/apps/services/modules/console/server.go
@@ -409,6 +409,10 @@ func (this *serverComp) handleWeb(c *gin.Context) {
case "api_delagent":
this.delAgent(c)
return
+ // MCP 工具发现:连上 MCP 服务列出它暴露的工具(服务配置页填完 url 点一下、agent 绑定时勾工具都用它)。
+ case "api_getmcptools":
+ this.getMcpTools(c)
+ return
// TTS 音色表(代码常量,见 comm/ttsvoice.go):后台 agent 配音色时用它渲染候选,不落库。
case "api_getttsvoices":
this.getTTSVoices(c)
diff --git a/apps/services/modules/user/api_v3_getagents.go b/apps/services/modules/user/api_v3_getagents.go
index 220c79cb..02260247 100644
--- a/apps/services/modules/user/api_v3_getagents.go
+++ b/apps/services/modules/user/api_v3_getagents.go
@@ -23,12 +23,24 @@ import (
// 注册在 apiV3Comp(Version=v3)上,路由 = user_getagents_v3;已在 EncryptMsgs 中声明
// (系统提示词/编排关系不明文出网关)。v2 的 user_getagents_v2 保留不动。
func (this *apiV3Comp) GetAgents(session comm.IUserSession, req *pb.UserGetAgentsV3Req) (resp *pb.UserGetAgentsV3Resp, errdata *pb.ErrorData) {
+ items, err := this.agentItemsV3()
+ if err != nil {
+ errdata = &pb.ErrorData{Code: pb.ErrorCode_DBError, Message: err.Error()}
+ return
+ }
+ resp = &pb.UserGetAgentsV3Resp{Agents: items}
+ return
+}
+
+// agentItemsV3 组装下发客户端的 agent 列表。user_getagents_v3 与 user_getappconfig_v3 共用这一份:
+// 两个接口下发的必须是同一套结构与筛选口径,否则客户端从 appconfig 里拿到的 agent 会缺 type
+// 与服务编排(历史上 appconfig 下发的是已废弃的旧 agent 表,客户端不知道该走 chat 还是 sts-chat)。
+func (this *apiV3Comp) agentItemsV3() ([]*pb.AgentItemV3, error) {
app := comm.AppName()
rows := make([]*comm.AgentConfig, 0)
if err := postgres.Find(comm.TableAgentConfig, &rows, "(app_name=? OR app_name='')", app); err != nil && err != postgres.ErrNoDocuments {
- errdata = &pb.ErrorData{Code: pb.ErrorCode_DBError, Message: err.Error()}
- return
+ return nil, err
}
// 同 id 应用行覆盖全局行;仅启用的(应用行停用则回退全局行,与第三方服务下发同语义)。
@@ -53,7 +65,7 @@ func (this *apiV3Comp) GetAgents(session comm.IUserSession, req *pb.UserGetAgent
})
providerOf := this.svcProviders(app)
- resp = &pb.UserGetAgentsV3Resp{Agents: make([]*pb.AgentItemV3, 0, len(list))}
+ items := make([]*pb.AgentItemV3, 0, len(list))
for _, a := range list {
item := &pb.AgentItemV3{
Id: a.Id,
@@ -72,6 +84,7 @@ func (this *apiV3Comp) GetAgents(session comm.IUserSession, req *pb.UserGetAgent
SupportLangs: a.SupportLangs,
DefaultLang: a.DefaultLang,
McpSvcIds: append([]string{}, a.McpSvcIds...),
+ Mcps: agentMcpBindings(a),
Sort: a.Sort,
VoiceMap: resolveAgentVoices(a, providerOf[voiceSvcIdOf(a)]),
Variables: make([]*pb.AgentVariableItem, 0, len(a.Variables)),
@@ -81,9 +94,23 @@ func (this *apiV3Comp) GetAgents(session comm.IUserSession, req *pb.UserGetAgent
Key: v.Key, Label: v.Label, DefValue: v.DefValue, Description: v.Description,
})
}
- resp.Agents = append(resp.Agents, item)
+ items = append(items, item)
}
- return
+ return items, nil
+}
+
+// agentMcpBindings 把 agent 的 MCP 绑定展开成「服务 + 该 agent 允许的工具」。
+// 工具白名单为空 = 这个服务的工具全放开;最终还要与服务自身 tools 白名单
+// (在 thirdsvcs 的 fields.tools 里,按区域解析)取交集,那步由客户端做——
+// 服务侧白名单是按区域分叉的,agent 下发时没有区域上下文。
+func agentMcpBindings(a *comm.AgentConfig) []*pb.AgentMcpBindingItem {
+ out := make([]*pb.AgentMcpBindingItem, 0, len(a.McpSvcIds))
+ for _, id := range a.McpSvcIds {
+ item := &pb.AgentMcpBindingItem{SvcId: id, Tools: make([]string, 0, 4)}
+ item.Tools = append(item.Tools, a.McpTools[id]...)
+ out = append(out, item)
+ }
+ return out
}
// svcProviders 取本应用作用域下 svc_id → provider 的映射(应用行覆盖全局行)。
diff --git a/apps/services/modules/user/api_v3_getappconfig.go b/apps/services/modules/user/api_v3_getappconfig.go
index a407d710..ea8af89e 100644
--- a/apps/services/modules/user/api_v3_getappconfig.go
+++ b/apps/services/modules/user/api_v3_getappconfig.go
@@ -11,7 +11,9 @@ import (
// @Summary 获取应用配置(v3)
// @Description 一次性下发客户端启动所需的全部配置:环境变量 + 智能体 + 产品 + 第三方服务(含 MCP) + 会议记录能力。
// 与 v2 的差别:不再单独下发 mcps(MCP 已并入第三方服务配置,在 thirdsvcs 里按 categories 含 "10" 识别);
-// 新增 meet(会议记录支持的语言与可选总结大模型,来自后台「会议记录服务」编排)。v1/v2 保持原样不受影响。
+// 新增 meet(会议记录支持的语言与可选总结大模型,来自后台「会议记录服务」编排);
+// agents 换成与 user_getagents_v3 相同的结构(含 type=chat|sts-chat 与 stt/llm/tts/sts 服务编排、
+// MCP 绑定、变量、已补全的音色表),不再下发旧 agent 表。v1/v2 保持原样不受影响。
// @Tags User
// @Accept json
// @Produce json
@@ -36,9 +38,18 @@ func (this *apiV3Comp) GetAppConfig(session comm.IUserSession, req *pb.UserGetAp
)
// 环境变量:应用自有配置(app_config) 打底,本区域的 global_config 覆盖(口径同 v2)。
- config, globalconfig, agents, _ := this.module.configmodel.getdb()
+ // 注意 getdb 的第三个返回值是**旧 agent 表**,v3 已不下发它(见下方 agentItemsV3)。
+ config, globalconfig, _, _ := this.module.configmodel.getdb()
env := mergeAppEnv(config, globalconfig, region)
+ // 智能体:与 user_getagents_v3 同一份数据与结构(含 type、服务编排、MCP、变量、已补全音色),
+ // 客户端只调 appconfig 也能直接把 agent 跑起来。
+ agents, err := this.agentItemsV3()
+ if err != nil {
+ errdata = &pb.ErrorData{Code: pb.ErrorCode_DBError, Message: err.Error()}
+ return
+ }
+
products, err := this.module.cache.GetProducts()
if err != nil && err != mysql.ErrNoDocuments {
errdata = &pb.ErrorData{Code: pb.ErrorCode_DBError, Message: err.Error()}
diff --git a/apps/services/modules/user/module_test.go b/apps/services/modules/user/module_test.go
index 6c8b29fb..a5ebb519 100644
--- a/apps/services/modules/user/module_test.go
+++ b/apps/services/modules/user/module_test.go
@@ -181,7 +181,7 @@ func TestV3MeetCapabilitiesEmptySnapshot(t *testing.T) {
func TestV3AppConfigRespContract(t *testing.T) {
resp := &pb.UserGetAppConfigV3Resp{
Env: map[string]string{},
- Agents: []*pb.DBAgent{},
+ Agents: []*pb.AgentItemV3{}, // v3 下发的是带 type/服务编排的新结构,不是旧 agent 表
Products: []*pb.DBProduct{},
Thirdsvcs: []*pb.ThirdSvcItem{},
Meet: newV3Comp(nil).meetCapabilities(),
@@ -204,6 +204,36 @@ func TestV3AppConfigRespContract(t *testing.T) {
}
}
+// appconfig 里的 agent 必须是「能直接跑起来」的那一份:带 type(chat|sts-chat) 与服务编排。
+// 之前这里下发的是旧 agent 表(DBAgent),客户端拿到 agent 却不知道走哪条链路、调哪个服务。
+func TestV3AppConfigAgentCarriesTypeAndServices(t *testing.T) {
+ resp := &pb.UserGetAppConfigV3Resp{Agents: []*pb.AgentItemV3{{
+ Id: "chat-abc", Name: "口语搭子", Type: comm.AgentTypeChat,
+ SttSvcId: "svc_stt", LlmSvcId: "svc_llm", TtsSvcId: "svc_tts",
+ }}}
+ buf, err := json.Marshal(resp)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var got struct {
+ Agents []map[string]any `json:"agents"`
+ }
+ if err = json.Unmarshal(buf, &got); err != nil {
+ t.Fatal(err)
+ }
+ if len(got.Agents) != 1 {
+ t.Fatalf("agents 解析失败:%s", buf)
+ }
+ for _, k := range []string{"type", "stt_svc_id", "llm_svc_id", "tts_svc_id", "sts_svc_id", "mcp_svc_ids", "variables", "voice_map"} {
+ if _, ok := got.Agents[0][k]; !ok {
+ t.Fatalf("appconfig 的 agent 缺字段 %s:%s", k, buf)
+ }
+ }
+ if got.Agents[0]["type"] != comm.AgentTypeChat {
+ t.Fatalf("agent type 没下发对:%s", buf)
+ }
+}
+
// 客户端靠 categories 含 "10" 从 thirdsvcs 里挑出 MCP 服务——v3 去掉 mcps 后这是唯一识别方式。
func TestV3McpIdentifiedByCategories(t *testing.T) {
svcs := []*pb.ThirdSvcItem{
@@ -635,8 +665,23 @@ func TestApiProbeAppConfigV3(t *testing.T) {
t.Logf(" svc[%s] name=%s provider=%s categories=%s fields=%d mcp=%v",
s.Id, s.Name, s.Provider, s.Categories, len(s.Fields), comm.CategoriesHasMCP(s.Categories))
}
+ // agent 要能「拿了就跑」:type 指明走 chat 还是 sts-chat,对应槽位的服务 id 得齐。
for _, a := range d.Agents {
- t.Logf(" agent id=%s name=%s", a.Id, a.Name)
+ t.Logf(" agent id=%s name=%s type=%s stt=%s llm=%s tts=%s sts=%s mcp=%v vars=%d voices=%d",
+ a.Id, a.Name, a.Type, a.SttSvcId, a.LlmSvcId, a.TtsSvcId, a.StsSvcId,
+ a.McpSvcIds, len(a.Variables), len(a.VoiceMap))
+ switch a.Type {
+ case comm.AgentTypeChat:
+ if a.SttSvcId == "" || a.LlmSvcId == "" || a.TtsSvcId == "" {
+ t.Fatalf("chat agent %s 缺服务编排:stt=%q llm=%q tts=%q", a.Id, a.SttSvcId, a.LlmSvcId, a.TtsSvcId)
+ }
+ case comm.AgentTypeStsChat:
+ if a.StsSvcId == "" {
+ t.Fatalf("sts-chat agent %s 缺 sts_svc_id", a.Id)
+ }
+ default:
+ t.Fatalf("agent %s 的 type 为 %q:客户端不知道该走哪条链路(下发的应是 chat|sts-chat)", a.Id, a.Type)
+ }
}
// meet 是 v3 新增的:为空可以(后台没编排会议记录),为 null 不行——客户端会崩。
diff --git a/apps/services/pb/user_msg_v3.pb.go b/apps/services/pb/user_msg_v3.pb.go
index f15edcb9..22187a1e 100644
--- a/apps/services/pb/user_msg_v3.pb.go
+++ b/apps/services/pb/user_msg_v3.pb.go
@@ -206,7 +206,7 @@ func (x *MeetCapabilitiesItem) GetSummaryServices() []*MeetSummaryServiceItem {
type UserGetAppConfigV3Resp struct {
state protoimpl.MessageState `protogen:"open.v1"`
Env map[string]string `protobuf:"bytes,1,rep,name=env,proto3" json:"env" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` //@go_tags(`json:"env"`) 应用环境变量(app_config + 本区域 global_config)
- Agents []*DBAgent `protobuf:"bytes,2,rep,name=agents,proto3" json:"agents"` //@go_tags(`json:"agents"`) 智能体列表
+ Agents []*AgentItemV3 `protobuf:"bytes,2,rep,name=agents,proto3" json:"agents"` //@go_tags(`json:"agents"`) 智能体列表(结构同 user_getagents_v3,含 type 与服务编排)
Products []*DBProduct `protobuf:"bytes,3,rep,name=products,proto3" json:"products"` //@go_tags(`json:"products"`) 产品列表
Thirdsvcs []*ThirdSvcItem `protobuf:"bytes,4,rep,name=thirdsvcs,proto3" json:"thirdsvcs"` //@go_tags(`json:"thirdsvcs"`) 第三方服务(含 MCP 类,按用户区域合并、凭据已解密为明文)
Meet *MeetCapabilitiesItem `protobuf:"bytes,5,opt,name=meet,proto3" json:"meet"` //@go_tags(`json:"meet"`) 会议记录能力(语言 + 总结大模型)
@@ -251,7 +251,7 @@ func (x *UserGetAppConfigV3Resp) GetEnv() map[string]string {
return nil
}
-func (x *UserGetAppConfigV3Resp) GetAgents() []*DBAgent {
+func (x *UserGetAppConfigV3Resp) GetAgents() []*AgentItemV3 {
if x != nil {
return x.Agents
}
@@ -388,6 +388,61 @@ func (x *AgentVariableItem) GetDescription() string {
return ""
}
+// agent 的 MCP 绑定项:绑了哪个 MCP 服务 + 这个 agent 允许它调用的工具。
+// 同一个 agent 挂多个 MCP 时工具会互相干扰(同名工具、用不上的工具挤占模型注意力),
+// 故除服务自身的 tools 白名单外,再按 agent 收一次口。
+type AgentMcpBindingItem struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ SvcId string `protobuf:"bytes,1,opt,name=svc_id,json=svcId,proto3" json:"svc_id"` //@go_tags(`json:"svc_id"`) MCP 服务 id(详情在 getappconfig_v3 的 thirdsvcs 里)
+ Tools []string `protobuf:"bytes,2,rep,name=tools,proto3" json:"tools"` //@go_tags(`json:"tools"`) 允许调用的工具名;空=该服务的工具全放开(仍受服务自身 tools 白名单约束)
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *AgentMcpBindingItem) Reset() {
+ *x = AgentMcpBindingItem{}
+ mi := &file_user_user_msg_v3_proto_msgTypes[6]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *AgentMcpBindingItem) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*AgentMcpBindingItem) ProtoMessage() {}
+
+func (x *AgentMcpBindingItem) ProtoReflect() protoreflect.Message {
+ mi := &file_user_user_msg_v3_proto_msgTypes[6]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use AgentMcpBindingItem.ProtoReflect.Descriptor instead.
+func (*AgentMcpBindingItem) Descriptor() ([]byte, []int) {
+ return file_user_user_msg_v3_proto_rawDescGZIP(), []int{6}
+}
+
+func (x *AgentMcpBindingItem) GetSvcId() string {
+ if x != nil {
+ return x.SvcId
+ }
+ return ""
+}
+
+func (x *AgentMcpBindingItem) GetTools() []string {
+ if x != nil {
+ return x.Tools
+ }
+ return nil
+}
+
type AgentItemV3 struct {
state protoimpl.MessageState `protogen:"open.v1"`
Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id"` //@go_tags(`json:"id"`) agent id
@@ -409,13 +464,14 @@ type AgentItemV3 struct {
McpSvcIds []string `protobuf:"bytes,17,rep,name=mcp_svc_ids,json=mcpSvcIds,proto3" json:"mcp_svc_ids"` //@go_tags(`json:"mcp_svc_ids"`) 绑定的 MCP 服务 id(详情在 getappconfig_v3 的 thirdsvcs 里)
Variables []*AgentVariableItem `protobuf:"bytes,18,rep,name=variables,proto3" json:"variables"` //@go_tags(`json:"variables"`) 变量定义
Sort int32 `protobuf:"varint,19,opt,name=sort,proto3" json:"sort"` //@go_tags(`json:"sort"`) 排序
+ Mcps []*AgentMcpBindingItem `protobuf:"bytes,20,rep,name=mcps,proto3" json:"mcps"` //@go_tags(`json:"mcps"`) MCP 绑定(含每个服务允许的工具);mcp_svc_ids 是它的 id 平铺,保留给老客户端
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *AgentItemV3) Reset() {
*x = AgentItemV3{}
- mi := &file_user_user_msg_v3_proto_msgTypes[6]
+ mi := &file_user_user_msg_v3_proto_msgTypes[7]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -427,7 +483,7 @@ func (x *AgentItemV3) String() string {
func (*AgentItemV3) ProtoMessage() {}
func (x *AgentItemV3) ProtoReflect() protoreflect.Message {
- mi := &file_user_user_msg_v3_proto_msgTypes[6]
+ mi := &file_user_user_msg_v3_proto_msgTypes[7]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -440,7 +496,7 @@ func (x *AgentItemV3) ProtoReflect() protoreflect.Message {
// Deprecated: Use AgentItemV3.ProtoReflect.Descriptor instead.
func (*AgentItemV3) Descriptor() ([]byte, []int) {
- return file_user_user_msg_v3_proto_rawDescGZIP(), []int{6}
+ return file_user_user_msg_v3_proto_rawDescGZIP(), []int{7}
}
func (x *AgentItemV3) GetId() string {
@@ -576,6 +632,13 @@ func (x *AgentItemV3) GetSort() int32 {
return 0
}
+func (x *AgentItemV3) GetMcps() []*AgentMcpBindingItem {
+ if x != nil {
+ return x.Mcps
+ }
+ return nil
+}
+
type UserGetAgentsV3Resp struct {
state protoimpl.MessageState `protogen:"open.v1"`
Agents []*AgentItemV3 `protobuf:"bytes,1,rep,name=agents,proto3" json:"agents"` //@go_tags(`json:"agents"`) agent 列表
@@ -585,7 +648,7 @@ type UserGetAgentsV3Resp struct {
func (x *UserGetAgentsV3Resp) Reset() {
*x = UserGetAgentsV3Resp{}
- mi := &file_user_user_msg_v3_proto_msgTypes[7]
+ mi := &file_user_user_msg_v3_proto_msgTypes[8]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -597,7 +660,7 @@ func (x *UserGetAgentsV3Resp) String() string {
func (*UserGetAgentsV3Resp) ProtoMessage() {}
func (x *UserGetAgentsV3Resp) ProtoReflect() protoreflect.Message {
- mi := &file_user_user_msg_v3_proto_msgTypes[7]
+ mi := &file_user_user_msg_v3_proto_msgTypes[8]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -610,7 +673,7 @@ func (x *UserGetAgentsV3Resp) ProtoReflect() protoreflect.Message {
// Deprecated: Use UserGetAgentsV3Resp.ProtoReflect.Descriptor instead.
func (*UserGetAgentsV3Resp) Descriptor() ([]byte, []int) {
- return file_user_user_msg_v3_proto_rawDescGZIP(), []int{7}
+ return file_user_user_msg_v3_proto_rawDescGZIP(), []int{8}
}
func (x *UserGetAgentsV3Resp) GetAgents() []*AgentItemV3 {
@@ -637,10 +700,10 @@ const file_user_user_msg_v3_proto_rawDesc = "" +
"\x13translate_languages\x18\x03 \x03(\tR\x12translateLanguages\x124\n" +
"\x16translate_any_language\x18\x04 \x01(\bR\x14translateAnyLanguage\x122\n" +
"\x15summary_client_choice\x18\x05 \x01(\bR\x13summaryClientChoice\x12B\n" +
- "\x10summary_services\x18\x06 \x03(\v2\x17.MeetSummaryServiceItemR\x0fsummaryServices\"\xa6\x02\n" +
+ "\x10summary_services\x18\x06 \x03(\v2\x17.MeetSummaryServiceItemR\x0fsummaryServices\"\xaa\x02\n" +
"\x16UserGetAppConfigV3Resp\x122\n" +
- "\x03env\x18\x01 \x03(\v2 .UserGetAppConfigV3Resp.EnvEntryR\x03env\x12 \n" +
- "\x06agents\x18\x02 \x03(\v2\b.DBAgentR\x06agents\x12&\n" +
+ "\x03env\x18\x01 \x03(\v2 .UserGetAppConfigV3Resp.EnvEntryR\x03env\x12$\n" +
+ "\x06agents\x18\x02 \x03(\v2\f.AgentItemV3R\x06agents\x12&\n" +
"\bproducts\x18\x03 \x03(\v2\n" +
".DBProductR\bproducts\x12+\n" +
"\tthirdsvcs\x18\x04 \x03(\v2\r.ThirdSvcItemR\tthirdsvcs\x12)\n" +
@@ -653,7 +716,10 @@ const file_user_user_msg_v3_proto_rawDesc = "" +
"\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" +
"\x05label\x18\x02 \x01(\tR\x05label\x12\x1b\n" +
"\tdef_value\x18\x03 \x01(\tR\bdefValue\x12 \n" +
- "\vdescription\x18\x04 \x01(\tR\vdescription\"\xe7\x04\n" +
+ "\vdescription\x18\x04 \x01(\tR\vdescription\"B\n" +
+ "\x13AgentMcpBindingItem\x12\x15\n" +
+ "\x06svc_id\x18\x01 \x01(\tR\x05svcId\x12\x14\n" +
+ "\x05tools\x18\x02 \x03(\tR\x05tools\"\x91\x05\n" +
"\vAgentItemV3\x12\x0e\n" +
"\x02id\x18\x01 \x01(\tR\x02id\x12\x12\n" +
"\x04name\x18\x02 \x01(\tR\x04name\x12\x12\n" +
@@ -679,7 +745,8 @@ const file_user_user_msg_v3_proto_rawDesc = "" +
"\tvoice_map\x18\x10 \x03(\v2\x0f.AgentVoiceItemR\bvoiceMap\x12\x1e\n" +
"\vmcp_svc_ids\x18\x11 \x03(\tR\tmcpSvcIds\x120\n" +
"\tvariables\x18\x12 \x03(\v2\x12.AgentVariableItemR\tvariables\x12\x12\n" +
- "\x04sort\x18\x13 \x01(\x05R\x04sort\";\n" +
+ "\x04sort\x18\x13 \x01(\x05R\x04sort\x12(\n" +
+ "\x04mcps\x18\x14 \x03(\v2\x14.AgentMcpBindingItemR\x04mcps\";\n" +
"\x13UserGetAgentsV3Resp\x12$\n" +
"\x06agents\x18\x01 \x03(\v2\f.AgentItemV3R\x06agentsB\x06Z\x04.;pbb\x06proto3"
@@ -695,7 +762,7 @@ func file_user_user_msg_v3_proto_rawDescGZIP() []byte {
return file_user_user_msg_v3_proto_rawDescData
}
-var file_user_user_msg_v3_proto_msgTypes = make([]protoimpl.MessageInfo, 9)
+var file_user_user_msg_v3_proto_msgTypes = make([]protoimpl.MessageInfo, 10)
var file_user_user_msg_v3_proto_goTypes = []any{
(*UserGetAppConfigV3Req)(nil), // 0: UserGetAppConfigV3Req
(*MeetSummaryServiceItem)(nil), // 1: MeetSummaryServiceItem
@@ -703,29 +770,30 @@ var file_user_user_msg_v3_proto_goTypes = []any{
(*UserGetAppConfigV3Resp)(nil), // 3: UserGetAppConfigV3Resp
(*UserGetAgentsV3Req)(nil), // 4: UserGetAgentsV3Req
(*AgentVariableItem)(nil), // 5: AgentVariableItem
- (*AgentItemV3)(nil), // 6: AgentItemV3
- (*UserGetAgentsV3Resp)(nil), // 7: UserGetAgentsV3Resp
- nil, // 8: UserGetAppConfigV3Resp.EnvEntry
- (*DBAgent)(nil), // 9: DBAgent
+ (*AgentMcpBindingItem)(nil), // 6: AgentMcpBindingItem
+ (*AgentItemV3)(nil), // 7: AgentItemV3
+ (*UserGetAgentsV3Resp)(nil), // 8: UserGetAgentsV3Resp
+ nil, // 9: UserGetAppConfigV3Resp.EnvEntry
(*DBProduct)(nil), // 10: DBProduct
(*ThirdSvcItem)(nil), // 11: ThirdSvcItem
(*AgentVoiceItem)(nil), // 12: AgentVoiceItem
}
var file_user_user_msg_v3_proto_depIdxs = []int32{
1, // 0: MeetCapabilitiesItem.summary_services:type_name -> MeetSummaryServiceItem
- 8, // 1: UserGetAppConfigV3Resp.env:type_name -> UserGetAppConfigV3Resp.EnvEntry
- 9, // 2: UserGetAppConfigV3Resp.agents:type_name -> DBAgent
+ 9, // 1: UserGetAppConfigV3Resp.env:type_name -> UserGetAppConfigV3Resp.EnvEntry
+ 7, // 2: UserGetAppConfigV3Resp.agents:type_name -> AgentItemV3
10, // 3: UserGetAppConfigV3Resp.products:type_name -> DBProduct
11, // 4: UserGetAppConfigV3Resp.thirdsvcs:type_name -> ThirdSvcItem
2, // 5: UserGetAppConfigV3Resp.meet:type_name -> MeetCapabilitiesItem
12, // 6: AgentItemV3.voice_map:type_name -> AgentVoiceItem
5, // 7: AgentItemV3.variables:type_name -> AgentVariableItem
- 6, // 8: UserGetAgentsV3Resp.agents:type_name -> AgentItemV3
- 9, // [9:9] is the sub-list for method output_type
- 9, // [9:9] is the sub-list for method input_type
- 9, // [9:9] is the sub-list for extension type_name
- 9, // [9:9] is the sub-list for extension extendee
- 0, // [0:9] is the sub-list for field type_name
+ 6, // 8: AgentItemV3.mcps:type_name -> AgentMcpBindingItem
+ 7, // 9: UserGetAgentsV3Resp.agents:type_name -> AgentItemV3
+ 10, // [10:10] is the sub-list for method output_type
+ 10, // [10:10] is the sub-list for method input_type
+ 10, // [10:10] is the sub-list for extension type_name
+ 10, // [10:10] is the sub-list for extension extendee
+ 0, // [0:10] is the sub-list for field type_name
}
func init() { file_user_user_msg_v3_proto_init() }
@@ -741,7 +809,7 @@ func file_user_user_msg_v3_proto_init() {
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_user_user_msg_v3_proto_rawDesc), len(file_user_user_msg_v3_proto_rawDesc)),
NumEnums: 0,
- NumMessages: 9,
+ NumMessages: 10,
NumExtensions: 0,
NumServices: 0,
},
diff --git a/apps/services/swagger/docs.go b/apps/services/swagger/docs.go
index 6e8dc66e..dd55cfab 100644
--- a/apps/services/swagger/docs.go
+++ b/apps/services/swagger/docs.go
@@ -3728,6 +3728,13 @@ const docTemplate = `{
"type": "string"
}
},
+ "mcps": {
+ "description": "@go_tags(` + "`" + `json:\"mcps\"` + "`" + `) MCP 绑定(含每个服务允许的工具);mcp_svc_ids 是它的 id 平铺,保留给老客户端",
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/pb.AgentMcpBindingItem"
+ }
+ },
"name": {
"description": "@go_tags(` + "`" + `json:\"name\"` + "`" + `) 名称",
"type": "string"
@@ -3776,6 +3783,22 @@ const docTemplate = `{
}
}
},
+ "pb.AgentMcpBindingItem": {
+ "type": "object",
+ "properties": {
+ "svc_id": {
+ "description": "@go_tags(` + "`" + `json:\"svc_id\"` + "`" + `) MCP 服务 id(详情在 getappconfig_v3 的 thirdsvcs 里)",
+ "type": "string"
+ },
+ "tools": {
+ "description": "@go_tags(` + "`" + `json:\"tools\"` + "`" + `) 允许调用的工具名;空=该服务的工具全放开(仍受服务自身 tools 白名单约束)",
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ }
+ }
+ },
"pb.AgentSource": {
"description": "智能体来源",
"type": "integer",
@@ -7593,10 +7616,10 @@ const docTemplate = `{
"type": "object",
"properties": {
"agents": {
- "description": "@go_tags(` + "`" + `json:\"agents\"` + "`" + `) 智能体列表",
+ "description": "@go_tags(` + "`" + `json:\"agents\"` + "`" + `) 智能体列表(结构同 user_getagents_v3,含 type 与服务编排)",
"type": "array",
"items": {
- "$ref": "#/definitions/pb.DBAgent"
+ "$ref": "#/definitions/pb.AgentItemV3"
}
},
"env": {
diff --git a/apps/services/swagger/swagger.json b/apps/services/swagger/swagger.json
index 687c6312..82f892e3 100644
--- a/apps/services/swagger/swagger.json
+++ b/apps/services/swagger/swagger.json
@@ -3720,6 +3720,13 @@
"type": "string"
}
},
+ "mcps": {
+ "description": "@go_tags(`json:\"mcps\"`) MCP 绑定(含每个服务允许的工具);mcp_svc_ids 是它的 id 平铺,保留给老客户端",
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/pb.AgentMcpBindingItem"
+ }
+ },
"name": {
"description": "@go_tags(`json:\"name\"`) 名称",
"type": "string"
@@ -3768,6 +3775,22 @@
}
}
},
+ "pb.AgentMcpBindingItem": {
+ "type": "object",
+ "properties": {
+ "svc_id": {
+ "description": "@go_tags(`json:\"svc_id\"`) MCP 服务 id(详情在 getappconfig_v3 的 thirdsvcs 里)",
+ "type": "string"
+ },
+ "tools": {
+ "description": "@go_tags(`json:\"tools\"`) 允许调用的工具名;空=该服务的工具全放开(仍受服务自身 tools 白名单约束)",
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ }
+ }
+ },
"pb.AgentSource": {
"description": "智能体来源",
"type": "integer",
@@ -7585,10 +7608,10 @@
"type": "object",
"properties": {
"agents": {
- "description": "@go_tags(`json:\"agents\"`) 智能体列表",
+ "description": "@go_tags(`json:\"agents\"`) 智能体列表(结构同 user_getagents_v3,含 type 与服务编排)",
"type": "array",
"items": {
- "$ref": "#/definitions/pb.DBAgent"
+ "$ref": "#/definitions/pb.AgentItemV3"
}
},
"env": {
diff --git a/apps/services/swagger/swagger.yaml b/apps/services/swagger/swagger.yaml
index d45de266..04043c1a 100644
--- a/apps/services/swagger/swagger.yaml
+++ b/apps/services/swagger/swagger.yaml
@@ -187,6 +187,12 @@ definitions:
items:
type: string
type: array
+ mcps:
+ description: '@go_tags(`json:"mcps"`) MCP 绑定(含每个服务允许的工具);mcp_svc_ids 是它的
+ id 平铺,保留给老客户端'
+ items:
+ $ref: '#/definitions/pb.AgentMcpBindingItem'
+ type: array
name:
description: '@go_tags(`json:"name"`) 名称'
type: string
@@ -222,6 +228,19 @@ definitions:
$ref: '#/definitions/pb.AgentVoiceItem'
type: array
type: object
+ pb.AgentMcpBindingItem:
+ properties:
+ svc_id:
+ description: '@go_tags(`json:"svc_id"`) MCP 服务 id(详情在 getappconfig_v3 的 thirdsvcs
+ 里)'
+ type: string
+ tools:
+ description: '@go_tags(`json:"tools"`) 允许调用的工具名;空=该服务的工具全放开(仍受服务自身
+ tools 白名单约束)'
+ items:
+ type: string
+ type: array
+ type: object
pb.AgentSource:
description: 智能体来源
enum:
@@ -3055,9 +3074,9 @@ definitions:
pb.UserGetAppConfigV3Resp:
properties:
agents:
- description: '@go_tags(`json:"agents"`) 智能体列表'
+ description: '@go_tags(`json:"agents"`) 智能体列表(结构同 user_getagents_v3,含 type 与服务编排)'
items:
- $ref: '#/definitions/pb.DBAgent'
+ $ref: '#/definitions/pb.AgentItemV3'
type: array
env:
additionalProperties: