Source code for klea_utils.mcp.access

#!/usr/bin/env python3
"""
Tool access levels for MCP tool invocation (ADR-0037).

Classification is derived from the standard MCP ``ToolAnnotations``
(``readOnlyHint`` / ``destructiveHint``) carried on ``ToolInfo``, with an
optional operator override (``ToolAccessOverride``).  Levels are
``read_only`` (only explicitly read-only, non-destructive tools) and
``full`` (every tool); an unannotated tool fails closed in ``read_only``.
See ``devdocs/system/mcp-permissions.md`` and
``devdocs/adr/0037-tool-access-levels.md``.

File: klea_utils/mcp/access.py

Copyright 2026 Ankur Sinha
Author: Ankur Sinha <sanjay DOT ankur AT gmail DOT com>
"""

import logging
from typing import Any, Literal, cast

from pydantic import BaseModel

from klea_utils.mcp.schemas import ToolInfo

logger = logging.getLogger(__name__)

#: Tool invocation access levels (ADR-0037).
AccessLevel = Literal["read_only", "full"]

#: Default level: every tool is permitted (the pre-ADR-0037 behaviour).
DEFAULT_ACCESS_LEVEL: AccessLevel = "full"


[docs] class ToolAccessOverride(BaseModel): """Operator declaration of a tool's capability (ADR-0037). Configured per tool under ``general.tool_access`` and applied over the tool's own MCP annotations. Either field may be left ``None`` to leave the annotated value unchanged; the operator who writes the config is the trust root for the declaration. """ read_only: bool | None = None destructive: bool | None = None
[docs] def resolve_capability( read_only: bool | None, destructive: bool | None, override: ToolAccessOverride | None = None, ) -> tuple[bool | None, bool | None]: """Apply an operator override over a tool's annotated capability. Precedence is explicit ``tool_access`` override > tool annotation > ``None`` (which fails closed downstream). A ``None`` override field leaves the annotated value unchanged. :param read_only: Annotated read-only hint, or ``None``. :param destructive: Annotated destructive hint, or ``None``. :param override: Operator override, or ``None``. :returns: The effective ``(read_only, destructive)`` pair. """ if override is None: return read_only, destructive if override.read_only is not None: read_only = override.read_only if override.destructive is not None: destructive = override.destructive return read_only, destructive
[docs] def tool_permits( read_only: bool | None, destructive: bool | None, access_level: AccessLevel, ) -> bool: """Return whether *access_level* permits invoking this tool. ``full`` permits every tool. ``read_only`` permits a tool only when it is explicitly read-only and not destructive; an unannotated tool (``None``) is **not** permitted (fail-closed). :param read_only: Tool's read-only capability. :param destructive: Tool's destructive capability. :param access_level: The active access level. :returns: True when the tool may be invoked. """ if access_level == "full": return True return read_only is True and destructive is not True
[docs] def filter_tools_info( tools_info: dict[str, dict[str, ToolInfo]], access_level: AccessLevel, ) -> dict[str, dict[str, ToolInfo]]: """Return *tools_info* with tools disallowed by *access_level* removed. Domains are preserved as keys even when all their tools are filtered out. ``full`` returns the input unchanged; otherwise a new mapping is built. :param tools_info: Per-domain ``{name: ToolInfo}`` mapping. :param access_level: The active access level. :returns: A filtered mapping (the input is not mutated). """ if access_level == "full": return tools_info filtered: dict[str, dict[str, ToolInfo]] = {} for domain, tools in tools_info.items(): filtered[domain] = { name: info for name, info in tools.items() if tool_permits(info.read_only, info.destructive, access_level) } total = sum(len(tools) for tools in tools_info.values()) allowed = sum(len(tools) for tools in filtered.values()) dropped = total - allowed logger.debug(f"{access_level = }\n{total = }\n{allowed = }\n{dropped = }") return filtered
[docs] def check_tool_access( tool: str, read_only: bool | None, destructive: bool | None, access_level: AccessLevel, ) -> str | None: """Return a denial message when *tool* is not permitted, else ``None``. Mirrors :func:`tool_permits` but returns a human-readable message for the non-halting synthetic error result the caller produces (the ADR-0007 error contract) instead of raising. :param tool: Tool name, used in the message. :param read_only: Tool's read-only capability. :param destructive: Tool's destructive capability. :param access_level: The active access level. :returns: A denial message, or ``None`` when the tool is permitted. """ if tool_permits(read_only, destructive, access_level): return None reason = ( "it is annotated destructive" if destructive is True else "it is not annotated read-only" ) return ( f"Tool '{tool}' is not available with read-only access: {reason}. " "Full access is required to invoke it." )
[docs] def resolve_access_level(value: Any) -> AccessLevel: """Coerce a value to a valid access level, defaulting to ``full``. Used for untrusted inputs (env var strings); an unrecognised value is logged and replaced with :data:`DEFAULT_ACCESS_LEVEL`. :param value: Candidate value (e.g. from a request component or env). :returns: The matching level, or :data:`DEFAULT_ACCESS_LEVEL`. """ if isinstance(value, str): candidate = value.strip().lower() if candidate in ("read_only", "full"): return cast(AccessLevel, candidate) if value is not None: logger.warning( f"Unknown access level {value!r}; defaulting to {DEFAULT_ACCESS_LEVEL}" ) return DEFAULT_ACCESS_LEVEL