feat(F.7b): tool access UI + unified keys page (v3.9.0)
Some checks failed
Release / Test before release (push) Has been cancelled
Release / Publish to PyPI (push) Has been cancelled
Release / Publish to Docker Hub (push) Has been cancelled
Release / Create GitHub Release (push) Has been cancelled

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-06 21:00:42 +02:00
parent 3fc02b5734
commit d8a0805412
30 changed files with 2965 additions and 72 deletions

View File

@@ -27,6 +27,8 @@ from typing import Any
from starlette.requests import Request
from starlette.responses import JSONResponse, Response
from core.tool_registry import ToolDefinition
logger = logging.getLogger(__name__)
# Per-user rate limiting defaults
@@ -36,9 +38,6 @@ 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.
@@ -71,24 +70,15 @@ def _check_user_rate_limit(user_id: str) -> tuple[bool, str]:
return True, ""
def _get_tools_for_plugin(plugin_type: str) -> list[dict[str, Any]]:
"""Get MCP tool definitions for a plugin type (cached).
def _tools_to_mcp_schema(tools: list[ToolDefinition]) -> list[dict[str, Any]]:
"""Convert ToolDefinition objects into MCP ``tools/list`` response shape.
Returns tool schemas with the ``site`` parameter removed
(auto-injected for user endpoints).
Strips the auto-injected ``site`` parameter, since user endpoints bind a
single site per alias.
"""
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"]:
@@ -101,11 +91,24 @@ def _get_tools_for_plugin(plugin_type: str) -> list[dict[str, Any]]:
"inputSchema": schema,
}
)
_tool_schema_cache[plugin_type] = result
return result
async def _get_visible_tools_for_site(
site_id: str,
key_scopes: list[str],
plugin_type: str,
) -> list[dict[str, Any]]:
"""Return tools/list payload filtered by key scope + site scope + toggles (F.7b)."""
from core.tool_access import get_tool_access_manager
access = get_tool_access_manager()
tools = await access.get_visible_tools(
site_id=site_id, key_scopes=key_scopes, plugin_type=plugin_type
)
return _tools_to_mcp_schema(tools)
async def _execute_tool(
tool_name: str,
arguments: dict[str, Any],
@@ -353,7 +356,7 @@ async def user_mcp_handler(request: Request) -> Response:
return Response(status_code=204)
elif method == "tools/list":
tools = _get_tools_for_plugin(site["plugin_type"])
tools = await _get_visible_tools_for_site(site["id"], key_scopes, site["plugin_type"])
return JSONResponse(_jsonrpc_result(req_id, {"tools": tools}))
elif method == "tools/call":
@@ -378,19 +381,56 @@ async def user_mcp_handler(request: Request) -> Response:
required_scope = tool_def.required_scope
# key_scopes is set during authentication (both mhu_ and JWT paths)
# F.7b: enforce category-based scope allowlist in addition to the
# legacy read/write/admin hierarchy. A tool is allowed only if BOTH
# (a) the legacy hierarchy grants it, AND
# (b) the tool's category is in BOTH the key-scope set AND the
# site's stored tool_scope set (the narrower layer wins).
from core.tool_access import KNOWN_CATEGORIES, SCOPE_CUSTOM, scopes_to_categories
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])
legacy_ok = key_level >= required_level
if key_level < required_level:
key_cats = scopes_to_categories(key_scopes)
key_category_ok = tool_def.category not in KNOWN_CATEGORIES or tool_def.category in key_cats
# Site-level scope check (skipped for "custom" preset).
site_scope = site.get("tool_scope") or "admin"
if site_scope and site_scope != SCOPE_CUSTOM:
site_cats = scopes_to_categories([site_scope])
site_category_ok = (
tool_def.category not in KNOWN_CATEGORIES or tool_def.category in site_cats
)
else:
site_category_ok = True
if not (legacy_ok and key_category_ok and site_category_ok):
return JSONResponse(
_jsonrpc_error(
req_id,
-32600,
f"Insufficient scope. Tool '{tool_name}' requires '{required_scope}' scope.",
f"Insufficient scope. Tool '{tool_name}' requires "
f"scope '{required_scope}' (category '{tool_def.category}').",
)
)
# F.7b: honour per-site tool toggles — a disabled tool cannot be called
# even if scopes would otherwise allow it.
try:
toggles = await db.get_site_tool_toggles(site["id"])
if not toggles.get(tool_name, True):
return JSONResponse(
_jsonrpc_error(
req_id,
-32600,
f"Tool '{tool_name}' is disabled for this site.",
)
)
except Exception as exc: # non-fatal — fall through on DB errors
logger.warning("Failed to check site tool toggles for %s: %s", site["id"], exc)
# Decrypt credentials
try:
from core.encryption import get_credential_encryption