Source code for klea_utils.mcp.registry

#!/usr/bin/env python3
"""
Shared MCP tool registration helpers.

File: klea_utils/mcp/registry.py

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

import inspect
import logging
from types import ModuleType
from typing import Any

from fastmcp import FastMCP
from mcp.types import ToolAnnotations

from klea_utils.mcp.schemas import ToolInfo

logger = logging.getLogger(__name__)


[docs] def register_tools(mcp: FastMCP, modules: list[ModuleType]): """Register tools from the given modules. A function is registered as a tool when it is decorated with :func:`tool_meta` (which attaches ``ToolInfo`` metadata). The function name is used as the tool name. Helper functions in the same module that are not decorated are ignored (logged at debug level, so a forgotten decoration is easy to spot). Only functions *defined* in the given module are registered, so an imported decorated function is not picked up accidentally. :param mcp: FastMCP server to register the tools on. :param modules: list of modules with tool function definitions """ for module in modules: for fname, fn in inspect.getmembers(module, inspect.isfunction): if fn.__module__ != module.__name__: # Imported function; not a registration candidate. continue if not hasattr(fn, "_tool_meta"): logger.debug(f"Skipping function without ToolInfo metadata: {fname}") continue metadata: ToolInfo = fn._tool_meta kwargs: dict[str, Any] = {} # Only pass an explicit description when ToolInfo # provides one. Otherwise let fastmcp derive the # LLM-facing description from the docstring's opening # text block (klea's docstring-first convention). # Passing the raw docstring here would dump the whole # Args/Returns prose into the tool description, # duplicating parameter text that the client also shows # from the schema. # # Docstring conventions (summary + Use when / Do not # use for bullets + one example, ~100-250 tokens) are # documented in docs/concepts/mcp.rst, "Tool # description length and style". if metadata.description is not None: kwargs["description"] = metadata.description if metadata.title is not None: kwargs["title"] = metadata.title if metadata.tags is not None: kwargs["tags"] = metadata.tags if metadata.meta is not None or metadata.checkpaths is not None: # Fold checkpaths into the meta dict so it travels to clients # on the MCP Tool's _meta field; the tool caller node reads it # from there to gate path arguments before calling the tool. tool_meta_dict = dict(metadata.meta or {}) if metadata.checkpaths is not None: tool_meta_dict["checkpaths"] = list(metadata.checkpaths) kwargs["meta"] = tool_meta_dict # Fold the standard MCP ToolAnnotations hints. Only hints that # are explicitly declared (non-None) are expressed; a tool with # no hints carries no annotations object. annotations_kwargs: dict[str, Any] = {} if metadata.read_only is not None: annotations_kwargs["readOnlyHint"] = metadata.read_only if metadata.destructive is not None: annotations_kwargs["destructiveHint"] = metadata.destructive if metadata.idempotent is not None: annotations_kwargs["idempotentHint"] = metadata.idempotent if metadata.open_world is not None: annotations_kwargs["openWorldHint"] = metadata.open_world if annotations_kwargs: kwargs["annotations"] = ToolAnnotations(**annotations_kwargs) mcp.tool(fn, **kwargs) logger.debug(f"Registered MCP tool: {fname}")
[docs] def tool_meta(metadata: ToolInfo): """Decorator that attaches :class:`ToolInfo` metadata to a tool function. Usage:: @tool_meta(ToolInfo(tags={"bundled", "web"})) async def web_fetch(ctx: Context, url: str, ...): ... The metadata is read by :func:`register_tools` when the function is registered on a FastMCP server (it sets ``description``, ``title``, ``tags``, and ``meta`` on the tool if provided). A function is only registered as a tool when it carries this decoration; the function name is used as the tool name. :param metadata: :class:`ToolInfo` to attach to the decorated function. :returns: The decorated function, unchanged, with ``_tool_meta`` set. """ def wrapper(fn): fn._tool_meta = metadata return fn return wrapper