Skip to content

Commit 15c9c58

Browse files
fix(mcp): add async support and test_request_context to mcp_auth_hook
This commit extends PR apache#36013 to fully resolve the 'Working outside of application context' error for async MCP tools. Changes: - Add async/sync detection using inspect.iscoroutinefunction() - Create separate async_wrapper and sync_wrapper functions - Add test_request_context() nested inside app_context() for g object access - Support await for async tools, regular call for sync tools The original PR added app_context() which partially fixed the issue, but async tools like list_charts still failed because: 1. The decorator didn't await async functions 2. Only app_context() was used, but g object requires test_request_context() Both contexts are needed: - app_context() provides current_app, config, db - test_request_context() provides g, request Tested with get_instance_info and list_charts - both work without context errors.
1 parent 8319a46 commit 15c9c58

File tree

1 file changed

+144
-132
lines changed

1 file changed

+144
-132
lines changed

superset/mcp_service/auth.py

Lines changed: 144 additions & 132 deletions
Original file line numberDiff line numberDiff line change
@@ -16,35 +16,15 @@
1616
# under the License.
1717

1818
"""
19-
Authentication and authorization hooks for MCP tools.
20-
21-
This module provides a flexible authentication framework that can be customized
22-
by deployers through configuration and the FastMCP auth provider system.
23-
24-
Default Behavior:
25-
- Uses configured user (MCP_DEV_USERNAME) for authentication
26-
- Leverages Superset's existing RBAC and security manager
27-
- Provides dataset-level access control via has_dataset_access()
28-
29-
Customization Options for Deployers:
30-
1. JWT Authentication: Configure BearerAuthProvider in mcp_config.py with your
31-
JWT issuer, audience, and public key/JWKS URI. See create_default_mcp_auth_factory()
32-
for reference implementation.
33-
34-
2. User Resolution: Override get_user_from_request() to extract user from JWT
35-
claims, headers, or other authentication mechanisms.
36-
37-
3. Permission Checks: Add custom permission logic in mcp_auth_hook before tool
38-
execution. Use Superset's security_manager for role-based checks.
39-
40-
4. Audit Logging: Extend mcp_auth_hook to log tool access, integrate with your
41-
SIEM/logging infrastructure, or track usage metrics.
42-
43-
5. Rate Limiting: Integrate rate limiting middleware in the mcp_auth_hook wrapper
44-
based on user, IP, or tool-specific quotas.
45-
46-
The hook pattern allows production deployments to layer additional security
47-
without modifying tool implementations.
19+
Minimal authentication hooks for MCP tools.
20+
This is a placeholder implementation that provides basic user context.
21+
22+
Future enhancements (to be added in separate PRs):
23+
- JWT token authentication and validation
24+
- User impersonation support
25+
- Permission checking with scopes
26+
- Comprehensive audit logging
27+
- Field-level permissions
4828
"""
4929

5030
import logging
@@ -69,18 +49,6 @@ def get_user_from_request() -> User:
6949
Default implementation uses MCP_DEV_USERNAME from configuration for
7050
development and single-user deployments.
7151
72-
Deployers can customize this function to:
73-
- Extract user from JWT claims (when using BearerAuthProvider)
74-
- Implement user impersonation based on request headers
75-
- Add fallback authentication for service accounts
76-
- Integrate with external identity providers
77-
78-
Example JWT customization:
79-
from flask import request
80-
token = request.headers.get('Authorization', '').replace('Bearer ', '')
81-
# Parse JWT and extract user from claims
82-
# Then query user from database as shown below
83-
8452
Returns:
8553
User object with roles and groups eagerly loaded
8654
@@ -93,7 +61,6 @@ def get_user_from_request() -> User:
9361
from superset.extensions import db
9462

9563
# Use configured username for authentication
96-
# Deployers: Replace this with your authentication logic
9764
username = current_app.config.get("MCP_DEV_USERNAME")
9865

