Three bugs fixed for social-login users connecting Claude.ai via OAuth: 1. User OAuth clients now allow 'admin' scope by default — previously defaulted to ['read', 'write'], causing invalid_scope when Claude.ai requests 'admin' scope during authorization. 2. Fix JWT tokens with 'aud' claim being rejected by middleware — when Claude.ai sends RFC 8707 resource parameter, the issued JWT gets an aud claim. validate_access_token() now sets verify_aud=False to prevent PyJWT InvalidAudienceError from silently marking valid tokens as invalid in _is_valid_token(). 3. Fix NameError in user_mcp_handler tools/call scope check — key_info was only defined in the mhu_ auth path but referenced in tools/call for both paths. Replaced with key_scopes variable set by both mhu_ and JWT auth paths. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
419 lines
14 KiB
Python
419 lines
14 KiB
Python
"""Per-user MCP endpoint handler (Track E.3).
|
|
|
|
Handles MCP JSON-RPC requests for user-owned sites at
|
|
``/u/{user_id}/{alias}/mcp``. Implements the Streamable HTTP transport
|
|
protocol directly (no per-user FastMCP instances).
|
|
|
|
Flow:
|
|
1. Validate user API key (Bearer token)
|
|
2. Look up user's site from SQLite
|
|
3. Decrypt credentials
|
|
4. For tools/list: return plugin tools (without ``site`` param)
|
|
5. For tools/call: create plugin instance, call method, return result
|
|
|
|
Usage:
|
|
# In server.py route registration:
|
|
from core.user_endpoints import user_mcp_handler
|
|
Route("/u/{user_id}/{alias}/mcp", endpoint=user_mcp_handler, methods=["POST"])
|
|
"""
|
|
|
|
import json
|
|
import logging
|
|
import os
|
|
import time
|
|
from copy import deepcopy
|
|
from typing import Any
|
|
|
|
from starlette.requests import Request
|
|
from starlette.responses import JSONResponse, Response
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Per-user rate limiting defaults
|
|
USER_RATE_LIMIT_PER_MIN = int(os.getenv("USER_RATE_LIMIT_PER_MIN", "30"))
|
|
USER_RATE_LIMIT_PER_HR = int(os.getenv("USER_RATE_LIMIT_PER_HR", "500"))
|
|
|
|
# In-memory rate limit tracking: user_id -> list of timestamps
|
|
_rate_limits: dict[str, list[float]] = {}
|
|
|
|
# Cache for tool schemas per plugin type (computed once)
|
|
_tool_schema_cache: dict[str, list[dict[str, Any]]] = {}
|
|
|
|
|
|
def _check_user_rate_limit(user_id: str) -> tuple[bool, str]:
|
|
"""Check per-user rate limits.
|
|
|
|
Args:
|
|
user_id: User UUID.
|
|
|
|
Returns:
|
|
Tuple of (allowed, error_message). error_message is empty if allowed.
|
|
"""
|
|
now = time.time()
|
|
timestamps = _rate_limits.setdefault(user_id, [])
|
|
|
|
# Prune old entries (older than 1 hour)
|
|
cutoff_hr = now - 3600
|
|
_rate_limits[user_id] = [t for t in timestamps if t > cutoff_hr]
|
|
timestamps = _rate_limits[user_id]
|
|
|
|
# Check per-minute limit
|
|
cutoff_min = now - 60
|
|
recent_min = sum(1 for t in timestamps if t > cutoff_min)
|
|
if recent_min >= USER_RATE_LIMIT_PER_MIN:
|
|
return False, f"Rate limit exceeded: {USER_RATE_LIMIT_PER_MIN} requests/minute"
|
|
|
|
# Check per-hour limit
|
|
if len(timestamps) >= USER_RATE_LIMIT_PER_HR:
|
|
return False, f"Rate limit exceeded: {USER_RATE_LIMIT_PER_HR} requests/hour"
|
|
|
|
timestamps.append(now)
|
|
return True, ""
|
|
|
|
|
|
def _get_tools_for_plugin(plugin_type: str) -> list[dict[str, Any]]:
|
|
"""Get MCP tool definitions for a plugin type (cached).
|
|
|
|
Returns tool schemas with the ``site`` parameter removed
|
|
(auto-injected for user endpoints).
|
|
"""
|
|
if plugin_type in _tool_schema_cache:
|
|
return _tool_schema_cache[plugin_type]
|
|
|
|
from core.tool_registry import get_tool_registry
|
|
|
|
registry = get_tool_registry()
|
|
tools = registry.get_by_plugin_type(plugin_type)
|
|
|
|
result = []
|
|
for tool_def in tools:
|
|
schema = deepcopy(tool_def.input_schema)
|
|
# Remove 'site' parameter (auto-injected)
|
|
if "properties" in schema:
|
|
schema["properties"].pop("site", None)
|
|
if "required" in schema and "site" in schema["required"]:
|
|
schema["required"] = [r for r in schema["required"] if r != "site"]
|
|
|
|
result.append(
|
|
{
|
|
"name": tool_def.name,
|
|
"description": tool_def.description,
|
|
"inputSchema": schema,
|
|
}
|
|
)
|
|
|
|
_tool_schema_cache[plugin_type] = result
|
|
return result
|
|
|
|
|
|
async def _execute_tool(
|
|
tool_name: str,
|
|
arguments: dict[str, Any],
|
|
plugin_type: str,
|
|
config_dict: dict[str, Any],
|
|
) -> Any:
|
|
"""Execute a tool by creating a plugin instance and calling the method.
|
|
|
|
Uses the same pattern as unified_handler in tool_generator.py.
|
|
"""
|
|
from plugins import registry as plugin_registry
|
|
|
|
if not plugin_registry.is_registered(plugin_type):
|
|
return {"type": "text", "text": f"Error: Unknown plugin type '{plugin_type}'"}
|
|
|
|
method_name = tool_name
|
|
# Strip plugin_type prefix: "wordpress_list_posts" → "list_posts"
|
|
prefix = f"{plugin_type}_"
|
|
if method_name.startswith(prefix):
|
|
method_name = method_name[len(prefix) :]
|
|
|
|
try:
|
|
plugin_instance = plugin_registry.create_instance(
|
|
plugin_type,
|
|
project_id=f"user_{config_dict.get('alias', 'unknown')}",
|
|
config=config_dict,
|
|
)
|
|
|
|
if not hasattr(plugin_instance, method_name):
|
|
return {"type": "text", "text": f"Error: Method '{method_name}' not found"}
|
|
|
|
method = getattr(plugin_instance, method_name)
|
|
|
|
# Process arguments (parse JSON strings, filter None/empty)
|
|
filtered_args = {}
|
|
for key, value in arguments.items():
|
|
if value is None:
|
|
continue
|
|
if isinstance(value, str):
|
|
stripped = value.strip()
|
|
if stripped == "":
|
|
continue
|
|
if (stripped.startswith("{") and stripped.endswith("}")) or (
|
|
stripped.startswith("[") and stripped.endswith("]")
|
|
):
|
|
try:
|
|
value = json.loads(value)
|
|
except json.JSONDecodeError:
|
|
pass
|
|
filtered_args[key] = value
|
|
|
|
result = await method(**filtered_args)
|
|
return result
|
|
|
|
except Exception as e:
|
|
logger.error("Tool execution error %s: %s", tool_name, e, exc_info=True)
|
|
return {"type": "text", "text": f"Error: {type(e).__name__}: {e}"}
|
|
|
|
|
|
def _jsonrpc_error(req_id: Any, code: int, message: str) -> dict[str, Any]:
|
|
"""Build a JSON-RPC error response."""
|
|
return {
|
|
"jsonrpc": "2.0",
|
|
"id": req_id,
|
|
"error": {"code": code, "message": message},
|
|
}
|
|
|
|
|
|
def _jsonrpc_result(req_id: Any, result: Any) -> dict[str, Any]:
|
|
"""Build a JSON-RPC success response."""
|
|
return {
|
|
"jsonrpc": "2.0",
|
|
"id": req_id,
|
|
"result": result,
|
|
}
|
|
|
|
|
|
async def user_mcp_handler(request: Request) -> Response:
|
|
"""Handle MCP JSON-RPC requests for user endpoints.
|
|
|
|
Route: POST /u/{user_id}/{alias}/mcp
|
|
|
|
Validates user API key, looks up the site, and handles MCP protocol
|
|
methods (initialize, tools/list, tools/call).
|
|
"""
|
|
user_id = request.path_params.get("user_id", "")
|
|
alias = request.path_params.get("alias", "")
|
|
|
|
if not user_id or not alias:
|
|
return JSONResponse(
|
|
_jsonrpc_error(None, -32600, "Invalid endpoint path"),
|
|
status_code=400,
|
|
)
|
|
|
|
# --- Authentication ---
|
|
auth_header = request.headers.get("authorization", "")
|
|
if not auth_header.startswith("Bearer "):
|
|
return JSONResponse(
|
|
_jsonrpc_error(None, -32600, "Missing Authorization header"),
|
|
status_code=401,
|
|
)
|
|
|
|
api_key = auth_header[7:] # Strip "Bearer "
|
|
|
|
# Shared scope tracking — set by whichever auth path succeeds
|
|
key_scopes: list[str] = []
|
|
|
|
# Try mhu_ API key first, then fall back to OAuth JWT token
|
|
if api_key.startswith("mhu_"):
|
|
try:
|
|
from core.user_keys import get_user_key_manager
|
|
|
|
key_mgr = get_user_key_manager()
|
|
key_info = await key_mgr.validate_key(api_key)
|
|
except RuntimeError:
|
|
return JSONResponse(
|
|
_jsonrpc_error(None, -32600, "Authentication service unavailable"),
|
|
status_code=503,
|
|
)
|
|
|
|
if key_info is None:
|
|
return JSONResponse(
|
|
_jsonrpc_error(None, -32600, "Invalid API key"),
|
|
status_code=401,
|
|
)
|
|
|
|
if key_info["user_id"] != user_id:
|
|
return JSONResponse(
|
|
_jsonrpc_error(None, -32600, "API key does not match user"),
|
|
status_code=403,
|
|
)
|
|
key_scopes = key_info.get("scopes", "read").split()
|
|
else:
|
|
# Try OAuth JWT token (issued after consent flow via GitHub/Google login)
|
|
try:
|
|
import jwt as pyjwt
|
|
|
|
from core.oauth import get_token_manager
|
|
|
|
token_manager = get_token_manager()
|
|
jwt_payload = token_manager.validate_access_token(api_key)
|
|
|
|
# sub = "user:{uuid}" — extract actual user_id
|
|
sub = jwt_payload.get("sub", "")
|
|
if not sub.startswith("user:"):
|
|
return JSONResponse(
|
|
_jsonrpc_error(None, -32600, "Token not authorized for user endpoint"),
|
|
status_code=403,
|
|
)
|
|
jwt_user_id = sub[len("user:") :]
|
|
|
|
if jwt_user_id != user_id:
|
|
return JSONResponse(
|
|
_jsonrpc_error(None, -32600, "Token user mismatch"),
|
|
status_code=403,
|
|
)
|
|
key_scopes = jwt_payload.get("scope", "read").split()
|
|
except pyjwt.ExpiredSignatureError:
|
|
return JSONResponse(
|
|
_jsonrpc_error(None, -32600, "Token expired"),
|
|
status_code=401,
|
|
)
|
|
except Exception:
|
|
return JSONResponse(
|
|
_jsonrpc_error(None, -32600, "Invalid token"),
|
|
status_code=401,
|
|
)
|
|
|
|
# --- Rate Limiting ---
|
|
allowed, rate_msg = _check_user_rate_limit(user_id)
|
|
if not allowed:
|
|
return JSONResponse(
|
|
_jsonrpc_error(None, -32600, rate_msg),
|
|
status_code=429,
|
|
)
|
|
|
|
# --- Look up site ---
|
|
try:
|
|
from core.database import get_database
|
|
|
|
db = get_database()
|
|
site = await db.get_site_by_alias(user_id, alias)
|
|
except RuntimeError:
|
|
return JSONResponse(
|
|
_jsonrpc_error(None, -32600, "Database unavailable"),
|
|
status_code=503,
|
|
)
|
|
|
|
if site is None:
|
|
return JSONResponse(
|
|
_jsonrpc_error(None, -32600, f"Site '{alias}' not found"),
|
|
status_code=404,
|
|
)
|
|
|
|
if site["status"] == "disabled":
|
|
return JSONResponse(
|
|
_jsonrpc_error(None, -32600, "Site is disabled"),
|
|
status_code=403,
|
|
)
|
|
|
|
# --- Parse JSON-RPC body ---
|
|
try:
|
|
body = await request.json()
|
|
except Exception:
|
|
return JSONResponse(
|
|
_jsonrpc_error(None, -32700, "Parse error"),
|
|
status_code=400,
|
|
)
|
|
|
|
req_id = body.get("id")
|
|
method = body.get("method", "")
|
|
params = body.get("params", {})
|
|
|
|
# --- Handle MCP methods ---
|
|
|
|
if method == "initialize":
|
|
version = "3.1.0"
|
|
return JSONResponse(
|
|
_jsonrpc_result(
|
|
req_id,
|
|
{
|
|
"protocolVersion": "2024-11-05",
|
|
"capabilities": {"tools": {}},
|
|
"serverInfo": {
|
|
"name": f"mcphub-{alias}",
|
|
"version": version,
|
|
},
|
|
},
|
|
)
|
|
)
|
|
|
|
elif method == "notifications/initialized":
|
|
# Notification — no response needed
|
|
return Response(status_code=204)
|
|
|
|
elif method == "tools/list":
|
|
tools = _get_tools_for_plugin(site["plugin_type"])
|
|
return JSONResponse(_jsonrpc_result(req_id, {"tools": tools}))
|
|
|
|
elif method == "tools/call":
|
|
tool_name = params.get("name", "")
|
|
arguments = params.get("arguments", {})
|
|
|
|
# Verify tool belongs to this plugin type
|
|
plugin_prefix = f"{site['plugin_type']}_"
|
|
if not tool_name.startswith(plugin_prefix):
|
|
return JSONResponse(
|
|
_jsonrpc_error(req_id, -32601, f"Tool '{tool_name}' not available for this site")
|
|
)
|
|
|
|
# Check required scope
|
|
from core.tool_registry import get_tool_registry
|
|
|
|
registry = get_tool_registry()
|
|
tool_def = registry.get_by_name(tool_name)
|
|
if not tool_def:
|
|
return JSONResponse(_jsonrpc_error(req_id, -32601, f"Tool '{tool_name}' not found"))
|
|
|
|
required_scope = tool_def.required_scope
|
|
# key_scopes is set during authentication (both mhu_ and JWT paths)
|
|
|
|
scope_hierarchy = {"read": 1, "write": 2, "admin": 3}
|
|
required_level = scope_hierarchy.get(required_scope, 0)
|
|
key_level = max([scope_hierarchy.get(s, 0) for s in key_scopes] + [0])
|
|
|
|
if key_level < required_level:
|
|
return JSONResponse(
|
|
_jsonrpc_error(
|
|
req_id,
|
|
-32600,
|
|
f"Insufficient scope. Tool '{tool_name}' requires '{required_scope}' scope.",
|
|
)
|
|
)
|
|
|
|
# Decrypt credentials
|
|
try:
|
|
from core.encryption import get_credential_encryption
|
|
|
|
encryptor = get_credential_encryption()
|
|
credentials = encryptor.decrypt_credentials(site["credentials"], site["id"])
|
|
except Exception as e:
|
|
logger.error("Credential decryption failed for site %s: %s", site["id"], e)
|
|
return JSONResponse(
|
|
_jsonrpc_error(req_id, -32603, "Failed to decrypt site credentials")
|
|
)
|
|
|
|
# Build config dict for plugin instantiation
|
|
config_dict = {
|
|
"site_url": site["url"],
|
|
"url": site["url"],
|
|
"alias": alias,
|
|
**credentials,
|
|
}
|
|
|
|
result = await _execute_tool(tool_name, arguments, site["plugin_type"], config_dict)
|
|
|
|
# Format result as MCP content
|
|
if isinstance(result, str):
|
|
content = [{"type": "text", "text": result}]
|
|
elif isinstance(result, dict) and "type" in result:
|
|
content = [result]
|
|
elif isinstance(result, list):
|
|
content = result
|
|
else:
|
|
content = [{"type": "text", "text": json.dumps(result, default=str)}]
|
|
|
|
return JSONResponse(_jsonrpc_result(req_id, {"content": content}))
|
|
|
|
else:
|
|
return JSONResponse(_jsonrpc_error(req_id, -32601, f"Method '{method}' not supported"))
|