fastmcp(feat): Support FastMCP 4 and collect tools from a live server - #78
Merged
Conversation
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #78 +/- ##
==========================================
+ Coverage 93.05% 93.41% +0.35%
==========================================
Files 276 278 +2
Lines 23095 24125 +1030
==========================================
+ Hits 21491 22536 +1045
+ Misses 1604 1589 -15 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
tony
force-pushed
the
fastmcp-4.x
branch
2 times, most recently
from
September 5, 2026 12:04
f3d6362 to
d9aec8a
Compare
The global `exclude-newer = "3 days"` cooldown holds every release back, including FastMCP's, so the collector's real-object tests would run against a FastMCP a patch behind the one being targeted. `false` exempts a package from any `exclude-newer` constraint without committing a date that would age into the lockfile. Both halves need it. `fastmcp` is a code-free metapackage whose only job is depending on `fastmcp-slim[client,server]` at its exact version, so exempting one without the other leaves the pair unresolvable. Filed ahead of the gp-libs entry, which keeps its own note. https://gofastmcp.com/getting-started/installation https://docs.astral.sh/uv/reference/settings/#exclude-newer-package
SDK v2 renamed the annotation model fields to snake_case, keeping camelCase as serialization aliases. Tool hints survived only through a FastMCP shim that warns and is going away; a resource lastModified resolved to nothing, so it vanished from pages silently. Match FastMCP prompt schema note by shape rather than by its exact sentence, which v4 reworded, and only in a final paragraph so a multi-paragraph description survives. Documented names are unchanged; only the reads move.
sphinx-autodoc-fastmcp introspects a FastMCP server but declared no FastMCP dependency, so its tests could only use hand-written stubs -- which answer whichever spelling they were written with, and so cannot catch an SDK rename. Dev-only; the extension still works without FastMCP installed.
Builds a real server with real ToolAnnotations, a last_modified resource and a generated prompt-argument schema, so a rename in FastMCP object model (https://gofastmcp.com/servers/tools) fails here rather than silently emptying a documentation page. Skips when FastMCP is absent.
The mock collector took 4 kwargs where FastMCP takes many more, and the module loop swallowed the resulting TypeError with a warning -- so an unrecognised keyword dropped the rest of its module and the build still passed. Measured: a three-tool module documented one. Read Tool components off the server instead, taking module attribution from Tool.fn.__module__ rather than the config list position. Reads the provider registry directly like prompts and resources do, not list_tools(), so a runtime toolset gate cannot erase tools from the docs. Ledger: .git/spike/fastmcp-v4-tool-collection.md
FastMCP permits two tools or prompts to register under one name when they differ another way, so both are served. The name-keyed docs index silently kept the last. Keep the first and warn, as the URI-keyed resource index already does. Ledger: .git/spike/collector-collision-pass2.md
audience, priority and lastModified were collected onto ResourceInfo and then dropped: the card builder never took them, so they reached no page. Emit them as card facts beside the MIME type, only when set. Ledger: .git/spike/fastmcp-v4-metadata.md
Reading tools off a live server put the server callable on ToolInfo, and a tool registered inside a register(mcp) factory is a closure -- unpicklable, so Sphinx died on the environment cache and no incremental build completed. Drop the field when pickling. Nothing reads it; params, return_annotation and docstring are all captured at collection time. Found by an end-to-end docs build, which the collector-level check could not have caught.
The lockfile manifest still carried the resolved `exclude-newer` timestamp for `fastmcp` and `fastmcp-slim`, so it disagreed with the `false` now in pyproject.toml and `uv lock --check` failed. No version moves: fastmcp stays at 4.0.2. `uv lock` rewrites only the two manifest entries, because it holds existing pins unless a constraint forces them to change.
…egate why: descending past a provider to read it unfiltered also stepped past FastMCP's own handling of two things it does around that call. - An aggregate defaults to provider_error_strategy="warn": an unreachable remote is logged and its siblings still serve. Listing providers directly inherited none of that, so one failing provider raised out of builder-inited and the build produced no documentation at all, where the server itself still answered with its healthy tools. - An AggregateProvider holds providers rather than a server or an inner provider, so it fell through to its own listing, which re-enters each child's filtered one. A mount's disabled tool survived directly and vanished once wrapped in an aggregate -- the boundary fix from 8512ace, one wrapper out. Separately, the docstring outranked the published description, so an explicit description= or one a transform rewrote lost to the function's own docstring. What the server publishes is what a caller reads. what: gather providers through the holder's configured failure strategy, recurse into an aggregate's providers, and prefer the component's description with the docstring as fallback.
…rote
why: three ways collection still lost to something it should have survived.
- The listing was bounded as a whole, on the future the Sphinx thread waits
on, so the timeout fired outside the per-provider failure policy. A
provider that hangs aborted the build with TimeoutError even under
provider_error_strategy="warn", where a provider that raises was already
logged and skipped.
- The schema-note pattern matched any final paragraph asking for a JSON
schema, so an authored description -- 'Provide a JSON schema: {"type":
"object"}.' -- was erased entirely. Both generated spellings say
"matching the following"; an author does not.
- A rename-only transform yields an unannotated forwarding callable, so the
Returns fact disappeared from a tool that returns exactly what it did
before. Its published output schema still describes the result.
what: bound each provider with asyncio.wait_for inside the failure policy,
so a slow remote is logged and skipped like a failing one; require
"matching the following" in the note pattern; and fall back to the output
schema for the return display, unwrapping the single `result` property
FastMCP uses for a non-object return.
…t matrix why: each round of review has found a defect on a shape the previous tests did not vary, and a passing suite kept reading as coverage it was not. The 64-cell matrix that returned no mismatches was built entirely with mount(), so it missed both an aggregate provider re-entering a filtered listing and a failing provider aborting the build. what: parametrize over how a child is attached -- mount, add_provider, an aggregate wrapper, each with and without a namespace, plus a child carrying its own transform -- at one and two levels, asserting collected identity equals what the server lists for tools, resources, templates and prompts. A second case asserts a disabled tool survives every attachment, since each reaches its child by a different path. Removing either boundary fix fails the matrix on exactly the shapes it governs, which the point tests could not do.
…ring it why: three places inferred a fact the server states outright, and two of them were regressions from the previous round's own fixes. - The provider timeout wrapped a whole subtree, so a slow descendant discarded the healthy components already gathered beneath it. Measured: a child listing ['local'] directly listed [] through a mount. - A Field(alias=...) publishes under a name that may be another parameter's own. Looking the published name up in the signature borrowed that other parameter's annotation, so the table said str for a property the server accepts as integer. Alias ownership is in the declaration. - A TypedDict whose only field is result publishes the same shape as a generated wrapper, so a renamed tool returning an object documented the inner type. FastMCP marks the wrappers it makes with x-fastmcp-wrap-result. what: bound the leaf listing rather than a subtree; map published alias to declaring parameter before consulting the signature, resolving deferred annotations first since PEP 563 hides the metadata that carries the alias; and unwrap only a schema carrying the marker.
…an stall why: three defects, one of which the gates could not see. - The real-server fixture used typing.TypedDict, which pydantic rejects below Python 3.12. Verified against a real 3.10 with this branch's pins: PydanticUserError at tool registration. - A Field carrying an alias may be the parameter's default rather than Annotated metadata. Reading only the metadata form left the published name matched against the wrong parameter, so a property the server accepts as integer documented as str -- the same defect as the Annotated case, through the other spelling. - _PROVIDER_TIMEOUT bounded the underlying _list_* call but not the transforms wrapped around it, and the outer wait has no deadline, so a transform that stalls held the build open. Measured: 34s to return where the bound was 0.5s. what: take TypedDict from typing_extensions; read an alias from either carrier, including validation_alias; and bound each transform call the way the listing beneath it is bounded.
why: Renamed tools lose model types when schemas contain local refs. what: - Resolve pointers and union members with cycle protection.
why: Aggregate subclasses can refresh components while listing. what: - Traverse inherited listings and invoke overridden listings.
why: Input aliases can collide with another signature parameter. what: - Match validation aliases before serialization aliases.
why: Forwarding kwargs can shadow a published tool argument. what: - Use schema types for variadic signature parameters.
why: Python 3.10 uses Sphinx 8, whose inventory entries are tuples. what: - Read canonical URIs from both supported inventory formats.
`rm -rf {{ builddir }}/*` leaves `.doctrees` and `.buildinfo`: bash does not
match dotfiles with `*`. The next build is then incremental and skips
unchanged documents, so warnings that only fire on first read do not appear
and a warning count taken after `just clean` reads lower than a real build.
The recipe's own second line already uses the un-globbed form.
why: Inline code kept a separate background when an API header was hovered. what: Make signature code transparent within the shared API header.
why: pnpm temporary probes modify the watched Vite directory and trigger another Sphinx build. what: Ignore those exact build events in start and design while retaining source-file watching.
why: Live-server tools already have documented precedence over module entries, so reporting each overlap as a duplicate adds build noise. what: - Skip module entries whose names the server owns. - Verify both collection modes preserve server metadata and module fallbacks while genuine module collisions still warn.
why: Collector diagnostics bypassed Sphinx, allowing strict builds to succeed without recording or counting collection warnings. what: - Route collector warnings through Sphinx with fastmcp categories. - Give name collisions a duplicate subtype for selective suppression. - Exercise strict CLI exit status and warning files, and document the warning policy alongside the collector configuration.
why: The checked-in .python-version made every QA job use Python 3.14, including jobs labeled 3.10 through 3.13. what: - Set UV_PYTHON from the job matrix for dependency sync and checks.
why: Python 3.10 and 3.11 omit the TypedDict base metadata needed to inherit key descriptions. The fixture assumed Python 3.12 behavior. what: - Use typing_extensions.TypedDict for this fixture before Python 3.12 - Document the backport requirement for inherited key descriptions - Verify the assertion still fails when base traversal is removed
why: The matrix now selects each Python, so mypy runs against the newest Sphinx each interpreter can install. Sphinx 8.1 on 3.10 declares resolve_any_xref returning list[tuple[str, Element]] and 8.2 narrows it to list[tuple[str, reference]]; list is invariant, so no single annotation satisfies both supertypes. tomli is installed below 3.11 and absent above it, so its import ignore is needed in one environment and unused in the other, and the cast on its parsed result is redundant only where it is typed. what: Pair each environment-dependent ignore with unused-ignore, and replace the tomllib cast with a typed local. Annotations only, no runtime change.
why: Sphinx 8.1's add_html_theme takes a str; 8.2 widened it to accept os.PathLike. Passing a Path failed the type check on the newest Sphinx that Python 3.10 can install. what: Convert the path at the call site, matching gp-furo-theme, which already does this, and follow the fake through its test and doctest.
why: Sphinx 8.2 moved autodoc's per-document annotation store from env.temp_data["annotations"] to env.current_document.autodoc_annotations. The extension read the new attribute unconditionally while declaring sphinx>=8.1 and requires-python>=3.10, and 8.1 is the newest Sphinx that 3.10 can install. Reading it there raises AttributeError, so every type the extension records is lost: eight integration tests error out on 3.10 once the matrix actually selects that interpreter. The type checker reported this as a missing attribute, but it is a crash, not an annotation gap. what: Read the 8.2 attribute when present and fall back to the 8.1 mapping, returning the live store in both so recorded annotations are not dropped. Cover each store with a regression test; the fallback test asserts writes land in temp_data, which a copy would silently lose.
why: The real Python matrix exposed fixtures that assumed StrEnum, NotRequired and InventoryFile.loads were available everywhere. what: - Exercise a string enum and backported NotRequired on Python 3.10 - Read inventories through the existing Sphinx 8.1-compatible API - Preserve all signature parity and inventory assertions
why: The summary directive built refuri by interpolating the configured
fastmcp_area_map value, so the link resolved against whatever directory the
page happened to sit in. On this project's own docs that 404s twice: from
/gallery/ it resolves to /gallery/packages/sphinx-autodoc-fastmcp/examples/,
and from the examples page it appends the path to itself. Nothing catches it
-- these are generated hrefs, not cross-references, so -W stays green.
The area map is also the wrong source. It is a configured guess at where a
tool is documented; the registered label knows where the card actually is.
what: Emit the same placeholder node the {tool} role emits and let
resolve_tool_refs do the work. It resolves through the canonical label and
calls builder.get_relative_uri with the real fromdocname, which the directive
cannot do at parse time. A tool with no card now renders as plain literal
text rather than a link to a page that may not exist.
Two collision fixtures asserted the old behaviour and are updated: one
expected href="api/#..." in a scenario with no "api" document -- a broken
link encoded as a passing test -- and now asserts that raw area path never
appears; the other counts two canonical in-page links because the summary
resolves the same way the inline toolref does.
Verified: all 283 assets on the built site resolve 200, where two 404'd
before; mypy clean under 3.10/Sphinx 8.1.3 and 3.14/Sphinx 8.2.3.
why: The guard compared the declared axis name to COMPONENT_KINDS verbatim, so fastmcp_axes with an axis named "Tool" cleared it. The anchors it then generates go through make_id, which lower-cases, so they collide with the canonical tool ids exactly as a lower-case "tool" axis would -- the guard let through the case it exists to catch. what: Case-fold before the membership test, and cover the capitalised form in the doctest.
why: The fastmcp_server_module description told users the collector reads local_provider._components directly. It has not done that for some time -- it walks the server's providers, descends into mounted servers and aggregates, and applies each provider's transforms, all under a timeout. The string is shipped confval help, so the inaccuracy renders into every consumer's documentation. `local_provider._components` now appears nowhere in the package except that sentence. what: Describe the behaviour instead of the internals. Naming a private attribute is what let the text rot without anything noticing; the replacement says what the collector enumerates and why, which stays true across refactors of how it gets there.
why: Sphinx seeds genindex, modindex and search into the standard domain before any document is read. A tool named after one of them can never claim its bare-slug alias, so the collision warning fires on every build, forever, with no configuration that avoids it and nothing for an author to do. This extension already treats the case as expected: the comment above the canonical-first lookup in _transforms.py says such a tool still links to its own card precisely because the built-ins occupy those labels first. Warning about a condition we document as handled is the wrong severity. It matters now rather than later. These diagnostics are moving onto Sphinx's logger, and consumer docs builds are gaining -W. A consumer with a tool named `search` would get a permanently red gate for a name collision that degrades nothing. what: Skip the warning when the existing claimant is one of Sphinx's seeded labels, identified by its initial_data entry rather than by name alone. Registration still declines, so the canonical id remains the only anchor. A collision with another document's label is actionable and still warns.
why: c34c51e moved the collector onto sphinx.util.logging and documented that collector warnings obey -W. The extension's other three modules were left on stdlib logging, so the eight diagnostics they raise reached stderr and nothing else: invisible to -W, absent from the -w warnings file, and unreachable by suppress_warnings. A consumer building with -W could print a warning and still exit 0, which is exactly what agentgrep's docs build did. what: Convert _directives.py, _models.py and _transforms.py to sphinx.util.logging and tag each warning with the condition class it reports: alias, axis, config, xref. One subtype per class rather than per message, so suppress_warnings stays usable. Documented as a table in the how-to alongside the existing fastmcp.duplicate. The unmatched-axis test now asserts on Sphinx's warning stream rather than caplog -- reaching that stream is the whole point of the change, and Sphinx swaps logging handlers during a build so caplog cannot observe it. Reverting either module to stdlib logging fails it.
why: 9705776 updated the logger name in this test without reformatting it, so two lines exceeded the limit and ruff format failed every qa job. what: Reformat. No behaviour change.
why: Describe the documentation changes users can expect. what: - Cover live server tools, resource metadata, warnings and links. - Note the Sphinx compatibility, preview and API header fixes.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
sphinx-autodoc-fastmcpsupports FastMCP 4.x only.lastModifiedresolved to nothing at all.fastmcp_server_module, including mounted servers, dynamic providers, namespaces and transforms. Tool names, parameters, defaults and descriptions follow what the server publishes; injected context parameters stay out of the documentation.audience,priorityandlastModified. They were collected ontoResourceInfoand never emitted, so no reader could see who a resource is for or when it last changed.-W,-wandsuppress_warningscontrols, withfastmcpcategories for selective suppression.Changes by area
Collector
_collector.py:_HINTSand_RESOURCE_ANNOTATION_FIELDSbecome documented-name/field-name pairs, so reads use the SDK v2 field while the rendered vocabulary keeps the camelCase names the MCP schema publishes._collector.py: traverse server and aggregate providers, call provider listing methods, and apply provider/server transforms in order. Mounted children are traversed without runtime filtering, so disabled tools remain documented. Provider discovery still runs; failing or timed-out providers follow the configured failure strategy._collector.py: build tool parameters from the published schema, including validation aliases, local schema references and transformed arguments. Preserve source-module attribution where a callable is available, and document components without a callable from their published metadata. Resolve the configured server once per build._collector.py:_index_by_unique_name()keeps the first tool or prompt on a name collision and warns. The server/module merge skips names already owned by the server before collision checking, so genuine module/module and server/server collisions still warn._collector.py:_strip_schema_note()matches FastMCP's generated schema hint by shape rather than by its exact sentence, and only in a description's final paragraph.Rendering
_directives.py:_annotation_fact_rows()emits set annotations as resource-card facts beside the MIME type, for both resources and resource templates._models.py:ToolInfo.funcbecomes optional, with a__getstate__that drops it so the environment pickles. Everything rendered is captured at collection time._directives.py/_transforms.py: resolve summary links through registered canonical targets relative to the page being rendered. Skip warnings for aliases reserved by Sphinx itself; continue reporting collisions with author-defined labels._models.py: reject reserved axis names regardless of capitalization.sphinx-autodoc-typehints-gp/ theme integration: retain parameter and return descriptions and theme registration on Sphinx 8.1, including Python 3.10 builds.Dependencies and tests
pyproject.toml: addsfastmcp>=4.0.2to the dev group, and exemptsfastmcpandfastmcp-slimfrom the release cooldown in[tool.uv.exclude-newer-package]— the metapackage pins its companion to an exact version, so exempting one without the other leaves the pair unresolvable.tests/ext/fastmcp/test_real_server.py: exercise real FastMCP 4 servers across mounts, providers, transforms, published schemas, disabled tools, duplicate names and environment pickling. Both module-scanning modes test quiet server precedence and preserved module-only collisions.tests/ext/fastmcp/test_directives_integration.py: asserts the annotation facts reach the rendered page.Design decisions
Preserve provider behavior before runtime filtering. Use provider listing methods and apply their transforms, traversing mounted servers and aggregates as needed. This retains discovery, namespaces and renamed components while bypassing the public listing methods' middleware and enabled-state filtering, so disabled tools stay documented.
Keep the documented names camelCase. Only the attribute reads move to snake_case. Pages,
term_from_annotationsandresolve_axesstill speakreadOnlyHintandlastModified, which is what the MCP schema publishes — renaming them would be a breaking change to consumers for no gain.Drop
funcon pickle rather than making it lazy. Nothing reads the field after collection, so a class-level__getstate__enforces that structurally instead of by convention.Fail on the compatibility shim, not just on the value. FastMCP answers the SDK v1 spellings through a warn-once shim it plans to remove, so a value-only assertion would stay green right through the removal. The hint test raises on the warning instead.
Keep module scanning available. The
registerandintrospectmodes remain available without a live server and supply tools the server does not own when both sources are configured.Make warnings actionable through Sphinx. Use the
fastmcpcategory throughout, withduplicate,alias,axis,configandxrefsubtypes where applicable. Documented server precedence is quiet; genuine collisions remain visible and can fail strict builds.Test plan
uv run ruff check .— cleanuv run ruff format . --check— cleanuv run mypy .— cleanuv run pytest— the full workspace suite and doctests pass across the Python CI matrix, including real FastMCP 4 server teststest_tool_hints_survive_the_object_model— hints resolve without tripping FastMCP's deprecation warningtest_a_configured_server_yields_tools_the_mock_would_drop— a module the mock collector would have truncated is collected wholetest_duplicate_tool_names_warn_instead_of_vanishing— the first registration is kept and the collision is namedtest_collected_tools_survive_environment_pickling— a closure-registered tool picklestest_resource_annotations_render— the annotation facts reach the built HTMLuv run sphinx-build -W -b dirhtml docs docs/_build/html— builds clean with warnings as errorsuv lock --check— the lock matches the manifest-W,-wandsuppress_warningsbehavior