From 882eee99116ef5c99ab69e0c6fd447e67b8d502a Mon Sep 17 00:00:00 2001 From: Fabian Sauter Date: Thu, 20 Aug 2026 10:54:02 +0200 Subject: [PATCH 1/5] Allow specifying MCP OAuth 2.0 trusted pivate hosts --- tests/test_app_js.py | 20 ++++- tests/test_mcp_admin_api.py | 77 ++++++++++++++++ tests/test_mcp_oauth_discovery.py | 92 ++++++++++++++++++++ tests/test_mcp_private_hosts.py | 62 +++++++++++++ turnstone/console/server.py | 101 ++++++++++++++++++++- turnstone/console/static/admin.js | 129 +++++++++++++++++++++++++++ turnstone/console/static/index.html | 50 +++++++++++ turnstone/console/static/style.css | 89 +++++++++++++++++++ turnstone/core/mcp_oauth.py | 80 +++++++++++++++-- turnstone/core/mcp_private_hosts.py | 130 ++++++++++++++++++++++++++++ turnstone/core/settings_registry.py | 18 ++++ 11 files changed, 837 insertions(+), 11 deletions(-) create mode 100644 tests/test_mcp_private_hosts.py create mode 100644 turnstone/core/mcp_private_hosts.py diff --git a/tests/test_app_js.py b/tests/test_app_js.py index 9b725c83a..390a5a848 100644 --- a/tests/test_app_js.py +++ b/tests/test_app_js.py @@ -29,6 +29,25 @@ ) _CONSOLE_APP_JS = Path(__file__).resolve().parent.parent / "turnstone/console/static/app.js" _CONSOLE_INDEX = Path(__file__).resolve().parent.parent / "turnstone/console/static/index.html" +_CONSOLE_ADMIN_JS = Path(__file__).resolve().parent.parent / "turnstone/console/static/admin.js" + + +def test_mcp_private_hosts_ui_explains_sources_and_uses_safe_dom_rendering() -> None: + html = _CONSOLE_INDEX.read_text(encoding="utf-8") + script = _CONSOLE_ADMIN_JS.read_text(encoding="utf-8") + + assert 'id="mcp-private-host-form"' in html + assert 'id="mcp-private-host-list"' in html + assert "TURNSTONE_MCP_OAUTH_TRUSTED_PRIVATE_HOSTS" in html + assert "operator-controlled" in html + assert "/v1/api/admin/mcp-servers/trusted-private-hosts" in script + assert 'entry.source === "environment"' in script + assert "if (!entry.readonly)" in script + renderer_start = script.index("function _renderMcpTrustedPrivateHosts()") + renderer_end = script.index("function _setMcpPrivateHostStatus", renderer_start) + renderer = script[renderer_start:renderer_end] + assert ".textContent = entry.host" in renderer + assert "innerHTML" not in renderer def _pane_method_offset(body: str, name: str) -> int: @@ -752,7 +771,6 @@ def test_phase8_appendtooloutput_dispatches_mcp_error_before_renderer() -> None: _COORD_JS = ( Path(__file__).resolve().parent.parent / "turnstone/console/static/coordinator/coordinator.js" ) -_CONSOLE_ADMIN_JS = Path(__file__).resolve().parent.parent / "turnstone/console/static/admin.js" _CONSOLE_GOVERNANCE_JS = ( Path(__file__).resolve().parent.parent / "turnstone/console/static/governance.js" ) diff --git a/tests/test_mcp_admin_api.py b/tests/test_mcp_admin_api.py index e369dfe73..c81c1078b 100644 --- a/tests/test_mcp_admin_api.py +++ b/tests/test_mcp_admin_api.py @@ -33,14 +33,17 @@ admin_create_mcp_server, admin_delete_mcp_server, admin_get_mcp_server, + admin_get_mcp_trusted_private_hosts, admin_import_mcp_config, admin_list_mcp_servers, admin_mcp_reconnect_one, admin_mcp_refresh_one, admin_mcp_reload, admin_update_mcp_server, + admin_update_mcp_trusted_private_hosts, ) from turnstone.core.auth import AuthResult +from turnstone.core.config_store import ConfigStore from turnstone.core.storage._sqlite import SQLiteBackend # --------------------------------------------------------------------------- @@ -126,6 +129,15 @@ async def dispatch(self, request: Request, call_next: Any) -> Response: admin_mcp_reload, methods=["POST"], ), + Route( + "/api/admin/mcp-servers/trusted-private-hosts", + admin_get_mcp_trusted_private_hosts, + ), + Route( + "/api/admin/mcp-servers/trusted-private-hosts", + admin_update_mcp_trusted_private_hosts, + methods=["PUT"], + ), Route( "/api/admin/mcp-servers/{name}/refresh", admin_mcp_refresh_one, @@ -240,6 +252,7 @@ def client(storage): middleware=[Middleware(_InjectAuthMiddleware)], ) app.state.auth_storage = storage + app.state.config_store = ConfigStore(storage) _install_token_store(app, storage) # Default: OIDC enabled under the entra profile so oauth_obo writes pass the # requirement gate. Per-test overrides install rfc8693 / disabled / bad @@ -256,6 +269,7 @@ def client_no_perm(storage): middleware=[Middleware(_InjectAuthNoMcpMiddleware)], ) app.state.auth_storage = storage + app.state.config_store = ConfigStore(storage) _install_token_store(app, storage) return TestClient(app) @@ -335,6 +349,69 @@ def test_list_returns_created_servers(self, client): assert "server-b" in names +class TestTrustedPrivateHosts: + endpoint = "/v1/api/admin/mcp-servers/trusted-private-hosts" + + def test_get_merges_environment_and_manual_sources( + self, client: TestClient, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv( + "TURNSTONE_MCP_OAUTH_TRUSTED_PRIVATE_HOSTS", + "gitlab.internal.example", + ) + saved = client.put(self.endpoint, json={"hosts": ["manual.internal.example"]}) + assert saved.status_code == 200 + + response = client.get(self.endpoint) + assert response.status_code == 200 + assert response.json()["hosts"] == [ + { + "host": "gitlab.internal.example", + "source": "environment", + "readonly": True, + }, + { + "host": "manual.internal.example", + "source": "manual", + "readonly": False, + }, + ] + + def test_put_cannot_replace_or_duplicate_environment_hosts( + self, client: TestClient, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv( + "TURNSTONE_MCP_OAUTH_TRUSTED_PRIVATE_HOSTS", + "gitlab.internal.example", + ) + response = client.put( + self.endpoint, + json={"hosts": ["gitlab.internal.example", "user.internal.example"]}, + ) + assert response.status_code == 200 + assert response.json()["hosts"] == [ + { + "host": "gitlab.internal.example", + "source": "environment", + "readonly": True, + }, + { + "host": "user.internal.example", + "source": "manual", + "readonly": False, + }, + ] + + def test_put_rejects_urls_and_wildcards(self, client: TestClient) -> None: + for invalid in ("https://gitlab.internal", "*.internal"): + response = client.put(self.endpoint, json={"hosts": [invalid]}) + assert response.status_code == 400 + + def test_requires_admin_mcp_permission(self, client_no_perm: TestClient) -> None: + assert client_no_perm.get(self.endpoint).status_code == 403 + assert client_no_perm.put(self.endpoint, json={"hosts": []}).status_code == 403 + + # --------------------------------------------------------------------------- # Create # --------------------------------------------------------------------------- diff --git a/tests/test_mcp_oauth_discovery.py b/tests/test_mcp_oauth_discovery.py index 1abe073cf..cd8a1e195 100644 --- a/tests/test_mcp_oauth_discovery.py +++ b/tests/test_mcp_oauth_discovery.py @@ -68,6 +68,20 @@ def _public_addr_patch(): return patch("socket.getaddrinfo", return_value=[(2, 1, 6, "", ("93.184.216.34", 0))]) +def _private_addr_patch(): + return patch("socket.getaddrinfo", return_value=[(2, 1, 6, "", ("192.168.5.120", 0))]) + + +def _private_as_metadata_doc() -> dict[str, Any]: + return { + "issuer": "https://gitlab.internal.example", + "authorization_endpoint": "https://gitlab.internal.example/oauth/authorize", + "token_endpoint": "https://gitlab.internal.example/oauth/token", + "code_challenge_methods_supported": ["S256"], + "token_endpoint_auth_methods_supported": ["none"], + } + + def _mk_storage_mock(server_id: str = "srv-id") -> MagicMock: storage = MagicMock() storage.update_mcp_server.return_value = True @@ -737,3 +751,81 @@ async def _run() -> ASMetadata: with pytest.raises(MCPOAuthDiscoveryError, match="revocation_endpoint"): asyncio.run(_run()) + + +class TestTrustedPrivateHosts: + def test_exact_trusted_private_resource_host_allows_prm_discovery(self) -> None: + async def _get(url: str, *args: Any, **kwargs: Any) -> MagicMock: + if url.endswith("/.well-known/oauth-protected-resource"): + return _mk_response( + 200, + {"authorization_servers": ["https://gitlab.internal.example"]}, + ) + if url.endswith("/.well-known/oauth-authorization-server"): + return _mk_response(200, _private_as_metadata_doc()) + raise AssertionError(f"unexpected URL: {url}") + + client = MagicMock(spec=httpx.AsyncClient) + client.get = AsyncMock(side_effect=_get) + storage = _mk_storage_mock() + + async def _run() -> ASMetadata: + with _private_addr_patch(): + return await discover_authorization_server( + server_name="private-gitlab", + server_url="https://gitlab.internal.example/mcp", + override_url=None, + cached_issuer=None, + http_client=client, + storage=storage, + server_id="srv-id", + trusted_hosts=frozenset(), + trusted_private_hosts=frozenset({"gitlab.internal.example"}), + ) + + metadata = asyncio.run(_run()) + assert metadata.issuer == "https://gitlab.internal.example" + + def test_exact_trusted_private_host_allows_discovery(self) -> None: + client = MagicMock(spec=httpx.AsyncClient) + client.get = AsyncMock(return_value=_mk_response(200, _private_as_metadata_doc())) + storage = _mk_storage_mock() + + async def _run() -> ASMetadata: + with _private_addr_patch(): + return await discover_authorization_server( + server_name="private-gitlab", + server_url="https://gitlab.internal.example/mcp", + override_url="https://gitlab.internal.example", + cached_issuer=None, + http_client=client, + storage=storage, + server_id="srv-id", + trusted_hosts=frozenset(), + trusted_private_hosts=frozenset({"gitlab.internal.example"}), + ) + + metadata = asyncio.run(_run()) + assert metadata.token_endpoint == "https://gitlab.internal.example/oauth/token" + + def test_unlisted_private_host_remains_rejected(self) -> None: + client = MagicMock(spec=httpx.AsyncClient) + client.get = AsyncMock(return_value=_mk_response(200, _private_as_metadata_doc())) + storage = _mk_storage_mock() + + async def _run() -> None: + with _private_addr_patch(): + await discover_authorization_server( + server_name="private-gitlab", + server_url="https://gitlab.internal.example/mcp", + override_url="https://gitlab.internal.example", + cached_issuer=None, + http_client=client, + storage=storage, + server_id="srv-id", + trusted_hosts=frozenset(), + trusted_private_hosts=frozenset({"other.internal.example"}), + ) + + with pytest.raises(MCPOAuthDiscoveryError, match="non-public address"): + asyncio.run(_run()) diff --git a/tests/test_mcp_private_hosts.py b/tests/test_mcp_private_hosts.py new file mode 100644 index 000000000..9c3a4cebe --- /dev/null +++ b/tests/test_mcp_private_hosts.py @@ -0,0 +1,62 @@ +"""Trusted private-host configuration for MCP OAuth discovery.""" + +from __future__ import annotations + +import pytest + +from turnstone.core.mcp_private_hosts import ( + MAX_TRUSTED_PRIVATE_HOSTS, + merge_trusted_private_hosts, + normalize_trusted_private_host, + parse_trusted_private_hosts, +) + + +@pytest.mark.parametrize( + "raw", + [ + "https://gitlab.internal.example", + "gitlab.internal.example/path", + "*.internal.example", + "user@gitlab.internal.example", + "gitlab.internal.example:443", + "", + ], +) +def test_normalize_rejects_everything_except_an_exact_host(raw: str) -> None: + with pytest.raises(ValueError): + normalize_trusted_private_host(raw) + + +def test_normalize_is_case_insensitive_and_removes_dns_root_dot() -> None: + assert normalize_trusted_private_host("GitLab.Internal.Example.") == ("gitlab.internal.example") + + +def test_parse_accepts_comma_and_newline_separated_hosts() -> None: + assert parse_trusted_private_hosts("one.internal, TWO.internal.\n10.20.30.40") == ( + "one.internal", + "two.internal", + "10.20.30.40", + ) + + +def test_merge_marks_environment_entries_readonly_and_wins_duplicates() -> None: + merged = merge_trusted_private_hosts( + environment_value="gitlab.internal.example,env.internal", + manual_value="manual.internal\ngitlab.internal.example", + ) + + assert merged == [ + {"host": "gitlab.internal.example", "source": "environment", "readonly": True}, + {"host": "env.internal", "source": "environment", "readonly": True}, + {"host": "manual.internal", "source": "manual", "readonly": False}, + ] + + +def test_merge_caps_the_combined_environment_and_manual_list() -> None: + environment = ",".join(f"env-{i}.internal" for i in range(MAX_TRUSTED_PRIVATE_HOSTS)) + with pytest.raises(ValueError, match="in total"): + merge_trusted_private_hosts( + environment_value=environment, + manual_value="one-more.internal", + ) diff --git a/turnstone/console/server.py b/turnstone/console/server.py index 8c5995188..0a6acd7b2 100644 --- a/turnstone/console/server.py +++ b/turnstone/console/server.py @@ -10954,6 +10954,94 @@ async def admin_list_mcp_servers(request: Request) -> JSONResponse: return JSONResponse({"servers": result}) +def _trusted_private_hosts_response(config_store: Any) -> dict[str, Any]: + from turnstone.core.mcp_private_hosts import ( + MCP_TRUSTED_PRIVATE_HOSTS_ENV, + configured_trusted_private_hosts, + ) + + return { + "hosts": configured_trusted_private_hosts(config_store), + "environment_variable": MCP_TRUSTED_PRIVATE_HOSTS_ENV, + "help": ( + "Add only exact hostnames or IP addresses for operator-controlled MCP OAuth " + "services that deliberately resolve to a private network. This exception " + "allows private addresses for those hosts only; HTTPS, same-origin, port, " + "userinfo, and dangerous-address protections remain enabled." + ), + } + + +async def admin_get_mcp_trusted_private_hosts(request: Request) -> JSONResponse: + """GET the merged environment and user-managed private OAuth hosts.""" + from turnstone.core.auth import require_permission + + err = require_permission(request, "admin.mcp") + if err: + return err + config_store = getattr(request.app.state, "config_store", None) + if config_store is None: + return JSONResponse({"error": "Configuration store unavailable"}, status_code=503) + try: + return JSONResponse(_trusted_private_hosts_response(config_store)) + except ValueError as exc: + return JSONResponse({"error": str(exc)}, status_code=500) + + +async def admin_update_mcp_trusted_private_hosts(request: Request) -> JSONResponse: + """PUT user-managed private OAuth hosts without mutating environment entries.""" + from turnstone.core.auth import require_permission + from turnstone.core.mcp_private_hosts import ( + MAX_TRUSTED_PRIVATE_HOSTS, + MCP_TRUSTED_PRIVATE_HOSTS_ENV, + MCP_TRUSTED_PRIVATE_HOSTS_SETTING, + merge_trusted_private_hosts, + normalize_trusted_private_host, + parse_trusted_private_hosts, + ) + from turnstone.core.web_helpers import read_json_or_400 + + err = require_permission(request, "admin.mcp") + if err: + return err + config_store = getattr(request.app.state, "config_store", None) + if config_store is None: + return JSONResponse({"error": "Configuration store unavailable"}, status_code=503) + body = await read_json_or_400(request) + if isinstance(body, JSONResponse): + return body + raw_hosts = body.get("hosts") + if not isinstance(raw_hosts, list) or any(not isinstance(host, str) for host in raw_hosts): + return JSONResponse({"error": "hosts must be a list of strings"}, status_code=400) + if len(raw_hosts) > MAX_TRUSTED_PRIVATE_HOSTS: + return JSONResponse( + {"error": f"at most {MAX_TRUSTED_PRIVATE_HOSTS} hosts are allowed"}, + status_code=400, + ) + try: + environment_hosts = set( + parse_trusted_private_hosts(os.environ.get(MCP_TRUSTED_PRIVATE_HOSTS_ENV)) + ) + manual_hosts = list(dict.fromkeys(normalize_trusted_private_host(h) for h in raw_hosts)) + except ValueError as exc: + return JSONResponse({"error": str(exc)}, status_code=400) + manual_hosts = [host for host in manual_hosts if host not in environment_hosts] + try: + merge_trusted_private_hosts( + environment_value=os.environ.get(MCP_TRUSTED_PRIVATE_HOSTS_ENV), + manual_value="\n".join(manual_hosts), + ) + except ValueError as exc: + return JSONResponse({"error": str(exc)}, status_code=400) + config_store.set( + MCP_TRUSTED_PRIVATE_HOSTS_SETTING, + "\n".join(manual_hosts), + changed_by=_auth_user_id(request), + ) + await _publish_config_change(request) + return JSONResponse(_trusted_private_hosts_response(config_store)) + + async def admin_create_mcp_server(request: Request) -> JSONResponse: """POST /v1/api/admin/mcp-servers — create an MCP server definition.""" import uuid @@ -15835,7 +15923,9 @@ def _seed_config_from_env(config_store: Any, storage: Any) -> None: """ from turnstone.core.settings_registry import SETTINGS - for key in SETTINGS: + for key, defn in SETTINGS.items(): + if not defn.seed_from_env: + continue env_name = "TURNSTONE_" + key.replace(".", "_").upper() env_val = os.environ.get(env_name) if env_val is None: @@ -16426,6 +16516,15 @@ def _coord_attachment_owner( admin_mcp_reload, methods=["POST"], ), + Route( + "/api/admin/mcp-servers/trusted-private-hosts", + admin_get_mcp_trusted_private_hosts, + ), + Route( + "/api/admin/mcp-servers/trusted-private-hosts", + admin_update_mcp_trusted_private_hosts, + methods=["PUT"], + ), Route( "/api/admin/mcp-servers/{name}/refresh", admin_mcp_refresh_one, diff --git a/turnstone/console/static/admin.js b/turnstone/console/static/admin.js index 80fb60198..47d2632bc 100644 --- a/turnstone/console/static/admin.js +++ b/turnstone/console/static/admin.js @@ -4696,8 +4696,11 @@ let _mcpCurrentView = "servers"; let _registryResults = []; let _registryCursor = null; let _registryQuery = ""; +let _mcpTrustedPrivateHosts = []; +let _mcpPrivateHostsWired = false; function loadAdminMcp() { + loadMcpTrustedPrivateHosts(); authFetch("/v1/api/admin/mcp-servers") .then(function (r) { if (!r.ok) throw new Error("Failed"); @@ -4724,6 +4727,132 @@ function loadAdminMcp() { }); } +function loadMcpTrustedPrivateHosts() { + _wireMcpPrivateHosts(); + authFetch("/v1/api/admin/mcp-servers/trusted-private-hosts") + .then(function (r) { + if (!r.ok) { + return r.json().then(function (data) { + throw new Error(data.error || "Failed to load trusted private hosts"); + }); + } + return r.json(); + }) + .then(function (data) { + _mcpTrustedPrivateHosts = data.hosts || []; + _renderMcpTrustedPrivateHosts(); + _setMcpPrivateHostStatus(""); + }) + .catch(function (error) { + _setMcpPrivateHostStatus(error.message || "Failed to load trusted private hosts", true); + }); +} + +function _wireMcpPrivateHosts() { + if (_mcpPrivateHostsWired) return; + const form = document.getElementById("mcp-private-host-form"); + const input = document.getElementById("mcp-private-host-input"); + if (!form || !input) return; + _mcpPrivateHostsWired = true; + form.addEventListener("submit", function (event) { + event.preventDefault(); + const host = input.value.trim(); + if (!host) return; + const manual = _mcpTrustedPrivateHosts + .filter(function (entry) { + return entry.source === "manual"; + }) + .map(function (entry) { + return entry.host; + }); + manual.push(host); + _saveMcpTrustedPrivateHosts(manual, function () { + input.value = ""; + input.focus(); + }); + }); +} + +function _saveMcpTrustedPrivateHosts(manualHosts, onSuccess) { + _setMcpPrivateHostStatus("Saving..."); + authFetch("/v1/api/admin/mcp-servers/trusted-private-hosts", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ hosts: manualHosts }), + }) + .then(function (r) { + return r.json().then(function (data) { + if (!r.ok) throw new Error(data.error || "Failed to save trusted private hosts"); + return data; + }); + }) + .then(function (data) { + _mcpTrustedPrivateHosts = data.hosts || []; + _renderMcpTrustedPrivateHosts(); + _setMcpPrivateHostStatus("Trusted private hosts saved."); + if (onSuccess) onSuccess(); + }) + .catch(function (error) { + _setMcpPrivateHostStatus(error.message || "Failed to save trusted private hosts", true); + }); +} + +function _renderMcpTrustedPrivateHosts() { + const list = document.getElementById("mcp-private-host-list"); + if (!list) return; + list.replaceChildren(); + if (!_mcpTrustedPrivateHosts.length) { + const empty = document.createElement("span"); + empty.className = "dashboard-empty"; + empty.textContent = "No private hosts are trusted."; + list.appendChild(empty); + return; + } + _mcpTrustedPrivateHosts.forEach(function (entry) { + const row = document.createElement("div"); + row.className = "mcp-private-host-row"; + row.setAttribute("role", "listitem"); + + const host = document.createElement("code"); + host.className = "mcp-private-host-name"; + host.textContent = entry.host; + row.appendChild(host); + + const source = document.createElement("span"); + source.className = "scope-badge " + + (entry.source === "environment" ? "mcp-host-source-environment" : "mcp-host-source-manual"); + source.textContent = entry.source === "environment" ? "environment" : "manual"; + row.appendChild(source); + + if (!entry.readonly) { + const remove = document.createElement("button"); + remove.type = "button"; + remove.className = "admin-action-btn admin-action-btn-ghost mcp-private-host-remove"; + remove.textContent = "Remove"; + remove.setAttribute("aria-label", "Remove trusted private host " + entry.host); + remove.addEventListener("click", function () { + const remaining = _mcpTrustedPrivateHosts + .filter(function (candidate) { + return candidate.source === "manual" && candidate.host !== entry.host; + }) + .map(function (candidate) { + return candidate.host; + }); + _saveMcpTrustedPrivateHosts(remaining); + }); + row.appendChild(remove); + } + list.appendChild(row); + }); +} + +function _setMcpPrivateHostStatus(message, isError) { + const status = document.getElementById("mcp-private-host-status"); + if (!status) return; + status.textContent = message; + status.classList.toggle("is-error", Boolean(isError)); +} + function _wireMcpTokenDropButtons(el, attr, opts) { // Shared binder for the two per-server token-drop list actions — // "Bulk-revoke" (oauth_user consents) and "Flush cache" (oauth_obo minted diff --git a/turnstone/console/static/index.html b/turnstone/console/static/index.html index 93efca38e..3967ddd95 100644 --- a/turnstone/console/static/index.html +++ b/turnstone/console/static/index.html @@ -1246,6 +1246,56 @@

