distinct / distinct_mcp /common.py
aaxaxax's picture
initial commit
2aa8b3a
Raw
History Blame
5.63 kB
"""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",
]