fastmcp(feat): Support FastMCP 4 and collect tools from a live server - #78
Open
tony wants to merge 28 commits into
Open
fastmcp(feat): Support FastMCP 4 and collect tools from a live server#78tony wants to merge 28 commits into
tony wants to merge 28 commits into
Conversation
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #78 +/- ##
==========================================
+ Coverage 93.05% 93.28% +0.22%
==========================================
Files 276 278 +2
Lines 23095 23942 +847
==========================================
+ Hits 21491 22334 +843
- Misses 1604 1608 +4 ☔ 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.
Records the branch under the unreleased entry: the SDK v2 field-name reads, collecting tools off the live server, the pickling and duplicate-name fixes, rendered resource annotations, and the cooldown exemption. Restores the maintainers placeholder the earlier draft overwrote. It marks where forthcoming notes go and has sat under the unreleased header since the project was scaffolded; nothing but a release moves it. Applies the fixed subheading order from WRITING.md. Rendering a resource's annotations is a feature, not a fix -- it moves to `### What's new`, which belongs above `### Fixes`. Drops the FastMCP patch-cadence narrative from the dependency entry. A version range that was true this week is the fragile-metric anti-pattern; the capability is that a refreshed lock no longer lags what the tests target.
why: docs.yml deploys only on push to main, so the FastMCP 4 collector work cannot be seen rendered before it merges. Reviewing the resource annotation cards and the live-server tool sections needs a real build. what: add fastmcp-4.x to the workflow's branch list. The paths-filter already lists .github/workflows/docs.yml as publishable, so this commit triggers its own deploy. Revert before merge. The deploy is not branch-aware: it syncs to the production bucket with --delete and purges the CDN, so while this is live gp-sphinx.git-pull.com serves unreleased docs.
why: fastmcp_server_module was documented as collecting prompts and resources only. This branch makes it collect tools too, and take precedence over fastmcp_tool_modules, so three passages described behaviour the code no longer has. what: correct the confval description, the conf.py snippet and the "Live server collection" section; state the precedence rule and the duplicate-name warning next to the on_duplicate sentence that would otherwise mislead. Both resource directives now say which facts their card renders, which is what autodirectives publishes. Add docs/_ext/fastmcp_demo_server.py so the site demonstrates what it documents: a resource carrying every annotation, one carrying none, a template and a prompt. It registers no tools, so collect_tools still falls through to fastmcp_tool_modules and the demo tool cards are unaffected. Also tighten _SCHEMA_NOTE_RE. Matching by shape alone ate a written final paragraph asking the reader for a JSON schema; requiring the schema object after the colon separates the generated note from prose. Found while checking a downstream report of curly quotes inside the note.
why: under PEP 563 an annotation reaches the collector as the author's source text, which is the best display available -- until it carries Annotated metadata, because that metadata is code. A parameter declared with a description built at import time rendered the call that produced it rather than its value. what: _strip_annotated reduces Annotated[X, ...] to X, handling both the source-string and resolved-object forms. Every site that enriches a type from a signature routes through it: extract_params for the module modes, and the prompt argument enrichment. Text-level stripping rather than typing.get_type_hints, which fails on TYPE_CHECKING-only names -- sphinx.util.typing falls back to raw __annotations__ on three exception types for exactly that reason, and sphinx-autodoc-typehints goes further and still falls back.
why: parameter tables were built from the Python signature, which disagrees with what the server serves. An injected Context was documented though FastMCP excludes it from the schema and no caller can pass it, and its default rendered as the repr of a sentinel -- an object address, so two builds of one source emitted different HTML. what: _params_from_schema takes the parameter set, required flags, defaults and descriptions from the tool's published schema, and keeps the signature only for the type display the schema cannot express in Python terms. Defaults render from JSON values, so they are stable. The schema already carries both NumPy docstring text and evaluated Field descriptions, so reading it costs no authoring convenience. Module-scanning modes are unchanged; they have no schema to read.
why: reading the server's registry replaced the module modes rather than adding to them, and read less than the registry holds. Four consequences, each measured: - A mounted child server's tools are absent from the parent's registry, and the early return discarded the module entries that were the only record of them. A parent with one mounted child documented one tool where the server serves two. - ProxyTool and FastMCPProviderTool carry no `fn`, so filtering on one dropped every proxied or provider-backed tool without a warning. - MCP SDK v1 publishes the documented camelCase names as attributes. Reading only the v2 fields emptied hints and lastModified for FastMCP 3 consumers, and the extension declares no version floor. - collect_tools and collect_prompts_and_resources each resolved fastmcp_server_module, so a factory ran twice and the two collectors read different servers. - A parameter declared with Field(alias=...) publishes the alias, which names no signature parameter, so its type rendered as an em dash. what: merge server-collected tools over module-collected ones instead of replacing them; document components with no callable from the schema alone; fall back to the v1 attribute only when the v2 field is absent, so a v2 model never touches its deprecated alias; resolve the server once per build; and describe an aliased parameter from its schema type.
…unts why: components were read from the server's own registry, which a mounted child server does not appear in. A parent with one mounted child served two tools and documented one, and the module-scanning fallback was the only thing keeping such a tool on the page at all. what: walk `server.providers` recursively, following a provider's wrapped server, with a depth cap and a cycle guard. A namespaced mount renames what it carries, so reproduce the prefix from the transform rather than reading past it -- a name the server does not serve is worse than a missing page, and a transform whose renaming cannot be reproduced is refused with a warning instead. `list_tools()` is not the primitive here even with middleware disabled: it still filters by enabled state and auth, so a tool switched off at runtime would vanish from its own documentation. The test asserts the collected names equal `list_tools(run_middleware=False)` for a plain and a namespaced mount, so the two cannot drift apart silently.
… does why: the walk prefixed every component's name. A namespace moves into a resource's URI, not its name, so a resource mounted under one was documented as one_thing at data://thing where the server serves thing at data://one/thing -- both halves wrong, and a directive using the served URI could not resolve. Checking tools alone had proved nothing about resources. The cycle guard also suppressed any server already visited, so one child mounted under two namespaces documented one of its two served names. A repeated mount is not a cycle. what: apply each transform through its own `_transform_uri` and `_transform_name`, choosing by whether the component is URI-keyed, so the rule stays FastMCP's rather than a copy of it. Scope the guard to the active traversal path. The test now asserts collected identity equals what the server lists for tools, resources and prompts, and for a child mounted twice.
…sforms why: four ways the collected identity still diverged from the served one. Nested mounts applied the outer namespace first, documenting inner_outer_hello where the server serves outer_inner_hello. A transform added to the server itself was ignored, so a server serving public_hi documented hi. A component with no callable lost its description, leaving newly documented proxy tools with an empty card. And a union parameter reaching the schema fallback kept only its first alternative. what: build the transform chain inner-to-outer; collect a server's own transforms alongside its providers'; fall back to the component's description when there is no callable docstring; and give _schema_type_text the union handling _template_params_from_schema already had, so both callers share one renderer rather than two.
why: seven more ways the collected identity diverged from the served one, each measured against list_tools(run_middleware=False): - mount(..., tool_names=...) wraps the provider in a ToolTransform, which has no namespace methods, so the whole mount was refused with a warning that named nothing; with a namespace around it the provider was wrapped twice and peeling one layer found nothing and said nothing. - A transform added to a provider directly, or to a child server, was either ignored or applied after the namespace it sits inside, so api_hello documented as hello and outer_inner_hello as inner_outer_hello. - Annotated metadata nested inside a container survived stripping, so dict[str, Annotated[int, Field(description=f'...')]] still leaked code. - A schema property with no default key documented None, which a default_factory parameter does not accept. - Server-collected tools were merged over module ones with a dict update, discarding a same-named module entry without the collision report every other duplicate gets. - A mount tree past the depth cap was cut off silently. - An empty annotation list rendered a blank fact row as if set. what: treat a ToolTransform's rename map as reproducible alongside a Namespace; peel every wrapper layer and lead the chain with the innermost provider's own transforms; strip Annotated anywhere in the expression tree; render a default only when the schema publishes one; merge served tools first through _index_by_unique_name; warn at the depth cap; skip an empty list annotation. Every case is asserted equal to what the server lists, for tools, resources and prompts.
…nnot be read
why: four more divergences from what the server serves, each measured.
- A ToolTransformConfig renames arguments and changes title, description
and tags; copying only its name documented lookup(raw_query) with the
original title where the server publishes lookup(query) titled Look Up.
- An OpenAPI or other dynamic provider lists its tools only
asynchronously and holds no registry, so the walk skipped it without a
word and documented an empty index for a server that serves tools.
- A boolean property subschema -- {"properties": {"payload": true}} is
valid JSON Schema -- raised AttributeError and aborted the build.
- @tool(description=...) on a function with no docstring documented
nothing, because the fallback only covered a missing callable.
what: apply a matching ToolTransformConfig to the component through its
own apply(), which is synchronous and yields the same TransformedTool the
server lists; warn naming any provider with no registry to read; treat a
boolean subschema as one with no fields; fall back to the component's
description whenever the docstring is empty.
…roduce why: four more divergences from what the server serves, each measured. - A ToolTransform was applied to any component carrying `parameters`, which a resource template also does; a template sharing a targeted tool's name raised TypeError and aborted the build. - A transformed tool's callable is FastMCP's forwarding function, so module attribution landed on FastMCP's own module and every summary link for a renamed tool pointed at tool_transform/. - A custom Transform on a server or provider was filtered out and the pre-transform names published, where the mount path had refused and warned. The Transform contract is async-only, so a rule that is not data cannot be reproduced; publishing past it is the wrong name. - A registry-less provider inside a namespace fell through without the diagnostic its un-namespaced form already got. what: apply a ToolTransform only to a Tool; follow parent_tool to the original callable for attribution; fail closed with a warning at the server and provider levels, matching the mount path; warn on the namespaced fallthrough. Remove the local-registry fallback, which re-published exactly the identities a refusal had declined.
why: reproducing each server-side rename as data could never be general. The Transform contract is entirely async, so a custom transform, a dynamic provider, and -- measured -- a tool disabled with server.disable() all forced the walk to refuse, and the last emptied the whole server: a silently empty index, the exact failure the refusal existed to prevent. what: list through FastMCP's own Provider.list_tools/resources/templates/ prompts on an event loop owned by a background thread, the shape sphinx_vite_builder._internal.bus already uses; asyncio.run raises inside sphinx-autobuild's loop, a thread-owned one does not. Those listings apply every mount, namespace, rename and custom transform exactly as the server does, and keep disabled components -- filtering by enabled state and auth happens one level up in FastMCP.list_*, which is deliberately not used. Through that listing a mounted tool is a proxy with no callable, so _origin_tool follows parent_tool and the proxy's server back to the tool that has one, matching a namespaced name by Namespace's documented namespace_name shape. Attribution, docstrings and Python type display are unchanged from the registry walk on every shape it handled. Measured equal to server.list_*(run_middleware=False) on ten shapes, plus a disabled tool kept where that listing drops it. The refuse-and-warn tests for custom transforms and dynamic providers become served-identity assertions, since those are now documented rather than refused.
why: the listing was only unfiltered at the root. FastMCPProvider lists a mounted child by calling the child's own list_tools(), which drops disabled components and runs the child's middleware -- so a tool switched off or gated inside a mounted server disappeared from its documentation, the failure listing through the provider level exists to prevent. Measured: a child's disabled tool survives direct collection and vanishes under a mount. Two more, both from the proxy that mounting inserts: - Recovering the wrapped tool by stripping the namespace off the served name picks a sibling literally called namespace_name. A child defining hello and ns_hello, mounted under ns, documented the decoy's description and types for both. - Replacing the callable with the origin's made a transformed tool contradict its own schema: Tool.from_tool retyping an argument to str still documented int. what: descend into a mounted child and compose each provider's and server's transforms the way Provider.list_* does, instead of asking the child for its own listing. Match a proxy on the _original_name it recorded. Use the origin callable only where it is the truth -- module attribution, and a proxy with no transform between; a transformed tool's types come from the schema it publishes.
…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.
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
lastModifiedresolved to nothing at all.fastmcp_server_moduleis set, tools are read off the running FastMCP instance instead of being replayed through a hand-written collector whosetool()accepted four keyword arguments where FastMCP's accepts many more — one unrecognised keyword aborted the rest of its module, and the build still succeeded.audience,priorityandlastModified. They were collected ontoResourceInfoand never emitted, so no reader could see who a resource is for or when it last changed.PicklingErroronce the server's own callable reachedToolInfo— a tool registered inside aregister(mcp)factory is a closure, and Sphinx pickles its environment between builds.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:_tools_from_server()and_tool_from_component()buildToolInfofrom registeredToolcomponents;collect_tools()prefers that path when a server is configured. Module attribution comes fromTool.fn.__module__rather than a tool's position infastmcp_tool_modules._collector.py:_index_by_unique_name()gives the name-keyed tool and prompt indexes first-wins semantics with a warning, matching what the URI-keyed resource index already did._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.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: a realFastMCPserver fixture covering tool annotations, a resource withlast_modified, and a generated prompt-argument schema.tests/ext/fastmcp/test_directives_integration.py: asserts the annotation facts reach the rendered page.Design decisions
Read the component registry, not
list_tools().list_tools()routes through middleware, so a server that hides tools at runtime would have them erased from its own documentation. Reading the provider registry directly is what prompts and resources already do.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.
Leave the module-scanning modes alone. Projects without
fastmcp_server_modulekeep the previous behaviour, including the catch-all aroundregister(). Narrowing that is a separate change with its own compatibility question.Test plan
uv run ruff check .— cleanuv run ruff format . --check— cleanuv run mypy .— cleanuv run pytest— the FastMCP extension suite passes, including the new real-server tests; doctests in_strip_schema_notecover the reworded and multi-paragraph casestest_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