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/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