mcp-as-code 0.1.5__tar.gz → 0.1.7__tar.gz

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.
Files changed (40) hide show
  1. {mcp_as_code-0.1.5 → mcp_as_code-0.1.7}/PKG-INFO +46 -24
  2. {mcp_as_code-0.1.5 → mcp_as_code-0.1.7}/README.md +43 -22
  3. {mcp_as_code-0.1.5 → mcp_as_code-0.1.7}/examples/serve-mcp/README.md +1 -1
  4. {mcp_as_code-0.1.5 → mcp_as_code-0.1.7}/pyproject.toml +5 -1
  5. {mcp_as_code-0.1.5 → mcp_as_code-0.1.7}/src/maco/__init__.py +1 -1
  6. {mcp_as_code-0.1.5 → mcp_as_code-0.1.7}/src/maco/codegen.py +226 -80
  7. {mcp_as_code-0.1.5 → mcp_as_code-0.1.7}/src/maco/sandbox/providers/base.py +0 -1
  8. {mcp_as_code-0.1.5 → mcp_as_code-0.1.7}/src/maco/sandbox/providers/matchlock.py +4 -3
  9. {mcp_as_code-0.1.5 → mcp_as_code-0.1.7}/src/maco/serve_mcp.py +3 -5
  10. {mcp_as_code-0.1.5 → mcp_as_code-0.1.7}/src/maco/templates/code_execute_description.j2 +8 -1
  11. {mcp_as_code-0.1.5 → mcp_as_code-0.1.7}/src/maco/templates/codegen/client.py.j2 +37 -10
  12. {mcp_as_code-0.1.5 → mcp_as_code-0.1.7}/src/maco/templates/codegen/model.py.j2 +1 -1
  13. {mcp_as_code-0.1.5 → mcp_as_code-0.1.7}/src/maco/templates/codegen/pyproject.toml.j2 +1 -1
  14. mcp_as_code-0.1.7/src/maco/templates/codegen/server_init.py.j2 +12 -0
  15. mcp_as_code-0.1.7/src/maco/templates/codegen/tool.py.j2 +21 -0
  16. {mcp_as_code-0.1.5 → mcp_as_code-0.1.7}/src/maco/templates/serve_mcp_instructions.j2 +7 -1
  17. mcp_as_code-0.1.5/src/maco/templates/codegen/root_model.py.j2 +0 -3
  18. mcp_as_code-0.1.5/src/maco/templates/codegen/server_init.py.j2 +0 -11
  19. mcp_as_code-0.1.5/src/maco/templates/codegen/tool.py.j2 +0 -38
  20. {mcp_as_code-0.1.5 → mcp_as_code-0.1.7}/.gitignore +0 -0
  21. {mcp_as_code-0.1.5 → mcp_as_code-0.1.7}/LICENSE +0 -0
  22. {mcp_as_code-0.1.5 → mcp_as_code-0.1.7}/docs/mcp-config.md +0 -0
  23. {mcp_as_code-0.1.5 → mcp_as_code-0.1.7}/images/sandbox/README.md +0 -0
  24. {mcp_as_code-0.1.5 → mcp_as_code-0.1.7}/src/maco/cli.py +0 -0
  25. {mcp_as_code-0.1.5 → mcp_as_code-0.1.7}/src/maco/config.py +0 -0
  26. {mcp_as_code-0.1.5 → mcp_as_code-0.1.7}/src/maco/gateway.py +0 -0
  27. {mcp_as_code-0.1.5 → mcp_as_code-0.1.7}/src/maco/mcp_manager.py +0 -0
  28. {mcp_as_code-0.1.5 → mcp_as_code-0.1.7}/src/maco/oauth.py +0 -0
  29. {mcp_as_code-0.1.5 → mcp_as_code-0.1.7}/src/maco/runner.py +0 -0
  30. {mcp_as_code-0.1.5 → mcp_as_code-0.1.7}/src/maco/sandbox/__init__.py +0 -0
  31. {mcp_as_code-0.1.5 → mcp_as_code-0.1.7}/src/maco/sandbox/core.py +0 -0
  32. {mcp_as_code-0.1.5 → mcp_as_code-0.1.7}/src/maco/sandbox/providers/__init__.py +0 -0
  33. {mcp_as_code-0.1.5 → mcp_as_code-0.1.7}/src/maco/sandbox/providers/docker.py +0 -0
  34. {mcp_as_code-0.1.5 → mcp_as_code-0.1.7}/src/maco/sandbox/providers/local.py +0 -0
  35. {mcp_as_code-0.1.5 → mcp_as_code-0.1.7}/src/maco/service.py +0 -0
  36. {mcp_as_code-0.1.5 → mcp_as_code-0.1.7}/src/maco/templates/bash_description.j2 +0 -0
  37. {mcp_as_code-0.1.5 → mcp_as_code-0.1.7}/src/maco/templates/codegen/package_init.py.j2 +0 -0
  38. {mcp_as_code-0.1.5 → mcp_as_code-0.1.7}/src/maco/templates/codegen/type_alias.py.j2 +0 -0
  39. {mcp_as_code-0.1.5 → mcp_as_code-0.1.7}/src/maco/templates/server_catalog.j2 +0 -0
  40. {mcp_as_code-0.1.5 → mcp_as_code-0.1.7}/src/maco/version.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mcp-as-code
3
- Version: 0.1.5
3
+ Version: 0.1.7
4
4
  Summary: Execute MCP tools through generated Python code interfaces
5
5
  License-Expression: Apache-2.0
6
6
  License-File: LICENSE
@@ -8,15 +8,18 @@ Requires-Python: >=3.11
8
8
  Requires-Dist: click>=8.1
9
9
  Requires-Dist: httpx>=0.28
10
10
  Requires-Dist: jinja2>=3.1
11
- Requires-Dist: matchlock==0.2.15
12
11
  Requires-Dist: mcp>=1.24.0
13
12
  Requires-Dist: pydantic>=2.0
