Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/nilscript/controlplane/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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='<div class=empty style=padding:22px><div class=big>No automations yet</div><div>Click “+ New cross-system automation” above to build one between two systems — or ask the agent via MCP.</div></div>';return;}
box.innerHTML=automations.map(a=>{
const nm=(a.name&&(a.name.en||a.name.ar))||a.automation_id;
const ps=a.plan_summary||{};
Expand Down
40 changes: 39 additions & 1 deletion src/nilscript/mcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.

Expand All @@ -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)
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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

Expand Down
31 changes: 31 additions & 0 deletions tests/test_mcp_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading