Skip to content

fix: preserve disabled-version fallback when VersionFilter passes a range through a mount - #5838

Closed
BlueX888 wants to merge 7 commits into
PrefectHQ:mainfrom
BlueX888:fix/versionfilter-mounted-disabled-version
Closed

BlueX888 wants to merge 7 commits into
PrefectHQ:mainfrom
BlueX888:fix/versionfilter-mounted-disabled-version

Conversation

@BlueX888

@BlueX888 BlueX888 commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

Closes #5837

Description

With a parent VersionFilter over a mounted child and the child's latest component version disabled, the parent's list_* advertise the older enabled version (v1.0) but versionless get_tool/get_resource/get_resource_template/get_prompt return None, so call_tool/read_resource/render_prompt fail with Unknown tool / Resource not found / Unknown prompt. Only explicit version=VersionSpec(eq="1.0") calls work.

Root cause: VersionFilter forwards its policy range to the mounted child as an explicit VersionSpec. The child treats any non-None spec as an exact request, skips its disabled-version fallback, and returns None for the highest in-range version; the parent then early-returns None (its fallback only covers a non-None-but-disabled component).

What changed

  • Server.get_tool, get_resource, get_resource_template and get_prompt now treat only VersionSpec.eq as an explicit exact request. Range specs resolve to the highest enabled version within the range, selected from the filtered listing using version.matches(...) and version_sort_key.
  • The fallback for a range lookup miss re-resolves each listed candidate version, highest first, through the same super().get_*(..., VersionSpec(eq=candidate.version)) path. Listing is not authoritative for resolution, so lookup-only refusal transforms and lookup-time auth checks are honored rather than bypassed. A resolved component must match the candidate's identity and version, passes through session transforms, and must be enabled.
  • Lookup misses with no version constraint or with an exact eq request remain authoritative; lookup-only refusal semantics for unconstrained and exact lookups are preserved.
  • No changes to VersionFilter itself; explicit VersionSpec.eq behavior is unchanged.

Contribution type

  • Bug fix (simple, well-scoped fix for a clearly broken behavior)
  • Documentation improvement
  • Enhancement

Bug fix verification

Test paths: tests/server/versioning/test_mounting.py::TestMountedVersionFilterWithDisabledLatest (five tests) and ::TestMountedVersionFilterLookupRefusals (two tests).

Red at adf90b66 (fix stashed, tests kept):

>       assert tool is not None
E       assert None is not None
tests\server\versioning\test_mounting.py:421
1 failed, 4 passed

Red before the review follow-up (at d991088, follow-up stashed): the lookup-only refusal test got a v2 tool instead of None (assert FastMCPProviderTool(name='calc', version='2.0', ...) is None).

Green at the final head: 5 passed + 2 passed.

The regression tests cover all four component types (list vs versionless get vs call/read/render), plus controls: v2 enabled still resolves to v2; no parent filter + v2 disabled falls back to v1; explicit VersionSpec(eq="1.0") works and exact eq v2 remains unserved; filter on the child directly falls back; a lookup-only refusal transform under the filter still returns None for versionless get while listing passes through; an auth-denied highest version falls back to a public lower version.

Validation

  • uv run --frozen pytest tests/server/versioning/test_mounting.py -q -> 20 passed
  • uv run --frozen pytest tests/server/versioning tests/server/test_hashed_tool_dispatch.py -q -> 161 passed
  • uv run --frozen pytest tests/server/versioning tests/server/transforms tests/server/providers tests/server/test_server.py tests/server/test_hashed_tool_dispatch.py tests/server/test_session_visibility.py tests/server/test_providers.py -q -> 1600 passed, 26 warnings
  • uv run --frozen ruff check and ruff format --check on the changed files -> clean
  • uv run --isolated ty check -> All checks passed

AI assistance

Tool(s) used: Claude Code
How you used it: investigated from a verified reproduction, drafted the range-aware fallback and regression tests, and revised the fallback in response to review.

