diff --git a/api/routes.py b/api/routes.py index edef7ed5..63739194 100644 --- a/api/routes.py +++ b/api/routes.py @@ -6229,6 +6229,9 @@ def handle_patch(handler, parsed) -> bool: if not _check_csrf(handler): return j(handler, {"error": _csrf_rejection_error(handler)}, status=403) body = read_body(handler) + if parsed.path.startswith("/api/mcp/servers/"): + name = parsed.path[len("/api/mcp/servers/"):] + return _handle_mcp_server_toggle(handler, name, body) if parsed.path.startswith("/api/kanban/"): from api.kanban_bridge import handle_kanban_patch @@ -6244,6 +6247,9 @@ def handle_delete(handler, parsed) -> bool: if not _check_csrf(handler): return j(handler, {"error": _csrf_rejection_error(handler)}, status=403) body = read_body(handler) + if parsed.path.startswith("/api/mcp/servers/"): + name = parsed.path[len("/api/mcp/servers/"):] + return _handle_mcp_server_delete(handler, name) if parsed.path.startswith("/api/kanban/"): from api.kanban_bridge import handle_kanban_delete @@ -6253,6 +6259,17 @@ def handle_delete(handler, parsed) -> bool: return True return False + +def handle_put(handler, parsed) -> bool: + """Handle all PUT routes. Returns True if handled, False for 404.""" + if not _check_csrf(handler): + return j(handler, {"error": "Cross-origin request rejected"}, status=403) + body = read_body(handler) + if parsed.path.startswith("/api/mcp/servers/"): + name = parsed.path[len("/api/mcp/servers/"):] + return _handle_mcp_server_update(handler, name, body) + return False + # ── GET route helpers ───────────────────────────────────────────────────────── # MIME types for static file serving. Hoisted to module scope to avoid @@ -11888,7 +11905,7 @@ def _handle_mcp_servers_list(handler): ] return j(handler, { "servers": result, - "toggle_supported": False, + "toggle_supported": True, "reload_required": True, }) @@ -11912,6 +11929,30 @@ def _handle_mcp_server_delete(handler, name): return j(handler, {"ok": True, "deleted": name}) +def _handle_mcp_server_toggle(handler, name, body): + """Toggle enabled state for an MCP server (PATCH /api/mcp/servers/{name}).""" + from urllib.parse import unquote + name = unquote(name) + if not name: + return bad(handler, "name is required") + if "enabled" not in body: + return bad(handler, "enabled field is required") + enabled = bool(body["enabled"]) + cfg = get_config() + servers = cfg.get("mcp_servers", {}) + if not isinstance(servers, dict): + servers = {} + if name not in servers: + return bad(handler, f"MCP server '{name}' not found", 404) + if not isinstance(servers[name], dict): + return bad(handler, f"MCP server '{name}' has invalid config", 400) + servers[name]["enabled"] = enabled + cfg["mcp_servers"] = servers + _save_yaml_config_file(_get_config_path(), cfg) + reload_config() + return j(handler, {"ok": True, "name": name, "enabled": enabled}) + + _MASKED_PLACEHOLDER = "••••••" diff --git a/server.py b/server.py index 3888afc5..7405d073 100644 --- a/server.py +++ b/server.py @@ -115,7 +115,7 @@ from api.auth import check_auth from api.config import HOST, PORT, STATE_DIR, SESSION_DIR, DEFAULT_WORKSPACE from api.helpers import j, get_profile_cookie from api.profiles import set_request_profile, clear_request_profile -from api.routes import handle_delete, handle_get, handle_patch, handle_post +from api.routes import handle_delete, handle_get, handle_patch, handle_post, handle_put from api.startup import auto_install_agent_deps, fix_credential_permissions from api.updates import WEBUI_VERSION @@ -286,6 +286,9 @@ class Handler(BaseHTTPRequestHandler): def do_POST(self) -> None: self._handle_write(handle_post) + def do_PUT(self) -> None: + self._handle_write(handle_put) + def do_PATCH(self) -> None: self._handle_write(handle_patch) diff --git a/static/i18n.js b/static/i18n.js index 0f1f8867..5e54d9d6 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -87,6 +87,11 @@ const LOCALES = { mcp_tool_count: '{0} tools', mcp_enabled_yes: 'Enabled', mcp_enabled_no: 'Disabled', + mcp_enable_server: 'Enable this MCP server', + mcp_disable_server: 'Disable this MCP server', + mcp_enabled_toast: (name) => `MCP server "${name}" enabled.`, + mcp_disabled_toast: (name) => `MCP server "${name}" disabled.`, + mcp_toggle_failed: 'Failed to update MCP server.', 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…', @@ -1325,6 +1330,11 @@ const LOCALES = { mcp_tool_count: '{0} strumenti', mcp_enabled_yes: 'Abilitato', mcp_enabled_no: 'Disabilitato', + mcp_enable_server: 'Enable this MCP server', + mcp_disable_server: 'Disable this MCP server', + mcp_enabled_toast: (name) => `MCP server "${name}" enabled.`, + mcp_disabled_toast: (name) => `MCP server "${name}" disabled.`, + mcp_toggle_failed: 'Failed to update MCP server.', mcp_tools_title: 'Strumenti MCP', mcp_tools_desc: 'Cerca strumenti noti tra i server MCP attivi.', mcp_tools_search_placeholder: 'Cerca strumenti per nome, server o descrizione…', @@ -2555,6 +2565,11 @@ const LOCALES = { mcp_tool_count: '{0} 個のツール', mcp_enabled_yes: '有効', mcp_enabled_no: '無効', + mcp_enable_server: 'Enable this MCP server', + mcp_disable_server: 'Disable this MCP server', + mcp_enabled_toast: (name) => `MCP server "${name}" enabled.`, + mcp_disabled_toast: (name) => `MCP server "${name}" disabled.`, + mcp_toggle_failed: 'Failed to update MCP server.', mcp_tools_title: 'MCP ツール', mcp_tools_desc: 'アクティブな MCP サーバー全体から既知のツールを検索します。', mcp_tools_search_placeholder: '名前、サーバー、説明でツールを検索…', @@ -3787,6 +3802,11 @@ const LOCALES = { mcp_tool_count: '{0} tools', mcp_enabled_yes: 'Enabled', mcp_enabled_no: 'Disabled', + mcp_enable_server: 'Enable this MCP server', + mcp_disable_server: 'Disable this MCP server', + mcp_enabled_toast: (name) => `MCP server "${name}" enabled.`, + mcp_disabled_toast: (name) => `MCP server "${name}" disabled.`, + mcp_toggle_failed: 'Failed to update MCP server.', 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…', @@ -4951,6 +4971,11 @@ const LOCALES = { mcp_tool_count: '{0} tools', mcp_enabled_yes: 'Enabled', mcp_enabled_no: 'Disabled', + mcp_enable_server: 'Enable this MCP server', + mcp_disable_server: 'Disable this MCP server', + mcp_enabled_toast: (name) => `MCP server "${name}" enabled.`, + mcp_disabled_toast: (name) => `MCP server "${name}" disabled.`, + mcp_toggle_failed: 'Failed to update MCP server.', 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…', @@ -6118,6 +6143,11 @@ const LOCALES = { mcp_tool_count: '{0} tools', mcp_enabled_yes: 'Enabled', mcp_enabled_no: 'Disabled', + mcp_enable_server: 'Enable this MCP server', + mcp_disable_server: 'Disable this MCP server', + mcp_enabled_toast: (name) => `MCP server "${name}" enabled.`, + mcp_disabled_toast: (name) => `MCP server "${name}" disabled.`, + mcp_toggle_failed: 'Failed to update MCP server.', 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…', @@ -7289,6 +7319,11 @@ const LOCALES = { mcp_tool_count: '{0} 个工具', mcp_enabled_yes: '已启用', mcp_enabled_no: '已禁用', + mcp_enable_server: 'Enable this MCP server', + mcp_disable_server: 'Disable this MCP server', + mcp_enabled_toast: (name) => `MCP server "${name}" enabled.`, + mcp_disabled_toast: (name) => `MCP server "${name}" disabled.`, + mcp_toggle_failed: 'Failed to update MCP server.', mcp_tools_title: 'MCP 工具', mcp_tools_desc: '搜索活跃 MCP 服务器中的已知工具。', mcp_tools_search_placeholder: '按名称、服务器或描述搜索工具…', @@ -8447,6 +8482,11 @@ const LOCALES = { mcp_tool_count: '{0} tools', mcp_enabled_yes: 'Enabled', mcp_enabled_no: 'Disabled', + mcp_enable_server: 'Enable this MCP server', + mcp_disable_server: 'Disable this MCP server', + mcp_enabled_toast: (name) => `MCP server "${name}" enabled.`, + mcp_disabled_toast: (name) => `MCP server "${name}" disabled.`, + mcp_toggle_failed: 'Failed to update MCP server.', 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…', @@ -10754,6 +10794,11 @@ const LOCALES = { mcp_tool_count: '{0} tools', mcp_enabled_yes: 'Enabled', mcp_enabled_no: 'Disabled', + mcp_enable_server: 'Enable this MCP server', + mcp_disable_server: 'Disable this MCP server', + mcp_enabled_toast: (name) => `MCP server "${name}" enabled.`, + mcp_disabled_toast: (name) => `MCP server "${name}" disabled.`, + mcp_toggle_failed: 'Failed to update MCP server.', 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…', @@ -11981,6 +12026,11 @@ const LOCALES = { mcp_tool_count: '{0} outils', mcp_enabled_yes: 'Activé', mcp_enabled_no: 'Désactivé', + mcp_enable_server: 'Enable this MCP server', + mcp_disable_server: 'Disable this MCP server', + mcp_enabled_toast: (name) => `MCP server "${name}" enabled.`, + mcp_disabled_toast: (name) => `MCP server "${name}" disabled.`, + mcp_toggle_failed: 'Failed to update MCP server.', mcp_tools_title: 'Outils MCP', mcp_tools_desc: 'Recherchez des outils connus sur les serveurs MCP actifs.', mcp_tools_search_placeholder: 'Outils de recherche par nom, serveur ou description…', diff --git a/static/panels.js b/static/panels.js index fdeeb510..89ad0d20 100644 --- a/static/panels.js +++ b/static/panels.js @@ -7120,6 +7120,16 @@ function _mcpStatusLabel(status){ }[status]||'mcp_status_unknown'; return t(key); } +function toggleMcpServer(name, enabled){ + api('/api/mcp/servers/'+encodeURIComponent(name),{ + method:'PATCH', + body:JSON.stringify({enabled:enabled}), + }).then(r=>{ + if(r&&r.ok) showToast(t(enabled?'mcp_enabled_toast':'mcp_disabled_toast',name)); + else showToast(t('mcp_toggle_failed'),'error'); + loadMcpServers(); + }).catch(()=>{showToast(t('mcp_toggle_failed'),'error');loadMcpServers();}); +} function loadMcpServers(){ const list=$('mcpServerList'); if(!list) return; @@ -7130,7 +7140,6 @@ function loadMcpServers(){ list.innerHTML=`
${esc(t('mcp_no_servers'))}
`; return; } - const toggleNote=r.toggle_supported?'':'
'+esc(t('mcp_toggle_followup'))+'
'; list.innerHTML=r.servers.map(s=>{ 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'; @@ -7144,6 +7153,11 @@ function loadMcpServers(){ 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(' | '); + const isEnabled=s.enabled!==false; + const encodedName=encodeURIComponent(s.name).replace(/'/g,"\\'"); + const toggleBtn=r.toggle_supported + ?`` + :`${esc(t(isEnabled?'mcp_enabled_yes':'mcp_enabled_no'))}`; return `
${esc(s.name)} @@ -7151,9 +7165,9 @@ function loadMcpServers(){ ${statusBadge}
${esc(detail)}${secretInfo?' | '+esc(secretInfo):''}
-
${esc(t('mcp_tool_count',toolCount))}${esc(t(s.enabled===false?'mcp_enabled_no':'mcp_enabled_yes'))}
+
${esc(t('mcp_tool_count',toolCount))}${toggleBtn}
`; - }).join('')+toggleNote; + }).join(''); }).catch(()=>{list.innerHTML=`
${esc(t('mcp_load_failed'))}
`}); } let _mcpToolsCache=[]; diff --git a/static/style.css b/static/style.css index f9b6094f..5566545a 100644 --- a/static/style.css +++ b/static/style.css @@ -2956,6 +2956,10 @@ main.main.showing-logs > #mainLogs{display:flex;} .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-toggle-btn{font-size:10px;font-weight:600;padding:2px 8px;border-radius:999px;border:1px solid transparent;cursor:pointer;transition:opacity .15s;} +.mcp-toggle-btn:hover{opacity:.8;} +.mcp-toggle-enabled{background:rgba(34,197,94,.15);color:#4ade80;border-color:rgba(34,197,94,.3);} +.mcp-toggle-disabled{background:rgba(161,161,170,.12);color:#a1a1aa;border-color:rgba(161,161,170,.25);} .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);} diff --git a/tests/test_issue538_mcp_management.py b/tests/test_issue538_mcp_management.py index 758eff1a..77c3ea72 100644 --- a/tests/test_issue538_mcp_management.py +++ b/tests/test_issue538_mcp_management.py @@ -5,6 +5,7 @@ from api.routes import ( _handle_mcp_servers_list, _handle_mcp_server_update, _handle_mcp_server_delete, + _handle_mcp_server_toggle, _mask_secrets, _parse_mcp_enabled, _server_summary, @@ -60,7 +61,7 @@ class TestMcpList: assert status == 200 payload = _json_payload(h) assert payload['servers'] == [] - assert payload['toggle_supported'] is False + assert payload['toggle_supported'] is True assert payload['reload_required'] is True @patch('api.routes._mcp_runtime_status_by_name') @@ -307,3 +308,83 @@ class TestStripMaskedValues: def test_empty_dicts(self): assert _strip_masked_values({}, {}) == {} assert _strip_masked_values({"k": "v"}, {}) == {"k": "v"} + + +class TestMcpToggle: + """PATCH /api/mcp/servers/ — enable/disable.""" + + @patch('api.routes.reload_config') + @patch('api.routes._save_yaml_config_file') + @patch('api.routes._get_config_path', return_value='/tmp/test.yaml') + @patch('api.routes.get_config') + def test_disable_server(self, mock_cfg, mock_path, mock_save, mock_reload): + mock_cfg.return_value = {'mcp_servers': {'myserver': {'command': 'run'}}} + h = _make_handler() + h.command = 'PATCH' + _handle_mcp_server_toggle(h, 'myserver', {'enabled': False}) + assert mock_save.called + saved = mock_save.call_args[0][1] + assert saved['mcp_servers']['myserver']['enabled'] is False + assert mock_reload.called + + @patch('api.routes.reload_config') + @patch('api.routes._save_yaml_config_file') + @patch('api.routes._get_config_path', return_value='/tmp/test.yaml') + @patch('api.routes.get_config') + def test_enable_server(self, mock_cfg, mock_path, mock_save, mock_reload): + mock_cfg.return_value = {'mcp_servers': {'myserver': {'command': 'run', 'enabled': False}}} + h = _make_handler() + h.command = 'PATCH' + _handle_mcp_server_toggle(h, 'myserver', {'enabled': True}) + saved = mock_save.call_args[0][1] + assert saved['mcp_servers']['myserver']['enabled'] is True + + @patch('api.routes.get_config') + def test_nonexistent_server_returns_404(self, mock_cfg): + mock_cfg.return_value = {'mcp_servers': {}} + h = _make_handler() + h.command = 'PATCH' + _handle_mcp_server_toggle(h, 'ghost', {'enabled': True}) + status = h.send_response.call_args[0][0] + assert status == 404 + + def test_empty_name_rejected(self): + h = _make_handler() + h.command = 'PATCH' + _handle_mcp_server_toggle(h, '', {'enabled': True}) + status = h.send_response.call_args[0][0] + assert status == 400 + + def test_missing_enabled_field_rejected(self): + h = _make_handler() + h.command = 'PATCH' + _handle_mcp_server_toggle(h, 'myserver', {}) + status = h.send_response.call_args[0][0] + assert status == 400 + + @patch('api.routes.reload_config') + @patch('api.routes._save_yaml_config_file') + @patch('api.routes._get_config_path', return_value='/tmp/test.yaml') + @patch('api.routes.get_config') + def test_response_payload(self, mock_cfg, mock_path, mock_save, mock_reload): + mock_cfg.return_value = {'mcp_servers': {'srv': {'url': 'http://localhost'}}} + h = _make_handler() + h.command = 'PATCH' + _handle_mcp_server_toggle(h, 'srv', {'enabled': False}) + body = h.wfile.write.call_args[0][0] + payload = json.loads(body.decode('utf-8')) + assert payload == {'ok': True, 'name': 'srv', 'enabled': False} + + @patch('api.routes.reload_config') + @patch('api.routes._save_yaml_config_file') + @patch('api.routes._get_config_path', return_value='/tmp/test.yaml') + @patch('api.routes.get_config') + def test_url_encoded_name(self, mock_cfg, mock_path, mock_save, mock_reload): + """Names with special characters must be URL-decoded.""" + mock_cfg.return_value = {'mcp_servers': {'my server': {'command': 'x'}}} + h = _make_handler() + h.command = 'PATCH' + _handle_mcp_server_toggle(h, 'my%20server', {'enabled': False}) + saved = mock_save.call_args[0][1] + assert 'my server' in saved['mcp_servers'] + assert saved['mcp_servers']['my server']['enabled'] is False diff --git a/tests/test_issue696_mcp_visibility_panel.py b/tests/test_issue696_mcp_visibility_panel.py index 999192e5..3fa1cf43 100644 --- a/tests/test_issue696_mcp_visibility_panel.py +++ b/tests/test_issue696_mcp_visibility_panel.py @@ -24,7 +24,8 @@ def test_mcp_panel_renders_status_badges_tool_counts_and_empty_error_states(): 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 "toggleMcpServer" in js + assert "mcp-toggle-btn" in js assert "api('/api/mcp/servers')" in js assert "mcp-delete-btn" not in js assert "showMcpAddForm" not in js