Skip to content

Refactor visibility to mark-based enabled system#2912

Merged
jlowin merged 5 commits intomainfrom
jlowin/mark-based-visibility
Jan 18, 2026
Merged

Refactor visibility to mark-based enabled system#2912
jlowin merged 5 commits intomainfrom
jlowin/mark-based-visibility

Conversation

@jlowin
Copy link
Copy Markdown
Member

@jlowin jlowin commented Jan 18, 2026

The visibility system now uses a simpler, mark-based approach where each enable() or disable() call adds an immutable Enabled transform. Transforms mark components via internal metadata rather than filtering inline, allowing later transforms to override earlier ones.

The key architectural change: enabled filtering now happens at the Server level, not Provider level. This lets server-level (or session-level) transforms override provider-level disables - so you can disable everything at the provider but selectively re-enable at the server.

from fastmcp import FastMCP

mcp = FastMCP("Server")

@mcp.tool(tags={"admin"})
def dangerous_tool() -> str:
    return "deleted"

# Disable by tag
mcp.disable(tags={"admin"})

# Later, selectively re-enable (overrides the disable)
mcp.enable(name="dangerous_tool")

Also renames VisibilityEnabled and collapses VisibilityRule into the transform class.

Closes #2910

Rename Visibility to Enabled, collapse VisibilityRule into the transform,
and move enabled filtering from Provider to Server level so server-level
transforms can override provider-level disables.
@marvin-context-protocol marvin-context-protocol Bot added enhancement Improvement to existing functionality. For issues and smaller PR improvements. breaking change Breaks backward compatibility. Requires minor version bump. Critical for maintainer attention. server Related to FastMCP server implementation or server-side functionality. labels Jan 18, 2026
Copy link
Copy Markdown

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

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: 50ba6ea5a4

ℹ️ 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 on lines +285 to +286
if not enabled:
self.disable(name=tool.name)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Scope disable to tool version/type when enabled=False

Here enabled=False calls disable(name=tool.name) without components or version, but Enabled._matches treats a name-only rule as matching all component types and all versions. If you have multiple versions of the same tool (or a prompt/resource sharing the same name), disabling one tool via the decorator will also disable those other components, which is a behavior regression from the prior key-based disable. Consider passing components=["tool"] and version=tool.version (or equivalent) to keep the disable scoped to the intended tool instance.

Useful? React with 👍 / 👎.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Jan 18, 2026

Caution

Review failed

The pull request is closed.

Note

Other AI code review bot(s) detected

CodeRabbit has detected other AI code review bot(s) in this pull request and will avoid duplicating their findings in the review comments. This may lead to a less comprehensive review.

Walkthrough

This PR replaces the Visibility system with a mark-based Enabled transform that annotates components via internal metadata and defers final filtering to providers/servers. The Visibility transform module and ComponentService subsystem were removed; a new Enabled transform and is_enabled helper were added and re-exported. Provider.enable/disable signatures changed to accept names, version, tags, components and return Self (with an only allowlist flag); server-level enable/disable methods were removed and server get_* helpers now apply post-transform enabled filtering. Decorator metadata for tools, resources, and prompts gained an enabled: bool = True field and component-manager endpoints now call server.enable()/server.disable() directly. Documentation and navigation were renamed from “visibility” to “enabled.”

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 76.54% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the main architectural change: refactoring the visibility system to use a mark-based enabled system instead of the previous approach.
Description check ✅ Passed The pull request description is mostly complete, including a clear explanation of the architectural change, example code, and a reference to the closed issue (#2910). All key changes are explained.
Linked Issues check ✅ Passed The pull request successfully implements all major requirements from issue #2910: replacing the visibility system with mark-based enabled transforms, moving filtering to server level, renaming Visibility to Enabled, and allowing later transforms to override earlier ones.
Out of Scope Changes check ✅ Passed All changes are aligned with the PR objectives. Documentation updates reflect the terminology shift from visibility to enabled, code changes implement the new mark-based system, and the public API changes (enable/disable signatures, Enabled transform) directly support the stated goals.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 5

🧹 Nitpick comments (4)
docs/docs.json (1)

586-610: Consider adding redirects for old visibility paths.

The redirects section handles several legacy paths but doesn't include redirects from the old visibility URLs to the new enabled URLs. Users or external links referencing /servers/visibility or /python-sdk/fastmcp-server-transforms-visibility would receive 404 errors.

♻️ Suggested redirects to add
     {
       "destination": "/servers/providers/transforms",
       "source": "/patterns/tool-transformation"
+    },
+    {
+      "destination": "/servers/enabled",
+      "source": "/servers/visibility"
+    },
+    {
+      "destination": "/python-sdk/fastmcp-server-transforms-enabled",
+      "source": "/python-sdk/fastmcp-server-transforms-visibility"
     }
   ],
src/fastmcp/server/transforms/__init__.py (1)

234-237: Consider removing unused noqa directives.

Static analysis indicates the # noqa: E402 comments are unnecessary on these import lines. If E402 (module level import not at top of file) is not enabled in your linting configuration, these directives serve no purpose.

