moonbridge 0.8.0__py3-none-any.whl → 0.9.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.
- moonbridge/__init__.py +1 -1
- moonbridge/server.py +53 -4
- moonbridge/signals.py +68 -0
- {moonbridge-0.8.0.dist-info → moonbridge-0.9.0.dist-info}/METADATA +52 -1
- {moonbridge-0.8.0.dist-info → moonbridge-0.9.0.dist-info}/RECORD +8 -7
- {moonbridge-0.8.0.dist-info → moonbridge-0.9.0.dist-info}/WHEEL +0 -0
- {moonbridge-0.8.0.dist-info → moonbridge-0.9.0.dist-info}/entry_points.txt +0 -0
- {moonbridge-0.8.0.dist-info → moonbridge-0.9.0.dist-info}/licenses/LICENSE +0 -0
moonbridge/__init__.py
CHANGED
moonbridge/server.py
CHANGED
|
@@ -21,6 +21,7 @@ from mcp.types import TextContent, Tool
|
|
|
21
21
|
|
|
22
22
|
from moonbridge.adapters import ADAPTER_REGISTRY, CLIAdapter, get_adapter
|
|
23
23
|
from moonbridge.adapters.base import AgentResult
|
|
24
|
+
from moonbridge.signals import extract_quality_signals
|
|
24
25
|
from moonbridge.tools import build_tools
|
|
25
26
|
|
|
26
27
|
server = Server("moonbridge")
|
|
@@ -78,6 +79,22 @@ def _warn_if_unrestricted() -> None:
|
|
|
78
79
|
print(message, file=sys.stderr)
|
|
79
80
|
|
|
80
81
|
|
|
82
|
+
def _validate_allowed_dirs() -> None:
|
|
83
|
+
if not ALLOWED_DIRS:
|
|
84
|
+
return
|
|
85
|
+
missing_count = 0
|
|
86
|
+
for path in ALLOWED_DIRS:
|
|
87
|
+
if os.path.isdir(path):
|
|
88
|
+
continue
|
|
89
|
+
missing_count += 1
|
|
90
|
+
logger.warning("MOONBRIDGE_ALLOWED_DIRS entry does not exist: %s", path)
|
|
91
|
+
if missing_count == len(ALLOWED_DIRS) and STRICT_MODE:
|
|
92
|
+
message = "MOONBRIDGE_ALLOWED_DIRS entries do not exist"
|
|
93
|
+
logger.error(message)
|
|
94
|
+
print(message, file=sys.stderr)
|
|
95
|
+
sys.exit(1)
|
|
96
|
+
|
|
97
|
+
|
|
81
98
|
def _safe_env(adapter: CLIAdapter) -> dict[str, str]:
|
|
82
99
|
env = {key: os.environ[key] for key in adapter.config.safe_env_keys if key in os.environ}
|
|
83
100
|
if "PATH" not in env and "PATH" in os.environ:
|
|
@@ -327,7 +344,7 @@ def _run_cli_sync(
|
|
|
327
344
|
)
|
|
328
345
|
status = "success" if proc.returncode == 0 else "error"
|
|
329
346
|
logger.info("Agent %s completed with status: %s", agent_index, status)
|
|
330
|
-
|
|
347
|
+
result = AgentResult(
|
|
331
348
|
status=status,
|
|
332
349
|
output=stdout,
|
|
333
350
|
stderr=stderr_value,
|
|
@@ -335,6 +352,13 @@ def _run_cli_sync(
|
|
|
335
352
|
duration_ms=duration_ms,
|
|
336
353
|
agent_index=agent_index,
|
|
337
354
|
)
|
|
355
|
+
if result.status == "success":
|
|
356
|
+
signals = extract_quality_signals(result.output, result.stderr)
|
|
357
|
+
if signals:
|
|
358
|
+
raw = dict(result.raw or {})
|
|
359
|
+
raw["quality_signals"] = signals
|
|
360
|
+
result = replace(result, raw=raw)
|
|
361
|
+
return result
|
|
338
362
|
except TimeoutExpired:
|
|
339
363
|
_terminate_process(proc)
|
|
340
364
|
duration_ms = int((time.monotonic() - start) * 1000)
|
|
@@ -401,6 +425,12 @@ def _json_text(payload: Any) -> list[TextContent]:
|
|
|
401
425
|
|
|
402
426
|
|
|
403
427
|
def _status_check(cwd: str, adapter: CLIAdapter) -> dict[str, Any]:
|
|
428
|
+
config = {
|
|
429
|
+
"strict_mode": STRICT_MODE,
|
|
430
|
+
"allowed_dirs": ALLOWED_DIRS or None,
|
|
431
|
+
"unrestricted": not ALLOWED_DIRS,
|
|
432
|
+
"cwd": cwd,
|
|
433
|
+
}
|
|
404
434
|
installed, _path = adapter.check_installed()
|
|
405
435
|
if not installed:
|
|
406
436
|
return {
|
|
@@ -408,20 +438,27 @@ def _status_check(cwd: str, adapter: CLIAdapter) -> dict[str, Any]:
|
|
|
408
438
|
"message": (
|
|
409
439
|
f"{adapter.config.name} CLI not found. Install: {adapter.config.install_hint}"
|
|
410
440
|
),
|
|
441
|
+
"config": config,
|
|
411
442
|
}
|
|
412
443
|
timeout = min(DEFAULT_TIMEOUT, 60)
|
|
413
444
|
result = _run_cli_sync(adapter, "status check", False, cwd, timeout, 0)
|
|
414
445
|
if result.status == "auth_error":
|
|
415
|
-
return {
|
|
446
|
+
return {
|
|
447
|
+
"status": "auth_error",
|
|
448
|
+
"message": adapter.config.auth_message,
|
|
449
|
+
"config": config,
|
|
450
|
+
}
|
|
416
451
|
if result.status == "success":
|
|
417
452
|
return {
|
|
418
453
|
"status": "success",
|
|
419
454
|
"message": f"{adapter.config.name} CLI available and authenticated",
|
|
455
|
+
"config": config,
|
|
420
456
|
}
|
|
421
457
|
return {
|
|
422
458
|
"status": "error",
|
|
423
459
|
"message": f"{adapter.config.name} CLI error",
|
|
424
460
|
"details": result.to_dict(),
|
|
461
|
+
"config": config,
|
|
425
462
|
}
|
|
426
463
|
|
|
427
464
|
|
|
@@ -444,6 +481,7 @@ def _adapter_info(cwd: str, adapter: CLIAdapter) -> dict[str, Any]:
|
|
|
444
481
|
|
|
445
482
|
@server.list_tools()
|
|
446
483
|
async def list_tools() -> list[Tool]:
|
|
484
|
+
"""Build MCP tool metadata for the active adapter."""
|
|
447
485
|
adapter = get_adapter()
|
|
448
486
|
tool_desc = adapter.config.tool_description
|
|
449
487
|
status_desc = f"Verify {adapter.config.name} CLI is installed and authenticated"
|
|
@@ -456,7 +494,15 @@ async def list_tools() -> list[Tool]:
|
|
|
456
494
|
|
|
457
495
|
|
|
458
496
|
async def handle_tool(name: str, arguments: dict[str, Any]) -> list[TextContent]:
|
|
459
|
-
"""
|
|
497
|
+
"""Dispatch a tool invocation with validation and stable error payloads.
|
|
498
|
+
|
|
499
|
+
Separated from ``call_tool`` so tests can invoke tool logic without the
|
|
500
|
+
MCP decorator.
|
|
501
|
+
|
|
502
|
+
Args:
|
|
503
|
+
name: MCP tool name (``spawn_agent``, ``spawn_agents_parallel``, etc.).
|
|
504
|
+
arguments: Tool argument payload from the MCP client.
|
|
505
|
+
"""
|
|
460
506
|
try:
|
|
461
507
|
cwd = _validate_cwd(None)
|
|
462
508
|
if name == "spawn_agent":
|
|
@@ -556,11 +602,12 @@ async def handle_tool(name: str, arguments: dict[str, Any]) -> list[TextContent]
|
|
|
556
602
|
|
|
557
603
|
@server.call_tool()
|
|
558
604
|
async def call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent]:
|
|
559
|
-
"""MCP tool handler
|
|
605
|
+
"""MCP tool handler -- delegates to ``handle_tool`` for testability."""
|
|
560
606
|
return await handle_tool(name, arguments)
|
|
561
607
|
|
|
562
608
|
|
|
563
609
|
async def run() -> None:
|
|
610
|
+
"""Run the MCP server over stdio until the client disconnects."""
|
|
564
611
|
async with stdio_server() as (read_stream, write_stream):
|
|
565
612
|
await server.run(
|
|
566
613
|
read_stream,
|
|
@@ -570,8 +617,10 @@ async def run() -> None:
|
|
|
570
617
|
|
|
571
618
|
|
|
572
619
|
def main() -> None:
|
|
620
|
+
"""CLI entry point that validates prerequisites then starts the server."""
|
|
573
621
|
_configure_logging()
|
|
574
622
|
_warn_if_unrestricted()
|
|
623
|
+
_validate_allowed_dirs()
|
|
575
624
|
from moonbridge import __version__
|
|
576
625
|
from moonbridge.version_check import check_for_updates
|
|
577
626
|
|
moonbridge/signals.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Heuristic extraction of quality signals from agent output."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
# Diff markers at line start.
|
|
9
|
+
_DIFF_MARKER_RE = re.compile(r"^(?:\+\+\+ |--- |@@ )", re.MULTILINE)
|
|
10
|
+
# File headers in unified diffs.
|
|
11
|
+
_DIFF_FILE_RE = re.compile(r"^(?:\+\+\+ b/|--- a/)(.+)$", re.MULTILINE)
|
|
12
|
+
# Git-style summary lines.
|
|
13
|
+
_FILES_CHANGED_RE = re.compile(r"\b(\d+)\s+files?\s+changed\b", re.IGNORECASE)
|
|
14
|
+
_MODIFIED_FILES_RE = re.compile(r"\bModified\s+(\d+)\s+files?\b", re.IGNORECASE)
|
|
15
|
+
# Pytest-style summaries.
|
|
16
|
+
_PASSED_RE = re.compile(r"(?<!\w)(\d+)\s+passed\b", re.IGNORECASE)
|
|
17
|
+
_FAILED_RE = re.compile(r"(?<!\w)(\d+)\s+failed\b", re.IGNORECASE)
|
|
18
|
+
# stderr error markers.
|
|
19
|
+
_ERROR_RE = re.compile(r"(Traceback \(most recent call last\)|\berror:)", re.IGNORECASE)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _last_int(pattern: re.Pattern[str], text: str) -> int | None:
|
|
23
|
+
matches = pattern.findall(text)
|
|
24
|
+
if not matches:
|
|
25
|
+
return None
|
|
26
|
+
return int(matches[-1])
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _count_files_changed(output: str) -> int:
|
|
30
|
+
paths = {path for path in _DIFF_FILE_RE.findall(output) if path and path != "/dev/null"}
|
|
31
|
+
if paths:
|
|
32
|
+
return len(paths)
|
|
33
|
+
match = _FILES_CHANGED_RE.search(output) or _MODIFIED_FILES_RE.search(output)
|
|
34
|
+
if match:
|
|
35
|
+
return int(match.group(1))
|
|
36
|
+
return 0
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def extract_quality_signals(output: str, stderr: str | None = None) -> dict[str, Any]:
|
|
40
|
+
"""Extract heuristic quality signals from agent output."""
|
|
41
|
+
signals: dict[str, Any] = {}
|
|
42
|
+
if not output and not stderr:
|
|
43
|
+
return signals
|
|
44
|
+
|
|
45
|
+
has_diff = bool(_DIFF_MARKER_RE.search(output))
|
|
46
|
+
if has_diff:
|
|
47
|
+
signals["has_diff"] = True
|
|
48
|
+
|
|
49
|
+
files_changed = _count_files_changed(output)
|
|
50
|
+
if files_changed:
|
|
51
|
+
signals["files_changed"] = files_changed
|
|
52
|
+
|
|
53
|
+
combined = output
|
|
54
|
+
if stderr:
|
|
55
|
+
combined = f"{output}\n{stderr}"
|
|
56
|
+
|
|
57
|
+
tests_passed = _last_int(_PASSED_RE, combined)
|
|
58
|
+
if tests_passed is not None:
|
|
59
|
+
signals["tests_passed"] = tests_passed
|
|
60
|
+
|
|
61
|
+
tests_failed = _last_int(_FAILED_RE, combined)
|
|
62
|
+
if tests_failed is not None:
|
|
63
|
+
signals["tests_failed"] = tests_failed
|
|
64
|
+
|
|
65
|
+
if stderr and _ERROR_RE.search(stderr):
|
|
66
|
+
signals["has_errors"] = True
|
|
67
|
+
|
|
68
|
+
return signals
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: moonbridge
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.9.0
|
|
4
4
|
Summary: MCP server for spawning AI coding agents (Kimi, Codex, and more)
|
|
5
5
|
Project-URL: Homepage, https://github.com/misty-step/moonbridge
|
|
6
6
|
Project-URL: Repository, https://github.com/misty-step/moonbridge
|
|
@@ -90,6 +90,32 @@ export MOONBRIDGE_SKIP_UPDATE_CHECK=1
|
|
|
90
90
|
|
|
91
91
|
**Best for:** Tasks that benefit from parallel execution or volume.
|
|
92
92
|
|
|
93
|
+
## How it Works
|
|
94
|
+
|
|
95
|
+
### Connection Flow
|
|
96
|
+
1. MCP client (Claude Code, Cursor, etc.) connects to Moonbridge over stdio
|
|
97
|
+
2. Client discovers available tools via `list_tools`
|
|
98
|
+
3. Client calls `spawn_agent` or `spawn_agents_parallel`
|
|
99
|
+
|
|
100
|
+
### Spawn Process
|
|
101
|
+
1. Moonbridge validates the prompt and working directory
|
|
102
|
+
2. Resolves which adapter to use (Kimi, Codex)
|
|
103
|
+
3. Adapter builds the CLI command with appropriate flags
|
|
104
|
+
4. Spawns subprocess in a separate process group
|
|
105
|
+
5. Captures stdout/stderr, enforces timeout
|
|
106
|
+
6. Returns structured JSON result
|
|
107
|
+
|
|
108
|
+
### Parallel Execution
|
|
109
|
+
- `spawn_agents_parallel` runs up to 10 agents concurrently via `asyncio.gather`
|
|
110
|
+
- Each agent is independent (separate process, separate output)
|
|
111
|
+
- All results returned together when the last agent finishes (or times out)
|
|
112
|
+
|
|
113
|
+
```
|
|
114
|
+
MCP Client → stdio → Moonbridge → adapter → CLI subprocess
|
|
115
|
+
→ CLI subprocess (parallel)
|
|
116
|
+
→ CLI subprocess (parallel)
|
|
117
|
+
```
|
|
118
|
+
|
|
93
119
|
## Tools
|
|
94
120
|
|
|
95
121
|
| Tool | Use case |
|
|
@@ -170,6 +196,31 @@ All tools return JSON with these fields:
|
|
|
170
196
|
| `MOONBRIDGE_SANDBOX_MAX_COPY` | Max sandbox copy size in bytes (default 500MB) |
|
|
171
197
|
| `MOONBRIDGE_LOG_LEVEL` | Set to `DEBUG` for verbose logging |
|
|
172
198
|
|
|
199
|
+
## Security
|
|
200
|
+
|
|
201
|
+
### 1. Directory Restrictions (`MOONBRIDGE_ALLOWED_DIRS`)
|
|
202
|
+
|
|
203
|
+
Default: agents can operate in any directory. Set `MOONBRIDGE_ALLOWED_DIRS` to restrict: colon-separated allowed paths. Symlinks resolved via `os.path.realpath` before checking. Strict mode (`MOONBRIDGE_STRICT=1`) exits on startup if no valid allowed directories are configured.
|
|
204
|
+
|
|
205
|
+
```bash
|
|
206
|
+
export MOONBRIDGE_ALLOWED_DIRS="/home/user/projects:/home/user/work"
|
|
207
|
+
export MOONBRIDGE_STRICT=1 # require restrictions
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
### 2. Environment Sanitization
|
|
211
|
+
|
|
212
|
+
Only whitelisted env vars are passed to spawned agents. Each adapter defines its own allowlist (`PATH`, `HOME`, plus adapter-specific like `OPENAI_API_KEY` for Codex). Your shell environment (secrets, tokens, SSH keys) is not inherited by default.
|
|
213
|
+
|
|
214
|
+
### 3. Input Validation
|
|
215
|
+
|
|
216
|
+
Model parameters are validated to prevent flag injection (values starting with `-` are rejected). Prompts are capped at 100,000 characters and cannot be empty.
|
|
217
|
+
|
|
218
|
+
### 4. Process Isolation
|
|
219
|
+
|
|
220
|
+
Agents run in separate process groups (`start_new_session=True`). Orphan cleanup on exit. Sandbox mode available (`MOONBRIDGE_SANDBOX=1`) for copy-on-run isolation.
|
|
221
|
+
|
|
222
|
+
> **Not OS-level sandboxing.** Agents can still read arbitrary host files. For strong isolation, use containers/VMs.
|
|
223
|
+
|
|
173
224
|
## Troubleshooting
|
|
174
225
|
|
|
175
226
|
### "CLI not found"
|
|
@@ -1,15 +1,16 @@
|
|
|
1
|
-
moonbridge/__init__.py,sha256=
|
|
1
|
+
moonbridge/__init__.py,sha256=ARkdZ2AN2BQF7Jf4ECee-WXhmAGnSDwkJoftXCqure4,198
|
|
2
2
|
moonbridge/py.typed,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
|
|
3
3
|
moonbridge/sandbox.py,sha256=4-eIu-rURtaxKKg-d3iWwNq_x6MB_uiJBBG2uMwoKcM,7907
|
|
4
|
-
moonbridge/server.py,sha256=
|
|
4
|
+
moonbridge/server.py,sha256=bRvDSvxpmbRWeMKA1JAczmqL2PcpsWfZtjM1jGQL0OE,21195
|
|
5
|
+
moonbridge/signals.py,sha256=tpdKuVBIrQ9XOZm6WaxcNSSPj9WqWUYp6aGseGq9W6I,2210
|
|
5
6
|
moonbridge/tools.py,sha256=uw338Dilrto2t5dL9XbK4O31-JdB7Vh9RqCXHg20gHI,10126
|
|
6
7
|
moonbridge/version_check.py,sha256=VQueK0O_b-2Xc-XjupJsoW3Zs1Kce5q_BgqBhANGXN8,4579
|
|
7
8
|
moonbridge/adapters/__init__.py,sha256=w3pLvjtC2XnUhf9UzNmniQB3oq4rG8gorSH0tWR-BEE,988
|
|
8
9
|
moonbridge/adapters/base.py,sha256=REoEsAcqEvyVQpTgz6ytd9ioxag--nnvX90YBXMQG8Y,1716
|
|
9
10
|
moonbridge/adapters/codex.py,sha256=G0q_A6vP5Usqix3sE8ssrG_qzrCWMeFcO9oWcNKTO3g,2964
|
|
10
11
|
moonbridge/adapters/kimi.py,sha256=ejCxG2OGr0Qr4n0psL6p96_mMJ3lLKMbGcNYWkuC0uA,2189
|
|
11
|
-
moonbridge-0.
|
|
12
|
-
moonbridge-0.
|
|
13
|
-
moonbridge-0.
|
|
14
|
-
moonbridge-0.
|
|
15
|
-
moonbridge-0.
|
|
12
|
+
moonbridge-0.9.0.dist-info/METADATA,sha256=T_721mqFlkFCMcUvgimOVMT79fJ12bqpe1XDTxVmbIo,10564
|
|
13
|
+
moonbridge-0.9.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
14
|
+
moonbridge-0.9.0.dist-info/entry_points.txt,sha256=kgL38HQy3adncDQl_o5sdtPRog56zKdHk6pKKzyR6Ww,54
|
|
15
|
+
moonbridge-0.9.0.dist-info/licenses/LICENSE,sha256=7WMSJoybL2cUot_wb9GUrw5mzfFmtrDzqlMS9ZE709g,1065
|
|
16
|
+
moonbridge-0.9.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|