feat: add MCP server visibility panel
This commit is contained in:
@@ -7167,33 +7167,111 @@ def _mask_secrets(obj):
|
||||
return masked
|
||||
|
||||
|
||||
def _server_summary(name, cfg):
|
||||
def _parse_mcp_enabled(value) -> bool:
|
||||
"""Parse Hermes MCP ``enabled`` values without raising on bad config."""
|
||||
if value is None:
|
||||
return True
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if isinstance(value, (int, float)):
|
||||
return value != 0
|
||||
if isinstance(value, str):
|
||||
normalized = value.strip().lower()
|
||||
if normalized in {"true", "1", "yes", "on"}:
|
||||
return True
|
||||
if normalized in {"false", "0", "no", "off"}:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _mcp_runtime_status_by_name() -> dict[str, dict]:
|
||||
"""Return already-known MCP runtime status without starting servers.
|
||||
|
||||
``tools.mcp_tool.get_mcp_status()`` only reads the existing MCP registry and
|
||||
configuration; it does not probe or spawn MCP subprocesses. If Hermes Agent
|
||||
is unavailable, fall back to an empty map so the API remains safe.
|
||||
"""
|
||||
try:
|
||||
from tools.mcp_tool import get_mcp_status
|
||||
statuses = get_mcp_status()
|
||||
except Exception:
|
||||
return {}
|
||||
if not isinstance(statuses, list):
|
||||
return {}
|
||||
return {
|
||||
str(entry.get("name")): entry
|
||||
for entry in statuses
|
||||
if isinstance(entry, dict) and entry.get("name")
|
||||
}
|
||||
|
||||
|
||||
def _server_summary(name, cfg, runtime_status=None):
|
||||
"""Return a safe summary of an MCP server config."""
|
||||
runtime_status = runtime_status if isinstance(runtime_status, dict) else {}
|
||||
out = {"name": name}
|
||||
if not isinstance(cfg, dict):
|
||||
out.update({
|
||||
"transport": "invalid",
|
||||
"timeout": 120,
|
||||
"connect_timeout": 60,
|
||||
"enabled": False,
|
||||
"active": False,
|
||||
"status": "invalid_config",
|
||||
"tool_count": None,
|
||||
})
|
||||
return out
|
||||
|
||||
enabled = _parse_mcp_enabled(cfg.get("enabled", True))
|
||||
connected = bool(runtime_status.get("connected")) if enabled else False
|
||||
if "url" in cfg:
|
||||
out["transport"] = "http"
|
||||
# Mask auth headers
|
||||
if "headers" in cfg:
|
||||
out["headers"] = _mask_secrets(cfg["headers"])
|
||||
out["url"] = cfg["url"]
|
||||
else:
|
||||
elif "command" in cfg:
|
||||
out["transport"] = "stdio"
|
||||
out["command"] = cfg.get("command", "")
|
||||
out["args"] = cfg.get("args", [])
|
||||
if "env" in cfg:
|
||||
out["env"] = _mask_secrets(cfg["env"])
|
||||
else:
|
||||
out["transport"] = "invalid"
|
||||
enabled = False
|
||||
connected = False
|
||||
|
||||
out["timeout"] = cfg.get("timeout", 120)
|
||||
out["connect_timeout"] = cfg.get("connect_timeout", 60)
|
||||
out["enabled"] = enabled
|
||||
out["active"] = connected
|
||||
if out["transport"] == "invalid":
|
||||
out["status"] = "invalid_config"
|
||||
elif not enabled:
|
||||
out["status"] = "disabled"
|
||||
elif connected:
|
||||
out["status"] = "active"
|
||||
else:
|
||||
out["status"] = "configured"
|
||||
out["tool_count"] = runtime_status.get("tools") if runtime_status else None
|
||||
return out
|
||||
|
||||
|
||||
def _handle_mcp_servers_list(handler):
|
||||
"""List all configured MCP servers."""
|
||||
"""List configured MCP servers with safe, read-only runtime visibility."""
|
||||
cfg = get_config()
|
||||
servers = cfg.get("mcp_servers", {})
|
||||
if not isinstance(servers, dict):
|
||||
servers = {}
|
||||
result = [_server_summary(name, scfg) for name, scfg in servers.items()]
|
||||
return j(handler, {"servers": result})
|
||||
runtime = _mcp_runtime_status_by_name()
|
||||
result = [
|
||||
_server_summary(name, scfg, runtime.get(str(name)))
|
||||
for name, scfg in servers.items()
|
||||
]
|
||||
return j(handler, {
|
||||
"servers": result,
|
||||
"toggle_supported": False,
|
||||
"reload_required": True,
|
||||
})
|
||||
|
||||
|
||||
def _handle_mcp_server_delete(handler, name):
|
||||
|
||||
BIN
docs/pr-media/696/mcp-servers-system-panel.png
Normal file
BIN
docs/pr-media/696/mcp-servers-system-panel.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 71 KiB |
@@ -46,7 +46,7 @@ const LOCALES = {
|
||||
parse_failed_note: 'parse failed',
|
||||
you: 'You',
|
||||
mcp_servers_title: 'MCP Servers',
|
||||
mcp_servers_desc: 'Manage MCP servers configured in config.yaml.',
|
||||
mcp_servers_desc: 'View MCP servers configured in config.yaml.',
|
||||
mcp_no_servers: 'No MCP servers configured.',
|
||||
mcp_add_server: '+ Add Server',
|
||||
mcp_field_name: 'Server Name',
|
||||
@@ -67,6 +67,16 @@ const LOCALES = {
|
||||
mcp_deleted: 'MCP server deleted.',
|
||||
mcp_delete_failed: 'Failed to delete MCP server.',
|
||||
mcp_load_failed: 'Failed to load MCP servers.',
|
||||
mcp_restart_hint: 'Server changes are read-only here for now. Edit config.yaml and restart Hermes for changes to take effect.',
|
||||
mcp_toggle_followup: 'Enable/disable controls are intentionally deferred until MCP reload semantics are explicit.',
|
||||
mcp_status_active: 'Active',
|
||||
mcp_status_configured: 'Configured',
|
||||
mcp_status_disabled: 'Disabled',
|
||||
mcp_status_invalid_config: 'Invalid config',
|
||||
mcp_status_unknown: 'Unknown',
|
||||
mcp_tool_count: '{0} tools',
|
||||
mcp_enabled_yes: 'Enabled',
|
||||
mcp_enabled_no: 'Disabled',
|
||||
// PDF preview (#480)
|
||||
pdf_loading: 'Loading PDF {0}…',
|
||||
pdf_too_large: 'PDF too large for inline preview',
|
||||
@@ -1027,6 +1037,16 @@ const LOCALES = {
|
||||
mcp_deleted: 'MCPサーバーを削除しました。',
|
||||
mcp_delete_failed: 'MCPサーバーの削除に失敗しました。',
|
||||
mcp_load_failed: 'MCPサーバーの読み込みに失敗しました。',
|
||||
mcp_restart_hint: 'Server changes are read-only here for now. Edit config.yaml and restart Hermes for changes to take effect.',
|
||||
mcp_toggle_followup: 'Enable/disable controls are intentionally deferred until MCP reload semantics are explicit.',
|
||||
mcp_status_active: 'Active',
|
||||
mcp_status_configured: 'Configured',
|
||||
mcp_status_disabled: 'Disabled',
|
||||
mcp_status_invalid_config: 'Invalid config',
|
||||
mcp_status_unknown: 'Unknown',
|
||||
mcp_tool_count: '{0} tools',
|
||||
mcp_enabled_yes: 'Enabled',
|
||||
mcp_enabled_no: 'Disabled',
|
||||
// PDF preview (#480)
|
||||
pdf_loading: 'PDF {0} を読み込み中…',
|
||||
pdf_too_large: 'PDF が大きすぎてインラインプレビューできません',
|
||||
@@ -1984,6 +2004,16 @@ const LOCALES = {
|
||||
mcp_deleted: 'MCP 伺服器已刪除。',
|
||||
mcp_delete_failed: '刪除 MCP 伺服器失敗。',
|
||||
mcp_load_failed: '載入 MCP 伺服器失敗。',
|
||||
mcp_restart_hint: 'Server changes are read-only here for now. Edit config.yaml and restart Hermes for changes to take effect.',
|
||||
mcp_toggle_followup: 'Enable/disable controls are intentionally deferred until MCP reload semantics are explicit.',
|
||||
mcp_status_active: 'Active',
|
||||
mcp_status_configured: 'Configured',
|
||||
mcp_status_disabled: 'Disabled',
|
||||
mcp_status_invalid_config: 'Invalid config',
|
||||
mcp_status_unknown: 'Unknown',
|
||||
mcp_tool_count: '{0} tools',
|
||||
mcp_enabled_yes: 'Enabled',
|
||||
mcp_enabled_no: 'Disabled',
|
||||
thinking: 'Думаю',
|
||||
expand_all: 'Развернуть всё',
|
||||
collapse_all: 'Свернуть всё',
|
||||
@@ -2875,6 +2905,16 @@ const LOCALES = {
|
||||
mcp_deleted: 'MCP 服务器已删除。',
|
||||
mcp_delete_failed: '删除 MCP 服务器失败。',
|
||||
mcp_load_failed: '加载 MCP 服务器失败。',
|
||||
mcp_restart_hint: 'Server changes are read-only here for now. Edit config.yaml and restart Hermes for changes to take effect.',
|
||||
mcp_toggle_followup: 'Enable/disable controls are intentionally deferred until MCP reload semantics are explicit.',
|
||||
mcp_status_active: 'Active',
|
||||
mcp_status_configured: 'Configured',
|
||||
mcp_status_disabled: 'Disabled',
|
||||
mcp_status_invalid_config: 'Invalid config',
|
||||
mcp_status_unknown: 'Unknown',
|
||||
mcp_tool_count: '{0} tools',
|
||||
mcp_enabled_yes: 'Enabled',
|
||||
mcp_enabled_no: 'Disabled',
|
||||
thinking: 'Pensando',
|
||||
expand_all: 'Expandir todo',
|
||||
collapse_all: 'Contraer todo',
|
||||
@@ -3769,6 +3809,16 @@ const LOCALES = {
|
||||
mcp_deleted: 'MCP-Server gelöscht.',
|
||||
mcp_delete_failed: 'Fehler beim Löschen.',
|
||||
mcp_load_failed: 'Fehler beim Laden.',
|
||||
mcp_restart_hint: 'Server changes are read-only here for now. Edit config.yaml and restart Hermes for changes to take effect.',
|
||||
mcp_toggle_followup: 'Enable/disable controls are intentionally deferred until MCP reload semantics are explicit.',
|
||||
mcp_status_active: 'Active',
|
||||
mcp_status_configured: 'Configured',
|
||||
mcp_status_disabled: 'Disabled',
|
||||
mcp_status_invalid_config: 'Invalid config',
|
||||
mcp_status_unknown: 'Unknown',
|
||||
mcp_tool_count: '{0} tools',
|
||||
mcp_enabled_yes: 'Enabled',
|
||||
mcp_enabled_no: 'Disabled',
|
||||
thinking: 'Nachdenken',
|
||||
expand_all: 'Alle ausklappen',
|
||||
collapse_all: 'Alle einklappen',
|
||||
@@ -4667,6 +4717,16 @@ const LOCALES = {
|
||||
mcp_deleted: 'MCP 服务器已删除。',
|
||||
mcp_delete_failed: 'MCP 服务器删除失败。',
|
||||
mcp_load_failed: 'MCP 服务器加载失败。',
|
||||
mcp_restart_hint: 'Server changes are read-only here for now. Edit config.yaml and restart Hermes for changes to take effect.',
|
||||
mcp_toggle_followup: 'Enable/disable controls are intentionally deferred until MCP reload semantics are explicit.',
|
||||
mcp_status_active: 'Active',
|
||||
mcp_status_configured: 'Configured',
|
||||
mcp_status_disabled: 'Disabled',
|
||||
mcp_status_invalid_config: 'Invalid config',
|
||||
mcp_status_unknown: 'Unknown',
|
||||
mcp_tool_count: '{0} tools',
|
||||
mcp_enabled_yes: 'Enabled',
|
||||
mcp_enabled_no: 'Disabled',
|
||||
thinking: '\u601d\u8003\u8fc7\u7a0b',
|
||||
expand_all: '\u5168\u90e8\u5c55\u5f00',
|
||||
collapse_all: '\u5168\u90e8\u6298\u53e0',
|
||||
@@ -5560,6 +5620,16 @@ const LOCALES = {
|
||||
mcp_deleted: 'MCP 伺服器已刪除。',
|
||||
mcp_delete_failed: '刪除 MCP 伺服器失敗。',
|
||||
mcp_load_failed: '載入 MCP 伺服器失敗。',
|
||||
mcp_restart_hint: 'Server changes are read-only here for now. Edit config.yaml and restart Hermes for changes to take effect.',
|
||||
mcp_toggle_followup: 'Enable/disable controls are intentionally deferred until MCP reload semantics are explicit.',
|
||||
mcp_status_active: 'Active',
|
||||
mcp_status_configured: 'Configured',
|
||||
mcp_status_disabled: 'Disabled',
|
||||
mcp_status_invalid_config: 'Invalid config',
|
||||
mcp_status_unknown: 'Unknown',
|
||||
mcp_tool_count: '{0} tools',
|
||||
mcp_enabled_yes: 'Enabled',
|
||||
mcp_enabled_no: 'Disabled',
|
||||
thinking: '\u601d\u8003\u904e\u7a0b',
|
||||
expand_all: '\u5168\u90e8\u5c55\u958b',
|
||||
collapse_all: '\u5168\u90e8\u6298\u758a',
|
||||
@@ -7315,6 +7385,16 @@ const LOCALES = {
|
||||
mcp_deleted: 'MCP server deleted.',
|
||||
mcp_delete_failed: 'Failed to delete MCP server.',
|
||||
mcp_load_failed: 'Failed to load MCP servers.',
|
||||
mcp_restart_hint: 'Server changes are read-only here for now. Edit config.yaml and restart Hermes for changes to take effect.',
|
||||
mcp_toggle_followup: 'Enable/disable controls are intentionally deferred until MCP reload semantics are explicit.',
|
||||
mcp_status_active: 'Active',
|
||||
mcp_status_configured: 'Configured',
|
||||
mcp_status_disabled: 'Disabled',
|
||||
mcp_status_invalid_config: 'Invalid config',
|
||||
mcp_status_unknown: 'Unknown',
|
||||
mcp_tool_count: '{0} tools',
|
||||
mcp_enabled_yes: 'Enabled',
|
||||
mcp_enabled_no: 'Disabled',
|
||||
thinking: '생각 중',
|
||||
expand_all: '모두 펼치기',
|
||||
collapse_all: '모두 접기',
|
||||
|
||||
@@ -1007,42 +1007,9 @@
|
||||
<!-- MCP Servers Section -->
|
||||
<div class="settings-field" style="margin-top:18px;padding-top:16px;border-top:1px solid var(--border)">
|
||||
<label data-i18n="mcp_servers_title">MCP Servers</label>
|
||||
<div style="font-size:11px;color:var(--muted);margin-bottom:8px" data-i18n="mcp_servers_desc">Manage Model Context Protocol servers configured in config.yaml.</div>
|
||||
<div style="font-size:11px;color:var(--muted);margin-bottom:8px" data-i18n="mcp_servers_desc">View Model Context Protocol servers configured in config.yaml.</div>
|
||||
<div id="mcpServerList"></div>
|
||||
<button class="sm-btn" onclick="showMcpAddForm()" style="margin-top:8px;width:100%;padding:7px;font-weight:600" data-i18n="mcp_add_server">+ Add Server</button>
|
||||
</div>
|
||||
<div id="mcpAddFormWrap" style="display:none;margin-top:10px">
|
||||
<div class="settings-field">
|
||||
<label data-i18n="mcp_field_name">Server Name</label>
|
||||
<input type="text" id="mcpName" placeholder="my-server" style="width:100%;padding:8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px;font-size:13px">
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label data-i18n="mcp_transport_label">Transport Type</label>
|
||||
<select id="mcpTransport" onchange="mcpTransportChanged()" style="width:100%;padding:8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px;font-size:13px">
|
||||
<option value="stdio">stdio (local command)</option>
|
||||
<option value="http">HTTP (remote URL)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="settings-field" id="mcpCommandField">
|
||||
<label data-i18n="mcp_field_command">Command</label>
|
||||
<input type="text" id="mcpCommand" placeholder="npx" style="width:100%;padding:8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px;font-size:13px">
|
||||
</div>
|
||||
<div class="settings-field" id="mcpArgsField">
|
||||
<label data-i18n="mcp_field_args">Arguments (comma-separated)</label>
|
||||
<input type="text" id="mcpArgs" placeholder="-y, @modelcontextprotocol/server-filesystem" style="width:100%;padding:8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px;font-size:13px">
|
||||
</div>
|
||||
<div class="settings-field" id="mcpUrlField" style="display:none">
|
||||
<label data-i18n="mcp_field_url">URL</label>
|
||||
<input type="text" id="mcpUrl" placeholder="https://my-mcp-server.example.com/mcp" style="width:100%;padding:8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px;font-size:13px">
|
||||
</div>
|
||||
<div class="settings-field" id="mcpTimeoutField">
|
||||
<label data-i18n="mcp_field_timeout">Timeout (seconds)</label>
|
||||
<input type="number" id="mcpTimeout" value="120" min="10" max="600" style="width:100%;padding:8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px;font-size:13px">
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;margin-top:8px">
|
||||
<button class="sm-btn" onclick="saveMcpServer()" style="flex:1;padding:7px;font-weight:600" data-i18n="mcp_save">Save</button>
|
||||
<button class="sm-btn" onclick="hideMcpAddForm()" style="flex:1;padding:7px;color:var(--muted)" data-i18n="mcp_cancel">Cancel</button>
|
||||
</div>
|
||||
<div class="mcp-restart-hint" data-i18n="mcp_restart_hint">Server changes are read-only here for now. Edit config.yaml and restart Hermes for changes to take effect.</div>
|
||||
</div>
|
||||
<button class="sm-btn" onclick="saveSettings()" style="margin-top:12px;width:100%;padding:8px;font-weight:600" data-i18n="settings_save_btn">Save Settings</button>
|
||||
</div>
|
||||
|
||||
105
static/panels.js
105
static/panels.js
@@ -4885,93 +4885,50 @@ function dismissErrorBanner(){
|
||||
|
||||
|
||||
// ── MCP Server Management ──
|
||||
function _mcpStatusLabel(status){
|
||||
const key={
|
||||
active:'mcp_status_active',
|
||||
configured:'mcp_status_configured',
|
||||
disabled:'mcp_status_disabled',
|
||||
invalid_config:'mcp_status_invalid_config',
|
||||
}[status]||'mcp_status_unknown';
|
||||
return t(key);
|
||||
}
|
||||
function loadMcpServers(){
|
||||
const list=$('mcpServerList');
|
||||
if(!list) return;
|
||||
list.innerHTML=`<div style="color:var(--muted);font-size:12px;padding:6px 0">${esc(t('loading'))}</div>`;
|
||||
api('/api/mcp/servers').then(r=>{
|
||||
if(!r||!r.servers) return;
|
||||
if(!r||!Array.isArray(r.servers)) return;
|
||||
if(!r.servers.length){
|
||||
list.innerHTML=`<div style="color:var(--muted);font-size:12px;padding:6px 0">${t('mcp_no_servers')}</div>`;
|
||||
list.innerHTML=`<div class="mcp-empty-state" style="color:var(--muted);font-size:12px;padding:6px 0">${esc(t('mcp_no_servers'))}</div>`;
|
||||
return;
|
||||
}
|
||||
const toggleNote=r.toggle_supported?'':'<div class="mcp-readonly-note">'+esc(t('mcp_toggle_followup'))+'</div>';
|
||||
list.innerHTML=r.servers.map(s=>{
|
||||
const transportLabel=s.transport==='http'?'HTTP':s.transport==='stdio'?'stdio':(''+s.transport);
|
||||
const transportLabel=s.transport==='http'?'HTTP':s.transport==='stdio'?'stdio':(''+(s.transport||'unknown'));
|
||||
const transportClass=s.transport==='http'?'mcp-http':s.transport==='stdio'?'mcp-stdio':'mcp-unknown';
|
||||
const badge=`<span class="mcp-transport-badge ${transportClass}">${esc(transportLabel)}</span>`;
|
||||
const detail=s.transport==='http'?s.url:`${s.command} ${s.args?s.args.join(' '):''}`;
|
||||
const transportBadge=`<span class="mcp-transport-badge ${transportClass}">${esc(transportLabel)}</span>`;
|
||||
const status=s.status||'configured';
|
||||
const statusBadge=`<span class="mcp-status-badge mcp-status-${esc(status)}">${esc(_mcpStatusLabel(status))}</span>`;
|
||||
const toolCount=s.tool_count===null||typeof s.tool_count==='undefined'?'—':String(s.tool_count);
|
||||
const detail=s.transport==='http'
|
||||
? (s.url||'')
|
||||
: (s.transport==='stdio'?`${s.command||''} ${Array.isArray(s.args)?s.args.join(' '):''}`:t('mcp_status_invalid_config'));
|
||||
const envInfo=s.env?Object.entries(s.env).map(([k,v])=>`${k}=${v}`).join(', '):'';
|
||||
const headersInfo=s.headers?Object.entries(s.headers).map(([k,v])=>`${k}=${v}`).join(', '):'';
|
||||
const secretInfo=[envInfo,headersInfo].filter(Boolean).join(' | ');
|
||||
return `<div class="mcp-server-row">
|
||||
<div style="display:flex;align-items:center;gap:8px">
|
||||
<span class="mcp-server-name">${esc(s.name)}</span>${badge}
|
||||
<div class="mcp-server-row-head">
|
||||
<span class="mcp-server-name">${esc(s.name)}</span>
|
||||
${transportBadge}
|
||||
${statusBadge}
|
||||
</div>
|
||||
<div class="mcp-server-detail">${esc(detail)}${envInfo?' | '+esc(envInfo):''}</div>
|
||||
<button class="mcp-delete-btn" data-mcp-name="${esc(s.name)}" title="Delete">×</button>
|
||||
<div class="mcp-server-detail">${esc(detail)}${secretInfo?' | '+esc(secretInfo):''}</div>
|
||||
<div class="mcp-server-meta"><span class="mcp-tool-count">${esc(t('mcp_tool_count',toolCount))}</span><span>${esc(t(s.enabled===false?'mcp_enabled_no':'mcp_enabled_yes'))}</span></div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}).catch(()=>{list.innerHTML=`<div style="color:#ef4444;font-size:12px;padding:6px 0">${t('mcp_load_failed')}</div>`});
|
||||
// Delegate delete-button clicks — uses data-mcp-name to avoid inline onclick XSS
|
||||
if(list&&!list._mcpDeleteBound){
|
||||
list._mcpDeleteBound=true;
|
||||
list.addEventListener('click',function(e){
|
||||
const btn=e.target.closest('.mcp-delete-btn');
|
||||
if(!btn) return;
|
||||
const name=btn.getAttribute('data-mcp-name');
|
||||
if(name) deleteMcpServer(name);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function showMcpAddForm(){
|
||||
const wrap=$('mcpAddFormWrap');
|
||||
if(wrap) wrap.style.display='block';
|
||||
}
|
||||
function hideMcpAddForm(){
|
||||
const wrap=$('mcpAddFormWrap');
|
||||
if(wrap) wrap.style.display='none';
|
||||
['mcpName','mcpCommand','mcpArgs','mcpUrl','mcpTimeout'].forEach(id=>{
|
||||
const el=$(id);if(el)el.value=id==='mcpTimeout'?'120':'';
|
||||
});
|
||||
const tr=$('mcpTransport');if(tr)tr.value='stdio';
|
||||
mcpTransportChanged();
|
||||
}
|
||||
function mcpTransportChanged(){
|
||||
const tr=$('mcpTransport');
|
||||
const isHttp=tr&&tr.value==='http';
|
||||
const cmdF=$('mcpCommandField');if(cmdF)cmdF.style.display=isHttp?'none':'';
|
||||
const argsF=$('mcpArgsField');if(argsF)argsF.style.display=isHttp?'none':'';
|
||||
const urlF=$('mcpUrlField');if(urlF)urlF.style.display=isHttp?'block':'none';
|
||||
}
|
||||
function saveMcpServer(){
|
||||
const name=($('mcpName')||{}).value||'';
|
||||
if(!name.trim()){showToast(t('mcp_name_required'));return;}
|
||||
const tr=($('mcpTransport')||{}).value||'stdio';
|
||||
const timeout=parseInt(($('mcpTimeout')||{}).value)||120;
|
||||
const body={timeout};
|
||||
if(tr==='http'){
|
||||
body.url=($('mcpUrl')||{}).value||'';
|
||||
if(!body.url.trim()){showToast(t('mcp_url_required'));return;}
|
||||
}else{
|
||||
body.command=($('mcpCommand')||{}).value||'';
|
||||
if(!body.command.trim()){showToast(t('mcp_command_required'));return;}
|
||||
const argsStr=($('mcpArgs')||{}).value||'';
|
||||
if(argsStr.trim()) body.args=argsStr.split(',').map(a=>a.trim()).filter(Boolean);
|
||||
}
|
||||
const encName=encodeURIComponent(name.trim());
|
||||
api(`/api/mcp/servers/${encName}`,{method:'PUT',body:JSON.stringify(body)})
|
||||
.then(r=>{
|
||||
if(r&&r.ok){showToast(t('mcp_saved'));hideMcpAddForm();loadMcpServers();}
|
||||
else{showToast((r&&r.error)||t('mcp_save_failed'));}
|
||||
}).catch(()=>{showToast(t('mcp_save_failed'));});
|
||||
}
|
||||
async function deleteMcpServer(name){
|
||||
const _ok=await showConfirmDialog({title:t('mcp_delete_confirm_title'),message:t('mcp_delete_confirm_message',name),confirmLabel:t('delete_title'),danger:true,focusCancel:true});
|
||||
if(!_ok) return;
|
||||
const encName=encodeURIComponent(name);
|
||||
api(`/api/mcp/servers/${encName}`,{method:'DELETE'})
|
||||
.then(r=>{
|
||||
if(r&&r.ok){showToast(t('mcp_deleted'));loadMcpServers();}
|
||||
else{showToast((r&&r.error)||t('mcp_delete_failed'));}
|
||||
}).catch(()=>{showToast(t('mcp_delete_failed'));});
|
||||
}).join('')+toggleNote;
|
||||
}).catch(()=>{list.innerHTML=`<div class="mcp-error-state" style="color:#ef4444;font-size:12px;padding:6px 0">${esc(t('mcp_load_failed'))}</div>`});
|
||||
}
|
||||
function loadGatewayStatus(){
|
||||
const card=$('gatewayStatusCard');
|
||||
|
||||
@@ -2273,16 +2273,22 @@ main.main.showing-profiles > #mainProfiles{display:flex;}
|
||||
#mainSettings #btnSignOut:hover{color:var(--accent-text)!important;border-color:var(--accent-bg-strong)!important;}
|
||||
|
||||
/* MCP Server Management */
|
||||
.mcp-server-row{display:flex;align-items:center;gap:8px;padding:6px 8px;border:1px solid var(--border);border-radius:6px;margin-bottom:4px;position:relative;font-size:12px;}
|
||||
.mcp-server-row{display:flex;flex-direction:column;gap:4px;padding:8px 10px;border:1px solid var(--border);border-radius:8px;margin-bottom:6px;position:relative;font-size:12px;background:var(--surface);}
|
||||
.mcp-server-row:hover{background:var(--code-bg);}
|
||||
.mcp-server-row-head{display:flex;align-items:center;gap:8px;min-width:0;flex-wrap:wrap;}
|
||||
.mcp-server-name{font-weight:600;color:var(--text);}
|
||||
.mcp-server-detail{flex:1;color:var(--muted);font-size:11px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
|
||||
.mcp-transport-badge{font-size:9px;font-weight:700;text-transform:uppercase;letter-spacing:.04em;padding:2px 6px;border-radius:4px;flex-shrink:0;}
|
||||
.mcp-server-detail{color:var(--muted);font-size:11px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:100%;}
|
||||
.mcp-server-meta{display:flex;gap:10px;color:var(--muted);font-size:11px;}
|
||||
.mcp-transport-badge,.mcp-status-badge{font-size:9px;font-weight:700;text-transform:uppercase;letter-spacing:.04em;padding:2px 6px;border-radius:999px;flex-shrink:0;}
|
||||
.mcp-stdio{background:rgba(99,102,241,.12);color:#818cf8;}
|
||||
.mcp-unknown{background:rgba(161,161,170,.12);color:#a1a1aa;}
|
||||
.mcp-http{background:rgba(34,197,94,.12);color:#4ade80;}
|
||||
.mcp-delete-btn{background:none;border:none;color:var(--muted);font-size:16px;cursor:pointer;padding:2px 4px;border-radius:4px;flex-shrink:0;}
|
||||
.mcp-delete-btn:hover{color:#ef4444;background:rgba(239,68,68,.1);}
|
||||
.mcp-status-active{background:rgba(34,197,94,.12);color:#4ade80;}
|
||||
.mcp-status-configured{background:rgba(245,158,11,.12);color:#f59e0b;}
|
||||
.mcp-status-disabled{background:rgba(161,161,170,.12);color:#a1a1aa;}
|
||||
.mcp-status-invalid_config,.mcp-status-unknown{background:rgba(239,68,68,.12);color:#f87171;}
|
||||
.mcp-tool-count{color:var(--text);}
|
||||
.mcp-readonly-note,.mcp-restart-hint{margin-top:8px;color:var(--muted);font-size:11px;line-height:1.45;background:var(--code-bg);border:1px solid var(--border2);border-radius:6px;padding:8px 10px;}
|
||||
|
||||
/* Picker grids (theme / skin / font-size): make the card chrome use
|
||||
tokens so all skins flip correctly. */
|
||||
|
||||
@@ -6,6 +6,7 @@ from api.routes import (
|
||||
_handle_mcp_server_update,
|
||||
_handle_mcp_server_delete,
|
||||
_mask_secrets,
|
||||
_parse_mcp_enabled,
|
||||
_server_summary,
|
||||
_strip_masked_values,
|
||||
)
|
||||
@@ -18,6 +19,11 @@ def _make_handler():
|
||||
return h
|
||||
|
||||
|
||||
def _json_payload(handler):
|
||||
body = handler.wfile.write.call_args[0][0]
|
||||
return json.loads(body.decode('utf-8'))
|
||||
|
||||
|
||||
SAMPLE_MCP = {
|
||||
"searxng": {
|
||||
"command": "mcp-searxng",
|
||||
@@ -52,6 +58,43 @@ class TestMcpList:
|
||||
assert h.send_response.called
|
||||
status = h.send_response.call_args[0][0]
|
||||
assert status == 200
|
||||
payload = _json_payload(h)
|
||||
assert payload['servers'] == []
|
||||
assert payload['toggle_supported'] is False
|
||||
assert payload['reload_required'] is True
|
||||
|
||||
@patch('api.routes._mcp_runtime_status_by_name')
|
||||
@patch('api.routes.get_config')
|
||||
def test_list_payload_includes_status_tool_counts_and_safe_invalid_config(self, mock_cfg, mock_runtime):
|
||||
mock_cfg.return_value = {
|
||||
'mcp_servers': {
|
||||
'searxng': {'command': 'mcp-searxng', 'args': ['--port', '8888']},
|
||||
'web-reader': {
|
||||
'url': 'http://localhost:3001/mcp',
|
||||
'headers': {'Authorization': 'Bearer secret123'},
|
||||
},
|
||||
'disabled': {'command': 'disabled-cmd', 'enabled': 0},
|
||||
'broken': 'not-a-dict',
|
||||
}
|
||||
}
|
||||
mock_runtime.return_value = {
|
||||
'searxng': {'connected': True, 'tools': 3},
|
||||
'web-reader': {'connected': False, 'tools': 0},
|
||||
}
|
||||
h = _make_handler()
|
||||
_handle_mcp_servers_list(h)
|
||||
payload = _json_payload(h)
|
||||
by_name = {s['name']: s for s in payload['servers']}
|
||||
assert by_name['searxng']['status'] == 'active'
|
||||
assert by_name['searxng']['active'] is True
|
||||
assert by_name['searxng']['tool_count'] == 3
|
||||
assert by_name['web-reader']['status'] == 'configured'
|
||||
assert '••••' in by_name['web-reader']['headers']['Authorization']
|
||||
assert by_name['disabled']['enabled'] is False
|
||||
assert by_name['disabled']['active'] is False
|
||||
assert by_name['disabled']['status'] == 'disabled'
|
||||
assert by_name['broken']['transport'] == 'invalid'
|
||||
assert by_name['broken']['status'] == 'invalid_config'
|
||||
|
||||
def test_secrets_are_masked(self):
|
||||
"""_mask_secrets hides API keys in headers and env."""
|
||||
@@ -75,6 +118,10 @@ class TestMcpList:
|
||||
summary = _server_summary('minimal', {'command': 'x'})
|
||||
assert summary['timeout'] == 120
|
||||
|
||||
def test_numeric_zero_enabled_flag_is_disabled(self):
|
||||
"""YAML numeric false-y values should not show a disabled server as enabled."""
|
||||
assert _parse_mcp_enabled(0) is False
|
||||
|
||||
|
||||
class TestMcpSave:
|
||||
"""PUT /api/mcp/servers/<name> — add or update."""
|
||||
|
||||
46
tests/test_issue696_mcp_visibility_panel.py
Normal file
46
tests/test_issue696_mcp_visibility_panel.py
Normal file
@@ -0,0 +1,46 @@
|
||||
"""Regression tests for issue #696 — MCP server visibility panel MVP."""
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def read(relpath: str) -> str:
|
||||
return (ROOT / relpath).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_settings_system_panel_contains_readonly_mcp_visibility_section():
|
||||
html = read("static/index.html")
|
||||
assert 'data-i18n="mcp_servers_title"' in html
|
||||
assert 'id="mcpServerList"' in html
|
||||
assert 'class="mcp-restart-hint"' in html
|
||||
assert 'id="mcpAddFormWrap"' not in html
|
||||
assert 'onclick="showMcpAddForm()"' not in html
|
||||
|
||||
|
||||
def test_mcp_panel_renders_status_badges_tool_counts_and_empty_error_states():
|
||||
js = read("static/panels.js")
|
||||
assert "function _mcpStatusLabel" in js
|
||||
assert "mcp-status-badge" in js
|
||||
assert "mcp-tool-count" in js
|
||||
assert "mcp-empty-state" in js
|
||||
assert "mcp-error-state" in js
|
||||
assert "mcp_toggle_followup" in js
|
||||
assert "api('/api/mcp/servers')" in js
|
||||
assert "mcp-delete-btn" not in js
|
||||
assert "showMcpAddForm" not in js
|
||||
assert "saveMcpServer" not in js
|
||||
|
||||
|
||||
def test_mcp_i18n_includes_visibility_status_labels():
|
||||
i18n = read("static/i18n.js")
|
||||
for key in [
|
||||
"mcp_status_active",
|
||||
"mcp_status_configured",
|
||||
"mcp_status_disabled",
|
||||
"mcp_status_invalid_config",
|
||||
"mcp_tool_count",
|
||||
"mcp_enabled_yes",
|
||||
"mcp_enabled_no",
|
||||
"mcp_toggle_followup",
|
||||
]:
|
||||
assert key in i18n
|
||||
Reference in New Issue
Block a user