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/emit/render.py
ADDED
|
@@ -0,0 +1,917 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
from jinja2 import Environment, PackageLoader, StrictUndefined, TemplateError, UndefinedError
|
|
9
|
+
|
|
10
|
+
from mcp4api import __version__
|
|
11
|
+
from mcp4api.curate.models import Manifest, Tool
|
|
12
|
+
from mcp4api.emit.catalog import (
|
|
13
|
+
_LINE,
|
|
14
|
+
_Catalog,
|
|
15
|
+
_ident,
|
|
16
|
+
_py,
|
|
17
|
+
_scalar,
|
|
18
|
+
_SchemaProblem,
|
|
19
|
+
_Type,
|
|
20
|
+
)
|
|
21
|
+
from mcp4api.errors import UsageError
|
|
22
|
+
from mcp4api.model import ApiSpec, Endpoint, JsonObject, JsonValue, SecurityScheme
|
|
23
|
+
|
|
24
|
+
_INPUT_REF = re.compile(r"^\$input\.([A-Za-z_][A-Za-z0-9_]*)$")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
_STEP_REF = re.compile(r"^\$steps\[(\d+)\]\.body((?:\.[A-Za-z_][A-Za-z0-9_]*|\[\])*)$")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
_VERSION = re.compile(
|
|
31
|
+
r"^(?:[0-9]+!)?"
|
|
32
|
+
r"[0-9]+(?:\.[0-9]+)*"
|
|
33
|
+
r"(?:(?:a|b|rc)[0-9]+)?"
|
|
34
|
+
r"(?:\.post[0-9]+)?"
|
|
35
|
+
r"(?:\.dev[0-9]+)?"
|
|
36
|
+
r"(?:\+[a-z0-9]+(?:\.[a-z0-9]+)*)?$",
|
|
37
|
+
re.IGNORECASE,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True)
|
|
42
|
+
class Rendered:
|
|
43
|
+
files: dict[str, str]
|
|
44
|
+
header: str
|
|
45
|
+
package: str
|
|
46
|
+
base_url_env: str
|
|
47
|
+
timeout_env: str
|
|
48
|
+
redact_env_names: tuple[str, ...]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True)
|
|
52
|
+
class _Const:
|
|
53
|
+
name: str
|
|
54
|
+
literal: str
|
|
55
|
+
reason: str
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class _Flags:
|
|
59
|
+
def __init__(self) -> None:
|
|
60
|
+
self.annotated = False
|
|
61
|
+
self.literal = False
|
|
62
|
+
self.field = False
|
|
63
|
+
self.adapter = False
|
|
64
|
+
self.pick = False
|
|
65
|
+
self.api_key = False
|
|
66
|
+
self.bearer = False
|
|
67
|
+
self.keys: list[tuple[str, str]] = []
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def render_files(spec: ApiSpec, manifest: Manifest, *, tools_sha256: str) -> Rendered:
|
|
71
|
+
slug = _slug(manifest.api.title)
|
|
72
|
+
module = f"{slug.replace('-', '_')}_mcp"
|
|
73
|
+
env_name = slug.replace("-", "_").upper()
|
|
74
|
+
program = f"{slug}-mcp"
|
|
75
|
+
line1, line2 = _header_lines(spec.source.uri, spec.source.sha256, tools_sha256)
|
|
76
|
+
catalog = _Catalog(_spec_defs(spec))
|
|
77
|
+
endpoints = {endpoint.id: endpoint for endpoint in spec.endpoints}
|
|
78
|
+
server, flags, annotations = _build_server(
|
|
79
|
+
manifest,
|
|
80
|
+
endpoints,
|
|
81
|
+
spec.security_schemes,
|
|
82
|
+
catalog,
|
|
83
|
+
)
|
|
84
|
+
base = _py(manifest.api.base_url)
|
|
85
|
+
if len(f"_DEFAULT_BASE_URL = {base}") > _LINE:
|
|
86
|
+
base = "(\n " + _py(manifest.api.base_url) + "\n)"
|
|
87
|
+
context = {"line1": line1, "line2": line2}
|
|
88
|
+
environment = _environment()
|
|
89
|
+
files = {
|
|
90
|
+
"README.md": _render(
|
|
91
|
+
environment,
|
|
92
|
+
"README.md.j2",
|
|
93
|
+
{
|
|
94
|
+
**context,
|
|
95
|
+
"title": manifest.api.title,
|
|
96
|
+
"program": program,
|
|
97
|
+
"env_rows": _env_rows(env_name, manifest.api.base_url, flags),
|
|
98
|
+
"tools": [
|
|
99
|
+
{
|
|
100
|
+
"name": tool.name,
|
|
101
|
+
"title": tool.title,
|
|
102
|
+
"description": _flat(tool.description),
|
|
103
|
+
}
|
|
104
|
+
for tool in manifest.tools
|
|
105
|
+
],
|
|
106
|
+
"claude_desktop": _claude(program, env_name, flags),
|
|
107
|
+
},
|
|
108
|
+
),
|
|
109
|
+
"pyproject.toml": _render(
|
|
110
|
+
environment,
|
|
111
|
+
"pyproject.toml.j2",
|
|
112
|
+
{
|
|
113
|
+
**context,
|
|
114
|
+
"distribution": program,
|
|
115
|
+
"version": _package_version(spec.version),
|
|
116
|
+
"description": _toml(f"{manifest.api.title} MCP server"),
|
|
117
|
+
"program": program,
|
|
118
|
+
"module": module,
|
|
119
|
+
},
|
|
120
|
+
),
|
|
121
|
+
f"{module}/__init__.py": _render(environment, "file.py.j2", {**context, "body": ""}),
|
|
122
|
+
f"{module}/__main__.py": _render(
|
|
123
|
+
environment,
|
|
124
|
+
"main.py.j2",
|
|
125
|
+
{**context, "module": module, "program_literal": _py(program)},
|
|
126
|
+
),
|
|
127
|
+
f"{module}/client.py": _render(
|
|
128
|
+
environment,
|
|
129
|
+
"client.py.j2",
|
|
130
|
+
{
|
|
131
|
+
**context,
|
|
132
|
+
"default_base_url": base,
|
|
133
|
+
"base_url_env": _py(f"{env_name}_BASE_URL"),
|
|
134
|
+
"timeout_env": _py(f"{env_name}_TIMEOUT"),
|
|
135
|
+
"has_api_key": flags.api_key,
|
|
136
|
+
"api_key_env": _py(f"{env_name}_API_KEY"),
|
|
137
|
+
"has_bearer": flags.bearer,
|
|
138
|
+
"token_env": _py(f"{env_name}_TOKEN"),
|
|
139
|
+
"has_pick": flags.pick,
|
|
140
|
+
"redact_env_names": _tuple(_redact_env_names(env_name, flags)),
|
|
141
|
+
},
|
|
142
|
+
),
|
|
143
|
+
f"{module}/models.py": _render(
|
|
144
|
+
environment,
|
|
145
|
+
"file.py.j2",
|
|
146
|
+
{**context, "body": catalog.source().rstrip("\n")},
|
|
147
|
+
),
|
|
148
|
+
f"{module}/server.py": _render(
|
|
149
|
+
environment,
|
|
150
|
+
"file.py.j2",
|
|
151
|
+
{
|
|
152
|
+
**context,
|
|
153
|
+
"body": _server_text(
|
|
154
|
+
module, slug, manifest.api.title, flags, server, annotations, catalog
|
|
155
|
+
).rstrip("\n"),
|
|
156
|
+
},
|
|
157
|
+
),
|
|
158
|
+
}
|
|
159
|
+
return Rendered(
|
|
160
|
+
files=files,
|
|
161
|
+
header=f"{line1}\n{line2}",
|
|
162
|
+
package=module,
|
|
163
|
+
base_url_env=f"{env_name}_BASE_URL",
|
|
164
|
+
timeout_env=f"{env_name}_TIMEOUT",
|
|
165
|
+
redact_env_names=tuple(_redact_env_names(env_name, flags)),
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _build_server(
|
|
170
|
+
manifest: Manifest,
|
|
171
|
+
endpoints: dict[str, Endpoint],
|
|
172
|
+
schemes: dict[str, SecurityScheme],
|
|
173
|
+
catalog: _Catalog,
|
|
174
|
+
) -> tuple[tuple[list[_Const], list[list[str]]], _Flags, list[str]]:
|
|
175
|
+
flags = _Flags()
|
|
176
|
+
constants: list[_Const] = []
|
|
177
|
+
functions: list[list[str]] = []
|
|
178
|
+
annotations: list[str] = []
|
|
179
|
+
used_functions = {"mcp", "api"}
|
|
180
|
+
for tool in manifest.tools:
|
|
181
|
+
try:
|
|
182
|
+
output = catalog.compile_output(tool.output.schema_, tool.name)
|
|
183
|
+
compiled = {
|
|
184
|
+
item.name: catalog.compile_input(item.schema_, tool.name, item.name)
|
|
185
|
+
for item in tool.inputs
|
|
186
|
+
}
|
|
187
|
+
except _SchemaProblem as exc:
|
|
188
|
+
raise UsageError(f"{tool.name} schema is not supported: {exc.detail}") from exc
|
|
189
|
+
function_name = _unique(_ident(tool.name), used_functions)
|
|
190
|
+
lines = _tool_lines(
|
|
191
|
+
tool,
|
|
192
|
+
endpoints,
|
|
193
|
+
schemes,
|
|
194
|
+
output,
|
|
195
|
+
compiled,
|
|
196
|
+
function_name,
|
|
197
|
+
constants,
|
|
198
|
+
flags,
|
|
199
|
+
)
|
|
200
|
+
functions.append(lines)
|
|
201
|
+
annotations.append(output.annotation)
|
|
202
|
+
annotations.extend(item.annotation for item in compiled.values())
|
|
203
|
+
return (constants, functions), flags, annotations
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _server_text(
|
|
207
|
+
module: str,
|
|
208
|
+
slug: str,
|
|
209
|
+
title: str,
|
|
210
|
+
flags: _Flags,
|
|
211
|
+
built: tuple[list[_Const], list[list[str]]],
|
|
212
|
+
annotations: list[str],
|
|
213
|
+
catalog: _Catalog,
|
|
214
|
+
) -> str:
|
|
215
|
+
constants, functions = built
|
|
216
|
+
text = "\n".join("\n".join(lines) for lines in functions)
|
|
217
|
+
kept = [item for item in constants if re.search(rf"\b{re.escape(item.name)}\b", text)]
|
|
218
|
+
models = _mentioned(annotations, catalog.classes)
|
|
219
|
+
lines = ["from __future__ import annotations"]
|
|
220
|
+
typing_names: list[str] = []
|
|
221
|
+
if flags.annotated:
|
|
222
|
+
typing_names.append("Annotated")
|
|
223
|
+
if flags.literal:
|
|
224
|
+
typing_names.append("Literal")
|
|
225
|
+
if typing_names:
|
|
226
|
+
lines.extend(["", f"from typing import {', '.join(typing_names)}"])
|
|
227
|
+
lines.extend(["", "from mcp.server import MCPServer"])
|
|
228
|
+
if functions:
|
|
229
|
+
lines.append("from mcp.types import ToolAnnotations")
|
|
230
|
+
pydantic: list[str] = []
|
|
231
|
+
if flags.field:
|
|
232
|
+
pydantic.append("Field")
|
|
233
|
+
if flags.adapter:
|
|
234
|
+
pydantic.append("TypeAdapter")
|
|
235
|
+
if pydantic:
|
|
236
|
+
lines.append(f"from pydantic import {', '.join(pydantic)}")
|
|
237
|
+
if functions:
|
|
238
|
+
lines.append("")
|
|
239
|
+
lines.append(f"from {module}.client import ApiClient")
|
|
240
|
+
if models:
|
|
241
|
+
lines.extend(_from_import(f"{module}.models", sorted(models)))
|
|
242
|
+
lines.append("")
|
|
243
|
+
lines.extend(_assignment(slug, title))
|
|
244
|
+
if functions:
|
|
245
|
+
lines.append("api = ApiClient()")
|
|
246
|
+
for const in kept:
|
|
247
|
+
lines.extend(_const_lines(const))
|
|
248
|
+
for function in functions:
|
|
249
|
+
lines.extend(["", ""])
|
|
250
|
+
lines.extend(function)
|
|
251
|
+
return "\n".join(lines)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _tool_lines(
|
|
255
|
+
tool: Tool,
|
|
256
|
+
endpoints: dict[str, Endpoint],
|
|
257
|
+
schemes: dict[str, SecurityScheme],
|
|
258
|
+
output: _Type,
|
|
259
|
+
compiled: dict[str, _Type],
|
|
260
|
+
function_name: str,
|
|
261
|
+
constants: list[_Const],
|
|
262
|
+
flags: _Flags,
|
|
263
|
+
) -> list[str]:
|
|
264
|
+
if tool.output.from_step < 0 or tool.output.from_step >= len(tool.steps):
|
|
265
|
+
raise UsageError(f"{tool.name} output step is outside the tool")
|
|
266
|
+
for step in tool.steps:
|
|
267
|
+
_known(endpoints, step.endpoint_id)
|
|
268
|
+
_require_json(tool.name, endpoints[step.endpoint_id])
|
|
269
|
+
for item in tool.inputs:
|
|
270
|
+
_known(endpoints, item.maps_to.endpoint_id)
|
|
271
|
+
for fixed in tool.fixed:
|
|
272
|
+
_known(endpoints, fixed.endpoint_id)
|
|
273
|
+
ordered = [item for item in tool.inputs if item.required]
|
|
274
|
+
ordered.extend(item for item in tool.inputs if not item.required)
|
|
275
|
+
params: list[str] = []
|
|
276
|
+
param_names: dict[str, str] = {}
|
|
277
|
+
used_params = {"api"}
|
|
278
|
+
for item in ordered:
|
|
279
|
+
ident = _unique(_ident(item.name), used_params)
|
|
280
|
+
alias = item.name if ident != item.name else None
|
|
281
|
+
param_names[item.name] = ident
|
|
282
|
+
params.append(
|
|
283
|
+
_parameter(
|
|
284
|
+
ident, compiled[item.name], item.description, alias, optional=not item.required
|
|
285
|
+
)
|
|
286
|
+
)
|
|
287
|
+
_note(compiled[item.name].annotation, flags)
|
|
288
|
+
if params:
|
|
289
|
+
flags.annotated = True
|
|
290
|
+
flags.field = True
|
|
291
|
+
_note(output.annotation, flags)
|
|
292
|
+
const_for = _constants(tool, constants)
|
|
293
|
+
referenced = _referenced(tool)
|
|
294
|
+
step_names = _step_names(tool, endpoints, function_name, set(param_names.values()), referenced)
|
|
295
|
+
calls: list[str] = []
|
|
296
|
+
for index, step in enumerate(tool.steps):
|
|
297
|
+
endpoint = endpoints[step.endpoint_id]
|
|
298
|
+
auth = _auth_plan(endpoint, schemes)
|
|
299
|
+
_note_auth(auth, flags)
|
|
300
|
+
slots = _slots(tool, index, endpoint.id, param_names, const_for, step_names, flags)
|
|
301
|
+
calls.extend(_request(tool.name, step_names[index], endpoint, slots, auth))
|
|
302
|
+
lines = [
|
|
303
|
+
*_decorator(tool, function_name),
|
|
304
|
+
*_signature(function_name, params, output.annotation),
|
|
305
|
+
*_docstring(tool.description),
|
|
306
|
+
*calls,
|
|
307
|
+
]
|
|
308
|
+
if calls:
|
|
309
|
+
lines.append("")
|
|
310
|
+
target = step_names[tool.output.from_step]
|
|
311
|
+
if output.model is not None and not output.nullable:
|
|
312
|
+
expr = f"{output.model}.model_validate({target})"
|
|
313
|
+
else:
|
|
314
|
+
flags.adapter = True
|
|
315
|
+
expr = f"TypeAdapter({output.annotation}).validate_python({target})"
|
|
316
|
+
lines.extend(_indent(_return(expr), 4))
|
|
317
|
+
return lines
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _constants(tool: Tool, constants: list[_Const]) -> dict[tuple[str, str, str], str]:
|
|
321
|
+
winning: dict[tuple[str, str, str], tuple[str, str, str]] = {}
|
|
322
|
+
order: list[tuple[str, str, str]] = []
|
|
323
|
+
step_of: dict[str, int] = {}
|
|
324
|
+
for index, step in enumerate(tool.steps):
|
|
325
|
+
step_of.setdefault(step.endpoint_id, index)
|
|
326
|
+
for fixed in tool.fixed:
|
|
327
|
+
if fixed.endpoint_id not in step_of:
|
|
328
|
+
continue
|
|
329
|
+
key = (fixed.endpoint_id, fixed.in_, fixed.param)
|
|
330
|
+
if key not in winning:
|
|
331
|
+
order.append(key)
|
|
332
|
+
try:
|
|
333
|
+
literal = _fixed(fixed.value)
|
|
334
|
+
except UsageError as exc:
|
|
335
|
+
raise UsageError(f"{tool.name} {exc.message}") from exc
|
|
336
|
+
winning[key] = (literal, fixed.reason, fixed.param)
|
|
337
|
+
found: dict[tuple[str, str, str], str] = {}
|
|
338
|
+
for saved in order:
|
|
339
|
+
literal, reason, param = winning[saved]
|
|
340
|
+
base = _upper_name(tool.name, param)
|
|
341
|
+
found[saved] = _take_const(
|
|
342
|
+
base,
|
|
343
|
+
literal,
|
|
344
|
+
reason,
|
|
345
|
+
constants,
|
|
346
|
+
tool.name,
|
|
347
|
+
param,
|
|
348
|
+
step_of[saved[0]],
|
|
349
|
+
)
|
|
350
|
+
return found
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _take_const(
|
|
354
|
+
base: str,
|
|
355
|
+
literal: str,
|
|
356
|
+
reason: str,
|
|
357
|
+
constants: list[_Const],
|
|
358
|
+
tool: str,
|
|
359
|
+
param: str,
|
|
360
|
+
step_index: int,
|
|
361
|
+
) -> str:
|
|
362
|
+
for item in constants:
|
|
363
|
+
if item.name == base and item.literal == literal:
|
|
364
|
+
return base
|
|
365
|
+
name = base
|
|
366
|
+
if any(item.name == name for item in constants):
|
|
367
|
+
name = _upper_name(f"{tool}_step{step_index}", param)
|
|
368
|
+
suffix = 2
|
|
369
|
+
while any(item.name == name for item in constants):
|
|
370
|
+
name = f"{name}_{suffix}"
|
|
371
|
+
suffix += 1
|
|
372
|
+
constants.append(_Const(name, literal, reason))
|
|
373
|
+
return name
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def _step_names(
|
|
377
|
+
tool: Tool,
|
|
378
|
+
endpoints: dict[str, Endpoint],
|
|
379
|
+
function_name: str,
|
|
380
|
+
params: set[str],
|
|
381
|
+
referenced: set[int],
|
|
382
|
+
) -> list[str]:
|
|
383
|
+
used = set(params)
|
|
384
|
+
used.add("api")
|
|
385
|
+
used.add(function_name)
|
|
386
|
+
names: list[str] = []
|
|
387
|
+
for index, step in enumerate(tool.steps):
|
|
388
|
+
operation = endpoints[step.endpoint_id].operation_id
|
|
389
|
+
raw = _snake(operation) if operation else f"step_{index}"
|
|
390
|
+
base = _ident(raw or f"step_{index}")
|
|
391
|
+
if base in used:
|
|
392
|
+
base = _ident(f"{base}_response")
|
|
393
|
+
if index not in referenced and not base.startswith("_"):
|
|
394
|
+
base = f"_{base}"
|
|
395
|
+
names.append(_unique(base, used))
|
|
396
|
+
return names
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def _referenced(tool: Tool) -> set[int]:
|
|
400
|
+
found = {tool.output.from_step}
|
|
401
|
+
for step in tool.steps:
|
|
402
|
+
for bind in step.bind:
|
|
403
|
+
match = _STEP_REF.match(bind.ref)
|
|
404
|
+
if match:
|
|
405
|
+
found.add(int(match.group(1)))
|
|
406
|
+
return found
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def _slots(
|
|
410
|
+
tool: Tool,
|
|
411
|
+
index: int,
|
|
412
|
+
endpoint_id: str,
|
|
413
|
+
param_names: dict[str, str],
|
|
414
|
+
const_for: dict[tuple[str, str, str], str],
|
|
415
|
+
step_names: list[str],
|
|
416
|
+
flags: _Flags,
|
|
417
|
+
) -> list[tuple[str, str, str]]:
|
|
418
|
+
order: list[tuple[str, str]] = []
|
|
419
|
+
values: dict[tuple[str, str], str] = {}
|
|
420
|
+
|
|
421
|
+
def put(location: str, name: str, expr: str) -> None:
|
|
422
|
+
key = (location, name)
|
|
423
|
+
if key not in values:
|
|
424
|
+
order.append(key)
|
|
425
|
+
values[key] = expr
|
|
426
|
+
|
|
427
|
+
for item in tool.inputs:
|
|
428
|
+
mapped = item.maps_to
|
|
429
|
+
if mapped.endpoint_id == endpoint_id:
|
|
430
|
+
put(mapped.in_, mapped.param, param_names[item.name])
|
|
431
|
+
for key, expr in const_for.items():
|
|
432
|
+
if key[0] == endpoint_id:
|
|
433
|
+
put(key[1], key[2], expr)
|
|
434
|
+
for bind in tool.steps[index].bind:
|
|
435
|
+
put(bind.in_, bind.param, _bind_expr(tool, bind.ref, index, param_names, step_names, flags))
|
|
436
|
+
return [(location, name, values[(location, name)]) for location, name in order]
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def _bind_expr(
|
|
440
|
+
tool: Tool,
|
|
441
|
+
ref: str,
|
|
442
|
+
index: int,
|
|
443
|
+
param_names: dict[str, str],
|
|
444
|
+
step_names: list[str],
|
|
445
|
+
flags: _Flags,
|
|
446
|
+
) -> str:
|
|
447
|
+
input_match = _INPUT_REF.match(ref)
|
|
448
|
+
if input_match:
|
|
449
|
+
source = input_match.group(1)
|
|
450
|
+
if source not in param_names:
|
|
451
|
+
raise UsageError(f"{tool.name} bind {ref} is not supported")
|
|
452
|
+
return param_names[source]
|
|
453
|
+
step_match = _STEP_REF.match(ref)
|
|
454
|
+
if not step_match:
|
|
455
|
+
raise UsageError(f"{tool.name} bind {ref} is not supported")
|
|
456
|
+
bind_index = int(step_match.group(1))
|
|
457
|
+
if bind_index < 0 or bind_index >= index or bind_index >= len(step_names):
|
|
458
|
+
raise UsageError(f"{tool.name} bind {ref} is outside the tool")
|
|
459
|
+
keys = [
|
|
460
|
+
match.group(1) for match in re.finditer(r"\.([A-Za-z_][A-Za-z0-9_]*)", step_match.group(2))
|
|
461
|
+
]
|
|
462
|
+
var = step_names[bind_index]
|
|
463
|
+
if not keys:
|
|
464
|
+
return var
|
|
465
|
+
flags.pick = True
|
|
466
|
+
quoted = ", ".join(_py(key) for key in keys)
|
|
467
|
+
return f"api.pick({var}, {quoted})"
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def _request(
|
|
471
|
+
tool_name: str,
|
|
472
|
+
local: str,
|
|
473
|
+
endpoint: Endpoint,
|
|
474
|
+
slots: list[tuple[str, str, str]],
|
|
475
|
+
auth: tuple[tuple[str, str, str], ...],
|
|
476
|
+
) -> list[str]:
|
|
477
|
+
arguments = [_py(endpoint.method.upper()), _py(endpoint.path)]
|
|
478
|
+
groups = {
|
|
479
|
+
"path_params": [(name, expr) for location, name, expr in slots if location == "path"],
|
|
480
|
+
"query": [(name, expr) for location, name, expr in slots if location == "query"],
|
|
481
|
+
"headers": [(name, expr) for location, name, expr in slots if location == "header"],
|
|
482
|
+
"cookies": [(name, expr) for location, name, expr in slots if location == "cookie"],
|
|
483
|
+
}
|
|
484
|
+
for key in ("path_params", "query", "headers", "cookies"):
|
|
485
|
+
pairs = groups[key]
|
|
486
|
+
if pairs:
|
|
487
|
+
arguments.append(f"{key}={_mapping(pairs)}")
|
|
488
|
+
whole = [expr for location, name, expr in slots if location == "body" and name == "$body"]
|
|
489
|
+
fields = [
|
|
490
|
+
(name, expr) for location, name, expr in slots if location == "body" and name != "$body"
|
|
491
|
+
]
|
|
492
|
+
if whole and fields:
|
|
493
|
+
raise UsageError(f"{tool_name} schema is not supported: mixed request body")
|
|
494
|
+
if whole:
|
|
495
|
+
arguments.append(f"body={whole[0]}")
|
|
496
|
+
elif fields:
|
|
497
|
+
arguments.append(f"body={_mapping(fields)}")
|
|
498
|
+
if auth:
|
|
499
|
+
arguments.append(f"auth={_auth_literal(auth)}")
|
|
500
|
+
return _indent(_invoke(f"{local} = await api.request", arguments, indent=4), 4)
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def _auth_plan(
|
|
504
|
+
endpoint: Endpoint, schemes: dict[str, SecurityScheme]
|
|
505
|
+
) -> tuple[tuple[str, str, str], ...]:
|
|
506
|
+
if not endpoint.security:
|
|
507
|
+
return ()
|
|
508
|
+
for requirement in endpoint.security:
|
|
509
|
+
chosen: list[tuple[str, str, str]] = []
|
|
510
|
+
supported = True
|
|
511
|
+
for name in requirement:
|
|
512
|
+
scheme = schemes.get(name)
|
|
513
|
+
if scheme is None or not _scheme_ok(scheme):
|
|
514
|
+
supported = False
|
|
515
|
+
break
|
|
516
|
+
chosen.append(_scheme_tuple(scheme))
|
|
517
|
+
if supported:
|
|
518
|
+
return tuple(chosen)
|
|
519
|
+
raise UsageError(f"unsupported authentication for {endpoint.id}")
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def _scheme_ok(scheme: SecurityScheme) -> bool:
|
|
523
|
+
if scheme.type == "apiKey":
|
|
524
|
+
return bool(scheme.name) and scheme.location in {"header", "query", "cookie"}
|
|
525
|
+
return scheme.type == "http" and (scheme.scheme or "").lower() == "bearer"
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def _scheme_tuple(scheme: SecurityScheme) -> tuple[str, str, str]:
|
|
529
|
+
if scheme.type == "apiKey":
|
|
530
|
+
return ("api_key", scheme.location or "", scheme.name or "")
|
|
531
|
+
return ("bearer", "header", "Authorization")
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
def _note_auth(auth: tuple[tuple[str, str, str], ...], flags: _Flags) -> None:
|
|
535
|
+
for kind, place, name in auth:
|
|
536
|
+
if kind == "api_key":
|
|
537
|
+
flags.api_key = True
|
|
538
|
+
slot = (place, name)
|
|
539
|
+
if slot not in flags.keys:
|
|
540
|
+
flags.keys.append(slot)
|
|
541
|
+
elif kind == "bearer":
|
|
542
|
+
flags.bearer = True
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def _decorator(tool: Tool, function_name: str) -> list[str]:
|
|
546
|
+
lines = ["@mcp.tool("]
|
|
547
|
+
if function_name != tool.name:
|
|
548
|
+
lines.append(f" name={_py(tool.name)},")
|
|
549
|
+
lines.append(f" title={_py(tool.title)},")
|
|
550
|
+
lines.append(" annotations=ToolAnnotations(")
|
|
551
|
+
lines.append(f" read_only_hint={_py(tool.annotations.readOnlyHint)},")
|
|
552
|
+
lines.append(f" destructive_hint={_py(tool.annotations.destructiveHint)},")
|
|
553
|
+
lines.append(f" idempotent_hint={_py(tool.annotations.idempotentHint)},")
|
|
554
|
+
lines.append(f" open_world_hint={_py(tool.annotations.openWorldHint)},")
|
|
555
|
+
lines.append(" ),")
|
|
556
|
+
lines.append(")")
|
|
557
|
+
return lines
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def _signature(name: str, params: list[str], returns: str) -> list[str]:
|
|
561
|
+
if not params:
|
|
562
|
+
return [f"async def {name}() -> {returns}:"]
|
|
563
|
+
one = f"async def {name}({', '.join(params)}) -> {returns}:"
|
|
564
|
+
if "\n" not in one and len(one) <= _LINE:
|
|
565
|
+
return [one]
|
|
566
|
+
lines = [f"async def {name}("]
|
|
567
|
+
for param in params:
|
|
568
|
+
parts = param.split("\n")
|
|
569
|
+
for index, part in enumerate(parts):
|
|
570
|
+
suffix = "," if index == len(parts) - 1 else ""
|
|
571
|
+
lines.append(f" {part}{suffix}")
|
|
572
|
+
lines.append(f") -> {returns}:")
|
|
573
|
+
return lines
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
def _parameter(
|
|
577
|
+
name: str,
|
|
578
|
+
typ: _Type,
|
|
579
|
+
description: str,
|
|
580
|
+
alias: str | None,
|
|
581
|
+
*,
|
|
582
|
+
optional: bool,
|
|
583
|
+
) -> str:
|
|
584
|
+
annotation = typ.annotation
|
|
585
|
+
if optional and not typ.nullable and annotation != "None":
|
|
586
|
+
annotation = f"{annotation} | None"
|
|
587
|
+
parts = [f"description={_py(description)}"]
|
|
588
|
+
if alias is not None:
|
|
589
|
+
parts.append(f"alias={_py(alias)}")
|
|
590
|
+
if typ.constraints:
|
|
591
|
+
parts.append(typ.constraints)
|
|
592
|
+
field_text = f"Field({', '.join(parts)})"
|
|
593
|
+
default = " = None" if optional else ""
|
|
594
|
+
one = f"{name}: Annotated[{annotation}, {field_text}]{default}"
|
|
595
|
+
if len(one) + 5 <= _LINE:
|
|
596
|
+
return one
|
|
597
|
+
return "\n".join(
|
|
598
|
+
[
|
|
599
|
+
f"{name}: Annotated[",
|
|
600
|
+
f" {annotation},",
|
|
601
|
+
f" {field_text},",
|
|
602
|
+
f"]{default}",
|
|
603
|
+
]
|
|
604
|
+
)
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
def _docstring(text: str) -> list[str]:
|
|
608
|
+
quote = '"""' if '"""' not in text else "'''"
|
|
609
|
+
if quote in text:
|
|
610
|
+
raise UsageError("description cannot be rendered")
|
|
611
|
+
parts = text.replace("\\", "\\\\").split("\n")
|
|
612
|
+
if len(parts) == 1:
|
|
613
|
+
line = f" {quote}{parts[0]}{quote}"
|
|
614
|
+
if len(line) > _LINE:
|
|
615
|
+
line += " # noqa: E501"
|
|
616
|
+
return [line]
|
|
617
|
+
lines = [f" {quote}{parts[0]}", *parts[1:-1], f"{parts[-1]}{quote}"]
|
|
618
|
+
return [line + (" # noqa: E501" if len(line) > _LINE else "") for line in lines]
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
def _const_lines(const: _Const) -> list[str]:
|
|
622
|
+
lines = _reason_lines(const.reason)
|
|
623
|
+
assignment = f"{const.name} = {const.literal}"
|
|
624
|
+
if len(assignment) <= _LINE:
|
|
625
|
+
lines.append(assignment)
|
|
626
|
+
return lines
|
|
627
|
+
lines.extend([f"{const.name} = (", f" {const.literal}", ")"])
|
|
628
|
+
return lines
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
def _reason_lines(reason: str) -> list[str]:
|
|
632
|
+
text = _flat(reason)
|
|
633
|
+
if not text:
|
|
634
|
+
return []
|
|
635
|
+
lines: list[str] = []
|
|
636
|
+
while text:
|
|
637
|
+
room = _LINE - 2
|
|
638
|
+
if len(text) <= room:
|
|
639
|
+
lines.append(f"# {text}")
|
|
640
|
+
break
|
|
641
|
+
cut = text.rfind(" ", 0, room + 1)
|
|
642
|
+
if cut <= 0:
|
|
643
|
+
cut = room
|
|
644
|
+
lines.append(f"# {text[:cut].rstrip()}")
|
|
645
|
+
text = text[cut:].lstrip()
|
|
646
|
+
return lines
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
def _assignment(slug: str, title: str) -> list[str]:
|
|
650
|
+
line = f"mcp = MCPServer({_py(slug)}, title={_py(title)})"
|
|
651
|
+
if len(line) <= _LINE:
|
|
652
|
+
return [line]
|
|
653
|
+
return ["mcp = MCPServer(", f" {_py(slug)},", f" title={_py(title)},", ")"]
|
|
654
|
+
|
|
655
|
+
|
|
656
|
+
def _invoke(prefix: str, arguments: list[str], *, indent: int) -> list[str]:
|
|
657
|
+
if all("\n" not in item for item in arguments):
|
|
658
|
+
single = f"{prefix}({', '.join(arguments)})"
|
|
659
|
+
if len(single) + indent <= _LINE:
|
|
660
|
+
return [single]
|
|
661
|
+
lines = [f"{prefix}("]
|
|
662
|
+
for argument in arguments:
|
|
663
|
+
parts = argument.split("\n")
|
|
664
|
+
for index, part in enumerate(parts):
|
|
665
|
+
suffix = "," if index == len(parts) - 1 else ""
|
|
666
|
+
lines.append(f" {part}{suffix}")
|
|
667
|
+
lines.append(")")
|
|
668
|
+
return lines
|
|
669
|
+
|
|
670
|
+
|
|
671
|
+
def _return(expr: str) -> list[str]:
|
|
672
|
+
single = f"return {expr}"
|
|
673
|
+
if "\n" not in expr and len(single) + 4 <= _LINE:
|
|
674
|
+
return [single]
|
|
675
|
+
if "\n" not in expr and expr.endswith(")"):
|
|
676
|
+
open_at = expr.rfind("(")
|
|
677
|
+
head = expr[:open_at]
|
|
678
|
+
inner = expr[open_at + 1 : -1]
|
|
679
|
+
return [f"return {head}(", f" {inner},", ")"]
|
|
680
|
+
parts = expr.split("\n")
|
|
681
|
+
return [f"return {parts[0]}", *parts[1:]]
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
def _mapping(pairs: list[tuple[str, str]]) -> str:
|
|
685
|
+
body = ", ".join(f"{_py(key)}: {expr}" for key, expr in pairs)
|
|
686
|
+
single = "{" + body + "}"
|
|
687
|
+
if len(single) <= 72:
|
|
688
|
+
return single
|
|
689
|
+
lines = ["{"]
|
|
690
|
+
for key, expr in pairs:
|
|
691
|
+
parts = expr.split("\n")
|
|
692
|
+
if len(parts) == 1:
|
|
693
|
+
lines.append(f" {_py(key)}: {expr},")
|
|
694
|
+
continue
|
|
695
|
+
lines.append(f" {_py(key)}: {parts[0]}")
|
|
696
|
+
lines.extend(f" {part}" for part in parts[1:])
|
|
697
|
+
lines[-1] = f"{lines[-1]},"
|
|
698
|
+
lines.append("}")
|
|
699
|
+
return "\n".join(lines)
|
|
700
|
+
|
|
701
|
+
|
|
702
|
+
def _auth_literal(auth: tuple[tuple[str, str, str], ...]) -> str:
|
|
703
|
+
items = ", ".join("(" + ", ".join(_py(part) for part in triple) + ")" for triple in auth)
|
|
704
|
+
if len(auth) == 1:
|
|
705
|
+
return f"({items},)"
|
|
706
|
+
return f"({items})"
|
|
707
|
+
|
|
708
|
+
|
|
709
|
+
def _indent(lines: list[str], spaces: int) -> list[str]:
|
|
710
|
+
pad = " " * spaces
|
|
711
|
+
return [f"{pad}{line}" if line else line for line in lines]
|
|
712
|
+
|
|
713
|
+
|
|
714
|
+
def _from_import(module: str, names: list[str]) -> list[str]:
|
|
715
|
+
single = f"from {module} import {', '.join(names)}"
|
|
716
|
+
if len(single) <= _LINE:
|
|
717
|
+
return [single]
|
|
718
|
+
return [f"from {module} import (", *[f" {name}," for name in names], ")"]
|
|
719
|
+
|
|
720
|
+
|
|
721
|
+
def _note(annotation: str, flags: _Flags) -> None:
|
|
722
|
+
if "Literal[" in annotation:
|
|
723
|
+
flags.literal = True
|
|
724
|
+
|
|
725
|
+
|
|
726
|
+
def _mentioned(annotations: list[str], classes: set[str]) -> set[str]:
|
|
727
|
+
found: set[str] = set()
|
|
728
|
+
for annotation in annotations:
|
|
729
|
+
for name in classes:
|
|
730
|
+
if re.search(rf"\b{re.escape(name)}\b", annotation):
|
|
731
|
+
found.add(name)
|
|
732
|
+
return found
|
|
733
|
+
|
|
734
|
+
|
|
735
|
+
def _known(endpoints: dict[str, Endpoint], endpoint_id: str) -> None:
|
|
736
|
+
if endpoint_id not in endpoints:
|
|
737
|
+
raise UsageError(f"unknown endpoint {endpoint_id}")
|
|
738
|
+
|
|
739
|
+
|
|
740
|
+
def _require_json(tool_name: str, endpoint: Endpoint) -> None:
|
|
741
|
+
body = endpoint.request_body
|
|
742
|
+
if body is None:
|
|
743
|
+
return
|
|
744
|
+
media = body.content_type.split(";", 1)[0].strip().lower()
|
|
745
|
+
if media == "application/json" or media.endswith("+json"):
|
|
746
|
+
return
|
|
747
|
+
raise UsageError(f"{tool_name} request body is not JSON")
|
|
748
|
+
|
|
749
|
+
|
|
750
|
+
def _fixed(value: JsonValue) -> str:
|
|
751
|
+
if isinstance(value, (dict, list)) or not _scalar(value):
|
|
752
|
+
raise UsageError("fixed value must be a scalar")
|
|
753
|
+
return _py(value)
|
|
754
|
+
|
|
755
|
+
|
|
756
|
+
def _slug(title: str) -> str:
|
|
757
|
+
slug = re.sub(r"[^a-z0-9]+", "-", title.lower()).strip("-")
|
|
758
|
+
if not slug:
|
|
759
|
+
slug = "api"
|
|
760
|
+
if slug[0].isdigit():
|
|
761
|
+
slug = f"api-{slug}"
|
|
762
|
+
return slug
|
|
763
|
+
|
|
764
|
+
|
|
765
|
+
def _package_version(version: str) -> str:
|
|
766
|
+
if _VERSION.match(version):
|
|
767
|
+
return version
|
|
768
|
+
return "0.1.0"
|
|
769
|
+
|
|
770
|
+
|
|
771
|
+
def _header_lines(uri: str, source_sha: str, tools_sha: str) -> tuple[str, str]:
|
|
772
|
+
line1 = (
|
|
773
|
+
f"GENERATED BY mcp4api {__version__} from {uri} "
|
|
774
|
+
f"(sha256 {source_sha}) and tools.yaml (sha256 {tools_sha})."
|
|
775
|
+
)
|
|
776
|
+
line2 = (
|
|
777
|
+
"DO NOT EDIT. Change tools.yaml or overrides.yaml and run `mcp4api` again; "
|
|
778
|
+
"edits here are overwritten."
|
|
779
|
+
)
|
|
780
|
+
return line1, line2
|
|
781
|
+
|
|
782
|
+
|
|
783
|
+
def _unique(base: str, used: set[str]) -> str:
|
|
784
|
+
if base not in used:
|
|
785
|
+
used.add(base)
|
|
786
|
+
return base
|
|
787
|
+
index = 2
|
|
788
|
+
while f"{base}_{index}" in used:
|
|
789
|
+
index += 1
|
|
790
|
+
name = f"{base}_{index}"
|
|
791
|
+
used.add(name)
|
|
792
|
+
return name
|
|
793
|
+
|
|
794
|
+
|
|
795
|
+
def _snake(text: str) -> str:
|
|
796
|
+
spaced = re.sub(r"[^0-9A-Za-z]+", "_", text)
|
|
797
|
+
step1 = re.sub(r"(.)([A-Z][a-z]+)", r"\1_\2", spaced)
|
|
798
|
+
step2 = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", step1)
|
|
799
|
+
return re.sub(r"_+", "_", step2).strip("_").lower()
|
|
800
|
+
|
|
801
|
+
|
|
802
|
+
def _upper_name(tool: str, param: str) -> str:
|
|
803
|
+
return f"{_upper(tool)}_{_upper(param)}"
|
|
804
|
+
|
|
805
|
+
|
|
806
|
+
def _upper(text: str) -> str:
|
|
807
|
+
cleaned = re.sub(r"[^0-9A-Za-z]+", "_", text).strip("_").upper()
|
|
808
|
+
if not cleaned:
|
|
809
|
+
return "VALUE"
|
|
810
|
+
if cleaned[0].isdigit():
|
|
811
|
+
return f"V_{cleaned}"
|
|
812
|
+
return cleaned
|
|
813
|
+
|
|
814
|
+
|
|
815
|
+
def _flat(text: str) -> str:
|
|
816
|
+
return " ".join(text.split())
|
|
817
|
+
|
|
818
|
+
|
|
819
|
+
def _redact_env_names(env_name: str, flags: _Flags) -> list[str]:
|
|
820
|
+
names: list[str] = []
|
|
821
|
+
if flags.api_key:
|
|
822
|
+
names.append(f"{env_name}_API_KEY")
|
|
823
|
+
if flags.bearer:
|
|
824
|
+
names.append(f"{env_name}_TOKEN")
|
|
825
|
+
return names
|
|
826
|
+
|
|
827
|
+
|
|
828
|
+
def _tuple(names: list[str]) -> str:
|
|
829
|
+
if not names:
|
|
830
|
+
return "()"
|
|
831
|
+
inner = ", ".join(_py(name) for name in names)
|
|
832
|
+
if len(names) == 1:
|
|
833
|
+
return f"({inner},)"
|
|
834
|
+
return f"({inner})"
|
|
835
|
+
|
|
836
|
+
|
|
837
|
+
def _env_rows(env_name: str, base_url: str, flags: _Flags) -> list[dict[str, str]]:
|
|
838
|
+
rows: list[dict[str, str]] = []
|
|
839
|
+
if flags.api_key:
|
|
840
|
+
rows.append({"name": f"{env_name}_API_KEY", "purpose": _key_purpose(flags.keys)})
|
|
841
|
+
if flags.bearer:
|
|
842
|
+
rows.append(
|
|
843
|
+
{
|
|
844
|
+
"name": f"{env_name}_TOKEN",
|
|
845
|
+
"purpose": "Bearer token sent in the Authorization header.",
|
|
846
|
+
}
|
|
847
|
+
)
|
|
848
|
+
rows.append({"name": f"{env_name}_BASE_URL", "purpose": f"Base URL. Defaults to <{base_url}>."})
|
|
849
|
+
rows.append(
|
|
850
|
+
{"name": f"{env_name}_TIMEOUT", "purpose": "Request timeout in seconds. Defaults to 30."}
|
|
851
|
+
)
|
|
852
|
+
rows.append(
|
|
853
|
+
{
|
|
854
|
+
"name": "MCP4API_ALLOWED_HOSTS",
|
|
855
|
+
"purpose": "Comma-separated hosts the client may call. Unset allows any host.",
|
|
856
|
+
}
|
|
857
|
+
)
|
|
858
|
+
return rows
|
|
859
|
+
|
|
860
|
+
|
|
861
|
+
def _key_purpose(keys: list[tuple[str, str]]) -> str:
|
|
862
|
+
parts: list[str] = []
|
|
863
|
+
for place, name in keys:
|
|
864
|
+
if place == "header":
|
|
865
|
+
parts.append(f"API key sent in the {name} header")
|
|
866
|
+
elif place == "query":
|
|
867
|
+
parts.append(f"API key sent in the {name} query parameter")
|
|
868
|
+
else:
|
|
869
|
+
parts.append(f"API key sent in the {name} cookie")
|
|
870
|
+
if not parts:
|
|
871
|
+
return "API key."
|
|
872
|
+
return ". ".join(parts) + "."
|
|
873
|
+
|
|
874
|
+
|
|
875
|
+
def _claude(program: str, env_name: str, flags: _Flags) -> str:
|
|
876
|
+
env = {row["name"]: "" for row in _env_rows(env_name, "", flags)}
|
|
877
|
+
payload = {
|
|
878
|
+
"mcpServers": {
|
|
879
|
+
program: {
|
|
880
|
+
"command": "uvx",
|
|
881
|
+
"args": ["--from", "./generated", program],
|
|
882
|
+
"env": env,
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
return json.dumps(payload, indent=2)
|
|
887
|
+
|
|
888
|
+
|
|
889
|
+
def _toml(text: str) -> str:
|
|
890
|
+
literal = json.dumps(text, ensure_ascii=False)
|
|
891
|
+
return literal[1:-1]
|
|
892
|
+
|
|
893
|
+
|
|
894
|
+
def _spec_defs(spec: ApiSpec) -> dict[str, JsonObject]:
|
|
895
|
+
return {name: schema for name, schema in spec.defs.items() if isinstance(schema, dict)}
|
|
896
|
+
|
|
897
|
+
|
|
898
|
+
def _environment() -> Environment:
|
|
899
|
+
return Environment(
|
|
900
|
+
loader=PackageLoader("mcp4api.emit", "templates"),
|
|
901
|
+
undefined=StrictUndefined,
|
|
902
|
+
autoescape=False,
|
|
903
|
+
keep_trailing_newline=True,
|
|
904
|
+
trim_blocks=True,
|
|
905
|
+
lstrip_blocks=True,
|
|
906
|
+
newline_sequence="\n",
|
|
907
|
+
)
|
|
908
|
+
|
|
909
|
+
|
|
910
|
+
def _render(environment: Environment, name: str, context: Mapping[str, object]) -> str:
|
|
911
|
+
try:
|
|
912
|
+
rendered: object = environment.get_template(name).render(context)
|
|
913
|
+
except (TemplateError, UndefinedError) as exc:
|
|
914
|
+
raise UsageError(f"could not render {name}") from exc
|
|
915
|
+
if not isinstance(rendered, str):
|
|
916
|
+
raise UsageError(f"could not render {name}")
|
|
917
|
+
return rendered
|