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/config.py
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""Configuration loading for maco."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ConfigError(ValueError):
|
|
13
|
+
"""Raised when an MCP configuration file is invalid."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class ServerConfig:
|
|
18
|
+
"""Configuration for one MCP server."""
|
|
19
|
+
|
|
20
|
+
name: str
|
|
21
|
+
server_type: str = "stdio"
|
|
22
|
+
command: str | None = None
|
|
23
|
+
args: list[str] = field(default_factory=list)
|
|
24
|
+
env: dict[str, str] = field(default_factory=dict)
|
|
25
|
+
cwd: str | None = None
|
|
26
|
+
base_url: str | None = None
|
|
27
|
+
headers: dict[str, str] = field(default_factory=dict)
|
|
28
|
+
tool_white_list: list[str] = field(default_factory=list)
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def is_stdio(self) -> bool:
|
|
32
|
+
return self.server_type == "stdio"
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def is_streamable_http(self) -> bool:
|
|
36
|
+
return self.server_type in {"http", "streamable_http", "streamable-http"}
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def is_sse(self) -> bool:
|
|
40
|
+
return self.server_type == "sse"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class MacoConfig:
|
|
45
|
+
"""Top-level maco configuration."""
|
|
46
|
+
|
|
47
|
+
path: Path
|
|
48
|
+
servers: dict[str, ServerConfig]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def load_config(path: str | os.PathLike[str] = "mcp.json") -> MacoConfig:
|
|
52
|
+
"""Load a Claude-style MCP config file.
|
|
53
|
+
|
|
54
|
+
The expected shape is ``{"mcpServers": {"name": {...}}}``.
|
|
55
|
+
Values in ``env`` and string fields are expanded with the environment of the
|
|
56
|
+
process running ``maco``; for example ``"$GITHUB_TOKEN"`` or
|
|
57
|
+
``"${GITHUB_TOKEN}"`` becomes the current value of that variable.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
config_path = Path(path).expanduser()
|
|
61
|
+
if not config_path.exists():
|
|
62
|
+
raise ConfigError(f"configuration file not found: {config_path}")
|
|
63
|
+
|
|
64
|
+
data = _read_mapping(config_path)
|
|
65
|
+
servers_data = _extract_servers(data)
|
|
66
|
+
servers: dict[str, ServerConfig] = {}
|
|
67
|
+
for name, raw_server in servers_data.items():
|
|
68
|
+
if not isinstance(raw_server, dict):
|
|
69
|
+
raise ConfigError(f"server {name!r} must be an object")
|
|
70
|
+
servers[name] = _parse_server(name, raw_server)
|
|
71
|
+
|
|
72
|
+
if not servers:
|
|
73
|
+
raise ConfigError(f"no MCP servers configured in {config_path}")
|
|
74
|
+
return MacoConfig(path=config_path.resolve(), servers=servers)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _read_mapping(path: Path) -> dict[str, Any]:
|
|
78
|
+
text = path.read_text(encoding="utf-8")
|
|
79
|
+
try:
|
|
80
|
+
data = json.loads(text)
|
|
81
|
+
except Exception as exc: # pragma: no cover - parser-specific messages vary
|
|
82
|
+
raise ConfigError(f"failed to parse {path}: {exc}") from exc
|
|
83
|
+
|
|
84
|
+
if not isinstance(data, dict):
|
|
85
|
+
raise ConfigError(f"configuration root in {path} must be an object")
|
|
86
|
+
return data
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _extract_servers(data: dict[str, Any]) -> dict[str, Any]:
|
|
90
|
+
if isinstance(data.get("mcpServers"), dict):
|
|
91
|
+
return data["mcpServers"]
|
|
92
|
+
raise ConfigError("configuration must contain a Claude-style mcpServers object")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _parse_server(name: str, raw: dict[str, Any]) -> ServerConfig:
|
|
96
|
+
server_type = str(
|
|
97
|
+
raw.get("server_type")
|
|
98
|
+
or raw.get("type")
|
|
99
|
+
or raw.get("transport")
|
|
100
|
+
or _infer_server_type(raw)
|
|
101
|
+
).strip().lower()
|
|
102
|
+
if server_type in {"streamablehttp", "streamable-http"}:
|
|
103
|
+
server_type = "streamable_http"
|
|
104
|
+
|
|
105
|
+
env = _string_map(raw.get("env") or {})
|
|
106
|
+
headers = _string_map(raw.get("headers") or {})
|
|
107
|
+
args = raw.get("args") or []
|
|
108
|
+
if not isinstance(args, list) or not all(isinstance(arg, str) for arg in args):
|
|
109
|
+
raise ConfigError(f"server {name!r} args must be a list of strings")
|
|
110
|
+
|
|
111
|
+
white_list = (
|
|
112
|
+
raw.get("tool_white_list")
|
|
113
|
+
or raw.get("tool_whitelist")
|
|
114
|
+
or raw.get("tools")
|
|
115
|
+
or []
|
|
116
|
+
)
|
|
117
|
+
if not isinstance(white_list, list) or not all(isinstance(tool, str) for tool in white_list):
|
|
118
|
+
raise ConfigError(f"server {name!r} tool whitelist must be a list of strings")
|
|
119
|
+
|
|
120
|
+
command = _optional_expanded(raw.get("command"))
|
|
121
|
+
base_url = _optional_expanded(raw.get("base_url") or raw.get("url"))
|
|
122
|
+
cwd = _optional_expanded(raw.get("cwd"))
|
|
123
|
+
|
|
124
|
+
if server_type == "stdio" and not command:
|
|
125
|
+
raise ConfigError(f"server {name!r} requires command for stdio transport")
|
|
126
|
+
if server_type in {"http", "streamable_http", "sse"} and not base_url:
|
|
127
|
+
raise ConfigError(f"server {name!r} requires base_url/url for {server_type} transport")
|
|
128
|
+
if server_type not in {"stdio", "http", "streamable_http", "sse"}:
|
|
129
|
+
raise ConfigError(f"server {name!r} has unsupported transport {server_type!r}")
|
|
130
|
+
|
|
131
|
+
return ServerConfig(
|
|
132
|
+
name=name,
|
|
133
|
+
server_type=server_type,
|
|
134
|
+
command=command,
|
|
135
|
+
args=[_expand_value(arg) for arg in args],
|
|
136
|
+
env=env,
|
|
137
|
+
cwd=cwd,
|
|
138
|
+
base_url=base_url,
|
|
139
|
+
headers=headers,
|
|
140
|
+
tool_white_list=white_list,
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _infer_server_type(raw: dict[str, Any]) -> str:
|
|
145
|
+
if raw.get("base_url") or raw.get("url"):
|
|
146
|
+
return "http"
|
|
147
|
+
return "stdio"
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _optional_expanded(value: Any) -> str | None:
|
|
151
|
+
if value is None:
|
|
152
|
+
return None
|
|
153
|
+
if not isinstance(value, str):
|
|
154
|
+
raise ConfigError(f"expected string value, got {type(value).__name__}")
|
|
155
|
+
return _expand_value(value)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _string_map(raw: Any) -> dict[str, str]:
|
|
159
|
+
if not isinstance(raw, dict):
|
|
160
|
+
raise ConfigError("expected object with string keys and values")
|
|
161
|
+
result: dict[str, str] = {}
|
|
162
|
+
for key, value in raw.items():
|
|
163
|
+
if not isinstance(key, str):
|
|
164
|
+
raise ConfigError("expected object with string keys")
|
|
165
|
+
if value is None:
|
|
166
|
+
result[key] = ""
|
|
167
|
+
elif isinstance(value, str):
|
|
168
|
+
result[key] = _expand_value(value)
|
|
169
|
+
else:
|
|
170
|
+
result[key] = _expand_value(str(value))
|
|
171
|
+
return result
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _expand_value(value: str) -> str:
|
|
175
|
+
"""Expand ~/ and environment variables in a config value."""
|
|
176
|
+
|
|
177
|
+
return os.path.expandvars(os.path.expanduser(value))
|
maco/gateway.py
ADDED
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
"""Local HTTP gateway for generated MCP code."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import concurrent.futures
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
import secrets
|
|
12
|
+
import signal
|
|
13
|
+
import socket
|
|
14
|
+
import sys
|
|
15
|
+
import threading
|
|
16
|
+
import time
|
|
17
|
+
from typing import Any
|
|
18
|
+
from http import HTTPStatus
|
|
19
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
20
|
+
|
|
21
|
+
from .config import MacoConfig
|
|
22
|
+
from .mcp_manager import MCPManager
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class ServeOptions:
|
|
27
|
+
host: str = "127.0.0.1"
|
|
28
|
+
port: int = 0
|
|
29
|
+
workspace: str | Path = ".maco"
|
|
30
|
+
token: str | None = None
|
|
31
|
+
use_token: bool = True
|
|
32
|
+
extra_hosts: tuple[str, ...] = ()
|
|
33
|
+
freebind_hosts: tuple[str, ...] = ()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class ManagerLoop:
|
|
37
|
+
"""Runs the async MCP manager on a private event loop."""
|
|
38
|
+
|
|
39
|
+
def __init__(self, config: MacoConfig):
|
|
40
|
+
self.manager = MCPManager(config)
|
|
41
|
+
self.loop = asyncio.new_event_loop()
|
|
42
|
+
self._thread = threading.Thread(target=self._run, name="maco-mcp-loop", daemon=True)
|
|
43
|
+
self._ready: concurrent.futures.Future[None] = concurrent.futures.Future()
|
|
44
|
+
self._main_future: concurrent.futures.Future[None] | None = None
|
|
45
|
+
self._stop_event: asyncio.Event | None = None
|
|
46
|
+
|
|
47
|
+
def start(self) -> None:
|
|
48
|
+
self._thread.start()
|
|
49
|
+
self._main_future = asyncio.run_coroutine_threadsafe(self._main(), self.loop)
|
|
50
|
+
self._ready.result()
|
|
51
|
+
|
|
52
|
+
def stop(self) -> None:
|
|
53
|
+
try:
|
|
54
|
+
if self._stop_event is not None:
|
|
55
|
+
self.loop.call_soon_threadsafe(self._stop_event.set)
|
|
56
|
+
if self._main_future is not None:
|
|
57
|
+
self._main_future.result(timeout=10)
|
|
58
|
+
finally:
|
|
59
|
+
self.loop.call_soon_threadsafe(self.loop.stop)
|
|
60
|
+
self._thread.join(timeout=10)
|
|
61
|
+
self.loop.close()
|
|
62
|
+
|
|
63
|
+
def call_tool(self, server: str, tool: str, arguments: dict[str, Any]) -> dict[str, Any]:
|
|
64
|
+
return self.run(self.manager.call_tool(server, tool, arguments))
|
|
65
|
+
|
|
66
|
+
def list_tools(self) -> dict[str, list[dict[str, Any]]]:
|
|
67
|
+
return self.run(self.manager.list_tools())
|
|
68
|
+
|
|
69
|
+
def run(self, coro: Any, timeout: float | None = None) -> Any:
|
|
70
|
+
future = asyncio.run_coroutine_threadsafe(coro, self.loop)
|
|
71
|
+
return future.result(timeout=timeout)
|
|
72
|
+
|
|
73
|
+
def _run(self) -> None:
|
|
74
|
+
asyncio.set_event_loop(self.loop)
|
|
75
|
+
self.loop.run_forever()
|
|
76
|
+
|
|
77
|
+
async def _main(self) -> None:
|
|
78
|
+
try:
|
|
79
|
+
await self.manager.start()
|
|
80
|
+
self._stop_event = asyncio.Event()
|
|
81
|
+
self._ready.set_result(None)
|
|
82
|
+
await self._stop_event.wait()
|
|
83
|
+
except Exception as exc:
|
|
84
|
+
if not self._ready.done():
|
|
85
|
+
self._ready.set_exception(exc)
|
|
86
|
+
raise
|
|
87
|
+
finally:
|
|
88
|
+
await self.manager.aclose()
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class GatewayServer:
|
|
92
|
+
"""Managed maco gateway suitable for embedding or blocking CLI use."""
|
|
93
|
+
|
|
94
|
+
def __init__(self, config: MacoConfig, options: ServeOptions) -> None:
|
|
95
|
+
self.config = config
|
|
96
|
+
self.options = options
|
|
97
|
+
self.workspace = Path(options.workspace).expanduser().resolve()
|
|
98
|
+
self.gateway_file = self.workspace / "gateway.json"
|
|
99
|
+
self.token = options.token if options.use_token else None
|
|
100
|
+
if options.use_token and not self.token:
|
|
101
|
+
self.token = secrets.token_urlsafe(32)
|
|
102
|
+
self.manager_loop = ManagerLoop(config)
|
|
103
|
+
self.httpd: ThreadingHTTPServer | None = None
|
|
104
|
+
self.extra_httpds: list[ThreadingHTTPServer] = []
|
|
105
|
+
self.thread: threading.Thread | None = None
|
|
106
|
+
self.extra_threads: list[threading.Thread] = []
|
|
107
|
+
self.url = ""
|
|
108
|
+
self.extra_urls: list[str] = []
|
|
109
|
+
|
|
110
|
+
def start(self) -> GatewayServer:
|
|
111
|
+
self.workspace.mkdir(parents=True, exist_ok=True)
|
|
112
|
+
self.manager_loop.start()
|
|
113
|
+
try:
|
|
114
|
+
handler_cls = _make_handler(self.manager_loop, self.token)
|
|
115
|
+
freebind_hosts = set(self.options.freebind_hosts)
|
|
116
|
+
self.httpd = _make_http_server(
|
|
117
|
+
self.options.host,
|
|
118
|
+
self.options.port,
|
|
119
|
+
handler_cls,
|
|
120
|
+
freebind=self.options.host in freebind_hosts,
|
|
121
|
+
)
|
|
122
|
+
actual_host, actual_port = self.httpd.server_address[:2]
|
|
123
|
+
display_host = "127.0.0.1" if actual_host in {"0.0.0.0", ""} else actual_host
|
|
124
|
+
self.url = f"http://{display_host}:{actual_port}/"
|
|
125
|
+
self.thread = threading.Thread(target=self.httpd.serve_forever, name="maco-gateway", daemon=True)
|
|
126
|
+
self.thread.start()
|
|
127
|
+
for index, host in enumerate(dict.fromkeys(self.options.extra_hosts)):
|
|
128
|
+
if host == self.options.host:
|
|
129
|
+
continue
|
|
130
|
+
extra_httpd = _make_http_server(
|
|
131
|
+
host,
|
|
132
|
+
actual_port,
|
|
133
|
+
handler_cls,
|
|
134
|
+
freebind=host in freebind_hosts,
|
|
135
|
+
)
|
|
136
|
+
extra_host, extra_port = extra_httpd.server_address[:2]
|
|
137
|
+
extra_display_host = "127.0.0.1" if extra_host in {"0.0.0.0", ""} else extra_host
|
|
138
|
+
self.extra_httpds.append(extra_httpd)
|
|
139
|
+
self.extra_urls.append(f"http://{extra_display_host}:{extra_port}/")
|
|
140
|
+
thread = threading.Thread(
|
|
141
|
+
target=extra_httpd.serve_forever,
|
|
142
|
+
name=f"maco-gateway-extra-{index}",
|
|
143
|
+
daemon=True,
|
|
144
|
+
)
|
|
145
|
+
thread.start()
|
|
146
|
+
self.extra_threads.append(thread)
|
|
147
|
+
_write_gateway_file(self.gateway_file, self.url, self.token, self.config.path)
|
|
148
|
+
except Exception:
|
|
149
|
+
for httpd, thread in [(self.httpd, self.thread), *zip(self.extra_httpds, self.extra_threads)]:
|
|
150
|
+
if httpd is not None:
|
|
151
|
+
if thread is not None and thread.is_alive():
|
|
152
|
+
httpd.shutdown()
|
|
153
|
+
thread.join(timeout=10)
|
|
154
|
+
httpd.server_close()
|
|
155
|
+
self.manager_loop.stop()
|
|
156
|
+
raise
|
|
157
|
+
return self
|
|
158
|
+
|
|
159
|
+
def stop(self) -> None:
|
|
160
|
+
for httpd in [self.httpd, *self.extra_httpds]:
|
|
161
|
+
if httpd is not None:
|
|
162
|
+
httpd.shutdown()
|
|
163
|
+
httpd.server_close()
|
|
164
|
+
for thread in [self.thread, *self.extra_threads]:
|
|
165
|
+
if thread is not None:
|
|
166
|
+
thread.join(timeout=10)
|
|
167
|
+
self.manager_loop.stop()
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def serve(config: MacoConfig, options: ServeOptions) -> None:
|
|
171
|
+
"""Run the gateway until interrupted."""
|
|
172
|
+
|
|
173
|
+
gateway = GatewayServer(config, options).start()
|
|
174
|
+
stop_event = threading.Event()
|
|
175
|
+
|
|
176
|
+
def _request_shutdown(signum: int, _frame: Any) -> None:
|
|
177
|
+
print(f"\nreceived signal {signum}; stopping maco gateway", file=sys.stderr)
|
|
178
|
+
stop_event.set()
|
|
179
|
+
|
|
180
|
+
old_sigint = signal.signal(signal.SIGINT, _request_shutdown)
|
|
181
|
+
old_sigterm = signal.signal(signal.SIGTERM, _request_shutdown)
|
|
182
|
+
try:
|
|
183
|
+
print("maco gateway started")
|
|
184
|
+
print(f" URL: {gateway.url}")
|
|
185
|
+
print(f" workspace: {gateway.workspace}")
|
|
186
|
+
print(f" gateway file: {gateway.gateway_file}")
|
|
187
|
+
print(" press Ctrl+C to stop")
|
|
188
|
+
stop_event.wait()
|
|
189
|
+
finally:
|
|
190
|
+
signal.signal(signal.SIGINT, old_sigint)
|
|
191
|
+
signal.signal(signal.SIGTERM, old_sigterm)
|
|
192
|
+
gateway.stop()
|
|
193
|
+
if stop_event.is_set():
|
|
194
|
+
print("maco gateway stopped")
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _write_gateway_file(path: Path, url: str, token: str | None, config_path: Path) -> None:
|
|
198
|
+
payload = {
|
|
199
|
+
"url": url,
|
|
200
|
+
"token": token,
|
|
201
|
+
"pid": os.getpid(),
|
|
202
|
+
"config": str(config_path),
|
|
203
|
+
"started_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
|
204
|
+
}
|
|
205
|
+
path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _make_http_server(
|
|
209
|
+
host: str,
|
|
210
|
+
port: int,
|
|
211
|
+
handler_cls: type[BaseHTTPRequestHandler],
|
|
212
|
+
*,
|
|
213
|
+
freebind: bool = False,
|
|
214
|
+
) -> ThreadingHTTPServer:
|
|
215
|
+
httpd = ThreadingHTTPServer((host, port), handler_cls, bind_and_activate=False)
|
|
216
|
+
try:
|
|
217
|
+
if freebind:
|
|
218
|
+
_enable_freebind(httpd.socket)
|
|
219
|
+
httpd.server_bind()
|
|
220
|
+
httpd.server_activate()
|
|
221
|
+
except Exception:
|
|
222
|
+
httpd.server_close()
|
|
223
|
+
raise
|
|
224
|
+
return httpd
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _enable_freebind(sock: socket.socket) -> None:
|
|
228
|
+
if not sys.platform.startswith("linux"):
|
|
229
|
+
return
|
|
230
|
+
ip_freebind = getattr(socket, "IP_FREEBIND", 15)
|
|
231
|
+
sock.setsockopt(socket.SOL_IP, ip_freebind, 1)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _make_handler(manager_loop: ManagerLoop, token: str | None) -> type[BaseHTTPRequestHandler]:
|
|
235
|
+
class Handler(BaseHTTPRequestHandler):
|
|
236
|
+
server_version = "maco-gateway/0.1"
|
|
237
|
+
|
|
238
|
+
def do_GET(self) -> None: # noqa: N802 - stdlib API
|
|
239
|
+
if self.path.rstrip("/") in {"", "/health"}:
|
|
240
|
+
self._write_json({"ok": True, "servers": manager_loop.manager.server_names()})
|
|
241
|
+
return
|
|
242
|
+
if self.path.rstrip("/") == "/tools":
|
|
243
|
+
if token and self.headers.get("Authorization") != f"Bearer {token}":
|
|
244
|
+
self._write_error(HTTPStatus.UNAUTHORIZED, "unauthorized")
|
|
245
|
+
return
|
|
246
|
+
try:
|
|
247
|
+
self._write_json({"servers": manager_loop.list_tools()})
|
|
248
|
+
except Exception as exc: # pragma: no cover - defensive gateway path
|
|
249
|
+
self._write_error(HTTPStatus.INTERNAL_SERVER_ERROR, str(exc))
|
|
250
|
+
return
|
|
251
|
+
self._write_error(HTTPStatus.NOT_FOUND, "not found")
|
|
252
|
+
|
|
253
|
+
def do_POST(self) -> None: # noqa: N802 - stdlib API
|
|
254
|
+
if self.path.rstrip("/") not in {"", "/call"}:
|
|
255
|
+
self._write_error(HTTPStatus.NOT_FOUND, "not found")
|
|
256
|
+
return
|
|
257
|
+
if token and self.headers.get("Authorization") != f"Bearer {token}":
|
|
258
|
+
self._write_error(HTTPStatus.UNAUTHORIZED, "unauthorized")
|
|
259
|
+
return
|
|
260
|
+
try:
|
|
261
|
+
request = self._read_json()
|
|
262
|
+
server_name = _required_str(request, "server")
|
|
263
|
+
tool_name = _required_str(request, "tool")
|
|
264
|
+
arguments = request.get("arguments") or {}
|
|
265
|
+
if not isinstance(arguments, dict):
|
|
266
|
+
raise ValueError("arguments must be an object")
|
|
267
|
+
response = manager_loop.call_tool(server_name, tool_name, arguments)
|
|
268
|
+
self._write_json(response)
|
|
269
|
+
except KeyError as exc:
|
|
270
|
+
self._write_error(HTTPStatus.NOT_FOUND, str(exc))
|
|
271
|
+
except ValueError as exc:
|
|
272
|
+
self._write_error(HTTPStatus.BAD_REQUEST, str(exc))
|
|
273
|
+
except Exception as exc:
|
|
274
|
+
self._write_error(HTTPStatus.INTERNAL_SERVER_ERROR, str(exc))
|
|
275
|
+
|
|
276
|
+
def log_message(self, format: str, *args: Any) -> None:
|
|
277
|
+
print(f"{self.address_string()} - {format % args}", file=sys.stderr)
|
|
278
|
+
|
|
279
|
+
def _read_json(self) -> dict[str, Any]:
|
|
280
|
+
length = int(self.headers.get("Content-Length") or "0")
|
|
281
|
+
body = self.rfile.read(length)
|
|
282
|
+
data = json.loads(body.decode("utf-8") if body else "{}")
|
|
283
|
+
if not isinstance(data, dict):
|
|
284
|
+
raise ValueError("request body must be a JSON object")
|
|
285
|
+
return data
|
|
286
|
+
|
|
287
|
+
def _write_json(self, payload: dict[str, Any], status: HTTPStatus = HTTPStatus.OK) -> None:
|
|
288
|
+
body = json.dumps(payload, sort_keys=True).encode("utf-8")
|
|
289
|
+
self.send_response(status.value)
|
|
290
|
+
self.send_header("Content-Type", "application/json")
|
|
291
|
+
self.send_header("Content-Length", str(len(body)))
|
|
292
|
+
self.end_headers()
|
|
293
|
+
self.wfile.write(body)
|
|
294
|
+
|
|
295
|
+
def _write_error(self, status: HTTPStatus, message: str) -> None:
|
|
296
|
+
self._write_json({"error": message}, status=status)
|
|
297
|
+
|
|
298
|
+
return Handler
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _required_str(data: dict[str, Any], key: str) -> str:
|
|
302
|
+
value = data.get(key)
|
|
303
|
+
if not isinstance(value, str) or not value:
|
|
304
|
+
raise ValueError(f"{key} must be a non-empty string")
|
|
305
|
+
return value
|
maco/mcp_manager.py
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""Async MCP client manager used by generation and the gateway."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Any, AsyncIterator
|
|
8
|
+
|
|
9
|
+
from mcp import ClientSession
|
|
10
|
+
from mcp.client.sse import sse_client
|
|
11
|
+
from mcp.client.stdio import StdioServerParameters, stdio_client
|
|
12
|
+
from mcp.client.streamable_http import streamable_http_client
|
|
13
|
+
|
|
14
|
+
from .config import MacoConfig, ServerConfig
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class ServerState:
|
|
19
|
+
"""Runtime state for an initialized MCP server."""
|
|
20
|
+
|
|
21
|
+
config: ServerConfig
|
|
22
|
+
session: ClientSession
|
|
23
|
+
tools: list[Any]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class MCPManager:
|
|
27
|
+
"""Owns MCP sessions for all configured servers."""
|
|
28
|
+
|
|
29
|
+
def __init__(self, config: MacoConfig):
|
|
30
|
+
self.config = config
|
|
31
|
+
self._stack = contextlib.AsyncExitStack()
|
|
32
|
+
self._servers: dict[str, ServerState] = {}
|
|
33
|
+
self._started = False
|
|
34
|
+
|
|
35
|
+
async def __aenter__(self) -> MCPManager:
|
|
36
|
+
await self.start()
|
|
37
|
+
return self
|
|
38
|
+
|
|
39
|
+
async def __aexit__(self, exc_type: object, exc: object, tb: object) -> None:
|
|
40
|
+
await self.aclose()
|
|
41
|
+
|
|
42
|
+
async def start(self) -> None:
|
|
43
|
+
if self._started:
|
|
44
|
+
return
|
|
45
|
+
try:
|
|
46
|
+
for server in self.config.servers.values():
|
|
47
|
+
read_stream, write_stream = await self._stack.enter_async_context(
|
|
48
|
+
_client_streams(server)
|
|
49
|
+
)
|
|
50
|
+
session = await self._stack.enter_async_context(
|
|
51
|
+
ClientSession(read_stream, write_stream)
|
|
52
|
+
)
|
|
53
|
+
await session.initialize()
|
|
54
|
+
list_result = await session.list_tools()
|
|
55
|
+
tools = [
|
|
56
|
+
tool
|
|
57
|
+
for tool in list_result.tools
|
|
58
|
+
if not server.tool_white_list or tool.name in server.tool_white_list
|
|
59
|
+
]
|
|
60
|
+
self._servers[server.name] = ServerState(
|
|
61
|
+
config=server,
|
|
62
|
+
session=session,
|
|
63
|
+
tools=tools,
|
|
64
|
+
)
|
|
65
|
+
self._started = True
|
|
66
|
+
except BaseException:
|
|
67
|
+
await self.aclose()
|
|
68
|
+
raise
|
|
69
|
+
|
|
70
|
+
async def aclose(self) -> None:
|
|
71
|
+
self._servers.clear()
|
|
72
|
+
self._started = False
|
|
73
|
+
await self._stack.aclose()
|
|
74
|
+
|
|
75
|
+
async def list_tools(self, server_filter: str | None = None) -> dict[str, list[dict[str, Any]]]:
|
|
76
|
+
await self._ensure_started()
|
|
77
|
+
result: dict[str, list[dict[str, Any]]] = {}
|
|
78
|
+
for name, state in self._servers.items():
|
|
79
|
+
if server_filter and name != server_filter:
|
|
80
|
+
continue
|
|
81
|
+
result[name] = [_model_to_jsonable(tool) for tool in state.tools]
|
|
82
|
+
return result
|
|
83
|
+
|
|
84
|
+
async def call_tool(
|
|
85
|
+
self,
|
|
86
|
+
server_name: str,
|
|
87
|
+
tool_name: str,
|
|
88
|
+
arguments: dict[str, Any] | None = None,
|
|
89
|
+
) -> dict[str, Any]:
|
|
90
|
+
await self._ensure_started()
|
|
91
|
+
state = self._servers.get(server_name)
|
|
92
|
+
if state is None:
|
|
93
|
+
known = ", ".join(sorted(self._servers)) or "<none>"
|
|
94
|
+
raise KeyError(f"unknown MCP server {server_name!r}; known servers: {known}")
|
|
95
|
+
if not any(tool.name == tool_name for tool in state.tools):
|
|
96
|
+
known_tools = ", ".join(sorted(tool.name for tool in state.tools)) or "<none>"
|
|
97
|
+
raise KeyError(
|
|
98
|
+
f"unknown MCP tool {server_name}.{tool_name}; known tools for {server_name}: {known_tools}"
|
|
99
|
+
)
|
|
100
|
+
result = await state.session.call_tool(tool_name, arguments=arguments or {})
|
|
101
|
+
return _model_to_jsonable(result)
|
|
102
|
+
|
|
103
|
+
def server_names(self) -> list[str]:
|
|
104
|
+
return sorted(self._servers)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
async def _ensure_started(self) -> None:
|
|
108
|
+
if not self._started:
|
|
109
|
+
await self.start()
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@contextlib.asynccontextmanager
|
|
113
|
+
async def _client_streams(server: ServerConfig) -> AsyncIterator[tuple[Any, Any]]:
|
|
114
|
+
if server.is_stdio:
|
|
115
|
+
params = StdioServerParameters(
|
|
116
|
+
command=server.command or "",
|
|
117
|
+
args=server.args,
|
|
118
|
+
env=server.env or None,
|
|
119
|
+
cwd=server.cwd,
|
|
120
|
+
)
|
|
121
|
+
async with stdio_client(params) as streams:
|
|
122
|
+
yield streams
|
|
123
|
+
return
|
|
124
|
+
|
|
125
|
+
if server.is_streamable_http:
|
|
126
|
+
if server.headers:
|
|
127
|
+
import httpx
|
|
128
|
+
|
|
129
|
+
async with httpx.AsyncClient(headers=server.headers, follow_redirects=True) as http_client:
|
|
130
|
+
async with streamable_http_client(
|
|
131
|
+
server.base_url or "",
|
|
132
|
+
http_client=http_client,
|
|
133
|
+
) as (read_stream, write_stream, _get_session_id):
|
|
134
|
+
yield read_stream, write_stream
|
|
135
|
+
else:
|
|
136
|
+
async with streamable_http_client(server.base_url or "") as (
|
|
137
|
+
read_stream,
|
|
138
|
+
write_stream,
|
|
139
|
+
_get_session_id,
|
|
140
|
+
):
|
|
141
|
+
yield read_stream, write_stream
|
|
142
|
+
return
|
|
143
|
+
|
|
144
|
+
if server.is_sse:
|
|
145
|
+
async with sse_client(server.base_url or "", headers=server.headers or None) as streams:
|
|
146
|
+
yield streams
|
|
147
|
+
return
|
|
148
|
+
|
|
149
|
+
raise ValueError(f"unsupported MCP server transport for {server.name}: {server.server_type}")
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _model_to_jsonable(value: Any) -> dict[str, Any]:
|
|
153
|
+
if hasattr(value, "model_dump"):
|
|
154
|
+
return value.model_dump(by_alias=True, exclude_none=True, mode="json")
|
|
155
|
+
if isinstance(value, dict):
|
|
156
|
+
return value
|
|
157
|
+
raise TypeError(f"cannot serialize MCP object of type {type(value).__name__}")
|