File size: 5,631 Bytes
2aa8b3a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
"""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",
]