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/serve_mcp.py
ADDED
|
@@ -0,0 +1,527 @@
|
|
|
1
|
+
"""HTTP MCP server for running maco code inside a sandbox provider."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
import signal
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
import threading
|
|
13
|
+
from types import FrameType
|
|
14
|
+
from typing import Annotated, Any
|
|
15
|
+
|
|
16
|
+
from jinja2 import Environment, PackageLoader, StrictUndefined
|
|
17
|
+
from mcp.server.fastmcp import FastMCP
|
|
18
|
+
from pydantic import Field
|
|
19
|
+
|
|
20
|
+
from .codegen import fetch_gateway_tools, generate_sandbox_sdk, server_module_names
|
|
21
|
+
from .config import load_config
|
|
22
|
+
from .gateway import GatewayServer, ServeOptions
|
|
23
|
+
from .sandbox import (
|
|
24
|
+
DEFAULT_MATCHLOCK_GATEWAY_IP,
|
|
25
|
+
GatewayInfo,
|
|
26
|
+
SandboxContext,
|
|
27
|
+
SandboxExec,
|
|
28
|
+
SandboxProvider,
|
|
29
|
+
SandboxRunResult,
|
|
30
|
+
provider_from_name,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
_TEMPLATES = Environment(
|
|
35
|
+
loader=PackageLoader("maco", "templates"),
|
|
36
|
+
trim_blocks=True,
|
|
37
|
+
lstrip_blocks=True,
|
|
38
|
+
undefined=StrictUndefined,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True)
|
|
43
|
+
class ServeMcpOptions:
|
|
44
|
+
"""Configuration for the sandbox-backed MCP server."""
|
|
45
|
+
|
|
46
|
+
config: str | Path = "mcp.json"
|
|
47
|
+
provider: str = "local"
|
|
48
|
+
workspace: str | Path = ".maco"
|
|
49
|
+
clean: bool = False
|
|
50
|
+
scratch: str | Path | None = None
|
|
51
|
+
gateway_file: str | Path | None = None
|
|
52
|
+
gateway_host: str | None = None
|
|
53
|
+
gateway_port: int = 0
|
|
54
|
+
gateway_token: str | None = None
|
|
55
|
+
gateway_use_token: bool = True
|
|
56
|
+
host: str = "127.0.0.1"
|
|
57
|
+
port: int = 8789
|
|
58
|
+
timeout: int = 60
|
|
59
|
+
debug: bool = False
|
|
60
|
+
image: str | None = None
|
|
61
|
+
python_command: str | None = None
|
|
62
|
+
docker_binary: str = "docker"
|
|
63
|
+
docker_network: str | None = None
|
|
64
|
+
docker_gateway_host: str = "host.docker.internal"
|
|
65
|
+
docker_gateway_ip: str | None = None
|
|
66
|
+
matchlock_binary: str = "matchlock"
|
|
67
|
+
matchlock_gateway_host: str = "maco-gateway.internal"
|
|
68
|
+
matchlock_gateway_ip: str | None = None
|
|
69
|
+
matchlock_allow_host: tuple[str, ...] = ()
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class _ServeMcpShutdown(KeyboardInterrupt):
|
|
73
|
+
"""Raised from signal handlers so provider cleanup can run."""
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _install_shutdown_signal_handlers() -> tuple[tuple[int, Any], ...]:
|
|
77
|
+
if threading.current_thread() is not threading.main_thread():
|
|
78
|
+
return ()
|
|
79
|
+
|
|
80
|
+
def _request_shutdown(signum: int, _frame: FrameType | None) -> None:
|
|
81
|
+
print(f"\nreceived signal {signum}; stopping maco serve-mcp", file=sys.stderr)
|
|
82
|
+
raise _ServeMcpShutdown
|
|
83
|
+
|
|
84
|
+
return (
|
|
85
|
+
(signal.SIGINT, signal.signal(signal.SIGINT, _request_shutdown)),
|
|
86
|
+
(signal.SIGTERM, signal.signal(signal.SIGTERM, _request_shutdown)),
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _restore_signal_handlers(handlers: tuple[tuple[int, Any], ...]) -> None:
|
|
91
|
+
if threading.current_thread() is not threading.main_thread():
|
|
92
|
+
return
|
|
93
|
+
for signum, handler in handlers:
|
|
94
|
+
signal.signal(signum, handler)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def serve_mcp(options: ServeMcpOptions) -> None:
|
|
98
|
+
"""Run a streamable HTTP MCP server exposing sandboxed bash/code tools."""
|
|
99
|
+
|
|
100
|
+
gateway_server: GatewayServer | None = None
|
|
101
|
+
provider: SandboxProvider | None = None
|
|
102
|
+
old_signal_handlers = _install_shutdown_signal_handlers()
|
|
103
|
+
try:
|
|
104
|
+
workspace = Path(options.workspace).expanduser().resolve()
|
|
105
|
+
scratch = (
|
|
106
|
+
Path(options.scratch).expanduser().resolve()
|
|
107
|
+
if options.scratch is not None
|
|
108
|
+
else workspace.parent / "maco-serve-mcp"
|
|
109
|
+
)
|
|
110
|
+
gateway_file = (
|
|
111
|
+
Path(options.gateway_file).expanduser().resolve()
|
|
112
|
+
if options.gateway_file is not None
|
|
113
|
+
else workspace / "gateway.json"
|
|
114
|
+
)
|
|
115
|
+
normalized_provider = _normalize_provider(options.provider)
|
|
116
|
+
managed_gateway = options.gateway_file is None
|
|
117
|
+
docker_gateway_ip = (
|
|
118
|
+
_docker_gateway_ip(
|
|
119
|
+
options.docker_gateway_ip,
|
|
120
|
+
managed_gateway=managed_gateway,
|
|
121
|
+
docker_binary=options.docker_binary,
|
|
122
|
+
docker_network=options.docker_network,
|
|
123
|
+
)
|
|
124
|
+
if normalized_provider == "docker"
|
|
125
|
+
else None
|
|
126
|
+
)
|
|
127
|
+
matchlock_gateway_ip = (
|
|
128
|
+
_matchlock_gateway_ip(
|
|
129
|
+
options.matchlock_gateway_ip,
|
|
130
|
+
managed_gateway=managed_gateway,
|
|
131
|
+
gateway_file=gateway_file,
|
|
132
|
+
)
|
|
133
|
+
if normalized_provider == "matchlock"
|
|
134
|
+
else None
|
|
135
|
+
)
|
|
136
|
+
if options.gateway_file is None:
|
|
137
|
+
config = load_config(options.config)
|
|
138
|
+
gateway_host = options.gateway_host or _default_gateway_host()
|
|
139
|
+
extra_hosts = _gateway_extra_hosts(
|
|
140
|
+
normalized_provider,
|
|
141
|
+
docker_gateway_ip=docker_gateway_ip,
|
|
142
|
+
matchlock_gateway_ip=matchlock_gateway_ip,
|
|
143
|
+
explicit_gateway_host=options.gateway_host,
|
|
144
|
+
)
|
|
145
|
+
gateway_server = GatewayServer(
|
|
146
|
+
config,
|
|
147
|
+
ServeOptions(
|
|
148
|
+
host=gateway_host,
|
|
149
|
+
port=options.gateway_port,
|
|
150
|
+
workspace=workspace,
|
|
151
|
+
token=options.gateway_token,
|
|
152
|
+
use_token=options.gateway_use_token,
|
|
153
|
+
extra_hosts=extra_hosts,
|
|
154
|
+
freebind_hosts=_gateway_freebind_hosts(
|
|
155
|
+
gateway_host,
|
|
156
|
+
extra_hosts=extra_hosts,
|
|
157
|
+
docker_gateway_ip=docker_gateway_ip,
|
|
158
|
+
matchlock_gateway_ip=matchlock_gateway_ip,
|
|
159
|
+
),
|
|
160
|
+
),
|
|
161
|
+
).start()
|
|
162
|
+
gateway_file = gateway_server.gateway_file
|
|
163
|
+
print("maco gateway started")
|
|
164
|
+
print(f" URL: {gateway_server.url}")
|
|
165
|
+
for url in gateway_server.extra_urls:
|
|
166
|
+
print(f" extra URL: {url}")
|
|
167
|
+
print(f" gateway file: {gateway_file}")
|
|
168
|
+
gateway = GatewayInfo.from_file(gateway_file)
|
|
169
|
+
tools_by_server = fetch_gateway_tools(gateway.url, token=gateway.token)
|
|
170
|
+
modules = sorted(server_module_names(tools_by_server.keys()).values())
|
|
171
|
+
if options.provider.replace("_", "-").lower() == "local":
|
|
172
|
+
_clean_local_sdk(workspace, clean=options.clean)
|
|
173
|
+
stats = generate_sandbox_sdk(tools_by_server, workspace=workspace, clean=False)
|
|
174
|
+
print(f"Generated local sandbox SDK with {stats.tool_count} tools from {stats.server_count} servers")
|
|
175
|
+
print(f"SDK workspace: {stats.workspace}")
|
|
176
|
+
context = SandboxContext(
|
|
177
|
+
workspace=workspace,
|
|
178
|
+
scratch=scratch,
|
|
179
|
+
gateway=gateway,
|
|
180
|
+
timeout=options.timeout,
|
|
181
|
+
python_command=options.python_command,
|
|
182
|
+
debug=options.debug,
|
|
183
|
+
)
|
|
184
|
+
provider = provider_from_name(
|
|
185
|
+
options.provider,
|
|
186
|
+
context,
|
|
187
|
+
image=options.image,
|
|
188
|
+
docker_binary=options.docker_binary,
|
|
189
|
+
docker_network=options.docker_network,
|
|
190
|
+
docker_gateway_host=options.docker_gateway_host,
|
|
191
|
+
docker_gateway_ip=docker_gateway_ip,
|
|
192
|
+
matchlock_binary=options.matchlock_binary,
|
|
193
|
+
matchlock_gateway_host=options.matchlock_gateway_host,
|
|
194
|
+
matchlock_gateway_ip=matchlock_gateway_ip,
|
|
195
|
+
matchlock_extra_allow_hosts=list(options.matchlock_allow_host),
|
|
196
|
+
)
|
|
197
|
+
provider.start()
|
|
198
|
+
app = create_serve_mcp_app(provider, context, server_modules=modules, host=options.host, port=options.port)
|
|
199
|
+
print("maco serve-mcp started")
|
|
200
|
+
print(f" URL: http://{options.host}:{options.port}/mcp")
|
|
201
|
+
print(f" provider: {options.provider}")
|
|
202
|
+
print(f" SDK: {provider.guest_workspace}")
|
|
203
|
+
print(f" scratch: {scratch}")
|
|
204
|
+
app.run("streamable-http")
|
|
205
|
+
except _ServeMcpShutdown:
|
|
206
|
+
pass
|
|
207
|
+
finally:
|
|
208
|
+
_restore_signal_handlers(old_signal_handlers)
|
|
209
|
+
try:
|
|
210
|
+
if provider is not None:
|
|
211
|
+
provider.stop()
|
|
212
|
+
finally:
|
|
213
|
+
if gateway_server is not None:
|
|
214
|
+
gateway_server.stop()
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def create_serve_mcp_app(
|
|
218
|
+
provider: SandboxProvider,
|
|
219
|
+
context: SandboxContext,
|
|
220
|
+
*,
|
|
221
|
+
server_modules: list[str] | None = None,
|
|
222
|
+
host: str = "127.0.0.1",
|
|
223
|
+
port: int = 8789,
|
|
224
|
+
) -> FastMCP:
|
|
225
|
+
"""Create the MCP app used by ``serve_mcp``.
|
|
226
|
+
|
|
227
|
+
Separated for tests and future embedding. Tools intentionally return plain
|
|
228
|
+
JSON-serializable dictionaries so MCP clients can inspect exit status,
|
|
229
|
+
stdout, and stderr.
|
|
230
|
+
"""
|
|
231
|
+
|
|
232
|
+
app = FastMCP(
|
|
233
|
+
"maco-serve-mcp",
|
|
234
|
+
instructions=_mcp_instructions(provider, context, server_modules=server_modules),
|
|
235
|
+
host=host,
|
|
236
|
+
port=port,
|
|
237
|
+
json_response=True,
|
|
238
|
+
stateless_http=True,
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
@app.tool(description=_bash_description(provider, context, server_modules=server_modules))
|
|
242
|
+
def bash(
|
|
243
|
+
command: Annotated[
|
|
244
|
+
str,
|
|
245
|
+
Field(description="Non-interactive shell command to run in the sandbox scratch directory."),
|
|
246
|
+
],
|
|
247
|
+
timeout: Annotated[
|
|
248
|
+
int | None,
|
|
249
|
+
Field(description="Optional command timeout in seconds. Omit to use the server default."),
|
|
250
|
+
] = None,
|
|
251
|
+
) -> dict[str, Any]:
|
|
252
|
+
result = provider.run(SandboxExec(command=command, timeout=timeout))
|
|
253
|
+
return _result_payload(result)
|
|
254
|
+
|
|
255
|
+
@app.tool(description=_code_execute_description(provider, context, server_modules=server_modules))
|
|
256
|
+
def code_execute(
|
|
257
|
+
code: Annotated[
|
|
258
|
+
str,
|
|
259
|
+
Field(
|
|
260
|
+
description=(
|
|
261
|
+
"Python source code to write into the sandbox scratch directory and execute. "
|
|
262
|
+
"Import generated tools from tools.<server>."
|
|
263
|
+
)
|
|
264
|
+
),
|
|
265
|
+
],
|
|
266
|
+
filename: Annotated[
|
|
267
|
+
str | None,
|
|
268
|
+
Field(
|
|
269
|
+
description=(
|
|
270
|
+
"Optional relative .py path in scratch. Leave null to use <hash>.py."
|
|
271
|
+
)
|
|
272
|
+
),
|
|
273
|
+
] = None,
|
|
274
|
+
args: Annotated[
|
|
275
|
+
list[str] | None,
|
|
276
|
+
Field(
|
|
277
|
+
description=(
|
|
278
|
+
"Optional command-line arguments passed as sys.argv[1:]. "
|
|
279
|
+
"Leave null for no arguments."
|
|
280
|
+
)
|
|
281
|
+
),
|
|
282
|
+
] = None,
|
|
283
|
+
timeout: Annotated[
|
|
284
|
+
int | None,
|
|
285
|
+
Field(description="Optional script timeout in seconds. Omit to use the server default."),
|
|
286
|
+
] = None,
|
|
287
|
+
) -> dict[str, Any]:
|
|
288
|
+
filename = filename if filename is not None else _content_addressed_script_filename(code)
|
|
289
|
+
guest_script = provider.write_file(filename, code)
|
|
290
|
+
command = provider.python_script_command(guest_script, args or [])
|
|
291
|
+
result = provider.run(SandboxExec(command=command, timeout=timeout))
|
|
292
|
+
payload = _result_payload(result)
|
|
293
|
+
payload["script"] = guest_script
|
|
294
|
+
payload["guest_script"] = guest_script
|
|
295
|
+
return payload
|
|
296
|
+
|
|
297
|
+
return app
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _mcp_instructions(
|
|
301
|
+
provider: SandboxProvider,
|
|
302
|
+
context: SandboxContext,
|
|
303
|
+
*,
|
|
304
|
+
server_modules: list[str] | None = None,
|
|
305
|
+
) -> str:
|
|
306
|
+
return _render_model_text("serve_mcp_instructions.j2", provider, context, server_modules=server_modules)
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _bash_description(
|
|
310
|
+
provider: SandboxProvider,
|
|
311
|
+
context: SandboxContext,
|
|
312
|
+
*,
|
|
313
|
+
server_modules: list[str] | None = None,
|
|
314
|
+
) -> str:
|
|
315
|
+
return _render_model_text("bash_description.j2", provider, context, server_modules=server_modules)
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _code_execute_description(
|
|
319
|
+
provider: SandboxProvider,
|
|
320
|
+
context: SandboxContext,
|
|
321
|
+
*,
|
|
322
|
+
server_modules: list[str] | None = None,
|
|
323
|
+
) -> str:
|
|
324
|
+
return _render_model_text("code_execute_description.j2", provider, context, server_modules=server_modules)
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _render_model_text(
|
|
328
|
+
template_name: str,
|
|
329
|
+
provider: SandboxProvider,
|
|
330
|
+
context: SandboxContext,
|
|
331
|
+
*,
|
|
332
|
+
server_modules: list[str] | None = None,
|
|
333
|
+
) -> str:
|
|
334
|
+
wrapper_root = _guest_tools_root(provider)
|
|
335
|
+
return _TEMPLATES.get_template(template_name).render(
|
|
336
|
+
server_modules=server_modules if server_modules is not None else _server_modules(context.workspace),
|
|
337
|
+
wrapper_root=wrapper_root,
|
|
338
|
+
).strip()
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _server_modules(workspace: Path) -> list[str]:
|
|
342
|
+
server_root = workspace / "tools"
|
|
343
|
+
return sorted(
|
|
344
|
+
path.name
|
|
345
|
+
for path in server_root.iterdir()
|
|
346
|
+
if path.is_dir() and (path / "__init__.py").exists()
|
|
347
|
+
) if server_root.exists() else []
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _guest_tools_root(provider: SandboxProvider) -> str:
|
|
351
|
+
return f"{provider.guest_workspace.rstrip('/')}/tools"
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _clean_local_sdk(workspace: Path, *, clean: bool) -> None:
|
|
355
|
+
if not clean:
|
|
356
|
+
return
|
|
357
|
+
for path in [workspace / "tools", workspace / "manifest.json", workspace / "pyproject.toml"]:
|
|
358
|
+
if path.is_dir():
|
|
359
|
+
import shutil
|
|
360
|
+
|
|
361
|
+
shutil.rmtree(path)
|
|
362
|
+
elif path.exists():
|
|
363
|
+
path.unlink()
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _content_addressed_script_filename(code: str) -> str:
|
|
367
|
+
digest = hashlib.sha256(code.encode("utf-8")).hexdigest()[:16]
|
|
368
|
+
return f"{digest}.py"
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def _default_gateway_host() -> str:
|
|
372
|
+
return "127.0.0.1"
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def _normalize_provider(provider: str) -> str:
|
|
376
|
+
return provider.replace("_", "-").lower()
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def _matchlock_gateway_ip(
|
|
380
|
+
configured_ip: str | None,
|
|
381
|
+
*,
|
|
382
|
+
managed_gateway: bool,
|
|
383
|
+
gateway_file: Path,
|
|
384
|
+
) -> str | None:
|
|
385
|
+
if configured_ip:
|
|
386
|
+
return configured_ip
|
|
387
|
+
if managed_gateway:
|
|
388
|
+
return DEFAULT_MATCHLOCK_GATEWAY_IP
|
|
389
|
+
try:
|
|
390
|
+
gateway = GatewayInfo.from_file(gateway_file)
|
|
391
|
+
except Exception:
|
|
392
|
+
return None
|
|
393
|
+
host = _url_host(gateway.url)
|
|
394
|
+
if host == DEFAULT_MATCHLOCK_GATEWAY_IP:
|
|
395
|
+
return DEFAULT_MATCHLOCK_GATEWAY_IP
|
|
396
|
+
return None
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def _docker_gateway_ip(
|
|
400
|
+
configured_ip: str | None,
|
|
401
|
+
*,
|
|
402
|
+
managed_gateway: bool,
|
|
403
|
+
docker_binary: str,
|
|
404
|
+
docker_network: str | None,
|
|
405
|
+
) -> str | None:
|
|
406
|
+
if configured_ip:
|
|
407
|
+
return configured_ip
|
|
408
|
+
if not managed_gateway:
|
|
409
|
+
return None
|
|
410
|
+
if _is_docker_desktop(docker_binary):
|
|
411
|
+
return None
|
|
412
|
+
detected_ip = _detect_docker_gateway_ip(docker_binary, docker_network)
|
|
413
|
+
if detected_ip:
|
|
414
|
+
return detected_ip
|
|
415
|
+
network = docker_network or "bridge"
|
|
416
|
+
raise ValueError(
|
|
417
|
+
f"could not detect Docker gateway IP for network {network!r}; "
|
|
418
|
+
"pass --docker-gateway-ip explicitly"
|
|
419
|
+
)
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def _is_docker_desktop(docker_binary: str) -> bool:
|
|
423
|
+
if not sys.platform.startswith("linux"):
|
|
424
|
+
return True
|
|
425
|
+
try:
|
|
426
|
+
completed = subprocess.run(
|
|
427
|
+
[docker_binary, "info", "--format", "{{.OperatingSystem}}"],
|
|
428
|
+
text=True,
|
|
429
|
+
stdout=subprocess.PIPE,
|
|
430
|
+
stderr=subprocess.DEVNULL,
|
|
431
|
+
timeout=5,
|
|
432
|
+
check=False,
|
|
433
|
+
)
|
|
434
|
+
except Exception:
|
|
435
|
+
return False
|
|
436
|
+
if completed.returncode != 0:
|
|
437
|
+
return False
|
|
438
|
+
return "docker desktop" in completed.stdout.strip().lower()
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def _detect_docker_gateway_ip(docker_binary: str, docker_network: str | None) -> str | None:
|
|
442
|
+
network = docker_network or "bridge"
|
|
443
|
+
try:
|
|
444
|
+
completed = subprocess.run(
|
|
445
|
+
[docker_binary, "network", "inspect", network],
|
|
446
|
+
text=True,
|
|
447
|
+
stdout=subprocess.PIPE,
|
|
448
|
+
stderr=subprocess.DEVNULL,
|
|
449
|
+
timeout=5,
|
|
450
|
+
check=False,
|
|
451
|
+
)
|
|
452
|
+
except Exception:
|
|
453
|
+
return None
|
|
454
|
+
if completed.returncode != 0:
|
|
455
|
+
return None
|
|
456
|
+
try:
|
|
457
|
+
networks = json.loads(completed.stdout)
|
|
458
|
+
except json.JSONDecodeError:
|
|
459
|
+
return None
|
|
460
|
+
if not isinstance(networks, list) or not networks or not isinstance(networks[0], dict):
|
|
461
|
+
return None
|
|
462
|
+
ipam = networks[0].get("IPAM")
|
|
463
|
+
configs = ipam.get("Config") if isinstance(ipam, dict) else None
|
|
464
|
+
if not isinstance(configs, list):
|
|
465
|
+
return None
|
|
466
|
+
for config in configs:
|
|
467
|
+
if not isinstance(config, dict):
|
|
468
|
+
continue
|
|
469
|
+
gateway = config.get("Gateway")
|
|
470
|
+
if isinstance(gateway, str) and gateway and "." in gateway:
|
|
471
|
+
return gateway
|
|
472
|
+
return None
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def _gateway_extra_hosts(
|
|
476
|
+
provider: str,
|
|
477
|
+
*,
|
|
478
|
+
docker_gateway_ip: str | None,
|
|
479
|
+
matchlock_gateway_ip: str | None,
|
|
480
|
+
explicit_gateway_host: str | None,
|
|
481
|
+
) -> tuple[str, ...]:
|
|
482
|
+
gateway_ip = _provider_gateway_ip(provider, docker_gateway_ip, matchlock_gateway_ip)
|
|
483
|
+
if not gateway_ip:
|
|
484
|
+
return ()
|
|
485
|
+
if explicit_gateway_host and explicit_gateway_host not in {"127.0.0.1", "localhost", "::1"}:
|
|
486
|
+
return ()
|
|
487
|
+
return (gateway_ip,)
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def _gateway_freebind_hosts(
|
|
491
|
+
gateway_host: str,
|
|
492
|
+
*,
|
|
493
|
+
extra_hosts: tuple[str, ...],
|
|
494
|
+
docker_gateway_ip: str | None,
|
|
495
|
+
matchlock_gateway_ip: str | None,
|
|
496
|
+
) -> tuple[str, ...]:
|
|
497
|
+
hosts = list(extra_hosts)
|
|
498
|
+
if gateway_host in {docker_gateway_ip, matchlock_gateway_ip}:
|
|
499
|
+
hosts.append(gateway_host)
|
|
500
|
+
return tuple(dict.fromkeys(hosts))
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def _provider_gateway_ip(
|
|
504
|
+
provider: str,
|
|
505
|
+
docker_gateway_ip: str | None,
|
|
506
|
+
matchlock_gateway_ip: str | None,
|
|
507
|
+
) -> str | None:
|
|
508
|
+
if provider == "docker":
|
|
509
|
+
return docker_gateway_ip
|
|
510
|
+
if provider == "matchlock":
|
|
511
|
+
return matchlock_gateway_ip
|
|
512
|
+
return None
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
def _url_host(url: str) -> str | None:
|
|
516
|
+
from urllib.parse import urlsplit
|
|
517
|
+
|
|
518
|
+
return urlsplit(url).hostname
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
def _result_payload(result: SandboxRunResult) -> dict[str, Any]:
|
|
522
|
+
return {
|
|
523
|
+
"ok": result.ok,
|
|
524
|
+
"exit_code": result.exit_code,
|
|
525
|
+
"stdout": result.stdout,
|
|
526
|
+
"stderr": result.stderr,
|
|
527
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
Run a non-interactive shell command in the sandbox.
|
|
2
|
+
|
|
3
|
+
Use this to inspect generated tool files, search for available tool exports, or run small shell/Python probes before using code_execute.
|
|
4
|
+
|
|
5
|
+
Useful discovery commands:
|
|
6
|
+
rg --files {{ wrapper_root }}
|
|
7
|
+
fd . {{ wrapper_root }} -t f
|
|
8
|
+
rg "^def " {{ wrapper_root }}/<server>
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
Run Python code in the sandbox. Use this for multi-step MCP work, loops, filtering, joins, or structured output.
|
|
2
|
+
|
|
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
|
+
|
|
5
|
+
Import generated tools with:
|
|
6
|
+
from tools.<server> import <tool>
|
|
7
|
+
|
|
8
|
+
{% include "server_catalog.j2" %}
|
|
9
|
+
|
|
10
|
+
Inspect wrapper code at:
|
|
11
|
+
{{ wrapper_root }}/<server>/__init__.py
|
|
12
|
+
{{ wrapper_root }}/<server>/<tool>.py
|
|
13
|
+
|
|
14
|
+
Use the bash tool with rg/fd for discovery before writing code when needed.
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# This file is automatically generated by maco. Do not edit.
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
from urllib.error import HTTPError, URLError
|
|
10
|
+
from urllib.request import Request, urlopen
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
_CURRENT_DIR = Path(__file__).resolve().parent
|
|
14
|
+
_WORKSPACE_DIR = _CURRENT_DIR.parent
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def call_mcp_tool(
|
|
18
|
+
server_name: str,
|
|
19
|
+
tool_name: str,
|
|
20
|
+
arguments: dict[str, Any] | None = None,
|
|
21
|
+
*,
|
|
22
|
+
timeout: float | None = 60.0,
|
|
23
|
+
) -> Any:
|
|
24
|
+
"""Call an MCP tool through a running maco gateway.
|
|
25
|
+
|
|
26
|
+
The gateway is discovered from MACO_GATEWAY_URL or from gateway.json in the
|
|
27
|
+
generated workspace. Structured MCP output is returned directly when
|
|
28
|
+
available; otherwise text content is returned, with a best-effort JSON parse.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
gateway = _load_gateway()
|
|
32
|
+
url = gateway.get("url")
|
|
33
|
+
if not url:
|
|
34
|
+
raise RuntimeError(
|
|
35
|
+
"maco gateway URL is not configured. Start `maco serve` and run code with `maco run`, "
|
|
36
|
+
"or set MACO_GATEWAY_URL."
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
payload = json.dumps(
|
|
40
|
+
{
|
|
41
|
+
"server": server_name,
|
|
42
|
+
"tool": tool_name,
|
|
43
|
+
"arguments": arguments or {},
|
|
44
|
+
}
|
|
45
|
+
).encode("utf-8")
|
|
46
|
+
headers = {"Content-Type": "application/json"}
|
|
47
|
+
token = os.environ.get("MACO_GATEWAY_TOKEN") or gateway.get("token")
|
|
48
|
+
if token:
|
|
49
|
+
headers["Authorization"] = f"Bearer {token}"
|
|
50
|
+
|
|
51
|
+
request = Request(url, data=payload, headers=headers, method="POST")
|
|
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:
|
|
59
|
+
raise RuntimeError(f"failed to connect to maco gateway at {url}: {exc}") from exc
|
|
60
|
+
|
|
61
|
+
result = json.loads(raw)
|
|
62
|
+
if result.get("isError"):
|
|
63
|
+
raise RuntimeError(f"MCP tool {server_name}.{tool_name} returned an error: {_content_text(result)}")
|
|
64
|
+
if "structuredContent" in result:
|
|
65
|
+
return result["structuredContent"]
|
|
66
|
+
text = _content_text(result)
|
|
67
|
+
try:
|
|
68
|
+
return json.loads(text)
|
|
69
|
+
except Exception:
|
|
70
|
+
return text
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _load_gateway() -> dict[str, Any]:
|
|
74
|
+
gateway: dict[str, Any] = {}
|
|
75
|
+
gateway_file = os.environ.get("MACO_GATEWAY_FILE")
|
|
76
|
+
if gateway_file:
|
|
77
|
+
gateway.update(_read_json(Path(gateway_file)))
|
|
78
|
+
else:
|
|
79
|
+
gateway.update(_read_json(_WORKSPACE_DIR / "gateway.json"))
|
|
80
|
+
if os.environ.get("MACO_GATEWAY_URL"):
|
|
81
|
+
gateway["url"] = os.environ["MACO_GATEWAY_URL"]
|
|
82
|
+
if os.environ.get("MACO_GATEWAY_TOKEN"):
|
|
83
|
+
gateway["token"] = os.environ["MACO_GATEWAY_TOKEN"]
|
|
84
|
+
return gateway
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _read_json(path: Path) -> dict[str, Any]:
|
|
88
|
+
try:
|
|
89
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
90
|
+
except FileNotFoundError:
|
|
91
|
+
return {}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _content_text(result: dict[str, Any]) -> str:
|
|
95
|
+
parts: list[str] = []
|
|
96
|
+
for block in result.get("content") or []:
|
|
97
|
+
if isinstance(block, dict) and block.get("type") == "text":
|
|
98
|
+
parts.append(str(block.get("text", "")))
|
|
99
|
+
elif isinstance(block, dict):
|
|
100
|
+
parts.append(json.dumps(block, sort_keys=True))
|
|
101
|
+
else:
|
|
102
|
+
parts.append(str(block))
|
|
103
|
+
return "".join(parts)
|
|
104
|
+
|