+
+
+

+ Trusted private OAuth hosts +

+

+ Allow OAuth discovery for operator-controlled MCP services + that resolve to private network addresses. Add only exact + hostnames or IP addresses you control. HTTPS, same-origin, + port, userinfo, and dangerous-address protections remain + enabled. Entries loaded from + TURNSTONE_MCP_OAUTH_TRUSTED_PRIVATE_HOSTS are + read-only. +

+
+
+ + + +
+
+ Loading trusted hosts... +
+
+
"; @@ -4201,6 +4206,52 @@ function _renderSettings(container, grouped) { } } +function _renderMcpTrustedPrivateHostsSetting(item) { + const escapedKey = escapeHtml(item.key); + const shortKey = "oauth_trusted_private_hosts"; + const helpId = escapedKey + "-help"; + + let html = + '
'; + html += '
'; + html += '
' + shortKey; + html += + ' '; + html += "
"; + html += '
' + escapeHtml(item.description) + "
"; + html += + '
"; + html += '
'; + html += + '
' + + '' + + '' + + '' + + "
"; + html += + '
' + + 'Loading trusted hosts...
'; + html += + '
'; + html += "
"; + return html; +} + function _renderSettingRow(item) { const shortKey = item.key.indexOf(".") !== -1 @@ -4697,10 +4748,8 @@ let _registryResults = []; let _registryCursor = null; let _registryQuery = ""; let _mcpTrustedPrivateHosts = []; -let _mcpPrivateHostsWired = false; function loadAdminMcp() { - loadMcpTrustedPrivateHosts(); authFetch("/v1/api/admin/mcp-servers") .then(function (r) { if (!r.ok) throw new Error("Failed"); @@ -4749,11 +4798,11 @@ function loadMcpTrustedPrivateHosts() { } function _wireMcpPrivateHosts() { - if (_mcpPrivateHostsWired) return; const form = document.getElementById("mcp-private-host-form"); const input = document.getElementById("mcp-private-host-input"); if (!form || !input) return; - _mcpPrivateHostsWired = true; + if (form.dataset.wired === "true") return; + form.dataset.wired = "true"; form.addEventListener("submit", function (event) { event.preventDefault(); const host = input.value.trim(); @@ -4827,7 +4876,7 @@ function _renderMcpTrustedPrivateHosts() { if (!entry.readonly) { const remove = document.createElement("button"); remove.type = "button"; - remove.className = "admin-action-btn admin-action-btn-ghost mcp-private-host-remove"; + remove.className = "settings-reset-btn mcp-private-host-remove"; remove.textContent = "Remove"; remove.setAttribute("aria-label", "Remove trusted private host " + entry.host); remove.addEventListener("click", function () { diff --git a/turnstone/console/static/index.html b/turnstone/console/static/index.html index 3967ddd95..93efca38e 100644 --- a/turnstone/console/static/index.html +++ b/turnstone/console/static/index.html @@ -1246,56 +1246,6 @@

-
-
-

- Trusted private OAuth hosts -

-

- Allow OAuth discovery for operator-controlled MCP services - that resolve to private network addresses. Add only exact - hostnames or IP addresses you control. HTTPS, same-origin, - port, userinfo, and dangerous-address protections remain - enabled. Entries loaded from - TURNSTONE_MCP_OAUTH_TRUSTED_PRIVATE_HOSTS are - read-only. -

-
-
- - - -
-
- Loading trusted hosts... -
-
-