mcp-as-code 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.
- maco/__init__.py +3 -0
- maco/_build_info.py +4 -0
- maco/cli.py +258 -0
- maco/codegen.py +680 -0
- maco/config.py +177 -0
- maco/gateway.py +305 -0
- maco/mcp_manager.py +157 -0
- maco/runner.py +104 -0
- maco/sandbox/__init__.py +43 -0
- maco/sandbox/core.py +216 -0
- maco/sandbox/providers/__init__.py +7 -0
- maco/sandbox/providers/base.py +69 -0
- maco/sandbox/providers/docker.py +228 -0
- maco/sandbox/providers/local.py +46 -0
- maco/sandbox/providers/matchlock.py +224 -0
- maco/serve_mcp.py +527 -0
- maco/templates/bash_description.j2 +8 -0
- maco/templates/code_execute_description.j2 +14 -0
- maco/templates/codegen/client.py.j2 +104 -0
- maco/templates/codegen/model.py.j2 +6 -0
- maco/templates/codegen/package_init.py.j2 +2 -0
- maco/templates/codegen/pyproject.toml.j2 +8 -0
- maco/templates/codegen/root_model.py.j2 +3 -0
- maco/templates/codegen/server_init.py.j2 +11 -0
- maco/templates/codegen/tool.py.j2 +38 -0
- maco/templates/codegen/type_alias.py.j2 +2 -0
- maco/templates/serve_mcp_instructions.j2 +17 -0
- maco/templates/server_catalog.j2 +8 -0
- maco/version.py +72 -0
- mcp_as_code-0.1.0.dist-info/METADATA +212 -0
- mcp_as_code-0.1.0.dist-info/RECORD +34 -0
- mcp_as_code-0.1.0.dist-info/WHEEL +4 -0
- mcp_as_code-0.1.0.dist-info/entry_points.txt +2 -0
- mcp_as_code-0.1.0.dist-info/licenses/LICENSE +203 -0
maco/codegen.py
ADDED
|
@@ -0,0 +1,680 @@
|
|
|
1
|
+
"""Generate Python code interfaces for MCP tools."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from collections import Counter
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
import json
|
|
9
|
+
import keyword
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
import re
|
|
12
|
+
import shutil
|
|
13
|
+
from typing import Any, cast
|
|
14
|
+
from urllib.error import HTTPError, URLError
|
|
15
|
+
from urllib.request import Request, urlopen
|
|
16
|
+
|
|
17
|
+
from jinja2 import Environment, PackageLoader, StrictUndefined
|
|
18
|
+
|
|
19
|
+
from .config import MacoConfig
|
|
20
|
+
from .mcp_manager import MCPManager
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
_CODEGEN_TEMPLATES = Environment(
|
|
24
|
+
loader=PackageLoader("maco", "templates"),
|
|
25
|
+
trim_blocks=True,
|
|
26
|
+
lstrip_blocks=True,
|
|
27
|
+
keep_trailing_newline=True,
|
|
28
|
+
undefined=StrictUndefined,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _pyrepr(value: Any) -> str:
|
|
33
|
+
return repr(value)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
_CODEGEN_TEMPLATES.filters["pyrepr"] = _pyrepr
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class GenerationStats:
|
|
41
|
+
server_count: int
|
|
42
|
+
tool_count: int
|
|
43
|
+
workspace: Path
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True)
|
|
47
|
+
class TypeSource:
|
|
48
|
+
"""Generated Python type source for one JSON schema."""
|
|
49
|
+
|
|
50
|
+
source: str
|
|
51
|
+
type_expr: str
|
|
52
|
+
is_model: bool = False
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
async def generate_async(
|
|
56
|
+
config: MacoConfig,
|
|
57
|
+
workspace: str | Path = ".maco",
|
|
58
|
+
server_filter: str | None = None,
|
|
59
|
+
clean: bool = False,
|
|
60
|
+
) -> GenerationStats:
|
|
61
|
+
"""Generate Python wrappers for all configured MCP tools."""
|
|
62
|
+
|
|
63
|
+
async with MCPManager(config) as manager:
|
|
64
|
+
tools_by_server = await manager.list_tools(server_filter=server_filter)
|
|
65
|
+
|
|
66
|
+
return generate_from_catalog(
|
|
67
|
+
tools_by_server,
|
|
68
|
+
workspace=workspace,
|
|
69
|
+
clean=clean,
|
|
70
|
+
config_path=config.path,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def generate_from_catalog(
|
|
75
|
+
tools_by_server: dict[str, list[dict[str, Any]]],
|
|
76
|
+
*,
|
|
77
|
+
workspace: str | Path = ".maco",
|
|
78
|
+
clean: bool = False,
|
|
79
|
+
config_path: str | Path | None = None,
|
|
80
|
+
package_name: str = "maco_generated.servers",
|
|
81
|
+
client_module: str = "maco_generated.client",
|
|
82
|
+
package_docstring: str = "Generated MCP wrappers for maco.",
|
|
83
|
+
servers_docstring: str = "Generated MCP server packages.",
|
|
84
|
+
) -> GenerationStats:
|
|
85
|
+
"""Generate wrappers from an already-fetched MCP tool catalog."""
|
|
86
|
+
|
|
87
|
+
workspace_path = Path(workspace).expanduser().resolve()
|
|
88
|
+
if clean and workspace_path.exists():
|
|
89
|
+
shutil.rmtree(workspace_path)
|
|
90
|
+
|
|
91
|
+
package_parts = package_name.split(".")
|
|
92
|
+
if not package_parts or any(not part for part in package_parts):
|
|
93
|
+
raise ValueError("package_name must be a dotted Python package name")
|
|
94
|
+
|
|
95
|
+
client_parts = client_module.split(".")
|
|
96
|
+
if not client_parts or any(not part for part in client_parts):
|
|
97
|
+
raise ValueError("client_module must be a dotted Python module name")
|
|
98
|
+
|
|
99
|
+
generated_pkg = workspace_path / package_parts[0]
|
|
100
|
+
servers_pkg = workspace_path.joinpath(*package_parts)
|
|
101
|
+
servers_pkg.mkdir(parents=True, exist_ok=True)
|
|
102
|
+
|
|
103
|
+
_write_workspace_pyproject(workspace_path)
|
|
104
|
+
_write_template(
|
|
105
|
+
generated_pkg / "__init__.py",
|
|
106
|
+
"codegen/package_init.py.j2",
|
|
107
|
+
docstring=package_docstring,
|
|
108
|
+
)
|
|
109
|
+
for depth in range(1, max(len(package_parts) - 1, 1)):
|
|
110
|
+
package_path = workspace_path.joinpath(*package_parts[: depth + 1])
|
|
111
|
+
_write_template(
|
|
112
|
+
package_path / "__init__.py",
|
|
113
|
+
"codegen/package_init.py.j2",
|
|
114
|
+
docstring=servers_docstring,
|
|
115
|
+
)
|
|
116
|
+
if servers_pkg != generated_pkg:
|
|
117
|
+
_write_template(
|
|
118
|
+
servers_pkg / "__init__.py",
|
|
119
|
+
"codegen/package_init.py.j2",
|
|
120
|
+
docstring=servers_docstring,
|
|
121
|
+
)
|
|
122
|
+
(generated_pkg / "py.typed").write_text("", encoding="utf-8")
|
|
123
|
+
client_path = workspace_path.joinpath(*client_parts).with_suffix(".py")
|
|
124
|
+
_write_client(client_path)
|
|
125
|
+
|
|
126
|
+
manifest = {
|
|
127
|
+
"version": 1,
|
|
128
|
+
"config": str(config_path) if config_path is not None else None,
|
|
129
|
+
"package": package_name,
|
|
130
|
+
"servers": [],
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
server_module_names = _unique_sanitized_names(tools_by_server.keys())
|
|
134
|
+
server_count = 0
|
|
135
|
+
tool_count = 0
|
|
136
|
+
|
|
137
|
+
for server_name, tools in sorted(tools_by_server.items()):
|
|
138
|
+
server_module = server_module_names[server_name]
|
|
139
|
+
server_dir = servers_pkg / server_module
|
|
140
|
+
server_dir.mkdir(parents=True, exist_ok=True)
|
|
141
|
+
tool_module_names = _unique_sanitized_names(tool["name"] for tool in tools)
|
|
142
|
+
|
|
143
|
+
exports: list[str] = []
|
|
144
|
+
server_manifest = {
|
|
145
|
+
"name": server_name,
|
|
146
|
+
"module": server_module,
|
|
147
|
+
"tools": [],
|
|
148
|
+
}
|
|
149
|
+
for tool in sorted(tools, key=lambda item: item["name"]):
|
|
150
|
+
tool_name = tool["name"]
|
|
151
|
+
func_name = tool_module_names[tool_name]
|
|
152
|
+
module_path = server_dir / f"{func_name}.py"
|
|
153
|
+
_write_tool(module_path, server_name, tool, func_name, client_module)
|
|
154
|
+
exports.append(func_name)
|
|
155
|
+
server_manifest["tools"].append(
|
|
156
|
+
{
|
|
157
|
+
"name": tool_name,
|
|
158
|
+
"function": func_name,
|
|
159
|
+
"module": f"{package_name}.{server_module}.{func_name}",
|
|
160
|
+
"description": tool.get("description") or "",
|
|
161
|
+
}
|
|
162
|
+
)
|
|
163
|
+
tool_count += 1
|
|
164
|
+
|
|
165
|
+
_write_server_init(server_dir / "__init__.py", exports)
|
|
166
|
+
manifest["servers"].append(server_manifest)
|
|
167
|
+
server_count += 1
|
|
168
|
+
|
|
169
|
+
(workspace_path / "manifest.json").write_text(
|
|
170
|
+
json.dumps(manifest, indent=2, sort_keys=True) + "\n",
|
|
171
|
+
encoding="utf-8",
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
return GenerationStats(
|
|
175
|
+
server_count=server_count,
|
|
176
|
+
tool_count=tool_count,
|
|
177
|
+
workspace=workspace_path,
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def generate(
|
|
182
|
+
config: MacoConfig,
|
|
183
|
+
workspace: str | Path = ".maco",
|
|
184
|
+
server_filter: str | None = None,
|
|
185
|
+
clean: bool = False,
|
|
186
|
+
) -> GenerationStats:
|
|
187
|
+
return asyncio.run(generate_async(config, workspace, server_filter, clean))
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def generate_sandbox_sdk(
|
|
191
|
+
tools_by_server: dict[str, list[dict[str, Any]]],
|
|
192
|
+
*,
|
|
193
|
+
workspace: str | Path,
|
|
194
|
+
clean: bool = True,
|
|
195
|
+
) -> GenerationStats:
|
|
196
|
+
"""Generate the sandbox-facing SDK package at ``tools.<server>``."""
|
|
197
|
+
|
|
198
|
+
return generate_from_catalog(
|
|
199
|
+
tools_by_server,
|
|
200
|
+
workspace=workspace,
|
|
201
|
+
clean=clean,
|
|
202
|
+
package_name="tools",
|
|
203
|
+
client_module="tools._client",
|
|
204
|
+
package_docstring="Generated sandbox tools for maco.",
|
|
205
|
+
servers_docstring="Generated sandbox tool modules.",
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def generate_sandbox_sdk_from_gateway(
|
|
210
|
+
gateway_url: str,
|
|
211
|
+
*,
|
|
212
|
+
token: str | None = None,
|
|
213
|
+
workspace: str | Path,
|
|
214
|
+
clean: bool = True,
|
|
215
|
+
timeout: float | None = 30.0,
|
|
216
|
+
) -> GenerationStats:
|
|
217
|
+
"""Generate the sandbox SDK from a running gateway's live tool catalog."""
|
|
218
|
+
|
|
219
|
+
return generate_sandbox_sdk(
|
|
220
|
+
fetch_gateway_tools(gateway_url, token=token, timeout=timeout),
|
|
221
|
+
workspace=workspace,
|
|
222
|
+
clean=clean,
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def fetch_gateway_tools(
|
|
227
|
+
gateway_url: str,
|
|
228
|
+
*,
|
|
229
|
+
token: str | None = None,
|
|
230
|
+
timeout: float | None = 30.0,
|
|
231
|
+
) -> dict[str, list[dict[str, Any]]]:
|
|
232
|
+
"""Fetch the live tool catalog from a running maco gateway."""
|
|
233
|
+
|
|
234
|
+
url = gateway_url.rstrip("/") + "/tools"
|
|
235
|
+
headers: dict[str, str] = {}
|
|
236
|
+
if token:
|
|
237
|
+
headers["Authorization"] = f"Bearer {token}"
|
|
238
|
+
request = Request(url, headers=headers, method="GET")
|
|
239
|
+
try:
|
|
240
|
+
with urlopen(request, timeout=timeout) as response:
|
|
241
|
+
payload = json.loads(response.read().decode("utf-8"))
|
|
242
|
+
except HTTPError as exc:
|
|
243
|
+
detail = exc.read().decode("utf-8", errors="replace")
|
|
244
|
+
raise RuntimeError(f"failed to fetch maco gateway tools: HTTP {exc.code}: {detail}") from exc
|
|
245
|
+
except URLError as exc:
|
|
246
|
+
raise RuntimeError(f"failed to connect to maco gateway at {url}: {exc}") from exc
|
|
247
|
+
if not isinstance(payload, dict) or not isinstance(payload.get("servers"), dict):
|
|
248
|
+
raise RuntimeError("maco gateway /tools response must contain a servers object")
|
|
249
|
+
result: dict[str, list[dict[str, Any]]] = {}
|
|
250
|
+
for server_name, tools in payload["servers"].items():
|
|
251
|
+
if not isinstance(server_name, str) or not isinstance(tools, list):
|
|
252
|
+
raise RuntimeError("maco gateway /tools response has an invalid server entry")
|
|
253
|
+
server_tools: list[dict[str, Any]] = []
|
|
254
|
+
for tool in tools:
|
|
255
|
+
if not isinstance(tool, dict):
|
|
256
|
+
raise RuntimeError("maco gateway /tools response has an invalid tool entry")
|
|
257
|
+
server_tools.append(tool)
|
|
258
|
+
result[server_name] = server_tools
|
|
259
|
+
return result
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def server_module_names(server_names: Any) -> dict[str, str]:
|
|
263
|
+
"""Return generated module names for configured MCP server names."""
|
|
264
|
+
|
|
265
|
+
return _unique_sanitized_names(server_names)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _render_template(template_name: str, **context: Any) -> str:
|
|
269
|
+
return _CODEGEN_TEMPLATES.get_template(template_name).render(**context)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _render_source(template_name: str, **context: Any) -> str:
|
|
273
|
+
return _render_template(template_name, **context).rstrip()
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _write_template(path: Path, template_name: str, **context: Any) -> None:
|
|
277
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
278
|
+
path.write_text(_render_template(template_name, **context), encoding="utf-8")
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _write_workspace_pyproject(workspace: Path) -> None:
|
|
282
|
+
_write_template(workspace / "pyproject.toml", "codegen/pyproject.toml.j2")
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _write_client(path: Path) -> None:
|
|
286
|
+
_write_template(path, "codegen/client.py.j2")
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _write_tool(
|
|
290
|
+
path: Path,
|
|
291
|
+
server_name: str,
|
|
292
|
+
tool: dict[str, Any],
|
|
293
|
+
func_name: str,
|
|
294
|
+
client_module: str,
|
|
295
|
+
) -> None:
|
|
296
|
+
tool_name = tool["name"]
|
|
297
|
+
description = tool.get("description") or ""
|
|
298
|
+
input_schema = tool.get("inputSchema") or {"type": "object", "properties": {}}
|
|
299
|
+
output_schema = tool.get("outputSchema")
|
|
300
|
+
input_type = _schema_type_source(f"{_class_name(func_name)}Input", input_schema)
|
|
301
|
+
output_type = _schema_type_source(
|
|
302
|
+
f"{_class_name(func_name)}Output",
|
|
303
|
+
output_schema,
|
|
304
|
+
missing_type_expr="_t.Any",
|
|
305
|
+
)
|
|
306
|
+
return_expr = _return_expr(output_type)
|
|
307
|
+
_write_template(
|
|
308
|
+
path,
|
|
309
|
+
"codegen/tool.py.j2",
|
|
310
|
+
description=description,
|
|
311
|
+
docstring=_docstring(description, input_schema, output_schema),
|
|
312
|
+
func_name=func_name,
|
|
313
|
+
input_is_model=input_type.is_model,
|
|
314
|
+
input_type_expr=input_type.type_expr,
|
|
315
|
+
input_type_source=input_type.source,
|
|
316
|
+
output_type_expr=output_type.type_expr,
|
|
317
|
+
output_type_source=output_type.source,
|
|
318
|
+
return_expr=return_expr,
|
|
319
|
+
client_module=client_module,
|
|
320
|
+
server_name=server_name,
|
|
321
|
+
tool_name=tool_name,
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _write_server_init(path: Path, exports: list[str]) -> None:
|
|
326
|
+
_write_template(path, "codegen/server_init.py.j2", exports=exports)
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _typed_dict_source(class_name: str, schema: dict[str, Any]) -> str:
|
|
330
|
+
"""Backward-compatible helper used by tests and older callers."""
|
|
331
|
+
|
|
332
|
+
return _schema_type_source(class_name, schema).source
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _schema_type_source(
|
|
336
|
+
root_name: str,
|
|
337
|
+
schema: Any,
|
|
338
|
+
*,
|
|
339
|
+
missing_type_expr: str = "dict[str, _t.Any]",
|
|
340
|
+
) -> TypeSource:
|
|
341
|
+
if not isinstance(schema, dict):
|
|
342
|
+
root_type = _class_name(root_name)
|
|
343
|
+
return TypeSource(_render_type_alias(root_type, missing_type_expr), root_type)
|
|
344
|
+
used_names: set[str] = set()
|
|
345
|
+
return _schema_to_type(_class_name(root_name), schema, schema, used_names, define_named=True)
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _schema_to_type(
|
|
349
|
+
type_name: str,
|
|
350
|
+
schema: dict[str, Any],
|
|
351
|
+
root_schema: dict[str, Any],
|
|
352
|
+
used_names: set[str],
|
|
353
|
+
*,
|
|
354
|
+
define_named: bool = False,
|
|
355
|
+
) -> TypeSource:
|
|
356
|
+
schema = _resolve_schema_ref(schema, root_schema)
|
|
357
|
+
|
|
358
|
+
if "const" in schema:
|
|
359
|
+
return _maybe_alias(type_name, _literal_type([schema["const"]]), used_names, define_named)
|
|
360
|
+
if isinstance(schema.get("enum"), list) and schema["enum"]:
|
|
361
|
+
return _maybe_alias(type_name, _literal_type(schema["enum"]), used_names, define_named)
|
|
362
|
+
|
|
363
|
+
for key in ("oneOf", "anyOf"):
|
|
364
|
+
variants = schema.get(key)
|
|
365
|
+
if isinstance(variants, list) and variants:
|
|
366
|
+
definitions: list[str] = []
|
|
367
|
+
type_exprs: list[str] = []
|
|
368
|
+
for index, variant in enumerate(variants, start=1):
|
|
369
|
+
if not isinstance(variant, dict):
|
|
370
|
+
type_exprs.append("_t.Any")
|
|
371
|
+
continue
|
|
372
|
+
variant_schema = cast("dict[str, Any]", variant)
|
|
373
|
+
variant_type = _schema_to_type(
|
|
374
|
+
f"{type_name}Variant{index}",
|
|
375
|
+
variant_schema,
|
|
376
|
+
root_schema,
|
|
377
|
+
used_names,
|
|
378
|
+
)
|
|
379
|
+
definitions.append(variant_type.source)
|
|
380
|
+
type_exprs.append(variant_type.type_expr)
|
|
381
|
+
return _maybe_alias(
|
|
382
|
+
type_name,
|
|
383
|
+
_union_type(type_exprs),
|
|
384
|
+
used_names,
|
|
385
|
+
define_named,
|
|
386
|
+
definitions,
|
|
387
|
+
)
|
|
388
|
+
|
|
389
|
+
all_of = schema.get("allOf")
|
|
390
|
+
if isinstance(all_of, list) and len(all_of) == 1 and isinstance(all_of[0], dict):
|
|
391
|
+
return _schema_to_type(type_name, all_of[0], root_schema, used_names, define_named=define_named)
|
|
392
|
+
|
|
393
|
+
schema_type = schema.get("type")
|
|
394
|
+
if isinstance(schema_type, list):
|
|
395
|
+
definitions = []
|
|
396
|
+
type_exprs = []
|
|
397
|
+
for item in schema_type:
|
|
398
|
+
item_schema = {**schema, "type": item}
|
|
399
|
+
item_type = _schema_to_type(type_name, item_schema, root_schema, used_names)
|
|
400
|
+
definitions.append(item_type.source)
|
|
401
|
+
type_exprs.append(item_type.type_expr)
|
|
402
|
+
return _maybe_alias(
|
|
403
|
+
type_name,
|
|
404
|
+
_union_type(type_exprs),
|
|
405
|
+
used_names,
|
|
406
|
+
define_named,
|
|
407
|
+
definitions,
|
|
408
|
+
)
|
|
409
|
+
|
|
410
|
+
if schema_type == "object" or "properties" in schema:
|
|
411
|
+
return _object_type_source(type_name, schema, root_schema, used_names, define_named=define_named)
|
|
412
|
+
if schema_type == "array":
|
|
413
|
+
items = schema.get("items")
|
|
414
|
+
if isinstance(items, dict):
|
|
415
|
+
item_type = _schema_to_type(
|
|
416
|
+
f"{type_name}Item",
|
|
417
|
+
items,
|
|
418
|
+
root_schema,
|
|
419
|
+
used_names,
|
|
420
|
+
)
|
|
421
|
+
return _maybe_alias(
|
|
422
|
+
type_name,
|
|
423
|
+
f"list[{item_type.type_expr}]",
|
|
424
|
+
used_names,
|
|
425
|
+
define_named,
|
|
426
|
+
[item_type.source],
|
|
427
|
+
)
|
|
428
|
+
return _maybe_alias(type_name, "list[_t.Any]", used_names, define_named)
|
|
429
|
+
if schema_type == "string":
|
|
430
|
+
return _maybe_alias(type_name, "str", used_names, define_named)
|
|
431
|
+
if schema_type == "integer":
|
|
432
|
+
return _maybe_alias(type_name, "int", used_names, define_named)
|
|
433
|
+
if schema_type == "number":
|
|
434
|
+
return _maybe_alias(type_name, "float", used_names, define_named)
|
|
435
|
+
if schema_type == "boolean":
|
|
436
|
+
return _maybe_alias(type_name, "bool", used_names, define_named)
|
|
437
|
+
if schema_type == "null":
|
|
438
|
+
return _maybe_alias(type_name, "None", used_names, define_named)
|
|
439
|
+
return _maybe_alias(type_name, "_t.Any", used_names, define_named)
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def _object_type_source(
|
|
443
|
+
type_name: str,
|
|
444
|
+
schema: dict[str, Any],
|
|
445
|
+
root_schema: dict[str, Any],
|
|
446
|
+
used_names: set[str],
|
|
447
|
+
*,
|
|
448
|
+
define_named: bool,
|
|
449
|
+
) -> TypeSource:
|
|
450
|
+
properties = schema.get("properties")
|
|
451
|
+
if isinstance(properties, dict) and properties:
|
|
452
|
+
reserved_name = _reserve_type_name(type_name, used_names)
|
|
453
|
+
required = {field for field in schema.get("required", []) if isinstance(field, str)}
|
|
454
|
+
definitions: list[str] = []
|
|
455
|
+
fields: list[dict[str, str]] = []
|
|
456
|
+
used_fields: set[str] = set()
|
|
457
|
+
for raw_prop_name, raw_prop_schema in sorted(properties.items()):
|
|
458
|
+
prop_name = str(raw_prop_name)
|
|
459
|
+
prop_schema = cast("dict[str, Any]", raw_prop_schema if isinstance(raw_prop_schema, dict) else {})
|
|
460
|
+
prop_type = _schema_to_type(
|
|
461
|
+
f"{reserved_name}{_class_name(str(prop_name))}",
|
|
462
|
+
prop_schema,
|
|
463
|
+
root_schema,
|
|
464
|
+
used_names,
|
|
465
|
+
)
|
|
466
|
+
definitions.append(prop_type.source)
|
|
467
|
+
default = _field_default(prop_name, prop_schema, required)
|
|
468
|
+
nullable = _is_nullable(prop_schema)
|
|
469
|
+
type_expr = prop_type.type_expr
|
|
470
|
+
if prop_name not in required or nullable:
|
|
471
|
+
type_expr = _optional_type(type_expr)
|
|
472
|
+
field_name = _safe_field_name(prop_name, used_fields)
|
|
473
|
+
field_args = _field_args(prop_name, prop_schema, default, field_name)
|
|
474
|
+
fields.append(
|
|
475
|
+
{
|
|
476
|
+
"field_args": field_args,
|
|
477
|
+
"name": field_name,
|
|
478
|
+
"type_expr": type_expr,
|
|
479
|
+
}
|
|
480
|
+
)
|
|
481
|
+
definitions.append(_render_source("codegen/model.py.j2", class_name=reserved_name, fields=fields))
|
|
482
|
+
return TypeSource(_join_definitions(definitions), reserved_name, is_model=True)
|
|
483
|
+
|
|
484
|
+
additional = schema.get("additionalProperties")
|
|
485
|
+
if isinstance(additional, dict):
|
|
486
|
+
value_type = _schema_to_type(f"{type_name}Value", additional, root_schema, used_names)
|
|
487
|
+
return _maybe_alias(
|
|
488
|
+
type_name,
|
|
489
|
+
f"dict[str, {value_type.type_expr}]",
|
|
490
|
+
used_names,
|
|
491
|
+
define_named,
|
|
492
|
+
[value_type.source],
|
|
493
|
+
)
|
|
494
|
+
|
|
495
|
+
return _maybe_alias(type_name, "dict[str, _t.Any]", used_names, define_named)
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def _resolve_schema_ref(schema: dict[str, Any], root_schema: dict[str, Any]) -> dict[str, Any]:
|
|
499
|
+
ref = schema.get("$ref")
|
|
500
|
+
if not isinstance(ref, str) or not ref.startswith("#/"):
|
|
501
|
+
return schema
|
|
502
|
+
target: Any = root_schema
|
|
503
|
+
for part in ref[2:].split("/"):
|
|
504
|
+
part = part.replace("~1", "/").replace("~0", "~")
|
|
505
|
+
if not isinstance(target, dict) or part not in target:
|
|
506
|
+
return schema
|
|
507
|
+
target = target[part]
|
|
508
|
+
if not isinstance(target, dict):
|
|
509
|
+
return schema
|
|
510
|
+
merged = dict(target)
|
|
511
|
+
merged.update({key: value for key, value in schema.items() if key != "$ref"})
|
|
512
|
+
return merged
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
def _maybe_alias(
|
|
516
|
+
type_name: str,
|
|
517
|
+
type_expr: str,
|
|
518
|
+
used_names: set[str],
|
|
519
|
+
define_named: bool,
|
|
520
|
+
definitions: list[str] | None = None,
|
|
521
|
+
) -> TypeSource:
|
|
522
|
+
definitions = definitions or []
|
|
523
|
+
if not define_named:
|
|
524
|
+
return TypeSource(_join_definitions(definitions), type_expr)
|
|
525
|
+
reserved_name = _reserve_type_name(type_name, used_names)
|
|
526
|
+
if _is_root_model_expr(type_expr):
|
|
527
|
+
return TypeSource(
|
|
528
|
+
_join_definitions(
|
|
529
|
+
[
|
|
530
|
+
*definitions,
|
|
531
|
+
_render_source("codegen/root_model.py.j2", class_name=reserved_name, type_expr=type_expr),
|
|
532
|
+
]
|
|
533
|
+
),
|
|
534
|
+
reserved_name,
|
|
535
|
+
is_model=True,
|
|
536
|
+
)
|
|
537
|
+
return TypeSource(_join_definitions([*definitions, _render_type_alias(reserved_name, type_expr)]), reserved_name)
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def _render_type_alias(type_name: str, type_expr: str) -> str:
|
|
541
|
+
return _render_source("codegen/type_alias.py.j2", type_name=type_name, type_expr=type_expr)
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
def _is_root_model_expr(type_expr: str) -> bool:
|
|
545
|
+
return type_expr not in {"_t.Any", "None"} and not type_expr.startswith("dict[")
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
def _field_default(prop_name: str, schema: dict[str, Any], required: set[str]) -> str:
|
|
549
|
+
if "default" in schema:
|
|
550
|
+
return repr(schema["default"])
|
|
551
|
+
return "..." if prop_name in required else "None"
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
def _field_args(prop_name: str, schema: dict[str, Any], default: str, field_name: str) -> str:
|
|
555
|
+
kwargs = [f"default={default}"]
|
|
556
|
+
if field_name != prop_name:
|
|
557
|
+
kwargs.append(f"alias={prop_name!r}")
|
|
558
|
+
description = schema.get("description")
|
|
559
|
+
if isinstance(description, str) and description:
|
|
560
|
+
kwargs.append(f"description={description!r}")
|
|
561
|
+
title = schema.get("title")
|
|
562
|
+
if isinstance(title, str) and title:
|
|
563
|
+
kwargs.append(f"title={title!r}")
|
|
564
|
+
for schema_key, field_key in (
|
|
565
|
+
("minimum", "ge"),
|
|
566
|
+
("maximum", "le"),
|
|
567
|
+
("exclusiveMinimum", "gt"),
|
|
568
|
+
("exclusiveMaximum", "lt"),
|
|
569
|
+
("minLength", "min_length"),
|
|
570
|
+
("maxLength", "max_length"),
|
|
571
|
+
("pattern", "pattern"),
|
|
572
|
+
):
|
|
573
|
+
if schema_key in schema:
|
|
574
|
+
kwargs.append(f"{field_key}={schema[schema_key]!r}")
|
|
575
|
+
return f"Field({', '.join(kwargs)})"
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
def _safe_field_name(name: str, used_fields: set[str]) -> str:
|
|
579
|
+
candidate = re.sub(r"\W", "_", name)
|
|
580
|
+
if not candidate or candidate[0].isdigit():
|
|
581
|
+
candidate = f"field_{candidate}"
|
|
582
|
+
if keyword.iskeyword(candidate):
|
|
583
|
+
candidate += "_"
|
|
584
|
+
base = candidate
|
|
585
|
+
index = 2
|
|
586
|
+
while candidate in used_fields:
|
|
587
|
+
candidate = f"{base}_{index}"
|
|
588
|
+
index += 1
|
|
589
|
+
used_fields.add(candidate)
|
|
590
|
+
return candidate
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
def _optional_type(type_expr: str) -> str:
|
|
594
|
+
if "None" in type_expr.split(" | "):
|
|
595
|
+
return type_expr
|
|
596
|
+
return f"{type_expr} | None"
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
def _is_nullable(schema: dict[str, Any]) -> bool:
|
|
600
|
+
schema_type = schema.get("type")
|
|
601
|
+
return schema_type == "null" or (isinstance(schema_type, list) and "null" in schema_type)
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
def _return_expr(output_type: TypeSource) -> str:
|
|
605
|
+
if output_type.type_expr == "_t.Any":
|
|
606
|
+
return "result"
|
|
607
|
+
if output_type.is_model:
|
|
608
|
+
return f"{output_type.type_expr}.model_validate(result)"
|
|
609
|
+
return f"_t.cast({output_type.type_expr}, result)"
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
def _literal_type(values: list[Any]) -> str:
|
|
613
|
+
return "_t.Literal[{}]".format(", ".join(repr(value) for value in values))
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
def _union_type(type_exprs: list[str]) -> str:
|
|
617
|
+
unique = []
|
|
618
|
+
for expr in type_exprs:
|
|
619
|
+
if expr and expr not in unique:
|
|
620
|
+
unique.append(expr)
|
|
621
|
+
if not unique:
|
|
622
|
+
return "_t.Any"
|
|
623
|
+
if len(unique) == 1:
|
|
624
|
+
return unique[0]
|
|
625
|
+
return " | ".join(unique)
|
|
626
|
+
|
|
627
|
+
|
|
628
|
+
def _join_definitions(definitions: list[str]) -> str:
|
|
629
|
+
return "\n\n".join(definition for definition in definitions if definition)
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
def _reserve_type_name(type_name: str, used_names: set[str]) -> str:
|
|
633
|
+
base = _class_name(type_name)
|
|
634
|
+
candidate = base
|
|
635
|
+
index = 2
|
|
636
|
+
while candidate in used_names:
|
|
637
|
+
candidate = f"{base}{index}"
|
|
638
|
+
index += 1
|
|
639
|
+
used_names.add(candidate)
|
|
640
|
+
return candidate
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
def _docstring(description: str, input_schema: dict[str, Any], output_schema: Any) -> str:
|
|
644
|
+
del input_schema, output_schema
|
|
645
|
+
return (description.strip() or "Call the MCP tool.").replace('"""', '\"\"\"')
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
def _unique_sanitized_names(names: Any) -> dict[str, str]:
|
|
649
|
+
originals = list(names)
|
|
650
|
+
base_names = [_sanitize_identifier(name) for name in originals]
|
|
651
|
+
counts: Counter[str] = Counter()
|
|
652
|
+
result: dict[str, str] = {}
|
|
653
|
+
for original, base in zip(originals, base_names, strict=True):
|
|
654
|
+
counts[base] += 1
|
|
655
|
+
result[original] = base if counts[base] == 1 else f"{base}_{counts[base]}"
|
|
656
|
+
return result
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
def _sanitize_identifier(name: str) -> str:
|
|
660
|
+
words = [part for part in re.split(r"[^0-9A-Za-z]+", name.strip()) if part]
|
|
661
|
+
if not words:
|
|
662
|
+
result = "tool"
|
|
663
|
+
else:
|
|
664
|
+
result = words[0].lower() + "".join(part[:1].upper() + part[1:] for part in words[1:])
|
|
665
|
+
result = re.sub(r"\W", "_", result)
|
|
666
|
+
if result[0].isdigit():
|
|
667
|
+
result = f"_{result}"
|
|
668
|
+
if keyword.iskeyword(result):
|
|
669
|
+
result += "_"
|
|
670
|
+
return result
|
|
671
|
+
|
|
672
|
+
|
|
673
|
+
def _class_name(func_name: str) -> str:
|
|
674
|
+
parts = [part for part in re.split(r"[^0-9A-Za-z]+", str(func_name)) if part]
|
|
675
|
+
result = "".join(part[:1].upper() + part[1:] for part in parts) or "Tool"
|
|
676
|
+
if result[0].isdigit():
|
|
677
|
+
result = f"_{result}"
|
|
678
|
+
if keyword.iskeyword(result):
|
|
679
|
+
result += "Type"
|
|
680
|
+
return result
|