From a1dd8b12e1ddd00c99c2cfdc532416ccc42f35e9 Mon Sep 17 00:00:00 2001
From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com>
Date: Sat, 27 Jun 2026 12:18:36 -0400
Subject: [PATCH] Fix dev apps launch escaping (#4399)
---
fastmcp_slim/fastmcp/cli/apps_dev.py | 24 +++++++---
tests/cli/test_run.py | 72 ++++++++++++++++++++++++++++
2 files changed, 90 insertions(+), 6 deletions(-)
diff --git a/fastmcp_slim/fastmcp/cli/apps_dev.py b/fastmcp_slim/fastmcp/cli/apps_dev.py
index cefdd2296..b4074b14b 100644
--- a/fastmcp_slim/fastmcp/cli/apps_dev.py
+++ b/fastmcp_slim/fastmcp/cli/apps_dev.py
@@ -26,6 +26,7 @@ from __future__ import annotations
import asyncio
import contextlib
+import html
import io
import json
import logging
@@ -39,7 +40,7 @@ import time
import webbrowser
from pathlib import Path
from typing import Any
-from urllib.parse import quote
+from urllib.parse import urlencode
import httpcore
import httpx
@@ -54,6 +55,18 @@ from fastmcp.utilities.logging import get_logger
logger = get_logger(__name__)
+def _json_for_script(value: Any) -> str:
+ """Serialize JSON for embedding inside an HTML script element."""
+ return (
+ json.dumps(value)
+ .replace("&", "\\u0026")
+ .replace("<", "\\u003c")
+ .replace(">", "\\u003e")
+ .replace("\u2028", "\\u2028")
+ .replace("\u2029", "\\u2029")
+ )
+
+
# ---------------------------------------------------------------------------
# MCP message log (captures proxy traffic for the dev UI log panel)
# ---------------------------------------------------------------------------
@@ -1415,10 +1428,10 @@ def _make_dev_app(
args_raw = request.query_params.get("args", "{}")
tool_args = json.loads(args_raw)
host_html = _HOST_HTML_TEMPLATE.format(
- tool_name=tool,
+ tool_name=html.escape(tool, quote=True),
import_map_tag=import_map_tag,
- tool_name_json=json.dumps(tool),
- tool_args_json=json.dumps(tool_args),
+ tool_name_json=_json_for_script(tool),
+ tool_args_json=_json_for_script(tool_args),
mcp_sdk_version=_MCP_SDK_VERSION,
)
return (
@@ -1478,8 +1491,7 @@ def _make_dev_app(
except (json.JSONDecodeError, TypeError):
pass
tool_args[k] = v
- args_json = quote(json.dumps(tool_args))
- url = f"/launch?tool={tool}&args={args_json}"
+ url = "/launch?" + urlencode({"tool": tool, "args": json.dumps(tool_args)})
return Response(
content=json.dumps(url),
media_type="application/json",
diff --git a/tests/cli/test_run.py b/tests/cli/test_run.py
index 44d180790..bc8487a0e 100644
--- a/tests/cli/test_run.py
+++ b/tests/cli/test_run.py
@@ -5,6 +5,7 @@ import subprocess
import sys
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch
+from urllib.parse import parse_qs, urlsplit
import pytest
from pydantic import ValidationError
@@ -1034,6 +1035,77 @@ class TestInspectorModuleMode:
class TestRunDevApps:
"""Test running dev apps with the run command."""
+ def test_launch_escapes_tool_name_in_html_contexts(self):
+ """Test /launch escapes the tool query parameter in HTML sinks."""
+ starlette_app = _make_dev_app(
+ mcp_url="http://127.0.0.1:8000/mcp",
+ app_bridge_js="// js",
+ import_map_tag="",
+ message_log=_MessageLog(),
+ log_panel=False,
+ )
+ client = TestClient(starlette_app, raise_server_exceptions=False)
+
+ payload = "
"
+ response = client.get("/launch", params={"tool": payload, "args": "{}"})
+
+ assert response.status_code == 200
+ assert payload not in response.text
+ assert (
+ "</title><script>alert(1)</script>"
+ "<img src=x onerror=alert(2)>"
+ ) in response.text
+ assert "\\u003c/script\\u003e" in response.text
+
+ def test_launch_serializes_args_safely_inside_script(self):
+ """Test /launch escapes argument values embedded in the script element."""
+ starlette_app = _make_dev_app(
+ mcp_url="http://127.0.0.1:8000/mcp",
+ app_bridge_js="// js",
+ import_map_tag="",
+ message_log=_MessageLog(),
+ log_panel=False,
+ )
+ client = TestClient(starlette_app, raise_server_exceptions=False)
+
+ payload = {"name": "&"}
+ response = client.get(
+ "/launch",
+ params={"tool": "safe_tool", "args": json.dumps(payload)},
+ )
+
+ assert response.status_code == 200
+ assert json.dumps(payload) not in response.text
+ assert (
+ '"\\u003c/script\\u003e\\u003cscript\\u003ealert(1)'
+ '\\u003c/script\\u003e\\u0026"'
+ ) in response.text
+
+ def test_api_launch_encodes_generated_launch_url(self):
+ """Test /api/launch encodes query parameters in the returned URL."""
+ starlette_app = _make_dev_app(
+ mcp_url="http://127.0.0.1:8000/mcp",
+ app_bridge_js="// js",
+ import_map_tag="",
+ message_log=_MessageLog(),
+ log_panel=False,
+ )
+ client = TestClient(starlette_app, raise_server_exceptions=False)
+
+ response = client.post(
+ "/api/launch",
+ json={
+ "tool": "tool&name="}',
+ },
+ )
+
+ assert response.status_code == 200
+ url = response.json()
+ query = parse_qs(urlsplit(url).query)
+ assert query["tool"] == ["tool&name="}
+
@pytest.mark.parametrize(
"host, expected_host",
[