mcp-to-cli-kit 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mcp_to_cli_kit/__init__.py +1 -0
- mcp_to_cli_kit/agent.py +106 -0
- mcp_to_cli_kit/arguments.py +789 -0
- mcp_to_cli_kit/cache.py +115 -0
- mcp_to_cli_kit/cli.py +1011 -0
- mcp_to_cli_kit/client.py +543 -0
- mcp_to_cli_kit/config.py +480 -0
- mcp_to_cli_kit/output.py +125 -0
- mcp_to_cli_kit/py.typed +0 -0
- mcp_to_cli_kit-0.1.0.dist-info/METADATA +238 -0
- mcp_to_cli_kit-0.1.0.dist-info/RECORD +13 -0
- mcp_to_cli_kit-0.1.0.dist-info/WHEEL +4 -0
- mcp_to_cli_kit-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,789 @@
|
|
|
1
|
+
"""Compile a tool's JSON input schema into click options, and the option values back into arguments.
|
|
2
|
+
|
|
3
|
+
The rules, chosen so that what a user types reaches the server unchanged or fails loudly:
|
|
4
|
+
|
|
5
|
+
- An option is named after the property in kebab case (``startTime`` becomes ``--start-time``),
|
|
6
|
+
and the property's own name is what goes on the wire. The property's own spelling
|
|
7
|
+
(``--startTime``) and its snake case (``--start_time``) are accepted as hidden aliases, unless
|
|
8
|
+
another option already uses that spelling.
|
|
9
|
+
- An option the user leaves out is not sent. Schema defaults are shown in the help, never sent.
|
|
10
|
+
- A boolean is a ``--x/--no-x`` pair, so ``false`` can be sent.
|
|
11
|
+
- An ``enum`` of scalars is a fixed choice.
|
|
12
|
+
- An array is a repeatable option, one item per occurrence. A single occurrence holding a JSON
|
|
13
|
+
array is taken as the whole array. Values are never split on commas.
|
|
14
|
+
- An object takes a JSON literal. Invalid JSON is a usage error, not a string.
|
|
15
|
+
- ``$ref`` to the schema's own ``$defs`` or ``definitions``, ``allOf`` and nullable ``anyOf`` /
|
|
16
|
+
``oneOf`` / ``type: [T, "null"]`` are resolved before an option is chosen. Anything else
|
|
17
|
+
(a real union, a schema without a type) takes JSON when the value parses and fits the
|
|
18
|
+
schema, and the raw string otherwise.
|
|
19
|
+
- ``--input-json`` passes the whole argument object as JSON, from a literal, ``@file`` or ``-``
|
|
20
|
+
for stdin. ``--arg-file NAME=PATH`` reads one property's value from a file, or from stdin when
|
|
21
|
+
the path is ``-``, exactly as if it had been typed after the property's option. Options given
|
|
22
|
+
next to them are merged in; naming the same property twice is an error.
|
|
23
|
+
- The merged arguments are validated against the full schema with ``jsonschema`` before the
|
|
24
|
+
call, which covers required properties, ``minimum`` / ``maximum`` and the rest.
|
|
25
|
+
- An option whose name collides with a built-in option (``--help``, ``--input-json``,
|
|
26
|
+
``--arg-file``, ``--json``, ``--pretty``) gets an ``arg-`` prefix.
|
|
27
|
+
- Help text is never truncated.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import inspect
|
|
33
|
+
import json
|
|
34
|
+
import math
|
|
35
|
+
import re
|
|
36
|
+
import sys
|
|
37
|
+
from collections.abc import Mapping, Sequence
|
|
38
|
+
from dataclasses import dataclass, field
|
|
39
|
+
from pathlib import Path
|
|
40
|
+
from typing import Any, Literal
|
|
41
|
+
|
|
42
|
+
import click
|
|
43
|
+
import jsonschema
|
|
44
|
+
from jsonschema.exceptions import SchemaError
|
|
45
|
+
from jsonschema.protocols import Validator
|
|
46
|
+
|
|
47
|
+
Kind = Literal["boolean", "integer", "number", "string", "enum", "array", "object", "any"]
|
|
48
|
+
|
|
49
|
+
INPUT_JSON_OPTION = "--input-json"
|
|
50
|
+
|
|
51
|
+
ARG_FILE_OPTION = "--arg-file"
|
|
52
|
+
|
|
53
|
+
# Built into every tool command. The output-mode flags are added by the CLI, next to these.
|
|
54
|
+
BUILTIN_OPTIONS = frozenset({"--help", INPUT_JSON_OPTION, ARG_FILE_OPTION, "--json", "--pretty"})
|
|
55
|
+
|
|
56
|
+
# A property name that would turn into one of these is prefixed, so the built-in stays reachable.
|
|
57
|
+
COLLISION_PREFIX = "arg-"
|
|
58
|
+
|
|
59
|
+
_MAX_REF_DEPTH = 32
|
|
60
|
+
|
|
61
|
+
_TRUE_WORDS = frozenset({"true", "1", "yes", "on"})
|
|
62
|
+
_FALSE_WORDS = frozenset({"false", "0", "no", "off"})
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass(frozen=True)
|
|
66
|
+
class SchemaShape:
|
|
67
|
+
"""What a (sub)schema accepts, after ``$ref``, ``allOf`` and nullability are resolved.
|
|
68
|
+
|
|
69
|
+
Attributes:
|
|
70
|
+
kind: The option type chosen for it.
|
|
71
|
+
schema: The resolved schema, with the ``null`` alternative removed.
|
|
72
|
+
nullable: Whether ``null`` is also allowed.
|
|
73
|
+
enum: The allowed values, for ``kind == "enum"``.
|
|
74
|
+
items: The shape of the items, for ``kind == "array"``.
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
kind: Kind
|
|
78
|
+
schema: Mapping[str, Any]
|
|
79
|
+
nullable: bool = False
|
|
80
|
+
enum: tuple[Any, ...] = ()
|
|
81
|
+
items: SchemaShape | None = None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass(frozen=True)
|
|
85
|
+
class Parameter:
|
|
86
|
+
"""One tool property as a click option.
|
|
87
|
+
|
|
88
|
+
Attributes:
|
|
89
|
+
name: The property name, sent on the wire unchanged.
|
|
90
|
+
flag: The long option, ``--kebab-name``.
|
|
91
|
+
aliases: Further accepted spellings, not shown in the help.
|
|
92
|
+
dest: The click parameter name, a safe identifier unrelated to the property name.
|
|
93
|
+
shape: What the property accepts.
|
|
94
|
+
required: Whether the schema lists it as required.
|
|
95
|
+
description: The property's description, if any.
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
name: str
|
|
99
|
+
flag: str
|
|
100
|
+
aliases: tuple[str, ...]
|
|
101
|
+
dest: str
|
|
102
|
+
shape: SchemaShape
|
|
103
|
+
required: bool
|
|
104
|
+
description: str | None
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@dataclass
|
|
108
|
+
class CompiledTool:
|
|
109
|
+
"""A tool's input schema, ready to build a click command from and to turn values back into arguments."""
|
|
110
|
+
|
|
111
|
+
schema: Mapping[str, Any]
|
|
112
|
+
parameters: list[Parameter] = field(default_factory=list)
|
|
113
|
+
input_json_dest: str = "input_json"
|
|
114
|
+
arg_file_dest: str = "arg_file"
|
|
115
|
+
|
|
116
|
+
def options(self) -> list[click.Parameter]:
|
|
117
|
+
"""Build the click options: the tool's properties first, then ``--input-json`` and ``--arg-file``."""
|
|
118
|
+
options: list[click.Parameter] = [_build_option(self.schema, parameter) for parameter in self.parameters]
|
|
119
|
+
options.append(
|
|
120
|
+
click.Option(
|
|
121
|
+
[INPUT_JSON_OPTION, self.input_json_dest],
|
|
122
|
+
metavar="JSON|@FILE|-",
|
|
123
|
+
help=(
|
|
124
|
+
"All arguments as one JSON object: a literal, @path to read a file, or - to read stdin. "
|
|
125
|
+
"Options given as well are merged in; naming the same property twice is an error."
|
|
126
|
+
),
|
|
127
|
+
)
|
|
128
|
+
)
|
|
129
|
+
options.append(
|
|
130
|
+
click.Option(
|
|
131
|
+
[ARG_FILE_OPTION, self.arg_file_dest],
|
|
132
|
+
metavar="NAME=PATH",
|
|
133
|
+
multiple=True,
|
|
134
|
+
help=(
|
|
135
|
+
"Read one property's value from a file, or from stdin when PATH is -, exactly as if it were "
|
|
136
|
+
"typed after the property's option. NAME is the option or the property name. Repeatable."
|
|
137
|
+
),
|
|
138
|
+
)
|
|
139
|
+
)
|
|
140
|
+
return options
|
|
141
|
+
|
|
142
|
+
def find(self, name: str) -> Parameter | None:
|
|
143
|
+
"""Find a parameter by its property name, its option or any alias, with or without the dashes."""
|
|
144
|
+
wanted = name if name.startswith("--") else f"--{name}"
|
|
145
|
+
for parameter in self.parameters:
|
|
146
|
+
if name == parameter.name or wanted == parameter.flag or wanted in parameter.aliases:
|
|
147
|
+
return parameter
|
|
148
|
+
return None
|
|
149
|
+
|
|
150
|
+
def arguments(self, values: Mapping[str, Any]) -> dict[str, Any]:
|
|
151
|
+
"""Turn the parsed click values into the tool's arguments, leaving out everything not given.
|
|
152
|
+
|
|
153
|
+
Raises:
|
|
154
|
+
click.BadParameter: A value cannot be converted or read, or a property is given twice.
|
|
155
|
+
"""
|
|
156
|
+
arguments: dict[str, Any] = {}
|
|
157
|
+
sources: dict[str, str] = {}
|
|
158
|
+
raw_input = values.get(self.input_json_dest)
|
|
159
|
+
arg_files = [self._parse_arg_file(spec) + (spec,) for spec in values.get(self.arg_file_dest) or ()]
|
|
160
|
+
|
|
161
|
+
# Checked before anything is read, so a conflict never consumes stdin.
|
|
162
|
+
stdin_users = [INPUT_JSON_OPTION] if raw_input in ("-", "@-") else []
|
|
163
|
+
stdin_users += [f"{ARG_FILE_OPTION} {spec}" for _, path, spec in arg_files if path == "-"]
|
|
164
|
+
if len(stdin_users) > 1:
|
|
165
|
+
raise click.BadParameter(f"only one input can read stdin, got: {', '.join(stdin_users)}.")
|
|
166
|
+
|
|
167
|
+
if raw_input is not None:
|
|
168
|
+
for name, value in read_input_json(raw_input).items():
|
|
169
|
+
arguments[name] = value
|
|
170
|
+
sources[name] = INPUT_JSON_OPTION
|
|
171
|
+
|
|
172
|
+
given: dict[str, tuple[Parameter, Any, str]] = {}
|
|
173
|
+
for parameter in self.parameters:
|
|
174
|
+
value = values.get(parameter.dest)
|
|
175
|
+
if value is None or (isinstance(value, tuple) and len(value) == 0):
|
|
176
|
+
continue
|
|
177
|
+
given[parameter.name] = (parameter, value, parameter.flag)
|
|
178
|
+
|
|
179
|
+
for parameter, path, spec in arg_files:
|
|
180
|
+
if parameter.name in given:
|
|
181
|
+
raise click.BadParameter(
|
|
182
|
+
f"{parameter.name!r} is given both as {given[parameter.name][2]} and with {ARG_FILE_OPTION}.",
|
|
183
|
+
param_hint=ARG_FILE_OPTION,
|
|
184
|
+
)
|
|
185
|
+
content = _read_arg_file(path)
|
|
186
|
+
value = (content,) if parameter.shape.kind == "array" else content
|
|
187
|
+
given[parameter.name] = (parameter, value, f"{ARG_FILE_OPTION} {spec}")
|
|
188
|
+
|
|
189
|
+
for name, (parameter, value, source) in given.items():
|
|
190
|
+
converted = convert_value(self.schema, parameter, value)
|
|
191
|
+
if name in arguments:
|
|
192
|
+
raise click.BadParameter(
|
|
193
|
+
f"{name!r} is given both in {sources[name]} and as {source}.", param_hint=parameter.flag
|
|
194
|
+
)
|
|
195
|
+
arguments[name] = converted
|
|
196
|
+
return arguments
|
|
197
|
+
|
|
198
|
+
def _parse_arg_file(self, spec: str) -> tuple[Parameter, str]:
|
|
199
|
+
name, separator, path = spec.partition("=")
|
|
200
|
+
if not separator or not name or not path:
|
|
201
|
+
raise click.BadParameter(f"expected NAME=PATH, got {spec!r}.", param_hint=ARG_FILE_OPTION)
|
|
202
|
+
parameter = self.find(name)
|
|
203
|
+
if parameter is None:
|
|
204
|
+
known = ", ".join(parameter.flag for parameter in self.parameters) or "none"
|
|
205
|
+
raise click.BadParameter(f"no property named {name!r}. Known: {known}.", param_hint=ARG_FILE_OPTION)
|
|
206
|
+
return parameter, path
|
|
207
|
+
|
|
208
|
+
def summary(self) -> str:
|
|
209
|
+
"""The parameter list printed with a usage error, so no ``--help`` round trip is needed."""
|
|
210
|
+
if not self.parameters:
|
|
211
|
+
return "This tool takes no parameters."
|
|
212
|
+
rows = [(_option_label(parameter), _summary_notes(parameter)) for parameter in self.parameters]
|
|
213
|
+
width = min(max(len(left) for left, _ in rows), 40)
|
|
214
|
+
lines = ["Parameters:"]
|
|
215
|
+
for left, notes in rows:
|
|
216
|
+
lines.append(f" {left.ljust(width)} {notes}".rstrip())
|
|
217
|
+
return "\n".join(lines)
|
|
218
|
+
|
|
219
|
+
def validate(self, arguments: Mapping[str, Any]) -> list[str]:
|
|
220
|
+
"""Validate the arguments against the full input schema.
|
|
221
|
+
|
|
222
|
+
Returns:
|
|
223
|
+
One readable message per problem, empty when the arguments are valid. A schema that
|
|
224
|
+
is itself invalid is not held against the user, and validates everything.
|
|
225
|
+
"""
|
|
226
|
+
validator = _validator_for(self.schema)
|
|
227
|
+
if validator is None:
|
|
228
|
+
return []
|
|
229
|
+
messages = []
|
|
230
|
+
for error in sorted(validator.iter_errors(dict(arguments)), key=lambda error: list(error.absolute_path)):
|
|
231
|
+
messages.append(_describe_validation_error(self, error))
|
|
232
|
+
return messages
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def compile_tool(input_schema: Mapping[str, Any] | None) -> CompiledTool:
|
|
236
|
+
"""Compile a tool's ``inputSchema`` into options.
|
|
237
|
+
|
|
238
|
+
Args:
|
|
239
|
+
input_schema: The schema from ``tools/list``. None or an empty schema means no properties.
|
|
240
|
+
"""
|
|
241
|
+
schema: Mapping[str, Any] = input_schema or {"type": "object"}
|
|
242
|
+
root = _resolve(schema, schema)
|
|
243
|
+
properties: Mapping[str, Any] = root.get("properties") or {}
|
|
244
|
+
required = set(root.get("required") or [])
|
|
245
|
+
|
|
246
|
+
shapes = {
|
|
247
|
+
name: shape_of(schema, property_schema if isinstance(property_schema, Mapping) else {})
|
|
248
|
+
for name, property_schema in properties.items()
|
|
249
|
+
}
|
|
250
|
+
flags = _allocate_flags(list(properties), {name for name, shape in shapes.items() if shape.kind == "boolean"})
|
|
251
|
+
|
|
252
|
+
parameters = []
|
|
253
|
+
for index, (name, property_schema) in enumerate(properties.items()):
|
|
254
|
+
flag, aliases = flags[name]
|
|
255
|
+
resolved = property_schema if isinstance(property_schema, Mapping) else {}
|
|
256
|
+
description = _resolve(schema, resolved).get("description")
|
|
257
|
+
if isinstance(description, str):
|
|
258
|
+
description = inspect.cleandoc(description)
|
|
259
|
+
parameters.append(
|
|
260
|
+
Parameter(
|
|
261
|
+
name=name,
|
|
262
|
+
flag=flag,
|
|
263
|
+
aliases=aliases,
|
|
264
|
+
dest=f"arg_{index}",
|
|
265
|
+
shape=shapes[name],
|
|
266
|
+
required=name in required,
|
|
267
|
+
description=description if isinstance(description, str) else None,
|
|
268
|
+
)
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
return CompiledTool(schema=schema, parameters=parameters)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _is_valid_flag(flag: str) -> bool:
|
|
275
|
+
return re.fullmatch(r"--[A-Za-z0-9][A-Za-z0-9_-]*", flag) is not None
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def kebab_case(name: str) -> str:
|
|
279
|
+
"""``startTime`` and ``start_time`` become ``start-time``; ``HTTPCode`` becomes ``http-code``."""
|
|
280
|
+
spaced = re.sub(r"([a-z0-9])([A-Z])", r"\1-\2", name)
|
|
281
|
+
spaced = re.sub(r"([A-Z]+)([A-Z][a-z])", r"\1-\2", spaced)
|
|
282
|
+
kebab = re.sub(r"[^A-Za-z0-9]+", "-", spaced).strip("-").lower()
|
|
283
|
+
return kebab or "arg"
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _allocate_flags(names: list[str], booleans: set[str]) -> dict[str, tuple[str, tuple[str, ...]]]:
|
|
287
|
+
"""Give every property its option and aliases, independently of the order the schema lists them in.
|
|
288
|
+
|
|
289
|
+
Three passes, each in sorted order: the kebab-case options of all properties, then each
|
|
290
|
+
property's own name, then the snake-case forms. A spelling from an earlier pass always wins,
|
|
291
|
+
so a property's own name beats another property's derived alias. A boolean also claims the
|
|
292
|
+
``--no-`` form of each spelling, and booleans go first in the first pass, so ``false`` can
|
|
293
|
+
always be sent; a property whose option would be that ``--no-`` form is renamed.
|
|
294
|
+
"""
|
|
295
|
+
used: set[str] = set(BUILTIN_OPTIONS)
|
|
296
|
+
|
|
297
|
+
def spellings(flag: str, name: str) -> set[str]:
|
|
298
|
+
return {flag, f"--no-{flag[2:]}"} if name in booleans else {flag}
|
|
299
|
+
|
|
300
|
+
def free(flag: str, name: str) -> bool:
|
|
301
|
+
return _is_valid_flag(flag) and not spellings(flag, name) & used
|
|
302
|
+
|
|
303
|
+
primaries: dict[str, str] = {}
|
|
304
|
+
for name in sorted(names, key=lambda item: (item not in booleans, item)):
|
|
305
|
+
base = f"--{kebab_case(name)}"
|
|
306
|
+
if spellings(base, name) & BUILTIN_OPTIONS:
|
|
307
|
+
base = f"--{COLLISION_PREFIX}{kebab_case(name)}"
|
|
308
|
+
flag = base
|
|
309
|
+
counter = 2
|
|
310
|
+
while not free(flag, name):
|
|
311
|
+
flag = f"{base}-{counter}"
|
|
312
|
+
counter += 1
|
|
313
|
+
used |= spellings(flag, name)
|
|
314
|
+
primaries[name] = flag
|
|
315
|
+
|
|
316
|
+
# A boolean's alias must not take the --no- form that is another property's own name.
|
|
317
|
+
own_names = {f"--{name}": name for name in names}
|
|
318
|
+
|
|
319
|
+
def claimed_by_other(alias: str, name: str) -> bool:
|
|
320
|
+
return any(own_names.get(spelling, name) != name for spelling in spellings(alias, name))
|
|
321
|
+
|
|
322
|
+
def own_name(name: str) -> str:
|
|
323
|
+
return f"--{name}"
|
|
324
|
+
|
|
325
|
+
def snake_case(name: str) -> str:
|
|
326
|
+
return f"--{kebab_case(name).replace('-', '_')}"
|
|
327
|
+
|
|
328
|
+
aliases: dict[str, list[str]] = {name: [] for name in names}
|
|
329
|
+
for derive in (own_name, snake_case):
|
|
330
|
+
for name in sorted(names):
|
|
331
|
+
alias = derive(name)
|
|
332
|
+
if (
|
|
333
|
+
alias != primaries[name]
|
|
334
|
+
and alias not in aliases[name]
|
|
335
|
+
and free(alias, name)
|
|
336
|
+
and not claimed_by_other(alias, name)
|
|
337
|
+
):
|
|
338
|
+
used |= spellings(alias, name)
|
|
339
|
+
aliases[name].append(alias)
|
|
340
|
+
|
|
341
|
+
return {name: (primaries[name], tuple(aliases[name])) for name in names}
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def shape_of(root: Mapping[str, Any], schema: Mapping[str, Any]) -> SchemaShape:
|
|
345
|
+
"""Work out what a property schema accepts and which option type fits it."""
|
|
346
|
+
resolved = _resolve(root, schema)
|
|
347
|
+
nullable = False
|
|
348
|
+
|
|
349
|
+
if "const" in resolved and "enum" not in resolved:
|
|
350
|
+
resolved = {**resolved, "enum": [resolved["const"]]}
|
|
351
|
+
|
|
352
|
+
enum = resolved.get("enum")
|
|
353
|
+
if isinstance(enum, list) and enum:
|
|
354
|
+
values = [value for value in enum if value is not None]
|
|
355
|
+
nullable = len(values) != len(enum)
|
|
356
|
+
if values and all(isinstance(value, str | int | float | bool) for value in values):
|
|
357
|
+
return SchemaShape("enum", resolved, nullable, enum=tuple(values))
|
|
358
|
+
return SchemaShape("any", resolved, nullable)
|
|
359
|
+
|
|
360
|
+
for union_key in ("anyOf", "oneOf"):
|
|
361
|
+
branches = resolved.get(union_key)
|
|
362
|
+
if isinstance(branches, list) and branches:
|
|
363
|
+
resolved_branches = [_resolve(root, branch) for branch in branches if isinstance(branch, Mapping)]
|
|
364
|
+
non_null = [branch for branch in resolved_branches if not _is_null_schema(branch)]
|
|
365
|
+
nullable = len(non_null) != len(resolved_branches)
|
|
366
|
+
if len(non_null) == 1:
|
|
367
|
+
rest = {key: value for key, value in resolved.items() if key != union_key}
|
|
368
|
+
inner = shape_of(root, {**rest, **non_null[0]})
|
|
369
|
+
return SchemaShape(
|
|
370
|
+
inner.kind, inner.schema, True if nullable else inner.nullable, inner.enum, inner.items
|
|
371
|
+
)
|
|
372
|
+
return SchemaShape("any", resolved, nullable)
|
|
373
|
+
|
|
374
|
+
declared = resolved.get("type")
|
|
375
|
+
types: list[str]
|
|
376
|
+
if isinstance(declared, list):
|
|
377
|
+
types = [str(item) for item in declared]
|
|
378
|
+
elif isinstance(declared, str):
|
|
379
|
+
types = [declared]
|
|
380
|
+
elif "properties" in resolved or "additionalProperties" in resolved:
|
|
381
|
+
types = ["object"]
|
|
382
|
+
elif "items" in resolved:
|
|
383
|
+
types = ["array"]
|
|
384
|
+
else:
|
|
385
|
+
types = []
|
|
386
|
+
|
|
387
|
+
if "null" in types:
|
|
388
|
+
nullable = True
|
|
389
|
+
types = [item for item in types if item != "null"]
|
|
390
|
+
|
|
391
|
+
if len(types) != 1:
|
|
392
|
+
return SchemaShape("any", resolved, nullable)
|
|
393
|
+
|
|
394
|
+
kind = types[0]
|
|
395
|
+
if kind == "array":
|
|
396
|
+
items = resolved.get("items")
|
|
397
|
+
item_shape = shape_of(root, items) if isinstance(items, Mapping) else SchemaShape("any", {})
|
|
398
|
+
return SchemaShape("array", resolved, nullable, items=item_shape)
|
|
399
|
+
if kind in ("boolean", "integer", "number", "string", "object"):
|
|
400
|
+
return SchemaShape(kind, resolved, nullable) # type: ignore[arg-type]
|
|
401
|
+
return SchemaShape("any", resolved, nullable)
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def _is_null_schema(schema: Mapping[str, Any]) -> bool:
|
|
405
|
+
return schema.get("type") == "null" or schema.get("const", ...) is None or schema.get("enum") == [None]
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def _resolve(root: Mapping[str, Any], schema: Mapping[str, Any], depth: int = 0) -> Mapping[str, Any]:
|
|
409
|
+
"""Inline a local ``$ref`` and merge ``allOf``, at the top level of ``schema`` only."""
|
|
410
|
+
if depth > _MAX_REF_DEPTH:
|
|
411
|
+
return schema
|
|
412
|
+
|
|
413
|
+
current: Mapping[str, Any] = schema
|
|
414
|
+
reference = current.get("$ref")
|
|
415
|
+
if isinstance(reference, str):
|
|
416
|
+
target = _lookup_ref(root, reference)
|
|
417
|
+
if target is not None:
|
|
418
|
+
siblings = {key: value for key, value in current.items() if key != "$ref"}
|
|
419
|
+
current = {**_resolve(root, target, depth + 1), **siblings}
|
|
420
|
+
|
|
421
|
+
parts = current.get("allOf")
|
|
422
|
+
if isinstance(parts, list) and parts:
|
|
423
|
+
merged: dict[str, Any] = {key: value for key, value in current.items() if key != "allOf"}
|
|
424
|
+
for part in parts:
|
|
425
|
+
if not isinstance(part, Mapping):
|
|
426
|
+
continue
|
|
427
|
+
resolved_part = _resolve(root, part, depth + 1)
|
|
428
|
+
for key, value in resolved_part.items():
|
|
429
|
+
if key == "properties" and isinstance(value, Mapping):
|
|
430
|
+
existing = merged.get("properties") or {}
|
|
431
|
+
# Earlier definitions win; properties keep the order they are declared in.
|
|
432
|
+
merged["properties"] = {**existing, **{k: v for k, v in value.items() if k not in existing}}
|
|
433
|
+
elif key == "required" and isinstance(value, list):
|
|
434
|
+
merged["required"] = list(dict.fromkeys([*(merged.get("required") or []), *value]))
|
|
435
|
+
else:
|
|
436
|
+
merged.setdefault(key, value)
|
|
437
|
+
current = merged
|
|
438
|
+
|
|
439
|
+
return current
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def _lookup_ref(root: Mapping[str, Any], reference: str) -> Mapping[str, Any] | None:
|
|
443
|
+
if not reference.startswith("#"):
|
|
444
|
+
return None
|
|
445
|
+
node: Any = root
|
|
446
|
+
for token in reference[1:].split("/"):
|
|
447
|
+
if token == "":
|
|
448
|
+
continue
|
|
449
|
+
token = token.replace("~1", "/").replace("~0", "~")
|
|
450
|
+
if isinstance(node, Mapping) and token in node:
|
|
451
|
+
node = node[token]
|
|
452
|
+
elif isinstance(node, list) and token.isdigit() and int(token) < len(node):
|
|
453
|
+
node = node[int(token)]
|
|
454
|
+
else:
|
|
455
|
+
return None
|
|
456
|
+
return node if isinstance(node, Mapping) else None
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
class ToolOption(click.Option):
|
|
460
|
+
"""An option whose help lists only its primary spelling, not the aliases."""
|
|
461
|
+
|
|
462
|
+
def __init__(self, parameter: Parameter, declarations: list[str], **attrs: Any) -> None:
|
|
463
|
+
super().__init__(declarations, **attrs)
|
|
464
|
+
self.tool_parameter = parameter
|
|
465
|
+
|
|
466
|
+
def get_help_record(self, ctx: click.Context) -> tuple[str, str] | None:
|
|
467
|
+
record = super().get_help_record(ctx)
|
|
468
|
+
if record is None:
|
|
469
|
+
return None
|
|
470
|
+
return _option_label(self.tool_parameter), record[1]
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def _option_label(parameter: Parameter) -> str:
|
|
474
|
+
if parameter.shape.kind == "boolean":
|
|
475
|
+
return f"{parameter.flag} / --no-{parameter.flag[2:]}"
|
|
476
|
+
return f"{parameter.flag} {_metavar(_display_shape(parameter.shape))}"
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def _build_option(root: Mapping[str, Any], parameter: Parameter) -> click.Option:
|
|
480
|
+
shape = parameter.shape
|
|
481
|
+
help_text = _option_help(parameter)
|
|
482
|
+
names = [parameter.flag, *parameter.aliases]
|
|
483
|
+
|
|
484
|
+
if shape.kind == "boolean":
|
|
485
|
+
pairs = [f"{name}/--no-{name[2:]}" for name in names]
|
|
486
|
+
return ToolOption(parameter, [*pairs, parameter.dest], default=None, help=help_text)
|
|
487
|
+
|
|
488
|
+
if shape.kind == "enum":
|
|
489
|
+
choices = [_choice_label(value) for value in shape.enum]
|
|
490
|
+
return ToolOption(
|
|
491
|
+
parameter, [*names, parameter.dest], type=click.Choice(choices, case_sensitive=True), help=help_text
|
|
492
|
+
)
|
|
493
|
+
|
|
494
|
+
if shape.kind == "array":
|
|
495
|
+
return ToolOption(
|
|
496
|
+
parameter, [*names, parameter.dest], multiple=True, metavar=_metavar(shape.items), help=help_text
|
|
497
|
+
)
|
|
498
|
+
|
|
499
|
+
return ToolOption(parameter, [*names, parameter.dest], metavar=_metavar(shape), help=help_text)
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def _display_shape(shape: SchemaShape) -> SchemaShape | None:
|
|
503
|
+
return shape.items if shape.kind == "array" else shape
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def _summary_notes(parameter: Parameter) -> str:
|
|
507
|
+
shape = parameter.shape
|
|
508
|
+
notes = []
|
|
509
|
+
if parameter.required:
|
|
510
|
+
notes.append("[required]")
|
|
511
|
+
if shape.kind == "array":
|
|
512
|
+
notes.append("[repeatable]")
|
|
513
|
+
if shape.items is not None and shape.items.kind == "enum":
|
|
514
|
+
notes.append("allowed: " + ", ".join(_choice_label(value) for value in shape.items.enum))
|
|
515
|
+
for keyword in ("minimum", "maximum"):
|
|
516
|
+
if keyword in shape.schema and isinstance(shape.schema[keyword], int | float):
|
|
517
|
+
notes.append(f"{keyword} {shape.schema[keyword]}")
|
|
518
|
+
return " ".join(notes)
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
def _choice_label(value: Any) -> str:
|
|
522
|
+
if isinstance(value, bool):
|
|
523
|
+
return "true" if value else "false"
|
|
524
|
+
return str(value)
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def _metavar(shape: SchemaShape | None) -> str:
|
|
528
|
+
if shape is None:
|
|
529
|
+
return "VALUE"
|
|
530
|
+
return {
|
|
531
|
+
"boolean": "BOOLEAN",
|
|
532
|
+
"integer": "INTEGER",
|
|
533
|
+
"number": "NUMBER",
|
|
534
|
+
"string": "TEXT",
|
|
535
|
+
"enum": "[" + "|".join(_choice_label(value) for value in shape.enum) + "]",
|
|
536
|
+
"array": "JSON",
|
|
537
|
+
"object": "JSON",
|
|
538
|
+
"any": "VALUE",
|
|
539
|
+
}[shape.kind]
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def _option_help(parameter: Parameter) -> str:
|
|
543
|
+
shape = parameter.shape
|
|
544
|
+
parts: list[str] = []
|
|
545
|
+
if parameter.description:
|
|
546
|
+
parts.append(parameter.description.strip())
|
|
547
|
+
|
|
548
|
+
details: list[str] = []
|
|
549
|
+
if shape.kind == "array":
|
|
550
|
+
item = shape.items
|
|
551
|
+
if item is not None and item.kind == "enum":
|
|
552
|
+
details.append("Allowed items: " + ", ".join(_choice_label(value) for value in item.enum) + ".")
|
|
553
|
+
details.append("Repeat the option for each item, or pass the whole array as one JSON literal.")
|
|
554
|
+
elif shape.kind == "object":
|
|
555
|
+
details.append("A JSON object.")
|
|
556
|
+
elif shape.kind == "any":
|
|
557
|
+
details.append("JSON, or text when the value is not JSON.")
|
|
558
|
+
|
|
559
|
+
for keyword, label in (
|
|
560
|
+
("minimum", "Minimum"),
|
|
561
|
+
("exclusiveMinimum", "Greater than"),
|
|
562
|
+
("maximum", "Maximum"),
|
|
563
|
+
("exclusiveMaximum", "Less than"),
|
|
564
|
+
("minLength", "Minimum length"),
|
|
565
|
+
("maxLength", "Maximum length"),
|
|
566
|
+
("minItems", "Minimum items"),
|
|
567
|
+
("maxItems", "Maximum items"),
|
|
568
|
+
("pattern", "Pattern"),
|
|
569
|
+
("format", "Format"),
|
|
570
|
+
):
|
|
571
|
+
if keyword in shape.schema and not isinstance(shape.schema[keyword], bool | dict | list):
|
|
572
|
+
details.append(f"{label}: {shape.schema[keyword]}.")
|
|
573
|
+
|
|
574
|
+
if shape.schema.get("default") is not None:
|
|
575
|
+
details.append(f"Server default: {json.dumps(shape.schema['default'], ensure_ascii=False)}.")
|
|
576
|
+
if parameter.required:
|
|
577
|
+
details.append("[required]")
|
|
578
|
+
|
|
579
|
+
return " ".join([*parts, *details])
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
def convert_value(root: Mapping[str, Any], parameter: Parameter, value: Any) -> Any:
|
|
583
|
+
"""Convert what click parsed for one option into the JSON value to send.
|
|
584
|
+
|
|
585
|
+
Raises:
|
|
586
|
+
click.BadParameter: The value does not fit the option's type.
|
|
587
|
+
"""
|
|
588
|
+
shape = parameter.shape
|
|
589
|
+
try:
|
|
590
|
+
if shape.kind == "array":
|
|
591
|
+
return _convert_array(root, shape, tuple(value))
|
|
592
|
+
return _convert_scalar(root, shape, value)
|
|
593
|
+
except ValueError as error:
|
|
594
|
+
raise click.BadParameter(str(error), param_hint=parameter.flag) from error
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
def _convert_array(root: Mapping[str, Any], shape: SchemaShape, values: tuple[str, ...]) -> list[Any]:
|
|
598
|
+
item = shape.items or SchemaShape("any", {})
|
|
599
|
+
if len(values) == 1 and values[0].lstrip().startswith("["):
|
|
600
|
+
try:
|
|
601
|
+
parsed = json.loads(values[0])
|
|
602
|
+
except json.JSONDecodeError as error:
|
|
603
|
+
if item.kind not in ("string", "any", "enum"):
|
|
604
|
+
raise ValueError(f"{values[0]!r} is not a valid JSON array: {error.msg}.") from error
|
|
605
|
+
else:
|
|
606
|
+
if isinstance(parsed, list):
|
|
607
|
+
return parsed
|
|
608
|
+
return [_convert_scalar(root, item, value) for value in values]
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
def _convert_scalar(root: Mapping[str, Any], shape: SchemaShape, value: Any) -> Any:
|
|
612
|
+
if shape.kind == "boolean":
|
|
613
|
+
if isinstance(value, bool):
|
|
614
|
+
return value
|
|
615
|
+
return _parse_bool(str(value))
|
|
616
|
+
if shape.kind == "enum":
|
|
617
|
+
for allowed in shape.enum:
|
|
618
|
+
if _choice_label(allowed) == value:
|
|
619
|
+
return allowed
|
|
620
|
+
raise ValueError(f"{value!r} is not one of {', '.join(_choice_label(v) for v in shape.enum)}.")
|
|
621
|
+
if shape.kind == "integer":
|
|
622
|
+
return _parse_integer(str(value))
|
|
623
|
+
if shape.kind == "number":
|
|
624
|
+
return _parse_number(str(value))
|
|
625
|
+
if shape.kind == "string":
|
|
626
|
+
return str(value)
|
|
627
|
+
if shape.kind == "object":
|
|
628
|
+
parsed = _parse_json(str(value), "a JSON object")
|
|
629
|
+
if not isinstance(parsed, dict):
|
|
630
|
+
raise ValueError(f"expected a JSON object, got {type(parsed).__name__}.")
|
|
631
|
+
return parsed
|
|
632
|
+
if shape.kind == "array":
|
|
633
|
+
parsed = _parse_json(str(value), "a JSON array")
|
|
634
|
+
if not isinstance(parsed, list):
|
|
635
|
+
raise ValueError(f"expected a JSON array, got {type(parsed).__name__}.")
|
|
636
|
+
return parsed
|
|
637
|
+
return _convert_any(root, shape, str(value))
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
def _convert_any(root: Mapping[str, Any], shape: SchemaShape, value: str) -> Any:
|
|
641
|
+
"""Prefer the JSON reading of the value when it fits the schema, the plain string otherwise."""
|
|
642
|
+
try:
|
|
643
|
+
parsed: Any = json.loads(value)
|
|
644
|
+
except json.JSONDecodeError:
|
|
645
|
+
return value
|
|
646
|
+
validator = _validator_for(_with_definitions(root, shape.schema))
|
|
647
|
+
if validator is None or validator.is_valid(parsed):
|
|
648
|
+
return parsed
|
|
649
|
+
if validator.is_valid(value):
|
|
650
|
+
return value
|
|
651
|
+
return parsed
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
def _with_definitions(root: Mapping[str, Any], schema: Mapping[str, Any]) -> dict[str, Any]:
|
|
655
|
+
combined = dict(schema)
|
|
656
|
+
for key in ("$defs", "definitions"):
|
|
657
|
+
if key in root and key not in combined:
|
|
658
|
+
combined[key] = root[key]
|
|
659
|
+
return combined
|
|
660
|
+
|
|
661
|
+
|
|
662
|
+
def _parse_bool(value: str) -> bool:
|
|
663
|
+
lowered = value.strip().lower()
|
|
664
|
+
if lowered in _TRUE_WORDS:
|
|
665
|
+
return True
|
|
666
|
+
if lowered in _FALSE_WORDS:
|
|
667
|
+
return False
|
|
668
|
+
raise ValueError(f"{value!r} is not a boolean; use true or false.")
|
|
669
|
+
|
|
670
|
+
|
|
671
|
+
def _parse_integer(value: str) -> int:
|
|
672
|
+
try:
|
|
673
|
+
return int(value.strip())
|
|
674
|
+
except ValueError:
|
|
675
|
+
raise ValueError(f"{value!r} is not an integer.") from None
|
|
676
|
+
|
|
677
|
+
|
|
678
|
+
def _parse_number(value: str) -> int | float:
|
|
679
|
+
text = value.strip()
|
|
680
|
+
try:
|
|
681
|
+
return int(text)
|
|
682
|
+
except ValueError:
|
|
683
|
+
pass
|
|
684
|
+
try:
|
|
685
|
+
number = float(text)
|
|
686
|
+
except ValueError:
|
|
687
|
+
raise ValueError(f"{value!r} is not a number.") from None
|
|
688
|
+
if not math.isfinite(number):
|
|
689
|
+
raise ValueError(f"{value!r} is not a finite number.")
|
|
690
|
+
return number
|
|
691
|
+
|
|
692
|
+
|
|
693
|
+
def _parse_json(value: str, what: str) -> Any:
|
|
694
|
+
try:
|
|
695
|
+
return json.loads(value)
|
|
696
|
+
except json.JSONDecodeError as error:
|
|
697
|
+
raise ValueError(f"{value!r} is not {what}: {error.msg} at position {error.pos}.") from error
|
|
698
|
+
|
|
699
|
+
|
|
700
|
+
def _read_arg_file(path: str) -> str:
|
|
701
|
+
"""Read an ``--arg-file`` value exactly as stored, without stripping a trailing newline."""
|
|
702
|
+
if path == "-":
|
|
703
|
+
return sys.stdin.read()
|
|
704
|
+
file = Path(path).expanduser()
|
|
705
|
+
try:
|
|
706
|
+
return file.read_text(encoding="utf-8")
|
|
707
|
+
except OSError as error:
|
|
708
|
+
raise click.BadParameter(
|
|
709
|
+
f"cannot read {file}: {error.strerror or error}", param_hint=ARG_FILE_OPTION
|
|
710
|
+
) from error
|
|
711
|
+
except UnicodeDecodeError as error:
|
|
712
|
+
raise click.BadParameter(f"{file} is not UTF-8 text: {error.reason}", param_hint=ARG_FILE_OPTION) from error
|
|
713
|
+
|
|
714
|
+
|
|
715
|
+
def read_input_json(raw: str) -> dict[str, Any]:
|
|
716
|
+
"""Read the ``--input-json`` value: a JSON literal, ``@path`` for a file, or ``-`` / ``@-`` for stdin.
|
|
717
|
+
|
|
718
|
+
Raises:
|
|
719
|
+
click.BadParameter: The source cannot be read, or does not hold a JSON object.
|
|
720
|
+
"""
|
|
721
|
+
source = "the value"
|
|
722
|
+
text = raw
|
|
723
|
+
if raw in ("-", "@-"):
|
|
724
|
+
source = "stdin"
|
|
725
|
+
text = sys.stdin.read()
|
|
726
|
+
elif raw.startswith("@"):
|
|
727
|
+
path = Path(raw[1:]).expanduser()
|
|
728
|
+
source = str(path)
|
|
729
|
+
try:
|
|
730
|
+
text = path.read_text(encoding="utf-8")
|
|
731
|
+
except OSError as error:
|
|
732
|
+
raise click.BadParameter(
|
|
733
|
+
f"cannot read {path}: {error.strerror or error}", param_hint=INPUT_JSON_OPTION
|
|
734
|
+
) from error
|
|
735
|
+
try:
|
|
736
|
+
parsed = json.loads(text)
|
|
737
|
+
except json.JSONDecodeError as error:
|
|
738
|
+
raise click.BadParameter(
|
|
739
|
+
f"{source} is not valid JSON: {error.msg} at line {error.lineno} column {error.colno}.",
|
|
740
|
+
param_hint=INPUT_JSON_OPTION,
|
|
741
|
+
) from error
|
|
742
|
+
if not isinstance(parsed, dict):
|
|
743
|
+
raise click.BadParameter(
|
|
744
|
+
f"{source} must hold a JSON object of arguments, got {type(parsed).__name__}.", param_hint=INPUT_JSON_OPTION
|
|
745
|
+
)
|
|
746
|
+
return parsed
|
|
747
|
+
|
|
748
|
+
|
|
749
|
+
def _validator_for(schema: Mapping[str, Any]) -> Validator | None:
|
|
750
|
+
try:
|
|
751
|
+
validator_class = jsonschema.validators.validator_for(schema, default=jsonschema.Draft202012Validator)
|
|
752
|
+
validator_class.check_schema(dict(schema))
|
|
753
|
+
except SchemaError:
|
|
754
|
+
print("warning: the tool's input schema is invalid, arguments are not validated", file=sys.stderr)
|
|
755
|
+
return None
|
|
756
|
+
validator: Validator = validator_class(schema)
|
|
757
|
+
return validator
|
|
758
|
+
|
|
759
|
+
|
|
760
|
+
def _describe_validation_error(tool: CompiledTool, error: jsonschema.ValidationError) -> str:
|
|
761
|
+
path = list(error.absolute_path)
|
|
762
|
+
flags = {parameter.name: parameter.flag for parameter in tool.parameters}
|
|
763
|
+
|
|
764
|
+
if error.validator == "required" and not path:
|
|
765
|
+
missing = _missing_property(error)
|
|
766
|
+
if missing is not None:
|
|
767
|
+
flag = flags.get(missing)
|
|
768
|
+
return f"missing required {flag or repr(missing)}" + (f" ({missing!r})" if flag else "")
|
|
769
|
+
|
|
770
|
+
if error.validator == "additionalProperties" and not path:
|
|
771
|
+
return f"unexpected arguments: {error.message}"
|
|
772
|
+
|
|
773
|
+
if path and isinstance(path[0], str):
|
|
774
|
+
head = flags.get(path[0], repr(path[0]))
|
|
775
|
+
rest = "".join(f"[{item!r}]" for item in path[1:])
|
|
776
|
+
return f"{head}{rest}: {error.message}"
|
|
777
|
+
return error.message
|
|
778
|
+
|
|
779
|
+
|
|
780
|
+
def _missing_property(error: jsonschema.ValidationError) -> str | None:
|
|
781
|
+
match = re.match(r"^'(.+)' is a required property$", error.message)
|
|
782
|
+
return match.group(1) if match else None
|
|
783
|
+
|
|
784
|
+
|
|
785
|
+
def signature(command_path: str, parameters: Sequence[Parameter]) -> str:
|
|
786
|
+
"""A one-line summary of a tool's options, required ones first, for usage errors."""
|
|
787
|
+
required = [f"{parameter.flag} {_metavar(parameter.shape)}" for parameter in parameters if parameter.required]
|
|
788
|
+
optional = [f"[{parameter.flag}]" for parameter in parameters if not parameter.required]
|
|
789
|
+
return " ".join([command_path, *required, *optional])
|