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/__init__.py +1 -0
- mcp4api/__main__.py +5 -0
- mcp4api/cassettes.py +96 -0
- mcp4api/cli.py +665 -0
- mcp4api/curate/__init__.py +3 -0
- mcp4api/curate/agent.py +850 -0
- mcp4api/curate/criteria.md +21 -0
- mcp4api/curate/draft.py +606 -0
- mcp4api/curate/models.py +614 -0
- mcp4api/curate/rules.py +1059 -0
- mcp4api/emit/__init__.py +205 -0
- mcp4api/emit/catalog.py +717 -0
- mcp4api/emit/render.py +917 -0
- mcp4api/emit/templates/README.md.j2 +58 -0
- mcp4api/emit/templates/client.py.j2 +305 -0
- mcp4api/emit/templates/conftest.py.j2 +20 -0
- mcp4api/emit/templates/file.py.j2 +4 -0
- mcp4api/emit/templates/header.py.j2 +2 -0
- mcp4api/emit/templates/main.py.j2 +99 -0
- mcp4api/emit/templates/pyproject.toml.j2 +24 -0
- mcp4api/emit/templates/test_tools.py.j2 +40 -0
- mcp4api/errors.py +35 -0
- mcp4api/ingest/__init__.py +3 -0
- mcp4api/ingest/build.py +1064 -0
- mcp4api/ingest/convert.py +569 -0
- mcp4api/ingest/load.py +603 -0
- mcp4api/model.py +324 -0
- mcp4api/py.typed +0 -0
- mcp4api/report.py +330 -0
- mcp4api/suite.py +1384 -0
- mcp4api/verify.py +1114 -0
- mcp4api-0.1.0.dist-info/METADATA +157 -0
- mcp4api-0.1.0.dist-info/RECORD +36 -0
- mcp4api-0.1.0.dist-info/WHEEL +4 -0
- mcp4api-0.1.0.dist-info/entry_points.txt +2 -0
- mcp4api-0.1.0.dist-info/licenses/LICENSE +21 -0
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
|