"""Plumbing shared by every server in this package. Four things live here because each one would otherwise be copied into five files and drift in four of them. **The result envelope.** MCP returns a call result as a list of typed content blocks, and :func:`distinct_tools.mcp._result_payload` reads ``structuredContent`` first when a server supplies it. Every tool here supplies it. The alternative, letting the in-process transport wrap a plain return value in a text block, encodes the payload as JSON and then encodes that string as JSON again, so every quote and newline is escaped twice and a text-heavy result can double in size before the broker measures it against ``JobPolicy.max_output_bytes``. The MCP specification says a server that returns structured content should *also* serialise it into a text block for clients that cannot read the structured field. This client can read it, and the duplicate copy would cost the bytes the tool's own caps are sized against, so these servers deliberately do not send one. A server published to unknown clients should make the opposite choice. **Argument checking.** Unknown arguments are refused rather than ignored, for the reason :mod:`distinct_tools.local` gives: ignoring one is always the permissive outcome and it hides the model misreading the schema, which is the failure this library exists to make visible. **Refusals are :class:`~distinct_tools.core.ToolInputError`.** Raising it from an in-process MCP handler reaches the broker unchanged, so the model gets ``invalid_input`` and the reason rather than a generic tool failure. That matters more than it looks: below 7B the single most common recovery from a refusal is to call the tool again unchanged, and a message that names the argument and what would be accepted is the only thing that redirects it. **Publishing an existing spec.** :func:`definition_from_spec` republishes a :class:`~distinct_tools.core.ToolSpec` over MCP with the same description and the same schema the direct path advertises, so the text the model reads cannot diverge between the two routes. """ from __future__ import annotations from collections.abc import Mapping, Sequence from typing import Any from distinct_tools.core import ToolInputError, ToolSpec from distinct_tools.mcp import McpToolDefinition def structured(value: Mapping[str, Any]) -> dict[str, Any]: """Return an MCP call result carrying ``value`` as structured content.""" return {"content": [], "structuredContent": dict(value)} def reject_unknown(arguments: Mapping[str, Any], known: frozenset[str]) -> None: """Refuse any argument the tool does not understand.""" if not isinstance(arguments, Mapping): raise ToolInputError("arguments must be an object") unexpected = sorted(str(key) for key in arguments if str(key) not in known) if unexpected: accepted = ", ".join(sorted(known)) raise ToolInputError( f"unknown argument(s): {', '.join(unexpected)}. This tool accepts: {accepted}" ) def text_argument( arguments: Mapping[str, Any], key: str, limit: int, *, required: bool = True, default: str = "", ) -> str: """Return a bounded string argument, or refuse with the reason.""" value = arguments.get(key) if value is None and not required: return default if not isinstance(value, str): raise ToolInputError(f"{key} must be a string") if required and not value.strip(): raise ToolInputError(f"{key} must be a non-empty string") if len(value) > limit: raise ToolInputError(f"{key} must be at most {limit:,} characters") return value def choice_argument( arguments: Mapping[str, Any], key: str, allowed: Sequence[str], *, default: str | None = None, ) -> str: """Return one of ``allowed``, or refuse with the full list of choices.""" value = arguments.get(key) if value is None and default is not None: return default if not isinstance(value, str): raise ToolInputError(f"{key} must be a string") lowered = value.strip().lower() if lowered not in allowed: raise ToolInputError(f"{key} must be one of: {', '.join(allowed)}") return lowered def flag_argument(arguments: Mapping[str, Any], key: str, *, default: bool = False) -> bool: """Return a strictly boolean argument. A string ``"true"`` is refused rather than coerced. Coercing it would make the tool's behaviour depend on how the model happened to spell a value the schema said was a boolean, and the refusal names the problem. """ value = arguments.get(key) if value is None: return default if not isinstance(value, bool): raise ToolInputError(f"{key} must be true or false") return value def definition_from_spec(spec: ToolSpec, *, name: str | None = None) -> McpToolDefinition: """Build an MCP definition from a spec the direct path already publishes. ``ToolSpec`` deep-freezes its schema into mapping proxies and tuples, which are not JSON types, so the published dictionary is taken from :meth:`ToolSpec.to_dict` rather than from the attribute. Handing the frozen form to the JSON encoder is exactly what the digest path refuses. """ published = spec.to_dict() return McpToolDefinition( name=name or spec.tool_id, description=str(published["description"]), input_schema=published["input_schema"], ) __all__ = [ "choice_argument", "definition_from_spec", "flag_argument", "reject_unknown", "structured", "text_argument", ]