From 9b1b774077795a1d94c3f76644b34ba3d6fa34c3 Mon Sep 17 00:00:00 2001 From: AI Bot Date: Wed, 24 Jun 2026 17:22:02 +0300 Subject: [PATCH 1/2] fix(cp): always show the Automations panel (empty state) so the compose form is reachable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The panel was hidden when zero automations existed — but the '+ New cross-system automation' button and operator-token field live inside it, so a first automation could never be created. Always render it, with an empty state. --- src/nilscript/controlplane/app.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/nilscript/controlplane/app.py b/src/nilscript/controlplane/app.py index 634909c..32b6bf7 100644 --- a/src/nilscript/controlplane/app.py +++ b/src/nilscript/controlplane/app.py @@ -1192,7 +1192,8 @@ def index() -> str: const r=await fetch('/api/automations');const {automations}=await r.json(); const wrap=document.getElementById('autoWrap'),box=document.getElementById('automations'); document.getElementById('autocount').textContent=automations.length; - wrap.style.display=automations.length?'block':'none'; + wrap.style.display='block'; // always visible — the compose form + token live here, even with 0 automations + if(!automations.length){box.innerHTML='
No automations yet
Click “+ New cross-system automation” above to build one between two systems — or ask the agent via MCP.
';return;} box.innerHTML=automations.map(a=>{ const nm=(a.name&&(a.name.en||a.name.ar))||a.automation_id; const ps=a.plan_summary||{}; From 4e1a5d879e2cf7121e191c60cdf1c2589dd2aec7 Mon Sep 17 00:00:00 2001 From: AI Bot Date: Thu, 25 Jun 2026 18:28:19 +0300 Subject: [PATCH 2/2] fix(mcp): NIL_MCP_ALLOWED_HOSTS widens the streamable-HTTP DNS-rebinding allowlist FastMCP only honors transport_security as a constructor kwarg and otherwise auto-enables a localhost-only allowlist, so the remote MCP 421s ('Invalid Host header') when reached by its container/service name or a public mcp.* host behind a reverse proxy. build_server now accepts allowed_hosts and threads a TransportSecuritySettings; build_asgi_app reads NIL_MCP_ALLOWED_HOSTS (JSON or comma-separated, host:* wildcard ok). /mcp stays bearer-gated. --- src/nilscript/mcp/server.py | 40 ++++++++++++++++++++++++++++++++++++- tests/test_mcp_server.py | 31 ++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 1 deletion(-) diff --git a/src/nilscript/mcp/server.py b/src/nilscript/mcp/server.py index d774a46..d93813f 100644 --- a/src/nilscript/mcp/server.py +++ b/src/nilscript/mcp/server.py @@ -15,6 +15,7 @@ from __future__ import annotations import json +import os from typing import Any # Imported at module scope (not lazily) so the wrapped tool functions' stringized annotations @@ -139,6 +140,7 @@ def build_server( port: int = 8765, tools_provider: ToolsProvider | None = None, automation_tools: Any = None, + allowed_hosts: list[str] | None = None, ): # type: ignore[no-untyped-def] """Bind the NilTools surface onto a FastMCP server. Imports `mcp` lazily. @@ -149,8 +151,25 @@ def build_server( skill/skeleton resources and any `dynamic_verbs` always reflect the `tools` backend (the default). `automation_tools` (optional `AutomationTools`) adds the registry tools so an agent can author governed automations by talking — bound only when a control-plane registry is configured. + `allowed_hosts` (optional) widens the streamable-HTTP DNS-rebinding guard: FastMCP only reads + `transport_security` as a constructor kwarg and otherwise auto-enables a localhost-only allowlist, + so a server reachable by its container/service or public host (e.g. another in-cluster agent + dialing `nilscript-mcp:8765`) is 421-rejected unless those hosts are listed here. Entries may use + the SDK's ``host:*`` wildcard-port form. The `/mcp` front door stays bearer-gated regardless. """ - server = FastMCP(name, instructions=_INSTRUCTIONS, host=host, port=port) + transport_security = None + if allowed_hosts: + from mcp.server.transport_security import TransportSecuritySettings + + transport_security = TransportSecuritySettings( + enable_dns_rebinding_protection=True, + allowed_hosts=list(allowed_hosts), + allowed_origins=["*"], + ) + server = FastMCP( + name, instructions=_INSTRUCTIONS, host=host, port=port, + transport_security=transport_security, + ) provider = tools_provider if tools_provider is not None else SingletonToolsProvider(tools) _register_tools(server, provider) @@ -411,6 +430,24 @@ def serve( uvicorn.run(app, host=host, port=port) +def _allowed_hosts_from_env() -> list[str] | None: + """Parse ``NIL_MCP_ALLOWED_HOSTS`` (JSON list or comma-separated) for the DNS-rebinding allowlist. + + Set by the deploy when the server is reached by a name other than localhost (its container/service + name, or a public ``mcp.*`` host behind a reverse proxy). ``None`` (unset/blank) keeps FastMCP's + localhost-only default. Entries may use the SDK's ``host:*`` wildcard-port form. + """ + raw = os.environ.get("NIL_MCP_ALLOWED_HOSTS", "").strip() + if not raw: + return None + if raw.startswith("["): + hosts = [str(h).strip() for h in json.loads(raw)] + else: + hosts = [h.strip() for h in raw.split(",")] + hosts = [h for h in hosts if h] + return hosts or None + + def build_asgi_app( *, adapter_url: str, @@ -464,6 +501,7 @@ def build_asgi_app( server = build_server( tools, dynamic_verbs=verbs, tools_provider=provider, automation_tools=AutomationTools.from_env(), + allowed_hosts=_allowed_hosts_from_env(), ) app = server.streamable_http_app() # MCP mounted at /mcp diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index 48ab74b..7b81013 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -61,6 +61,37 @@ def test_build_asgi_app_returns_callable_even_if_adapter_unreachable() -> None: assert callable(app) +def test_build_server_honors_allowed_hosts() -> None: + # FastMCP only reads `transport_security` as a constructor kwarg and otherwise auto-enables a + # localhost-only allowlist — so a server reachable by its container/service name (e.g. another + # in-cluster agent connecting to `nilscript-mcp:8765`) needs the deploy to widen the allowlist. + server = build_server(_tools(), allowed_hosts=["nilscript-mcp:8765", "mcp.wosool.ai"]) + ts = server.settings.transport_security + assert ts is not None + assert ts.enable_dns_rebinding_protection is True + assert "nilscript-mcp:8765" in ts.allowed_hosts + assert "mcp.wosool.ai" in ts.allowed_hosts + + +def test_build_asgi_app_reads_allowed_hosts_from_env(monkeypatch) -> None: + # The deploy sets NIL_MCP_ALLOWED_HOSTS; build_asgi_app must thread it to the FastMCP server so + # the DNS-rebinding guard admits the in-cluster + public hosts. Accepts JSON or comma-separated. + monkeypatch.setenv("NIL_MCP_ALLOWED_HOSTS", '["nilscript-mcp:*","mcp.wosool.ai"]') + captured: dict = {} + import nilscript.mcp.server as srv + + real_build_server = srv.build_server + + def _spy(*args, **kwargs): # type: ignore[no-untyped-def] + captured["allowed_hosts"] = kwargs.get("allowed_hosts") + return real_build_server(*args, **kwargs) + + monkeypatch.setattr(srv, "build_server", _spy) + app = build_asgi_app(adapter_url="http://127.0.0.1:9", bearer="") + assert callable(app) + assert captured["allowed_hosts"] == ["nilscript-mcp:*", "mcp.wosool.ai"] + + def test_remote_auth_gate_protects_mcp_but_not_healthz() -> None: # sync test: build_asgi_app calls asyncio.run() internally (discovery), so it can't run inside # an active event loop — we drive the httpx assertions via a nested asyncio.run.