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.
@@ -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])