The bug FastMCP's own CI could not see
I opened a pull request to FastMCP (27k stars) at 23:12:32 UTC. A bot closed it at 23:12:47. Fifteen seconds. A second bot then labeled it too-long , with the comment: "condense this issue. We'll triage it once it's trimmed down." It was merged the next day, unedited. Nobody condensed anything. The label is still on the merged PR. That is the funny part. The bug underneath it is the useful part, because it is a failure mode that any project with a type-checking gate can be sitting on right now without knowing. The bug Context.elicit is FastMCP's "ask the human a question" primitive. It declares six alternative call signatures as @overload stubs, one per supported response_type . Each stub's body was ... , and the explanatory prose sat after the body. Verbatim from fastmcp/server/context.py at 3.4.5, lines 1022 to 1061: @overload async def elicit( self, message: str, response_type: None, *, response_title: str | None = None, response_description: str | None = None, ) -> ( AcceptedElicitation[dict[str, Any]] | DeclinedElicitation | CancelledElicitation ): ... """When response_type is None, the accepted elicitation will contain an empty dict""" # AcceptedElicitation[T] | DeclinedElicitation | CancelledElicitation: ... """When response_type is not None, the accepted elicitation will contain the response data""" @overload async def elicit( self, message: str, response_type: list[str], *, response_title: str | None = None, response_description: str | None = None, ) -> AcceptedElicitation[str] | DeclinedElicitation | CancelledElicitation: ... """When response_type is a list of strings, the accepted elicitation will contain the selected string response""" Three more follow the same pattern, six in total. That string is not a docstring. A docstring has to be the first statement inside a function or class body. Here the body already ended at ... , so the string is a bare expression statement sitting in the class body, between two overloads. And a statement between overloads terminates the overload chain for mypy. Why only mypy Here is the reduced shape. No FastMCP install needed, paste it into a file and run two checkers: from typing import overload @overload def f(x: None) -> str: ... """Doc for the None case.""" @overload def f(x: list[str]) -> int: ... """Doc for the list case.""" def f(x): return x f(["a", "b"]) # mypy: error. pyright: fine. | checker | overloads registered | verdict on the list[str] call | |---|---|---| | mypy | 1 of 6 | error, no call-site workaround | | pyright | 6 of 6 | fine | | ty | 6 of 6 | fine | pyright and ty tolerate the interleaved statement and keep collecting overloads. mypy stops. FastMCP's own gate is ty . So the project's CI was green on a file that was broken for a large share of its users, and it stayed that way through a release. Why it actually bit Two details turn this from a style nit into a live problem. First: the one stub mypy could still see is the deprecated one. In the 3.x line the first overload is response_type: None , which the library's own docstring marks as deprecated. So mypy users were being funneled toward exactly the call shape the library is retiring. There is no way out at the call site either. I tried five formulations (explicit annotation, cast , a typed local, a Sequence[str] alias, direct literal). All five fail under mypy. All five pass under pyright. Second: I needed the invisible signature for a security control. response_type=None compiles to an empty JSON schema: {"type": "object", "properties": {}} The library documents this itself, in the very docstring that broke the overload chain: "When response_type is None, the accepted elicitation will contain an empty dict." An empty dict means the accept payload carries no data. A client that auto-accepts produces bytes that are indistinguishable, on the wire, from a human clicking approve. I proved that with a live probe a week earlier, when a delete tool ran its full delete path with confirm=False . The hardening is to stop treating "accepted" as a signal and require an affirmative value: CONFIRM: Final[list[str]] = ["cancel", "confirm"] ... result = await ctx.elicit(message, response_type=CONFIRM) list[str] . One of the five overloads mypy could not see. The fix Move each literal inside its stub body, where it becomes an actual docstring: @overload async def elicit( self, message: str, response_type: None = None, ) -> AcceptedElicitation[None] | DeclinedElicitation | CancelledElicitation: """The accepted elicitation will contain no data""" +15 / -24 , one file. No runtime change, no API change. The implementation function is byte-identical before and after (I hashed it). The deletion count is larger than the addition count only because dropping the trailing ... let ruff format collapse two return annotations onto fewer lines. The timeline Every timestamp below is from the GitHub timeline API. 08-05 22:36:16Z issue opened 08-05 23:12:32Z PR opened 08-05 23:12:47Z auto-CLOSED, 15 seconds later (missing-issue-link: external PRs must reference an issue ASSIGNED to their author) 08-05 23:14:09Z bot labels it too-long "Excessively verbose or unedited LLM output. Condense before triage." ... ~14 hours of silence ... 08-06 13:29:05Z maintainer assigns the issue 08-06 13:29:17Z label removed, PR auto-REOPENS 08-06 13:29:30Z APPROVED, 13 seconds later 08-06 13:34:12Z a second PR merges, see below 08-06 13:35:50Z MERGED The part I did not expect Twenty-eight seconds after approving my PR, the project lead opened a branch named codex/review-closed-contributor-prs and merged it four minutes later. It adds a line to FastMCP's own CLAUDE.md , the file that instructs their review agents: Review closed contributor PRs. External PRs may be closed as part of the issue-link workflow, so closure alone is not a negative signal. Then my PR merged. I want to be precise about what I am claiming here: there is no explicit cross-reference between those two pull requests. I am reporting the order of events and the twenty-eight second gap. Draw your own conclusion. It is a good line to add either way. When a repo automates triage, "this PR is closed" stops meaning "a human rejected this" and starts meaning "a bot ran." Anything downstream that reads closure as a signal, human or agent, is now reading a stale convention. Status, honestly Merged to main , which is the 4.x line, 289 commits ahead of the latest release. It is not in any release. v3.4.6 released 08-05 still has all six stray literals =3.4.5,<4 , as I do across eleven packages, you consume the broken version until someone backports it or you move to 4.x. Also worth saying plainly: this never broke my own CI, because my gate is ty , not mypy. It breaks downstream consumers of my package who run mypy. I found it by running a checker my project does not run. The takeaway A green gate proves that the checker you ran agrees with you. It does not prove the code is right. Three type checkers looked at this file. One saw the bug. FastMCP happened to run one of the two that did not, and shipped it. I happened to run the third, for unrelated reasons, on code I needed for a security fix. If you maintain a typed Python library, the cheap version of this lesson is: run a second checker in a non-blocking job. You do not have to fix what it finds. You just have to be able to see it. Issue: PrefectHQ/fastmcp#4773 PR: PrefectHQ/fastmcp#4774 Top comments (0)
Comments
No comments yet. Start the discussion.