[codex] fix MCP management lifecycle (#1144)
* feat(mcp): add MCP server management UI - Server CRUD: add/edit/remove with YAML/JSON Monaco editor - raw_config passthrough: zero field loss on edit/toggle - tool_details embedding: single-request card data (1+N → 1) - Auto-retry exponential backoff (2s→32s, max 5 retries) - Route safety guards (hasRoute) for dynamic sidebar - i18n: 9 languages (de/en/es/fr/ja/ko/pt/zh/zh-TW) - 19 unit tests + 8 UX browser tests - 35 files, +2933 lines * fix mcp management lifecycle --------- Co-authored-by: Crafter-feng <succeed_happu@163.com>
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 28 KiB |
@@ -0,0 +1,87 @@
|
|||||||
|
import { request } from '../client'
|
||||||
|
|
||||||
|
export interface McpServerInfo {
|
||||||
|
name: string
|
||||||
|
transport: 'stdio' | 'http' | 'sse'
|
||||||
|
connected: boolean
|
||||||
|
tools: number
|
||||||
|
tools_registered: number
|
||||||
|
tool_names: string[]
|
||||||
|
tool_names_registered: string[]
|
||||||
|
tool_details: Array<{ name: string; description?: string }>
|
||||||
|
error?: string | null
|
||||||
|
raw_config: McpServerConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface McpServersResponse {
|
||||||
|
ok: boolean
|
||||||
|
servers: McpServerInfo[]
|
||||||
|
total_tools: number
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface McpToolsResponse {
|
||||||
|
ok: boolean
|
||||||
|
results: Array<{
|
||||||
|
server: string
|
||||||
|
tools: Array<{
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
input_schema: Record<string, unknown>
|
||||||
|
}>
|
||||||
|
}>
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface McpServerConfig {
|
||||||
|
command?: string
|
||||||
|
args?: string[]
|
||||||
|
url?: string
|
||||||
|
env?: Record<string, string>
|
||||||
|
headers?: Record<string, string>
|
||||||
|
timeout?: number
|
||||||
|
connect_timeout?: number
|
||||||
|
enabled?: boolean
|
||||||
|
transport?: 'stdio' | 'http' | 'sse'
|
||||||
|
tools?: { include?: string[]; exclude?: string[] }
|
||||||
|
prompts?: boolean
|
||||||
|
resources?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
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)}` : ''
|
||||||
|
return request<McpToolsResponse>(`/api/hermes/mcp/tools${query}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function mcpServerAdd(name: string, config: McpServerConfig): Promise<{ ok: boolean; name?: string; error?: string }> {
|
||||||
|
return request('/api/hermes/mcp/servers', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ name, config }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function mcpServerRemove(name: string): Promise<{ ok: boolean; error?: string }> {
|
||||||
|
return request(`/api/hermes/mcp/servers/${encodeURIComponent(name)}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function mcpServerUpdate(name: string, config: McpServerConfig): Promise<{ ok: boolean; error?: string }> {
|
||||||
|
return request(`/api/hermes/mcp/servers/${encodeURIComponent(name)}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify({ config }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function mcpReload(name?: string): Promise<{ ok: boolean; message?: string; error?: string }> {
|
||||||
|
const query = name ? `?server=${encodeURIComponent(name)}` : ''
|
||||||
|
return request(`/api/hermes/mcp/reload${query}`, { method: 'POST' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function mcpServerTest(name: string): Promise<{ ok: boolean; tools?: string[]; error?: string }> {
|
||||||
|
return request(`/api/hermes/mcp/servers/${encodeURIComponent(name)}/test`, { method: 'POST' })
|
||||||
|
}
|
||||||
@@ -44,6 +44,7 @@ const bridgeCommands = computed(() => [
|
|||||||
{ name: 'compress', args: '', description: t('chat.slashCommands.compress') },
|
{ name: 'compress', args: '', description: t('chat.slashCommands.compress') },
|
||||||
{ name: 'steer', args: t('chat.slashCommandArgs.text'), description: t('chat.slashCommands.steer') },
|
{ name: 'steer', args: t('chat.slashCommandArgs.text'), description: t('chat.slashCommands.steer') },
|
||||||
{ name: 'destroy', args: '', description: t('chat.slashCommands.destroy') },
|
{ name: 'destroy', args: '', description: t('chat.slashCommands.destroy') },
|
||||||
|
{ name: 'reload-mcp', args: '', description: t('chat.slashCommands.reloadMcp') },
|
||||||
])
|
])
|
||||||
|
|
||||||
const slashActive = ref(false)
|
const slashActive = ref(false)
|
||||||
|
|||||||
@@ -0,0 +1,275 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { NButton, NSwitch, NPopconfirm } from 'naive-ui'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import type { McpServerInfo } from '@/api/hermes/mcp'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
server: McpServerInfo
|
||||||
|
toolsByServer: Record<string, Array<{ name: string; description?: string }>>
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
edit: [server: McpServerInfo]
|
||||||
|
test: [server: McpServerInfo]
|
||||||
|
reload: [name: string]
|
||||||
|
remove: [server: McpServerInfo]
|
||||||
|
toggleEnabled: [server: McpServerInfo]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
function statusClass(server: McpServerInfo) {
|
||||||
|
if (server.raw_config.enabled === false) return 'disabled'
|
||||||
|
return server.connected ? 'connected' : 'disconnected'
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusLabel(server: McpServerInfo) {
|
||||||
|
if (server.raw_config.enabled === false) return t('mcp.disabledStatus')
|
||||||
|
return server.connected ? t('mcp.connectedStatus') : t('mcp.disconnectedStatus')
|
||||||
|
}
|
||||||
|
|
||||||
|
const tools = computed(() => props.toolsByServer[props.server.name] || [])
|
||||||
|
const MAX_VISIBLE_TOOLS = 20
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="mcp-card" :class="{ disconnected: !server.connected, disabled: server.raw_config.enabled === false }">
|
||||||
|
<!-- 第一行:标题 + 标签 -->
|
||||||
|
<div class="card-header">
|
||||||
|
<h3 class="server-name">{{ server.name }}</h3>
|
||||||
|
<div class="server-badges">
|
||||||
|
<span class="type-badge transport">{{ server.transport }}</span>
|
||||||
|
<span class="type-badge" :class="statusClass(server)">{{ statusLabel(server) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 第二行:工具列表 + 数量 -->
|
||||||
|
<div class="card-body">
|
||||||
|
<div v-if="server.error" class="error-row">
|
||||||
|
<span class="error-text">{{ server.error }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="info-label">{{ t('mcp.toolList') }}</span>
|
||||||
|
<span class="info-value">
|
||||||
|
{{ server.tools_registered }}/{{ server.tools }}{{ t('mcp.count') }}{{ t('mcp.tools') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 工具标签列表 -->
|
||||||
|
<div v-if="server.tools > 0" class="tools-list">
|
||||||
|
<span
|
||||||
|
v-for="tool in tools.slice(0, MAX_VISIBLE_TOOLS)"
|
||||||
|
:key="tool.name"
|
||||||
|
class="tool-tag"
|
||||||
|
:title="tool.description"
|
||||||
|
>
|
||||||
|
{{ tool.name }}
|
||||||
|
</span>
|
||||||
|
<span v-if="tools.length > MAX_VISIBLE_TOOLS" class="tool-tag tool-tag-more">
|
||||||
|
+{{ tools.length - MAX_VISIBLE_TOOLS }} {{ t('mcp.more') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div v-else class="tools-empty">
|
||||||
|
<span class="muted">{{ t('mcp.zeroTools') }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 底部:按钮 + 开关 -->
|
||||||
|
<div class="card-footer">
|
||||||
|
<div class="card-actions">
|
||||||
|
<NButton size="tiny" quaternary @click="emit('edit', server)">{{ t('mcp.edit') }}</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)">
|
||||||
|
<template #trigger>
|
||||||
|
<NButton size="tiny" quaternary type="error">{{ t('mcp.remove') }}</NButton>
|
||||||
|
</template>
|
||||||
|
{{ t('mcp.confirmRemove', { name: server.name }) }}
|
||||||
|
</NPopconfirm>
|
||||||
|
</div>
|
||||||
|
<NSwitch
|
||||||
|
:value="server.raw_config.enabled !== false"
|
||||||
|
size="small"
|
||||||
|
@update:value="() => emit('toggleEnabled', server)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
@use '@/styles/variables' as *;
|
||||||
|
|
||||||
|
.mcp-card {
|
||||||
|
background-color: $bg-card;
|
||||||
|
border: 1px solid $border-color;
|
||||||
|
border-radius: $radius-md;
|
||||||
|
padding: 16px;
|
||||||
|
transition: border-color $transition-fast;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: rgba(var(--accent-primary-rgb), 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.disconnected {
|
||||||
|
border-color: rgba(var(--error-rgb), 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.disabled {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-name {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $text-primary;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
max-width: 70%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-badges {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-badge {
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
&.transport {
|
||||||
|
background: rgba(var(--accent-primary-rgb), 0.12);
|
||||||
|
color: $accent-primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.connected {
|
||||||
|
background: rgba(var(--success-rgb), 0.12);
|
||||||
|
color: $success;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.disconnected {
|
||||||
|
background: rgba(var(--error-rgb), 0.12);
|
||||||
|
color: $error;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.disabled {
|
||||||
|
background: rgba(var(--text-muted-rgb, 128,128,128), 0.12);
|
||||||
|
color: $text-muted;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-row {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-text {
|
||||||
|
color: $error;
|
||||||
|
font-size: 11px;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: $text-muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-value {
|
||||||
|
font-size: 12px;
|
||||||
|
color: $text-secondary;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tools-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px 6px;
|
||||||
|
height: 88px;
|
||||||
|
overflow-y: auto;
|
||||||
|
align-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-tag {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 22px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-family: $font-code;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: rgba(var(--accent-primary-rgb), 0.08);
|
||||||
|
color: $text-secondary;
|
||||||
|
white-space: nowrap;
|
||||||
|
max-width: 200px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
cursor: default;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(var(--accent-primary-rgb), 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
&-more {
|
||||||
|
background: rgba(var(--accent-primary-rgb), 0.15);
|
||||||
|
color: $accent-primary;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tools-empty {
|
||||||
|
height: 88px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted {
|
||||||
|
color: $text-muted;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
border-top: 1px solid $border-light;
|
||||||
|
padding-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -32,6 +32,9 @@ const isVersionPreview = import.meta.env.VITE_HERMES_PREVIEW === '1';
|
|||||||
function isNavActive(...names: string[]) {
|
function isNavActive(...names: string[]) {
|
||||||
return names.includes(selectedKey.value);
|
return names.includes(selectedKey.value);
|
||||||
}
|
}
|
||||||
|
function hasRoute(name: string): boolean {
|
||||||
|
return router.hasRoute(name);
|
||||||
|
}
|
||||||
const logoPath = '/logo.png';
|
const logoPath = '/logo.png';
|
||||||
|
|
||||||
const { record: collapsedGroups, persist: persistCollapsedGroups } = usePersistentRecord('hermes.sidebar.collapsedGroups');
|
const { record: collapsedGroups, persist: persistCollapsedGroups } = usePersistentRecord('hermes.sidebar.collapsedGroups');
|
||||||
@@ -186,6 +189,15 @@ function openChangelog() {
|
|||||||
</svg>
|
</svg>
|
||||||
<span>{{ t("sidebar.plugins") }}</span>
|
<span>{{ t("sidebar.plugins") }}</span>
|
||||||
</RouteLinkItem>
|
</RouteLinkItem>
|
||||||
|
<RouteLinkItem class="nav-item" :to="{ name: 'hermes.mcp' }" :active="selectedKey === 'hermes.mcp'">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M4 7V4h16v3" />
|
||||||
|
<path d="M9 20h6" />
|
||||||
|
<path d="M12 7v13" />
|
||||||
|
<rect x="4" y="7" width="16" height="7" rx="2" />
|
||||||
|
</svg>
|
||||||
|
<span>{{ t("sidebar.mcp") }}</span>
|
||||||
|
</RouteLinkItem>
|
||||||
<RouteLinkItem class="nav-item" :to="{ name: 'hermes.memory' }" :active="selectedKey === 'hermes.memory'">
|
<RouteLinkItem class="nav-item" :to="{ name: 'hermes.memory' }" :active="selectedKey === 'hermes.memory'">
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<path d="M9 18h6" />
|
<path d="M9 18h6" />
|
||||||
@@ -263,7 +275,7 @@ function openChangelog() {
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div v-show="!isGroupCollapsed('tools')" class="nav-group-items">
|
<div v-show="!isGroupCollapsed('tools')" class="nav-group-items">
|
||||||
<RouteLinkItem class="nav-item" :to="{ name: 'hermes.codingAgents' }" :active="selectedKey === 'hermes.codingAgents'">
|
<RouteLinkItem v-if="hasRoute('hermes.codingAgents')" class="nav-item" :to="{ name: 'hermes.codingAgents' }" :active="selectedKey === 'hermes.codingAgents'">
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<polyline points="16 18 22 12 16 6" />
|
<polyline points="16 18 22 12 16 6" />
|
||||||
<polyline points="8 6 2 12 8 18" />
|
<polyline points="8 6 2 12 8 18" />
|
||||||
@@ -271,7 +283,7 @@ function openChangelog() {
|
|||||||
</svg>
|
</svg>
|
||||||
<span>{{ t("sidebar.codingAgents") }}</span>
|
<span>{{ t("sidebar.codingAgents") }}</span>
|
||||||
</RouteLinkItem>
|
</RouteLinkItem>
|
||||||
<RouteLinkItem v-if="isSuperAdmin && !isVersionPreview" class="nav-item" :to="{ name: 'hermes.versionPreview' }" :active="selectedKey === 'hermes.versionPreview'">
|
<RouteLinkItem v-if="hasRoute('hermes.versionPreview') && isSuperAdmin && !isVersionPreview" class="nav-item" :to="{ name: 'hermes.versionPreview' }" :active="selectedKey === 'hermes.versionPreview'">
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" />
|
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" />
|
||||||
<polyline points="7.5 4.21 12 6.81 16.5 4.21" />
|
<polyline points="7.5 4.21 12 6.81 16.5 4.21" />
|
||||||
|
|||||||
@@ -105,6 +105,60 @@ export default {
|
|||||||
expired: 'Abgelaufen',
|
expired: 'Abgelaufen',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// MCP-Verwaltung
|
||||||
|
mcp: {
|
||||||
|
title: 'MCP-Server',
|
||||||
|
loadFailed: 'MCP-Server konnten nicht geladen werden',
|
||||||
|
reloadAll: 'Alle neu laden',
|
||||||
|
refresh: 'Aktualisieren',
|
||||||
|
total: 'Gesamt',
|
||||||
|
connected: 'Verbunden',
|
||||||
|
disconnected: 'Getrennt',
|
||||||
|
tools: 'werkzeuge',
|
||||||
|
tool: 'Werkzeuge',
|
||||||
|
searchPlaceholder: 'Server suchen...',
|
||||||
|
addServer: '+ Server hinzufuegen',
|
||||||
|
zeroTools: '0 Werkzeuge',
|
||||||
|
loading: 'Wird geladen...',
|
||||||
|
empty: 'Keine MCP-Server konfiguriert',
|
||||||
|
reloaded: '{server} neu geladen',
|
||||||
|
reloadedAll: 'Alle MCP-Server neu geladen',
|
||||||
|
reloadFailed: 'Neuladen fehlgeschlagen',
|
||||||
|
serverAdded: 'Server "{name}" hinzugefuegt',
|
||||||
|
addFailed: 'Server konnte nicht hinzugefuegt werden',
|
||||||
|
serverUpdated: 'Server "{name}" aktualisiert',
|
||||||
|
updateFailed: 'Server konnte nicht aktualisiert werden',
|
||||||
|
saveFailed: 'Speichern fehlgeschlagen',
|
||||||
|
serverRemoved: '"{name}" entfernt',
|
||||||
|
enabled: "Aktiviert: {name}",
|
||||||
|
disabled: "Deaktiviert: {name}",
|
||||||
|
connectedStatus: 'Verbunden',
|
||||||
|
disconnectedStatus: 'Getrennt',
|
||||||
|
disabledStatus: 'Deaktiviert',
|
||||||
|
toolList: 'Werkzeugliste',
|
||||||
|
count: ' ',
|
||||||
|
more: 'mehr',
|
||||||
|
removeFailed: 'Server konnte nicht entfernt werden',
|
||||||
|
testOk: 'Test OK — {count} Werkzeuge verfuegbar',
|
||||||
|
testEmpty: 'Test lieferte keine Werkzeuge',
|
||||||
|
testFailed: 'Test fehlgeschlagen',
|
||||||
|
edit: 'Bearbeiten',
|
||||||
|
test: 'Testen',
|
||||||
|
reload: 'Neu laden',
|
||||||
|
remove: 'Entfernen',
|
||||||
|
confirmRemove: 'Server "{name}" entfernen?',
|
||||||
|
cancel: 'Abbrechen',
|
||||||
|
add: 'Hinzufuegen',
|
||||||
|
save: 'Speichern',
|
||||||
|
addTitle: 'MCP-Server hinzufuegen',
|
||||||
|
editTitle: 'MCP-Server bearbeiten',
|
||||||
|
invalidJson: 'Ungültiges JSON',
|
||||||
|
invalidYaml: 'Ungültiges YAML-Format',
|
||||||
|
invalidConfig: 'Ungültige Konfiguration',
|
||||||
|
invalidServerConfig: 'Ungültige Serverkonfiguration',
|
||||||
|
missingCommandOrUrl: 'Muss command oder url enthalten',
|
||||||
|
},
|
||||||
|
|
||||||
// Sidebar
|
// Sidebar
|
||||||
sidebar: {
|
sidebar: {
|
||||||
chat: 'Chat',
|
chat: 'Chat',
|
||||||
@@ -115,6 +169,7 @@ export default {
|
|||||||
models: 'Modelle',
|
models: 'Modelle',
|
||||||
profiles: 'Profile',
|
profiles: 'Profile',
|
||||||
plugins: 'Plugins',
|
plugins: 'Plugins',
|
||||||
|
mcp: 'MCP',
|
||||||
skills: 'Fahigkeiten',
|
skills: 'Fahigkeiten',
|
||||||
memory: 'Gedachtnis',
|
memory: 'Gedachtnis',
|
||||||
logs: 'Protokolle',
|
logs: 'Protokolle',
|
||||||
@@ -235,6 +290,7 @@ export default {
|
|||||||
compress: 'Kontextkomprimierung im Leerlauf ausführen',
|
compress: 'Kontextkomprimierung im Leerlauf ausführen',
|
||||||
steer: 'Steuertext an den aktiven Bridge-Lauf senden',
|
steer: 'Steuertext an den aktiven Bridge-Lauf senden',
|
||||||
destroy: 'Bridge-Agent für diese Sitzung freigeben',
|
destroy: 'Bridge-Agent für diese Sitzung freigeben',
|
||||||
|
reloadMcp: 'MCP-Server neu laden',
|
||||||
},
|
},
|
||||||
attachFiles: 'Dateien anhangen',
|
attachFiles: 'Dateien anhangen',
|
||||||
showToolCalls: 'Tool-Aufrufe anzeigen',
|
showToolCalls: 'Tool-Aufrufe anzeigen',
|
||||||
|
|||||||
@@ -105,6 +105,60 @@ export default {
|
|||||||
stop: 'Stop',
|
stop: 'Stop',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// MCP Management
|
||||||
|
mcp: {
|
||||||
|
title: 'MCP Servers',
|
||||||
|
loadFailed: 'Failed to load MCP servers',
|
||||||
|
reloadAll: 'Reload All',
|
||||||
|
refresh: 'Refresh',
|
||||||
|
total: 'Total',
|
||||||
|
connected: 'Connected',
|
||||||
|
disconnected: 'Disconnected',
|
||||||
|
tools: 'tools',
|
||||||
|
tool: 'Tools',
|
||||||
|
searchPlaceholder: 'Search servers...',
|
||||||
|
addServer: '+ Add Server',
|
||||||
|
zeroTools: '0 tools',
|
||||||
|
loading: 'Loading...',
|
||||||
|
empty: 'No MCP servers configured',
|
||||||
|
reloaded: 'Reloaded {server}',
|
||||||
|
reloadedAll: 'All MCP servers reloaded',
|
||||||
|
reloadFailed: 'Reload failed',
|
||||||
|
serverAdded: 'Server "{name}" added',
|
||||||
|
addFailed: 'Failed to add server',
|
||||||
|
serverUpdated: 'Server "{name}" updated',
|
||||||
|
updateFailed: 'Failed to update server',
|
||||||
|
saveFailed: 'Save failed',
|
||||||
|
serverRemoved: 'Removed "{name}"',
|
||||||
|
enabled: "Enabled {name}",
|
||||||
|
disabled: "Disabled {name}",
|
||||||
|
connectedStatus: 'Connected',
|
||||||
|
disconnectedStatus: 'Disconnected',
|
||||||
|
disabledStatus: 'Disabled',
|
||||||
|
toolList: 'Tool List',
|
||||||
|
count: ' ',
|
||||||
|
more: 'more',
|
||||||
|
removeFailed: 'Failed to remove server',
|
||||||
|
testOk: 'Test OK — {count} tools available',
|
||||||
|
testEmpty: 'Test returned no tools',
|
||||||
|
testFailed: 'Test failed',
|
||||||
|
edit: 'Edit',
|
||||||
|
test: 'Test',
|
||||||
|
reload: 'Reload',
|
||||||
|
remove: 'Remove',
|
||||||
|
confirmRemove: 'Remove server "{name}"?',
|
||||||
|
cancel: 'Cancel',
|
||||||
|
add: 'Add',
|
||||||
|
save: 'Save',
|
||||||
|
addTitle: 'Add MCP Server',
|
||||||
|
editTitle: 'Edit MCP Server',
|
||||||
|
invalidJson: 'Invalid JSON format',
|
||||||
|
invalidYaml: 'Invalid YAML format',
|
||||||
|
invalidConfig: 'Invalid configuration',
|
||||||
|
invalidServerConfig: 'Invalid server configuration',
|
||||||
|
missingCommandOrUrl: 'Must have command or url',
|
||||||
|
},
|
||||||
|
|
||||||
// Sidebar
|
// Sidebar
|
||||||
sidebar: {
|
sidebar: {
|
||||||
chat: 'Chat',
|
chat: 'Chat',
|
||||||
@@ -117,6 +171,7 @@ export default {
|
|||||||
profiles: 'Profiles',
|
profiles: 'Profiles',
|
||||||
skills: 'Skills',
|
skills: 'Skills',
|
||||||
plugins: 'Plugins',
|
plugins: 'Plugins',
|
||||||
|
mcp: 'MCP',
|
||||||
memory: 'Memory',
|
memory: 'Memory',
|
||||||
logs: 'Logs',
|
logs: 'Logs',
|
||||||
usage: 'Usage',
|
usage: 'Usage',
|
||||||
@@ -236,6 +291,7 @@ export default {
|
|||||||
compress: 'Run context compression while idle',
|
compress: 'Run context compression while idle',
|
||||||
steer: 'Send steering text to the active bridge run',
|
steer: 'Send steering text to the active bridge run',
|
||||||
destroy: 'Release the bridge agent for this session',
|
destroy: 'Release the bridge agent for this session',
|
||||||
|
reloadMcp: 'Reload MCP servers',
|
||||||
},
|
},
|
||||||
attachFiles: 'Attach files',
|
attachFiles: 'Attach files',
|
||||||
autoPlaySpeech: 'Auto-play voice',
|
autoPlaySpeech: 'Auto-play voice',
|
||||||
|
|||||||
@@ -105,6 +105,60 @@ export default {
|
|||||||
expired: 'Expirado',
|
expired: 'Expirado',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Gestion de MCP
|
||||||
|
mcp: {
|
||||||
|
title: 'Servidores MCP',
|
||||||
|
loadFailed: 'Error al cargar servidores MCP',
|
||||||
|
reloadAll: 'Recargar todos',
|
||||||
|
refresh: 'Actualizar',
|
||||||
|
total: 'Total',
|
||||||
|
connected: 'Conectado',
|
||||||
|
disconnected: 'Desconectado',
|
||||||
|
tools: 'herramientas',
|
||||||
|
tool: 'Herramientas',
|
||||||
|
searchPlaceholder: 'Buscar servidores...',
|
||||||
|
addServer: '+ Agregar servidor',
|
||||||
|
zeroTools: '0 herramientas',
|
||||||
|
loading: 'Cargando...',
|
||||||
|
empty: 'No hay servidores MCP configurados',
|
||||||
|
reloaded: '{server} recargado',
|
||||||
|
reloadedAll: 'Todos los servidores MCP recargados',
|
||||||
|
reloadFailed: 'Error al recargar',
|
||||||
|
serverAdded: 'Servidor "{name}" agregado',
|
||||||
|
addFailed: 'Error al agregar servidor',
|
||||||
|
serverUpdated: 'Servidor "{name}" actualizado',
|
||||||
|
updateFailed: 'Error al actualizar servidor',
|
||||||
|
saveFailed: 'Error al guardar',
|
||||||
|
serverRemoved: '"{name}" eliminado',
|
||||||
|
enabled: "Habilitado: {name}",
|
||||||
|
disabled: "Deshabilitado: {name}",
|
||||||
|
connectedStatus: 'Conectado',
|
||||||
|
disconnectedStatus: 'Desconectado',
|
||||||
|
disabledStatus: 'Deshabilitado',
|
||||||
|
toolList: 'Lista de herramientas',
|
||||||
|
count: ' ',
|
||||||
|
more: 'más',
|
||||||
|
removeFailed: 'Error al eliminar servidor',
|
||||||
|
testOk: 'Prueba OK — {count} herramientas disponibles',
|
||||||
|
testEmpty: 'La prueba no devolvio herramientas',
|
||||||
|
testFailed: 'Error en la prueba',
|
||||||
|
edit: 'Editar',
|
||||||
|
test: 'Probar',
|
||||||
|
reload: 'Recargar',
|
||||||
|
remove: 'Eliminar',
|
||||||
|
confirmRemove: '¿Eliminar servidor "{name}"?',
|
||||||
|
cancel: 'Cancelar',
|
||||||
|
add: 'Agregar',
|
||||||
|
save: 'Guardar',
|
||||||
|
addTitle: 'Agregar servidor MCP',
|
||||||
|
editTitle: 'Editar servidor MCP',
|
||||||
|
invalidJson: 'JSON inválido',
|
||||||
|
invalidYaml: 'Formato YAML no válido',
|
||||||
|
invalidConfig: 'Configuración no válida',
|
||||||
|
invalidServerConfig: 'Configuración del servidor no válida',
|
||||||
|
missingCommandOrUrl: 'Debe incluir command o url',
|
||||||
|
},
|
||||||
|
|
||||||
// Sidebar
|
// Sidebar
|
||||||
sidebar: {
|
sidebar: {
|
||||||
chat: 'Chat',
|
chat: 'Chat',
|
||||||
@@ -115,6 +169,7 @@ export default {
|
|||||||
models: 'Modelos',
|
models: 'Modelos',
|
||||||
profiles: 'Perfiles',
|
profiles: 'Perfiles',
|
||||||
plugins: 'Plugins',
|
plugins: 'Plugins',
|
||||||
|
mcp: 'MCP',
|
||||||
skills: 'Habilidades',
|
skills: 'Habilidades',
|
||||||
memory: 'Memoria',
|
memory: 'Memoria',
|
||||||
logs: 'Registros',
|
logs: 'Registros',
|
||||||
@@ -235,6 +290,7 @@ export default {
|
|||||||
compress: 'Ejecutar compresión de contexto cuando esté inactiva',
|
compress: 'Ejecutar compresión de contexto cuando esté inactiva',
|
||||||
steer: 'Enviar texto de guía a la ejecución activa de Bridge',
|
steer: 'Enviar texto de guía a la ejecución activa de Bridge',
|
||||||
destroy: 'Liberar el agente Bridge de esta sesión',
|
destroy: 'Liberar el agente Bridge de esta sesión',
|
||||||
|
reloadMcp: 'Recargar servidores MCP',
|
||||||
},
|
},
|
||||||
attachFiles: 'Adjuntar archivos',
|
attachFiles: 'Adjuntar archivos',
|
||||||
showToolCalls: 'Mostrar llamadas de herramientas',
|
showToolCalls: 'Mostrar llamadas de herramientas',
|
||||||
|
|||||||
@@ -105,6 +105,60 @@ export default {
|
|||||||
expired: 'Expiré',
|
expired: 'Expiré',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Gestion de MCP
|
||||||
|
mcp: {
|
||||||
|
title: 'Serveurs MCP',
|
||||||
|
loadFailed: 'Echec du chargement des serveurs MCP',
|
||||||
|
reloadAll: 'Tout recharger',
|
||||||
|
refresh: 'Rafraichir',
|
||||||
|
total: 'Total',
|
||||||
|
connected: 'Connecte',
|
||||||
|
disconnected: 'Deconnecte',
|
||||||
|
tools: 'outils',
|
||||||
|
tool: 'Outils',
|
||||||
|
searchPlaceholder: 'Rechercher des serveurs...',
|
||||||
|
addServer: '+ Ajouter un serveur',
|
||||||
|
zeroTools: '0 outils',
|
||||||
|
loading: 'Chargement...',
|
||||||
|
empty: 'Aucun serveur MCP configure',
|
||||||
|
reloaded: '{server} recharge',
|
||||||
|
reloadedAll: 'Tous les serveurs MCP recharges',
|
||||||
|
reloadFailed: 'Echec du rechargement',
|
||||||
|
serverAdded: 'Serveur "{name}" ajoute',
|
||||||
|
addFailed: 'Echec de l ajout du serveur',
|
||||||
|
serverUpdated: 'Serveur "{name}" mis a jour',
|
||||||
|
updateFailed: 'Echec de la mise a jour du serveur',
|
||||||
|
saveFailed: 'Echec de la sauvegarde',
|
||||||
|
serverRemoved: '"{name}" supprime',
|
||||||
|
enabled: "Activé : {name}",
|
||||||
|
disabled: "Désactivé : {name}",
|
||||||
|
connectedStatus: 'Connecté',
|
||||||
|
disconnectedStatus: 'Déconnecté',
|
||||||
|
disabledStatus: 'Désactivé',
|
||||||
|
toolList: 'Liste des outils',
|
||||||
|
count: ' ',
|
||||||
|
more: 'de plus',
|
||||||
|
removeFailed: 'Echec de la suppression du serveur',
|
||||||
|
testOk: 'Test OK — {count} outils disponibles',
|
||||||
|
testEmpty: 'Le test n a retourne aucun outil',
|
||||||
|
testFailed: 'Echec du test',
|
||||||
|
edit: 'Modifier',
|
||||||
|
test: 'Tester',
|
||||||
|
reload: 'Recharger',
|
||||||
|
remove: 'Supprimer',
|
||||||
|
confirmRemove: 'Supprimer le serveur "{name}" ?',
|
||||||
|
cancel: 'Annuler',
|
||||||
|
add: 'Ajouter',
|
||||||
|
save: 'Enregistrer',
|
||||||
|
addTitle: 'Ajouter un serveur MCP',
|
||||||
|
editTitle: 'Modifier le serveur MCP',
|
||||||
|
invalidJson: 'JSON invalide',
|
||||||
|
invalidYaml: 'Format YAML invalide',
|
||||||
|
invalidConfig: 'Configuration invalide',
|
||||||
|
invalidServerConfig: 'Configuration du serveur invalide',
|
||||||
|
missingCommandOrUrl: 'Doit contenir command ou url',
|
||||||
|
},
|
||||||
|
|
||||||
// Sidebar
|
// Sidebar
|
||||||
sidebar: {
|
sidebar: {
|
||||||
chat: 'Discussion',
|
chat: 'Discussion',
|
||||||
@@ -115,6 +169,7 @@ export default {
|
|||||||
models: 'Modeles',
|
models: 'Modeles',
|
||||||
profiles: 'Profils',
|
profiles: 'Profils',
|
||||||
plugins: 'Plugins',
|
plugins: 'Plugins',
|
||||||
|
mcp: 'MCP',
|
||||||
skills: 'Competences',
|
skills: 'Competences',
|
||||||
memory: 'Memoire',
|
memory: 'Memoire',
|
||||||
logs: 'Journaux',
|
logs: 'Journaux',
|
||||||
@@ -235,6 +290,7 @@ export default {
|
|||||||
compress: 'Lancer la compression du contexte au repos',
|
compress: 'Lancer la compression du contexte au repos',
|
||||||
steer: 'Envoyer un guidage à l’exécution Bridge active',
|
steer: 'Envoyer un guidage à l’exécution Bridge active',
|
||||||
destroy: 'Libérer l’agent Bridge de cette session',
|
destroy: 'Libérer l’agent Bridge de cette session',
|
||||||
|
reloadMcp: 'Recharger les serveurs MCP',
|
||||||
},
|
},
|
||||||
attachFiles: 'Joindre des fichiers',
|
attachFiles: 'Joindre des fichiers',
|
||||||
showToolCalls: 'Afficher les appels d’outils',
|
showToolCalls: 'Afficher les appels d’outils',
|
||||||
|
|||||||
@@ -106,6 +106,60 @@ export default {
|
|||||||
},
|
},
|
||||||
|
|
||||||
// サイドバー
|
// サイドバー
|
||||||
|
// MCP 管理
|
||||||
|
mcp: {
|
||||||
|
title: 'MCP サーバー',
|
||||||
|
loadFailed: 'MCP サーバーの読み込みに失敗しました',
|
||||||
|
reloadAll: 'すべて再読み込み',
|
||||||
|
refresh: '更新',
|
||||||
|
total: '合計',
|
||||||
|
connected: '接続済み',
|
||||||
|
disconnected: '未接続',
|
||||||
|
tools: 'ツール',
|
||||||
|
tool: 'ツール',
|
||||||
|
searchPlaceholder: 'サーバーを検索...',
|
||||||
|
addServer: '+ サーバーを追加',
|
||||||
|
zeroTools: '0 個のツール',
|
||||||
|
loading: '読み込み中...',
|
||||||
|
empty: 'MCP サーバーが設定されていません',
|
||||||
|
reloaded: '{server} を再読み込みしました',
|
||||||
|
reloadedAll: 'すべての MCP サーバーを再読み込みしました',
|
||||||
|
reloadFailed: '再読み込みに失敗しました',
|
||||||
|
serverAdded: 'サーバー "{name}" を追加しました',
|
||||||
|
addFailed: 'サーバーの追加に失敗しました',
|
||||||
|
serverUpdated: 'サーバー "{name}" を更新しました',
|
||||||
|
updateFailed: 'サーバーの更新に失敗しました',
|
||||||
|
saveFailed: '保存に失敗しました',
|
||||||
|
serverRemoved: '"{name}" を削除しました',
|
||||||
|
enabled: "有効化: {name}",
|
||||||
|
disabled: "無効化: {name}",
|
||||||
|
connectedStatus: '接続済み',
|
||||||
|
disconnectedStatus: '未接続',
|
||||||
|
disabledStatus: '無効',
|
||||||
|
toolList: 'ツール一覧',
|
||||||
|
count: ' ',
|
||||||
|
more: '件',
|
||||||
|
removeFailed: 'サーバーの削除に失敗しました',
|
||||||
|
testOk: 'テスト成功 — {count} 個のツールが利用可能',
|
||||||
|
testEmpty: 'テスト結果にツールがありません',
|
||||||
|
testFailed: 'テストに失敗しました',
|
||||||
|
edit: '編集',
|
||||||
|
test: 'テスト',
|
||||||
|
reload: '再読み込み',
|
||||||
|
remove: '削除',
|
||||||
|
confirmRemove: 'サーバー "{name}" を削除しますか?',
|
||||||
|
cancel: 'キャンセル',
|
||||||
|
add: '追加',
|
||||||
|
save: '保存',
|
||||||
|
addTitle: 'MCP サーバーを追加',
|
||||||
|
editTitle: 'MCP サーバーを編集',
|
||||||
|
invalidJson: 'JSON形式エラー',
|
||||||
|
invalidYaml: 'YAML 形式が無効です',
|
||||||
|
invalidConfig: '設定形式が無効です',
|
||||||
|
invalidServerConfig: 'サーバー設定が無効です',
|
||||||
|
missingCommandOrUrl: 'command または url が必要です',
|
||||||
|
},
|
||||||
|
|
||||||
sidebar: {
|
sidebar: {
|
||||||
chat: 'チャット',
|
chat: 'チャット',
|
||||||
search: '検索',
|
search: '検索',
|
||||||
@@ -115,6 +169,7 @@ export default {
|
|||||||
models: 'モデル',
|
models: 'モデル',
|
||||||
profiles: 'プロファイル',
|
profiles: 'プロファイル',
|
||||||
plugins: 'プラグイン',
|
plugins: 'プラグイン',
|
||||||
|
mcp: 'MCP',
|
||||||
skills: 'スキル',
|
skills: 'スキル',
|
||||||
memory: 'メモリ',
|
memory: 'メモリ',
|
||||||
logs: 'ログ',
|
logs: 'ログ',
|
||||||
@@ -235,6 +290,7 @@ export default {
|
|||||||
compress: 'アイドル時にコンテキスト圧縮を実行',
|
compress: 'アイドル時にコンテキスト圧縮を実行',
|
||||||
steer: '実行中の Bridge に誘導テキストを送信',
|
steer: '実行中の Bridge に誘導テキストを送信',
|
||||||
destroy: 'このセッションの Bridge Agent を解放',
|
destroy: 'このセッションの Bridge Agent を解放',
|
||||||
|
reloadMcp: 'MCP サーバーを再読み込み',
|
||||||
},
|
},
|
||||||
attachFiles: 'ファイルを添付',
|
attachFiles: 'ファイルを添付',
|
||||||
showToolCalls: 'ツール呼び出しを表示',
|
showToolCalls: 'ツール呼び出しを表示',
|
||||||
|
|||||||
@@ -106,6 +106,60 @@ export default {
|
|||||||
},
|
},
|
||||||
|
|
||||||
// 사이드바
|
// 사이드바
|
||||||
|
// MCP 관리
|
||||||
|
mcp: {
|
||||||
|
title: 'MCP 서버',
|
||||||
|
loadFailed: 'MCP 서버를 불러오지 못했습니다',
|
||||||
|
reloadAll: '모두 다시 로드',
|
||||||
|
refresh: '새로고침',
|
||||||
|
total: '합계',
|
||||||
|
connected: '연결됨',
|
||||||
|
disconnected: '연결 끊김',
|
||||||
|
tools: '도구',
|
||||||
|
tool: '도구',
|
||||||
|
searchPlaceholder: '서버 검색...',
|
||||||
|
addServer: '+ 서버 추가',
|
||||||
|
zeroTools: '0개 도구',
|
||||||
|
loading: '로딩...',
|
||||||
|
empty: 'MCP 서버가 설정되지 않았습니다',
|
||||||
|
reloaded: '{server} 다시 로드됨',
|
||||||
|
reloadedAll: '모든 MCP 서버가 다시 로드되었습니다',
|
||||||
|
reloadFailed: '다시 로드 실패',
|
||||||
|
serverAdded: '서버 "{name}" 추가됨',
|
||||||
|
addFailed: '서버 추가 실패',
|
||||||
|
serverUpdated: '서버 "{name}" 업데이트됨',
|
||||||
|
updateFailed: '서버 업데이트 실패',
|
||||||
|
saveFailed: '저장 실패',
|
||||||
|
serverRemoved: '"{name}" 제거됨',
|
||||||
|
enabled: "{name} 활성화됨",
|
||||||
|
disabled: "{name} 비활성화됨",
|
||||||
|
connectedStatus: '연결됨',
|
||||||
|
disconnectedStatus: '연결 끊김',
|
||||||
|
disabledStatus: '비활성화됨',
|
||||||
|
toolList: '도구 목록',
|
||||||
|
count: ' ',
|
||||||
|
more: '개 더보기',
|
||||||
|
removeFailed: '서버 제거 실패',
|
||||||
|
testOk: '테스트 성공 — {count}개 도구 사용 가능',
|
||||||
|
testEmpty: '테스트에서 도구가 반환되지 않았습니다',
|
||||||
|
testFailed: '테스트 실패',
|
||||||
|
edit: '편집',
|
||||||
|
test: '테스트',
|
||||||
|
reload: '다시 로드',
|
||||||
|
remove: '제거',
|
||||||
|
confirmRemove: '서버 "{name}"을(를) 제거하시겠습니까?',
|
||||||
|
cancel: '취소',
|
||||||
|
add: '추가',
|
||||||
|
save: '저장',
|
||||||
|
addTitle: 'MCP 서버 추가',
|
||||||
|
editTitle: 'MCP 서버 편집',
|
||||||
|
invalidJson: 'JSON 형식 오류',
|
||||||
|
invalidYaml: 'YAML 형식이 올바르지 않습니다',
|
||||||
|
invalidConfig: '올바르지 않은 설정',
|
||||||
|
invalidServerConfig: '서버 설정이 올바르지 않습니다',
|
||||||
|
missingCommandOrUrl: 'command 또는 url이 필요합니다',
|
||||||
|
},
|
||||||
|
|
||||||
sidebar: {
|
sidebar: {
|
||||||
chat: '채팅',
|
chat: '채팅',
|
||||||
search: '검색',
|
search: '검색',
|
||||||
@@ -115,6 +169,7 @@ export default {
|
|||||||
models: '모델',
|
models: '모델',
|
||||||
profiles: '프로필',
|
profiles: '프로필',
|
||||||
plugins: '플러그인',
|
plugins: '플러그인',
|
||||||
|
mcp: 'MCP',
|
||||||
skills: '스킬',
|
skills: '스킬',
|
||||||
memory: '메모리',
|
memory: '메모리',
|
||||||
logs: '로그',
|
logs: '로그',
|
||||||
@@ -235,6 +290,7 @@ export default {
|
|||||||
compress: '유휴 상태에서 컨텍스트 압축 실행',
|
compress: '유휴 상태에서 컨텍스트 압축 실행',
|
||||||
steer: '활성 Bridge 실행에 지시 텍스트 보내기',
|
steer: '활성 Bridge 실행에 지시 텍스트 보내기',
|
||||||
destroy: '이 세션의 Bridge Agent 해제',
|
destroy: '이 세션의 Bridge Agent 해제',
|
||||||
|
reloadMcp: 'MCP 서버 다시 로드',
|
||||||
},
|
},
|
||||||
attachFiles: '파일 첨부',
|
attachFiles: '파일 첨부',
|
||||||
showToolCalls: '도구 호출 표시',
|
showToolCalls: '도구 호출 표시',
|
||||||
|
|||||||
@@ -105,6 +105,60 @@ export default {
|
|||||||
expired: 'Expirado',
|
expired: 'Expirado',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Gestao de MCP
|
||||||
|
mcp: {
|
||||||
|
title: 'Servidores MCP',
|
||||||
|
loadFailed: 'Falha ao carregar servidores MCP',
|
||||||
|
reloadAll: 'Recarregar todos',
|
||||||
|
refresh: 'Atualizar',
|
||||||
|
total: 'Total',
|
||||||
|
connected: 'Conectado',
|
||||||
|
disconnected: 'Desconectado',
|
||||||
|
tools: 'ferramentas',
|
||||||
|
tool: 'Ferramentas',
|
||||||
|
searchPlaceholder: 'Pesquisar servidores...',
|
||||||
|
addServer: '+ Adicionar servidor',
|
||||||
|
zeroTools: '0 ferramentas',
|
||||||
|
loading: 'Carregando...',
|
||||||
|
empty: 'Nenhum servidor MCP configurado',
|
||||||
|
reloaded: '{server} recarregado',
|
||||||
|
reloadedAll: 'Todos os servidores MCP recarregados',
|
||||||
|
reloadFailed: 'Falha ao recarregar',
|
||||||
|
serverAdded: 'Servidor "{name}" adicionado',
|
||||||
|
addFailed: 'Falha ao adicionar servidor',
|
||||||
|
serverUpdated: 'Servidor "{name}" atualizado',
|
||||||
|
updateFailed: 'Falha ao atualizar servidor',
|
||||||
|
saveFailed: 'Falha ao salvar',
|
||||||
|
serverRemoved: '"{name}" removido',
|
||||||
|
enabled: "Habilitado: {name}",
|
||||||
|
disabled: "Desabilitado: {name}",
|
||||||
|
connectedStatus: 'Conectado',
|
||||||
|
disconnectedStatus: 'Desconectado',
|
||||||
|
disabledStatus: 'Desativado',
|
||||||
|
toolList: 'Lista de ferramentas',
|
||||||
|
count: ' ',
|
||||||
|
more: 'mais',
|
||||||
|
removeFailed: 'Falha ao remover servidor',
|
||||||
|
testOk: 'Teste OK — {count} ferramentas disponiveis',
|
||||||
|
testEmpty: 'O teste nao retornou ferramentas',
|
||||||
|
testFailed: 'Falha no teste',
|
||||||
|
edit: 'Editar',
|
||||||
|
test: 'Testar',
|
||||||
|
reload: 'Recarregar',
|
||||||
|
remove: 'Remover',
|
||||||
|
confirmRemove: 'Remover servidor "{name}"?',
|
||||||
|
cancel: 'Cancelar',
|
||||||
|
add: 'Adicionar',
|
||||||
|
save: 'Salvar',
|
||||||
|
addTitle: 'Adicionar servidor MCP',
|
||||||
|
editTitle: 'Editar servidor MCP',
|
||||||
|
invalidJson: 'JSON inválido',
|
||||||
|
invalidYaml: 'Formato YAML inválido',
|
||||||
|
invalidConfig: 'Configuração inválida',
|
||||||
|
invalidServerConfig: 'Configuração do servidor inválida',
|
||||||
|
missingCommandOrUrl: 'Deve conter command ou url',
|
||||||
|
},
|
||||||
|
|
||||||
// Sidebar
|
// Sidebar
|
||||||
sidebar: {
|
sidebar: {
|
||||||
chat: 'Chat',
|
chat: 'Chat',
|
||||||
@@ -115,6 +169,7 @@ export default {
|
|||||||
models: 'Modelos',
|
models: 'Modelos',
|
||||||
profiles: 'Perfis',
|
profiles: 'Perfis',
|
||||||
plugins: 'Plugins',
|
plugins: 'Plugins',
|
||||||
|
mcp: 'MCP',
|
||||||
skills: 'Habilidades',
|
skills: 'Habilidades',
|
||||||
memory: 'Memoria',
|
memory: 'Memoria',
|
||||||
logs: 'Logs',
|
logs: 'Logs',
|
||||||
@@ -235,6 +290,7 @@ export default {
|
|||||||
compress: 'Executar compressão de contexto quando ocioso',
|
compress: 'Executar compressão de contexto quando ocioso',
|
||||||
steer: 'Enviar texto de orientação para a execução ativa do Bridge',
|
steer: 'Enviar texto de orientação para a execução ativa do Bridge',
|
||||||
destroy: 'Liberar o Bridge Agent desta sessão',
|
destroy: 'Liberar o Bridge Agent desta sessão',
|
||||||
|
reloadMcp: 'Recarregar servidores MCP',
|
||||||
},
|
},
|
||||||
attachFiles: 'Anexar arquivos',
|
attachFiles: 'Anexar arquivos',
|
||||||
showToolCalls: 'Mostrar chamadas de ferramentas',
|
showToolCalls: 'Mostrar chamadas de ferramentas',
|
||||||
|
|||||||
@@ -106,6 +106,60 @@ export default {
|
|||||||
},
|
},
|
||||||
|
|
||||||
// 側邊欄
|
// 側邊欄
|
||||||
|
// MCP 管理
|
||||||
|
mcp: {
|
||||||
|
title: 'MCP 伺服器',
|
||||||
|
loadFailed: '載入 MCP 伺服器失敗',
|
||||||
|
reloadAll: '全部重載',
|
||||||
|
refresh: '重新整理',
|
||||||
|
total: '總計',
|
||||||
|
connected: '已連線',
|
||||||
|
disconnected: '未連線',
|
||||||
|
tools: '工具',
|
||||||
|
tool: '工具',
|
||||||
|
searchPlaceholder: '搜尋伺服器...',
|
||||||
|
addServer: '+ 新增伺服器',
|
||||||
|
zeroTools: '0 個工具',
|
||||||
|
loading: '載入中...',
|
||||||
|
empty: '暫無 MCP 伺服器設定',
|
||||||
|
reloaded: '已重載 {server}',
|
||||||
|
reloadedAll: '所有 MCP 伺服器已重載',
|
||||||
|
reloadFailed: '重載失敗',
|
||||||
|
serverAdded: '伺服器 "{name}" 已新增',
|
||||||
|
addFailed: '新增伺服器失敗',
|
||||||
|
serverUpdated: '伺服器 "{name}" 已更新',
|
||||||
|
updateFailed: '更新伺服器失敗',
|
||||||
|
saveFailed: '儲存失敗',
|
||||||
|
serverRemoved: '已移除 "{name}"',
|
||||||
|
enabled: "已啟用 {name}",
|
||||||
|
disabled: "已禁用 {name}",
|
||||||
|
connectedStatus: '已連線',
|
||||||
|
disconnectedStatus: '未連線',
|
||||||
|
disabledStatus: '已停用',
|
||||||
|
toolList: '工具列表',
|
||||||
|
count: '個',
|
||||||
|
more: '更多',
|
||||||
|
removeFailed: '移除伺服器失敗',
|
||||||
|
testOk: '測試成功 — {count} 個工具可用',
|
||||||
|
testEmpty: '測試未回傳工具',
|
||||||
|
testFailed: '測試失敗',
|
||||||
|
edit: '編輯',
|
||||||
|
test: '測試',
|
||||||
|
reload: '重載',
|
||||||
|
remove: '移除',
|
||||||
|
confirmRemove: '確認刪除伺服器 "{name}"?',
|
||||||
|
cancel: '取消',
|
||||||
|
add: '新增',
|
||||||
|
save: '儲存',
|
||||||
|
addTitle: '新增 MCP 伺服器',
|
||||||
|
editTitle: '編輯 MCP 伺服器',
|
||||||
|
invalidJson: 'JSON 格式錯誤',
|
||||||
|
invalidYaml: 'YAML 格式錯誤',
|
||||||
|
invalidConfig: '配置格式錯誤',
|
||||||
|
invalidServerConfig: '伺服器配置無效',
|
||||||
|
missingCommandOrUrl: '必須包含 command 或 url',
|
||||||
|
},
|
||||||
|
|
||||||
sidebar: {
|
sidebar: {
|
||||||
chat: '對話',
|
chat: '對話',
|
||||||
search: '搜尋',
|
search: '搜尋',
|
||||||
@@ -116,6 +170,7 @@ export default {
|
|||||||
models: '模型',
|
models: '模型',
|
||||||
profiles: '使用者',
|
profiles: '使用者',
|
||||||
plugins: '插件',
|
plugins: '插件',
|
||||||
|
mcp: 'MCP',
|
||||||
skills: '技能',
|
skills: '技能',
|
||||||
memory: '記憶',
|
memory: '記憶',
|
||||||
logs: '日誌',
|
logs: '日誌',
|
||||||
@@ -235,6 +290,7 @@ export default {
|
|||||||
compress: '空閒時觸發上下文壓縮',
|
compress: '空閒時觸發上下文壓縮',
|
||||||
steer: '向目前 Bridge 執行傳送引導文字',
|
steer: '向目前 Bridge 執行傳送引導文字',
|
||||||
destroy: '釋放目前會話的 Bridge Agent',
|
destroy: '釋放目前會話的 Bridge Agent',
|
||||||
|
reloadMcp: '重載 MCP 伺服器',
|
||||||
},
|
},
|
||||||
attachFiles: '新增附件',
|
attachFiles: '新增附件',
|
||||||
autoPlaySpeech: '自動播放語音',
|
autoPlaySpeech: '自動播放語音',
|
||||||
|
|||||||
@@ -106,6 +106,60 @@ export default {
|
|||||||
},
|
},
|
||||||
|
|
||||||
// 侧边栏
|
// 侧边栏
|
||||||
|
// MCP 管理
|
||||||
|
mcp: {
|
||||||
|
title: 'MCP 服务器',
|
||||||
|
loadFailed: '加载 MCP 服务器失败',
|
||||||
|
reloadAll: '全部重载',
|
||||||
|
refresh: '刷新',
|
||||||
|
total: '总计',
|
||||||
|
connected: '已连接',
|
||||||
|
disconnected: '未连接',
|
||||||
|
tools: '工具',
|
||||||
|
tool: '工具',
|
||||||
|
searchPlaceholder: '搜索服务器...',
|
||||||
|
addServer: '+ 添加服务器',
|
||||||
|
zeroTools: '0 个工具',
|
||||||
|
loading: '加载中...',
|
||||||
|
empty: '暂无 MCP 服务器配置',
|
||||||
|
reloaded: '已重载 {server}',
|
||||||
|
reloadedAll: '所有 MCP 服务器已重载',
|
||||||
|
reloadFailed: '重载失败',
|
||||||
|
serverAdded: '服务器 "{name}" 已添加',
|
||||||
|
addFailed: '添加服务器失败',
|
||||||
|
serverUpdated: '服务器 "{name}" 已更新',
|
||||||
|
updateFailed: '更新服务器失败',
|
||||||
|
saveFailed: '保存失败',
|
||||||
|
serverRemoved: '已移除 "{name}"',
|
||||||
|
enabled: "已启用 {name}",
|
||||||
|
disabled: "已禁用 {name}",
|
||||||
|
connectedStatus: '已连接',
|
||||||
|
disconnectedStatus: '未连接',
|
||||||
|
disabledStatus: '已禁用',
|
||||||
|
toolList: '工具列表',
|
||||||
|
count: '个',
|
||||||
|
more: '更多',
|
||||||
|
removeFailed: '移除服务器失败',
|
||||||
|
testOk: '测试成功 — {count} 个工具可用',
|
||||||
|
testEmpty: '测试未返回工具',
|
||||||
|
testFailed: '测试失败',
|
||||||
|
edit: '编辑',
|
||||||
|
test: '测试',
|
||||||
|
reload: '重载',
|
||||||
|
remove: '移除',
|
||||||
|
confirmRemove: '确认删除服务器 "{name}"?',
|
||||||
|
cancel: '取消',
|
||||||
|
add: '添加',
|
||||||
|
save: '保存',
|
||||||
|
addTitle: '添加 MCP 服务器',
|
||||||
|
editTitle: '编辑 MCP 服务器',
|
||||||
|
invalidJson: 'JSON 格式错误',
|
||||||
|
invalidYaml: 'YAML 格式错误',
|
||||||
|
invalidConfig: '配置格式错误',
|
||||||
|
invalidServerConfig: '服务器配置无效',
|
||||||
|
missingCommandOrUrl: '必须包含 command 或 url',
|
||||||
|
},
|
||||||
|
|
||||||
sidebar: {
|
sidebar: {
|
||||||
chat: '对话',
|
chat: '对话',
|
||||||
search: '搜索',
|
search: '搜索',
|
||||||
@@ -116,6 +170,7 @@ export default {
|
|||||||
models: '模型',
|
models: '模型',
|
||||||
profiles: '用户',
|
profiles: '用户',
|
||||||
plugins: '插件',
|
plugins: '插件',
|
||||||
|
mcp: 'MCP',
|
||||||
skills: '技能',
|
skills: '技能',
|
||||||
memory: '记忆',
|
memory: '记忆',
|
||||||
logs: '日志',
|
logs: '日志',
|
||||||
@@ -236,6 +291,7 @@ export default {
|
|||||||
compress: '空闲时触发上下文压缩',
|
compress: '空闲时触发上下文压缩',
|
||||||
steer: '向当前 Bridge 运行发送引导文本',
|
steer: '向当前 Bridge 运行发送引导文本',
|
||||||
destroy: '释放当前会话的 Bridge Agent',
|
destroy: '释放当前会话的 Bridge Agent',
|
||||||
|
reloadMcp: '重载 MCP 服务器',
|
||||||
},
|
},
|
||||||
attachFiles: '添加附件',
|
attachFiles: '添加附件',
|
||||||
autoPlaySpeech: '自动播放语音',
|
autoPlaySpeech: '自动播放语音',
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ const rawMessages: Record<string, LocaleMessages> = { en, zh, 'zh-TW': zhTW, ja,
|
|||||||
|
|
||||||
export const messages: Record<string, LocaleMessages> = {}
|
export const messages: Record<string, LocaleMessages> = {}
|
||||||
for (const [locale, msg] of Object.entries(rawMessages)) {
|
for (const [locale, msg] of Object.entries(rawMessages)) {
|
||||||
messages[locale] = locale === 'en' ? msg : mergeMessagesWithFallback(en, msg)
|
messages[locale] = locale === 'en' ? msg : mergeMessagesWithFallback({ ...en }, { ...msg })
|
||||||
}
|
}
|
||||||
|
|
||||||
export { en }
|
export { en }
|
||||||
|
|||||||
@@ -128,6 +128,12 @@ const router = createRouter({
|
|||||||
component: () => import('@/views/hermes/VersionPreviewView.vue'),
|
component: () => import('@/views/hermes/VersionPreviewView.vue'),
|
||||||
meta: { requiresSuperAdmin: true },
|
meta: { requiresSuperAdmin: true },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/hermes/mcp',
|
||||||
|
name: 'hermes.mcp',
|
||||||
|
component: () => import('@/views/hermes/McpManagerView.vue'),
|
||||||
|
meta: { requiresSuperAdmin: true },
|
||||||
|
},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,627 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onUnmounted, ref } from 'vue'
|
||||||
|
import yaml from 'js-yaml'
|
||||||
|
import {
|
||||||
|
NAlert, NButton, NEmpty, NInput, NModal,
|
||||||
|
NSpin, NRadioGroup, NRadioButton, useMessage,
|
||||||
|
} from 'naive-ui'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import McpServerCard from '@/components/hermes/mcp/McpServerCard.vue'
|
||||||
|
import {
|
||||||
|
fetchMcpServers, mcpServerAdd, mcpServerRemove,
|
||||||
|
mcpServerUpdate, mcpServerTest, mcpReload,
|
||||||
|
type McpServerInfo, type McpServerConfig,
|
||||||
|
} from '@/api/hermes/mcp'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const message = useMessage()
|
||||||
|
|
||||||
|
const servers = ref<McpServerInfo[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref('')
|
||||||
|
const searchQuery = ref('')
|
||||||
|
|
||||||
|
const showModal = ref(false)
|
||||||
|
const modalMode = ref<'add' | 'edit'>('add')
|
||||||
|
const editingName = ref('')
|
||||||
|
const jsonText = ref('')
|
||||||
|
const jsonError = ref('')
|
||||||
|
const saving = ref(false)
|
||||||
|
const inputMode = ref<'json' | 'yaml'>('json')
|
||||||
|
|
||||||
|
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"'
|
||||||
|
|
||||||
|
const placeholder = computed(() => inputMode.value === 'json' ? jsonPlaceholder : yamlPlaceholder)
|
||||||
|
|
||||||
|
let formatTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
let _pendingReload: ReturnType<typeof setTimeout> | null = null
|
||||||
|
let _autoRetryCount = 0
|
||||||
|
const MAX_AUTO_RETRIES = 5
|
||||||
|
const BASE_RETRY_DELAY = 2000 // 2s base
|
||||||
|
|
||||||
|
function scheduleReload(delay = 3000) {
|
||||||
|
if (_pendingReload) clearTimeout(_pendingReload)
|
||||||
|
_pendingReload = setTimeout(() => { _pendingReload = null; loadServers() }, delay)
|
||||||
|
}
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (formatTimer) { clearTimeout(formatTimer); formatTimer = null }
|
||||||
|
if (_pendingReload) { clearTimeout(_pendingReload); _pendingReload = null }
|
||||||
|
})
|
||||||
|
|
||||||
|
function handleInput(text: string) {
|
||||||
|
if (formatTimer) clearTimeout(formatTimer)
|
||||||
|
if (!text.trim()) {
|
||||||
|
jsonError.value = ''
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const { data, error: parseErr } = parseConfig(text)
|
||||||
|
if (parseErr) {
|
||||||
|
jsonError.value = parseErr
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const { servers: extracted, error: extractErr } = extractServers(data)
|
||||||
|
if (extractErr) {
|
||||||
|
jsonError.value = extractErr
|
||||||
|
return
|
||||||
|
}
|
||||||
|
jsonError.value = ''
|
||||||
|
formatTimer = setTimeout(() => {
|
||||||
|
const formatted = inputMode.value === 'json'
|
||||||
|
? JSON.stringify(extracted, null, 2)
|
||||||
|
: yaml.dump(extracted, { indent: 2, lineWidth: -1 }).trimEnd()
|
||||||
|
if (formatted !== text) jsonText.value = formatted
|
||||||
|
}, 1500)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleModeChange(mode: 'json' | 'yaml') {
|
||||||
|
if (!jsonText.value.trim()) return
|
||||||
|
// Try to parse current content in old format
|
||||||
|
const oldMode = mode === 'json' ? 'yaml' : 'json'
|
||||||
|
let data: Record<string, unknown> | null = null
|
||||||
|
try {
|
||||||
|
if (oldMode === 'json') {
|
||||||
|
data = JSON.parse(jsonText.value)
|
||||||
|
} else {
|
||||||
|
data = yaml.load(jsonText.value, { schema: yaml.JSON_SCHEMA }) as Record<string, unknown>
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// If parse fails, try the new format
|
||||||
|
try {
|
||||||
|
if (mode === 'json') {
|
||||||
|
data = JSON.parse(jsonText.value)
|
||||||
|
} else {
|
||||||
|
data = yaml.load(jsonText.value, { schema: yaml.JSON_SCHEMA }) as Record<string, unknown>
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!data || typeof data !== 'object') return
|
||||||
|
// Convert to new format
|
||||||
|
if (mode === 'json') {
|
||||||
|
jsonText.value = JSON.stringify(data, null, 2)
|
||||||
|
} else {
|
||||||
|
jsonText.value = yaml.dump(data, { indent: 2, lineWidth: -1 }).trimEnd()
|
||||||
|
}
|
||||||
|
jsonError.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseConfig(text: string): { data: Record<string, unknown> | null; error: string } {
|
||||||
|
if (inputMode.value === 'json') {
|
||||||
|
try {
|
||||||
|
const obj = JSON.parse(text)
|
||||||
|
if (typeof obj !== 'object' || obj === null || Array.isArray(obj)) {
|
||||||
|
return { data: null, error: t('mcp.invalidJson') }
|
||||||
|
}
|
||||||
|
return { data: obj, error: '' }
|
||||||
|
} catch {
|
||||||
|
return { data: null, error: t('mcp.invalidJson') }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const obj = yaml.load(text, { schema: yaml.JSON_SCHEMA })
|
||||||
|
if (typeof obj !== 'object' || obj === null || Array.isArray(obj)) {
|
||||||
|
return { data: null, error: t('mcp.invalidYaml') }
|
||||||
|
}
|
||||||
|
return { data: obj as Record<string, unknown>, error: '' }
|
||||||
|
} catch (e: any) {
|
||||||
|
return { data: null, error: `${t('mcp.invalidYaml')}: ${e.message || ''}` }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractServers(data: Record<string, unknown> | null): { servers: Record<string, unknown>; error: string } {
|
||||||
|
if (!data) return { servers: {}, error: t('mcp.invalidConfig') }
|
||||||
|
// Unwrap mcpServers/mcp_servers wrapper
|
||||||
|
if (data.mcpServers && typeof data.mcpServers === 'object' && !data.command) {
|
||||||
|
return { servers: data.mcpServers as Record<string, unknown>, error: '' }
|
||||||
|
}
|
||||||
|
if (data.mcp_servers && typeof data.mcp_servers === 'object' && !data.command) {
|
||||||
|
return { servers: data.mcp_servers as Record<string, unknown>, error: '' }
|
||||||
|
}
|
||||||
|
return { servers: data, error: '' }
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateServerConfig(name: string, config: unknown): string | null {
|
||||||
|
if (typeof config !== 'object' || config === null) {
|
||||||
|
return `${name}: ${t('mcp.invalidServerConfig')}`
|
||||||
|
}
|
||||||
|
const cfg = config as Record<string, unknown>
|
||||||
|
if (!cfg.command && !cfg.url) {
|
||||||
|
return `${name}: ${t('mcp.missingCommandOrUrl')}`
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseAndValidate(text: string): { servers: Record<string, unknown>; error: string } {
|
||||||
|
const { data, error: parseErr } = parseConfig(text)
|
||||||
|
if (parseErr) return { servers: {}, error: parseErr }
|
||||||
|
const { servers, error: extractErr } = extractServers(data)
|
||||||
|
if (extractErr) return { servers: {}, error: extractErr }
|
||||||
|
// Validate each server has command or url
|
||||||
|
for (const [name, config] of Object.entries(servers)) {
|
||||||
|
const err = validateServerConfig(name, config)
|
||||||
|
if (err) return { servers: {}, error: err }
|
||||||
|
}
|
||||||
|
return { servers, error: '' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const toolsByServer = ref<Record<string, {name: string, description: string}[]>>({})
|
||||||
|
|
||||||
|
const summary = computed(() => {
|
||||||
|
let connected = 0, totalTools = 0
|
||||||
|
for (const s of servers.value) {
|
||||||
|
if (s.connected) connected++
|
||||||
|
totalTools += s.tools_registered
|
||||||
|
}
|
||||||
|
return { total: servers.value.length, connected, disconnected: servers.value.length - connected, totalTools }
|
||||||
|
})
|
||||||
|
|
||||||
|
const filteredServers = computed(() => {
|
||||||
|
const query = searchQuery.value.trim().toLowerCase()
|
||||||
|
if (!query) return servers.value
|
||||||
|
return servers.value.filter(s =>
|
||||||
|
s.name.toLowerCase().includes(query) ||
|
||||||
|
s.transport.includes(query) ||
|
||||||
|
s.tool_names.some(n => n.toLowerCase().includes(query))
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
async function loadServers() {
|
||||||
|
loading.value = true
|
||||||
|
error.value = ''
|
||||||
|
try {
|
||||||
|
const data = await fetchMcpServers()
|
||||||
|
servers.value = data.servers ?? []
|
||||||
|
// Populate toolsByServer from embedded tool_details
|
||||||
|
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 || '',
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 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) {
|
||||||
|
const delay = BASE_RETRY_DELAY * Math.pow(2, _autoRetryCount) // 2s, 4s, 8s, 16s, 32s
|
||||||
|
_autoRetryCount++
|
||||||
|
scheduleReload(delay)
|
||||||
|
} else {
|
||||||
|
_autoRetryCount = 0
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
error.value = err?.message || t('mcp.loadFailed')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleReload(server?: string) {
|
||||||
|
try {
|
||||||
|
const res = await mcpReload(server)
|
||||||
|
if (res.ok) {
|
||||||
|
if (server) {
|
||||||
|
const { [server]: _, ...rest } = toolsByServer.value
|
||||||
|
toolsByServer.value = rest
|
||||||
|
} else {
|
||||||
|
toolsByServer.value = {}
|
||||||
|
}
|
||||||
|
message.success(server ? t('mcp.reloaded', { server }) : t('mcp.reloadedAll'))
|
||||||
|
scheduleReload()
|
||||||
|
} else {
|
||||||
|
message.error(res.error || t('mcp.reloadFailed'))
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
message.error(err.message || t('mcp.reloadFailed'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openAddModal() {
|
||||||
|
modalMode.value = 'add'
|
||||||
|
editingName.value = ''
|
||||||
|
jsonText.value = ''
|
||||||
|
jsonError.value = ''
|
||||||
|
inputMode.value = 'json'
|
||||||
|
showModal.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditModal(server: McpServerInfo) {
|
||||||
|
modalMode.value = 'edit'
|
||||||
|
editingName.value = server.name
|
||||||
|
const serverConfig = { [server.name]: server.raw_config }
|
||||||
|
jsonText.value = inputMode.value === 'yaml'
|
||||||
|
? yaml.dump(serverConfig, { indent: 2, lineWidth: -1 }).trimEnd()
|
||||||
|
: JSON.stringify(serverConfig, null, 2)
|
||||||
|
jsonError.value = ''
|
||||||
|
showModal.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveServer() {
|
||||||
|
if (formatTimer) { clearTimeout(formatTimer); formatTimer = null }
|
||||||
|
const { servers: parsed, error: validationErr } = parseAndValidate(jsonText.value)
|
||||||
|
if (validationErr) {
|
||||||
|
jsonError.value = validationErr
|
||||||
|
return
|
||||||
|
}
|
||||||
|
jsonError.value = ''
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
if (modalMode.value === 'add') {
|
||||||
|
// Expect: { "server-name": { "command": "...", ... } }
|
||||||
|
const entries = Object.entries(parsed)
|
||||||
|
if (entries.length === 0) {
|
||||||
|
jsonError.value = t('mcp.invalidConfig')
|
||||||
|
saving.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let added = 0
|
||||||
|
for (const [name, config] of entries) {
|
||||||
|
if (typeof config !== 'object' || config === null) continue
|
||||||
|
const res = await mcpServerAdd(name, config as McpServerConfig)
|
||||||
|
if (res.ok) added++
|
||||||
|
else message.error(`${name}: ${res.error || t('mcp.addFailed')}`)
|
||||||
|
}
|
||||||
|
if (added > 0) {
|
||||||
|
showModal.value = false
|
||||||
|
message.success(t('mcp.serverAdded', { name: `${added} server(s)` }))
|
||||||
|
// Immediately show server from config (disconnected)
|
||||||
|
await loadServers()
|
||||||
|
// Delayed refresh to show updated connection status after discovery
|
||||||
|
scheduleReload()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const name = editingName.value
|
||||||
|
// For edit, config can be flat or wrapped: { "name": { ... } }
|
||||||
|
const config = (parsed[name] && typeof parsed[name] === 'object')
|
||||||
|
? parsed[name] as Record<string, unknown>
|
||||||
|
: parsed
|
||||||
|
const res = await mcpServerUpdate(name, config)
|
||||||
|
if (res.ok) {
|
||||||
|
showModal.value = false
|
||||||
|
message.success(t('mcp.serverUpdated', { name: editingName.value }))
|
||||||
|
// Immediately show updated config
|
||||||
|
await loadServers()
|
||||||
|
// Delayed refresh to show reconnection status
|
||||||
|
scheduleReload()
|
||||||
|
} else {
|
||||||
|
message.error(res.error || t('mcp.updateFailed'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
message.error(err.message || t('mcp.saveFailed'))
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRemove(server: McpServerInfo) {
|
||||||
|
try {
|
||||||
|
const res = await mcpServerRemove(server.name)
|
||||||
|
if (res.ok) {
|
||||||
|
message.success(t('mcp.serverRemoved', { name: server.name }))
|
||||||
|
const { [server.name]: _, ...rest } = toolsByServer.value
|
||||||
|
toolsByServer.value = rest
|
||||||
|
await loadServers()
|
||||||
|
} else {
|
||||||
|
message.error(res.error || t('mcp.removeFailed'))
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
message.error(err.message || t('mcp.removeFailed'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleToggleEnabled(server: McpServerInfo) {
|
||||||
|
const newValue = !server.raw_config.enabled
|
||||||
|
try {
|
||||||
|
const config = { ...server.raw_config, enabled: newValue }
|
||||||
|
const res = await mcpServerUpdate(server.name, config)
|
||||||
|
if (res.ok) {
|
||||||
|
message.success(t(newValue ? 'mcp.enabled' : 'mcp.disabled', { name: server.name }))
|
||||||
|
const { [server.name]: _, ...rest } = toolsByServer.value
|
||||||
|
toolsByServer.value = rest
|
||||||
|
await mcpReload(server.name)
|
||||||
|
scheduleReload()
|
||||||
|
} else {
|
||||||
|
message.error(res.error || t('mcp.updateFailed'))
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
message.error(err.message || t('mcp.updateFailed'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleTest(server: McpServerInfo) {
|
||||||
|
try {
|
||||||
|
const res = await mcpServerTest(server.name)
|
||||||
|
if (res.ok && res.tools) {
|
||||||
|
message.success(t('mcp.testOk', { count: res.tools.length }), { duration: 3000 })
|
||||||
|
} else {
|
||||||
|
message.warning(res.error || t('mcp.testEmpty'))
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
message.error(err.message || t('mcp.testFailed'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
void loadServers()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="mcp-view">
|
||||||
|
<header class="page-header">
|
||||||
|
<h2 class="header-title">{{ t('mcp.title') }}</h2>
|
||||||
|
<div class="header-actions">
|
||||||
|
<NButton size="small" quaternary :loading="loading" @click="_autoRetryCount = 0; loadServers()">
|
||||||
|
{{ t('mcp.refresh') }}
|
||||||
|
</NButton>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="mcp-content">
|
||||||
|
<NAlert v-if="error" type="error" class="mcp-notice">
|
||||||
|
{{ error }}
|
||||||
|
</NAlert>
|
||||||
|
|
||||||
|
<div class="summary-grid">
|
||||||
|
<div class="summary-card">
|
||||||
|
<span class="summary-label">{{ t('mcp.total') }}</span>
|
||||||
|
<strong>{{ summary.total }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="summary-card success">
|
||||||
|
<span class="summary-label">{{ t('mcp.connected') }}</span>
|
||||||
|
<strong>{{ summary.connected }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="summary-card warning">
|
||||||
|
<span class="summary-label">{{ t('mcp.disconnected') }}</span>
|
||||||
|
<strong>{{ summary.disconnected }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="summary-card info">
|
||||||
|
<span class="summary-label">{{ t('mcp.tool') }}</span>
|
||||||
|
<strong>{{ summary.totalTools }}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="toolbar-row">
|
||||||
|
<NInput
|
||||||
|
v-model:value="searchQuery"
|
||||||
|
:placeholder="t('mcp.searchPlaceholder')"
|
||||||
|
clearable
|
||||||
|
size="small"
|
||||||
|
class="search-input"
|
||||||
|
/>
|
||||||
|
<div class="btn-group">
|
||||||
|
<NButton size="small" type="primary" @click="handleReload()">
|
||||||
|
{{ t('mcp.reloadAll') }}
|
||||||
|
</NButton>
|
||||||
|
<NButton type="primary" size="small" @click="openAddModal">
|
||||||
|
{{ t('mcp.addServer') }}
|
||||||
|
</NButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<NSpin :show="loading && servers.length === 0">
|
||||||
|
<div v-if="filteredServers.length" class="servers-grid">
|
||||||
|
<McpServerCard
|
||||||
|
v-for="server in filteredServers"
|
||||||
|
:key="server.name"
|
||||||
|
:server="server"
|
||||||
|
:tools-by-server="toolsByServer"
|
||||||
|
@edit="openEditModal"
|
||||||
|
@test="handleTest"
|
||||||
|
@reload="handleReload"
|
||||||
|
@remove="handleRemove"
|
||||||
|
@toggle-enabled="handleToggleEnabled"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<NEmpty v-else-if="!loading" :description="t('mcp.empty')" />
|
||||||
|
</NSpin>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<NModal v-model:show="showModal" :title="modalMode === 'add' ? t('mcp.addTitle') : t('mcp.editTitle')" preset="card" :style="{ width: 'min(520px, calc(100vw - 32px))' }">
|
||||||
|
<div class="mode-switch-row">
|
||||||
|
<NRadioGroup v-model:value="inputMode" size="small" @update:value="handleModeChange">
|
||||||
|
<NRadioButton value="json">JSON</NRadioButton>
|
||||||
|
<NRadioButton value="yaml">YAML</NRadioButton>
|
||||||
|
</NRadioGroup>
|
||||||
|
</div>
|
||||||
|
<NInput
|
||||||
|
v-model:value="jsonText"
|
||||||
|
type="textarea"
|
||||||
|
:rows="16"
|
||||||
|
class="config-textarea"
|
||||||
|
:placeholder="placeholder"
|
||||||
|
:status="jsonError ? 'error' : undefined"
|
||||||
|
@input="handleInput"
|
||||||
|
/>
|
||||||
|
<div v-if="jsonError" class="config-error">{{ jsonError }}</div>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<NButton @click="showModal = false">{{ t('mcp.cancel') }}</NButton>
|
||||||
|
<NButton type="primary" :loading="saving" @click="saveServer">
|
||||||
|
{{ modalMode === 'add' ? t('mcp.add') : t('mcp.save') }}
|
||||||
|
</NButton>
|
||||||
|
</div>
|
||||||
|
</NModal>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
@use '@/styles/variables' as *;
|
||||||
|
|
||||||
|
.mcp-view {
|
||||||
|
height: calc(100 * var(--vh));
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mcp-content {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
display: flex;
|
||||||
|
flex-shrink: 0;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 21px 20px;
|
||||||
|
border-bottom: 1px solid $border-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-title {
|
||||||
|
margin: 0;
|
||||||
|
color: $text-primary;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 80px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mcp-notice {
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-card {
|
||||||
|
padding: 14px;
|
||||||
|
border: 1px solid $border-color;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: $bg-secondary;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
|
||||||
|
strong {
|
||||||
|
font-size: 24px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.success strong { color: $success; }
|
||||||
|
&.warning strong { color: $warning; }
|
||||||
|
&.error strong { color: $error; }
|
||||||
|
&.info strong { color: $accent-primary; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-label {
|
||||||
|
font-size: 11px;
|
||||||
|
color: $text-muted;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
|
||||||
|
.search-input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 360px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-group {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-shrink: 1;
|
||||||
|
min-width: 0;
|
||||||
|
|
||||||
|
.n-button {
|
||||||
|
flex: 1;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.servers-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(min(100%, 420px), 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-switch-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-textarea {
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-error {
|
||||||
|
color: var(--n-error-color);
|
||||||
|
font-size: 12px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: $breakpoint-mobile) {
|
||||||
|
.summary-grid {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-row {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
|
||||||
|
.search-input {
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-group {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.servers-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import type { Context } from 'koa'
|
||||||
|
import { bridgeMcpAction } from '../../services/hermes/mcp'
|
||||||
|
|
||||||
|
function getProfile(ctx: Context): string | undefined {
|
||||||
|
return (ctx.state as any)?.profile?.name || undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Validate server name: non-empty, no control chars, no path separators */
|
||||||
|
function isValidServerName(name: string): boolean {
|
||||||
|
if (!name || name.trim().length === 0) return false
|
||||||
|
if (name.length > 128) return false
|
||||||
|
// Reject path separators and control characters
|
||||||
|
if (/[/\\\x00-\x1f]/.test(name)) return false
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listServers(ctx: Context) {
|
||||||
|
try {
|
||||||
|
ctx.body = await bridgeMcpAction('mcp_list', {}, getProfile(ctx))
|
||||||
|
} catch (err: any) {
|
||||||
|
ctx.status = 503
|
||||||
|
ctx.body = { error: err.message || 'MCP bridge not available' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addServer(ctx: Context) {
|
||||||
|
try {
|
||||||
|
const { name, config } = (ctx.request.body || {}) as Record<string, unknown>
|
||||||
|
if (typeof name !== 'string' || !isValidServerName(name)) {
|
||||||
|
ctx.status = 400
|
||||||
|
ctx.body = { error: 'Valid server name is required' }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!config || typeof config !== 'object') {
|
||||||
|
ctx.status = 400
|
||||||
|
ctx.body = { error: 'config object is required' }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.body = await bridgeMcpAction('mcp_server_add', { name: name.trim(), config }, getProfile(ctx))
|
||||||
|
} catch (err: any) {
|
||||||
|
ctx.status = 503
|
||||||
|
ctx.body = { error: err.message || 'Failed to add MCP server' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateServer(ctx: Context) {
|
||||||
|
try {
|
||||||
|
const name = ctx.params.name as string
|
||||||
|
const { config } = (ctx.request.body || {}) as Record<string, unknown>
|
||||||
|
if (!name || !isValidServerName(name)) {
|
||||||
|
ctx.status = 400
|
||||||
|
ctx.body = { error: 'Valid server name is required' }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!config || typeof config !== 'object') {
|
||||||
|
ctx.status = 400
|
||||||
|
ctx.body = { error: 'config object is required' }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.body = await bridgeMcpAction('mcp_server_update', { name, config }, getProfile(ctx))
|
||||||
|
} catch (err: any) {
|
||||||
|
ctx.status = 503
|
||||||
|
ctx.body = { error: err.message || 'Failed to update MCP server' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeServer(ctx: Context) {
|
||||||
|
try {
|
||||||
|
const name = ctx.params.name as string
|
||||||
|
if (!name || !isValidServerName(name)) {
|
||||||
|
ctx.status = 400
|
||||||
|
ctx.body = { error: 'Valid server name is required' }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.body = await bridgeMcpAction('mcp_server_remove', { name }, getProfile(ctx))
|
||||||
|
} catch (err: any) {
|
||||||
|
ctx.status = 503
|
||||||
|
ctx.body = { error: err.message || 'Failed to remove MCP server' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function testServer(ctx: Context) {
|
||||||
|
try {
|
||||||
|
const name = ctx.params.name as string
|
||||||
|
if (!name || !isValidServerName(name)) {
|
||||||
|
ctx.status = 400
|
||||||
|
ctx.body = { error: 'Valid server name is required' }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.body = await bridgeMcpAction('mcp_server_test', { name }, getProfile(ctx))
|
||||||
|
} catch (err: any) {
|
||||||
|
ctx.status = 503
|
||||||
|
ctx.body = { error: err.message || 'Failed to test MCP server' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listTools(ctx: Context) {
|
||||||
|
try {
|
||||||
|
const server = ctx.query.server as string | undefined
|
||||||
|
const payload = server ? { server } : {}
|
||||||
|
ctx.body = await bridgeMcpAction('mcp_tools_list', payload, getProfile(ctx))
|
||||||
|
} catch (err: any) {
|
||||||
|
ctx.status = 503
|
||||||
|
ctx.body = { error: err.message || 'MCP bridge not available' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function reloadMcp(ctx: Context) {
|
||||||
|
try {
|
||||||
|
const server = ctx.query.server as string | undefined
|
||||||
|
const payload = server ? { server } : {}
|
||||||
|
ctx.body = await bridgeMcpAction('mcp_reload', payload, getProfile(ctx))
|
||||||
|
} catch (err: any) {
|
||||||
|
ctx.status = 503
|
||||||
|
ctx.body = { error: err.message || 'Failed to reload MCP' }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import Router from '@koa/router'
|
||||||
|
import * as ctrl from '../../controllers/hermes/mcp'
|
||||||
|
|
||||||
|
export const mcpRoutes = new Router()
|
||||||
|
|
||||||
|
mcpRoutes.get('/api/hermes/mcp/servers', ctrl.listServers)
|
||||||
|
mcpRoutes.post('/api/hermes/mcp/servers', ctrl.addServer)
|
||||||
|
mcpRoutes.patch('/api/hermes/mcp/servers/:name', ctrl.updateServer)
|
||||||
|
mcpRoutes.delete('/api/hermes/mcp/servers/:name', ctrl.removeServer)
|
||||||
|
mcpRoutes.post('/api/hermes/mcp/servers/:name/test', ctrl.testServer)
|
||||||
|
mcpRoutes.get('/api/hermes/mcp/tools', ctrl.listTools)
|
||||||
|
mcpRoutes.post('/api/hermes/mcp/reload', ctrl.reloadMcp)
|
||||||
@@ -35,6 +35,7 @@ import { mediaRoutes } from './hermes/media'
|
|||||||
import { proxyRoutes, proxyMiddleware } from './hermes/proxy'
|
import { proxyRoutes, proxyMiddleware } from './hermes/proxy'
|
||||||
import { groupChatRoutes, setGroupChatServer } from './hermes/group-chat'
|
import { groupChatRoutes, setGroupChatServer } from './hermes/group-chat'
|
||||||
import { performanceMonitorRoutes } from './hermes/performance-monitor'
|
import { performanceMonitorRoutes } from './hermes/performance-monitor'
|
||||||
|
import { mcpRoutes } from './hermes/mcp'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Register all routes on the Koa app.
|
* Register all routes on the Koa app.
|
||||||
@@ -80,6 +81,7 @@ export function registerRoutes(app: any, authMiddleware: Array<(ctx: Context, ne
|
|||||||
app.use(kanbanRoutes.routes()) // Must be before proxy
|
app.use(kanbanRoutes.routes()) // Must be before proxy
|
||||||
app.use(mediaRoutes.routes()) // Must be before proxy
|
app.use(mediaRoutes.routes()) // Must be before proxy
|
||||||
app.use(performanceMonitorRoutes.routes()) // Must be before proxy
|
app.use(performanceMonitorRoutes.routes()) // Must be before proxy
|
||||||
|
app.use(mcpRoutes.routes()) // MCP management
|
||||||
app.use(proxyRoutes.routes())
|
app.use(proxyRoutes.routes())
|
||||||
|
|
||||||
// Proxy catch-all middleware (must be last)
|
// Proxy catch-all middleware (must be last)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { URL } from 'url'
|
|||||||
import { join } from 'path'
|
import { join } from 'path'
|
||||||
import { bridgeLogger } from '../../logger'
|
import { bridgeLogger } from '../../logger'
|
||||||
import { getActiveProfileName, getProfileDir } from '../hermes-profile'
|
import { getActiveProfileName, getProfileDir } from '../hermes-profile'
|
||||||
|
import type { McpActionResponse } from '../mcp-types'
|
||||||
|
|
||||||
function resolveDefaultAgentBridgeEndpoint(): string {
|
function resolveDefaultAgentBridgeEndpoint(): string {
|
||||||
if (process.env.VITEST) {
|
if (process.env.VITEST) {
|
||||||
@@ -585,6 +586,36 @@ export class AgentBridgeClient {
|
|||||||
shutdown(): Promise<AgentBridgeResponse> {
|
shutdown(): Promise<AgentBridgeResponse> {
|
||||||
return this.request({ action: 'shutdown' }, { serialize: true })
|
return this.request({ action: 'shutdown' }, { serialize: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ───── MCP Management ─────
|
||||||
|
|
||||||
|
mcpList(profile?: string): Promise<McpActionResponse> {
|
||||||
|
return this.request({ action: 'mcp_list', ...(profile ? { profile } : {}) })
|
||||||
|
}
|
||||||
|
|
||||||
|
mcpAdd(name: string, config: Record<string, unknown>, profile?: string): Promise<McpActionResponse> {
|
||||||
|
return this.request({ action: 'mcp_server_add', name, config, ...(profile ? { profile } : {}) }, { serialize: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
mcpUpdate(name: string, config: Record<string, unknown>, profile?: string): Promise<McpActionResponse> {
|
||||||
|
return this.request({ action: 'mcp_server_update', name, config, ...(profile ? { profile } : {}) }, { serialize: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
mcpRemove(name: string, profile?: string): Promise<McpActionResponse> {
|
||||||
|
return this.request({ action: 'mcp_server_remove', name, ...(profile ? { profile } : {}) }, { serialize: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
mcpTest(name: string, profile?: string): Promise<McpActionResponse> {
|
||||||
|
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 } : {}) })
|
||||||
|
}
|
||||||
|
|
||||||
|
mcpReload(server?: string, profile?: string): Promise<McpActionResponse> {
|
||||||
|
return this.request({ action: 'mcp_reload', ...(server ? { server } : {}), ...(profile ? { profile } : {}) }, { serialize: true })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default AgentBridgeClient
|
export default AgentBridgeClient
|
||||||
|
|||||||
@@ -2297,8 +2297,345 @@ class BridgeServer:
|
|||||||
self._stop.set()
|
self._stop.set()
|
||||||
return {"status": "shutting_down"}
|
return {"status": "shutting_down"}
|
||||||
|
|
||||||
|
# ───── MCP Management (forwarded from broker) ─────
|
||||||
|
if action.startswith("mcp_"):
|
||||||
|
return self._handle_mcp_action(action, req, req.get("profile"))
|
||||||
|
|
||||||
raise ValueError(f"unknown action: {action}")
|
raise ValueError(f"unknown action: {action}")
|
||||||
|
|
||||||
|
# ───── MCP Management Methods (for BridgeServer worker process) ─────
|
||||||
|
|
||||||
|
def _read_mcp_config(self, profile=None):
|
||||||
|
"""Read config.yaml for the given profile."""
|
||||||
|
import yaml
|
||||||
|
config_path = _profile_home(profile) / "config.yaml"
|
||||||
|
try:
|
||||||
|
with open(config_path, encoding="utf-8") as f:
|
||||||
|
return yaml.safe_load(f) or {}
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def _save_mcp_config(self, cfg, profile=None):
|
||||||
|
"""Save config.yaml for the given profile using atomic write."""
|
||||||
|
import yaml
|
||||||
|
from utils import atomic_yaml_write
|
||||||
|
config_path = _profile_home(profile) / "config.yaml"
|
||||||
|
config_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
try:
|
||||||
|
atomic_yaml_write(config_path, cfg, sort_keys=False)
|
||||||
|
except Exception as e:
|
||||||
|
raise RuntimeError(f"Failed to save config to {config_path}: {e}")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _run_mcp_discovery_bg(discover_fn, profile: str | None = None):
|
||||||
|
"""Run MCP discovery in a background thread to avoid blocking."""
|
||||||
|
def _bg():
|
||||||
|
original = _apply_profile_env(profile)
|
||||||
|
try:
|
||||||
|
discover_fn()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[mcp-discovery-bg] failed: {e}", file=sys.stderr, flush=True)
|
||||||
|
finally:
|
||||||
|
_restore_profile_env(original)
|
||||||
|
threading.Thread(target=_bg, daemon=True).start()
|
||||||
|
|
||||||
|
def _handle_mcp_action(self, action: str, req: dict[str, Any], profile: str | None = None) -> dict[str, Any]:
|
||||||
|
"""Handle MCP management actions in worker process."""
|
||||||
|
try:
|
||||||
|
from tools.mcp_tool import discover_mcp_tools, register_mcp_servers, _run_on_mcp_loop, _servers, _lock
|
||||||
|
except ImportError:
|
||||||
|
return {"error": "MCP tool module not available", "ok": False}
|
||||||
|
|
||||||
|
if profile is None:
|
||||||
|
profile = _worker_profile() or "default"
|
||||||
|
|
||||||
|
dispatch = {
|
||||||
|
"mcp_list": lambda: self._mcp_list(profile, _servers, _lock),
|
||||||
|
"mcp_server_add": lambda: self._mcp_server_add(req, profile, discover_mcp_tools),
|
||||||
|
"mcp_server_update": lambda: self._mcp_server_update(req, profile, _servers, _lock, _run_on_mcp_loop, discover_mcp_tools),
|
||||||
|
"mcp_server_remove": lambda: self._mcp_server_remove(req, profile, _servers, _lock, _run_on_mcp_loop),
|
||||||
|
"mcp_server_test": lambda: self._mcp_server_test(req, _servers, _lock),
|
||||||
|
"mcp_tools_list": lambda: self._mcp_tools_list(req, profile, _servers, _lock),
|
||||||
|
"mcp_reload": lambda: self._mcp_reload(req, profile, _servers, _lock, _run_on_mcp_loop, discover_mcp_tools, register_mcp_servers),
|
||||||
|
}
|
||||||
|
handler = dispatch.get(action)
|
||||||
|
if handler:
|
||||||
|
return handler()
|
||||||
|
return {"error": f"unknown MCP action: {action}", "ok": False}
|
||||||
|
|
||||||
|
# ───── MCP sub-handlers ─────
|
||||||
|
|
||||||
|
def _build_server_entry(self, name: str, cfg: dict, connected: bool = False,
|
||||||
|
tools_count: int = 0, registered_count: int = 0,
|
||||||
|
raw_names: list | None = None, registered_names: list | None = None,
|
||||||
|
tool_details: list | None = None,
|
||||||
|
error: str | None = None) -> dict[str, Any]:
|
||||||
|
"""Build a normalized server entry dict for API responses."""
|
||||||
|
transport = "http" if cfg.get("url") else "stdio"
|
||||||
|
return {
|
||||||
|
"name": name,
|
||||||
|
"transport": transport,
|
||||||
|
"connected": connected,
|
||||||
|
"tools": tools_count,
|
||||||
|
"tools_registered": registered_count,
|
||||||
|
"tool_names": raw_names or [],
|
||||||
|
"tool_names_registered": registered_names or [],
|
||||||
|
"tool_details": tool_details or [],
|
||||||
|
"error": error,
|
||||||
|
"raw_config": cfg if isinstance(cfg, dict) else {},
|
||||||
|
}
|
||||||
|
|
||||||
|
def _mcp_list(self, profile: str, _servers, _lock) -> dict[str, Any]:
|
||||||
|
servers = []
|
||||||
|
total_tools = 0
|
||||||
|
|
||||||
|
config = self._read_mcp_config(profile)
|
||||||
|
mcp_configs = config.get("mcp_servers", {}) or {} if config else {}
|
||||||
|
profile_server_names = set(mcp_configs.keys())
|
||||||
|
|
||||||
|
with _lock:
|
||||||
|
server_snapshot = list(_servers.items())
|
||||||
|
for name, task in server_snapshot:
|
||||||
|
if name not in profile_server_names:
|
||||||
|
continue
|
||||||
|
raw_tool_names = []
|
||||||
|
try:
|
||||||
|
for mcp_tool in getattr(task, "_tools", []):
|
||||||
|
if hasattr(mcp_tool, "name"):
|
||||||
|
raw_tool_names.append(mcp_tool.name)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
registered = list(getattr(task, "_registered_tool_names", None) or [])
|
||||||
|
if not registered:
|
||||||
|
registered = list(raw_tool_names)
|
||||||
|
t = getattr(task, "_task", None)
|
||||||
|
connected = bool(t and not t.done())
|
||||||
|
err = getattr(task, "_error", None)
|
||||||
|
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 {}
|
||||||
|
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:
|
||||||
|
continue
|
||||||
|
if exclude_set and tname in exclude_set:
|
||||||
|
continue
|
||||||
|
tool_details.append({
|
||||||
|
"name": tname,
|
||||||
|
"description": getattr(mcp_tool, "description", ""),
|
||||||
|
})
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
entry = self._build_server_entry(
|
||||||
|
name, cfg, connected=connected,
|
||||||
|
tools_count=len(raw_tool_names), registered_count=len(registered),
|
||||||
|
raw_names=raw_tool_names, registered_names=registered,
|
||||||
|
tool_details=tool_details,
|
||||||
|
error=str(err) if err else None,
|
||||||
|
)
|
||||||
|
servers.append(entry)
|
||||||
|
total_tools += len(registered)
|
||||||
|
|
||||||
|
# Add servers from config that are not in runtime _servers
|
||||||
|
if config:
|
||||||
|
existing = {s["name"] for s in servers}
|
||||||
|
for name, cfg in mcp_configs.items():
|
||||||
|
if name not in existing and isinstance(cfg, dict):
|
||||||
|
servers.append(self._build_server_entry(name, cfg))
|
||||||
|
|
||||||
|
return {"servers": servers, "total_tools": total_tools, "ok": True}
|
||||||
|
|
||||||
|
def _mcp_server_add(self, req: dict, profile: str, discover_mcp_tools) -> dict[str, Any]:
|
||||||
|
name = str(req.get("name") or "").strip()
|
||||||
|
config = req.get("config", {})
|
||||||
|
if not name or not isinstance(config, dict):
|
||||||
|
return {"error": "name and config are required", "ok": False}
|
||||||
|
|
||||||
|
cfg = self._read_mcp_config(profile)
|
||||||
|
if not cfg:
|
||||||
|
return {"error": "config.yaml not found", "ok": False}
|
||||||
|
|
||||||
|
mcp_servers = cfg.setdefault("mcp_servers", {})
|
||||||
|
if not isinstance(mcp_servers, dict):
|
||||||
|
mcp_servers = {}
|
||||||
|
cfg["mcp_servers"] = mcp_servers
|
||||||
|
if name in mcp_servers:
|
||||||
|
return {"error": f"server '{name}' already exists, use update instead", "ok": False}
|
||||||
|
mcp_servers[name] = config
|
||||||
|
|
||||||
|
self._save_mcp_config(cfg, profile)
|
||||||
|
self._run_mcp_discovery_bg(discover_mcp_tools, profile)
|
||||||
|
|
||||||
|
return {"ok": True, "name": name}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _shutdown_mcp_server(name: str, _servers, _lock, run_on_mcp_loop) -> bool:
|
||||||
|
with _lock:
|
||||||
|
task = _servers.get(name)
|
||||||
|
if task is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
run_on_mcp_loop(lambda: task.shutdown(), timeout=15)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[mcp-server-shutdown] failed for {name}: {e}", file=sys.stderr, flush=True)
|
||||||
|
finally:
|
||||||
|
with _lock:
|
||||||
|
if _servers.get(name) is task:
|
||||||
|
_servers.pop(name, None)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _shutdown_mcp_servers(self, names: list[str], _servers, _lock, run_on_mcp_loop) -> int:
|
||||||
|
stopped = 0
|
||||||
|
for name in names:
|
||||||
|
if self._shutdown_mcp_server(name, _servers, _lock, run_on_mcp_loop):
|
||||||
|
stopped += 1
|
||||||
|
return stopped
|
||||||
|
|
||||||
|
def _mcp_server_update(self, req: dict, profile: str, _servers, _lock, run_on_mcp_loop, discover_mcp_tools) -> dict[str, Any]:
|
||||||
|
name = str(req.get("name") or "").strip()
|
||||||
|
config = req.get("config", {})
|
||||||
|
if not name or not isinstance(config, dict):
|
||||||
|
return {"error": "name and config are required", "ok": False}
|
||||||
|
|
||||||
|
cfg = self._read_mcp_config(profile)
|
||||||
|
if not cfg:
|
||||||
|
return {"error": "config.yaml not found", "ok": False}
|
||||||
|
|
||||||
|
mcp_servers = cfg.setdefault("mcp_servers", {})
|
||||||
|
if not isinstance(mcp_servers, dict):
|
||||||
|
mcp_servers = {}
|
||||||
|
cfg["mcp_servers"] = mcp_servers
|
||||||
|
if name not in mcp_servers:
|
||||||
|
return {"error": f"server \'{name}\' not found in config", "ok": False}
|
||||||
|
|
||||||
|
mcp_servers[name] = config
|
||||||
|
|
||||||
|
self._save_mcp_config(cfg, profile)
|
||||||
|
|
||||||
|
self._shutdown_mcp_server(name, _servers, _lock, run_on_mcp_loop)
|
||||||
|
|
||||||
|
self._run_mcp_discovery_bg(discover_mcp_tools, profile)
|
||||||
|
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
def _mcp_server_remove(self, req: dict, profile: str, _servers, _lock, run_on_mcp_loop) -> dict[str, Any]:
|
||||||
|
name = str(req.get("name") or "").strip()
|
||||||
|
if not name:
|
||||||
|
return {"error": "name is required", "ok": False}
|
||||||
|
|
||||||
|
# Write config first, then remove from memory
|
||||||
|
cfg = self._read_mcp_config(profile)
|
||||||
|
if cfg:
|
||||||
|
mcp_servers = cfg.get("mcp_servers", {})
|
||||||
|
if isinstance(mcp_servers, dict) and name in mcp_servers:
|
||||||
|
del mcp_servers[name]
|
||||||
|
self._save_mcp_config(cfg, profile)
|
||||||
|
|
||||||
|
self._shutdown_mcp_server(name, _servers, _lock, run_on_mcp_loop)
|
||||||
|
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
def _mcp_server_test(self, req: dict, _servers, _lock) -> dict[str, Any]:
|
||||||
|
name = str(req.get("name") or "").strip()
|
||||||
|
if not name:
|
||||||
|
return {"error": "name is required", "ok": False}
|
||||||
|
|
||||||
|
with _lock:
|
||||||
|
task = _servers.get(name)
|
||||||
|
if not task:
|
||||||
|
return {"error": f"server \'{name}\' is not connected", "ok": False}
|
||||||
|
|
||||||
|
tool_names = []
|
||||||
|
try:
|
||||||
|
for mcp_tool in getattr(task, "_tools", []):
|
||||||
|
if hasattr(mcp_tool, "name"):
|
||||||
|
tool_names.append(mcp_tool.name)
|
||||||
|
except Exception as e:
|
||||||
|
return {"error": f"failed to list tools: {e}", "ok": False}
|
||||||
|
|
||||||
|
return {"ok": True, "tools": tool_names}
|
||||||
|
|
||||||
|
def _mcp_tools_list(self, req: dict, profile: str, _servers, _lock) -> dict[str, Any]:
|
||||||
|
server_filter = str(req.get("server") or "").strip() or None
|
||||||
|
results = []
|
||||||
|
|
||||||
|
config = self._read_mcp_config(profile)
|
||||||
|
mcp_configs = config.get("mcp_servers", {}) or {} if config else {}
|
||||||
|
profile_server_names = set(mcp_configs.keys())
|
||||||
|
|
||||||
|
with _lock:
|
||||||
|
server_snapshot = list(_servers.items())
|
||||||
|
for sname, task in server_snapshot:
|
||||||
|
if sname not in profile_server_names:
|
||||||
|
continue
|
||||||
|
if server_filter and sname != server_filter:
|
||||||
|
continue
|
||||||
|
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 {}
|
||||||
|
include_set = set(tools_filter.get("include") or [])
|
||||||
|
exclude_set = set(tools_filter.get("exclude") or [])
|
||||||
|
def _should_include(tn):
|
||||||
|
if include_set:
|
||||||
|
return tn in include_set
|
||||||
|
if exclude_set:
|
||||||
|
return tn not in exclude_set
|
||||||
|
return True
|
||||||
|
try:
|
||||||
|
for mcp_tool in getattr(task, "_tools", []):
|
||||||
|
tname = getattr(mcp_tool, "name", "?")
|
||||||
|
if not _should_include(tname):
|
||||||
|
continue
|
||||||
|
tools.append({
|
||||||
|
"name": tname,
|
||||||
|
"description": getattr(mcp_tool, "description", ""),
|
||||||
|
"input_schema": getattr(mcp_tool, "inputSchema", {}),
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
results.append({"server": sname, "tools": [], "error": str(e)})
|
||||||
|
continue
|
||||||
|
results.append({"server": sname, "tools": tools})
|
||||||
|
|
||||||
|
return {"ok": True, "results": results}
|
||||||
|
|
||||||
|
def _mcp_reload(self, req: dict, profile: str, _servers, _lock, run_on_mcp_loop,
|
||||||
|
discover_mcp_tools, register_mcp_servers) -> dict[str, Any]:
|
||||||
|
target = str(req.get("server") or "").strip() or None
|
||||||
|
|
||||||
|
config = self._read_mcp_config(profile)
|
||||||
|
mcp_configs = config.get("mcp_servers", {}) or {} if config else {}
|
||||||
|
profile_server_names = set(mcp_configs.keys())
|
||||||
|
|
||||||
|
if target and target not in mcp_configs:
|
||||||
|
return {"error": "server \'%s\' not found in config" % target, "ok": False}
|
||||||
|
|
||||||
|
if target:
|
||||||
|
self._shutdown_mcp_server(target, _servers, _lock, run_on_mcp_loop)
|
||||||
|
else:
|
||||||
|
self._shutdown_mcp_servers(list(profile_server_names), _servers, _lock, run_on_mcp_loop)
|
||||||
|
|
||||||
|
# Run discovery in background to avoid blocking the request
|
||||||
|
if target:
|
||||||
|
def _reload_single():
|
||||||
|
original = _apply_profile_env(profile)
|
||||||
|
try:
|
||||||
|
server_config = {target: mcp_configs.get(target, {})}
|
||||||
|
register_mcp_servers(server_config)
|
||||||
|
finally:
|
||||||
|
_restore_profile_env(original)
|
||||||
|
self._run_mcp_discovery_bg(_reload_single, profile)
|
||||||
|
else:
|
||||||
|
self._run_mcp_discovery_bg(discover_mcp_tools, profile)
|
||||||
|
|
||||||
|
return {"ok": True, "message": "MCP servers reloaded"}
|
||||||
|
|
||||||
def _make_server_socket(self) -> socket.socket:
|
def _make_server_socket(self) -> socket.socket:
|
||||||
return _make_listen_socket(self.endpoint)
|
return _make_listen_socket(self.endpoint)
|
||||||
|
|
||||||
@@ -2829,9 +3166,13 @@ class BridgeBroker:
|
|||||||
forwarded = dict(req)
|
forwarded = dict(req)
|
||||||
forwarded["profile"] = profile
|
forwarded["profile"] = profile
|
||||||
forwarded.pop("worker_key", None)
|
forwarded.pop("worker_key", None)
|
||||||
resp = worker.request(forwarded, self._worker_request_timeout(req))
|
try:
|
||||||
self._record_response_routes(profile, key, resp)
|
resp = worker.request(forwarded, self._worker_request_timeout(req))
|
||||||
return resp
|
self._record_response_routes(profile, key, resp)
|
||||||
|
return resp
|
||||||
|
except RuntimeError as e:
|
||||||
|
# Worker returned ok=false or connection error — return error response
|
||||||
|
return {"ok": False, "error": str(e)}
|
||||||
|
|
||||||
def _worker_request_timeout(self, req: dict[str, Any]) -> float:
|
def _worker_request_timeout(self, req: dict[str, Any]) -> float:
|
||||||
try:
|
try:
|
||||||
@@ -3037,6 +3378,11 @@ class BridgeBroker:
|
|||||||
self.stop()
|
self.stop()
|
||||||
return {"status": "shutting_down"}
|
return {"status": "shutting_down"}
|
||||||
|
|
||||||
|
# ───── MCP Management ─────
|
||||||
|
if action.startswith("mcp_"):
|
||||||
|
profile = self._normalize_profile(req.get("profile"))
|
||||||
|
return self._forward(profile, req)
|
||||||
|
|
||||||
raise ValueError(f"unknown action: {action}")
|
raise ValueError(f"unknown action: {action}")
|
||||||
|
|
||||||
def _make_server_socket(self) -> socket.socket:
|
def _make_server_socket(self) -> socket.socket:
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
/**
|
||||||
|
* Shared MCP types used by both the bridge client and the service layer.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface McpServerEntry {
|
||||||
|
name: string
|
||||||
|
transport: string
|
||||||
|
connected: boolean
|
||||||
|
tools: number
|
||||||
|
tools_registered: number
|
||||||
|
tool_names: string[]
|
||||||
|
tool_names_registered: string[]
|
||||||
|
error?: string | null
|
||||||
|
command?: string
|
||||||
|
args?: string[]
|
||||||
|
url?: string
|
||||||
|
env?: Record<string, string>
|
||||||
|
headers?: Record<string, string>
|
||||||
|
tools_config?: { include?: string[]; exclude?: string[] }
|
||||||
|
prompts?: boolean
|
||||||
|
resources?: boolean
|
||||||
|
enabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface McpToolEntry {
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
input_schema: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface McpActionResult {
|
||||||
|
ok: boolean
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface McpListResponse extends McpActionResult {
|
||||||
|
servers: McpServerEntry[]
|
||||||
|
total_tools: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface McpAddResponse extends McpActionResult {
|
||||||
|
name?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface McpTestResponse extends McpActionResult {
|
||||||
|
tools?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface McpToolsListResponse extends McpActionResult {
|
||||||
|
results?: Array<{ server: string; tools: McpToolEntry[] }>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface McpReloadResponse extends McpActionResult {
|
||||||
|
message?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Union of all MCP action responses.
|
||||||
|
* Bridge client methods return this; controllers narrow by action.
|
||||||
|
*/
|
||||||
|
export type McpActionResponse =
|
||||||
|
| McpListResponse
|
||||||
|
| McpAddResponse
|
||||||
|
| McpTestResponse
|
||||||
|
| McpToolsListResponse
|
||||||
|
| McpReloadResponse
|
||||||
|
| McpActionResult
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { AgentBridgeClient } from './agent-bridge/client'
|
||||||
|
import type { McpActionResponse } from './mcp-types'
|
||||||
|
|
||||||
|
export type { McpServerEntry, McpActionResponse } from './mcp-types'
|
||||||
|
|
||||||
|
let bridgeClient: AgentBridgeClient | null = null
|
||||||
|
|
||||||
|
export function getBridgeClient(): AgentBridgeClient {
|
||||||
|
if (!bridgeClient) {
|
||||||
|
bridgeClient = new AgentBridgeClient()
|
||||||
|
}
|
||||||
|
return bridgeClient
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send an MCP action to the AgentBridge using typed client methods.
|
||||||
|
*/
|
||||||
|
export async function bridgeMcpAction(
|
||||||
|
action: string,
|
||||||
|
payload: Record<string, unknown> = {},
|
||||||
|
profile?: string
|
||||||
|
): Promise<McpActionResponse> {
|
||||||
|
const client = getBridgeClient()
|
||||||
|
let raw: McpActionResponse
|
||||||
|
|
||||||
|
switch (action) {
|
||||||
|
case 'mcp_list':
|
||||||
|
raw = await client.mcpList(profile)
|
||||||
|
break
|
||||||
|
case 'mcp_server_add': {
|
||||||
|
const addName = String(payload.name || '')
|
||||||
|
const addConfig = payload.config as Record<string, unknown> | undefined
|
||||||
|
if (!addName || !addConfig) throw new Error('name and config are required')
|
||||||
|
raw = await client.mcpAdd(addName, addConfig, profile)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'mcp_server_update': {
|
||||||
|
const updName = String(payload.name || '')
|
||||||
|
const updConfig = payload.config as Record<string, unknown> | undefined
|
||||||
|
if (!updName || !updConfig) throw new Error('name and config are required')
|
||||||
|
raw = await client.mcpUpdate(updName, updConfig, profile)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'mcp_server_remove': {
|
||||||
|
const rmName = String(payload.name || '')
|
||||||
|
if (!rmName) throw new Error('name is required')
|
||||||
|
raw = await client.mcpRemove(rmName, profile)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'mcp_server_test': {
|
||||||
|
const testName = String(payload.name || '')
|
||||||
|
if (!testName) throw new Error('name is required')
|
||||||
|
raw = await client.mcpTest(testName, profile)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'mcp_tools_list':
|
||||||
|
raw = await client.mcpTools(payload.server as string | undefined, profile)
|
||||||
|
break
|
||||||
|
case 'mcp_reload':
|
||||||
|
raw = await client.mcpReload(payload.server as string | undefined, profile)
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
throw new Error(`Unknown MCP action: ${action}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return raw
|
||||||
|
}
|
||||||
@@ -22,6 +22,7 @@ type CommandName =
|
|||||||
| 'compress'
|
| 'compress'
|
||||||
| 'steer'
|
| 'steer'
|
||||||
| 'destroy'
|
| 'destroy'
|
||||||
|
| 'reload-mcp'
|
||||||
|
|
||||||
interface ParsedSessionCommand {
|
interface ParsedSessionCommand {
|
||||||
name: CommandName
|
name: CommandName
|
||||||
@@ -57,6 +58,7 @@ const COMMAND_ALIASES: Record<string, CommandName> = {
|
|||||||
steer: 'steer',
|
steer: 'steer',
|
||||||
destroy: 'destroy',
|
destroy: 'destroy',
|
||||||
destory: 'destroy',
|
destory: 'destroy',
|
||||||
|
'reload-mcp': 'reload-mcp',
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseSessionCommand(input: string | ContentBlock[]): ParsedSessionCommand | null {
|
export function parseSessionCommand(input: string | ContentBlock[]): ParsedSessionCommand | null {
|
||||||
@@ -475,6 +477,35 @@ export async function handleSessionCommand(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case 'reload-mcp': {
|
||||||
|
if (state.isWorking) {
|
||||||
|
emitCommand({
|
||||||
|
ok: false,
|
||||||
|
action: 'reload-mcp',
|
||||||
|
terminal: false,
|
||||||
|
message: 'MCP reload can only run while the session is idle. Wait for the current run to finish or abort it first.',
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const server = command.args || undefined
|
||||||
|
const result = await ctx.bridge.mcpReload(server, ctx.profile)
|
||||||
|
emitCommand({
|
||||||
|
action: 'reload-mcp',
|
||||||
|
message: `MCP reloaded successfully.${server ? ` Server: ${server}` : ' All servers.'}`,
|
||||||
|
result,
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
emitCommand({
|
||||||
|
ok: false,
|
||||||
|
action: 'reload-mcp',
|
||||||
|
terminal: !state.isWorking,
|
||||||
|
message: `MCP reload failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
case 'destroy': {
|
case 'destroy': {
|
||||||
const wasWorking = state.isWorking
|
const wasWorking = state.isWorking
|
||||||
let bridgeReachable = true
|
let bridgeReachable = true
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
# 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)
|
||||||
@@ -34,7 +34,7 @@ vi.mock('vue-router', async (importOriginal) => {
|
|||||||
return {
|
return {
|
||||||
...actual,
|
...actual,
|
||||||
useRoute: () => ({ name: 'hermes.chat' }),
|
useRoute: () => ({ name: 'hermes.chat' }),
|
||||||
useRouter: () => ({ push: vi.fn() }),
|
useRouter: () => ({ push: vi.fn(), hasRoute: () => true }),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -711,6 +711,51 @@ assert response["ok"] is True, response
|
|||||||
assert captured["endpoint"] == "ipc:///tmp/worker.sock", captured
|
assert captured["endpoint"] == "ipc:///tmp/worker.sock", captured
|
||||||
assert captured["req"] == {"action": "chat"}, captured
|
assert captured["req"] == {"action": "chat"}, captured
|
||||||
assert captured["timeout"] == 310, captured
|
assert captured["timeout"] == 310, captured
|
||||||
|
`)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('awaits MCP server shutdown without holding the MCP registry lock', () => {
|
||||||
|
runPython(String.raw`
|
||||||
|
${harness}
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
lock = threading.Lock()
|
||||||
|
servers = {}
|
||||||
|
events = []
|
||||||
|
|
||||||
|
class FakeMcpTask:
|
||||||
|
async def shutdown(self):
|
||||||
|
events.append("shutdown-started")
|
||||||
|
acquired = lock.acquire(blocking=False)
|
||||||
|
events.append(("lock-free-during-shutdown", acquired))
|
||||||
|
if acquired:
|
||||||
|
lock.release()
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
events.append("shutdown-finished")
|
||||||
|
|
||||||
|
task = FakeMcpTask()
|
||||||
|
servers["github"] = task
|
||||||
|
|
||||||
|
def run_on_mcp_loop(factory, timeout=30):
|
||||||
|
events.append(("timeout", timeout))
|
||||||
|
asyncio.run(factory())
|
||||||
|
|
||||||
|
result = bridge.BridgeServer._shutdown_mcp_server(
|
||||||
|
"github",
|
||||||
|
servers,
|
||||||
|
lock,
|
||||||
|
run_on_mcp_loop,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result is True, result
|
||||||
|
assert "github" not in servers, servers
|
||||||
|
assert events == [
|
||||||
|
("timeout", 15),
|
||||||
|
"shutdown-started",
|
||||||
|
("lock-free-during-shutdown", True),
|
||||||
|
"shutdown-finished",
|
||||||
|
], events
|
||||||
`)
|
`)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,286 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
// ── Mocks ──────────────────────────────────────────────────
|
||||||
|
const mcpListMock = vi.fn()
|
||||||
|
const mcpAddMock = vi.fn()
|
||||||
|
const mcpUpdateMock = vi.fn()
|
||||||
|
const mcpRemoveMock = vi.fn()
|
||||||
|
const mcpTestMock = vi.fn()
|
||||||
|
const mcpToolsMock = vi.fn()
|
||||||
|
const mcpReloadMock = vi.fn()
|
||||||
|
|
||||||
|
vi.mock('../../packages/server/src/services/hermes/agent-bridge/client', () => ({
|
||||||
|
AgentBridgeClient: vi.fn().mockImplementation(() => ({
|
||||||
|
mcpList: mcpListMock,
|
||||||
|
mcpAdd: mcpAddMock,
|
||||||
|
mcpUpdate: mcpUpdateMock,
|
||||||
|
mcpRemove: mcpRemoveMock,
|
||||||
|
mcpTest: mcpTestMock,
|
||||||
|
mcpTools: mcpToolsMock,
|
||||||
|
mcpReload: mcpReloadMock,
|
||||||
|
})),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../../packages/server/src/services/logger', () => ({
|
||||||
|
logger: { warn: vi.fn(), error: vi.fn(), info: vi.fn() },
|
||||||
|
}))
|
||||||
|
|
||||||
|
// ── Helpers ────────────────────────────────────────────────
|
||||||
|
function createCtx(overrides: Record<string, any> = {}) {
|
||||||
|
const ctx: any = {
|
||||||
|
state: { profile: { name: 'test-profile' } },
|
||||||
|
request: { body: {} },
|
||||||
|
params: {},
|
||||||
|
query: {},
|
||||||
|
status: 200,
|
||||||
|
body: null,
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
const SAMPLE_SERVERS_RESPONSE = {
|
||||||
|
ok: true,
|
||||||
|
servers: [
|
||||||
|
{
|
||||||
|
name: 'github',
|
||||||
|
transport: 'stdio',
|
||||||
|
connected: true,
|
||||||
|
tools: 26,
|
||||||
|
tools_registered: 3,
|
||||||
|
tool_names: ['create_repository', 'search_repositories'],
|
||||||
|
tool_names_registered: ['mcp_github_create_repository', 'mcp_github_search_repositories'],
|
||||||
|
error: null,
|
||||||
|
raw_config: {
|
||||||
|
command: 'npx',
|
||||||
|
args: ['-y', '@modelcontextprotocol/server-github'],
|
||||||
|
tools: { include: ['create_repository', 'search_repositories'] },
|
||||||
|
prompts: null,
|
||||||
|
resources: null,
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
tool_details: [
|
||||||
|
{ name: 'create_repository', description: 'Create a repo' },
|
||||||
|
{ name: 'search_repositories', description: 'Search repos' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
total_tools: 3,
|
||||||
|
}
|
||||||
|
|
||||||
|
const SAMPLE_TOOLS_RESPONSE = {
|
||||||
|
ok: true,
|
||||||
|
results: [
|
||||||
|
{
|
||||||
|
server: 'github',
|
||||||
|
tools: [
|
||||||
|
{ name: 'create_repository', description: 'Create a repo', input_schema: {} },
|
||||||
|
{ name: 'search_repositories', description: 'Search repos', input_schema: {} },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tests ──────────────────────────────────────────────────
|
||||||
|
describe('MCP Controller', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('listServers', () => {
|
||||||
|
it('returns servers list from bridge', async () => {
|
||||||
|
mcpListMock.mockResolvedValue(SAMPLE_SERVERS_RESPONSE)
|
||||||
|
const { listServers } = await import('../../packages/server/src/controllers/hermes/mcp')
|
||||||
|
const ctx = createCtx()
|
||||||
|
await listServers(ctx)
|
||||||
|
expect(ctx.body).toEqual(SAMPLE_SERVERS_RESPONSE)
|
||||||
|
expect(mcpListMock).toHaveBeenCalledWith('test-profile')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns 503 on bridge error', async () => {
|
||||||
|
mcpListMock.mockRejectedValue(new Error('bridge down'))
|
||||||
|
const { listServers } = await import('../../packages/server/src/controllers/hermes/mcp')
|
||||||
|
const ctx = createCtx()
|
||||||
|
await listServers(ctx)
|
||||||
|
expect(ctx.status).toBe(503)
|
||||||
|
expect(ctx.body).toEqual({ error: 'bridge down' })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('addServer', () => {
|
||||||
|
it('sends name and config to bridge', async () => {
|
||||||
|
mcpAddMock.mockResolvedValue({ ok: true, name: 'my-server' })
|
||||||
|
const { addServer } = await import('../../packages/server/src/controllers/hermes/mcp')
|
||||||
|
const ctx = createCtx({ request: { body: { name: 'my-server', config: { command: 'node', args: ['srv.js'] } } } })
|
||||||
|
await addServer(ctx)
|
||||||
|
expect(mcpAddMock).toHaveBeenCalledWith('my-server', { command: 'node', args: ['srv.js'] }, 'test-profile')
|
||||||
|
expect(ctx.body).toEqual({ ok: true, name: 'my-server' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns 400 when name is missing', async () => {
|
||||||
|
const { addServer } = await import('../../packages/server/src/controllers/hermes/mcp')
|
||||||
|
const ctx = createCtx({ request: { body: { config: { command: 'x' } } } })
|
||||||
|
await addServer(ctx)
|
||||||
|
expect(ctx.status).toBe(400)
|
||||||
|
expect(mcpAddMock).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns 400 when config is missing', async () => {
|
||||||
|
const { addServer } = await import('../../packages/server/src/controllers/hermes/mcp')
|
||||||
|
const ctx = createCtx({ request: { body: { name: 'x' } } })
|
||||||
|
await addServer(ctx)
|
||||||
|
expect(ctx.status).toBe(400)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('updateServer', () => {
|
||||||
|
it('sends name from params and config to bridge', async () => {
|
||||||
|
mcpUpdateMock.mockResolvedValue({ ok: true })
|
||||||
|
const { updateServer } = await import('../../packages/server/src/controllers/hermes/mcp')
|
||||||
|
const ctx = createCtx({
|
||||||
|
params: { name: 'github' },
|
||||||
|
request: { body: { config: { tools: { include: ['a', 'b'] } } } },
|
||||||
|
})
|
||||||
|
await updateServer(ctx)
|
||||||
|
expect(mcpUpdateMock).toHaveBeenCalledWith('github', { tools: { include: ['a', 'b'] } }, 'test-profile')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns 400 when config is missing', async () => {
|
||||||
|
const { updateServer } = await import('../../packages/server/src/controllers/hermes/mcp')
|
||||||
|
const ctx = createCtx({ params: { name: 'github' }, request: { body: {} } })
|
||||||
|
await updateServer(ctx)
|
||||||
|
expect(ctx.status).toBe(400)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('removeServer', () => {
|
||||||
|
it('sends name to bridge', async () => {
|
||||||
|
mcpRemoveMock.mockResolvedValue({ ok: true })
|
||||||
|
const { removeServer } = await import('../../packages/server/src/controllers/hermes/mcp')
|
||||||
|
const ctx = createCtx({ params: { name: 'github' } })
|
||||||
|
await removeServer(ctx)
|
||||||
|
expect(mcpRemoveMock).toHaveBeenCalledWith('github', 'test-profile')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('testServer', () => {
|
||||||
|
it('returns tool list from bridge', async () => {
|
||||||
|
mcpTestMock.mockResolvedValue({ ok: true, tools: ['create_repository', 'search_repositories'] })
|
||||||
|
const { testServer } = await import('../../packages/server/src/controllers/hermes/mcp')
|
||||||
|
const ctx = createCtx({ params: { name: 'github' } })
|
||||||
|
await testServer(ctx)
|
||||||
|
expect(mcpTestMock).toHaveBeenCalledWith('github', 'test-profile')
|
||||||
|
expect(ctx.body).toEqual({ ok: true, tools: ['create_repository', 'search_repositories'] })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('listTools', () => {
|
||||||
|
it('returns tools without server filter', async () => {
|
||||||
|
mcpToolsMock.mockResolvedValue(SAMPLE_TOOLS_RESPONSE)
|
||||||
|
const { listTools } = await import('../../packages/server/src/controllers/hermes/mcp')
|
||||||
|
const ctx = createCtx({ query: {} })
|
||||||
|
await listTools(ctx)
|
||||||
|
expect(mcpToolsMock).toHaveBeenCalledWith(undefined, 'test-profile')
|
||||||
|
expect(ctx.body).toEqual(SAMPLE_TOOLS_RESPONSE)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('passes server filter to bridge', async () => {
|
||||||
|
mcpToolsMock.mockResolvedValue(SAMPLE_TOOLS_RESPONSE)
|
||||||
|
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')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns 503 on bridge error', async () => {
|
||||||
|
mcpToolsMock.mockRejectedValue(new Error('timeout'))
|
||||||
|
const { listTools } = await import('../../packages/server/src/controllers/hermes/mcp')
|
||||||
|
const ctx = createCtx()
|
||||||
|
await listTools(ctx)
|
||||||
|
expect(ctx.status).toBe(503)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('reloadMcp', () => {
|
||||||
|
it('reloads all servers when no filter', async () => {
|
||||||
|
mcpReloadMock.mockResolvedValue({ ok: true, message: 'MCP servers reloaded' })
|
||||||
|
const { reloadMcp } = await import('../../packages/server/src/controllers/hermes/mcp')
|
||||||
|
const ctx = createCtx({ query: {} })
|
||||||
|
await reloadMcp(ctx)
|
||||||
|
expect(mcpReloadMock).toHaveBeenCalledWith(undefined, 'test-profile')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reloads specific server', async () => {
|
||||||
|
mcpReloadMock.mockResolvedValue({ ok: true, message: 'MCP servers reloaded' })
|
||||||
|
const { reloadMcp } = await import('../../packages/server/src/controllers/hermes/mcp')
|
||||||
|
const ctx = createCtx({ query: { server: 'github' } })
|
||||||
|
await reloadMcp(ctx)
|
||||||
|
expect(mcpReloadMock).toHaveBeenCalledWith('github', 'test-profile')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns 503 on bridge error', async () => {
|
||||||
|
mcpReloadMock.mockRejectedValue(new Error('reload failed'))
|
||||||
|
const { reloadMcp } = await import('../../packages/server/src/controllers/hermes/mcp')
|
||||||
|
const ctx = createCtx()
|
||||||
|
await reloadMcp(ctx)
|
||||||
|
expect(ctx.status).toBe(503)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('profile handling', () => {
|
||||||
|
it('passes undefined profile when ctx.state.profile is missing', async () => {
|
||||||
|
mcpListMock.mockResolvedValue({ ok: true, servers: [], total_tools: 0 })
|
||||||
|
const { listServers } = await import('../../packages/server/src/controllers/hermes/mcp')
|
||||||
|
const ctx = createCtx({ state: {} })
|
||||||
|
await listServers(ctx)
|
||||||
|
expect(mcpListMock).toHaveBeenCalledWith(undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('passes undefined profile when profile.name is empty', async () => {
|
||||||
|
mcpListMock.mockResolvedValue({ ok: true, servers: [], total_tools: 0 })
|
||||||
|
const { listServers } = await import('../../packages/server/src/controllers/hermes/mcp')
|
||||||
|
const ctx = createCtx({ state: { profile: { name: '' } } })
|
||||||
|
await listServers(ctx)
|
||||||
|
expect(mcpListMock).toHaveBeenCalledWith(undefined)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('response structure', () => {
|
||||||
|
it('mcp_list response has all required fields', async () => {
|
||||||
|
mcpListMock.mockResolvedValue(SAMPLE_SERVERS_RESPONSE)
|
||||||
|
const { listServers } = await import('../../packages/server/src/controllers/hermes/mcp')
|
||||||
|
const ctx = createCtx()
|
||||||
|
await listServers(ctx)
|
||||||
|
const body = ctx.body as any
|
||||||
|
expect(body.ok).toBe(true)
|
||||||
|
expect(body.servers).toBeDefined()
|
||||||
|
expect(body.total_tools).toBeDefined()
|
||||||
|
const server = body.servers[0]
|
||||||
|
expect(server).toHaveProperty('name')
|
||||||
|
expect(server).toHaveProperty('transport')
|
||||||
|
expect(server).toHaveProperty('connected')
|
||||||
|
expect(server).toHaveProperty('tools')
|
||||||
|
expect(server).toHaveProperty('tools_registered')
|
||||||
|
expect(server).toHaveProperty('tool_names')
|
||||||
|
expect(server).toHaveProperty('tool_names_registered')
|
||||||
|
expect(server).toHaveProperty('raw_config')
|
||||||
|
expect(server).toHaveProperty('tool_details')
|
||||||
|
expect(server.raw_config).toHaveProperty('command')
|
||||||
|
expect(server.raw_config).toHaveProperty('enabled')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('mcp_tools_list response has tools with name/description/schema', async () => {
|
||||||
|
mcpToolsMock.mockResolvedValue(SAMPLE_TOOLS_RESPONSE)
|
||||||
|
const { listTools } = await import('../../packages/server/src/controllers/hermes/mcp')
|
||||||
|
const ctx = createCtx()
|
||||||
|
await listTools(ctx)
|
||||||
|
const body = ctx.body as any
|
||||||
|
expect(body.ok).toBe(true)
|
||||||
|
expect(body.results).toHaveLength(1)
|
||||||
|
const tool = body.results[0].tools[0]
|
||||||
|
expect(tool).toHaveProperty('name')
|
||||||
|
expect(tool).toHaveProperty('description')
|
||||||
|
expect(tool).toHaveProperty('input_schema')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -59,6 +59,7 @@ function makeContext(state: any, commandResult: Record<string, unknown> = {
|
|||||||
const runQueuedItem = vi.fn()
|
const runQueuedItem = vi.fn()
|
||||||
const bridge = {
|
const bridge = {
|
||||||
command: vi.fn(async () => commandResult),
|
command: vi.fn(async () => commandResult),
|
||||||
|
mcpReload: vi.fn(async () => ({ ok: true, message: 'MCP servers reloaded' })),
|
||||||
status: vi.fn(async () => ({
|
status: vi.fn(async () => ({
|
||||||
exists: true,
|
exists: true,
|
||||||
running: false,
|
running: false,
|
||||||
@@ -303,4 +304,30 @@ describe('plan session command', () => {
|
|||||||
}),
|
}),
|
||||||
}))
|
}))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('rejects MCP reload while the session is running', async () => {
|
||||||
|
const state = { messages: [], isWorking: true, events: [], queue: [] }
|
||||||
|
const { bridge, namespaceEmit, runQueuedItem, sessionMap, socket, nsp } = makeContext(state)
|
||||||
|
const { handleSessionCommand, parseSessionCommand } = await import('../../packages/server/src/services/hermes/run-chat/session-command')
|
||||||
|
const command = parseSessionCommand('/reload-mcp github')!
|
||||||
|
|
||||||
|
await handleSessionCommand('session-1', command, {
|
||||||
|
nsp: nsp as any,
|
||||||
|
socket: socket as any,
|
||||||
|
sessionMap,
|
||||||
|
bridge: bridge as any,
|
||||||
|
profile: 'default',
|
||||||
|
runQueuedItem,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(bridge.mcpReload).not.toHaveBeenCalled()
|
||||||
|
expect(runQueuedItem).not.toHaveBeenCalled()
|
||||||
|
expect(namespaceEmit).toHaveBeenCalledWith('session.command', expect.objectContaining({
|
||||||
|
command: 'reload-mcp',
|
||||||
|
ok: false,
|
||||||
|
action: 'reload-mcp',
|
||||||
|
terminal: false,
|
||||||
|
message: 'MCP reload can only run while the session is idle. Wait for the current run to finish or abort it first.',
|
||||||
|
}))
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||