mcp4api 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.
mcp4api/cli.py ADDED
@@ -0,0 +1,665 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import logging
5
+ import sys
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+ from typing import TYPE_CHECKING, Annotated, Protocol, TypedDict, runtime_checkable
9
+
10
+ import typer
11
+
12
+ from mcp4api import __version__
13
+ from mcp4api.curate.agent import (
14
+ DEFAULT_BUDGET_TOKENS,
15
+ DEFAULT_BUDGET_USD,
16
+ DEFAULT_MAX_TOOLS,
17
+ DEFAULT_MODEL,
18
+ )
19
+ from mcp4api.curate.models import CurationMode
20
+ from mcp4api.errors import ExitCode, GapError, Mcp4apiError, UsageError
21
+ from mcp4api.verify import OnFailure
22
+
23
+ if TYPE_CHECKING:
24
+ from mcp4api.verify import VerifyOptions
25
+
26
+ _DESCRIPTION = (
27
+ "Turn API docs into a working MCP server, "
28
+ "then verify every generated tool against the real API."
29
+ )
30
+ _STAGES = frozenset({"ingest", "curate", "emit", "verify", "record"})
31
+ _ENDPOINTS = "endpoints.json"
32
+ _TOOLS = "tools.yaml"
33
+ _GENERATED = "generated"
34
+ _LOG = logging.getLogger("mcp4api")
35
+
36
+ _Out = Annotated[
37
+ Path | None,
38
+ typer.Option("--out", help="Directory that receives stage artifacts."),
39
+ ]
40
+ _Json = Annotated[bool, typer.Option("--json", help="Print errors as a JSON object.")]
41
+ _Quiet = Annotated[bool, typer.Option("--quiet", help="Show errors only.")]
42
+ _Verbose = Annotated[bool, typer.Option("--verbose", help="Show debug logs.")]
43
+ _Offline = Annotated[bool, typer.Option("--offline", help="Read local files only.")]
44
+ _AllowRefHost = Annotated[
45
+ list[str] | None,
46
+ typer.Option("--allow-ref-host", help="Host allowed for remote reference fetches."),
47
+ ]
48
+ _Model = Annotated[str, typer.Option("--model", help="Claude model used for agent curation.")]
49
+ _BudgetTokens = Annotated[
50
+ int,
51
+ typer.Option("--budget-tokens", help="Maximum tokens for agent curation."),
52
+ ]
53
+ _BudgetUsd = Annotated[
54
+ float,
55
+ typer.Option("--budget-usd", help="Maximum estimated USD for agent curation."),
56
+ ]
57
+ _Curation = Annotated[CurationMode, typer.Option("--curation", help="Curation mode.")]
58
+ _MaxTools = Annotated[
59
+ int,
60
+ typer.Option("--max-tools", help="Tool-count target. Exceeding it requires a rationale."),
61
+ ]
62
+ _Namespace = Annotated[str, typer.Option("--namespace", help="Prefix added to each tool name.")]
63
+ _IncludeTag = Annotated[
64
+ list[str] | None,
65
+ typer.Option("--include-tag", help="Tag or category to keep instead of excluding."),
66
+ ]
67
+ _DryRun = Annotated[
68
+ bool,
69
+ typer.Option("--dry-run", help="Print a token and cost estimate without a paid call."),
70
+ ]
71
+ _Recurate = Annotated[
72
+ bool,
73
+ typer.Option("--recurate", help="Curate again even when the input hash matches."),
74
+ ]
75
+ _Force = Annotated[
76
+ bool,
77
+ typer.Option("--force", help="Overwrite generated files that were edited."),
78
+ ]
79
+ _AllowHost = Annotated[
80
+ list[str] | None,
81
+ typer.Option("--allow-host", help="Host the verification proxy may dial."),
82
+ ]
83
+ _OnFailure = Annotated[
84
+ OnFailure,
85
+ typer.Option(
86
+ "--on-failure",
87
+ help="exclude drops a failed tool. keep warns in its description.",
88
+ ),
89
+ ]
90
+ _Sandbox = Annotated[
91
+ str,
92
+ typer.Option(
93
+ "--sandbox",
94
+ help="Sandbox base URL for record, or for verify instead of fixtures.",
95
+ ),
96
+ ]
97
+ _Deterministic = Annotated[
98
+ bool,
99
+ typer.Option("--deterministic", help="Omit timestamps and durations from the report."),
100
+ ]
101
+ _Live = Annotated[bool, typer.Option("--live", help="Call the spec server instead of fixtures.")]
102
+ _AllowWrites = Annotated[
103
+ bool,
104
+ typer.Option("--allow-writes", help="Call destructive and non-idempotent tools during --live."),
105
+ ]
106
+ _Isolate = Annotated[
107
+ bool,
108
+ typer.Option("--isolate", help="Run verification in a Linux network namespace."),
109
+ ]
110
+ _Html = Annotated[bool, typer.Option("--html", help="Also write report/verification.html.")]
111
+ _Spec = Annotated[str, typer.Argument(help="OpenAPI file path or URL.")]
112
+
113
+
114
+ class ErrorReport(TypedDict):
115
+ ok: bool
116
+ exit_code: int
117
+ error: str
118
+
119
+
120
+ @dataclass(frozen=True)
121
+ class RunOptions:
122
+ out: Path
123
+ as_json: bool
124
+ quiet: bool
125
+ verbose: bool
126
+ offline: bool
127
+ allow_ref_hosts: tuple[str, ...]
128
+ model: str
129
+ budget_tokens: int
130
+ budget_usd: float
131
+ curation: CurationMode
132
+ max_tools: int
133
+ namespace: str
134
+ include_tags: tuple[str, ...]
135
+ dry_run: bool
136
+ recurate: bool
137
+ force: bool
138
+ allow_hosts: tuple[str, ...]
139
+ on_failure: OnFailure
140
+ sandbox: str
141
+ deterministic: bool
142
+ live: bool
143
+ allow_writes: bool
144
+ isolate: bool
145
+ html: bool
146
+
147
+
148
+ @runtime_checkable
149
+ class _ParserError(Protocol):
150
+ exit_code: int
151
+
152
+ def show(self) -> None: ...
153
+
154
+
155
+ def main(argv: list[str] | None = None) -> int:
156
+ """Run the primary command or one stage.
157
+
158
+ Args:
159
+ argv: Arguments to parse. Defaults to the process arguments.
160
+
161
+ Returns:
162
+ Process exit code. 0 means every generated tool verified. 1 is an
163
+ internal error. 2 is a usage or configuration error. 3 means at least
164
+ one tool is a known gap. 4 is an ingest, network, model, or upstream
165
+ API failure.
166
+ """
167
+ args = list(sys.argv[1:] if argv is None else argv)
168
+ try:
169
+ app(args=_normalize_argv(args), standalone_mode=False, prog_name="mcp4api")
170
+ except GapError as exc:
171
+ if not exc.printed or _json_requested(args):
172
+ _emit_error(exc.message, exc.exit_code, as_json=_json_requested(args))
173
+ return exc.exit_code
174
+ except Mcp4apiError as exc:
175
+ _emit_error(exc.message, exc.exit_code, as_json=_json_requested(args))
176
+ return exc.exit_code
177
+ except typer.TyperException as exc:
178
+ return _parser_exit(exc)
179
+ except Exception as exc:
180
+ _emit_error(
181
+ str(exc) or exc.__class__.__name__,
182
+ ExitCode.INTERNAL,
183
+ as_json=_json_requested(args),
184
+ )
185
+ return ExitCode.INTERNAL
186
+ return ExitCode.OK
187
+
188
+
189
+ def _print_version(value: bool) -> None:
190
+ if not value:
191
+ return
192
+ typer.echo(f"mcp4api {__version__}")
193
+ raise typer.Exit(code=ExitCode.OK)
194
+
195
+
196
+ app = typer.Typer(
197
+ add_completion=False,
198
+ help=_DESCRIPTION,
199
+ no_args_is_help=False,
200
+ )
201
+
202
+
203
+ @app.callback(invoke_without_command=True)
204
+ def _root(
205
+ ctx: typer.Context,
206
+ _version: Annotated[
207
+ bool,
208
+ typer.Option(
209
+ "--version",
210
+ callback=_print_version,
211
+ is_eager=True,
212
+ help="Print the version and exit.",
213
+ ),
214
+ ] = False,
215
+ ) -> None:
216
+ if ctx.invoked_subcommand is None:
217
+ raise UsageError("missing spec")
218
+
219
+
220
+ @app.command("run", hidden=True)
221
+ def _run(
222
+ ctx: typer.Context,
223
+ spec: _Spec,
224
+ out: _Out = None,
225
+ as_json: _Json = False,
226
+ quiet: _Quiet = False,
227
+ verbose: _Verbose = False,
228
+ offline: _Offline = False,
229
+ allow_ref_host: _AllowRefHost = None,
230
+ model: _Model = DEFAULT_MODEL,
231
+ budget_tokens: _BudgetTokens = DEFAULT_BUDGET_TOKENS,
232
+ budget_usd: _BudgetUsd = DEFAULT_BUDGET_USD,
233
+ curation: _Curation = CurationMode.AGENT,
234
+ max_tools: _MaxTools = DEFAULT_MAX_TOOLS,
235
+ namespace: _Namespace = "",
236
+ include_tag: _IncludeTag = None,
237
+ dry_run: _DryRun = False,
238
+ recurate: _Recurate = False,
239
+ force: _Force = False,
240
+ allow_host: _AllowHost = None,
241
+ on_failure: _OnFailure = OnFailure.EXCLUDE,
242
+ sandbox: _Sandbox = "",
243
+ deterministic: _Deterministic = False,
244
+ live: _Live = False,
245
+ allow_writes: _AllowWrites = False,
246
+ isolate: _Isolate = False,
247
+ html: _Html = False,
248
+ ) -> None:
249
+ options = _settings(
250
+ ctx,
251
+ out=out,
252
+ as_json=as_json,
253
+ quiet=quiet,
254
+ verbose=verbose,
255
+ offline=offline,
256
+ allow_ref_host=allow_ref_host,
257
+ model=model,
258
+ budget_tokens=budget_tokens,
259
+ budget_usd=budget_usd,
260
+ curation=curation,
261
+ max_tools=max_tools,
262
+ namespace=namespace,
263
+ include_tag=include_tag,
264
+ dry_run=dry_run,
265
+ recurate=recurate,
266
+ force=force,
267
+ allow_host=allow_host,
268
+ on_failure=on_failure,
269
+ sandbox=sandbox,
270
+ deterministic=deterministic,
271
+ live=live,
272
+ allow_writes=allow_writes,
273
+ isolate=isolate,
274
+ html=html,
275
+ )
276
+ _ingest(spec, options)
277
+ _curate(options)
278
+ if options.dry_run:
279
+ return
280
+ _emit(options)
281
+ _verify(options)
282
+
283
+
284
+ @app.command(help="Read an OpenAPI file path or URL and write endpoints.json.")
285
+ def ingest(
286
+ ctx: typer.Context,
287
+ spec: _Spec,
288
+ out: _Out = None,
289
+ as_json: _Json = False,
290
+ quiet: _Quiet = False,
291
+ verbose: _Verbose = False,
292
+ offline: _Offline = False,
293
+ allow_ref_host: _AllowRefHost = None,
294
+ ) -> None:
295
+ _ingest(
296
+ spec,
297
+ _settings(
298
+ ctx,
299
+ out=out,
300
+ as_json=as_json,
301
+ quiet=quiet,
302
+ verbose=verbose,
303
+ offline=offline,
304
+ allow_ref_host=allow_ref_host,
305
+ ),
306
+ )
307
+
308
+
309
+ @app.command(help="Read endpoints.json and write tools.yaml.")
310
+ def curate(
311
+ ctx: typer.Context,
312
+ out: _Out = None,
313
+ as_json: _Json = False,
314
+ quiet: _Quiet = False,
315
+ verbose: _Verbose = False,
316
+ offline: _Offline = False,
317
+ model: _Model = DEFAULT_MODEL,
318
+ budget_tokens: _BudgetTokens = DEFAULT_BUDGET_TOKENS,
319
+ budget_usd: _BudgetUsd = DEFAULT_BUDGET_USD,
320
+ curation: _Curation = CurationMode.AGENT,
321
+ max_tools: _MaxTools = DEFAULT_MAX_TOOLS,
322
+ namespace: _Namespace = "",
323
+ include_tag: _IncludeTag = None,
324
+ dry_run: _DryRun = False,
325
+ recurate: _Recurate = False,
326
+ ) -> None:
327
+ _curate(
328
+ _settings(
329
+ ctx,
330
+ out=out,
331
+ as_json=as_json,
332
+ quiet=quiet,
333
+ verbose=verbose,
334
+ offline=offline,
335
+ model=model,
336
+ budget_tokens=budget_tokens,
337
+ budget_usd=budget_usd,
338
+ curation=curation,
339
+ max_tools=max_tools,
340
+ namespace=namespace,
341
+ include_tag=include_tag,
342
+ dry_run=dry_run,
343
+ recurate=recurate,
344
+ )
345
+ )
346
+
347
+
348
+ @app.command(help="Read tools.yaml and endpoints.json and write generated/.")
349
+ def emit(
350
+ ctx: typer.Context,
351
+ out: _Out = None,
352
+ as_json: _Json = False,
353
+ quiet: _Quiet = False,
354
+ verbose: _Verbose = False,
355
+ force: _Force = False,
356
+ ) -> None:
357
+ options = _settings(ctx, out=out, as_json=as_json, quiet=quiet, verbose=verbose, force=force)
358
+ _require_file(options.out / _TOOLS)
359
+ _require_file(options.out / _ENDPOINTS)
360
+ _emit(options)
361
+
362
+
363
+ @app.command(help="Call every generated tool and write report/.")
364
+ def verify(
365
+ ctx: typer.Context,
366
+ out: _Out = None,
367
+ as_json: _Json = False,
368
+ quiet: _Quiet = False,
369
+ verbose: _Verbose = False,
370
+ offline: _Offline = False,
371
+ allow_host: _AllowHost = None,
372
+ on_failure: _OnFailure = OnFailure.EXCLUDE,
373
+ sandbox: _Sandbox = "",
374
+ deterministic: _Deterministic = False,
375
+ live: _Live = False,
376
+ allow_writes: _AllowWrites = False,
377
+ isolate: _Isolate = False,
378
+ html: _Html = False,
379
+ ) -> None:
380
+ options = _settings(
381
+ ctx,
382
+ out=out,
383
+ as_json=as_json,
384
+ quiet=quiet,
385
+ verbose=verbose,
386
+ offline=offline,
387
+ allow_host=allow_host,
388
+ on_failure=on_failure,
389
+ sandbox=sandbox,
390
+ deterministic=deterministic,
391
+ live=live,
392
+ allow_writes=allow_writes,
393
+ isolate=isolate,
394
+ html=html,
395
+ )
396
+ _require_dir(options.out / _GENERATED)
397
+ _require_file(options.out / _ENDPOINTS)
398
+ _require_file(options.out / _TOOLS)
399
+ _verify(options)
400
+
401
+
402
+ @app.command(help="Record cassettes against a sandbox URL.")
403
+ def record(
404
+ ctx: typer.Context,
405
+ out: _Out = None,
406
+ as_json: _Json = False,
407
+ quiet: _Quiet = False,
408
+ verbose: _Verbose = False,
409
+ allow_host: _AllowHost = None,
410
+ sandbox: _Sandbox = "",
411
+ ) -> None:
412
+ _record(
413
+ _settings(
414
+ ctx,
415
+ out=out,
416
+ as_json=as_json,
417
+ quiet=quiet,
418
+ verbose=verbose,
419
+ allow_host=allow_host,
420
+ sandbox=sandbox,
421
+ )
422
+ )
423
+
424
+
425
+ def _normalize_argv(argv: list[str]) -> list[str]:
426
+ values, flags = _flag_sets()
427
+ equals = tuple(name + "=" for name in values)
428
+ hoisted: list[str] = []
429
+ rest: list[str] = []
430
+ index = 0
431
+ while index < len(argv):
432
+ token = argv[index]
433
+ if token == "--":
434
+ rest.extend(argv[index:])
435
+ break
436
+ if token in values:
437
+ if index + 1 >= len(argv):
438
+ hoisted.append(token)
439
+ index += 1
440
+ continue
441
+ hoisted.extend((token, argv[index + 1]))
442
+ index += 2
443
+ continue
444
+ if token.startswith(equals):
445
+ hoisted.append(token)
446
+ index += 1
447
+ continue
448
+ if token in flags:
449
+ hoisted.append(token)
450
+ index += 1
451
+ continue
452
+ rest.append(token)
453
+ index += 1
454
+ if not rest:
455
+ return hoisted
456
+ if rest[0] in _STAGES:
457
+ command, positionals = rest[0], rest[1:]
458
+ elif rest[0].startswith("-"):
459
+ return [*hoisted, *rest]
460
+ else:
461
+ command, positionals = "run", rest
462
+ return [command, *hoisted, *positionals]
463
+
464
+
465
+ def _flag_sets() -> tuple[frozenset[str], frozenset[str]]:
466
+ command = typer.main.get_command(app)
467
+ values: set[str] = set()
468
+ flags: set[str] = set()
469
+ grouped = getattr(command, "commands", {})
470
+ commands = [command, *grouped.values()]
471
+ for item in commands:
472
+ for param in item.params:
473
+ opts = getattr(param, "opts", None) or ()
474
+ names = [name for name in opts if name.startswith("--")]
475
+ if not names:
476
+ continue
477
+ if getattr(param, "is_flag", False):
478
+ flags.update(names)
479
+ else:
480
+ values.update(names)
481
+ return frozenset(values), frozenset(flags)
482
+
483
+
484
+ def _settings(
485
+ ctx: typer.Context,
486
+ *,
487
+ out: Path | None = None,
488
+ as_json: bool = False,
489
+ quiet: bool = False,
490
+ verbose: bool = False,
491
+ offline: bool = False,
492
+ allow_ref_host: list[str] | None = None,
493
+ model: str = DEFAULT_MODEL,
494
+ budget_tokens: int = DEFAULT_BUDGET_TOKENS,
495
+ budget_usd: float = DEFAULT_BUDGET_USD,
496
+ curation: CurationMode = CurationMode.AGENT,
497
+ max_tools: int = DEFAULT_MAX_TOOLS,
498
+ namespace: str = "",
499
+ include_tag: list[str] | None = None,
500
+ dry_run: bool = False,
501
+ recurate: bool = False,
502
+ force: bool = False,
503
+ allow_host: list[str] | None = None,
504
+ on_failure: OnFailure = OnFailure.EXCLUDE,
505
+ sandbox: str = "",
506
+ deterministic: bool = False,
507
+ live: bool = False,
508
+ allow_writes: bool = False,
509
+ isolate: bool = False,
510
+ html: bool = False,
511
+ ) -> RunOptions:
512
+ if quiet and verbose:
513
+ raise UsageError("use only one of --quiet and --verbose")
514
+ if live and sandbox:
515
+ raise UsageError("use only one of --live and --sandbox")
516
+ _configure_logging(quiet=quiet, verbose=verbose)
517
+ options = RunOptions(
518
+ out=Path(".") if out is None else out,
519
+ as_json=as_json,
520
+ quiet=quiet,
521
+ verbose=verbose,
522
+ offline=offline,
523
+ allow_ref_hosts=tuple(allow_ref_host or ()),
524
+ model=model,
525
+ budget_tokens=budget_tokens,
526
+ budget_usd=budget_usd,
527
+ curation=curation,
528
+ max_tools=max_tools,
529
+ namespace=namespace,
530
+ include_tags=tuple(include_tag or ()),
531
+ dry_run=dry_run,
532
+ recurate=recurate,
533
+ force=force,
534
+ allow_hosts=tuple(allow_host or ()),
535
+ on_failure=on_failure,
536
+ sandbox=sandbox,
537
+ deterministic=deterministic,
538
+ live=live,
539
+ allow_writes=allow_writes,
540
+ isolate=isolate,
541
+ html=html,
542
+ )
543
+ ctx.obj = options
544
+ return options
545
+
546
+
547
+ def _ingest(spec: str, options: RunOptions) -> None:
548
+ from mcp4api.ingest import ingest as load_spec
549
+ from mcp4api.ingest import write_endpoints
550
+
551
+ _LOG.info("ingest")
552
+ loaded = load_spec(
553
+ spec,
554
+ offline=options.offline,
555
+ allow_ref_hosts=options.allow_ref_hosts,
556
+ )
557
+ write_endpoints(loaded, options.out)
558
+
559
+
560
+ def _curate(options: RunOptions) -> None:
561
+ from mcp4api.curate.agent import CurationOptions, curate
562
+
563
+ _LOG.info("curate")
564
+ result = curate(
565
+ options.out,
566
+ CurationOptions(
567
+ model=options.model,
568
+ mode=options.curation,
569
+ max_tools=options.max_tools,
570
+ namespace=options.namespace,
571
+ include_tags=options.include_tags,
572
+ budget_tokens=options.budget_tokens,
573
+ budget_usd=options.budget_usd,
574
+ dry_run=options.dry_run,
575
+ recurate=options.recurate,
576
+ offline=options.offline,
577
+ ),
578
+ )
579
+ if result.report:
580
+ sys.stdout.write(result.report)
581
+ for warning in result.warnings:
582
+ sys.stderr.write(f"{warning}\n")
583
+
584
+
585
+ def _require_file(path: Path) -> None:
586
+ if not path.is_file():
587
+ raise UsageError(f"not found: {path}")
588
+
589
+
590
+ def _require_dir(path: Path) -> None:
591
+ if not path.is_dir():
592
+ raise UsageError(f"not found: {path}")
593
+
594
+
595
+ def _emit(options: RunOptions) -> None:
596
+ from mcp4api.emit import emit
597
+
598
+ _LOG.info("emit")
599
+ emit(options.out, force=options.force)
600
+
601
+
602
+ def _verify(options: RunOptions) -> None:
603
+ from mcp4api.verify import verify
604
+
605
+ _LOG.info("verify")
606
+ outcome = verify(options.out, _verify_options(options))
607
+ if outcome.gaps:
608
+ raise GapError(outcome.summary, printed=not options.as_json)
609
+
610
+
611
+ def _record(options: RunOptions) -> None:
612
+ from mcp4api.verify import record as record_cassettes
613
+
614
+ if not options.sandbox:
615
+ raise UsageError("record needs --sandbox")
616
+ _LOG.info("record")
617
+ record_cassettes(options.out, _verify_options(options))
618
+
619
+
620
+ def _verify_options(options: RunOptions) -> VerifyOptions:
621
+ from mcp4api.verify import VerifyOptions as Options
622
+
623
+ return Options(
624
+ offline=options.offline,
625
+ live=options.live,
626
+ sandbox=options.sandbox,
627
+ allow_hosts=options.allow_hosts,
628
+ allow_writes=options.allow_writes,
629
+ on_failure=options.on_failure,
630
+ deterministic=options.deterministic,
631
+ html=options.html,
632
+ quiet=options.quiet,
633
+ as_json=options.as_json,
634
+ isolate=options.isolate,
635
+ )
636
+
637
+
638
+ def _configure_logging(*, quiet: bool, verbose: bool) -> None:
639
+ if quiet:
640
+ level = logging.ERROR
641
+ elif verbose:
642
+ level = logging.DEBUG
643
+ else:
644
+ level = logging.INFO
645
+ logging.basicConfig(level=level, format="%(message)s", force=True)
646
+
647
+
648
+ def _json_requested(argv: list[str]) -> bool:
649
+ return "--json" in argv
650
+
651
+
652
+ def _emit_error(message: str, code: ExitCode, *, as_json: bool) -> None:
653
+ if as_json:
654
+ report: ErrorReport = {"ok": False, "exit_code": code, "error": message}
655
+ sys.stdout.write(json.dumps(report) + "\n")
656
+ return
657
+ sys.stderr.write(f"{message}\n")
658
+
659
+
660
+ def _parser_exit(exc: typer.TyperException) -> int:
661
+ if isinstance(exc, _ParserError):
662
+ exc.show()
663
+ return exc.exit_code
664
+ sys.stderr.write(f"{exc}\n")
665
+ return exc.exit_code
@@ -0,0 +1,3 @@
1
+ from mcp4api.curate.agent import curate
2
+
3
+ __all__ = ["curate"]