[codex] add MCP tools visibility management (#1170)
* feat(mcp): add tools visibility management ## Features - Tools visibility modal with 3 modes: All, Include, Exclude - 'Manage Tools' button on McpServerCard (enabled only when connected) - 'Fetch Tools List' button to refresh available tools (raw mode) - Responsive design for mobile (480px), tablet (768px), desktop (1280px) - i18n translations for 9 languages (zh/en/zh-TW/ja/ko/de/es/fr/pt) ## Technical Details - Add raw parameter to fetchMcpTools API for unfiltered tools - Pass raw parameter through controller → bridgeMcpAction → client - Backend _mcp_tools_list supports raw_mode to skip include/exclude filter - 28 MCP unit tests pass (23 controller + 5 bridge action) ## Files Changed - McpManagerView.vue: Tools visibility modal with mode selector - McpServerCard.vue: Add manage tools button - mcp.ts (client): Add raw parameter to fetchMcpTools - mcp.ts (controller): Pass raw parameter to bridge - mcp.ts (services): Pass raw parameter to client.mcpTools - client.ts: Add raw parameter to mcpTools - hermes_bridge.py: Support raw_mode in _mcp_tools_list - 9 locale files: Add 14 translation keys each - mcp-controller.test.ts: Add 3 new test cases - bridge-mcp-action.test.ts: New test file for parameter passing * Delete projects directory chore: remove accidentally committed projects/ directory * fix MCP tools visibility edge cases * remove MCP docs screenshots --------- Co-authored-by: Crafter-feng <succeed_happu@163.com> Co-authored-by: Crafter-feng <37255449+Crafter-feng@users.noreply.github.com>
@@ -48,3 +48,4 @@ hermes-dependencies.md
|
||||
CLAUDE.md
|
||||
# Client source map artifacts
|
||||
packages/client/src/**/*.js
|
||||
.hermes/
|
||||
|
||||
|
Before Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 28 KiB |
@@ -52,8 +52,11 @@ export async function fetchMcpServers(): Promise<McpServersResponse> {
|
||||
return request<McpServersResponse>('/api/hermes/mcp/servers')
|
||||
}
|
||||
|
||||
export async function fetchMcpTools(server?: string): Promise<McpToolsResponse> {
|
||||
const query = server ? `?server=${encodeURIComponent(server)}` : ''
|
||||
export async function fetchMcpTools(server?: string, raw?: boolean): Promise<McpToolsResponse> {
|
||||
const params = new URLSearchParams()
|
||||
if (server) params.set('server', server)
|
||||
if (raw) params.set('raw', '1')
|
||||
const query = params.toString() ? `?${params.toString()}` : ''
|
||||
return request<McpToolsResponse>(`/api/hermes/mcp/tools${query}`)
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ const emit = defineEmits<{
|
||||
reload: [name: string]
|
||||
remove: [server: McpServerInfo]
|
||||
toggleEnabled: [server: McpServerInfo]
|
||||
manageTools: [server: McpServerInfo]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
@@ -80,6 +81,7 @@ const MAX_VISIBLE_TOOLS = 20
|
||||
<div class="card-footer">
|
||||
<div class="card-actions">
|
||||
<NButton size="tiny" quaternary @click="emit('edit', server)">{{ t('mcp.edit') }}</NButton>
|
||||
<NButton size="tiny" quaternary :disabled="!server.connected" @click="emit('manageTools', server)">{{ t('mcp.manageTools') }}</NButton>
|
||||
<NButton size="tiny" quaternary @click="emit('test', server)">{{ t('mcp.test') }}</NButton>
|
||||
<NButton size="tiny" quaternary @click="emit('reload', server.name)">{{ t('mcp.reload') }}</NButton>
|
||||
<NPopconfirm @positive-click="emit('remove', server)">
|
||||
|
||||
@@ -157,6 +157,24 @@ export default {
|
||||
invalidConfig: 'Ungültige Konfiguration',
|
||||
invalidServerConfig: 'Ungültige Serverkonfiguration',
|
||||
missingCommandOrUrl: 'Muss command oder url enthalten',
|
||||
manageTools: 'Tools verwalten',
|
||||
toolsVisibilityTitle: 'Tools-Sichtbarkeit verwalten',
|
||||
fetchTools: 'Tools-Liste abrufen',
|
||||
fetchToolsFailed: 'Tools-Liste konnte nicht abgerufen werden',
|
||||
toolsMode: 'Modus:',
|
||||
toolsModeAll: 'Alle',
|
||||
toolsModeInclude: 'Einschließen',
|
||||
toolsModeExclude: 'Ausschließen',
|
||||
toolsListHeader: 'Tool-Name',
|
||||
toolsEmpty: 'Keine Tools verfügbar, bitte zuerst die Tools-Liste abrufen',
|
||||
toolsSummaryAll: '{count} Tools insgesamt, alle aktiviert',
|
||||
toolsSummaryInclude: '{total} Tools insgesamt, {count} ausgewählt',
|
||||
toolsSummaryExclude: '{total} Tools insgesamt, {count} ausgeschlossen',
|
||||
toolsVisibilitySaved: 'Tools-Sichtbarkeit gespeichert',
|
||||
toolsSelectAll: 'Alle auswählen',
|
||||
toolsClearSelection: 'Auswahl löschen',
|
||||
toolsExcludeAll: 'Alle ausschließen',
|
||||
toolsClearExcluded: 'Ausschlüsse löschen',
|
||||
},
|
||||
|
||||
// Sidebar
|
||||
|
||||
@@ -157,6 +157,24 @@ export default {
|
||||
invalidConfig: 'Invalid configuration',
|
||||
invalidServerConfig: 'Invalid server configuration',
|
||||
missingCommandOrUrl: 'Must have command or url',
|
||||
manageTools: 'Manage Tools',
|
||||
toolsVisibilityTitle: 'Tools Visibility Management',
|
||||
fetchTools: 'Fetch Tools List',
|
||||
fetchToolsFailed: 'Failed to fetch tools list',
|
||||
toolsMode: 'Mode:',
|
||||
toolsModeAll: 'All',
|
||||
toolsModeInclude: 'Include',
|
||||
toolsModeExclude: 'Exclude',
|
||||
toolsListHeader: 'Tool Name',
|
||||
toolsEmpty: 'No tools available, please fetch tools list first',
|
||||
toolsSummaryAll: '{count} tools total, all enabled',
|
||||
toolsSummaryInclude: '{total} tools total, {count} selected',
|
||||
toolsSummaryExclude: '{total} tools total, {count} excluded',
|
||||
toolsVisibilitySaved: 'Tools visibility saved',
|
||||
toolsSelectAll: 'Select all',
|
||||
toolsClearSelection: 'Clear selection',
|
||||
toolsExcludeAll: 'Exclude all',
|
||||
toolsClearExcluded: 'Clear excluded',
|
||||
},
|
||||
|
||||
// Sidebar
|
||||
|
||||
@@ -157,6 +157,24 @@ export default {
|
||||
invalidConfig: 'Configuración no válida',
|
||||
invalidServerConfig: 'Configuración del servidor no válida',
|
||||
missingCommandOrUrl: 'Debe incluir command o url',
|
||||
manageTools: 'Gestionar herramientas',
|
||||
toolsVisibilityTitle: 'Gestión de visibilidad de herramientas',
|
||||
fetchTools: 'Obtener lista de herramientas',
|
||||
fetchToolsFailed: 'Error al obtener la lista de herramientas',
|
||||
toolsMode: 'Modo:',
|
||||
toolsModeAll: 'Todas',
|
||||
toolsModeInclude: 'Incluir',
|
||||
toolsModeExclude: 'Excluir',
|
||||
toolsListHeader: 'Nombre de herramienta',
|
||||
toolsEmpty: 'No hay herramientas disponibles, primero obtenga la lista de herramientas',
|
||||
toolsSummaryAll: '{count} herramientas en total, todas habilitadas',
|
||||
toolsSummaryInclude: '{total} herramientas en total, {count} seleccionadas',
|
||||
toolsSummaryExclude: '{total} herramientas en total, {count} excluidas',
|
||||
toolsVisibilitySaved: 'Visibilidad de herramientas guardada',
|
||||
toolsSelectAll: 'Seleccionar todo',
|
||||
toolsClearSelection: 'Borrar selección',
|
||||
toolsExcludeAll: 'Excluir todo',
|
||||
toolsClearExcluded: 'Borrar exclusiones',
|
||||
},
|
||||
|
||||
// Sidebar
|
||||
|
||||
@@ -157,6 +157,24 @@ export default {
|
||||
invalidConfig: 'Configuration invalide',
|
||||
invalidServerConfig: 'Configuration du serveur invalide',
|
||||
missingCommandOrUrl: 'Doit contenir command ou url',
|
||||
manageTools: 'Gérer les outils',
|
||||
toolsVisibilityTitle: 'Gestion de la visibilité des outils',
|
||||
fetchTools: 'Récupérer la liste des outils',
|
||||
fetchToolsFailed: 'Échec de la récupération de la liste des outils',
|
||||
toolsMode: 'Mode :',
|
||||
toolsModeAll: 'Tous',
|
||||
toolsModeInclude: 'Inclure',
|
||||
toolsModeExclude: 'Exclure',
|
||||
toolsListHeader: 'Nom de l\'outil',
|
||||
toolsEmpty: 'Aucun outil disponible, veuillez d\'abord récupérer la liste des outils',
|
||||
toolsSummaryAll: '{count} outils au total, tous activés',
|
||||
toolsSummaryInclude: '{total} outils au total, {count} sélectionnés',
|
||||
toolsSummaryExclude: '{total} outils au total, {count} exclus',
|
||||
toolsVisibilitySaved: 'Visibilité des outils enregistrée',
|
||||
toolsSelectAll: 'Tout sélectionner',
|
||||
toolsClearSelection: 'Effacer la sélection',
|
||||
toolsExcludeAll: 'Tout exclure',
|
||||
toolsClearExcluded: 'Effacer les exclusions',
|
||||
},
|
||||
|
||||
// Sidebar
|
||||
|
||||
@@ -158,6 +158,24 @@ export default {
|
||||
invalidConfig: '設定形式が無効です',
|
||||
invalidServerConfig: 'サーバー設定が無効です',
|
||||
missingCommandOrUrl: 'command または url が必要です',
|
||||
manageTools: 'ツール管理',
|
||||
toolsVisibilityTitle: 'ツール可視性管理',
|
||||
fetchTools: 'ツールリスト取得',
|
||||
fetchToolsFailed: 'ツールリストの取得に失敗しました',
|
||||
toolsMode: 'モード:',
|
||||
toolsModeAll: 'すべて',
|
||||
toolsModeInclude: '含める',
|
||||
toolsModeExclude: '除外',
|
||||
toolsListHeader: 'ツール名',
|
||||
toolsEmpty: 'ツールがありません。まずツールリストを取得してください',
|
||||
toolsSummaryAll: '合計 {count} ツール、すべて有効',
|
||||
toolsSummaryInclude: '合計 {total} ツール、{count} 選択済み',
|
||||
toolsSummaryExclude: '合計 {total} ツール、{count} 除外済み',
|
||||
toolsVisibilitySaved: 'ツール可視性が保存されました',
|
||||
toolsSelectAll: 'すべて選択',
|
||||
toolsClearSelection: '選択をクリア',
|
||||
toolsExcludeAll: 'すべて除外',
|
||||
toolsClearExcluded: '除外をクリア',
|
||||
},
|
||||
|
||||
sidebar: {
|
||||
|
||||
@@ -158,6 +158,24 @@ export default {
|
||||
invalidConfig: '올바르지 않은 설정',
|
||||
invalidServerConfig: '서버 설정이 올바르지 않습니다',
|
||||
missingCommandOrUrl: 'command 또는 url이 필요합니다',
|
||||
manageTools: '도구 관리',
|
||||
toolsVisibilityTitle: '도구 가시성 관리',
|
||||
fetchTools: '도구 목록 가져오기',
|
||||
fetchToolsFailed: '도구 목록을 가져오지 못했습니다',
|
||||
toolsMode: '모드:',
|
||||
toolsModeAll: '전체',
|
||||
toolsModeInclude: '포함',
|
||||
toolsModeExclude: '제외',
|
||||
toolsListHeader: '도구 이름',
|
||||
toolsEmpty: '도구가 없습니다. 먼저 도구 목록을 가져오세요',
|
||||
toolsSummaryAll: '총 {count}개 도구, 모두 활성화',
|
||||
toolsSummaryInclude: '총 {total}개 도구, {count}개 선택됨',
|
||||
toolsSummaryExclude: '총 {total}개 도구, {count}개 제외됨',
|
||||
toolsVisibilitySaved: '도구 가시성이 저장되었습니다',
|
||||
toolsSelectAll: '모두 선택',
|
||||
toolsClearSelection: '선택 지우기',
|
||||
toolsExcludeAll: '모두 제외',
|
||||
toolsClearExcluded: '제외 항목 지우기',
|
||||
},
|
||||
|
||||
sidebar: {
|
||||
|
||||
@@ -157,6 +157,24 @@ export default {
|
||||
invalidConfig: 'Configuração inválida',
|
||||
invalidServerConfig: 'Configuração do servidor inválida',
|
||||
missingCommandOrUrl: 'Deve conter command ou url',
|
||||
manageTools: 'Gerenciar ferramentas',
|
||||
toolsVisibilityTitle: 'Gerenciamento de visibilidade de ferramentas',
|
||||
fetchTools: 'Obter lista de ferramentas',
|
||||
fetchToolsFailed: 'Falha ao obter lista de ferramentas',
|
||||
toolsMode: 'Modo:',
|
||||
toolsModeAll: 'Todas',
|
||||
toolsModeInclude: 'Incluir',
|
||||
toolsModeExclude: 'Excluir',
|
||||
toolsListHeader: 'Nome da ferramenta',
|
||||
toolsEmpty: 'Nenhuma ferramenta disponível, obtenha a lista de ferramentas primeiro',
|
||||
toolsSummaryAll: '{count} ferramentas no total, todas habilitadas',
|
||||
toolsSummaryInclude: '{total} ferramentas no total, {count} selecionadas',
|
||||
toolsSummaryExclude: '{total} ferramentas no total, {count} excluídas',
|
||||
toolsVisibilitySaved: 'Visibilidade das ferramentas salva',
|
||||
toolsSelectAll: 'Selecionar tudo',
|
||||
toolsClearSelection: 'Limpar seleção',
|
||||
toolsExcludeAll: 'Excluir tudo',
|
||||
toolsClearExcluded: 'Limpar exclusões',
|
||||
},
|
||||
|
||||
// Sidebar
|
||||
|
||||
@@ -158,6 +158,24 @@ export default {
|
||||
invalidConfig: '配置格式錯誤',
|
||||
invalidServerConfig: '伺服器配置無效',
|
||||
missingCommandOrUrl: '必須包含 command 或 url',
|
||||
manageTools: '管理工具',
|
||||
toolsVisibilityTitle: '工具可見性管理',
|
||||
fetchTools: '獲取工具列表',
|
||||
fetchToolsFailed: '獲取工具列表失敗',
|
||||
toolsMode: '模式:',
|
||||
toolsModeAll: '全部',
|
||||
toolsModeInclude: '包含',
|
||||
toolsModeExclude: '排除',
|
||||
toolsListHeader: '工具名稱',
|
||||
toolsEmpty: '暫無工具,請先獲取工具列表',
|
||||
toolsSummaryAll: '共 {count} 個工具,全部啟用',
|
||||
toolsSummaryInclude: '共 {total} 個工具,已選 {count} 個',
|
||||
toolsSummaryExclude: '共 {total} 個工具,已排除 {count} 個',
|
||||
toolsVisibilitySaved: '工具可見性已儲存',
|
||||
toolsSelectAll: '全選',
|
||||
toolsClearSelection: '取消全選',
|
||||
toolsExcludeAll: '全部排除',
|
||||
toolsClearExcluded: '清空排除',
|
||||
},
|
||||
|
||||
sidebar: {
|
||||
|
||||
@@ -158,6 +158,24 @@ export default {
|
||||
invalidConfig: '配置格式错误',
|
||||
invalidServerConfig: '服务器配置无效',
|
||||
missingCommandOrUrl: '必须包含 command 或 url',
|
||||
manageTools: '管理工具',
|
||||
toolsVisibilityTitle: '工具可见性管理',
|
||||
fetchTools: '获取工具列表',
|
||||
fetchToolsFailed: '获取工具列表失败',
|
||||
toolsMode: '模式:',
|
||||
toolsModeAll: '全部',
|
||||
toolsModeInclude: '包含',
|
||||
toolsModeExclude: '排除',
|
||||
toolsListHeader: '工具名称',
|
||||
toolsEmpty: '暂无工具,请先获取工具列表',
|
||||
toolsSummaryAll: '共 {count} 个工具,全部启用',
|
||||
toolsSummaryInclude: '共 {total} 个工具,已选 {count} 个',
|
||||
toolsSummaryExclude: '共 {total} 个工具,已排除 {count} 个',
|
||||
toolsVisibilitySaved: '工具可见性已保存',
|
||||
toolsSelectAll: '全选',
|
||||
toolsClearSelection: '取消全选',
|
||||
toolsExcludeAll: '全部排除',
|
||||
toolsClearExcluded: '清空排除',
|
||||
},
|
||||
|
||||
sidebar: {
|
||||
|
||||
@@ -4,11 +4,12 @@ import yaml from 'js-yaml'
|
||||
import {
|
||||
NAlert, NButton, NEmpty, NInput, NModal,
|
||||
NSpin, NRadioGroup, NRadioButton, useMessage,
|
||||
NCheckbox, NScrollbar,
|
||||
} from 'naive-ui'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import McpServerCard from '@/components/hermes/mcp/McpServerCard.vue'
|
||||
import {
|
||||
fetchMcpServers, mcpServerAdd, mcpServerRemove,
|
||||
fetchMcpServers, fetchMcpTools, mcpServerAdd, mcpServerRemove,
|
||||
mcpServerUpdate, mcpServerTest, mcpReload,
|
||||
type McpServerInfo, type McpServerConfig,
|
||||
} from '@/api/hermes/mcp'
|
||||
@@ -29,6 +30,14 @@ const jsonError = ref('')
|
||||
const saving = ref(false)
|
||||
const inputMode = ref<'json' | 'yaml'>('json')
|
||||
|
||||
// Tools visibility modal
|
||||
const showToolsModal = ref(false)
|
||||
const toolsModalServer = ref<McpServerInfo | null>(null)
|
||||
const toolsMode = ref<'all' | 'include' | 'exclude'>('all')
|
||||
const selectedTools = ref<string[]>([])
|
||||
const allTools = ref<string[]>([])
|
||||
const fetchingTools = ref(false)
|
||||
|
||||
const jsonPlaceholder = '{\n "my-server": {\n "command": "npx",\n "args": ["-y", "@modelcontextprotocol/server-filesystem", "/path"]\n }\n}'
|
||||
const yamlPlaceholder = 'my-server:\n command: npx\n args:\n - "-y"\n - "@modelcontextprotocol/server-filesystem"\n - "/path"'
|
||||
|
||||
@@ -195,15 +204,15 @@ async function loadServers() {
|
||||
try {
|
||||
const data = await fetchMcpServers()
|
||||
servers.value = data.servers ?? []
|
||||
// Populate toolsByServer from embedded tool_details
|
||||
// Populate toolsByServer from embedded tool_details, including empty filtered results.
|
||||
const nextToolsByServer: Record<string, {name: string, description: string}[]> = {}
|
||||
for (const s of servers.value) {
|
||||
if (s.tool_details?.length) {
|
||||
toolsByServer.value[s.name] = s.tool_details.map(t => ({
|
||||
name: t.name,
|
||||
description: t.description || '',
|
||||
}))
|
||||
}
|
||||
nextToolsByServer[s.name] = (s.tool_details || []).map(t => ({
|
||||
name: t.name,
|
||||
description: t.description || '',
|
||||
}))
|
||||
}
|
||||
toolsByServer.value = nextToolsByServer
|
||||
// Auto-retry with exponential backoff if enabled servers are still disconnected
|
||||
const hasPending = servers.value.some(s => s.raw_config.enabled !== false && !s.connected)
|
||||
if (hasPending && _autoRetryCount < MAX_AUTO_RETRIES) {
|
||||
@@ -368,6 +377,113 @@ async function handleTest(server: McpServerInfo) {
|
||||
|
||||
|
||||
void loadServers()
|
||||
|
||||
function openToolsModal(server: McpServerInfo) {
|
||||
toolsModalServer.value = server
|
||||
// Load mode from config
|
||||
const tools = server.raw_config.tools
|
||||
if (!tools || (!tools.include && !tools.exclude)) {
|
||||
toolsMode.value = 'all'
|
||||
selectedTools.value = [...server.tool_names]
|
||||
} else if (tools.include) {
|
||||
toolsMode.value = 'include'
|
||||
selectedTools.value = [...tools.include]
|
||||
} else {
|
||||
toolsMode.value = 'exclude'
|
||||
selectedTools.value = [...(tools.exclude || [])]
|
||||
}
|
||||
allTools.value = [...server.tool_names]
|
||||
showToolsModal.value = true
|
||||
}
|
||||
|
||||
async function fetchToolsList() {
|
||||
if (!toolsModalServer.value) return
|
||||
fetchingTools.value = true
|
||||
try {
|
||||
const res = await fetchMcpTools(toolsModalServer.value.name, true)
|
||||
if (res.ok && res.results?.length) {
|
||||
const serverResult = res.results.find((r: { server: string }) => r.server === toolsModalServer.value!.name)
|
||||
if (serverResult?.tools) {
|
||||
allTools.value = serverResult.tools.map((t: { name: string }) => t.name)
|
||||
// Reset selection to current mode
|
||||
if (toolsMode.value === 'all') {
|
||||
selectedTools.value = [...allTools.value]
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
message.error(err.message || t('mcp.fetchToolsFailed'))
|
||||
} finally {
|
||||
fetchingTools.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleToolsModeChange(mode: 'all' | 'include' | 'exclude') {
|
||||
toolsMode.value = mode
|
||||
if (mode === 'all') {
|
||||
selectedTools.value = [...allTools.value]
|
||||
} else {
|
||||
// Load from config for include/exclude mode
|
||||
const server = toolsModalServer.value
|
||||
if (server) {
|
||||
const tools = server.raw_config.tools
|
||||
if (mode === 'include' && tools?.include) {
|
||||
selectedTools.value = [...tools.include]
|
||||
} else if (mode === 'exclude' && tools?.exclude) {
|
||||
selectedTools.value = [...tools.exclude]
|
||||
} else {
|
||||
selectedTools.value = []
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleToolCheck(tool: string, checked: boolean) {
|
||||
if (checked) {
|
||||
if (!selectedTools.value.includes(tool)) {
|
||||
selectedTools.value.push(tool)
|
||||
}
|
||||
} else {
|
||||
selectedTools.value = selectedTools.value.filter(t => t !== tool)
|
||||
}
|
||||
}
|
||||
|
||||
function selectAllTools() {
|
||||
selectedTools.value = [...allTools.value]
|
||||
}
|
||||
|
||||
function clearSelectedTools() {
|
||||
selectedTools.value = []
|
||||
}
|
||||
|
||||
async function saveToolsVisibility() {
|
||||
const server = toolsModalServer.value
|
||||
if (!server) return
|
||||
|
||||
const config = { ...server.raw_config }
|
||||
|
||||
if (toolsMode.value === 'all') {
|
||||
delete config.tools
|
||||
} else if (toolsMode.value === 'include') {
|
||||
config.tools = { include: [...selectedTools.value] }
|
||||
} else {
|
||||
config.tools = { exclude: [...selectedTools.value] }
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await mcpServerUpdate(server.name, config)
|
||||
if (res.ok) {
|
||||
message.success(t('mcp.toolsVisibilitySaved'))
|
||||
showToolsModal.value = false
|
||||
await loadServers()
|
||||
scheduleReload()
|
||||
} else {
|
||||
message.error(res.error || t('mcp.updateFailed'))
|
||||
}
|
||||
} catch (err: any) {
|
||||
message.error(err.message || t('mcp.updateFailed'))
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -435,6 +551,7 @@ void loadServers()
|
||||
@reload="handleReload"
|
||||
@remove="handleRemove"
|
||||
@toggle-enabled="handleToggleEnabled"
|
||||
@manage-tools="openToolsModal"
|
||||
/>
|
||||
</div>
|
||||
<NEmpty v-else-if="!loading" :description="t('mcp.empty')" />
|
||||
@@ -465,6 +582,67 @@ void loadServers()
|
||||
</NButton>
|
||||
</div>
|
||||
</NModal>
|
||||
|
||||
<!-- Tools Visibility Modal -->
|
||||
<NModal v-model:show="showToolsModal" :title="t('mcp.toolsVisibilityTitle')" preset="card" :style="{ width: 'min(480px, calc(100vw - 32px))' }">
|
||||
<div v-if="toolsModalServer" class="tools-modal-content">
|
||||
<div class="tools-modal-header">
|
||||
<span class="server-name-label">{{ toolsModalServer.name }}</span>
|
||||
<NButton size="small" :loading="fetchingTools" @click="fetchToolsList">
|
||||
{{ t('mcp.fetchTools') }}
|
||||
</NButton>
|
||||
</div>
|
||||
|
||||
<div class="tools-mode-selector">
|
||||
<span class="mode-label">{{ t('mcp.toolsMode') }}</span>
|
||||
<NRadioGroup v-model:value="toolsMode" size="small" @update:value="handleToolsModeChange">
|
||||
<NRadioButton value="all">{{ t('mcp.toolsModeAll') }}</NRadioButton>
|
||||
<NRadioButton value="include">{{ t('mcp.toolsModeInclude') }}</NRadioButton>
|
||||
<NRadioButton value="exclude">{{ t('mcp.toolsModeExclude') }}</NRadioButton>
|
||||
</NRadioGroup>
|
||||
</div>
|
||||
|
||||
<div class="tools-list-container">
|
||||
<div class="tools-list-header">
|
||||
<span>{{ t('mcp.toolsListHeader') }}</span>
|
||||
<div v-if="toolsMode !== 'all'" class="tools-list-actions">
|
||||
<NButton size="tiny" quaternary @click="selectAllTools">
|
||||
{{ toolsMode === 'exclude' ? t('mcp.toolsExcludeAll') : t('mcp.toolsSelectAll') }}
|
||||
</NButton>
|
||||
<NButton size="tiny" quaternary @click="clearSelectedTools">
|
||||
{{ toolsMode === 'exclude' ? t('mcp.toolsClearExcluded') : t('mcp.toolsClearSelection') }}
|
||||
</NButton>
|
||||
</div>
|
||||
</div>
|
||||
<NScrollbar style="max-height: 300px;">
|
||||
<div v-if="allTools.length" class="tools-checkbox-list">
|
||||
<div v-for="tool in allTools" :key="tool" class="tool-checkbox-item" :class="{ disabled: toolsMode === 'all' }">
|
||||
<NCheckbox
|
||||
:checked="selectedTools.includes(tool)"
|
||||
:disabled="toolsMode === 'all'"
|
||||
@update:checked="(val: boolean) => handleToolCheck(tool, val)"
|
||||
/>
|
||||
<span class="tool-name" :title="tool">{{ tool }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="tools-empty">
|
||||
<span class="muted">{{ t('mcp.toolsEmpty') }}</span>
|
||||
</div>
|
||||
</NScrollbar>
|
||||
</div>
|
||||
|
||||
<div class="tools-summary">
|
||||
<span v-if="toolsMode === 'all'">{{ t('mcp.toolsSummaryAll', { count: allTools.length }) }}</span>
|
||||
<span v-else-if="toolsMode === 'include'">{{ t('mcp.toolsSummaryInclude', { count: selectedTools.length, total: allTools.length }) }}</span>
|
||||
<span v-else>{{ t('mcp.toolsSummaryExclude', { count: selectedTools.length, total: allTools.length }) }}</span>
|
||||
</div>
|
||||
|
||||
<div class="modal-actions">
|
||||
<NButton @click="showToolsModal = false">{{ t('mcp.cancel') }}</NButton>
|
||||
<NButton type="primary" @click="saveToolsVisibility">{{ t('mcp.save') }}</NButton>
|
||||
</div>
|
||||
</div>
|
||||
</NModal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -624,4 +802,110 @@ void loadServers()
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.tools-modal-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.tools-modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.server-name-label {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.tools-mode-selector {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.mode-label {
|
||||
font-size: 13px;
|
||||
color: $text-muted;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tools-list-container {
|
||||
border: 1px solid $border-color;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tools-list-header {
|
||||
padding: 10px 12px;
|
||||
background: $bg-secondary;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: $text-muted;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tools-list-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.tools-checkbox-list {
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.tool-checkbox-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 6px 12px;
|
||||
transition: background 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: $bg-secondary;
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.tool-name {
|
||||
font-size: 13px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tools-empty {
|
||||
padding: 24px 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.tools-summary {
|
||||
font-size: 12px;
|
||||
color: $text-muted;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (max-width: $breakpoint-mobile) {
|
||||
.tools-mode-selector {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -97,7 +97,10 @@ export async function testServer(ctx: Context) {
|
||||
export async function listTools(ctx: Context) {
|
||||
try {
|
||||
const server = ctx.query.server as string | undefined
|
||||
const payload = server ? { server } : {}
|
||||
const raw = ctx.query.raw === '1' || ctx.query.raw === 'true'
|
||||
const payload: Record<string, any> = {}
|
||||
if (server) payload.server = server
|
||||
if (raw) payload.raw = true
|
||||
ctx.body = await bridgeMcpAction('mcp_tools_list', payload, getProfile(ctx))
|
||||
} catch (err: any) {
|
||||
ctx.status = 503
|
||||
|
||||
@@ -609,8 +609,8 @@ export class AgentBridgeClient {
|
||||
return this.request({ action: 'mcp_server_test', name, ...(profile ? { profile } : {}) }, { timeoutMs: 180_000 })
|
||||
}
|
||||
|
||||
mcpTools(server?: string, profile?: string): Promise<McpActionResponse> {
|
||||
return this.request({ action: 'mcp_tools_list', ...(server ? { server } : {}), ...(profile ? { profile } : {}) })
|
||||
mcpTools(server?: string, profile?: string, raw?: boolean): Promise<McpActionResponse> {
|
||||
return this.request({ action: 'mcp_tools_list', ...(server ? { server } : {}), ...(profile ? { profile } : {}), ...(raw ? { raw } : {}) })
|
||||
}
|
||||
|
||||
mcpReload(server?: string, profile?: string): Promise<McpActionResponse> {
|
||||
|
||||
@@ -2427,16 +2427,18 @@ class BridgeServer:
|
||||
cfg = getattr(task, "_config", {})
|
||||
# Build filtered tool_details (name + description) for card display
|
||||
srv_cfg = mcp_configs.get(name, {}) if isinstance(mcp_configs.get(name), dict) else {}
|
||||
tools_filter = srv_cfg.get("tools") or {}
|
||||
tools_filter = srv_cfg.get("tools") if isinstance(srv_cfg.get("tools"), dict) else {}
|
||||
has_include_filter = "include" in tools_filter
|
||||
has_exclude_filter = "exclude" in tools_filter
|
||||
include_set = set(tools_filter.get("include") or [])
|
||||
exclude_set = set(tools_filter.get("exclude") or [])
|
||||
tool_details = []
|
||||
try:
|
||||
for mcp_tool in getattr(task, "_tools", []):
|
||||
tname = getattr(mcp_tool, "name", "?")
|
||||
if include_set and tname not in include_set:
|
||||
if has_include_filter and tname not in include_set:
|
||||
continue
|
||||
if exclude_set and tname in exclude_set:
|
||||
if has_exclude_filter and tname in exclude_set:
|
||||
continue
|
||||
tool_details.append({
|
||||
"name": tname,
|
||||
@@ -2576,6 +2578,7 @@ class BridgeServer:
|
||||
|
||||
def _mcp_tools_list(self, req: dict, profile: str, _servers, _lock) -> dict[str, Any]:
|
||||
server_filter = str(req.get("server") or "").strip() or None
|
||||
raw_mode = bool(req.get("raw")) # Return unfiltered tools for visibility management
|
||||
results = []
|
||||
|
||||
config = self._read_mcp_config(profile)
|
||||
@@ -2592,13 +2595,17 @@ class BridgeServer:
|
||||
registered = set(getattr(task, "_registered_tool_names", None) or [])
|
||||
tools = []
|
||||
srv_cfg = mcp_configs.get(sname, {}) if isinstance(mcp_configs.get(sname), dict) else {}
|
||||
tools_filter = srv_cfg.get("tools") or {}
|
||||
tools_filter = srv_cfg.get("tools") if isinstance(srv_cfg.get("tools"), dict) else {}
|
||||
has_include_filter = "include" in tools_filter
|
||||
has_exclude_filter = "exclude" in tools_filter
|
||||
include_set = set(tools_filter.get("include") or [])
|
||||
exclude_set = set(tools_filter.get("exclude") or [])
|
||||
def _should_include(tn):
|
||||
if include_set:
|
||||
if raw_mode:
|
||||
return True # Skip filter in raw mode
|
||||
if has_include_filter:
|
||||
return tn in include_set
|
||||
if exclude_set:
|
||||
if has_exclude_filter:
|
||||
return tn not in exclude_set
|
||||
return True
|
||||
try:
|
||||
|
||||
@@ -54,7 +54,7 @@ export async function bridgeMcpAction(
|
||||
break
|
||||
}
|
||||
case 'mcp_tools_list':
|
||||
raw = await client.mcpTools(payload.server as string | undefined, profile)
|
||||
raw = await client.mcpTools(payload.server as string | undefined, profile, payload.raw as boolean | undefined)
|
||||
break
|
||||
case 'mcp_reload':
|
||||
raw = await client.mcpReload(payload.server as string | undefined, profile)
|
||||
|
||||
@@ -1,167 +0,0 @@
|
||||
# feat(mcp): Add MCP Server Management UI
|
||||
|
||||
## Summary
|
||||
|
||||
Add a complete MCP (Model Context Protocol) server management interface that allows users to manage MCP servers through the web UI. This feature provides a user-friendly way to configure, monitor, and control MCP servers without editing configuration files manually.
|
||||
|
||||
## Features
|
||||
|
||||
### Core Functionality
|
||||
- **Server List**: View all configured MCP servers with real-time status indicators (connected/disconnected/disabled)
|
||||
- **Add Server**: Add new MCP servers with YAML or JSON configuration editor (Monaco Editor)
|
||||
- **Edit Server**: Modify existing server configurations — uses `raw_config` passthrough to avoid field loss
|
||||
- **Remove Server**: Delete servers with confirmation dialog to prevent accidental deletion
|
||||
- **Enable/Disable**: Toggle individual servers on/off with instant feedback
|
||||
- **Test Connection**: Verify server connectivity and list available tools
|
||||
- **Reload**: Refresh server configuration without restarting
|
||||
- **Search/Filter**: Filter servers by name for quick navigation
|
||||
|
||||
### User Experience
|
||||
- **Monaco Editor**: Full-featured code editor with YAML/JSON syntax highlighting
|
||||
- **Responsive Design**: Works on desktop (1280px), tablet (768px), and mobile (480px)
|
||||
- **Auto-Retry**: Exponential backoff (2s → 4s → 8s → 16s → 32s, max 5 retries) for servers that haven't connected yet
|
||||
- **Loading States**: Clear feedback during async operations
|
||||
- **Error Handling**: User-friendly error messages with actionable guidance
|
||||
- **Multi-language Support**: 9 languages (English, Chinese Simplified/Traditional, Japanese, Korean, German, Spanish, French, Portuguese)
|
||||
|
||||
### Performance Optimizations
|
||||
- **`raw_config` Passthrough**: Backend returns the original config dict as-is — frontend edits/Toggles use it directly, eliminating field-rebuild bugs
|
||||
- **`tool_details` Embedding**: `mcp_list` response embeds filtered `{name, description}` per server, reducing first-load from 1+N requests to 1
|
||||
- **Route Safety**: `hasRoute()` guards for dynamic sidebar routes to prevent Vue runtime crashes
|
||||
|
||||
## Technical Implementation
|
||||
|
||||
### Frontend (Vue 3 + Naive UI)
|
||||
- `McpManagerView.vue`: Main management component with auto-retry, search, modal management
|
||||
- `McpServerCard.vue`: Server card component with status indicators, tool tags, action buttons
|
||||
- `mcp.ts` (api): Type definitions (`McpServerInfo` with `raw_config`, `tool_details`) and API client
|
||||
- Monaco Editor integration for configuration editing
|
||||
- Reactive state management with Vue 3 Composition API
|
||||
|
||||
### Backend (Koa + TypeScript)
|
||||
- `mcp.ts` (controller): Request handlers with input validation
|
||||
- `mcp.ts` (service): Business logic layer with typed interfaces
|
||||
- `mcp.ts` (routes): RESTful API endpoints
|
||||
- `mcp-types.ts`: Shared type definitions
|
||||
- `client.ts` (bridge): AgentBridge client methods
|
||||
|
||||
### Python Bridge (`hermes_bridge.py`)
|
||||
- `_build_server_entry()`: Normalized server entry builder returning `raw_config` and `tool_details`
|
||||
- `_mcp_list()`: Lists all MCP servers with embedded filtered tool details
|
||||
- `_mcp_server_add/update/remove`: Full CRUD with config validation and atomic YAML persistence
|
||||
- `_mcp_server_toggle`: Enable/disable individual servers
|
||||
- `_mcp_server_test`: Connection test with tool discovery
|
||||
- `_mcp_reload`: Hot-reload server connections
|
||||
- Background MCP discovery on worker startup
|
||||
|
||||
### Testing
|
||||
- `mcp-controller.test.ts`: 19 unit tests covering all controller methods
|
||||
- Tests for success cases, error handling, and edge cases
|
||||
- Mock-based testing for isolation
|
||||
|
||||
## Files Changed
|
||||
|
||||
```
|
||||
packages/client/src/api/hermes/mcp.ts | 87 ++++
|
||||
packages/client/src/components/hermes/mcp/McpServerCard.vue | 275 +++++++++
|
||||
packages/client/src/components/layout/AppSidebar.vue | 16 +-
|
||||
packages/client/src/components/hermes/chat/ChatInput.vue | 1 +
|
||||
packages/client/src/i18n/locales/{de,en,es,fr,ja,ko,pt,zh,zh-TW}.ts | 504 +++ (9 files)
|
||||
packages/client/src/i18n/messages.ts | 2 +-
|
||||
packages/client/src/router/index.ts | 6 +
|
||||
packages/client/src/views/hermes/McpManagerView.vue | 623 +++++++++++++++++
|
||||
packages/server/src/controllers/hermes/mcp.ts | 117 ++++
|
||||
packages/server/src/routes/hermes/mcp.ts | 12 +
|
||||
packages/server/src/routes/index.ts | 2 +
|
||||
packages/server/src/services/hermes/agent-bridge/client.ts | 31 +
|
||||
packages/server/src/services/hermes/agent-bridge/hermes_bridge.py | 368 +++++++++-
|
||||
packages/server/src/services/hermes/mcp-types.ts | 67 ++
|
||||
packages/server/src/services/hermes/mcp.ts | 67 ++
|
||||
packages/server/src/services/hermes/run-chat/session-command.ts | 22 +
|
||||
tests/server/mcp-controller.test.ts | 286 +++++++++
|
||||
```
|
||||
|
||||
**Total**: 27 files changed, ~2,900 insertions
|
||||
|
||||
## Testing
|
||||
|
||||
### Unit Tests (19/19 passing)
|
||||
```
|
||||
✓ MCP Controller > listServers > returns servers list from bridge
|
||||
✓ MCP Controller > listServers > returns 503 on bridge error
|
||||
✓ MCP Controller > addServer > sends name and config to bridge
|
||||
✓ MCP Controller > addServer > returns 400 when name is missing
|
||||
✓ MCP Controller > addServer > returns 400 when config is missing
|
||||
✓ MCP Controller > updateServer > sends name from params and config to bridge
|
||||
✓ MCP Controller > updateServer > returns 400 when config is missing
|
||||
✓ MCP Controller > removeServer > sends name to bridge
|
||||
✓ MCP Controller > testServer > returns tool list from bridge
|
||||
✓ MCP Controller > listTools > returns tools without server filter
|
||||
✓ MCP Controller > listTools > passes server filter to bridge
|
||||
✓ MCP Controller > listTools > returns 503 on bridge error
|
||||
✓ MCP Controller > reloadMcp > reloads all servers when no filter
|
||||
✓ MCP Controller > reloadMcp > reloads specific server
|
||||
✓ MCP Controller > reloadMcp > returns 500 on bridge error
|
||||
✓ MCP Controller > profile handling > passes undefined profile when ctx.state.profile is missing
|
||||
✓ MCP Controller > profile handling > passes undefined profile when profile.name is empty
|
||||
✓ MCP Controller > response structure > mcp_list response has all required fields
|
||||
✓ MCP Controller > response structure > mcp_tools_list response has tools with name/description/schema
|
||||
```
|
||||
|
||||
### UX Browser Tests (8/8 passing)
|
||||
|
||||
| Test Case | Screenshot | Result |
|
||||
|-----------|------------|--------|
|
||||
| TC-01 Initial State | [mcp-tc01-initial-state.png](docs/images/mcp/mcp-tc01-initial-state.png) | ✅ Summary cards 1/1/0/3, github server connected |
|
||||
| TC-02 Search Filter | [mcp-tc02-search-git.png](docs/images/mcp/mcp-tc02-search-git.png) | ✅ Correctly filters to github only |
|
||||
| TC-03 Search Empty | [mcp-tc03-search-empty.png](docs/images/mcp/mcp-tc03-search-empty.png) | ✅ Empty state "暂无 MCP 服务器配置" shown |
|
||||
| TC-04 Add Modal | [mcp-tc04-add-modal.png](docs/images/mcp/mcp-tc04-add-modal.png) | ✅ JSON/YAML toggle, config editor, Cancel/Save |
|
||||
| TC-05 Edit Modal | [mcp-tc05-edit-modal.png](docs/images/mcp/mcp-tc05-edit-modal.png) | ✅ Pre-filled config with raw_config passthrough |
|
||||
| TC-06 Tools Expanded | [mcp-tc06-tools-expanded.png](docs/images/mcp/mcp-tc06-tools-expanded.png) | ✅ Shows 3/26 tool tags |
|
||||
| TC-07 Responsive 768px | [mcp-tc07-responsive-768.png](docs/images/mcp/mcp-tc07-responsive-768.png) | ✅ Tablet layout, no overflow |
|
||||
| TC-08 Responsive 480px | [mcp-tc08-responsive-480.png](docs/images/mcp/mcp-tc08-responsive-480.png) | ✅ Mobile layout, all elements accessible |
|
||||
|
||||
### Build
|
||||
- [x] TypeScript compilation successful
|
||||
- [x] Vite build successful
|
||||
- [x] No linting errors
|
||||
|
||||
## Architecture Decisions
|
||||
|
||||
### 1. `raw_config` Passthrough
|
||||
**Problem**: Rebuilding config from scattered fields (command, args, env, etc.) caused field loss on edit/Toggle.
|
||||
**Solution**: Backend returns `raw_config` (original dict from config.yaml), frontend edits it directly. Zero rebuild, zero field loss.
|
||||
|
||||
### 2. `tool_details` Embedding
|
||||
**Problem**: First load required 1 API call for server list + N calls for tool details (one per server).
|
||||
**Solution**: `mcp_list` response embeds `tool_details` (filtered `{name, description}` per server). Single request for full card data.
|
||||
|
||||
### 3. Auto-Retry with Exponential Backoff
|
||||
**Problem**: MCP servers may not be connected on first page load (still initializing).
|
||||
**Solution**: Auto-retry with exponential backoff (2s, 4s, 8s, 16s, 32s), max 5 retries. Manual refresh resets counter.
|
||||
|
||||
### 4. Route Safety Guards
|
||||
**Problem**: Unknown routes (e.g., `codingAgents`, `versionPreview`) could crash Vue at runtime.
|
||||
**Solution**: `hasRoute()` checks before rendering dynamic sidebar route items.
|
||||
|
||||
## Screenshots
|
||||
|
||||
### Desktop (1280px) — Initial State
|
||||

|
||||
|
||||
### Edit Server Modal
|
||||

|
||||
|
||||
### Responsive — Mobile (480px)
|
||||

|
||||
|
||||
## Checklist
|
||||
|
||||
- [x] Code follows project style guidelines
|
||||
- [x] Self-review completed
|
||||
- [x] Documentation updated
|
||||
- [x] Tests added for new functionality (19 unit + 8 UX)
|
||||
- [x] All tests pass
|
||||
- [x] No breaking changes
|
||||
- [x] Multi-language support (9 languages)
|
||||
- [x] Responsive design verified (desktop/tablet/mobile)
|
||||
@@ -0,0 +1,108 @@
|
||||
import { execFileSync } from 'child_process'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
function runPython(script: string): any {
|
||||
try {
|
||||
const output = execFileSync('python3', ['-c', script], {
|
||||
cwd: process.cwd(),
|
||||
encoding: 'utf-8',
|
||||
stdio: 'pipe',
|
||||
})
|
||||
return JSON.parse(output)
|
||||
} catch (error) {
|
||||
const err = error as { stdout?: string; stderr?: string; message?: string }
|
||||
throw new Error([
|
||||
err.message || 'Python bridge MCP filter script failed',
|
||||
err.stdout ? `stdout:\n${err.stdout}` : '',
|
||||
err.stderr ? `stderr:\n${err.stderr}` : '',
|
||||
].filter(Boolean).join('\n\n'))
|
||||
}
|
||||
}
|
||||
|
||||
describe('agent bridge MCP tools filtering', () => {
|
||||
it('treats an empty include list as an active filter and keeps raw listing unfiltered', () => {
|
||||
const result = runPython(String.raw`
|
||||
import importlib.util
|
||||
import json
|
||||
import sys
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
path = Path("packages/server/src/services/hermes/agent-bridge/hermes_bridge.py")
|
||||
spec = importlib.util.spec_from_file_location("hermes_bridge", path)
|
||||
bridge = importlib.util.module_from_spec(spec)
|
||||
sys.modules[spec.name] = bridge
|
||||
spec.loader.exec_module(bridge)
|
||||
|
||||
class Tool:
|
||||
def __init__(self, name):
|
||||
self.name = name
|
||||
self.description = f"{name} description"
|
||||
self.inputSchema = {"type": "object"}
|
||||
|
||||
class Task:
|
||||
_task = None
|
||||
_error = None
|
||||
|
||||
def __init__(self):
|
||||
self._tools = [Tool("read_file"), Tool("write_file"), Tool("delete_file")]
|
||||
self._registered_tool_names = ["read_file", "write_file", "delete_file"]
|
||||
self._config = {"command": "mcp-server"}
|
||||
|
||||
server = bridge.BridgeServer("tcp://127.0.0.1:0")
|
||||
servers = {"fs": Task()}
|
||||
lock = threading.RLock()
|
||||
|
||||
def names(response):
|
||||
return [tool["name"] for tool in response["results"][0]["tools"]]
|
||||
|
||||
server._read_mcp_config = lambda profile: {
|
||||
"mcp_servers": {
|
||||
"fs": {
|
||||
"command": "mcp-server",
|
||||
"tools": {"include": []},
|
||||
},
|
||||
},
|
||||
}
|
||||
include_empty = server._mcp_tools_list({"server": "fs"}, "default", servers, lock)
|
||||
include_empty_list = server._mcp_list("default", servers, lock)
|
||||
include_empty_raw = server._mcp_tools_list({"server": "fs", "raw": True}, "default", servers, lock)
|
||||
|
||||
server._read_mcp_config = lambda profile: {
|
||||
"mcp_servers": {
|
||||
"fs": {
|
||||
"command": "mcp-server",
|
||||
"tools": {"include": ["read_file"]},
|
||||
},
|
||||
},
|
||||
}
|
||||
include_one = server._mcp_tools_list({"server": "fs"}, "default", servers, lock)
|
||||
|
||||
server._read_mcp_config = lambda profile: {
|
||||
"mcp_servers": {
|
||||
"fs": {
|
||||
"command": "mcp-server",
|
||||
"tools": {"exclude": ["delete_file"]},
|
||||
},
|
||||
},
|
||||
}
|
||||
exclude_one = server._mcp_tools_list({"server": "fs"}, "default", servers, lock)
|
||||
|
||||
print(json.dumps({
|
||||
"include_empty": names(include_empty),
|
||||
"include_empty_details": include_empty_list["servers"][0]["tool_details"],
|
||||
"include_empty_raw": names(include_empty_raw),
|
||||
"include_one": names(include_one),
|
||||
"exclude_one": names(exclude_one),
|
||||
}))
|
||||
`)
|
||||
|
||||
expect(result).toEqual({
|
||||
include_empty: [],
|
||||
include_empty_details: [],
|
||||
include_empty_raw: ['read_file', 'write_file', 'delete_file'],
|
||||
include_one: ['read_file'],
|
||||
exclude_one: ['read_file', 'write_file'],
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,56 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
// ── Mocks ──────────────────────────────────────────────────
|
||||
const mcpToolsMock = vi.fn()
|
||||
|
||||
vi.mock('../../packages/server/src/services/hermes/agent-bridge/client', () => ({
|
||||
AgentBridgeClient: vi.fn().mockImplementation(() => ({
|
||||
mcpTools: mcpToolsMock,
|
||||
})),
|
||||
}))
|
||||
|
||||
vi.mock('../../packages/server/src/services/logger', () => ({
|
||||
logger: { warn: vi.fn(), error: vi.fn(), info: vi.fn() },
|
||||
}))
|
||||
|
||||
// ── Tests ──────────────────────────────────────────────────
|
||||
describe('bridgeMcpAction - mcp_tools_list', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('passes server and profile to client.mcpTools', async () => {
|
||||
mcpToolsMock.mockResolvedValue({ ok: true, results: [] })
|
||||
const { bridgeMcpAction } = await import('../../packages/server/src/services/hermes/mcp')
|
||||
await bridgeMcpAction('mcp_tools_list', { server: 'github' }, 'test-profile')
|
||||
expect(mcpToolsMock).toHaveBeenCalledWith('github', 'test-profile', undefined)
|
||||
})
|
||||
|
||||
it('passes raw=true to client.mcpTools', async () => {
|
||||
mcpToolsMock.mockResolvedValue({ ok: true, results: [] })
|
||||
const { bridgeMcpAction } = await import('../../packages/server/src/services/hermes/mcp')
|
||||
await bridgeMcpAction('mcp_tools_list', { server: 'github', raw: true }, 'test-profile')
|
||||
expect(mcpToolsMock).toHaveBeenCalledWith('github', 'test-profile', true)
|
||||
})
|
||||
|
||||
it('passes raw=false to client.mcpTools', async () => {
|
||||
mcpToolsMock.mockResolvedValue({ ok: true, results: [] })
|
||||
const { bridgeMcpAction } = await import('../../packages/server/src/services/hermes/mcp')
|
||||
await bridgeMcpAction('mcp_tools_list', { server: 'github', raw: false }, 'test-profile')
|
||||
expect(mcpToolsMock).toHaveBeenCalledWith('github', 'test-profile', false)
|
||||
})
|
||||
|
||||
it('passes undefined server when not provided', async () => {
|
||||
mcpToolsMock.mockResolvedValue({ ok: true, results: [] })
|
||||
const { bridgeMcpAction } = await import('../../packages/server/src/services/hermes/mcp')
|
||||
await bridgeMcpAction('mcp_tools_list', {}, 'test-profile')
|
||||
expect(mcpToolsMock).toHaveBeenCalledWith(undefined, 'test-profile', undefined)
|
||||
})
|
||||
|
||||
it('passes undefined profile when not provided', async () => {
|
||||
mcpToolsMock.mockResolvedValue({ ok: true, results: [] })
|
||||
const { bridgeMcpAction } = await import('../../packages/server/src/services/hermes/mcp')
|
||||
await bridgeMcpAction('mcp_tools_list', { server: 'github' })
|
||||
expect(mcpToolsMock).toHaveBeenCalledWith('github', undefined, undefined)
|
||||
})
|
||||
})
|
||||
@@ -151,6 +151,53 @@ describe('MCP Controller', () => {
|
||||
await updateServer(ctx)
|
||||
expect(ctx.status).toBe(400)
|
||||
})
|
||||
|
||||
it('sends tools.include config for include mode', async () => {
|
||||
mcpUpdateMock.mockResolvedValue({ ok: true })
|
||||
const { updateServer } = await import('../../packages/server/src/controllers/hermes/mcp')
|
||||
const ctx = createCtx({
|
||||
params: { name: 'github' },
|
||||
request: { body: { config: { command: 'npx', args: ['-y', 'server'], tools: { include: ['read_file', 'write_file'] } } } },
|
||||
})
|
||||
await updateServer(ctx)
|
||||
expect(mcpUpdateMock).toHaveBeenCalledWith('github', {
|
||||
command: 'npx',
|
||||
args: ['-y', 'server'],
|
||||
tools: { include: ['read_file', 'write_file'] },
|
||||
}, 'test-profile')
|
||||
expect(ctx.body).toEqual({ ok: true })
|
||||
})
|
||||
|
||||
it('sends tools.exclude config for exclude mode', async () => {
|
||||
mcpUpdateMock.mockResolvedValue({ ok: true })
|
||||
const { updateServer } = await import('../../packages/server/src/controllers/hermes/mcp')
|
||||
const ctx = createCtx({
|
||||
params: { name: 'github' },
|
||||
request: { body: { config: { command: 'npx', args: ['-y', 'server'], tools: { exclude: ['delete_file'] } } } },
|
||||
})
|
||||
await updateServer(ctx)
|
||||
expect(mcpUpdateMock).toHaveBeenCalledWith('github', {
|
||||
command: 'npx',
|
||||
args: ['-y', 'server'],
|
||||
tools: { exclude: ['delete_file'] },
|
||||
}, 'test-profile')
|
||||
expect(ctx.body).toEqual({ ok: true })
|
||||
})
|
||||
|
||||
it('sends config without tools field for all mode', async () => {
|
||||
mcpUpdateMock.mockResolvedValue({ ok: true })
|
||||
const { updateServer } = await import('../../packages/server/src/controllers/hermes/mcp')
|
||||
const ctx = createCtx({
|
||||
params: { name: 'github' },
|
||||
request: { body: { config: { command: 'npx', args: ['-y', 'server'] } } },
|
||||
})
|
||||
await updateServer(ctx)
|
||||
expect(mcpUpdateMock).toHaveBeenCalledWith('github', {
|
||||
command: 'npx',
|
||||
args: ['-y', 'server'],
|
||||
}, 'test-profile')
|
||||
expect(ctx.body).toEqual({ ok: true })
|
||||
})
|
||||
})
|
||||
|
||||
describe('removeServer', () => {
|
||||
@@ -180,7 +227,7 @@ describe('MCP Controller', () => {
|
||||
const { listTools } = await import('../../packages/server/src/controllers/hermes/mcp')
|
||||
const ctx = createCtx({ query: {} })
|
||||
await listTools(ctx)
|
||||
expect(mcpToolsMock).toHaveBeenCalledWith(undefined, 'test-profile')
|
||||
expect(mcpToolsMock).toHaveBeenCalledWith(undefined, 'test-profile', undefined)
|
||||
expect(ctx.body).toEqual(SAMPLE_TOOLS_RESPONSE)
|
||||
})
|
||||
|
||||
@@ -189,7 +236,15 @@ describe('MCP Controller', () => {
|
||||
const { listTools } = await import('../../packages/server/src/controllers/hermes/mcp')
|
||||
const ctx = createCtx({ query: { server: 'github' } })
|
||||
await listTools(ctx)
|
||||
expect(mcpToolsMock).toHaveBeenCalledWith('github', 'test-profile')
|
||||
expect(mcpToolsMock).toHaveBeenCalledWith('github', 'test-profile', undefined)
|
||||
})
|
||||
|
||||
it('passes raw=true to get unfiltered tools', async () => {
|
||||
mcpToolsMock.mockResolvedValue(SAMPLE_TOOLS_RESPONSE)
|
||||
const { listTools } = await import('../../packages/server/src/controllers/hermes/mcp')
|
||||
const ctx = createCtx({ query: { server: 'github', raw: '1' } })
|
||||
await listTools(ctx)
|
||||
expect(mcpToolsMock).toHaveBeenCalledWith('github', 'test-profile', true)
|
||||
})
|
||||
|
||||
it('returns 503 on bridge error', async () => {
|
||||
|
||||