🧹 Proposed cleanup
-from fastmcp.server.transforms.enabled import Enabled, is_enabled  # noqa: E402
-from fastmcp.server.transforms.namespace import Namespace  # noqa: E402
-from fastmcp.server.transforms.tool_transform import ToolTransform  # noqa: E402
-from fastmcp.server.transforms.version_filter import VersionFilter  # noqa: E402
+from fastmcp.server.transforms.enabled import Enabled, is_enabled
+from fastmcp.server.transforms.namespace import Namespace
+from fastmcp.server.transforms.tool_transform import ToolTransform
+from fastmcp.server.transforms.version_filter import VersionFilter
docs/servers/enabled.mdx (1)

1-6: Consider aligning title with content terminology.

The title "Component Visibility" (lines 2-3) uses "visibility" terminology while the content consistently uses "enabled/disabled" language. Consider updating to "Component Enabled State" or "Enable/Disable Components" for consistency with the rest of the document and PR objectives.

♻️ Suggested title update
 ---
-title: Component Visibility
-sidebarTitle: Component Visibility
+title: Enable/Disable Components
+sidebarTitle: Enable/Disable
 description: Control which components are available to clients
 icon: toggle-on
 ---
src/fastmcp/contrib/component_manager/component_manager.py (1)

91-121: Missing error handling and response improvements.

  1. No existence check: The endpoint doesn't verify the component exists before enabling/disabling. If a user requests /tools/nonexistent/enable, the Enabled transform is added but it matches nothing—this succeeds silently.

  2. Grammar in response: "Enabled tool: foo" reads oddly. Consider "Tool 'foo' enabled" or similar.

  3. Missing type annotation: The endpoint function return type should be annotated.

♻️ Suggested improvements
 def _make_endpoint(server: FastMCP, component_type: str, action: str):
     """Create an endpoint function for enabling/disabling a component type."""

-    async def endpoint(request: Request) -> JSONResponse:
+    async def endpoint(request: Request) -> JSONResponse:
         # Get name from path params (tools/prompts use 'name', resources use 'uri')
         name = request.path_params.get("name") or request.path_params.get("uri")
         version = request.query_params.get("version")

+        if name is None:
+            return JSONResponse(
+                {"error": "Component name is required"}, status_code=400
+            )
+
         # Map component type to components list
         # ... existing logic ...

         # Call server.enable() or server.disable()
         method = getattr(server, action)
         method(name=name, version=version, components=components)

-        return JSONResponse(
-            {"message": f"{action.capitalize()}d {component_type}: {name}"}
-        )
+        return JSONResponse(
+            {"message": f"{component_type.capitalize()} '{name}' {action}d"}
+        )

     return endpoint

Comment thread docs/servers/providers/proxy.mdx
Comment thread src/fastmcp/server/providers/local_provider.py
Comment thread src/fastmcp/server/providers/local_provider.py
Comment thread src/fastmcp/server/providers/local_provider.py
Comment thread src/fastmcp/server/providers/local_provider.py
@jlowin jlowin removed the breaking change Breaks backward compatibility. Requires minor version bump. Critical for maintainer attention. label Jan 18, 2026
Change enable() and disable() to accept sets: names, keys, tags.
Use key-based disable for decorator enabled=False to scope exactly.
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

♻️ Duplicate comments (1)
src/fastmcp/server/providers/local_provider.py (1)

987-992: Missing components and version parameters in resource decorator disable calls.

The disable calls at lines 988 and 992 only pass keys={obj.key} without specifying components or version. This is inconsistent with the pattern used in add_resource (lines 376-388) and could cause unintended side effects if other component types share the same key prefix.

🔧 Suggested fix
                 if isinstance(obj, ResourceTemplate):
                     self.add_template(obj)
                     if not enabled:
-                        self.disable(keys={obj.key})
+                        self.disable(keys={obj.key}, components=["template"], version=obj.version)
                 else:
                     self.add_resource(obj)
                     if not enabled:
-                        self.disable(keys={obj.key})
+                        self.disable(keys={obj.key}, components=["resource"], version=obj.version)
🧹 Nitpick comments (5)
src/fastmcp/contrib/component_manager/component_manager.py (3)

8-8: Consider using the enhanced RequireAuthMiddleware from fastmcp.

The fastmcp.server.auth.middleware module provides an enhanced RequireAuthMiddleware that offers more detailed and actionable error messages for authentication failures (as shown in the relevant code snippet at src/fastmcp/server/auth/middleware.py:21-95). Using it would provide better developer experience when authentication errors occur.

♻️ Suggested change
-from mcp.server.auth.middleware.bearer_auth import RequireAuthMiddleware
+from fastmcp.server.auth.middleware import RequireAuthMiddleware

91-92: Add return type annotation for _make_endpoint.

Per coding guidelines, Python code should have full type annotations. The function is missing a return type annotation.

♻️ Suggested change
+from collections.abc import Awaitable, Callable
+
 ...
 
-def _make_endpoint(server: FastMCP, component_type: str, action: str):
+def _make_endpoint(
+    server: FastMCP, component_type: str, action: str
+) -> Callable[[Request], Awaitable[JSONResponse]]:
     """Create an endpoint function for enabling/disabling a component type."""