9966
if not username:
@@ -154,97 +121,142 @@ def mcp_auth_hook(tool_func: F) -> F:
154121
"""
155122
Authentication and authorization decorator for MCP tools.
156123
157-
This hook ensures proper Flask app context and user authentication for all
158-
MCP tool calls. Deployers can extend this decorator to add:
159-
160-
- Permission checks: Validate user has specific roles or tool access rights
161-
- JWT scope validation: Check OAuth scopes match required tool permissions
162-
- Audit logging: Track tool usage, parameters, and results for compliance
163-
- Rate limiting: Enforce per-user or per-tool rate limits
164-
- Custom middleware: Inject organization-specific security policies
165-
166-
Default behavior:
167-
1. Ensures Flask app context is active (critical for stateless_http mode)
168-
2. Authenticates user via get_user_from_request()
169-
3. Sets g.user for Superset's security manager integration
170-
4. Manages database session lifecycle
171-
172-
Example extensions:
173-
# Add permission check before tool execution:
174-
if tool_func.__name__ in ['execute_sql'] and not user.is_admin():
175-
raise PermissionError("SQL execution requires admin role")
176-
177-
# Add audit logging after successful execution:
178-
audit_log.info({
179-
'user': user.username,
180-
'tool': tool_func.__name__,
181-
'timestamp': datetime.now(),
182-
'result': 'success'
183-
})
124+
This decorator assumes Flask application context and g.user
125+
have already been set by WorkspaceContextMiddleware.
126+
127+
Supports both sync and async tool functions.
128+
129+
TODO (future PR): Add permission checking
130+
TODO (future PR): Add JWT scope validation
131+
TODO (future PR): Add comprehensive audit logging
184132
"""
133+
import asyncio
185134
import functools
135+
import inspect
186136

187-
@functools.wraps(tool_func)
188-
def wrapper(*args: Any, **kwargs: Any) -> Any:
189-
from superset.extensions import db
190-
from superset.mcp_service.flask_singleton import get_flask_app
191-
192-
# Get Flask app instance
193-
app = get_flask_app()
194-
195-
# Ensure Flask app context is active
196-
# This is critical for stateless_http mode where context isn't preserved
197-
with app.app_context():
198-
# Get user and set Flask context OUTSIDE try block
199-
user = get_user_from_request()
200-
201-
# Force load relationships NOW while session is definitely active
202-
# This prevents lazy-loading errors after session closure
203-
user_roles = user.roles # noqa: F841
204-
if hasattr(user, "groups"):
205-
user_groups = user.groups # noqa: F841
206-
207-
g.user = user
208-
209-
try:
210-
# Deployers: Add custom permission checks here
211-
# Example: Check tool-specific permissions, validate JWT scopes,
212-
# or enforce organizational policies
213-
214-
logger.debug(
215-
"MCP tool call: user=%s, tool=%s", user.username, tool_func.__name__
216-
)
217-
218-
result = tool_func(*args, **kwargs)
219-
220-
# Deployers: Add audit logging or post-execution hooks here
221-
# Example: Log successful tool execution, track metrics,
222-
# or trigger downstream workflows
223-
224-
return result
225-
226-
except Exception:
227-
# On error, rollback and cleanup session
228-
# pylint: disable=consider-using-transaction
229-
try:
230-
db.session.rollback()
231-
db.session.remove()
232-
except Exception as cleanup_error:
233-
# Log cleanup failure but re-raise original exception
234-
# We want to preserve the original error, not the cleanup error
235-
logger.warning(
236-
"Error cleaning up session after exception: %s", cleanup_error
237-
)
238-
raise
239-
240-
finally:
241-
# Only rollback if session is still active (no exception occurred)
242-
# Do NOT call remove() on success to avoid detaching user
243-
try:
244-
if db.session.is_active:
245-
# pylint: disable=consider-using-transaction
246-
db.session.rollback()
247-
except Exception as e:
248-
logger.warning("Error in finally block: %s", e)
137+
# Check if the tool function is async
138+
is_async = inspect.iscoroutinefunction(tool_func)
249139

