Source code for smarter.apps.mcpserver.protocol.tools.public

"""
The base class of the MCPServer's public tools, the scope that they work on, and the limits of their results.

Each tool is a :class:`PublicTool`: a name, a description, a Pydantic model of its
arguments, from which its MCP ``inputSchema`` is generated, and :meth:`PublicTool.run`,
which returns its result as Markdown text for the LLM. :meth:`PublicTool.call`
validates the arguments, and caches a read-only tool's result.

A tool works on the repositories of its :class:`RepositoryScope`. The endpoint of
one repository serves the tools without a ``repository`` argument, and the endpoint of
all of the repositories adds one, whose values are the repositories' names.

.. note::

    **Experimental.** The MCPServer was designed and coded by Claude Code (Anthropic's
    Claude Opus 5.5), with Lawrence McDaniel as co-author. It is experimental, and will
    be documented.
"""

from abc import abstractmethod
from typing import ClassVar, Optional

from pydantic import BaseModel, ConfigDict, Field, SecretStr, ValidationError

from smarter.apps.mcpserver.exceptions import (
    SmarterMCPServerServiceError,
    SmarterMCPServerToolError,
)
from smarter.apps.mcpserver.protocol.base import (
    MCPToolBase,
    invalid_arguments,
    simplify_schema,
    tool_error,
)
from smarter.apps.mcpserver.protocol.caching import (
    TOOL_CACHE_NAMESPACE,
    TOOL_RESULT_CACHE_TTL,
    index_generation,
)
from smarter.apps.mcpserver.services.reading import MAX_READ_CHARACTERS
from smarter.apps.mcpserver.services.repositories import (
    ProjectRepository,
    get_repository,
)
from smarter.common.const import VERSION
from smarter.lib.cache.tool_results import cached_tool_call
from smarter.lib.typing import SmarterDictType

DEFAULT_SEARCH_LIMIT = 8
"""The number of results that a search tool returns when the LLM does not give a ``limit``."""

MAX_SEARCH_LIMIT = 20
"""The most results that a search tool returns, which keeps a result within the LLM's context."""


