Source code for aeat.entrypoints.mcp._tools

"""Build the MCP tool descriptors from the Layer 0 capability manifest.

Each operator-callable registry command becomes one SDK-independent
:class:`McpToolDescriptor`: a namespaced tool name, a description drawn from the
family's operator intent, a per-verb input schema derived from the command's own
click parameters (via :func:`~entrypoints.mcp._input_schema.build_verb_input_schemas`),
the command's registered result model as the output schema, and the mutability
annotations. The server shell adapts these into the MCP SDK's ``Tool`` /
``ToolAnnotations`` types. This module owns no protocol detail and is unit-tested.
"""

from __future__ import annotations

from typing import Any

from pydantic import BaseModel, ConfigDict, Field

from ...application.operator_surface import (
    CommandSchemaRef,
    OperatorMutability,
    build_operator_surface_manifest,
)
from ...core.json_contract import ENVELOPE_SCHEMA_VERSION, SCHEMA_REGISTRY
from ._annotations import McpAnnotations, annotations_for_command
from ._dispatch import is_exposable_command, tool_name_for_command
from ._input_schema import VerbInputSchema, build_verb_input_schemas

_STRICT_FROZEN = ConfigDict(frozen=True, strict=True, validate_assignment=True, extra="forbid")

# Read-only command keys that are not a mounted command family (operator meta
# surfaces). Everything else falls back to LOCAL_STATE_MUTATING - the conservative
# default that asks for confirmation more often, never less.
_READ_ONLY_OVERRIDES: frozenset[str] = frozenset({"contract"})


[docs] class McpToolDescriptor(BaseModel): """SDK-independent description of one exposed MCP tool. ``input_schema`` is the rendered per-verb JSON Schema a client reads to build a typed argument form; ``verb_schema`` is the structured source of that render plus the resolved CLI path, which the server consumes to reconstruct the argv from named arguments. The two are always in lock-step: ``input_schema`` is exactly ``verb_schema.json_schema()``. """ model_config = _STRICT_FROZEN name: str = Field(min_length=1) command_key: str = Field(min_length=1) description: str = Field(min_length=1) input_schema: dict[str, Any] output_schema: dict[str, Any] annotations: McpAnnotations verb_schema: VerbInputSchema
def _family_mutability() -> dict[str, OperatorMutability]: """Map each normalized command-family child to its mutability.""" contract = build_operator_surface_manifest( envelope_schema_version=ENVELOPE_SCHEMA_VERSION, command_schemas=(), ).contract mapping: dict[str, OperatorMutability] = {} for family in contract.command_families: mapping[family.child.replace("-", "_")] = family.mutability return mapping def _mutability_for_key(command_key: str, family_map: dict[str, OperatorMutability]) -> OperatorMutability: if command_key in _READ_ONLY_OVERRIDES: return OperatorMutability.READ_ONLY tokens = command_key.split(".") family_token = tokens[1] if tokens[0] in {"config", "app"} and len(tokens) > 1 else tokens[0] return family_map.get(family_token, OperatorMutability.LOCAL_STATE_MUTATING) def _family_intent(command_key: str, family_map_intent: dict[str, str]) -> str: tokens = command_key.split(".") family_token = tokens[1] if tokens[0] in {"config", "app"} and len(tokens) > 1 else tokens[0] return family_map_intent.get(family_token, "")
[docs] def build_tool_descriptors() -> tuple[McpToolDescriptor, ...]: """Build the exposed MCP tool descriptors from the live manifest + registry. Reuses the CLI's own payload-discovery so the registry is fully populated, then emits one descriptor per operator-callable command key, skipping group-callback help surfaces. The output schema is the command's registered result model; the input schema is the CLI argument vector. Returns: Tuple of exposed :class:`McpToolDescriptor` entries. """ from ..cli import command_schema_refs refs: tuple[CommandSchemaRef, ...] = command_schema_refs() family_map = _family_mutability() contract = build_operator_surface_manifest( envelope_schema_version=ENVELOPE_SCHEMA_VERSION, command_schemas=(), ).contract intent_map = {family.child.replace("-", "_"): family.operator_question for family in contract.command_families} exposable_keys = tuple(ref.command for ref in refs if is_exposable_command(ref.command)) verb_schemas = build_verb_input_schemas(exposable_keys) descriptors: list[McpToolDescriptor] = [] for key in exposable_keys: mutability = _mutability_for_key(key, family_map) cli_form = "aeat app " + key.replace(".", " ") intent = _family_intent(key, intent_map) verb_schema = verb_schemas[key] # The model-facing description stays English (ADR mcp-protocol-hardening # H5): the CLI form carries the verb path and the shared family intent # follows. The command's own (Spanish) per-verb help is NOT put here - it # feeds the search index instead (P02/S05), so discovery gains the verb # vocabulary without a Spanish string on the model-facing surface. description = f"Run `{cli_form}`." + (f" {intent}." if intent else "") annotations = annotations_for_command(command_key=key, mutability=mutability, title=cli_form) descriptors.append( McpToolDescriptor( name=tool_name_for_command(key), command_key=key, description=description, input_schema=verb_schema.json_schema(), output_schema=_output_schema_for(key), annotations=annotations, verb_schema=verb_schema, ), ) return tuple(descriptors)
def _output_schema_for(command_key: str) -> dict[str, Any]: schema = SCHEMA_REGISTRY.get(command_key) if schema is None: return {"type": "object"} return schema.model_json_schema()