Skip to content

Discovery

discovery

PlainTool

Bases: Protocol

Structural protocol for plain dispatcher tools (callable, no execute).

Source code in packages/axm-mcp/src/axm_mcp/discovery.py
Python
class PlainTool(Protocol):
    """Structural protocol for plain dispatcher tools (callable, no ``execute``)."""

    def __call__(self, **kwargs: object) -> dict[str, object]: ...

ToolLike

Bases: Protocol

Minimal protocol for AXMTool-compatible objects.

Source code in packages/axm-mcp/src/axm_mcp/discovery.py
Python
@runtime_checkable
class ToolLike(Protocol):
    """Minimal protocol for AXMTool-compatible objects."""

    @property
    def name(self) -> str:
        """Tool name used for MCP registration."""
        ...

    def execute(self, **kwargs: object) -> ToolResultLike:
        """Execute the tool with the given keyword arguments."""
        ...
name property

Tool name used for MCP registration.

execute(**kwargs)

Execute the tool with the given keyword arguments.

Source code in packages/axm-mcp/src/axm_mcp/discovery.py
Python
def execute(self, **kwargs: object) -> ToolResultLike:
    """Execute the tool with the given keyword arguments."""
    ...

ToolResultLike

Bases: Protocol

Structural protocol matching axm.tools.base.ToolResult.

Declared locally to keep axm_mcp.discovery free of axm.* imports — axm-mcp is a pure discovery shell (enforced by test_mcp_decoupled.py).

Source code in packages/axm-mcp/src/axm_mcp/discovery.py
Python
class ToolResultLike(Protocol):
    """Structural protocol matching ``axm.tools.base.ToolResult``.

    Declared locally to keep ``axm_mcp.discovery`` free of ``axm.*``
    imports — ``axm-mcp`` is a pure discovery shell (enforced by
    ``test_mcp_decoupled.py``).
    """

    success: bool
    data: dict[str, object]
    error: str | None
    hint: str | None
    text: str | None

discover_tools()

Discover and instantiate all AXMTool entry points.

Supports both AXMTool subclasses (instantiated) and plain dispatcher functions (used as-is).

Tools can be excluded via the AXM_DISABLE_TOOLS environment variable — a comma-separated list of tool names or glob patterns (e.g. bib_*,ticket_*,ast_dead_code).

Returns:

Type Description
dict[str, ToolEntry]

Dict mapping tool name → tool instance or callable.

Source code in packages/axm-mcp/src/axm_mcp/discovery.py
Python
def discover_tools() -> dict[str, ToolEntry]:
    """Discover and instantiate all AXMTool entry points.

    Supports both ``AXMTool`` subclasses (instantiated) and plain
    dispatcher functions (used as-is).

    Tools can be excluded via the ``AXM_DISABLE_TOOLS`` environment
    variable — a comma-separated list of tool names or glob patterns
    (e.g. ``bib_*,ticket_*,ast_dead_code``).

    Returns:
        Dict mapping tool name → tool instance or callable.
    """
    raw = os.environ.get("AXM_DISABLE_TOOLS", "")
    disable_patterns = [p.strip() for p in raw.split(",") if p.strip()]
    if disable_patterns:
        logger.info("AXM_DISABLE_TOOLS: %s", disable_patterns)

    tools: dict[str, ToolEntry] = {}

    for ep in importlib.metadata.entry_points(group=_EP_GROUP):
        if disable_patterns and is_disabled(ep.name, disable_patterns):
            logger.info("Skipping disabled tool: %s", ep.name)
            continue
        try:
            obj = ep.load()
            if isinstance(obj, type):
                tool = obj()  # AXMTool class → instantiate
            else:
                tool = obj  # plain function → use as-is
            tools[ep.name] = tool
            logger.debug("Discovered tool: %s", ep.name)
        except Exception:
            logger.warning(
                "Failed to load tool entry point: %s",
                ep.name,
                exc_info=True,
            )

    return tools

is_disabled(name, patterns)

Check if a tool name matches any disable pattern.

Supports exact names (ast_dead_code) and glob patterns (bib_*, ticket_*).

Source code in packages/axm-mcp/src/axm_mcp/discovery.py
Python
def is_disabled(name: str, patterns: list[str]) -> bool:
    """Check if a tool name matches any disable pattern.

    Supports exact names (``ast_dead_code``) and glob patterns
    (``bib_*``, ``ticket_*``).
    """
    return any(fnmatch.fnmatch(name, pat) for pat in patterns)

register_list_tools(mcp, tools, extra_tools)

Register the list_tools meta-tool.

