[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>
This commit is contained in:
@@ -44,6 +44,7 @@ const bridgeCommands = computed(() => [
|
||||
{ name: 'compress', args: '', description: t('chat.slashCommands.compress') },
|
||||
{ name: 'steer', args: t('chat.slashCommandArgs.text'), description: t('chat.slashCommands.steer') },
|
||||
{ name: 'destroy', args: '', description: t('chat.slashCommands.destroy') },
|
||||
{ name: 'reload-mcp', args: '', description: t('chat.slashCommands.reloadMcp') },
|
||||
])
|
||||
|
||||
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[]) {
|
||||
return names.includes(selectedKey.value);
|
||||
}
|
||||
function hasRoute(name: string): boolean {
|
||||
return router.hasRoute(name);
|
||||
}
|
||||
const logoPath = '/logo.png';
|
||||
|
||||
const { record: collapsedGroups, persist: persistCollapsedGroups } = usePersistentRecord('hermes.sidebar.collapsedGroups');
|
||||
@@ -186,6 +189,15 @@ function openChangelog() {
|
||||
</svg>
|
||||
<span>{{ t("sidebar.plugins") }}</span>
|
||||
</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'">
|
||||
<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" />
|
||||
@@ -263,7 +275,7 @@ function openChangelog() {
|
||||
</svg>
|
||||
</div>
|
||||
<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">
|
||||
<polyline points="16 18 22 12 16 6" />
|
||||
<polyline points="8 6 2 12 8 18" />
|
||||
@@ -271,7 +283,7 @@ function openChangelog() {
|
||||
</svg>
|
||||
<span>{{ t("sidebar.codingAgents") }}</span>
|
||||
</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">
|
||||
<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" />
|
||||
|
||||
Reference in New Issue
Block a user