diff --git a/api/routes.py b/api/routes.py index 9cbb7b16..8cb81380 100644 --- a/api/routes.py +++ b/api/routes.py @@ -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() diff --git a/docs/pr-media/697/mcp-tools-search-filter.png b/docs/pr-media/697/mcp-tools-search-filter.png new file mode 100644 index 00000000..3d681893 Binary files /dev/null and b/docs/pr-media/697/mcp-tools-search-filter.png differ diff --git a/static/i18n.js b/static/i18n.js index b8241dc8..20dac616 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -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: '모두 접기', diff --git a/static/index.html b/static/index.html index 64eb114a..8c217a4e 100644 --- a/static/index.html +++ b/static/index.html @@ -1029,6 +1029,14 @@
Server changes are read-only here for now. Edit config.yaml and restart Hermes for changes to take effect.
+ +
+ +
Search known tools across active MCP servers.
+ +
+
Tool inventory only uses already-known active MCP runtime data; the WebUI does not start or probe servers.
+
diff --git a/static/panels.js b/static/panels.js index d5974d1b..d3c45a1d 100644 --- a/static/panels.js +++ b/static/panels.js @@ -5101,6 +5101,60 @@ function loadMcpServers(){ }).join('')+toggleNote; }).catch(()=>{list.innerHTML=`
${esc(t('mcp_load_failed'))}
`}); } +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=`
${esc(t(key))}
`; + return; + } + list.innerHTML=filtered.map(tool=>{ + const status=tool.status||'unknown'; + const statusBadge=`${esc(_mcpStatusLabel(status))}`; + const schemaText=_mcpToolSchemaText(tool.schema_summary); + return `
+
+ ${esc(tool.name)} + ${esc(tool.server||'unknown')} + ${statusBadge} +
+
${esc(tool.description||'')}
+
${esc(schemaText)}
+
`; + }).join(''); +} +function filterMcpTools(){ + const input=$('mcpToolSearch'); + _renderMcpTools(_mcpToolsCache,input?input.value:''); +} +function loadMcpTools(){ + const list=$('mcpToolList'); + if(!list) return; + list.innerHTML=`
${esc(t('loading'))}
`; + api('/api/mcp/tools').then(r=>{ + _mcpToolsCache=(r&&Array.isArray(r.tools))?r.tools:[]; + filterMcpTools(); + }).catch(()=>{list.innerHTML=`
${esc(t('mcp_tools_load_failed'))}
`}); +} 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 ────────────────────────────────────────────────── diff --git a/static/style.css b/static/style.css index 4e687c91..0233c165 100644 --- a/static/style.css +++ b/static/style.css @@ -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. */ diff --git a/tests/test_issue697_mcp_tool_inventory.py b/tests/test_issue697_mcp_tool_inventory.py new file mode 100644 index 00000000..4dfd4ba1 --- /dev/null +++ b/tests/test_issue697_mcp_tool_inventory.py @@ -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