22 changed files with 1277 additions and 77 deletions
@ -0,0 +1,156 @@ |
|||
<!-- |
|||
McpToolPicker:连上 MCP 服务把它暴露的工具列出来,勾选成白名单。 |
|||
|
|||
以前工具名靠人去问对接方再手抄进 tools 字段,抄错要等线上调用失败才发现;这里直接问服务端 |
|||
(后端 api_getmcptools 走标准 MCP 握手 initialize → tools/list)。 |
|||
|
|||
两处在用: |
|||
- 第三方服务配置 → MCP 服务:白名单存服务自身的 tools 字段(逗号分隔),父组件负责 csv ⇄ 数组 |
|||
- Agent 配置 → MCP 服务绑定:白名单按 agent 存(AgentConfig.mcp_tools[svcId]) |
|||
|
|||
空数组 = 全部工具(后续服务端新增的工具自动生效);非空 = 只给勾中的这些。 |
|||
--> |
|||
<template> |
|||
<div class="mcp-tools"> |
|||
<div class="mt-head"> |
|||
<button class="btn btn-outline btn-primary btn-xs" :disabled="!canFetch || loading || disabled" @click="fetchTools"> |
|||
<span v-if="loading" class="loading loading-spinner loading-xs"></span> |
|||
{{ loading ? '连接中…' : fetched ? '重新获取' : '获取工具列表' }} |
|||
</button> |
|||
<span v-if="!canFetch" class="mt-hint">{{ hint || '先填写 MCP 服务地址(url),才能获取工具列表' }}</span> |
|||
<span v-else-if="err" class="mt-err" :title="err">✕ {{ err }}</span> |
|||
<span v-else-if="fetched" class="mt-hint"> |
|||
服务端共 {{ tools.length }} 个工具{{ from ? `(取自${from})` : '' }},已选 {{ useAll ? '全部' : selected.length }} |
|||
</span> |
|||
<span v-else-if="modelValue.length" class="mt-hint">当前白名单 {{ modelValue.length }} 个(未获取,显示的是已保存的名字)</span> |
|||
<span v-else class="mt-hint">未设置白名单 = 该服务的工具全部可用</span> |
|||
</div> |
|||
|
|||
<!-- 没拉取过:把已保存的白名单原样列出来,别让人以为配置丢了 --> |
|||
<div v-if="!fetched && modelValue.length" class="mt-saved"> |
|||
<span v-for="n in modelValue" :key="n" class="tool-chip saved">{{ n }}</span> |
|||
</div> |
|||
|
|||
<template v-if="fetched"> |
|||
<label class="mt-all"> |
|||
<input type="checkbox" class="checkbox checkbox-xs checkbox-primary" :checked="useAll" :disabled="disabled" @change="toggleAll" /> |
|||
<span>使用全部工具</span> |
|||
<em>勾上则不写白名单,服务端以后新增的工具会自动对它生效</em> |
|||
</label> |
|||
|
|||
<div v-if="!tools.length" class="mt-empty">这个 MCP 服务没有暴露任何工具</div> |
|||
<div v-else class="tool-list" :class="{ dim: useAll }"> |
|||
<label v-for="t in tools" :key="t.name" class="tool-item" :class="{ on: !useAll && selected.includes(t.name) }"> |
|||
<input |
|||
type="checkbox" |
|||
class="checkbox checkbox-xs checkbox-primary" |
|||
:checked="useAll || selected.includes(t.name)" |
|||
:disabled="useAll || disabled" |
|||
@change="toggle(t.name)" |
|||
/> |
|||
<span class="min-w-0"> |
|||
<span class="tool-name">{{ t.name }}</span> |
|||
<span v-if="t.description" class="tool-desc">{{ t.description }}</span> |
|||
</span> |
|||
</label> |
|||
</div> |
|||
|
|||
<!-- 白名单里有、服务端却没列出来的:多半是改过名或服务下线了,留着会静默失效 --> |
|||
<div v-if="unknown.length" class="mt-unknown"> |
|||
<span>⚠ 白名单里这些工具服务端没有:</span> |
|||
<span v-for="n in unknown" :key="n" class="tool-chip stale">{{ n }}</span> |
|||
<button class="btn btn-ghost btn-xs" :disabled="disabled" @click="dropUnknown">清掉</button> |
|||
</div> |
|||
</template> |
|||
</div> |
|||
</template> |
|||
|
|||
<script setup lang="ts"> |
|||
import { ref, computed } from 'vue' |
|||
import { useApi } from '~/composables/useApi' |
|||
|
|||
interface McpTool { name: string; description: string } |
|||
|
|||
const props = withDefaults( |
|||
defineProps<{ |
|||
modelValue: string[] // 已选工具名;空数组=全部 |
|||
fetchParams: Record<string, unknown> // 传给 api_getmcptools 的入参:{url,type} 或 {app_name,id,region} |
|||
canFetch?: boolean // 入参是否齐(父组件判断,比如 url 填了没) |
|||
hint?: string |
|||
disabled?: boolean |
|||
}>(), |
|||
{ canFetch: true, hint: '', disabled: false }, |
|||
) |
|||
const emit = defineEmits<{ (e: 'update:modelValue', v: string[]): void }>() |
|||
|
|||
const { webApi } = useApi() |
|||
|
|||
const tools = ref<McpTool[]>([]) |
|||
const loading = ref(false) |
|||
const fetched = ref(false) |
|||
const err = ref('') |
|||
const from = ref('') |
|||
|
|||
const selected = computed(() => props.modelValue ?? []) |
|||
const useAll = computed(() => selected.value.length === 0) |
|||
// 已保存但服务端列表里没有的工具名 |
|||
const unknown = computed(() => { |
|||
if (!fetched.value) return [] |
|||
const have = new Set(tools.value.map((t) => t.name)) |
|||
return selected.value.filter((n) => !have.has(n)) |
|||
}) |
|||
|
|||
async function fetchTools() { |
|||
loading.value = true |
|||
err.value = '' |
|||
try { |
|||
const d = await webApi<{ tools: McpTool[]; from?: string }>('getmcptools', props.fetchParams) |
|||
tools.value = d?.tools ?? [] |
|||
from.value = d?.from ?? '' |
|||
fetched.value = true |
|||
} catch (e: any) { |
|||
err.value = e?.message ?? '获取失败' |
|||
fetched.value = false |
|||
} finally { |
|||
loading.value = false |
|||
} |
|||
} |
|||
|
|||
function toggle(name: string) { |
|||
const next = selected.value.includes(name) ? selected.value.filter((n) => n !== name) : [...selected.value, name] |
|||
// 勾满了等价于「全部」,直接写回空数组,免得以后服务端加了工具这个 agent 反而用不上 |
|||
emit('update:modelValue', next.length === tools.value.length && tools.value.length > 0 ? [] : next) |
|||
} |
|||
// 从「全部」切到「按需勾选」时先给一份全选的具体名单,人再去掉不要的;反向则清空 |
|||
function toggleAll() { |
|||
emit('update:modelValue', useAll.value ? tools.value.map((t) => t.name) : []) |
|||
} |
|||
function dropUnknown() { |
|||
const have = new Set(tools.value.map((t) => t.name)) |
|||
emit('update:modelValue', selected.value.filter((n) => have.has(n))) |
|||
} |
|||
|
|||
defineExpose({ fetchTools }) |
|||
</script> |
|||
|
|||
<style scoped> |
|||
.mcp-tools { border: 1px dashed #e2e8f0; border-radius: 10px; padding: 10px 12px; background: #fcfdfe; } |
|||
.mt-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } |
|||
.mt-hint { font-size: 11px; color: #94a3b8; } |
|||
.mt-err { font-size: 11px; color: #dc2626; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } |
|||
.mt-saved { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 8px; } |
|||
.tool-chip { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 10.5px; padding: 2px 7px; border-radius: 6px; } |
|||
.tool-chip.saved { background: #f1f5f9; color: #64748b; } |
|||
.tool-chip.stale { background: #fef2f2; color: #b91c1c; } |
|||
.mt-all { display: flex; align-items: center; gap: 6px; margin-top: 10px; font-size: 12px; color: #475569; cursor: pointer; } |
|||
.mt-all em { font-style: normal; font-size: 10.5px; color: #94a3b8; } |
|||
.mt-empty { font-size: 11.5px; color: #cbd5e1; padding: 10px 0; } |
|||
.tool-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 6px; margin-top: 8px; } |
|||
.tool-list.dim { opacity: .45; } |
|||
.tool-item { display: flex; align-items: flex-start; gap: 7px; padding: 6px 9px; border: 1px solid #eef2f7; border-radius: 8px; cursor: pointer; transition: all .15s; } |
|||
.tool-item:hover { border-color: #c7d2fe; background: #f8fafc; } |
|||
.tool-item.on { border-color: #6366f1; background: #eef2ff; } |
|||
.tool-name { display: block; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 11.5px; color: #1e293b; word-break: break-all; } |
|||
.tool-desc { display: block; font-size: 10.5px; color: #94a3b8; line-height: 1.45; margin-top: 1px; } |
|||
.mt-unknown { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; margin-top: 8px; font-size: 11px; color: #b91c1c; } |
|||
</style> |
|||
@ -0,0 +1,110 @@ |
|||
// 一次性工具:把某个应用业务库 goods 表导出的 JSON 同步进 console 主库的 app_goods 表。
|
|||
// 用法: go run ./cmd/syncgoods -dsn <postgres 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]) |
|||
} |
|||
@ -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) |
|||
} |
|||
} |
|||
} |
|||
@ -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 |
|||
} |
|||
@ -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) |
|||
} |
|||
} |
|||
Loading…
Reference in new issue