13
+ Provides-Extra: matchlock
14
+ Requires-Dist: matchlock<0.3.0,>=0.2.15; extra == 'matchlock'
14
15
  Description-Content-Type: text/markdown
15
16
 
16
17
  # maco
17
18
 
18
19
  **Connect every MCP server you need, keeping your agent's context lean.**
19
20
 
21
+ https://github.com/user-attachments/assets/4b91ea97-d48e-41c5-8189-0da8522ac459
22
+
20
23
  As the number of MCP servers you connect grows, tool schemas and intermediate tool call results clutter your agent's context. `maco` (mcp-as-code) collapses them all into a single endpoint with a programmatic interface.
21
24
 
22
25
  Instead of loading hundreds if not thousands of tool schemas upfront, `maco` reconstructs every MCP tool as Pydantic models and Python functions in a virtual filesystem and hands your agent just two of its favourite tools: `bash` to navigate, and `code_execute` to run. The agent discovers and composes tools as code, the thing frontier models do best.
@@ -42,26 +45,30 @@ tools
42
45
  **Programmatic leverage:** the agent is given a real programming language, Python, allowing it to orchestrate complex control flows with exceptional context-efficiency using loops, conditions, and state management.
43
46
 
44
47
  ```python
48
+ import asyncio
45
49
  from collections import Counter
46
- from tools.github import listCommits
47
-
48
- owner, repo, page, counts = "openclaw", "openclaw", 1, Counter()
49
-
50
- while True:
51
- commits = listCommits(owner=owner, repo=repo, perPage=100, page=page)
52
- for commit in commits:
53
- login = (commit.get("author") or {}).get("login")
54
- if login and "bot" not in login.lower():
55
- counts[login] += 1
56
- if len(commits) < 100 or page >= 20:
57
- break
58
- page += 1
59
-
60
- total = sum(counts.values())
61
- for login, count in counts.most_common():
62
- if count / total < 0.01:
63
- break
64
- print(f"@{login}: {count} commits ({count / total:.1%})")
50
+ from tools.github import ListCommitsInput, list_commits
51
+
52
+ async def main():
53
+ owner, repo, page, counts = "openclaw", "openclaw", 1, Counter()
54
+
55
+ while True:
56
+ commits = await list_commits(ListCommitsInput(owner=owner, repo=repo, per_page=100, page=page))
57
+ for commit in commits:
58
+ login = (commit.get("author") or {}).get("login")
59
+ if login and "bot" not in login.lower():
60
+ counts[login] += 1
61
+ if len(commits) < 100 or page >= 20:
62
+ break
63
+ page += 1
64
+
65
+ total = sum(counts.values())
66
+ for login, count in counts.most_common():
67
+ if count / total < 0.01:
68
+ break
69
+ print(f"@{login}: {count} commits ({count / total:.1%})")
70
+
71
+ asyncio.run(main())
65
72
  ```
66
73
 
67
74
  The example above illustrates the MCP code that will be executed to find the top contributors to an open-source repository.
@@ -74,6 +81,14 @@ Install the Python package `mcp-as-code`; it provides the `maco` executable:
74
81
  uv tool install mcp-as-code
75
82
  ```
76
83
 
84
+ To use the Matchlock sandbox provider, install the optional Matchlock support instead:
85
+
86
+ ```bash
87
+ uv tool install 'mcp-as-code[matchlock]'
88
+ ```
89
+
90
+ The Matchlock binary must also be installed and available on `PATH`.
91
+
77
92
  Then verify the CLI:
78
93
 
79
94
  ```bash
@@ -141,9 +156,16 @@ See [`docs/mcp-config.md`](docs/mcp-config.md) for the full config reference, in
141
156
 
142
157
  Choose the execution provider with `--provider`:
143
158
 
