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.
- {mcp_as_code-0.1.5 → mcp_as_code-0.1.7}/PKG-INFO +46 -24
- {mcp_as_code-0.1.5 → mcp_as_code-0.1.7}/README.md +43 -22
- {mcp_as_code-0.1.5 → mcp_as_code-0.1.7}/examples/serve-mcp/README.md +1 -1
- {mcp_as_code-0.1.5 → mcp_as_code-0.1.7}/pyproject.toml +5 -1
- {mcp_as_code-0.1.5 → mcp_as_code-0.1.7}/src/maco/__init__.py +1 -1
- {mcp_as_code-0.1.5 → mcp_as_code-0.1.7}/src/maco/codegen.py +226 -80
- {mcp_as_code-0.1.5 → mcp_as_code-0.1.7}/src/maco/sandbox/providers/base.py +0 -1
- {mcp_as_code-0.1.5 → mcp_as_code-0.1.7}/src/maco/sandbox/providers/matchlock.py +4 -3
- {mcp_as_code-0.1.5 → mcp_as_code-0.1.7}/src/maco/serve_mcp.py +3 -5
- {mcp_as_code-0.1.5 → mcp_as_code-0.1.7}/src/maco/templates/code_execute_description.j2 +8 -1
- {mcp_as_code-0.1.5 → mcp_as_code-0.1.7}/src/maco/templates/codegen/client.py.j2 +37 -10
- {mcp_as_code-0.1.5 → mcp_as_code-0.1.7}/src/maco/templates/codegen/model.py.j2 +1 -1
- {mcp_as_code-0.1.5 → mcp_as_code-0.1.7}/src/maco/templates/codegen/pyproject.toml.j2 +1 -1
- mcp_as_code-0.1.7/src/maco/templates/codegen/server_init.py.j2 +12 -0
- mcp_as_code-0.1.7/src/maco/templates/codegen/tool.py.j2 +21 -0
- {mcp_as_code-0.1.5 → mcp_as_code-0.1.7}/src/maco/templates/serve_mcp_instructions.j2 +7 -1
- mcp_as_code-0.1.5/src/maco/templates/codegen/root_model.py.j2 +0 -3
- mcp_as_code-0.1.5/src/maco/templates/codegen/server_init.py.j2 +0 -11
- mcp_as_code-0.1.5/src/maco/templates/codegen/tool.py.j2 +0 -38
- {mcp_as_code-0.1.5 → mcp_as_code-0.1.7}/.gitignore +0 -0
- {mcp_as_code-0.1.5 → mcp_as_code-0.1.7}/LICENSE +0 -0
- {mcp_as_code-0.1.5 → mcp_as_code-0.1.7}/docs/mcp-config.md +0 -0
- {mcp_as_code-0.1.5 → mcp_as_code-0.1.7}/images/sandbox/README.md +0 -0
- {mcp_as_code-0.1.5 → mcp_as_code-0.1.7}/src/maco/cli.py +0 -0
- {mcp_as_code-0.1.5 → mcp_as_code-0.1.7}/src/maco/config.py +0 -0
- {mcp_as_code-0.1.5 → mcp_as_code-0.1.7}/src/maco/gateway.py +0 -0
- {mcp_as_code-0.1.5 → mcp_as_code-0.1.7}/src/maco/mcp_manager.py +0 -0
- {mcp_as_code-0.1.5 → mcp_as_code-0.1.7}/src/maco/oauth.py +0 -0
- {mcp_as_code-0.1.5 → mcp_as_code-0.1.7}/src/maco/runner.py +0 -0
- {mcp_as_code-0.1.5 → mcp_as_code-0.1.7}/src/maco/sandbox/__init__.py +0 -0
- {mcp_as_code-0.1.5 → mcp_as_code-0.1.7}/src/maco/sandbox/core.py +0 -0
- {mcp_as_code-0.1.5 → mcp_as_code-0.1.7}/src/maco/sandbox/providers/__init__.py +0 -0
- {mcp_as_code-0.1.5 → mcp_as_code-0.1.7}/src/maco/sandbox/providers/docker.py +0 -0
- {mcp_as_code-0.1.5 → mcp_as_code-0.1.7}/src/maco/sandbox/providers/local.py +0 -0
- {mcp_as_code-0.1.5 → mcp_as_code-0.1.7}/src/maco/service.py +0 -0
- {mcp_as_code-0.1.5 → mcp_as_code-0.1.7}/src/maco/templates/bash_description.j2 +0 -0
- {mcp_as_code-0.1.5 → mcp_as_code-0.1.7}/src/maco/templates/codegen/package_init.py.j2 +0 -0
- {mcp_as_code-0.1.5 → mcp_as_code-0.1.7}/src/maco/templates/codegen/type_alias.py.j2 +0 -0
- {mcp_as_code-0.1.5 → mcp_as_code-0.1.7}/src/maco/templates/server_catalog.j2 +0 -0
- {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.
|
|
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
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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`:
|
|
145
|
-
- `docker`: runs
|
|
146
|
-
- `matchlock`: runs
|
|
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
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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`:
|
|
130
|
-
- `docker`: runs
|
|
131
|
-
- `matchlock`: runs
|
|
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
|
|
|
@@ -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":
|
|
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[
|
|
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(
|
|
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
|
-
) ->
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
580
|
-
if
|
|
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 =
|
|
650
|
-
|
|
651
|
-
|
|
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
|
|
654
|
-
|
|
655
|
-
|
|
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 = [
|
|
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 =
|
|
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
|
-
|
|
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 `
|
|
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
|
|
188
|
-
_clean_local_sdk(workspace
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
10
|
-
|
|
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
|
|
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
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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=
|
|
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 }}
|
|
@@ -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
|
-
|
|
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,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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|