[docs] class RepositoryScope(BaseModel): """ The repositories that one public endpoint serves. The endpoint creates its scope once, and gives it to each of its tools, which call :meth:`resolve` to turn a call's ``repository`` argument into a repository. It is frozen, because the tools share it. :param repository: The one repository of the endpoint, or None for all of them. :param repositories: The repositories that the endpoint serves: ``(repository,)``, or every repository of The Smarter Project. """ model_config = ConfigDict(frozen=True) repository: Optional[ProjectRepository] = None """The one repository of the endpoint, or None for the endpoint of every repository.""" repositories: tuple[ProjectRepository, ...] """The repositories that the endpoint serves, in their configured order.""" @property def is_single(self) -> bool: """Whether the endpoint serves one repository.""" return self.repository is not None @property def names(self) -> list[str]: """The names of the repositories.""" return [repository.name for repository in self.repositories]
[docs] def resolve(self, name: Optional[str], required: bool) -> Optional[ProjectRepository]: """ Return the repository that a tool call names. :param name: The tool call's ``repository`` argument. :param required: Whether the tool needs one repository. :returns: The endpoint's one repository, the named repository, or None for all of them. :raises SmarterMCPServerToolError: If the name is not one of the scope's repositories, or a required name is missing. """ if self.repository is not None: return self.repository choices = ", ".join(self.names) if not name: if required: raise SmarterMCPServerToolError(f"repository is required. It is one of: {choices}.") return None repository = get_repository(name) if repository is None: raise SmarterMCPServerToolError(f"{name} is not a repository. It is one of: {choices}.") if repository.name not in self.names: raise SmarterMCPServerToolError(f"{name} is not a repository. It is one of: {choices}.") return repository
[docs] class PublicToolArguments(BaseModel): """ The arguments of every tool: ``repository``, which only the public endpoint of every repository offers. A tool's own arguments model subclasses it. Arguments that the model does not define are refused (``extra="forbid"``), so that a misspelled argument is reported to the LLM rather than ignored. """ model_config = ConfigDict(extra="forbid") repository: Optional[str] = Field(default=None, description="The repository.") """ The name of the repository to work on, e.g. ``smarter-cli``. :meth:`PublicTool.input_schema` replaces its schema with an ``enum`` of the scope's repositories, or removes it from the endpoint of one repository. """
[docs] class PublicTool(MCPToolBase): """ A public tool of the MCPServer. A subclass sets :attr:`name`, :attr:`title`, :attr:`description` and :attr:`arguments_model`, the class attributes that say which endpoints serve it and how it takes a repository, and implements :meth:`run`. :meth:`call` validates the arguments and caches a read-only tool's result, so :meth:`run` only does the work. :param scope: The repositories of the endpoint. :param github_token: The caller's GitHub token, from the request's ``Authorization: Bearer`` header, or None. Only a tool that :attr:`requires_github_token` uses it. """ name: ClassVar[str] """The tool's name, which the LLM calls it by, e.g. ``search_code``.""" title: ClassVar[str] """The tool's human-readable name, which an MCP client may show.""" description: ClassVar[str] """What the tool does, and when to call it. The LLM reads it to decide which tool to call. """ arguments_model: ClassVar[type[PublicToolArguments]] = PublicToolArguments """The Pydantic model of the tool's arguments, a subclass of :class:`PublicToolArguments`.""" requires_repository: ClassVar[bool] = False """Whether a call to the public endpoint of every repository must name one.""" uses_repository: ClassVar[bool] = True """Whether the tool takes a ``repository`` argument on the public endpoint of every repository.""" project_wide: ClassVar[bool] = False """Whether only the public endpoint of every repository serves the tool.""" requires_github_token: ClassVar[bool] = False """Whether the tool is served only to a request that carries the caller's GitHub token.""" cache_ttl: ClassVar[Optional[int]] = None """Seconds that a read-only tool's result is cached, or None for :data:`~smarter.apps.mcpserver.protocol.caching.TOOL_RESULT_CACHE_TTL`."""
[docs] def __init__(self, scope: RepositoryScope, github_token: Optional[SecretStr] = None): """ Keep the endpoint's scope, and the caller's GitHub token. :param scope: The repositories of the endpoint. :param github_token: The caller's GitHub token, or None. """ self.scope = scope self.github_token = github_token
[docs] @classmethod def serves(cls, scope: RepositoryScope, has_github_token: bool = False) -> bool: """ Whether the endpoint of a scope serves the tool. A tool that :attr:`requires_github_token` is served only to a request that carries one, and a :attr:`project_wide` tool only by the public endpoint of every repository. :param scope: The endpoint's repositories. :param has_github_token: Whether the request carries the caller's GitHub token. :returns: True if the endpoint lists the tool, and lets the LLM call it. """ if cls.requires_github_token and not has_github_token: return False if cls.project_wide and scope.is_single: return False return True
[docs] def input_schema(self) -> SmarterDictType: """ Return the tool's MCP ``inputSchema``. :returns: A JSON schema of type object. The public endpoint of one repository omits ``repository``, and that of all of them lists the repositories' names. """ model_schema = self.arguments_model.model_json_schema() schema = simplify_schema(model_schema) properties = schema.setdefault("properties", {}) if self.scope.is_single or not self.uses_repository: properties.pop("repository", None) else: if self.requires_repository: description = "The repository." else: description = "The repository. Omit it to use all of the repositories." properties["repository"] = { "type": "string", "enum": self.scope.names, "description": description, } if self.requires_repository: # "repository" first, then the tool's own required arguments, in their order. required = ["repository"] for name in schema.get("required", []): if name != "repository": required.append(name) schema["required"] = required schema["type"] = "object" schema["additionalProperties"] = False return schema
[docs] def call(self, arguments: Optional[SmarterDictType]) -> str: """ Validate a tool call's arguments, and run the tool. The endpoint of one repository, and a tool that does not :attr:`uses_repository`, drop a ``repository`` argument, which their schema does not offer, rather than refuse it. A read-only tool's result is cached for an hour, or its :attr:`cache_ttl`, until an index changes. See :mod:`smarter.apps.mcpserver.protocol.caching`. The cache key is the platform's version, the index generation, the endpoint's repository (``*`` for all of them), the tool's name and its validated arguments, so a new release or a new index never serves a stale result. An error is not cached. A tool that writes runs on every call. **Side effects:** reads, and on a miss writes, the Django cache, with :func:`~smarter.lib.cache.tool_results.cached_tool_call`. :param arguments: The tool call's arguments. :returns: The result, as Markdown. :raises SmarterMCPServerToolError: If the arguments are not valid, or the tool fails. """ arguments = dict(arguments or {}) if self.scope.is_single or not self.uses_repository: arguments.pop("repository", None) try: validated = self.arguments_model.model_validate(arguments) except ValidationError as e: raise invalid_arguments(self.name, e) from e if not self.read_only: return self.run_service(validated) if self.scope.repository: scope = self.scope.repository.name else: scope = "*" version = str(VERSION.get("__version__", "0.0.0")) # the index generation changes whenever an index does, so new files are found at once. generation = index_generation() key = (version, generation, scope, self.name) if self.cache_ttl is None: timeout = TOOL_RESULT_CACHE_TTL else: timeout = self.cache_ttl def run_tool() -> str: return self.run_service(validated) result = cached_tool_call(TOOL_CACHE_NAMESPACE, key, validated, timeout, run_tool) text = str(result.value) return text
[docs] def run_service(self, arguments: PublicToolArguments) -> str: """ Run the tool, and turn a service's error into a tool error. :param arguments: The validated arguments. :returns: The result, as Markdown. :raises SmarterMCPServerToolError: If the tool, or a service that it calls, fails. """ try: text = self.run(arguments) except SmarterMCPServerServiceError as e: raise tool_error(e) from e return text
[docs] @abstractmethod def run(self, arguments: PublicToolArguments) -> str: """ Run the tool. :meth:`call` has already validated the arguments, and checked the cache. :param arguments: The validated arguments, an instance of :attr:`arguments_model`. :returns: The result, as Markdown. :raises SmarterMCPServerToolError: If the tool fails in a way that the LLM should be told about, e.g. a file that does not exist. """
__all__ = [ "DEFAULT_SEARCH_LIMIT", "MAX_READ_CHARACTERS", "MAX_SEARCH_LIMIT", "RepositoryScope", "PublicTool", "PublicToolArguments", "simplify_schema", ]