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||{};
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.