fix(mcp): hide write tools from users without write permissions#40098
Conversation
✅ Deploy Preview for superset-docs-preview ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## master #40098 +/- ##
==========================================
- Coverage 64.14% 64.12% -0.02%
==========================================
Files 2592 2592
Lines 138841 138892 +51
Branches 32201 32210 +9
==========================================
+ Hits 89064 89069 +5
- Misses 48245 48291 +46
Partials 1532 1532
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
a4a7f87 to
3993a04
Compare
There was a problem hiding this comment.
Pull request overview
This PR adds RBAC-aware MCP tool visibility so users without permissions see fewer tools in discovery, while improving permission-denied error handling.
Changes:
- Adds shared MCP tool visibility checks and a
tools/listfiltering middleware. - Converts
MCPPermissionDeniedErrorinto user-facingToolErrorresponses and warning-level logs. - Updates MCP instructions and unit tests for RBAC visibility behavior.
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
superset/mcp_service/auth.py |
Adds shared tool visibility helper and extracts app-context selection logic. |
superset/mcp_service/middleware.py |
Adds RBAC tool-list filtering and permission-denied error handling. |
superset/mcp_service/server.py |
Wires RBAC visibility middleware and delegates tool-search filtering. |
superset/mcp_service/app.py |
Updates MCP instructions for permission-aware tool availability. |
superset/mcp_service/__main__.py |
Reuses the shared middleware stack for stdio entrypoint setup. |
tests/unit_tests/mcp_service/test_auth_rbac.py |
Adds tests for shared tool visibility logic. |
tests/unit_tests/mcp_service/test_middleware.py |
Adds tests for permission-denied handling and RBAC tool-list middleware. |
tests/unit_tests/mcp_service/test_tool_search_transform.py |
Updates privacy-function patch targets after visibility refactor. |
4765078 to
be145c3
Compare
Phase 1: MCPPermissionDeniedError falls through to GlobalErrorHandlerMiddleware's generic "Internal error" branch (500-style response) because it doesn't subclass PermissionError. Fixed by adding it to _USER_ERROR_TYPES and an explicit elif branch in _handle_error() that converts it to a clean ToolError. Phase 2: Add RBACToolVisibilityMiddleware that intercepts tools/list and removes tools the calling user lacks permission to execute. Add is_tool_visible_to_current_user() to auth.py as the single source of truth for tool visibility, shared by both the new middleware and the existing tool-search transform. Register the middleware inside StructuredContentStripperMiddleware so it filters full tool objects before outputSchema stripping. Fail open: if user resolution fails, all tools are returned (call-time RBAC still enforces). Also update server instructions to note write tools require write permissions.
- Fail closed (return only public tools) when credentials are invalid (PermissionError from bad API key, ValueError from unknown dev username); fail open only when no auth source is configured at all - Extract _get_app_context_manager() to module level in auth.py so RBACToolVisibilityMiddleware reuses the same context-selection logic as mcp_auth_hook, preventing external g.user from being shadowed - Add RBACToolVisibilityMiddleware to __main__.py stdio entry point via build_middleware_list() to keep all transports in sync - Fix stale patch targets in test_tool_search_transform.py: update superset.mcp_service.server.user_can_view_data_model_metadata → superset.mcp_service.privacy.user_can_view_data_model_metadata - Qualify write tool listings in instructions with "(requires write access)" and add a permissions preamble so read-only users are not confused by tools they cannot call Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…lity - app.py: clarify execute_sql requires SQL Lab access (not write access) in both the instructions preamble and Permission Awareness section - auth.py: add log_denial param to check_tool_permission() to suppress noisy WARNING logs during tools/list scanning; downgrade "No authenticated user found" from ERROR to DEBUG in _setup_user_context - middleware.py: fail completely closed (return []) on credential failures instead of returning tools with no class_permission_name, which could include protect=True tools requiring auth; remove _public_tools_only helper - server.py: catch PermissionError (invalid API key) in addition to ValueError in _tool_allowed_for_current_user - tests: add tests for fail-closed branches (PermissionError, bad ValueError, and no-auth-configured ValueError in RBACToolVisibilityMiddleware)
…bility Thread 1 (app.py): Restructure the permission preamble to unambiguously separate write-access operations from SQL Lab access. Previously the preamble listed "saving SQL queries" inside the write-operations clause which could be read as including execute_sql. Now each permission type is its own bullet with explicit tool names. Thread 2 (server.py): Make _tool_allowed_for_current_user consistent with RBACToolVisibilityMiddleware: "No authenticated user found" ValueError now returns True (fail-open, show the tool) instead of False. Other ValueErrors and PermissionError remain fail-closed. Previously tool-search mode would hide all tools when no auth was configured, while tools/list showed all. Thread 3 (middleware.py): Replace _setup_user_context() with a direct call to get_user_from_request() in on_list_tools. _setup_user_context carries per-call execution overhead (retry loop, session management, error logging) that is inappropriate and noisy at list time. The middleware now controls all logging for list-time auth failures directly. Also updates all RBACToolVisibilityMiddleware tests to patch get_user_from_request instead of _setup_user_context, matching the refactored implementation.
- auth.py: collapse check_tool_permission signature to one line (ruff-format) - auth.py: extract _log_user_resolution_failure() helper to reduce _setup_user_context cyclomatic complexity from 11 to 10 (ruff C901) - test_middleware.py: shorten docstring to stay within 88-char limit (ruff E501)
- Restore "Available tools:" section header in app.py instructions so test_get_default_instructions_declares_data_boundary can find it - Revert fail-open change in _tool_allowed_for_current_user: tool-search should stay fail-closed (hide protected tools) when no user is resolved; only RBACToolVisibilityMiddleware.on_list_tools is fail-open for the no-auth-configured case Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…tructions Remove 'or running SQL' from the write-operations bullet so that SQL execution is not grouped under can_write. execute_sql is controlled by the separate execute_sql_query permission on SQLLab, which is already called out in its own bullet below. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Move contextlib, flask, and auth imports from function bodies to module level in auth.py and middleware.py. The only remaining local import is get_flask_app in _get_app_context_manager, which is deferred because importing it at module level would trigger create_app() before Superset is fully initialised (e.g. during unit-test collection). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… visibility - Fix ruff error: consolidate contextlib imports into single from-import - Fix test patch targets: middleware tests must patch middleware module after imports were promoted to module level (not auth module) - Fix _tool_allowed_for_current_user: pass public tools through when user resolution fails (only hide tools with _class_permission_name) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Move MCPPermissionDeniedError after underscore-prefixed import to satisfy ruff I001 (isort ordering: underscore names sort before M). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Apply ruff PT023: remove unnecessary parentheses from @pytest.mark.asyncio() decorators (34 occurrences). Required by ruff 0.9.7+ used in pre-commit (next). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…nd test_middleware.py Move create_response_size_guard_middleware and build_middleware_list from function body to module level in __main__.py (no circular import issue). Move MCPPermissionDeniedError and RBACToolVisibilityMiddleware from repeated local imports to module-level imports in test_middleware.py. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Split the combined except clause in _tool_allowed_for_current_user so that PermissionError (bad API key) returns False for every tool, matching RBACToolVisibilityMiddleware's fail-closed behaviour. ValueError (no auth source configured) retains the existing public-tool fallback. Adds a test to cover the new deny-all path. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
91b8442 to
6054446
Compare
1. MiddlewareContext is a frozen dataclass so assigning context.mcp_call_id raises FrozenInstanceError at runtime. Replace with a module-level ContextVar that LoggingMiddleware sets and StructuredContentStripperMiddleware reads. 2. _tool_allowed_for_current_user in server.py accesses flask.g without a Flask app context when called from the synthetic search_tools tool, causing RuntimeError and silently filtering out all search results. Guard with has_app_context() and push one via _get_app_context_manager() when none exists. Also remove unknown pylintrc option and suppress pre-existing consider-using-transaction warning in db_engine_specs/base.py.
The test was patching flask_singleton.get_flask_app, but when a Flask app context is already active (as in the test environment), _get_app_context_manager() never calls get_flask_app() so the mock had no effect. Patch _get_app_context_manager directly instead, which is the function that actually triggers the fail-open path.
|
Bito Automatic Review Skipped – PR Already Merged |
…he#40098) Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
SUMMARY
Add RBAC-aware tool visibility to the MCP service so that write tools are hidden from users who lack write permissions, and permission-denied errors surface cleanly to the caller.
Phase 1 — clean permission-denied errors:
MCPPermissionDeniedErrordid not subclassPermissionError, soGlobalErrorHandlerMiddleware._handle_error()fell through to the generic "Internal error" branch (500-style response). Fixed by:MCPPermissionDeniedErrorto_USER_ERROR_TYPESso it is logged at WARNING, not ERRORelif isinstance(error, MCPPermissionDeniedError)branch that converts the error to a structuredToolErrorwith the denial messagePhase 2 — per-request
tools/listfiltering:is_tool_visible_to_current_user(tool)toauth.pyas the single source of truth for tool visibility, covering both RBAC permissions and data-model metadata privacyRBACToolVisibilityMiddlewarethat interceptstools/listresponses and removes tools the requesting user lacks permission to execute. Fail-open: if user resolution fails, all tools are returned (call-time RBAC still enforces)_tool_allowed_for_current_user()inserver.pyto delegate tois_tool_visible_to_current_user()so tool-search andtools/listshare identical visibility logicRBACToolVisibilityMiddlewareinsideStructuredContentStripperMiddlewareso it filters full tool objects beforeoutputSchemastrippingBEFORE/AFTER SCREENSHOTS OR ANIMATED GIF
N/A — MCP protocol change, no UI.
TESTING INSTRUCTIONS
generate_chart,generate_dashboard, etc.) do not appear intools/list.ToolErrorwith a clear denial message (not an "Internal error").ADDITIONAL INFORMATION
MCP_RBAC_ENABLED(defaultTrue)