Checklist

  • This PR addresses an existing issue (or fixes a self-evident bug)
  • I have read CONTRIBUTING.md
  • I have added tests that cover my changes
  • I have run uv run prek run --all-files and all checks pass (focused checks passed)
  • I have self-reviewed my changes
  • If I used an LLM, it followed the repo's contributing conventions (not generic output)

Review follow-up

Codex review found a residual P1 path: when a range lookup resolved the highest version and later marks it disabled, the fallback could take a lower candidate straight from the listing, bypassing a lookup-only transform or provider that refuses that lower version. The fallback now re-resolves every listed candidate through the lookup path for both the lookup-miss and resolved-but-disabled cases, for all four component types. Regression tests cover disabled-highest plus lookup-refused-lower for tools, resources, templates and prompts.

The branch was also rebased onto the upstream get_resource → _lookup_resource refactor: the range fallback now lives inside _lookup_resource and preserves the new (resource, exists) hidden-vs-missing contract, with the same re-resolution rules for tools, resources, templates and prompts.

The triage review then found that a same-version duplicate could still be served from the listing after its own provider refused the lookup. Same-version components from different providers are indistinguishable by name and version, so the fallback now asks each provider whether its own lookup admits the candidate; a listed sibling that no provider admits is refused. TestProviderLookupAuthority covers a disabled first provider plus a lookup-refusing second provider, while test_duplicate_same_version_resolves_enabled_provider keeps the enabled-duplicate path covered.

@github-actions

github-actions Bot commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

This pull request was closed because it must link to an issue assigned to you. Once this PR links to an issue and a maintainer assigns that issue to you, it will reopen automatically. Please update this PR rather than opening a new one.

Per CONTRIBUTING.md, an external PR must reference an issue that's assigned to its author. To get there:

  1. If you opened the linked issue, a maintainer will assign you when they pick it up and this PR reopens automatically. If someone else opened it, the PR reopens only if a maintainer chooses to assign it to you — please don't comment to ask.

Once you're assigned and the link is present, this PR reopens automatically — no further action needed.

Maintainers: reopen this PR or remove the missing-issue-link label to bypass this check.

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-19T10:03:15.111307Z 37f0c6b PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@marvin-context-protocol marvin-context-protocol Bot added bug Something isn't working. Reports of errors, unexpected behavior, or broken functionality. server Related to FastMCP server implementation or server-side functionality. labels Oct 19, 2026

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 37f0c6be4d

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread fastmcp_slim/fastmcp/server/server.py Resolved
@BlueX888
BlueX888 force-pushed the fix/versionfilter-mounted-disabled-version branch 4 times, most recently from 3092731 to bdaafae Compare October 20, 2026 14:20
BlueX888 and others added 3 commits October 26, 2026 21:11
A parent VersionFilter forwards its range as an explicit VersionSpec to a
mounted child. The child treated any non-None spec as an exact request and
skipped its disabled-version fallback, so listing advertised an older
enabled version while versionless get_*/call_tool/read_resource/
render_prompt failed.

Only VersionSpec.eq now counts as an explicit exact request; ranges fall
back within the range. Fallback candidates are re-resolved highest-first
through the normal get_* lookup so lookup-only refusals, session
transforms, auth, and enabled state are honored rather than trusting the
listing. Resource lookup still reports whether a concrete resource exists
so hidden resources keep shadowing matching templates.
@BlueX888
BlueX888 force-pushed the fix/versionfilter-mounted-disabled-version branch from bdaafae to 39ac49d Compare October 26, 2026 14:16
@BlueX888

Copy link
Copy Markdown
Contributor Author

Follow-up on the range fallback in this PR: resolved a regression where PEP 440-equivalent version spellings could be listed but not resolved.

