Skip to content

fastmcp(feat): Support FastMCP 4 and collect tools from a live server - #78

Merged
tony merged 49 commits into
mainfrom
fastmcp-4.x
Sep 6, 2026
Merged

fastmcp(feat): Support FastMCP 4 and collect tools from a live server#78
tony merged 49 commits into
mainfrom
fastmcp-4.x

Conversation

@tony

@tony tony commented Sep 5, 2026

Copy link
Copy Markdown
Member

Summary

sphinx-autodoc-fastmcp supports FastMCP 4.x only.

  • Fix the collector's reads against MCP SDK v2, which FastMCP 4 builds on: the annotation model fields are snake_case now and the camelCase spellings survive only as serialization aliases, so tool hints resolved through a deprecated shim and a resource's lastModified resolved to nothing at all.
  • Add live-server tool collection through 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.
  • Add rendering for a resource's audience, priority and lastModified. They were collected onto ResourceInfo and never emitted, so no reader could see who a resource is for or when it last changed.
  • Fix silent loss of same-named tools and prompts, including versioned registrations. The docs index keeps the first entry and reports the collision. When a live-server tool overlaps a module-scanned tool, documented server precedence applies without a warning; module-only tools still fill the gaps.
  • Support incremental builds with live-server tools, including tools registered inside factories: collected metadata survives Sphinx environment pickling.
  • Add FastMCP as a dev-only dependency and test the collector against a real server. The extension declared no FastMCP dependency of any kind, so its tests built components from hand-written shims, and a shim keeps answering the spelling it was written with.
  • Fix diagnostic routing throughout the extension. Warnings reach Sphinx's -W, -w and suppress_warnings controls, with fastmcp categories for selective suppression.
  • Fix tool-summary links on nested pages, unnecessary warnings for built-in Sphinx aliases, and case-sensitive checks of reserved axis names.

Changes by area

Collector

  • _collector.py: _HINTS and _RESOURCE_ANNOTATION_FIELDS become 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.func becomes 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.
  • Docs preview / API layout: stop idle preview rebuilds, clear cached doctrees when cleaning docs, and blend inline signature code into API header backgrounds.

Dependencies and tests

  • pyproject.toml: adds fastmcp>=4.0.2 to the dev group, and exempts fastmcp and fastmcp-slim from 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.
  • Warning integration tests: invoke Sphinx in subprocesses to verify strict-build exit status, warning-file output and selective suppression.
  • CI: select the actual Python interpreter for each Python 3.10–3.14 job; validate against the locked FastMCP 4.0.3.

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_annotations and resolve_axes still speak readOnlyHint and lastModified, which is what the MCP schema publishes — renaming them would be a breaking change to consumers for no gain.

Drop func on 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 register and introspect modes 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 fastmcp category throughout, with duplicate, alias, axis, config and xref subtypes where applicable. Documented server precedence is quiet; genuine collisions remain visible and can fail strict builds.

Test plan

  • uv run ruff check . — clean
  • uv run ruff format . --check — clean
  • uv run mypy . — clean
  • uv run pytest — the full workspace suite and doctests pass across the Python CI matrix, including real FastMCP 4 server tests
  • test_tool_hints_survive_the_object_model — hints resolve without tripping FastMCP's deprecation warning
  • test_a_configured_server_yields_tools_the_mock_would_drop — a module the mock collector would have truncated is collected whole
  • test_duplicate_tool_names_warn_instead_of_vanishing — the first registration is kept and the collision is named
  • test_collected_tools_survive_environment_pickling — a closure-registered tool pickles
  • test_resource_annotations_render — the annotation facts reach the built HTML
  • uv run sphinx-build -W -b dirhtml docs docs/_build/html — builds clean with warnings as errors
  • uv lock --check — the lock matches the manifest
  • Server/module overlap is quiet and retains server-derived metadata in both scanning modes; module/module collisions still warn
  • Deliberately removing the precedence guard fails the overlap regression tests; checking the merged index instead fails the module-collision tests
  • Strict Sphinx subprocess builds verify -W, -w and suppress_warnings behavior
  • Nested summary links, reserved aliases and case-insensitive axis names have regression coverage
  • PR and push CI pass: Python QA, strict documentation, JavaScript, package builds and installation smoke tests

@codecov-commenter

codecov-commenter commented Sep 5, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 91.23758% with 97 lines in your changes missing coverage. Please review.
✅ Project coverage is 93.41%. Comparing base (3b0bdc1) to head (c74b7c1).

Files with missing lines Patch % Lines
tests/ext/fastmcp/test_real_server.py 90.55% 65 Missing ⚠️
...c-fastmcp/src/sphinx_autodoc_fastmcp/_collector.py 89.65% 24 Missing ⚠️
docs/_ext/fastmcp_demo_server.py 73.33% 4 Missing ⚠️
docs/_ext/package_reference.py 0.00% 1 Missing ⚠️
...doc-fastmcp/src/sphinx_autodoc_fastmcp/_parsing.py 95.65% 1 Missing ⚠️
scripts/ci/bump_version.py 0.00% 1 Missing ⚠️
scripts/ci/package_tools.py 66.66% 1 Missing ⚠️
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.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

tony added 9 commits September 5, 2026 11:53
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.
tony added 28 commits September 6, 2026 11:23
…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.
@tony
tony merged commit 57fe5e7 into main Sep 6, 2026
44 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants