mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-10-09 14:33:21 +02:00
`fastmcp dev apps` now runs its dev UI as a session started from the printed startup URL, so the preview is tied to the terminal that launched it. Opening the startup URL sets a session cookie, and requests also need the dev server's own Host and the dev UI's origin. The picker creates launches on the server, and launch pages open them by id. Tool UI resources run in a sandboxed iframe with their own opaque origin and talk to the dev UI through AppBridge, which matches how production MCP Apps hosts isolate app content. Picker errors are HTML-escaped. The `/mcp` proxy forwards only what the MCP server needs. The spawned MCP server runs with `FASTMCP_HTTP_HOST_ORIGIN_PROTECTION=auto` unless that variable is already set. Compatibility: opening the dev UI without the startup URL returns 403, and launch URLs come from the picker rather than being written by hand. Port-forwarded or proxied setups where the browser-facing host and port differ from the bind address get 400. Sandboxed apps send `Origin: null` and cannot use `alert`/`confirm`, popups, or `localStorage`. Co-authored-by: Bill Easton <williamseaston@gmail.com> Co-authored-by: Claude <noreply@anthropic.com>
657 lines
22 KiB
Python
657 lines
22 KiB
Python
"""Sessions, launches, and app frames in the `fastmcp dev apps` server."""
|
|
|
|
import html
|
|
from typing import Any
|
|
from unittest.mock import AsyncMock
|
|
|
|
import httpx2
|
|
import pytest
|
|
from starlette.testclient import TestClient
|
|
from starlette.websockets import WebSocketDisconnect
|
|
|
|
import fastmcp.cli.apps_dev as apps_dev
|
|
|
|
TOKEN = "test-process-session-token"
|
|
COOKIE = "fastmcp_dev_session_8080"
|
|
|
|
|
|
def build_client(*, host: str = "127.0.0.1", session: bool = False) -> TestClient:
|
|
app = apps_dev._make_dev_app(
|
|
"http://127.0.0.1:1/mcp",
|
|
"",
|
|
"",
|
|
apps_dev._MessageLog(),
|
|
False,
|
|
host=host,
|
|
port=8080,
|
|
session_token=TOKEN,
|
|
)
|
|
client = TestClient(app, base_url="http://127.0.0.1:8080")
|
|
if session:
|
|
client.get("/", params={"token": TOKEN})
|
|
return client
|
|
|
|
|
|
def create_launch(client: TestClient, tool: str = "ping") -> str:
|
|
response = client.post("/api/launch", json={"tool": tool, "value": "x"})
|
|
return response.json()
|
|
|
|
|
|
class TestSession:
|
|
@pytest.mark.parametrize(
|
|
"method,path",
|
|
[
|
|
("get", "/"),
|
|
("get", "/launch?id=x"),
|
|
("get", "/picker-app"),
|
|
("get", "/ui-resource?uri=ui://app/view.html"),
|
|
("get", "/js/app-bridge.js"),
|
|
("get", "/api/logs"),
|
|
("post", "/api/logs/bridge"),
|
|
("post", "/api/logs/clear"),
|
|
("post", "/api/launch"),
|
|
("get", "/mcp"),
|
|
("post", "/mcp"),
|
|
("delete", "/mcp"),
|
|
],
|
|
)
|
|
def test_requests_without_session_are_rejected(self, method: str, path: str):
|
|
response = build_client().request(method, path)
|
|
assert response.status_code == 403
|
|
|
|
def test_startup_url_starts_session_and_redirects(self):
|
|
client = build_client()
|
|
|
|
response = client.get("/", params={"token": TOKEN}, follow_redirects=False)
|
|
|
|
assert response.status_code == 303
|
|
assert response.headers["location"] == "/"
|
|
cookie = response.headers["set-cookie"]
|
|
assert cookie.startswith(f"{COOKIE}=")
|
|
assert "HttpOnly" in cookie
|
|
assert "SameSite=strict" in cookie
|
|
assert response.headers["referrer-policy"] == "no-referrer"
|
|
assert client.get("/").status_code == 200
|
|
assert client.get(create_launch(client)).status_code == 200
|
|
|
|
def test_session_cookie_differs_from_startup_token(self):
|
|
client = build_client(session=True)
|
|
|
|
session = client.cookies.get(COOKIE)
|
|
|
|
assert session
|
|
assert TOKEN not in session
|
|
|
|
@pytest.mark.parametrize("token", ["wrong", "", "é"])
|
|
def test_wrong_startup_token_starts_no_session(self, token: str):
|
|
response = build_client().get("/", params={"token": token})
|
|
|
|
assert response.status_code == 403
|
|
assert "set-cookie" not in response.headers
|
|
|
|
@pytest.mark.parametrize(
|
|
"token",
|
|
[
|
|
TOKEN.upper(),
|
|
TOKEN[:-1],
|
|
TOKEN + "x",
|
|
f" {TOKEN}",
|
|
f"{TOKEN} ",
|
|
"名前",
|
|
"wrong",
|
|
"",
|
|
],
|
|
ids=[
|
|
"uppercase",
|
|
"truncated",
|
|
"extended",
|
|
"leading_space",
|
|
"trailing_space",
|
|
"non_ascii",
|
|
"wrong",
|
|
"empty",
|
|
],
|
|
)
|
|
def test_startup_token_must_match_exactly(self, token: str):
|
|
client = build_client()
|
|
|
|
response = client.get("/", params={"token": token})
|
|
|
|
assert response.status_code == 403
|
|
assert "set-cookie" not in response.headers
|
|
assert client.get("/").status_code == 403
|
|
|
|
@pytest.mark.parametrize(
|
|
"method,path",
|
|
[("get", "/picker-app"), ("get", "/api/logs"), ("post", "/api/launch")],
|
|
)
|
|
def test_startup_token_only_starts_a_session_from_the_root_page(
|
|
self, method: str, path: str
|
|
):
|
|
client = build_client()
|
|
|
|
response = client.request(method, path, params={"token": TOKEN})
|
|
|
|
assert response.status_code == 403
|
|
assert "set-cookie" not in response.headers
|
|
|
|
@pytest.mark.parametrize(
|
|
"cookie",
|
|
[
|
|
f"{COOKIE}=wrong",
|
|
f"{COOKIE}=",
|
|
f"{COOKIE}={TOKEN}",
|
|
"fastmcp_dev_session_9999=anything",
|
|
"fastmcp_dev_session=anything",
|
|
],
|
|
ids=["wrong", "empty", "startup_token", "other_port", "no_port"],
|
|
)
|
|
def test_other_cookie_values_are_not_a_session(self, cookie: str):
|
|
response = build_client().get("/", headers={"Cookie": cookie})
|
|
|
|
assert response.status_code == 403
|
|
|
|
def test_websocket_connections_are_closed(self):
|
|
with pytest.raises(WebSocketDisconnect) as exc_info:
|
|
with build_client(session=True).websocket_connect("/mcp"):
|
|
pass
|
|
assert exc_info.value.code == 1008
|
|
|
|
|
|
class TestHostHeader:
|
|
@pytest.mark.parametrize(
|
|
"host",
|
|
[
|
|
"example.com:8080",
|
|
"dev.example.com:8080",
|
|
"127.0.0.1:9000",
|
|
"127.0.0.1",
|
|
"[::1]:9000",
|
|
"192.0.2.2:8080",
|
|
],
|
|
)
|
|
def test_other_host_headers_are_rejected(self, host: str):
|
|
response = build_client().get(
|
|
"/", params={"token": TOKEN}, headers={"Host": host}
|
|
)
|
|
|
|
assert response.status_code == 400
|
|
assert "set-cookie" not in response.headers
|
|
|
|
@pytest.mark.parametrize(
|
|
"host",
|
|
[
|
|
"other.example:8080",
|
|
"127.0.0.1.other.example:8080",
|
|
"localhost.other.example:8080",
|
|
"127.0.0.1:9999",
|
|
"127.0.0.1",
|
|
"127.0.0.1:",
|
|
"127.0.0.1:99999",
|
|
"127.0.0.1:http",
|
|
"[::1]:9999",
|
|
"[::1]",
|
|
"127.0.0.1:8080@other.example:8080",
|
|
"other.example:8080@127.0.0.1:8080",
|
|
"127.0.0.1:8080/path",
|
|
"127.0.0.1:8080?name=value",
|
|
"127.0.0.1:8080#fragment",
|
|
"[invalid]:8080",
|
|
"2130706433:8080",
|
|
"127.1:8080",
|
|
"0x7f.0.0.1:8080",
|
|
"[::ffff:127.0.0.1]:8080",
|
|
"localhost.:8080",
|
|
"127.0.0.1.:8080",
|
|
"localhost:8080:8080",
|
|
"127.0.0.1:8080:8080",
|
|
],
|
|
)
|
|
def test_malformed_or_mismatched_host_values_are_rejected(self, host: str):
|
|
response = build_client().get(
|
|
"/", params={"token": TOKEN}, headers={"Host": host}
|
|
)
|
|
|
|
assert response.status_code == 400
|
|
assert "set-cookie" not in response.headers
|
|
|
|
@pytest.mark.parametrize("host", ["LOCALHOST:8080", "Localhost:8080"])
|
|
def test_host_names_are_case_insensitive(self, host: str):
|
|
response = build_client().get(
|
|
"/", params={"token": TOKEN}, headers={"Host": host}, follow_redirects=False
|
|
)
|
|
|
|
assert response.status_code == 303
|
|
|
|
def test_bound_host_name_is_case_insensitive(self):
|
|
response = build_client(host="Dev.Example").get(
|
|
"/",
|
|
params={"token": TOKEN},
|
|
headers={"Host": "DEV.example:8080"},
|
|
follow_redirects=False,
|
|
)
|
|
|
|
assert response.status_code == 303
|
|
|
|
@pytest.mark.parametrize(
|
|
"second_host", ["127.0.0.1:8080", "other.example:8080"], ids=["same", "other"]
|
|
)
|
|
def test_multiple_host_headers_are_rejected(self, second_host: str):
|
|
response = build_client().get(
|
|
"/",
|
|
params={"token": TOKEN},
|
|
headers=[("Host", "127.0.0.1:8080"), ("Host", second_host)],
|
|
)
|
|
|
|
assert response.status_code == 400
|
|
assert "set-cookie" not in response.headers
|
|
|
|
@pytest.mark.parametrize(
|
|
"host",
|
|
[
|
|
"other.example:8080",
|
|
"localhost.other.example:8080",
|
|
"192.0.2.2:9999",
|
|
"192.0.2.2",
|
|
"192.0.2.20:8080",
|
|
"192.0.2.2.other.example:8080",
|
|
"192.0.2.2:8080@other.example:8080",
|
|
"localhost:8080",
|
|
"[::1]:8080",
|
|
],
|
|
)
|
|
def test_specific_bind_rejects_other_host_values(self, host: str):
|
|
response = build_client(host="192.0.2.2").get(
|
|
"/", params={"token": TOKEN}, headers={"Host": host}
|
|
)
|
|
|
|
assert response.status_code == 400
|
|
assert "set-cookie" not in response.headers
|
|
|
|
@pytest.mark.parametrize(
|
|
"host",
|
|
[
|
|
"other.example:8080",
|
|
"localhost.other.example:8080",
|
|
"192.0.2.2:9999",
|
|
"192.0.2.2",
|
|
"2130706433:8080",
|
|
"127.1:8080",
|
|
"0x7f.0.0.1:8080",
|
|
"localhost.:8080",
|
|
"192.0.2.2:8080@other.example:8080",
|
|
"192.0.2.2:8080/path",
|
|
],
|
|
)
|
|
def test_wildcard_bind_rejects_non_literal_and_malformed_hosts(self, host: str):
|
|
response = build_client(host="0.0.0.0").get(
|
|
"/", params={"token": TOKEN}, headers={"Host": host}
|
|
)
|
|
|
|
assert response.status_code == 400
|
|
assert "set-cookie" not in response.headers
|
|
|
|
@pytest.mark.parametrize(
|
|
"host", ["localhost:8080", "[::1]:8080", "[2001:db8::1]:8080"]
|
|
)
|
|
def test_wildcard_bind_accepts_loopback_names_and_ipv6_literals(self, host: str):
|
|
response = build_client(host="::").get(
|
|
"/", params={"token": TOKEN}, headers={"Host": host}, follow_redirects=False
|
|
)
|
|
|
|
assert response.status_code == 303
|
|
|
|
def test_default_http_port_accepts_host_without_port(self):
|
|
app = apps_dev._make_dev_app(
|
|
"http://127.0.0.1:1/mcp",
|
|
"",
|
|
"",
|
|
apps_dev._MessageLog(),
|
|
False,
|
|
port=80,
|
|
session_token=TOKEN,
|
|
)
|
|
client = TestClient(app, base_url="http://127.0.0.1")
|
|
|
|
startup = client.get("/", params={"token": TOKEN}, follow_redirects=False)
|
|
page = client.get("/", headers={"Origin": "http://127.0.0.1"})
|
|
|
|
assert startup.status_code == 303
|
|
assert page.status_code == 200
|
|
|
|
@pytest.mark.parametrize("host", ["127.0.0.1:8080", "localhost:8080", "[::1]:8080"])
|
|
def test_loopback_names_are_accepted(self, host: str):
|
|
response = build_client().get(
|
|
"/", params={"token": TOKEN}, headers={"Host": host}, follow_redirects=False
|
|
)
|
|
assert response.status_code == 303
|
|
|
|
def test_specific_bind_accepts_only_its_host(self):
|
|
client = build_client(host="192.0.2.2")
|
|
startup = {"token": TOKEN}
|
|
|
|
own = client.get(
|
|
"/",
|
|
params=startup,
|
|
headers={"Host": "192.0.2.2:8080"},
|
|
follow_redirects=False,
|
|
)
|
|
other = client.get("/", params=startup, headers={"Host": "127.0.0.1:8080"})
|
|
|
|
assert own.status_code == 303
|
|
assert other.status_code == 400
|
|
|
|
@pytest.mark.parametrize(
|
|
"host,status",
|
|
[
|
|
("192.0.2.2:8080", 303),
|
|
("127.0.0.1:8080", 303),
|
|
("example.com:8080", 400),
|
|
],
|
|
)
|
|
def test_wildcard_bind_accepts_literal_addresses(self, host: str, status: int):
|
|
response = build_client(host="0.0.0.0").get(
|
|
"/", params={"token": TOKEN}, headers={"Host": host}, follow_redirects=False
|
|
)
|
|
assert response.status_code == status
|
|
|
|
|
|
class TestOrigin:
|
|
@pytest.mark.parametrize(
|
|
"headers",
|
|
[
|
|
{"Origin": "http://example.com"},
|
|
{"Origin": "http://127.0.0.1:9000"},
|
|
{"Origin": "null"},
|
|
{"Sec-Fetch-Site": "cross-site"},
|
|
{"Sec-Fetch-Site": "same-site"},
|
|
],
|
|
)
|
|
def test_launch_page_requires_same_origin(self, headers: dict[str, str]):
|
|
client = build_client(session=True)
|
|
launch_url = create_launch(client)
|
|
|
|
response = client.get(launch_url, headers=headers)
|
|
|
|
assert response.status_code == 403
|
|
|
|
@pytest.mark.parametrize(
|
|
"headers",
|
|
[
|
|
{"Origin": "http://example.com"},
|
|
{"Origin": "http://127.0.0.1:9000"},
|
|
{"Origin": "null"},
|
|
{"Sec-Fetch-Site": "cross-site"},
|
|
{"Sec-Fetch-Site": "same-site"},
|
|
],
|
|
)
|
|
def test_log_clear_requires_same_origin(self, headers: dict[str, str]):
|
|
client = build_client(session=True)
|
|
|
|
response = client.post("/api/logs/clear", content="{}", headers=headers)
|
|
|
|
assert response.status_code == 403
|
|
|
|
@pytest.mark.parametrize(
|
|
"headers",
|
|
[
|
|
{"Origin": "http://other.example"},
|
|
{"Origin": "http://127.0.0.1:9999"},
|
|
{"Origin": "http://localhost:8080"},
|
|
{"Origin": "http://127.0.0.1"},
|
|
{"Origin": "https://127.0.0.1:8080"},
|
|
{"Origin": "HTTP://127.0.0.1:8080"},
|
|
{"Origin": "http://127.0.0.1:8080/"},
|
|
{"Origin": "http://127.0.0.1:8080@other.example"},
|
|
{"Origin": "http://other.example@127.0.0.1:8080"},
|
|
{"Origin": "http://127.0.0.1.other.example:8080"},
|
|
{"Origin": ""},
|
|
{"Origin": "null"},
|
|
{"Origin": "http://127.0.0.1:8080", "Sec-Fetch-Site": "cross-site"},
|
|
{"Origin": "http://127.0.0.1:8080", "Sec-Fetch-Site": "same-site"},
|
|
{"Sec-Fetch-Site": "cross-site"},
|
|
{"Sec-Fetch-Site": "same-site"},
|
|
{"Sec-Fetch-Site": "Cross-Site"},
|
|
{"Sec-Fetch-Site": ""},
|
|
{"Sec-Fetch-Site": "same-origin, cross-site"},
|
|
],
|
|
)
|
|
@pytest.mark.parametrize(
|
|
"method,path",
|
|
[
|
|
("get", "/"),
|
|
("get", "/picker-app"),
|
|
("get", "/api/logs"),
|
|
("post", "/api/launch"),
|
|
("post", "/api/logs/clear"),
|
|
("post", "/mcp"),
|
|
],
|
|
)
|
|
def test_cross_origin_requests_are_rejected_on_every_route(
|
|
self, method: str, path: str, headers: dict[str, str]
|
|
):
|
|
response = build_client(session=True).request(method, path, headers=headers)
|
|
|
|
assert response.status_code == 403
|
|
|
|
def test_referer_does_not_replace_the_origin_check(self):
|
|
response = build_client(session=True).post(
|
|
"/api/launch",
|
|
json={"tool": "ping", "value": "x"},
|
|
headers={
|
|
"Origin": "http://other.example",
|
|
"Referer": "http://127.0.0.1:8080/",
|
|
},
|
|
)
|
|
|
|
assert response.status_code == 403
|
|
|
|
@pytest.mark.parametrize(
|
|
"headers",
|
|
[
|
|
{"Origin": "http://127.0.0.1:8080"},
|
|
{"Sec-Fetch-Site": "same-origin"},
|
|
{"Sec-Fetch-Site": "none"},
|
|
{},
|
|
],
|
|
)
|
|
def test_same_origin_requests_work(self, headers: dict[str, str]):
|
|
response = build_client(session=True).post(
|
|
"/api/launch", json={"tool": "ping", "value": "x"}, headers=headers
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
assert response.json().startswith("/launch?id=")
|
|
|
|
def test_log_endpoints_work_for_the_session(self):
|
|
client = build_client(session=True)
|
|
|
|
client.post("/api/logs/bridge", json={"body": {"method": "legitimate"}})
|
|
logged = client.get("/api/logs").text
|
|
client.post("/api/logs/clear")
|
|
|
|
assert "legitimate" in logged
|
|
assert "legitimate" not in client.get("/api/logs").text
|
|
|
|
|
|
class TestLaunch:
|
|
def test_launch_url_from_picker_opens_launch_page(self):
|
|
client = build_client(session=True)
|
|
|
|
response = client.get(create_launch(client, tool="lookup"))
|
|
|
|
assert response.status_code == 200
|
|
assert 'const toolName = "lookup";' in response.text
|
|
|
|
def test_launch_ignores_tool_and_args_parameters(self):
|
|
client = build_client(session=True)
|
|
|
|
response = client.get(
|
|
"/launch",
|
|
params={"tool": "ping", "args": '{"x": 1}'},
|
|
headers={"Sec-Fetch-Site": "none"},
|
|
)
|
|
|
|
assert response.status_code == 404
|
|
assert "toolName" not in response.text
|
|
|
|
def test_unknown_launch_id_is_rejected(self):
|
|
response = build_client(session=True).get("/launch", params={"id": "unknown"})
|
|
|
|
assert response.status_code == 404
|
|
|
|
|
|
class TestPages:
|
|
def test_picker_error_shows_message_as_text(self, monkeypatch: pytest.MonkeyPatch):
|
|
message = "Expected <list> of items"
|
|
monkeypatch.setattr(
|
|
apps_dev, "_list_tools", AsyncMock(side_effect=ValueError(message))
|
|
)
|
|
|
|
response = build_client(session=True).get("/picker-app")
|
|
|
|
assert response.status_code == 200
|
|
assert message not in response.text
|
|
assert html.escape(message) in response.text
|
|
|
|
def test_ui_resource_runs_sandboxed_when_opened_directly(
|
|
self, monkeypatch: pytest.MonkeyPatch
|
|
):
|
|
monkeypatch.setattr(
|
|
apps_dev, "_read_mcp_resource", AsyncMock(return_value="<p>app</p>")
|
|
)
|
|
|
|
response = build_client(session=True).get("/ui-resource?uri=ui://app/view.html")
|
|
|
|
assert response.status_code == 200
|
|
csp = response.headers["content-security-policy"]
|
|
assert "sandbox allow-scripts allow-forms" in csp
|
|
assert "allow-same-origin" not in csp
|
|
assert "frame-ancestors 'self'" in csp
|
|
|
|
def test_host_pages_disallow_framing(self):
|
|
response = build_client(session=True).get("/")
|
|
assert response.headers["content-security-policy"] == "frame-ancestors 'none'"
|
|
|
|
def test_launch_page_sandboxes_app_frame(self):
|
|
client = build_client(session=True)
|
|
page = client.get(create_launch(client)).text
|
|
|
|
assert '<iframe id="app-frame" sandbox="allow-scripts allow-forms">' in page
|
|
assert "new PostMessageTransport(iframe.contentWindow, null)" not in page
|
|
assert "contentDocument" not in page
|
|
|
|
def test_launch_page_opens_only_external_web_links(self):
|
|
client = build_client(session=True)
|
|
page = client.get(create_launch(client)).text
|
|
|
|
assert 'target.protocol !== "https:"' in page
|
|
assert "target.origin === window.location.origin" in page
|
|
|
|
def test_picker_page_opens_only_web_links(self):
|
|
page = build_client(session=True).get("/").text
|
|
|
|
assert 'target.protocol !== "https:"' in page
|
|
assert "window.location.href = url;" not in page
|
|
|
|
def test_proxy_omits_browser_cookies_and_origin(
|
|
self, monkeypatch: pytest.MonkeyPatch
|
|
):
|
|
captured: list[httpx2.Request] = []
|
|
original_client = httpx2.AsyncClient
|
|
|
|
def backend(request: httpx2.Request) -> httpx2.Response:
|
|
captured.append(request)
|
|
return httpx2.Response(
|
|
200,
|
|
json={"jsonrpc": "2.0", "id": 1, "result": {}},
|
|
headers={
|
|
"Set-Cookie": f"{COOKIE}=other",
|
|
"Content-Security-Policy": "frame-ancestors *",
|
|
},
|
|
)
|
|
|
|
def client_factory(**kwargs: Any) -> httpx2.AsyncClient:
|
|
return original_client(transport=httpx2.MockTransport(backend), **kwargs)
|
|
|
|
monkeypatch.setattr(apps_dev.httpx2, "AsyncClient", client_factory)
|
|
|
|
response = build_client(session=True).post(
|
|
"/mcp",
|
|
json={"jsonrpc": "2.0", "id": 1, "method": "ping"},
|
|
headers={
|
|
"Origin": "http://127.0.0.1:8080",
|
|
"Referer": "http://127.0.0.1:8080/",
|
|
"Sec-Fetch-Site": "same-origin",
|
|
},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
forwarded = {key.lower() for key in captured[0].headers}
|
|
assert forwarded.isdisjoint({"cookie", "origin", "referer", "sec-fetch-site"})
|
|
assert "set-cookie" not in response.headers
|
|
assert response.headers["content-security-policy"] == (
|
|
"frame-ancestors 'none'; sandbox allow-scripts allow-forms"
|
|
)
|
|
|
|
|
|
class TestPickerForm:
|
|
@pytest.mark.parametrize(
|
|
"name,field_name",
|
|
[
|
|
("__base__", "field_base_"),
|
|
("__config__", "field_config_"),
|
|
("model_config", "field_model_config"),
|
|
("_private", "field_private"),
|
|
("plain", "plain"),
|
|
],
|
|
)
|
|
def test_form_submits_original_property_names(self, name: str, field_name: str):
|
|
schema = {
|
|
"type": "object",
|
|
"properties": {name: {"type": "string"}},
|
|
"required": [name],
|
|
}
|
|
|
|
model = apps_dev._model_from_schema("probe", schema)
|
|
page = apps_dev._build_picker_html(
|
|
[
|
|
{
|
|
"name": "probe",
|
|
"inputSchema": schema,
|
|
"_meta": {"ui": {"resourceUri": "ui://probe/view.html"}},
|
|
}
|
|
]
|
|
)
|
|
|
|
assert list(model.model_fields) == [field_name]
|
|
assert model.model_validate({name: "value"}).model_dump(by_alias=True) == {
|
|
name: "value"
|
|
}
|
|
assert f'"{name}":"{{{{ {field_name} }}}}"' in page
|
|
|
|
|
|
class TestSpawnedServer:
|
|
async def test_host_origin_protection_defaults_to_auto(
|
|
self, monkeypatch: pytest.MonkeyPatch
|
|
):
|
|
monkeypatch.delenv("FASTMCP_HTTP_HOST_ORIGIN_PROTECTION", raising=False)
|
|
spawn = AsyncMock()
|
|
monkeypatch.setattr(apps_dev.asyncio, "create_subprocess_exec", spawn)
|
|
|
|
await apps_dev._start_user_server("server.py", 8000, reload=False)
|
|
|
|
env = spawn.call_args.kwargs["env"]
|
|
assert env["FASTMCP_HTTP_HOST_ORIGIN_PROTECTION"] == "auto"
|
|
|
|
async def test_explicit_host_origin_protection_is_kept(
|
|
self, monkeypatch: pytest.MonkeyPatch
|
|
):
|
|
monkeypatch.setenv("FASTMCP_HTTP_HOST_ORIGIN_PROTECTION", "true")
|
|
spawn = AsyncMock()
|
|
monkeypatch.setattr(apps_dev.asyncio, "create_subprocess_exec", spawn)
|
|
|
|
await apps_dev._start_user_server("server.py", 8000, reload=False)
|
|
|
|
env = spawn.call_args.kwargs["env"]
|
|
assert env["FASTMCP_HTTP_HOST_ORIGIN_PROTECTION"] == "true"
|