_resolve_listed_candidates looked a listed version up with VersionSpec(eq=version) but then required resolved.version to equal the candidate's version as a raw string, and it looked an unversioned candidate up as None ("highest version"). Because eq matches PEP 440-equivalent spellings ("1" and "1.0"), the lookup returns one deterministic representative; disabling the other spelling by key or tag left list_tools advertising the enabled spelling while get_tool returned None and call_tool raised NotFoundError. The base revision resolves this configuration.

The fallback now groups candidates by normalized version and asks the lookup path once per group:

  • a group the lookup path refuses is still skipped, so lookup-only refusals and lookup-time auth stay authoritative at version granularity;
  • within a resolvable group it returns the highest enabled and authorized component, preferring the lookup result, so a disabled sibling cannot shadow an enabled one.

This also fixes the two same-root-cause cases from the review: an unversioned component being looked up as "highest version" when a higher same-name version is disabled (all component types), and duplicate same-name same-version components from different providers.

Tests: uv run pytest -n auto tests/server/versioning tests/server/mount -> 233 passed. Four regression tests were added to tests/server/versioning/test_visibility_version_fallback.py (equivalent spellings disabled by key and by tag, unversioned fallback, duplicate same-version providers); all four fail on the previous head eeaf11d and pass here. ruff check, ruff format --check, and ty check are clean.

Known limitation: a lookup-only refusal cannot be evaluated per component when two components share one normalized version (same name and version from different providers, or equivalent spellings), because the lookup path can only admit or refuse the version as a whole. Version-keyed refusals, the pattern the transform API exposes, are honored for the whole group.

…er-merge

# Conflicts:
#	fastmcp_slim/fastmcp/server/server.py
@BlueX888

Copy link
Copy Markdown
Contributor Author

Merged current main into the branch in eef9f6f, combining the range-fallback logic with main's auth-aware highest-available-version fallback. Targeted runs: tests/server/versioning 145 passed; tests/server/auth/test_authorization.py 102 passed, 2 skipped; tests/server/test_server.py 25 passed.

@fastmcp-triage-reviewer fastmcp-triage-reviewer Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated review by the FastMCP triage reviewer bot.

The same-version fallback can execute a tool its provider refuses. With two custom providers listing [email protected], disabling the first provider's tool lets its successful lookup admit the second provider's listed tool even when that provider's get_tool returns None. Component auth checks do not enforce this lookup refusal.

Preserve each candidate's lookup authority instead of admitting all listed siblings after one version-level lookup succeeds. Add a disabled-first/refused-second provider regression that asserts the refused function never executes, while retaining enabled-duplicate coverage.

Checks

Independent provider probe executes the refused function at this head; baseline returns None. Focused tests: 42 passed at head, 6 failed on baseline. Adjacent versioning tests: 103 passed.

@BlueX888

Copy link
Copy Markdown
Contributor Author

CI caught one merge regression: a plain resource lookup miss was falling back to list_resources, which reached mounted servers and invoked nested middleware. Fixed in 97d7a69: a plain miss is final again; only denied/disabled resources fall back (exact requests stay final). Re-ran: versioning+middleware 146 passed; middleware nested + auth authorization + server 144 passed, 2 skipped.

@github-actions github-actions Bot closed this Nov 11, 2026
@BlueX888

Copy link
Copy Markdown
Contributor Author

Addressed in 3d907e7. _resolve_listed_candidates now checks same-version siblings against each provider's own lookup authority for all four component types, so a listed component that its provider refuses can no longer be served after another provider's lookup admits the version. TestProviderLookupAuthority covers a disabled first provider plus a lookup-refusing second provider and asserts the refused tool never executes while remaining listed; test_duplicate_same_version_resolves_enabled_provider keeps the enabled-duplicate path covered.

Verification: 146 versioning tests, 1583 auth+provider tests, prek on the changed files.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working. Reports of errors, unexpected behavior, or broken functionality. missing-issue-link server Related to FastMCP server implementation or server-side functionality.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug] VersionFilter over a mount lists the older enabled version but versionless calls fail when the latest version is disabled

1 participant