99-111: Consider using a Literal type for component_type parameter.

Since component_type only accepts specific values ("tool", "resource", "prompt"), using a Literal type would provide better type safety and documentation. Currently, an invalid value would raise a KeyError at runtime.

♻️ Suggested improvement
+from typing import Literal
+
 ...
 
-def _make_endpoint(server: FastMCP, component_type: str, action: str):
+def _make_endpoint(
+    server: FastMCP,
+    component_type: Literal["tool", "resource", "prompt"],
+    action: Literal["enable", "disable"],
+) -> Callable[[Request], Awaitable[JSONResponse]]:
docs/python-sdk/fastmcp-server-transforms-enabled.mdx (1)

1-4: Missing required frontmatter fields.

Per coding guidelines, every MDX documentation page must begin with YAML frontmatter containing both title and description. The description field is missing.

📝 Suggested fix
 ---
 title: enabled
 sidebarTitle: enabled
+description: API reference for the Enabled transform that marks component enabled state.
 ---
docs/servers/enabled.mdx (1)

264-289: Consider adding a note about thread safety in the dynamic changes example.

The example shows modifying server-level enabled state based on user context during request handling. Since enabled state changes "affect all connected clients" (as the warning notes), concurrent requests from different users could create race conditions where one user's permission check affects another user's component visibility.

Consider either:

  1. Adding a note about this concurrency concern
  2. Showing a safer pattern (e.g., session-level transforms if supported)

Comment thread docs/servers/enabled.mdx Outdated
Comment thread src/fastmcp/server/providers/local_provider.py
Comment thread src/fastmcp/server/transforms/enabled.py
@marvin-context-protocol
Copy link
Copy Markdown
Contributor

Test Failure Analysis

Summary: Tests are failing because commit 84d7d3b changed the implementation to use .keys but didn't update the test assertions, which still check .names.

Root Cause: The commit "Support plural names/keys in enable/disable API" (84d7d3b) refactored the Enabled transform to use keys instead of names for component identification, but the test assertions in test_local_provider.py weren't updated in that commit. A follow-up commit (c4f506b) fixed the tests, but the CI ran on the intermediate commit 84d7d3b before the fix.

Suggested Solution: This was already fixed in commit c4f506b. The failing workflow ran on commit 84d7d3b, but the latest commit on the branch (c4f506b) has the corrected tests. Re-running the workflow on the latest commit should pass.

Detailed Analysis

The four failing tests all follow the same pattern:

FAILED tests/server/providers/test_local_provider.py::TestLocalProviderDecorators::test_tool_enabled_false
FAILED tests/server/providers/test_local_provider.py::TestLocalProviderDecorators::test_resource_enabled_false  
FAILED tests/server/providers/test_local_provider.py::TestLocalProviderDecorators::test_template_enabled_false
FAILED tests/server/providers/test_local_provider.py::TestLocalProviderDecorators::test_prompt_enabled_false

Each test creates an Enabled transform with enabled=False and checks its properties. At commit 84d7d3b, the tests check:

assert enabled_transforms[0].names == {"disabled_tool"}

But the Enabled class at that commit stores the component identifier in .keys, not .names, causing:

AssertionError: assert None == {'disabled_tool'}
 +  where None = Enabled(disable, keys={'tool:disabled_tool@'}, components={'tool'}).names

Commit c4f506b ("Update test_local_provider.py") fixed all four test assertions to check .keys instead of .names.

Related Files
  • tests/server/providers/test_local_provider.py (lines 353, 442, 478, 549) - Test assertions updated in c4f506b
  • src/fastmcp/server/transforms/enabled.py (line 84-85) - Enabled class stores keys not names

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
docs/servers/enabled.mdx (1)

208-235: Provider-level section implies pre-server filtering; update to mark/override semantics.
The PR objective says final filtering happens at the server so server-level rules can override provider-level disables. This section reads as if providers filter components before the server sees them.

✏️ Suggested edit
-Each provider maintains its own enabled state. Provider-level filtering happens before components reach the server.
+Each provider maintains its own enabled state. Provider-level rules mark components before they are combined at the server, and server-level rules can still override them.
@@
-# Server receives only regular_action
+# Clients see only regular_action (unless the server re-enables admin_action)
@@
-Provider-level filtering is useful when different servers should see different subsets of the same provider's components.
+Provider-level rules are useful when different servers should see different subsets of the same provider's components.

Comment thread docs/servers/enabled.mdx Outdated
Comment thread docs/servers/enabled.mdx Outdated
@jlowin jlowin merged commit 44a47e1 into main Jan 18, 2026
9 of 10 checks passed
@jlowin jlowin deleted the jlowin/mark-based-visibility branch January 18, 2026 20:19
@jlowin jlowin added feature Major new functionality. Reserved for 2-4 significant PRs per release. Not for issues. and removed enhancement Improvement to existing functionality. For issues and smaller PR improvements. labels Jan 20, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature Major new functionality. Reserved for 2-4 significant PRs per release. Not for issues. server Related to FastMCP server implementation or server-side functionality.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Simplify visibility with mark-based stateless transforms

1 participant