250-
return wrapper # type: ignore[return-value]
140+
if is_async:
141+
142+
@functools.wraps(tool_func)
143+
async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
144+
from superset.extensions import db
145+
from superset.mcp_service.flask_singleton import get_flask_app
146+
147+
# Get Flask app instance
148+
app = get_flask_app()
149+
150+
# Ensure Flask app AND request context are active
151+
# app_context() provides current_app, config, db
152+
# test_request_context() provides g, request
153+
with app.app_context():
154+
with app.test_request_context():
155+
# Get user from config
156+
user = get_user_from_request()
157+
158+
# Validate user has necessary relationships loaded
159+
# (Force access to ensure they're loaded if lazy)
160+
user_roles = user.roles # noqa: F841
161+
if hasattr(user, "groups"):
162+
user_groups = user.groups # noqa: F841
163+
164+
g.user = user
165+
166+
try:
167+
# TODO: Add permission checks here in future PR
168+
# TODO: Add audit logging here in future PR
169+
170+
logger.debug(
171+
"MCP tool call: user=%s, tool=%s",
172+
user.username,
173+
tool_func.__name__,
174+
)
175+
176+
result = await tool_func(*args, **kwargs)
177+
178+
return result
179+
180+
except Exception:
181+
# On error, rollback and cleanup session
182+
# pylint: disable=consider-using-transaction
183+
try:
184+
db.session.rollback()
185+
db.session.remove()
186+
except Exception as e:
187+
logger.warning("Error cleaning up session after exception: %s", e)
188+
raise
189+
190+
finally:
191+
# Only rollback if session is still active (no exception occurred)
192+
# Do NOT call remove() on success to avoid detaching user
193+
try:
194+
if db.session.is_active:
195+
# pylint: disable=consider-using-transaction
196+
db.session.rollback()
197+
except Exception as e:
198+
logger.warning("Error in finally block: %s", e)
199+
200+
return async_wrapper # type: ignore[return-value]
201+
202+
else:
203+
204+
@functools.wraps(tool_func)
205+
def sync_wrapper(*args: Any, **kwargs: Any) -> Any:
206+
from superset.extensions import db
207+
from superset.mcp_service.flask_singleton import get_flask_app
208+
209+
# Get Flask app instance
210+
app = get_flask_app()
211+
212+
# Ensure Flask app AND request context are active
213+
# app_context() provides current_app, config, db
214+
# test_request_context() provides g, request
215+
with app.app_context():
216+
with app.test_request_context():
217+
# Get user from config
218+
user = get_user_from_request()
219+
220+
# Validate user has necessary relationships loaded
221+
# (Force access to ensure they're loaded if lazy)
222+
user_roles = user.roles # noqa: F841
223+
if hasattr(user, "groups"):
224+
user_groups = user.groups # noqa: F841
225+
226+
g.user = user
227+
228+
try:
229+
# TODO: Add permission checks here in future PR
230+
# TODO: Add audit logging here in future PR
231+
232+
logger.debug(
233+
"MCP tool call: user=%s, tool=%s",
234+
user.username,
235+
tool_func.__name__,
236+
)
237+
238+
result = tool_func(*args, **kwargs)
239+
240+
return result
241+
242+
except Exception:
243+
# On error, rollback and cleanup session
244+
# pylint: disable=consider-using-transaction
245+
try:
246+
db.session.rollback()
247+
db.session.remove()
248+
except Exception as e:
249+
logger.warning("Error cleaning up session after exception: %s", e)
250+
raise
251+
252+
finally:
253+
# Only rollback if session is still active (no exception occurred)
254+
# Do NOT call remove() on success to avoid detaching user
255+
try:
256+
if db.session.is_active:
257+
# pylint: disable=consider-using-transaction
258+
db.session.rollback()
259+
except Exception as e:
260+
logger.warning("Error in finally block: %s", e)
261+
262+
return sync_wrapper # type: ignore[return-value]

0 commit comments

Comments
 (0)