Source code for klea_utils.mcp.dispatch

#!/usr/bin/env python3
"""
Client-side MCP tool-call dispatch with permission gating.

File: klea_utils/mcp/dispatch.py

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

import asyncio
import logging
from typing import Any, cast

from fastmcp.client.client import CallToolResult
from mcp.types import TextContent

from klea_utils.mcp.tool_impls.permission import check_tool_arguments_permissions

logger = logging.getLogger(__name__)


def _denied_result(denials: list[str]) -> CallToolResult:
    """Build a non-halting error result for a permission-denied tool call."""
    return CallToolResult(
        content=[TextContent(type="text", text="\n".join(denials))],
        structured_content=None,
        meta=None,
        is_error=True,
    )


[docs] async def dispatch_tool_calls( mcp_client: Any, tool_calls: list[tuple[str, dict[str, Any]]], tools_meta: dict[str, dict[str, Any]] | None = None, project_root: str | None = None, ) -> list[CallToolResult]: """Gate and dispatch tool calls against an MCP server. For each ``(tool, args)`` pair, the tool's ``meta`` (from ``tools_meta``) is checked with :func:`check_tool_arguments_permissions` before the call reaches the server; denied calls never touch the server and instead produce a synthetic non-halting error result. Allowed calls are dispatched in parallel, and the returned list stays aligned with the input *tool_calls* order. :param mcp_client: MCP client used for ``call_tool``. Its reentrant context is entered/exited by this helper. Typed as ``Any`` because :class:`fastmcp.Client.call_tool` has a complex signature (optional ``arguments``, keyword-only ``raise_on_error``, ``CallToolResult | ToolTask`` return) that a structural protocol would not cleanly match; tests substitute a fake implementing the subset used here. :param tool_calls: ``(tool name, arguments)`` pairs to invoke. :param tools_meta: Mapping of tool name to the tool's ``meta`` dict (e.g. ``{t.name: t.meta for t in mcp_tools}``). Path arguments declared under ``checkpaths`` are permission-checked client-side. :param project_root: Boundary directory for the permission gate. Defaults to the current working directory. :returns: One :class:`CallToolResult` per input call, in input order. """ tools_meta = tools_meta or {} n = len(tool_calls) results: list[CallToolResult | None] = [None] * n pending: list[tuple[int, Any]] = [] async with mcp_client: for i, (tool, args) in enumerate(tool_calls): tool_meta = tools_meta.get(tool) denials = check_tool_arguments_permissions(tool_meta, args, project_root) if denials: logger.warning( f"Denied tool call before dispatch\n{tool = }\n{denials = }" ) results[i] = _denied_result(denials) else: pending.append( ( i, mcp_client.call_tool( name=tool, arguments=args, raise_on_error=False, ), ) ) if pending: indices, coros = zip(*pending) gathered = await asyncio.gather(*coros, return_exceptions=True) for idx, res in zip(indices, gathered): if isinstance(res, BaseException): tool, args = tool_calls[idx] logger.warning( f"Tool call failed\n{tool = }\n{idx = }\n{args = }\n{res = }" ) results[idx] = CallToolResult( content=[ TextContent( type="text", text=f"{res.__class__.__name__}: {res}", ) ], structured_content=None, meta=None, is_error=True, ) else: results[idx] = res # type: ignore[assignment] if any(r is None for r in results): missing = [i for i, r in enumerate(results) if r is None] offending = [(i, tool_calls[i]) for i in missing] logger.error(f"dispatch left unfilled slots\n{offending = }") raise RuntimeError(f"dispatch internal error: unfilled results at {missing}") return cast(list[CallToolResult], results)