mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-05-25 11:10:18 +00:00
Stage 298: PR #1679 — feat: add searchable MCP tool inventory by @Michaelyklam
This commit is contained in:
+184
@@ -2820,6 +2820,10 @@ def handle_get(handler, parsed) -> bool:
|
||||
if parsed.path == "/api/mcp/servers":
|
||||
return _handle_mcp_servers_list(handler)
|
||||
|
||||
# ── MCP Tools (GET) ──
|
||||
if parsed.path == "/api/mcp/tools":
|
||||
return _handle_mcp_tools_list(handler)
|
||||
|
||||
# ── Checkpoints / Rollback (GET) ──
|
||||
if parsed.path == "/api/rollback/list":
|
||||
qs = parse_qs(parsed.query)
|
||||
@@ -7548,6 +7552,186 @@ def _server_summary(name, cfg, runtime_status=None):
|
||||
return out
|
||||
|
||||
|
||||
def _mcp_safe_display_text(value, *, limit: int) -> str:
|
||||
"""Return redacted, bounded MCP text safe for WebUI inventory rows."""
|
||||
if not isinstance(value, str):
|
||||
value = "" if value is None else str(value)
|
||||
value = _redact_text(value).strip()
|
||||
value = re.sub(r"Authorization:\s*Bearer\s+\S+", "[REDACTED CREDENTIAL]", value, flags=re.I)
|
||||
if len(value) > limit:
|
||||
value = value[: max(0, limit - 1)].rstrip() + "…"
|
||||
return value
|
||||
|
||||
|
||||
def _mcp_schema_type(schema) -> str:
|
||||
"""Return a compact, non-sensitive display type for a JSON schema node."""
|
||||
if not isinstance(schema, dict):
|
||||
return "unknown"
|
||||
typ = schema.get("type")
|
||||
if isinstance(typ, list):
|
||||
typ = "/".join(str(t) for t in typ if t)
|
||||
if isinstance(typ, str) and typ:
|
||||
return typ
|
||||
for composite in ("anyOf", "oneOf", "allOf"):
|
||||
if isinstance(schema.get(composite), list) and schema[composite]:
|
||||
return composite
|
||||
if "enum" in schema:
|
||||
return "enum"
|
||||
return "unknown"
|
||||
|
||||
|
||||
def _mcp_schema_summary(schema, *, limit: int = 12) -> list[dict]:
|
||||
"""Summarize an MCP input schema without exposing raw defaults/examples.
|
||||
|
||||
The WebUI only needs searchable/displayable argument hints. Returning raw
|
||||
JSON Schema can overexpose server-provided defaults, examples, enums, or
|
||||
vendor extensions, so this strips each parameter down to name/type/required
|
||||
and a redacted description.
|
||||
"""
|
||||
if not isinstance(schema, dict):
|
||||
return []
|
||||
properties = schema.get("properties")
|
||||
if not isinstance(properties, dict):
|
||||
return []
|
||||
required = schema.get("required")
|
||||
required_names = set(required) if isinstance(required, list) else set()
|
||||
out = []
|
||||
for name, prop in properties.items():
|
||||
if len(out) >= limit:
|
||||
break
|
||||
if not isinstance(name, str):
|
||||
continue
|
||||
prop = prop if isinstance(prop, dict) else {}
|
||||
desc = prop.get("description", "")
|
||||
if not isinstance(desc, str):
|
||||
desc = ""
|
||||
desc = _mcp_safe_display_text(desc, limit=180)
|
||||
out.append({
|
||||
"name": name,
|
||||
"type": _mcp_schema_type(prop),
|
||||
"required": name in required_names,
|
||||
"description": desc,
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def _mcp_tool_schema_from_payload(tool):
|
||||
if not isinstance(tool, dict):
|
||||
return {}
|
||||
for key in ("parameters", "inputSchema", "input_schema", "schema"):
|
||||
value = tool.get(key)
|
||||
if isinstance(value, dict):
|
||||
if key == "schema" and isinstance(value.get("parameters"), dict):
|
||||
return value["parameters"]
|
||||
return value
|
||||
return {}
|
||||
|
||||
|
||||
def _mcp_tool_summary(name, tool, server_summary):
|
||||
"""Return a safe global inventory row for one MCP tool."""
|
||||
server_summary = server_summary if isinstance(server_summary, dict) else {}
|
||||
if isinstance(tool, str):
|
||||
tool = {"name": tool}
|
||||
elif not isinstance(tool, dict):
|
||||
tool = {}
|
||||
tool_name = str(tool.get("name") or name or "")
|
||||
description = tool.get("description") or ""
|
||||
if not isinstance(description, str):
|
||||
description = str(description)
|
||||
description = _mcp_safe_display_text(description, limit=360)
|
||||
return {
|
||||
"name": tool_name,
|
||||
"server": str(server_summary.get("name") or ""),
|
||||
"description": description,
|
||||
"active": bool(server_summary.get("active")),
|
||||
"enabled": bool(server_summary.get("enabled")),
|
||||
"status": server_summary.get("status") or "unknown",
|
||||
"schema_summary": _mcp_schema_summary(_mcp_tool_schema_from_payload(tool)),
|
||||
}
|
||||
|
||||
|
||||
def _mcp_tools_from_runtime_status(runtime_by_name, server_summaries):
|
||||
"""Read detailed MCP tool payloads from runtime status when available."""
|
||||
tools = []
|
||||
if not isinstance(runtime_by_name, dict):
|
||||
return tools
|
||||
for server_name, runtime in runtime_by_name.items():
|
||||
if not isinstance(runtime, dict):
|
||||
continue
|
||||
raw_tools = runtime.get("tools")
|
||||
if not isinstance(raw_tools, list):
|
||||
raw_tools = runtime.get("tool_schemas")
|
||||
if not isinstance(raw_tools, list):
|
||||
continue
|
||||
server_summary = server_summaries.get(str(server_name), {"name": str(server_name)})
|
||||
for index, tool in enumerate(raw_tools):
|
||||
fallback_name = f"{server_name}:{index}"
|
||||
summary = _mcp_tool_summary(fallback_name, tool, server_summary)
|
||||
if summary["name"]:
|
||||
tools.append(summary)
|
||||
return tools
|
||||
|
||||
|
||||
def _mcp_tools_from_registry(server_summaries):
|
||||
"""Read already-registered MCP tool schemas without probing MCP servers."""
|
||||
try:
|
||||
from tools.registry import registry
|
||||
except Exception:
|
||||
return []
|
||||
tools = []
|
||||
try:
|
||||
names = registry.get_all_tool_names()
|
||||
except Exception:
|
||||
return []
|
||||
for tool_name in names:
|
||||
try:
|
||||
toolset = registry.get_toolset_for_tool(tool_name)
|
||||
except Exception:
|
||||
continue
|
||||
if not isinstance(toolset, str) or not toolset.startswith("mcp-"):
|
||||
continue
|
||||
server_name = toolset[len("mcp-"):]
|
||||
schema = registry.get_schema(tool_name) or {}
|
||||
server_summary = server_summaries.get(server_name, {
|
||||
"name": server_name,
|
||||
"enabled": True,
|
||||
"active": False,
|
||||
"status": "configured",
|
||||
})
|
||||
tools.append(_mcp_tool_summary(tool_name, schema, server_summary))
|
||||
return tools
|
||||
|
||||
|
||||
def _handle_mcp_tools_list(handler):
|
||||
"""List known MCP tools from already-available runtime inventory only."""
|
||||
cfg = get_config()
|
||||
servers = cfg.get("mcp_servers", {})
|
||||
if not isinstance(servers, dict):
|
||||
servers = {}
|
||||
runtime = _mcp_runtime_status_by_name()
|
||||
server_summaries = {
|
||||
str(name): _server_summary(str(name), scfg, runtime.get(str(name)))
|
||||
for name, scfg in servers.items()
|
||||
}
|
||||
tools = _mcp_tools_from_runtime_status(runtime, server_summaries)
|
||||
source = "mcp_runtime_status"
|
||||
if not tools:
|
||||
tools = _mcp_tools_from_registry(server_summaries)
|
||||
source = "tool_registry" if tools else "none"
|
||||
tools.sort(key=lambda row: (row.get("server", ""), row.get("name", "")))
|
||||
unavailable_servers = [
|
||||
summary["name"] for summary in server_summaries.values()
|
||||
if summary.get("enabled") and not summary.get("active")
|
||||
]
|
||||
return j(handler, {
|
||||
"tools": tools,
|
||||
"total": len(tools),
|
||||
"source": source,
|
||||
"inventory_scope": "already_known_runtime_only",
|
||||
"unavailable_servers": unavailable_servers,
|
||||
})
|
||||
|
||||
|
||||
def _handle_mcp_servers_list(handler):
|
||||
"""List configured MCP servers with safe, read-only runtime visibility."""
|
||||
cfg = get_config()
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 64 KiB |
@@ -77,6 +77,14 @@ const LOCALES = {
|
||||
mcp_tool_count: '{0} tools',
|
||||
mcp_enabled_yes: 'Enabled',
|
||||
mcp_enabled_no: 'Disabled',
|
||||
mcp_tools_title: 'MCP Tools',
|
||||
mcp_tools_desc: 'Search known tools across active MCP servers.',
|
||||
mcp_tools_search_placeholder: 'Search tools by name, server, or description…',
|
||||
mcp_tools_no_tools: 'No MCP tools are available from the active runtime inventory.',
|
||||
mcp_tools_no_matches: 'No MCP tools match your search.',
|
||||
mcp_tools_load_failed: 'Failed to load MCP tools.',
|
||||
mcp_tools_schema_empty: 'No schema parameters.',
|
||||
mcp_tools_runtime_note: 'Tool inventory only uses already-known active MCP runtime data; the WebUI does not start or probe servers.',
|
||||
// PDF preview (#480)
|
||||
pdf_loading: 'Loading PDF {0}…',
|
||||
pdf_too_large: 'PDF too large for inline preview',
|
||||
@@ -1060,6 +1068,14 @@ const LOCALES = {
|
||||
mcp_tool_count: '{0} tools',
|
||||
mcp_enabled_yes: 'Enabled',
|
||||
mcp_enabled_no: 'Disabled',
|
||||
mcp_tools_title: 'MCP Tools',
|
||||
mcp_tools_desc: 'Search known tools across active MCP servers.',
|
||||
mcp_tools_search_placeholder: 'Search tools by name, server, or description…',
|
||||
mcp_tools_no_tools: 'No MCP tools are available from the active runtime inventory.',
|
||||
mcp_tools_no_matches: 'No MCP tools match your search.',
|
||||
mcp_tools_load_failed: 'Failed to load MCP tools.',
|
||||
mcp_tools_schema_empty: 'No schema parameters.',
|
||||
mcp_tools_runtime_note: 'Tool inventory only uses already-known active MCP runtime data; the WebUI does not start or probe servers.',
|
||||
// PDF preview (#480)
|
||||
pdf_loading: 'PDF {0} を読み込み中…',
|
||||
pdf_too_large: 'PDF が大きすぎてインラインプレビューできません',
|
||||
@@ -2040,6 +2056,14 @@ const LOCALES = {
|
||||
mcp_tool_count: '{0} tools',
|
||||
mcp_enabled_yes: 'Enabled',
|
||||
mcp_enabled_no: 'Disabled',
|
||||
mcp_tools_title: 'MCP Tools',
|
||||
mcp_tools_desc: 'Search known tools across active MCP servers.',
|
||||
mcp_tools_search_placeholder: 'Search tools by name, server, or description…',
|
||||
mcp_tools_no_tools: 'No MCP tools are available from the active runtime inventory.',
|
||||
mcp_tools_no_matches: 'No MCP tools match your search.',
|
||||
mcp_tools_load_failed: 'Failed to load MCP tools.',
|
||||
mcp_tools_schema_empty: 'No schema parameters.',
|
||||
mcp_tools_runtime_note: 'Tool inventory only uses already-known active MCP runtime data; the WebUI does not start or probe servers.',
|
||||
thinking: 'Думаю',
|
||||
expand_all: 'Развернуть всё',
|
||||
collapse_all: 'Свернуть всё',
|
||||
@@ -2953,6 +2977,14 @@ const LOCALES = {
|
||||
mcp_tool_count: '{0} tools',
|
||||
mcp_enabled_yes: 'Enabled',
|
||||
mcp_enabled_no: 'Disabled',
|
||||
mcp_tools_title: 'MCP Tools',
|
||||
mcp_tools_desc: 'Search known tools across active MCP servers.',
|
||||
mcp_tools_search_placeholder: 'Search tools by name, server, or description…',
|
||||
mcp_tools_no_tools: 'No MCP tools are available from the active runtime inventory.',
|
||||
mcp_tools_no_matches: 'No MCP tools match your search.',
|
||||
mcp_tools_load_failed: 'Failed to load MCP tools.',
|
||||
mcp_tools_schema_empty: 'No schema parameters.',
|
||||
mcp_tools_runtime_note: 'Tool inventory only uses already-known active MCP runtime data; the WebUI does not start or probe servers.',
|
||||
thinking: 'Pensando',
|
||||
expand_all: 'Expandir todo',
|
||||
collapse_all: 'Contraer todo',
|
||||
@@ -3869,6 +3901,14 @@ const LOCALES = {
|
||||
mcp_tool_count: '{0} tools',
|
||||
mcp_enabled_yes: 'Enabled',
|
||||
mcp_enabled_no: 'Disabled',
|
||||
mcp_tools_title: 'MCP Tools',
|
||||
mcp_tools_desc: 'Search known tools across active MCP servers.',
|
||||
mcp_tools_search_placeholder: 'Search tools by name, server, or description…',
|
||||
mcp_tools_no_tools: 'No MCP tools are available from the active runtime inventory.',
|
||||
mcp_tools_no_matches: 'No MCP tools match your search.',
|
||||
mcp_tools_load_failed: 'Failed to load MCP tools.',
|
||||
mcp_tools_schema_empty: 'No schema parameters.',
|
||||
mcp_tools_runtime_note: 'Tool inventory only uses already-known active MCP runtime data; the WebUI does not start or probe servers.',
|
||||
thinking: 'Nachdenken',
|
||||
expand_all: 'Alle ausklappen',
|
||||
collapse_all: 'Alle einklappen',
|
||||
@@ -4789,6 +4829,14 @@ const LOCALES = {
|
||||
mcp_tool_count: '{0} tools',
|
||||
mcp_enabled_yes: 'Enabled',
|
||||
mcp_enabled_no: 'Disabled',
|
||||
mcp_tools_title: 'MCP Tools',
|
||||
mcp_tools_desc: 'Search known tools across active MCP servers.',
|
||||
mcp_tools_search_placeholder: 'Search tools by name, server, or description…',
|
||||
mcp_tools_no_tools: 'No MCP tools are available from the active runtime inventory.',
|
||||
mcp_tools_no_matches: 'No MCP tools match your search.',
|
||||
mcp_tools_load_failed: 'Failed to load MCP tools.',
|
||||
mcp_tools_schema_empty: 'No schema parameters.',
|
||||
mcp_tools_runtime_note: 'Tool inventory only uses already-known active MCP runtime data; the WebUI does not start or probe servers.',
|
||||
thinking: '\u601d\u8003\u8fc7\u7a0b',
|
||||
expand_all: '\u5168\u90e8\u5c55\u5f00',
|
||||
collapse_all: '\u5168\u90e8\u6298\u53e0',
|
||||
@@ -5704,6 +5752,14 @@ const LOCALES = {
|
||||
mcp_tool_count: '{0} tools',
|
||||
mcp_enabled_yes: 'Enabled',
|
||||
mcp_enabled_no: 'Disabled',
|
||||
mcp_tools_title: 'MCP Tools',
|
||||
mcp_tools_desc: 'Search known tools across active MCP servers.',
|
||||
mcp_tools_search_placeholder: 'Search tools by name, server, or description…',
|
||||
mcp_tools_no_tools: 'No MCP tools are available from the active runtime inventory.',
|
||||
mcp_tools_no_matches: 'No MCP tools match your search.',
|
||||
mcp_tools_load_failed: 'Failed to load MCP tools.',
|
||||
mcp_tools_schema_empty: 'No schema parameters.',
|
||||
mcp_tools_runtime_note: 'Tool inventory only uses already-known active MCP runtime data; the WebUI does not start or probe servers.',
|
||||
thinking: '\u601d\u8003\u904e\u7a0b',
|
||||
expand_all: '\u5168\u90e8\u5c55\u958b',
|
||||
collapse_all: '\u5168\u90e8\u6298\u758a',
|
||||
@@ -7488,6 +7544,14 @@ const LOCALES = {
|
||||
mcp_tool_count: '{0} tools',
|
||||
mcp_enabled_yes: 'Enabled',
|
||||
mcp_enabled_no: 'Disabled',
|
||||
mcp_tools_title: 'MCP Tools',
|
||||
mcp_tools_desc: 'Search known tools across active MCP servers.',
|
||||
mcp_tools_search_placeholder: 'Search tools by name, server, or description…',
|
||||
mcp_tools_no_tools: 'No MCP tools are available from the active runtime inventory.',
|
||||
mcp_tools_no_matches: 'No MCP tools match your search.',
|
||||
mcp_tools_load_failed: 'Failed to load MCP tools.',
|
||||
mcp_tools_schema_empty: 'No schema parameters.',
|
||||
mcp_tools_runtime_note: 'Tool inventory only uses already-known active MCP runtime data; the WebUI does not start or probe servers.',
|
||||
thinking: '생각 중',
|
||||
expand_all: '모두 펼치기',
|
||||
collapse_all: '모두 접기',
|
||||
|
||||
@@ -1029,6 +1029,14 @@
|
||||
<div id="mcpServerList"></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>
|
||||
<!-- MCP Tools Section -->
|
||||
<div class="settings-field" style="margin-top:18px;padding-top:16px;border-top:1px solid var(--border)">
|
||||
<label data-i18n="mcp_tools_title">MCP Tools</label>
|
||||
<div style="font-size:11px;color:var(--muted);margin-bottom:8px" data-i18n="mcp_tools_desc">Search known tools across active MCP servers.</div>
|
||||
<input type="search" id="mcpToolSearch" class="mcp-tool-search" data-i18n-placeholder="mcp_tools_search_placeholder" placeholder="Search tools by name, server, or description…" oninput="filterMcpTools()" autocomplete="off">
|
||||
<div id="mcpToolList"></div>
|
||||
<div class="mcp-restart-hint" data-i18n="mcp_tools_runtime_note">Tool inventory only uses already-known active MCP runtime data; the WebUI does not start or probe servers.</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>
|
||||
</div>
|
||||
|
||||
+55
-1
@@ -5101,6 +5101,60 @@ function loadMcpServers(){
|
||||
}).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>`});
|
||||
}
|
||||
let _mcpToolsCache=[];
|
||||
function _filterMcpToolsForSearch(tools, query){
|
||||
const q=(query||'').trim().toLowerCase();
|
||||
if(!q) return Array.isArray(tools)?tools:[];
|
||||
return (Array.isArray(tools)?tools:[]).filter(tool=>{
|
||||
const hay=[tool.name,tool.server,tool.description].map(v=>String(v||'').toLowerCase()).join(' ');
|
||||
return hay.includes(q);
|
||||
});
|
||||
}
|
||||
function _mcpToolSchemaText(schemaSummary){
|
||||
if(!Array.isArray(schemaSummary)||!schemaSummary.length) return t('mcp_tools_schema_empty');
|
||||
return schemaSummary.map(p=>{
|
||||
const req=p.required?'*':'';
|
||||
const desc=p.description?` — ${p.description}`:'';
|
||||
return `${p.name}${req}: ${p.type||'unknown'}${desc}`;
|
||||
}).join('\n');
|
||||
}
|
||||
function _renderMcpTools(tools, query){
|
||||
const list=$('mcpToolList');
|
||||
if(!list) return;
|
||||
const filtered=_filterMcpToolsForSearch(tools, query);
|
||||
if(!filtered.length){
|
||||
const key=query?'mcp_tools_no_matches':'mcp_tools_no_tools';
|
||||
list.innerHTML=`<div class="mcp-tool-empty-state" style="color:var(--muted);font-size:12px;padding:6px 0">${esc(t(key))}</div>`;
|
||||
return;
|
||||
}
|
||||
list.innerHTML=filtered.map(tool=>{
|
||||
const status=tool.status||'unknown';
|
||||
const statusBadge=`<span class="mcp-status-badge mcp-status-${esc(status)}">${esc(_mcpStatusLabel(status))}</span>`;
|
||||
const schemaText=_mcpToolSchemaText(tool.schema_summary);
|
||||
return `<div class="mcp-tool-row">
|
||||
<div class="mcp-server-row-head">
|
||||
<span class="mcp-tool-name">${esc(tool.name)}</span>
|
||||
<span class="mcp-tool-server">${esc(tool.server||'unknown')}</span>
|
||||
${statusBadge}
|
||||
</div>
|
||||
<div class="mcp-server-detail">${esc(tool.description||'')}</div>
|
||||
<pre class="mcp-tool-schema">${esc(schemaText)}</pre>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
function filterMcpTools(){
|
||||
const input=$('mcpToolSearch');
|
||||
_renderMcpTools(_mcpToolsCache,input?input.value:'');
|
||||
}
|
||||
function loadMcpTools(){
|
||||
const list=$('mcpToolList');
|
||||
if(!list) return;
|
||||
list.innerHTML=`<div style="color:var(--muted);font-size:12px;padding:6px 0">${esc(t('loading'))}</div>`;
|
||||
api('/api/mcp/tools').then(r=>{
|
||||
_mcpToolsCache=(r&&Array.isArray(r.tools))?r.tools:[];
|
||||
filterMcpTools();
|
||||
}).catch(()=>{list.innerHTML=`<div class="mcp-tool-error-state" style="color:#ef4444;font-size:12px;padding:6px 0">${esc(t('mcp_tools_load_failed'))}</div>`});
|
||||
}
|
||||
function loadGatewayStatus(){
|
||||
const card=$('gatewayStatusCard');
|
||||
if(!card) return;
|
||||
@@ -5127,7 +5181,7 @@ function loadGatewayStatus(){
|
||||
const _origSwitchSettings=switchSettingsSection;
|
||||
switchSettingsSection=function(name){
|
||||
_origSwitchSettings(name);
|
||||
if(name==='system'){loadMcpServers();loadGatewayStatus();}
|
||||
if(name==='system'){loadMcpServers();loadMcpTools();loadGatewayStatus();}
|
||||
};
|
||||
|
||||
// ── Checkpoints / Rollback ──────────────────────────────────────────────────
|
||||
|
||||
@@ -2292,6 +2292,12 @@ main.main.showing-profiles > #mainProfiles{display:flex;}
|
||||
.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;}
|
||||
.mcp-tool-search{width:100%;margin:0 0 8px 0;padding:8px 10px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:8px;font-size:12px;outline:none;}
|
||||
.mcp-tool-search:focus{border-color:var(--accent);box-shadow:0 0 0 2px var(--accent-bg-soft);}
|
||||
.mcp-tool-row{display:flex;flex-direction:column;gap:5px;padding:9px 10px;border:1px solid var(--border);border-radius:8px;margin-bottom:6px;font-size:12px;background:var(--surface);}
|
||||
.mcp-tool-name{font-weight:600;color:var(--text);overflow-wrap:anywhere;}
|
||||
.mcp-tool-server{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.04em;color:var(--muted);background:var(--code-bg);border:1px solid var(--border2);border-radius:999px;padding:2px 6px;}
|
||||
.mcp-tool-schema{margin:2px 0 0 0;padding:7px 8px;white-space:pre-wrap;max-height:140px;overflow:auto;background:var(--code-bg);border:1px solid var(--border2);border-radius:6px;color:var(--muted);font-size:11px;line-height:1.45;}
|
||||
|
||||
/* Picker grids (theme / skin / font-size): make the card chrome use
|
||||
tokens so all skins flip correctly. */
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
"""Regression tests for issue #697 — searchable global MCP tool inventory."""
|
||||
import json
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from api.routes import (
|
||||
_handle_mcp_tools_list,
|
||||
_mcp_schema_summary,
|
||||
_mcp_tool_summary,
|
||||
)
|
||||
|
||||
|
||||
def _make_handler():
|
||||
h = MagicMock()
|
||||
h.path = "/api/mcp/tools"
|
||||
h.command = "GET"
|
||||
return h
|
||||
|
||||
|
||||
def _json_payload(handler):
|
||||
body = handler.wfile.write.call_args[0][0]
|
||||
return json.loads(body.decode("utf-8"))
|
||||
|
||||
|
||||
def _read(relative_path: str) -> str:
|
||||
from pathlib import Path
|
||||
|
||||
return (Path(__file__).resolve().parents[1] / relative_path).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
class TestMcpToolInventoryApi:
|
||||
@patch("api.routes._mcp_runtime_status_by_name")
|
||||
@patch("api.routes.get_config")
|
||||
def test_endpoint_returns_sanitized_registered_mcp_tools(self, mock_cfg, mock_runtime):
|
||||
mock_cfg.return_value = {
|
||||
"mcp_servers": {
|
||||
"web-reader": {"url": "http://localhost:3001/mcp", "headers": {"Authorization": "Bearer secret-token"}},
|
||||
"disabled": {"command": "disabled-cmd", "enabled": False},
|
||||
}
|
||||
}
|
||||
mock_runtime.return_value = {
|
||||
"web-reader": {
|
||||
"connected": True,
|
||||
"tools": [
|
||||
{
|
||||
"name": "mcp_web_reader_fetch_page",
|
||||
"description": "Fetch a page without leaking Authorization: Bearer secret-token",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"url": {"type": "string", "description": "URL to fetch", "default": "https://token.example/?key=secret-token"},
|
||||
"limit": {"type": "integer", "description": "Maximum bytes"},
|
||||
},
|
||||
"required": ["url"],
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
"disabled": {"connected": False, "tools": 0},
|
||||
}
|
||||
h = _make_handler()
|
||||
_handle_mcp_tools_list(h)
|
||||
payload = _json_payload(h)
|
||||
|
||||
assert payload["source"] == "mcp_runtime_status"
|
||||
assert payload["total"] == 1
|
||||
assert payload["tools"][0]["name"] == "mcp_web_reader_fetch_page"
|
||||
assert payload["tools"][0]["server"] == "web-reader"
|
||||
assert payload["tools"][0]["status"] == "active"
|
||||
assert payload["tools"][0]["active"] is True
|
||||
assert payload["tools"][0]["enabled"] is True
|
||||
assert payload["tools"][0]["schema_summary"] == [
|
||||
{"name": "url", "type": "string", "required": True, "description": "URL to fetch"},
|
||||
{"name": "limit", "type": "integer", "required": False, "description": "Maximum bytes"},
|
||||
]
|
||||
raw = json.dumps(payload)
|
||||
assert "secret-token" not in raw
|
||||
assert "default" not in raw
|
||||
assert "Authorization" not in raw
|
||||
|
||||
def test_schema_summary_uses_parameter_names_types_required_and_descriptions_only(self):
|
||||
schema = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {"type": "string", "description": "Search text", "examples": ["secret"]},
|
||||
"tags": {"type": "array", "items": {"type": "string"}, "description": "Tag filters"},
|
||||
},
|
||||
"required": ["query"],
|
||||
}
|
||||
assert _mcp_schema_summary(schema) == [
|
||||
{"name": "query", "type": "string", "required": True, "description": "Search text"},
|
||||
{"name": "tags", "type": "array", "required": False, "description": "Tag filters"},
|
||||
]
|
||||
|
||||
def test_tool_summary_rejects_non_dict_schema_and_redacts_description(self):
|
||||
summary = _mcp_tool_summary(
|
||||
"search",
|
||||
{"description": "use API_KEY=super-secret", "parameters": "not-a-dict"},
|
||||
{"name": "search", "status": "configured", "enabled": True, "active": False},
|
||||
)
|
||||
assert summary["description"] != "use API_KEY=super-secret"
|
||||
assert "super-secret" not in summary["description"]
|
||||
assert summary["schema_summary"] == []
|
||||
|
||||
|
||||
class TestMcpToolInventoryUi:
|
||||
def test_system_settings_contains_searchable_global_mcp_tool_section(self):
|
||||
html = _read("static/index.html")
|
||||
assert 'data-i18n="mcp_tools_title"' in html
|
||||
assert 'id="mcpToolSearch"' in html
|
||||
assert 'id="mcpToolList"' in html
|
||||
assert 'oninput="filterMcpTools()"' in html
|
||||
|
||||
def test_panels_js_loads_tools_and_filters_name_server_description(self):
|
||||
js = _read("static/panels.js")
|
||||
assert "function loadMcpTools" in js
|
||||
assert "api('/api/mcp/tools')" in js
|
||||
assert "function filterMcpTools" in js
|
||||
assert "_filterMcpToolsForSearch" in js
|
||||
assert "tool.name" in js
|
||||
assert "tool.server" in js
|
||||
assert "tool.description" in js
|
||||
assert "mcp-tool-empty-state" in js
|
||||
assert "mcp-tool-error-state" in js
|
||||
|
||||
def test_mcp_tool_i18n_keys_are_present(self):
|
||||
i18n = _read("static/i18n.js")
|
||||
for key in [
|
||||
"mcp_tools_title",
|
||||
"mcp_tools_desc",
|
||||
"mcp_tools_search_placeholder",
|
||||
"mcp_tools_no_tools",
|
||||
"mcp_tools_no_matches",
|
||||
"mcp_tools_load_failed",
|
||||
"mcp_tools_schema_empty",
|
||||
]:
|
||||
assert key in i18n
|
||||
Reference in New Issue
Block a user