"""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 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="

app

") ) 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 '