You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

156 lines
7.6 KiB

<!--
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>