144
- - `local`: fastest feedback loop; runs commands as local subprocesses.
145
- - `docker`: runs commands in a long-lived Docker container.
146
- - `matchlock`: runs commands in a long-lived Matchlock micro-VM.
159
+ - `local`: ideally for local development and fast feedback loop, or maco is already running in an isolated sandbox.
160
+ - `docker`: runs mcp bash and code execution in a long-lived Docker container.
161
+ - `matchlock`: runs mcp bash and code execution in a long-lived Matchlock micro-VM; requires `mcp-as-code[matchlock]` and the Matchlock binary.
162
+
163
+ ## Credits
164
+
165
+ `maco` is inspired by and builds on ideas from:
166
+
167
+ - [Code execution with MCP: Building more efficient agents](https://www.anthropic.com/engineering/code-execution-with-mcp)
168
+ - [Code Mode: the better way to use MCP](https://blog.cloudflare.com/code-mode/)
147
169
 
148
170
  ## License
149
171
 
@@ -2,6 +2,8 @@
2
2
 
3
3
  **Connect every MCP server you need, keeping your agent's context lean.**
4
4
 
5
+ https://github.com/user-attachments/assets/4b91ea97-d48e-41c5-8189-0da8522ac459
6
+
5
7
  As the number of MCP servers you connect grows, tool schemas and intermediate tool call results clutter your agent's context. `maco` (mcp-as-code) collapses them all into a single endpoint with a programmatic interface.
6
8
 
7
9
  Instead of loading hundreds if not thousands of tool schemas upfront, `maco` reconstructs every MCP tool as Pydantic models and Python functions in a virtual filesystem and hands your agent just two of its favourite tools: `bash` to navigate, and `code_execute` to run. The agent discovers and composes tools as code, the thing frontier models do best.
@@ -27,26 +29,30 @@ tools
27
29
  **Programmatic leverage:** the agent is given a real programming language, Python, allowing it to orchestrate complex control flows with exceptional context-efficiency using loops, conditions, and state management.
28
30
 
29
31
  ```python
32
+ import asyncio
30
33
  from collections import Counter
31
- from tools.github import listCommits
32
-
33
- owner, repo, page, counts = "openclaw", "openclaw", 1, Counter()
34
-
35
- while True:
36
- commits = listCommits(owner=owner, repo=repo, perPage=100, page=page)
37
- for commit in commits:
38
- login = (commit.get("author") or {}).get("login")
39
- if login and "bot" not in login.lower():
40
- counts[login] += 1
41
- if len(commits) < 100 or page >= 20:
42
- break
43
- page += 1
44
-
45
- total = sum(counts.values())
46
- for login, count in counts.most_common():
47
- if count / total < 0.01:
48
- break
49
- print(f"@{login}: {count} commits ({count / total:.1%})")
34
+ from tools.github import ListCommitsInput, list_commits
35
+
36
+ async def main():
37
+ owner, repo, page, counts = "openclaw", "openclaw", 1, Counter()
38
+
39
+ while True:
40
+ commits = await list_commits(ListCommitsInput(owner=owner, repo=repo, per_page=100, page=page))
41
+ for commit in commits:
42
+ login = (commit.get("author") or {}).get("login")
43
+ if login and "bot" not in login.lower():
44
+ counts[login] += 1
45
+ if len(commits) < 100 or page >= 20:
46
+ break
47
+ page += 1
48
+
49
+ total = sum(counts.values())
50
+ for login, count in counts.most_common():
51
+ if count / total < 0.01:
52
+ break
53
+ print(f"@{login}: {count} commits ({count / total:.1%})")
54
+
55
+ asyncio.run(main())
50
56
  ```
51
57
 
52
58
  The example above illustrates the MCP code that will be executed to find the top contributors to an open-source repository.
@@ -59,6 +65,14 @@ Install the Python package `mcp-as-code`; it provides the `maco` executable:
59
65
  uv tool install mcp-as-code
60
66
  ```
61
67
 
68
+ To use the Matchlock sandbox provider, install the optional Matchlock support instead:
69
+
70
+ ```bash
71
+ uv tool install 'mcp-as-code[matchlock]'
72
+ ```
73
+
74
+ The Matchlock binary must also be installed and available on `PATH`.
75
+
62
76
  Then verify the CLI:
63
77
 
64
78
  ```bash
@@ -126,9 +140,16 @@ See [`docs/mcp-config.md`](docs/mcp-config.md) for the full config reference, in
126
140
 
127
141
  Choose the execution provider with `--provider`:
128
142
 
129
- - `local`: fastest feedback loop; runs commands as local subprocesses.
130
- - `docker`: runs commands in a long-lived Docker container.
131
- - `matchlock`: runs commands in a long-lived Matchlock micro-VM.
143
+ - `local`: ideally for local development and fast feedback loop, or maco is already running in an isolated sandbox.
144
+ - `docker`: runs mcp bash and code execution in a long-lived Docker container.
145
+ - `matchlock`: runs mcp bash and code execution in a long-lived Matchlock micro-VM; requires `mcp-as-code[matchlock]` and the Matchlock binary.
146
+
147
+ ## Credits
148
+
149
+ `maco` is inspired by and builds on ideas from:
150
+
151
+ - [Code execution with MCP: Building more efficient agents](https://www.anthropic.com/engineering/code-execution-with-mcp)
152
+ - [Code Mode: the better way to use MCP](https://blog.cloudflare.com/code-mode/)
132
153
 
133
154
  ## License
134
155
 
@@ -11,7 +11,7 @@ This example shows how to expose several upstream MCP servers through one compac
11
11
  - `node`/`npx`, for Playwright MCP
12
12
  - A GitHub personal access token in `GITHUB_TOKEN`, used by the hosted GitHub MCP server
13
13
  - Docker, only if you use the Docker sandbox provider
14
- - Optional: Matchlock, for the Matchlock sandbox provider
14
+ - Optional: `mcp-as-code[matchlock]` and the Matchlock binary, for the Matchlock sandbox provider
15
15
 
16
16
  If you are already authenticated with the GitHub CLI, export a token directly:
17
17
 
@@ -9,11 +9,15 @@ dependencies = [
9
9
  "click>=8.1",
10
10
  "httpx>=0.28",
11
11
  "jinja2>=3.1",
12
- "matchlock==0.2.15",
13
12
  "mcp>=1.24.0",
14
13
  "pydantic>=2.0",
15
14
  ]
16
15
 
16
+ [project.optional-dependencies]
17
+ matchlock = [
18
+ "matchlock>=0.2.15,<0.3.0",
19
+ ]
20
+
17
21
  [project.scripts]
18
22
  maco = "maco.cli:main"
19
23
 
@@ -1,3 +1,3 @@
1
1
  """maco Python interface generator and MCP code-execution server."""
2
2
 
3
- __version__ = "0.1.5"
3
+ __version__ = "0.1.7"
@@ -3,8 +3,8 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import asyncio
6
- from collections import Counter
7
6
  from dataclasses import dataclass
7
+ import hashlib
8
8
  import json
9
9
  import keyword
10
10
  from pathlib import Path
@@ -16,6 +16,7 @@ from urllib.request import Request, urlopen
16
16
 
17
17
  from jinja2 import Environment, PackageLoader, StrictUndefined
18
18
 
19
+ from . import __version__
19
20
  from .config import MacoConfig
20
21
  from .mcp_manager import MCPManager
21
22
 
@@ -36,6 +37,38 @@ def _pyrepr(value: Any) -> str:
36
37
  _CODEGEN_TEMPLATES.filters["pyrepr"] = _pyrepr
37
38
 
38
39
 
40
+ PYDANTIC_RESERVED_FIELD_NAMES = {
41
+ "construct",
42
+ "copy",
43
+ "dict",
44
+ "from_orm",
45
+ "json",
46
+ "model_computed_fields",
47
+ "model_config",
48
+ "model_construct",
49
+ "model_copy",
50
+ "model_dump",
51
+ "model_dump_json",
52
+ "model_extra",
53
+ "model_fields",
54
+ "model_fields_set",
55
+ "model_json_schema",
56
+ "model_parametrized_name",
57
+ "model_post_init",
58
+ "model_rebuild",
59
+ "model_validate",
60
+ "model_validate_json",
61
+ "model_validate_strings",
62
+ "parse_file",
63
+ "parse_obj",
64
+ "parse_raw",
65
+ "schema",
66
+ "schema_json",
67
+ "update_forward_refs",
68
+ "validate",
69
+ }
70
+
71
+
39
72
  @dataclass(frozen=True)
40
73
  class GenerationStats:
41
74
  server_count: int
@@ -52,6 +85,13 @@ class TypeSource:
52
85
  is_model: bool = False
53
86
 
54
87
 
88
+ @dataclass(frozen=True)
89
+ class ToolExport:
90
+ function: str
91
+ input_type: str
92
+ output_type: str
93
+
94
+
55
95
  async def generate_async(
56
96
  config: MacoConfig,
57
97
  workspace: str | Path = ".maco",
@@ -124,8 +164,11 @@ def generate_from_catalog(
124
164
  _write_client(client_path)
125
165
 
126
166
  manifest = {
127
- "version": 1,
167
+ "version": 2,
168
+ "generator": "maco.codegen",
169
+ "generator_version": __version__ or "unknown",
128
170
  "config": str(config_path) if config_path is not None else None,
171
+ "config_hash": _file_hash(config_path) if config_path is not None else None,
129
172
  "package": package_name,
130
173
  "servers": [],
131
174
  }
@@ -140,7 +183,7 @@ def generate_from_catalog(
140
183
  server_dir.mkdir(parents=True, exist_ok=True)
141
184
  tool_module_names = _unique_sanitized_names(tool["name"] for tool in tools)
142
185
 
143
- exports: list[str] = []
186
+ exports: list[ToolExport] = []
144
187
  server_manifest = {
145
188
  "name": server_name,
146
189
  "module": server_module,
@@ -150,14 +193,18 @@ def generate_from_catalog(
150
193
  tool_name = tool["name"]
151
194
  func_name = tool_module_names[tool_name]
152
195
  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)
196
+ tool_export = _write_tool(module_path, server_name, tool, func_name, client_module)
197
+ exports.append(tool_export)
155
198
  server_manifest["tools"].append(
156
199
  {
157
200
  "name": tool_name,
158
201
  "function": func_name,
159
202
  "module": f"{package_name}.{server_module}.{func_name}",
160
203
  "description": tool.get("description") or "",
204
+ "input_type": tool_export.input_type,
205
+ "output_type": tool_export.output_type,
206
+ "input_schema_hash": _schema_hash(tool.get("inputSchema")),
207
+ "output_schema_hash": _schema_hash(tool.get("outputSchema")),
161
208
  }
162
209
  )
163
210
  tool_count += 1
@@ -292,7 +339,7 @@ def _write_tool(
292
339
  tool: dict[str, Any],
293
340
  func_name: str,
294
341
  client_module: str,
295
- ) -> None:
342
+ ) -> ToolExport:
296
343
  tool_name = tool["name"]
297
344
  description = tool.get("description") or ""
298
345
  input_schema = tool.get("inputSchema") or {"type": "object", "properties": {}}
@@ -303,26 +350,30 @@ def _write_tool(
303
350
  output_schema,
304
351
  missing_type_expr="_t.Any",
305
352
  )
306
- return_expr = _return_expr(output_type)
307
353
  _write_template(
308
354
  path,
309
355
  "codegen/tool.py.j2",
310
356
  description=description,
311
357
  docstring=_docstring(description, input_schema, output_schema),
312
358
  func_name=func_name,
313
- input_is_model=input_type.is_model,
359
+ input_default_suffix=_input_default_suffix(input_schema),
314
360
  input_type_expr=input_type.type_expr,
315
361
  input_type_source=input_type.source,
316
362
  output_type_expr=output_type.type_expr,
317
363
  output_type_source=output_type.source,
318
- return_expr=return_expr,
319
364
  client_module=client_module,
320
365
  server_name=server_name,
321
366
  tool_name=tool_name,
322
367
  )
323
368
 
369
+ return ToolExport(
370
+ function=func_name,
371
+ input_type=input_type.type_expr,
372
+ output_type=output_type.type_expr,
373
+ )
324
374
 
325
- def _write_server_init(path: Path, exports: list[str]) -> None:
375
+
376
+ def _write_server_init(path: Path, exports: list[ToolExport]) -> None:
326
377
  _write_template(path, "codegen/server_init.py.j2", exports=exports)
327
378
 
328
379
 
@@ -355,10 +406,14 @@ def _schema_to_type(
355
406
  ) -> TypeSource:
356
407
  schema = _resolve_schema_ref(schema, root_schema)
357
408
 
409
+ all_of_schema = _merged_all_of_schema(schema, root_schema)
410
+ if all_of_schema is not None:
411
+ return _schema_to_type(type_name, all_of_schema, root_schema, used_names, define_named=define_named)
412
+
358
413
  if "const" in schema:
359
- return _maybe_alias(type_name, _literal_type([schema["const"]]), used_names, define_named)
414
+ return _maybe_alias(type_name, _literal_type([schema["const"]]), used_names, define_named, schema=schema)
360
415
  if isinstance(schema.get("enum"), list) and schema["enum"]:
361
- return _maybe_alias(type_name, _literal_type(schema["enum"]), used_names, define_named)
416
+ return _maybe_alias(type_name, _literal_type(schema["enum"]), used_names, define_named, schema=schema)
362
417
 
363
418
  for key in ("oneOf", "anyOf"):
364
419
  variants = schema.get(key)
@@ -386,10 +441,6 @@ def _schema_to_type(
386
441
  definitions,
387
442
  )
388
443
 
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
444
  schema_type = schema.get("type")
394
445
  if isinstance(schema_type, list):
395
446
  definitions = []
@@ -424,16 +475,17 @@ def _schema_to_type(
424
475
  used_names,
425
476
  define_named,
426
477
  [item_type.source],
478
+ schema=schema,
427
479
  )
428
- return _maybe_alias(type_name, "list[_t.Any]", used_names, define_named)
480
+ return _maybe_alias(type_name, "list[_t.Any]", used_names, define_named, schema=schema)
429
481
  if schema_type == "string":
430
- return _maybe_alias(type_name, "str", used_names, define_named)
482
+ return _maybe_alias(type_name, "str", used_names, define_named, schema=schema)
431
483
  if schema_type == "integer":
432
- return _maybe_alias(type_name, "int", used_names, define_named)
484
+ return _maybe_alias(type_name, "int", used_names, define_named, schema=schema)
433
485
  if schema_type == "number":
434
- return _maybe_alias(type_name, "float", used_names, define_named)
486
+ return _maybe_alias(type_name, "float", used_names, define_named, schema=schema)
435
487
  if schema_type == "boolean":
436
- return _maybe_alias(type_name, "bool", used_names, define_named)
488
+ return _maybe_alias(type_name, "bool", used_names, define_named, schema=schema)
437
489
  if schema_type == "null":
438
490
  return _maybe_alias(type_name, "None", used_names, define_named)
439
491
  return _maybe_alias(type_name, "_t.Any", used_names, define_named)
@@ -448,7 +500,19 @@ def _object_type_source(
448
500
  define_named: bool,
449
501
  ) -> TypeSource:
450
502
  properties = schema.get("properties")
451
- if isinstance(properties, dict) and properties:
503
+ additional = schema.get("additionalProperties")
504
+ if (not properties) and isinstance(additional, dict):
505
+ value_type = _schema_to_type(f"{type_name}Value", additional, root_schema, used_names)
506
+ return _maybe_alias(
507
+ type_name,
508
+ f"dict[str, {value_type.type_expr}]",
509
+ used_names,
510
+ define_named,
511
+ [value_type.source],
512
+ schema=schema,
513
+ )
514
+
515
+ if isinstance(properties, dict):
452
516
  reserved_name = _reserve_type_name(type_name, used_names)
453
517
  required = {field for field in schema.get("required", []) if isinstance(field, str)}
454
518
  definitions: list[str] = []
@@ -478,23 +542,64 @@ def _object_type_source(
478
542
  "type_expr": type_expr,
479
543
  }
480
544
  )
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],
545
+ definitions.append(
546
+ _render_source(
547
+ "codegen/model.py.j2",
548
+ class_name=reserved_name,
549
+ extra_behavior=_extra_behavior(schema),
550
+ fields=fields,
551
+ )
493
552
  )
553
+ return TypeSource(_join_definitions(definitions), reserved_name, is_model=True)
494
554
 
495
555
  return _maybe_alias(type_name, "dict[str, _t.Any]", used_names, define_named)
496
556
 
497
557
 
558
+ def _merged_all_of_schema(schema: dict[str, Any], root_schema: dict[str, Any]) -> dict[str, Any] | None:
559
+ all_of = schema.get("allOf")
560
+ if not isinstance(all_of, list) or not all_of:
561
+ return None
562
+
563
+ merged = {key: value for key, value in schema.items() if key != "allOf"}
564
+ properties: dict[str, Any] = dict(merged.get("properties") or {}) if isinstance(merged.get("properties"), dict) else {}
565
+ required: list[str] = [item for item in merged.get("required", []) if isinstance(item, str)]
566
+ additional_properties = merged.get("additionalProperties")
567
+
568
+ for item in all_of:
569
+ if not isinstance(item, dict):
570
+ return None
571
+ item_schema = _resolve_schema_ref(item, root_schema)
572
+ if not _is_object_schema(item_schema):
573
+ return item_schema if len(all_of) == 1 and not properties else None
574
+ item_properties = item_schema.get("properties")
575
+ if isinstance(item_properties, dict):
576
+ properties.update(item_properties)
577
+ for required_field in item_schema.get("required", []):
578
+ if isinstance(required_field, str) and required_field not in required:
579
+ required.append(required_field)
580
+ item_additional = item_schema.get("additionalProperties")
581
+ if item_additional is False:
582
+ additional_properties = False
583
+ elif additional_properties is None and item_additional is not None:
584
+ additional_properties = item_additional
585
+
586
+ merged["type"] = "object"
587
+ merged["properties"] = properties
588
+ if required:
589
+ merged["required"] = required
590
+ if additional_properties is not None:
591
+ merged["additionalProperties"] = additional_properties
592
+ return merged
593
+
594
+
595
+ def _is_object_schema(schema: dict[str, Any]) -> bool:
596
+ return schema.get("type") == "object" or "properties" in schema
597
+
598
+
599
+ def _extra_behavior(schema: dict[str, Any]) -> str:
600
+ return "forbid" if schema.get("additionalProperties") is False else "allow"
601
+
602
+
498
603
  def _resolve_schema_ref(schema: dict[str, Any], root_schema: dict[str, Any]) -> dict[str, Any]:
499
604
  ref = schema.get("$ref")
500
605
  if not isinstance(ref, str) or not ref.startswith("#/"):
@@ -518,22 +623,14 @@ def _maybe_alias(
518
623
  used_names: set[str],
519
624
  define_named: bool,
520
625
  definitions: list[str] | None = None,
626
+ *,
627
+ schema: dict[str, Any] | None = None,
521
628
  ) -> TypeSource:
522
629
  definitions = definitions or []
523
630
  if not define_named:
524
631
  return TypeSource(_join_definitions(definitions), type_expr)
525
632
  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
- )
633
+ type_expr = _annotated_type(type_expr, schema)
537
634
  return TypeSource(_join_definitions([*definitions, _render_type_alias(reserved_name, type_expr)]), reserved_name)
538
635
 
539
636
 
@@ -541,10 +638,6 @@ def _render_type_alias(type_name: str, type_expr: str) -> str:
541
638
  return _render_source("codegen/type_alias.py.j2", type_name=type_name, type_expr=type_expr)
542
639
 
543
640
 
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
641
  def _field_default(prop_name: str, schema: dict[str, Any], required: set[str]) -> str:
549
642
  if "default" in schema:
550
643
  return repr(schema["default"])
@@ -561,26 +654,16 @@ def _field_args(prop_name: str, schema: dict[str, Any], default: str, field_name
561
654
  title = schema.get("title")
562
655
  if isinstance(title, str) and title:
563
656
  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}")
657
+ kwargs.extend(_constraint_args(schema))
575
658
  return f"Field({', '.join(kwargs)})"
576
659
 
577
660
 
578
661
  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():
662
+ candidate = _sanitize_identifier(name)
663
+ if candidate.startswith("_"):
664
+ candidate = f"field_{candidate.lstrip('_') or 'value'}"
665
+ if candidate in PYDANTIC_RESERVED_FIELD_NAMES:
581
666
  candidate = f"field_{candidate}"
582
- if keyword.iskeyword(candidate):
583
- candidate += "_"
584
667
  base = candidate
585
668
  index = 2
586
669
  while candidate in used_fields:
@@ -601,14 +684,6 @@ def _is_nullable(schema: dict[str, Any]) -> bool:
601
684
  return schema_type == "null" or (isinstance(schema_type, list) and "null" in schema_type)
602
685
 
603
686
 
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
687
  def _literal_type(values: list[Any]) -> str:
613
688
  return "_t.Literal[{}]".format(", ".join(repr(value) for value in values))
614
689
 
@@ -646,22 +721,26 @@ def _docstring(description: str, input_schema: dict[str, Any], output_schema: An
646
721
 
647
722
 
648
723
  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()
724
+ originals = [str(name) for name in names]
725
+ groups: dict[str, list[str]] = {}
726
+ for original in originals:
727
+ groups.setdefault(_sanitize_identifier(original), []).append(original)
652
728
  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]}"
729
+ for base, group in groups.items():
730
+ if len(group) == 1:
731
+ result[group[0]] = base
732
+ continue
733
+ for index, original in enumerate(sorted(group), start=1):
734
+ result[original] = base if index == 1 else f"{base}_{index}"
656
735
  return result
657
736
 
658
737
 
659
738
  def _sanitize_identifier(name: str) -> str:
660
- words = [part for part in re.split(r"[^0-9A-Za-z]+", name.strip()) if part]
739
+ words = [word for part in re.split(r"[^0-9A-Za-z]+", name.strip()) for word in _identifier_words(part)]
661
740
  if not words:
662
741
  result = "tool"
663
742
  else:
664
- result = words[0].lower() + "".join(part[:1].upper() + part[1:] for part in words[1:])
743
+ result = "_".join(part.lower() for part in words)
665
744
  result = re.sub(r"\W", "_", result)
666
745
  if result[0].isdigit():
667
746
  result = f"_{result}"
@@ -670,6 +749,73 @@ def _sanitize_identifier(name: str) -> str:
670
749
  return result
671
750
 
672
751
 
752
+ def _identifier_words(part: str) -> list[str]:
753
+ return re.findall(r"[A-Z]+(?=[A-Z][a-z]|\d|$)|[A-Z]?[a-z]+|\d+", part)
754
+
755
+
756
+ def _input_default_suffix(schema: Any) -> str:
757
+ if not isinstance(schema, dict):
758
+ return " = None"
759
+ schema = _resolve_schema_ref(schema, schema)
760
+ if not _is_object_schema(schema):
761
+ return ""
762
+ required = schema.get("required")
763
+ if isinstance(required, list) and any(isinstance(item, str) for item in required):
764
+ return ""
765
+ return " | None = None"
766
+
767
+
768
+ def _annotated_type(type_expr: str, schema: dict[str, Any] | None) -> str:
769
+ if schema is None:
770
+ return type_expr
771
+ constraints = _constraint_args(schema)
772
+ if not constraints:
773
+ return type_expr
774
+ return f"_t.Annotated[{type_expr}, Field({', '.join(constraints)})]"
775
+
776
+
777
+ def _constraint_args(schema: dict[str, Any]) -> list[str]:
778
+ args: list[str] = []
779
+ for schema_key, field_key in (
780
+ ("minimum", "ge"),
781
+ ("maximum", "le"),
782
+ ("exclusiveMinimum", "gt"),
783
+ ("exclusiveMaximum", "lt"),
784
+ ("multipleOf", "multiple_of"),
785
+ ("minLength", "min_length"),
786
+ ("maxLength", "max_length"),
787
+ ("minItems", "min_length"),
788
+ ("maxItems", "max_length"),
789
+ ("minProperties", "min_length"),
790
+ ("maxProperties", "max_length"),
791
+ ("pattern", "pattern"),
792
+ ):
793
+ if schema_key in schema:
794
+ args.append(f"{field_key}={schema[schema_key]!r}")
795
+
796
+ json_schema_extra = {key: schema[key] for key in ("format", "uniqueItems") if key in schema}
797
+ if json_schema_extra:
798
+ args.append(f"json_schema_extra={json_schema_extra!r}")
799
+ return args
800
+
801
+
802
+ def _schema_hash(schema: Any) -> str | None:
803
+ if schema is None:
804
+ return None
805
+ encoded = json.dumps(schema, sort_keys=True, separators=(",", ":"), default=str).encode("utf-8")
806
+ return hashlib.sha256(encoded).hexdigest()
807
+
808
+
809
+ def _file_hash(path: str | Path | None) -> str | None:
810
+ if path is None:
811
+ return None
812
+ try:
813
+ data = Path(path).expanduser().read_bytes()
814
+ except OSError:
815
+ return None
816
+ return hashlib.sha256(data).hexdigest()
817
+
818
+
673
819
  def _class_name(func_name: str) -> str:
674
820
  parts = [part for part in re.split(r"[^0-9A-Za-z]+", str(func_name)) if part]
675
821
  result = "".join(part[:1].upper() + part[1:] for part in parts) or "Tool"
@@ -63,7 +63,6 @@ class BaseSandboxProvider:
63
63
  raise SandboxError("path must be relative and inside the sandbox scratch directory")
64
64
  return relative
65
65
 
66
-
67
66
  class RemoteSandboxProvider(BaseSandboxProvider):
68
67
  guest_workspace = SANDBOX_SDK_ROOT
69
68
  guest_scratch = "/workspace"
@@ -2,6 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ from importlib import import_module
5
6
  import shlex
6
7
  import sys
7
8
  from typing import Any
@@ -215,10 +216,10 @@ def _replace_url_host(url: str, host: str) -> str:
215
216
 
216
217
  def _load_matchlock_sdk() -> tuple[type[Any], type[Any], type[Any]]:
217
218
  try:
218
- from matchlock import Client, Config, Sandbox
219
+ matchlock = import_module("matchlock")
219
220
  except ImportError as exc: # pragma: no cover - depends on optional package availability
220
221
  raise SandboxError(
221
222
  "matchlock provider requires the Matchlock Python SDK; "
222
- "install `maco-sandbox[matchlock]` or `matchlock`"
223
+ "install `mcp-as-code[matchlock]`"
223
224
  ) from exc
224
- return Client, Config, Sandbox
225
+ return matchlock.Client, matchlock.Config, matchlock.Sandbox
@@ -184,8 +184,8 @@ def serve_mcp(options: ServeMcpOptions) -> None:
184
184
  gateway = GatewayInfo(url=gateway_server.url, token=gateway_server.token)
185
185
  tools_by_server = fetch_gateway_tools(gateway.url, token=gateway.token)
186
186
  modules = sorted(server_module_names(tools_by_server.keys()).values())
187
- if options.provider.replace("_", "-").lower() == "local":
188
- _clean_local_sdk(workspace, clean=options.clean)
187
+ if normalized_provider == "local":
188
+ _clean_local_sdk(workspace)
189
189
  stats = generate_sandbox_sdk(tools_by_server, workspace=workspace, clean=False)
190
190
  print(_LOCAL_SDK_GENERATED_TEMPLATE.render(stats=stats).strip())
191
191
  context = SandboxContext(
@@ -389,9 +389,7 @@ def _guest_tools_root(provider: SandboxProvider) -> str:
389
389
  return f"{provider.guest_workspace.rstrip('/')}/tools"
390
390
 
391
391
 
392
- def _clean_local_sdk(workspace: Path, *, clean: bool) -> None:
393
- if not clean:
394
- return
392
+ def _clean_local_sdk(workspace: Path) -> None:
395
393
  for path in [workspace / "tools", workspace / "manifest.json", workspace / "pyproject.toml"]:
396
394
  if path.is_dir():
397
395
  import shutil
@@ -3,7 +3,14 @@ Run Python code in the sandbox. Use this for multi-step MCP work, loops, filteri
3
3
  For most tasks, pass only the code argument. If filename is omitted, maco writes the code to a deterministic content-addressed path like <hash>.py under the writable scratch directory. Use filename only when a readable traceback path or stable named script matters. Use args only when the script explicitly reads command-line arguments.
4
4
 
5
5
  Import generated tools with:
6
- from tools.<server> import <tool>
6
+ import asyncio
7
+ from tools.<server> import <ToolInput>, <tool>
8
+ async def main():
9
+ result = await <tool>(<ToolInput>(<fields>))
10
+ asyncio.run(main())
11
+
12
+ Independent tool calls can run concurrently:
13
+ result_a, result_b = await asyncio.gather(tool_a(input_a), tool_b(input_b))
7
14
 
8
15
  {% include "server_catalog.j2" %}
9
16
 
@@ -6,15 +6,16 @@ import json
6
6
  import os
7
7
  from pathlib import Path
8
8
  from typing import Any
9
- from urllib.error import HTTPError, URLError
10
- from urllib.request import Request, urlopen
9
+
10
+ import httpx
11
+ from pydantic import BaseModel, TypeAdapter
11
12
 
12
13
 
13
14
  _CURRENT_DIR = Path(__file__).resolve().parent
14
15
  _WORKSPACE_DIR = _CURRENT_DIR.parent
15
16
 
16
17
 
17
- def call_mcp_tool(
18
+ async def _call_mcp_tool(
18
19
  server_name: str,
19
20
  tool_name: str,
20
21
  arguments: dict[str, Any] | None = None,
@@ -48,14 +49,16 @@ def call_mcp_tool(
48
49
  if token:
49
50
  headers["Authorization"] = f"Bearer {token}"
50
51
 
51
- request = Request(url, data=payload, headers=headers, method="POST")
52
52
  try:
53
- with urlopen(request, timeout=timeout) as response:
54
- raw = response.read().decode("utf-8")
55
- except HTTPError as exc:
56
- detail = exc.read().decode("utf-8", errors="replace")
57
- raise RuntimeError(f"MCP tool {server_name}.{tool_name} failed: HTTP {exc.code}: {detail}") from exc
58
- except URLError as exc:
53
+ async with httpx.AsyncClient(timeout=timeout) as client:
54
+ response = await client.post(url, content=payload, headers=headers)
55
+ raw = response.text
56
+ response.raise_for_status()
57
+ except httpx.HTTPStatusError as exc:
58
+ raise RuntimeError(
59
+ f"MCP tool {server_name}.{tool_name} failed: HTTP {exc.response.status_code}: {exc.response.text}"
60
+ ) from exc
61
+ except httpx.RequestError as exc:
59
62
  raise RuntimeError(f"failed to connect to maco gateway at {url}: {exc}") from exc
60
63
 
61
64
  result = json.loads(raw)
@@ -101,3 +104,27 @@ def _content_text(result: dict[str, Any]) -> str:
101
104
  else:
102
105
  parts.append(str(block))
103
106
  return "".join(parts)
107
+
108
+
109
+ async def call_mcp_tool(
110
+ server_name: str,
111
+ tool_name: str,
112
+ input_type: Any,
113
+ output_type: Any,
114
+ arguments: Any = None,
115
+ ) -> Any:
116
+ """Validate typed MCP arguments, call the tool, and validate the result."""
117
+
118
+ validated_input = TypeAdapter(input_type).validate_python({} if arguments is None else arguments)
119
+ result = await _call_mcp_tool(server_name, tool_name, _mcp_arguments(validated_input))
120
+ return TypeAdapter(output_type).validate_python(result)
121
+
122
+
123
+ def _mcp_arguments(value: Any) -> dict[str, Any]:
124
+ if value is None:
125
+ return {}
126
+ if isinstance(value, BaseModel):
127
+ return value.model_dump(by_alias=True, exclude_none=True)
128
+ if isinstance(value, dict):
129
+ return value
130
+ raise TypeError(f"MCP tool arguments must validate to an object, got {type(value).__name__}")
@@ -1,5 +1,5 @@
1
1
  class {{ class_name }}(BaseModel):
2
- model_config = ConfigDict(populate_by_name=True, extra='allow')
2
+ model_config = ConfigDict(populate_by_name=True, extra={{ extra_behavior | pyrepr }})
3
3
 
4
4
  {% for field in fields %}
5
5
  {{ field.name }}: {{ field.type_expr }} = {{ field.field_args }}
@@ -3,6 +3,6 @@ name = "maco-generated-workspace"
3
3
  version = "0.0.0"
4
4
  requires-python = ">=3.11"
5
5
  dependencies = [
6
+ "httpx>=0.28",
6
7
  "pydantic>=2.0",
7
8
  ]
8
-
@@ -0,0 +1,12 @@
1
+ # This file is automatically generated by maco. Do not edit.
2
+ {% for export in exports %}
3
+ from .{{ export.function }} import {{ export.function }}, {{ export.input_type }}, {{ export.output_type }}
4
+ {% endfor %}
5
+
6
+ __all__ = [
7
+ {% for export in exports %}
8
+ {{ export.function | pyrepr }},
9
+ {{ export.input_type | pyrepr }},
10
+ {{ export.output_type | pyrepr }},
11
+ {% endfor %}
12
+ ]
@@ -0,0 +1,21 @@
1
+ # This file is automatically generated by maco. Do not edit.
2
+
3
+ from __future__ import annotations
4
+
5
+ import typing as _t
6
+
7
+ from pydantic import BaseModel, ConfigDict, Field
8
+
9
+ from {{ client_module }} import call_mcp_tool
10
+
11
+
12
+ {{ input_type_source }}
13
+
14
+
15
+ {{ output_type_source }}
16
+
17
+
18
+ async def {{ func_name }}(arguments: {{ input_type_expr }}{{ input_default_suffix }}) -> {{ output_type_expr }}:
19
+ """{{ docstring }}"""
20
+
21
+ return await call_mcp_tool({{ server_name | pyrepr }}, {{ tool_name | pyrepr }}, {{ input_type_expr }}, {{ output_type_expr }}, arguments)
@@ -1,7 +1,13 @@
1
1
  Run shell and Python code in a sandbox with generated maco tools.
2
2
 
3
3
  Generated MCP tools are importable as:
4
- from tools.<server> import <tool>
4
+ import asyncio
5
+ from tools.<server> import <ToolInput>, <tool>
6
+ async def main():
7
+ result = await <tool>(<ToolInput>(<fields>))
8
+ asyncio.run(main())
9
+
10
+ Independent calls can run in parallel with asyncio.gather(call_a, call_b).
5
11
 
6
12
  {% include "server_catalog.j2" %}
7
13
 
@@ -1,3 +0,0 @@
1
- class {{ class_name }}(RootModel[{{ type_expr }}]):
2
- pass
3
-
@@ -1,11 +0,0 @@
1
- # This file is automatically generated by maco. Do not edit.
2
- {% for name in exports %}
3
- from .{{ name }} import {{ name }}
4
- {% endfor %}
5
-
6
- __all__ = [
7
- {% for name in exports %}
8
- {{ name | pyrepr }},
9
- {% endfor %}
10
- ]
11
-
@@ -1,38 +0,0 @@
1
- # This file is automatically generated by maco. Do not edit.
2
-
3
- from __future__ import annotations
4
-
5
- import typing as _t
6
-
7
- from pydantic import BaseModel, ConfigDict, Field, RootModel
8
-
9
- from {{ client_module }} import call_mcp_tool
10
-
11
-
12
- SERVER_NAME = {{ server_name | pyrepr }}
13
- TOOL_NAME = {{ tool_name | pyrepr }}
14
- DESCRIPTION = {{ description | pyrepr }}
15
-
16
-
17
- {{ input_type_source }}
18
-
19
-
20
- {{ output_type_source }}
21
-
22
-
23
- def {{ func_name }}(arguments: {{ input_type_expr }} | dict[str, _t.Any] | None = None, **kwargs: _t.Any) -> {{ output_type_expr }}:
24
- """{{ docstring }}"""
25
-
26
- payload: dict[str, _t.Any] = {}
27
- if arguments is not None:
28
- if isinstance(arguments, BaseModel):
29
- payload.update(arguments.model_dump(by_alias=True, exclude_none=True))
30
- else:
31
- payload.update(arguments)
32
- payload.update(kwargs)
33
- {% if input_is_model %}
34
- validated_input = {{ input_type_expr }}.model_validate(payload)
35
- payload = validated_input.model_dump(by_alias=True, exclude_none=True)
36
- {% endif %}
37
- result = call_mcp_tool(SERVER_NAME, TOOL_NAME, payload)
38
- return {{ return_expr }}
File without changes
File without changes
File without changes