Source code in packages/axm-mcp/src/axm_mcp/discovery.py
Python
def register_list_tools(  # type: ignore[explicit-any]
    mcp: FastMCP,
    tools: dict[str, ToolEntry],
    extra_tools: dict[str, str],
) -> None:
    """Register the list_tools meta-tool."""

    @mcp.tool(name="list_tools")
    def _list_tools(**kwargs: object) -> dict[str, object]:
        """List all available AXM tools with their names and descriptions."""
        tool_list = []
        for name, tool in sorted(tools.items()):
            tool_list.append({"name": name, "description": _get_tool_doc(tool)})
        for name, desc in sorted(extra_tools.items()):
            tool_list.append({"name": name, "description": desc})
        tool_list.sort(key=lambda t: t["name"])
        return {"tools": tool_list, "count": len(tool_list)}

    logger.info("Registered meta-tool: list_tools")

register_one(mcp, name, tool, *, override_module=None)

Register a single tool, capturing in closure.

Supports both AXMTool instances (with .execute()) and plain dispatcher functions. Sets the typed signature on the wrapper before handing it to mcp.tool(), so FastMCP generates the correct JSON-Schema for the tool's parameters.

For dispatcher functions (action + **kwargs), introspects sub-functions to build a union of all their typed parameters.

When an AXMTool returns a successful ToolResult whose text attribute is a string, the wrapper short-circuits and returns the raw string instead of the flattened dict. FastMCP converts this to a TextContent response, letting the LLM see pre-rendered markdown rather than JSON. A failing ToolResult (success=False) never short-circuits: it is flattened so the structural failure signal (success=False + error) reaches the caller. Any exception raised by execute() (or by a plain dispatcher) is caught and returned as the flattened AXM error shape — it never propagates to FastMCP.

Parameters:

Name Type Description Default
mcp FastMCP

FastMCP server instance.

required
name str

Tool name for MCP registration.

required
tool ToolEntry

Tool instance or plain function.

required
override_module ModuleType | None

For testing — module to search for _*_ACTIONS.

None
Source code in packages/axm-mcp/src/axm_mcp/discovery.py
Python
def register_one(  # type: ignore[explicit-any]
    mcp: FastMCP,
    name: str,
    tool: ToolEntry,
    *,
    override_module: ModuleType | None = None,
) -> None:
    """Register a single tool, capturing in closure.

    Supports both ``AXMTool`` instances (with ``.execute()``) and plain
    dispatcher functions.  Sets the typed signature on the wrapper
    **before** handing it to ``mcp.tool()``, so FastMCP generates the
    correct JSON-Schema for the tool's parameters.

    For *dispatcher* functions (``action`` + ``**kwargs``), introspects
    sub-functions to build a union of all their typed parameters.

    When an ``AXMTool`` returns a **successful** ``ToolResult`` whose
    ``text`` attribute is a string, the wrapper short-circuits and returns
    the raw string instead of the flattened dict.  FastMCP converts this to
    a ``TextContent`` response, letting the LLM see pre-rendered markdown
    rather than JSON.  A *failing* ``ToolResult`` (``success=False``) never
    short-circuits: it is flattened so the structural failure signal
    (``success=False`` + ``error``) reaches the caller.  Any exception
    raised by ``execute()`` (or by a plain dispatcher) is caught and
    returned as the flattened AXM error shape — it never propagates to
    FastMCP.

    Args:
        mcp: FastMCP server instance.
        name: Tool name for MCP registration.
        tool: Tool instance or plain function.
        override_module: For testing — module to search for ``_*_ACTIONS``.
    """
    is_plain = callable(tool) and not hasattr(tool, "execute")
    exec_fn: IntrospectableFn = (
        cast(IntrospectableFn, tool)
        if is_plain
        else cast(IntrospectableFn, cast(ToolLike, tool).execute)
    )
    # Protocol tools already trace via orchestrator.run_tool()
    ctx = _WrapperCtx(name=name, should_trace=not name.startswith("protocol_"))

    sync_wrapper = (
        _build_plain_wrapper(ctx, tool) if is_plain else _build_tool_wrapper(ctx, tool)
    )
    sync_wrapper.__doc__ = exec_fn.__doc__ or f"Execute {name} tool."

    wrapper = _wrap_with_lock(sync_wrapper, name)
    apply_signature(wrapper, exec_fn, override_module)

    # Register AFTER setting the signature so FastMCP sees it.
    mcp.tool(name=name)(wrapper)

register_tools(mcp, tools)

Register discovered tools as MCP tool callables.

Each tool becomes a callable tool_name(**kwargs) -> dict that delegates to tool.execute(**kwargs).

Parameters:

Name Type Description Default
mcp FastMCP

FastMCP server instance.

required
tools dict[str, ToolEntry]

Dict from discover_tools().

required
Source code in packages/axm-mcp/src/axm_mcp/discovery.py
Python
def register_tools(  # type: ignore[explicit-any]
    mcp: FastMCP,
    tools: dict[str, ToolEntry],
) -> None:
    """Register discovered tools as MCP tool callables.

    Each tool becomes a callable ``tool_name(**kwargs) -> dict``
    that delegates to ``tool.execute(**kwargs)``.

    Args:
        mcp: FastMCP server instance.
        tools: Dict from discover_tools().
    """
    for name, tool in tools.items():
        register_one(mcp, name, tool)
        logger.info("Registered MCP tool: %s", name)