moonbridge 0.5.2__py3-none-any.whl → 0.6.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 CHANGED
@@ -1,8 +1,8 @@
1
- """MCP server for spawning Kimi K2.5 agents."""
1
+ """MCP server for spawning AI coding agents."""
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- __version__ = "0.5.2"
5
+ __version__ = "0.6.0"
6
6
 
7
7
  from .server import main, run, server
8
8
 
@@ -1,5 +1,5 @@
1
1
  from dataclasses import dataclass
2
- from typing import Protocol
2
+ from typing import Any, Protocol
3
3
 
4
4
 
5
5
  @dataclass(frozen=True)
@@ -18,6 +18,35 @@ class AdapterConfig:
18
18
  default_timeout: int = 600
19
19
 
20
20
 
21
+ @dataclass(frozen=True)
22
+ class AgentResult:
23
+ """Agent execution result."""
24
+
25
+ status: str
26
+ output: str
27
+ stderr: str | None
28
+ returncode: int
29
+ duration_ms: int
30
+ agent_index: int
31
+ message: str | None = None
32
+ raw: dict[str, Any] | None = None
33
+
34
+ def to_dict(self) -> dict[str, Any]:
35
+ payload: dict[str, Any] = {
36
+ "status": self.status,
37
+ "output": self.output,
38
+ "stderr": self.stderr,
39
+ "returncode": self.returncode,
40
+ "duration_ms": self.duration_ms,
41
+ "agent_index": self.agent_index,
42
+ }
43
+ if self.message is not None:
44
+ payload["message"] = self.message
45
+ if self.raw is not None:
46
+ payload["raw"] = self.raw
47
+ return payload
48
+
49
+
21
50
  class CLIAdapter(Protocol):
22
51
  """Protocol for CLI backend adapters."""
23
52
 
@@ -55,6 +55,7 @@ class CodexAdapter:
55
55
  "gpt-5.1-codex-mini",
56
56
  "gpt-5.1-codex-max",
57
57
  ),
58
+ default_timeout=1800, # 30 minutes - Codex runs long
58
59
  )
59
60
 
60
61
  def build_command(
@@ -38,6 +38,7 @@ class KimiAdapter:
38
38
  install_hint="uv tool install kimi-cli",
39
39
  supports_thinking=True,
40
40
  known_models=("kimi-k2.5",),
41
+ default_timeout=600, # 10 minutes - Kimi is faster
41
42
  )
42
43
 
43
44
  def build_command(
moonbridge/server.py CHANGED
@@ -1,4 +1,4 @@
1
- """MCP server for spawning Kimi K2.5 agents."""
1
+ """MCP server for spawning AI coding agents."""
2
2
 
3
3
  from __future__ import annotations
4
4
 
@@ -19,6 +19,8 @@ from mcp.server.stdio import stdio_server
19
19
  from mcp.types import TextContent, Tool
20
20
 
21
21
  from moonbridge.adapters import ADAPTER_REGISTRY, CLIAdapter, get_adapter
22
+ from moonbridge.adapters.base import AgentResult
23
+ from moonbridge.tools import build_tools
22
24
 
23
25
  server = Server("moonbridge")
24
26
 
@@ -71,8 +73,20 @@ def _safe_env(adapter: CLIAdapter) -> dict[str, str]:
71
73
  return env
72
74
 
73
75
 
74
- def _validate_timeout(timeout_seconds: int | None) -> int:
75
- value = DEFAULT_TIMEOUT if timeout_seconds is None else int(timeout_seconds)
76
+ def _resolve_timeout(adapter: CLIAdapter, timeout_seconds: int | None) -> int:
77
+ """Resolve timeout: explicit > adapter-env > adapter-default > global."""
78
+ if timeout_seconds is not None:
79
+ value = int(timeout_seconds)
80
+ else:
81
+ # Check adapter-specific env var first
82
+ env_key = f"MOONBRIDGE_{adapter.config.name.upper()}_TIMEOUT"
83
+ if env_val := os.environ.get(env_key):
84
+ value = int(env_val)
85
+ elif adapter.config.default_timeout != 600:
86
+ # Use adapter default if explicitly set (not the base default)
87
+ value = adapter.config.default_timeout
88
+ else:
89
+ value = DEFAULT_TIMEOUT
76
90
  if value < 30 or value > 3600:
77
91
  raise ValueError("timeout_seconds must be between 30 and 3600")
78
92
  return value
@@ -180,29 +194,6 @@ def _auth_error(stderr: str | None, adapter: CLIAdapter) -> bool:
180
194
  return any(pattern in lowered for pattern in adapter.config.auth_patterns)
181
195
 
182
196
 
183
- def _result(
184
- *,
185
- status: str,
186
- output: str,
187
- stderr: str | None,
188
- returncode: int,
189
- duration_ms: int,
190
- agent_index: int,
191
- message: str | None = None,
192
- ) -> dict[str, Any]:
193
- payload: dict[str, Any] = {
194
- "status": status,
195
- "output": output,
196
- "stderr": stderr,
197
- "returncode": returncode,
198
- "duration_ms": duration_ms,
199
- "agent_index": agent_index,
200
- }
201
- if message is not None:
202
- payload["message"] = message
203
- return payload
204
-
205
-
206
197
  def _run_cli_sync(
207
198
  adapter: CLIAdapter,
208
199
  prompt: str,
@@ -212,7 +203,7 @@ def _run_cli_sync(
212
203
  agent_index: int,
213
204
  model: str | None = None,
214
205
  reasoning_effort: str | None = None,
215
- ) -> dict[str, Any]:
206
+ ) -> AgentResult:
216
207
  start = time.monotonic()
217
208
  cmd = adapter.build_command(prompt, thinking, model, reasoning_effort)
218
209
  logger.debug("Spawning agent with prompt: %s...", prompt[:100])
@@ -229,7 +220,7 @@ def _run_cli_sync(
229
220
  except FileNotFoundError:
230
221
  duration_ms = int((time.monotonic() - start) * 1000)
231
222
  logger.error("%s CLI not found or not executable", adapter.config.name)
232
- return _result(
223
+ return AgentResult(
233
224
  status="error",
234
225
  output="",
235
226
  stderr=f"{adapter.config.name} CLI not found or not executable",
@@ -240,7 +231,7 @@ def _run_cli_sync(
240
231
  except PermissionError as exc:
241
232
  duration_ms = int((time.monotonic() - start) * 1000)
242
233
  logger.error("Permission denied starting process: %s", exc)
243
- return _result(
234
+ return AgentResult(
244
235
  status="error",
245
236
  output="",
246
237
  stderr=f"Permission denied: {exc}",
@@ -251,7 +242,7 @@ def _run_cli_sync(
251
242
  except OSError as exc:
252
243
  duration_ms = int((time.monotonic() - start) * 1000)
253
244
  logger.error("Failed to start process: %s", exc)
254
- return _result(
245
+ return AgentResult(
255
246
  status="error",
256
247
  output="",
257
248
  stderr=f"Failed to start process: {exc}",
@@ -266,7 +257,7 @@ def _run_cli_sync(
266
257
  stderr_value = stderr or None
267
258
  if _auth_error(stderr_value, adapter):
268
259
  logger.info("Agent %s completed with status: auth_error", agent_index)
269
- return _result(
260
+ return AgentResult(
270
261
  status="auth_error",
271
262
  output=stdout,
272
263
  stderr=stderr_value,
@@ -277,7 +268,7 @@ def _run_cli_sync(
277
268
  )
278
269
  status = "success" if proc.returncode == 0 else "error"
279
270
  logger.info("Agent %s completed with status: %s", agent_index, status)
280
- return _result(
271
+ return AgentResult(
281
272
  status=status,
282
273
  output=stdout,
283
274
  stderr=stderr_value,
@@ -289,7 +280,7 @@ def _run_cli_sync(
289
280
  _terminate_process(proc)
290
281
  duration_ms = int((time.monotonic() - start) * 1000)
291
282
  logger.warning("Agent %s timed out after %s seconds", agent_index, timeout_seconds)
292
- return _result(
283
+ return AgentResult(
293
284
  status="timeout",
294
285
  output="",
295
286
  stderr=None,
@@ -301,7 +292,7 @@ def _run_cli_sync(
301
292
  _terminate_process(proc)
302
293
  duration_ms = int((time.monotonic() - start) * 1000)
303
294
  logger.error("Agent %s failed with error: %s", agent_index, exc)
304
- return _result(
295
+ return AgentResult(
305
296
  status="error",
306
297
  output="",
307
298
  stderr=str(exc),
@@ -328,14 +319,18 @@ def _status_check(cwd: str, adapter: CLIAdapter) -> dict[str, Any]:
328
319
  }
329
320
  timeout = min(DEFAULT_TIMEOUT, 60)
330
321
  result = _run_cli_sync(adapter, "status check", False, cwd, timeout, 0)
331
- if result["status"] == "auth_error":
322
+ if result.status == "auth_error":
332
323
  return {"status": "auth_error", "message": adapter.config.auth_message}
333
- if result["status"] == "success":
324
+ if result.status == "success":
334
325
  return {
335
326
  "status": "success",
336
327
  "message": f"{adapter.config.name} CLI available and authenticated",
337
328
  }
338
- return {"status": "error", "message": f"{adapter.config.name} CLI error", "details": result}
329
+ return {
330
+ "status": "error",
331
+ "message": f"{adapter.config.name} CLI error",
332
+ "details": result.to_dict(),
333
+ }
339
334
 
340
335
 
341
336
  def _adapter_info(cwd: str, adapter: CLIAdapter) -> dict[str, Any]:
@@ -344,7 +339,7 @@ def _adapter_info(cwd: str, adapter: CLIAdapter) -> dict[str, Any]:
344
339
  if installed:
345
340
  timeout = min(DEFAULT_TIMEOUT, 60)
346
341
  result = _run_cli_sync(adapter, "status check", False, cwd, timeout, 0)
347
- authenticated = result["status"] == "success"
342
+ authenticated = result.status == "success"
348
343
  return {
349
344
  "name": adapter.config.name,
350
345
  "description": adapter.config.tool_description,
@@ -359,112 +354,13 @@ def _adapter_info(cwd: str, adapter: CLIAdapter) -> dict[str, Any]:
359
354
  async def list_tools() -> list[Tool]:
360
355
  adapter = get_adapter()
361
356
  tool_desc = adapter.config.tool_description
362
- parallel_desc = f"{tool_desc} Run multiple agents in parallel."
363
357
  status_desc = f"Verify {adapter.config.name} CLI is installed and authenticated"
364
- adapter_schema = {
365
- "type": "string",
366
- "enum": list(ADAPTER_REGISTRY.keys()),
367
- "description": "Backend to use (kimi, codex). Defaults to MOONBRIDGE_ADAPTER env or kimi.",
368
- }
369
- return [
370
- Tool(
371
- name="spawn_agent",
372
- description=tool_desc,
373
- inputSchema={
374
- "type": "object",
375
- "properties": {
376
- "prompt": {
377
- "type": "string",
378
- "description": "Instructions for the agent (task, context, constraints)",
379
- },
380
- "adapter": adapter_schema,
381
- "thinking": {
382
- "type": "boolean",
383
- "description": "Enable extended reasoning mode for complex tasks",
384
- "default": False,
385
- },
386
- "timeout_seconds": {
387
- "type": "integer",
388
- "description": "Max execution time (30-3600s)",
389
- "default": DEFAULT_TIMEOUT,
390
- "minimum": 30,
391
- "maximum": 3600,
392
- },
393
- "model": {
394
- "type": "string",
395
- "description": (
396
- "Model to use (e.g., 'gpt-5.2-codex', 'kimi-k2.5'). "
397
- "Falls back to MOONBRIDGE_{ADAPTER}_MODEL or MOONBRIDGE_MODEL env vars."
398
- ),
399
- },
400
- "reasoning_effort": {
401
- "type": "string",
402
- "enum": ["low", "medium", "high", "xhigh"],
403
- "description": (
404
- "Reasoning effort for Codex (low, medium, high, xhigh). "
405
- "Ignored for Kimi (use thinking instead)."
406
- ),
407
- },
408
- },
409
- "required": ["prompt"],
410
- },
411
- ),
412
- Tool(
413
- name="spawn_agents_parallel",
414
- description=parallel_desc,
415
- inputSchema={
416
- "type": "object",
417
- "properties": {
418
- "agents": {
419
- "type": "array",
420
- "description": "List of agent specs with prompt and optional settings",
421
- "items": {
422
- "type": "object",
423
- "properties": {
424
- "prompt": {"type": "string"},
425
- "adapter": adapter_schema,
426
- "thinking": {"type": "boolean", "default": False},
427
- "timeout_seconds": {
428
- "type": "integer",
429
- "description": "Max execution time (30-3600s)",
430
- "default": DEFAULT_TIMEOUT,
431
- "minimum": 30,
432
- "maximum": 3600,
433
- },
434
- "model": {
435
- "type": "string",
436
- "description": (
437
- "Model to use. Falls back to "
438
- "MOONBRIDGE_{ADAPTER}_MODEL or MOONBRIDGE_MODEL env vars."
439
- ),
440
- },
441
- "reasoning_effort": {
442
- "type": "string",
443
- "enum": ["low", "medium", "high", "xhigh"],
444
- "description": (
445
- "Reasoning effort for Codex (low, medium, high, xhigh). "
446
- "Ignored for Kimi."
447
- ),
448
- },
449
- },
450
- "required": ["prompt"],
451
- },
452
- },
453
- },
454
- "required": ["agents"],
455
- },
456
- ),
457
- Tool(
458
- name="list_adapters",
459
- description="List available adapters and their status",
460
- inputSchema={"type": "object", "properties": {}},
461
- ),
462
- Tool(
463
- name="check_status",
464
- description=status_desc,
465
- inputSchema={"type": "object", "properties": {}},
466
- ),
467
- ]
358
+ return build_tools(
359
+ adapter_names=tuple(ADAPTER_REGISTRY.keys()),
360
+ default_timeout=DEFAULT_TIMEOUT,
361
+ tool_description=tool_desc,
362
+ status_description=status_desc,
363
+ )
468
364
 
469
365
 
470
366
  async def handle_tool(name: str, arguments: dict[str, Any]) -> list[TextContent]:
@@ -475,7 +371,7 @@ async def handle_tool(name: str, arguments: dict[str, Any]) -> list[TextContent]
475
371
  adapter = get_adapter(arguments.get("adapter"))
476
372
  prompt = _validate_prompt(arguments["prompt"])
477
373
  thinking = _validate_thinking(adapter, bool(arguments.get("thinking", False)))
478
- timeout_seconds = _validate_timeout(arguments.get("timeout_seconds"))
374
+ timeout_seconds = _resolve_timeout(adapter, arguments.get("timeout_seconds"))
479
375
  model = _resolve_model(adapter, arguments.get("model"))
480
376
  reasoning_effort = arguments.get("reasoning_effort")
481
377
  loop = asyncio.get_running_loop()
@@ -494,16 +390,16 @@ async def handle_tool(name: str, arguments: dict[str, Any]) -> list[TextContent]
494
390
  )
495
391
  except asyncio.CancelledError:
496
392
  return _json_text(
497
- _result(
393
+ AgentResult(
498
394
  status="cancelled",
499
395
  output="",
500
396
  stderr=None,
501
397
  returncode=-1,
502
398
  duration_ms=0,
503
399
  agent_index=0,
504
- )
400
+ ).to_dict()
505
401
  )
506
- return _json_text(result)
402
+ return _json_text(result.to_dict())
507
403
 
508
404
  if name == "spawn_agents_parallel":
509
405
  agents = list(arguments["agents"])
@@ -525,7 +421,7 @@ async def handle_tool(name: str, arguments: dict[str, Any]) -> list[TextContent]
525
421
  prompt,
526
422
  thinking,
527
423
  cwd,
528
- _validate_timeout(spec.get("timeout_seconds")),
424
+ _resolve_timeout(adapter, spec.get("timeout_seconds")),
529
425
  idx,
530
426
  model,
531
427
  reasoning_effort,
@@ -535,7 +431,7 @@ async def handle_tool(name: str, arguments: dict[str, Any]) -> list[TextContent]
535
431
  results = await asyncio.gather(*tasks)
536
432
  except asyncio.CancelledError:
537
433
  cancelled = [
538
- _result(
434
+ AgentResult(
539
435
  status="cancelled",
540
436
  output="",
541
437
  stderr=None,
@@ -545,9 +441,9 @@ async def handle_tool(name: str, arguments: dict[str, Any]) -> list[TextContent]
545
441
  )
546
442
  for idx in range(len(agents))
547
443
  ]
548
- return _json_text(cancelled)
549
- results.sort(key=lambda item: item["agent_index"])
550
- return _json_text(results)
444
+ return _json_text([item.to_dict() for item in cancelled])
445
+ results.sort(key=lambda item: item.agent_index)
446
+ return _json_text([item.to_dict() for item in results])
551
447
 
552
448
  if name == "list_adapters":
553
449
  info = [_adapter_info(cwd, adapter) for adapter in ADAPTER_REGISTRY.values()]
moonbridge/tools.py ADDED
@@ -0,0 +1,334 @@
1
+ """Tool schema definitions for Moonbridge MCP server.
2
+
3
+ This module provides dataclasses and functions for defining MCP tool schemas
4
+ in a reusable, type-safe manner.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass
10
+ from typing import Any
11
+
12
+ from mcp.types import Tool
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class ParameterDef:
17
+ """Definition for a JSON Schema parameter."""
18
+
19
+ type: str # "string", "integer", "boolean", "array"
20
+ description: str
21
+ default: Any = None
22
+ enum: tuple[str, ...] | None = None
23
+ minimum: int | None = None
24
+ maximum: int | None = None
25
+ items: dict[str, Any] | None = None # For array types
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class ToolDef:
30
+ """Definition for an MCP tool."""
31
+
32
+ name: str
33
+ description_template: str # May contain {adapter} placeholder
34
+ parameters: tuple[tuple[str, ParameterDef], ...] # Ordered (name, param) pairs
35
+ required: tuple[str, ...] = ()
36
+
37
+
38
+ # =============================================================================
39
+ # Reusable parameter definitions
40
+ # =============================================================================
41
+
42
+ PROMPT_PARAM = ParameterDef(
43
+ type="string",
44
+ description="Instructions for the agent (task, context, constraints)",
45
+ )
46
+
47
+ # Note: ADAPTER_PARAM enum is populated dynamically via build_adapter_param()
48
+ ADAPTER_PARAM_BASE = ParameterDef(
49
+ type="string",
50
+ description="Backend to use (kimi, codex). Defaults to MOONBRIDGE_ADAPTER env or kimi.",
51
+ # enum is set dynamically
52
+ )
53
+
54
+ THINKING_PARAM = ParameterDef(
55
+ type="boolean",
56
+ description="Enable extended reasoning mode for complex tasks",
57
+ default=False,
58
+ )
59
+
60
+ # Note: TIMEOUT_PARAM default is populated dynamically
61
+ TIMEOUT_PARAM_BASE = ParameterDef(
62
+ type="integer",
63
+ description=(
64
+ "Max execution time (30-3600s). "
65
+ "Defaults: Codex=1800s (30min), Kimi=600s (10min). "
66
+ "Complex implementations may need full 30min+."
67
+ ),
68
+ minimum=30,
69
+ maximum=3600,
70
+ # default is set dynamically
71
+ )
72
+
73
+ MODEL_PARAM = ParameterDef(
74
+ type="string",
75
+ description=(
76
+ "Model to use (e.g., 'gpt-5.2-codex', 'kimi-k2.5'). "
77
+ "Falls back to MOONBRIDGE_{ADAPTER}_MODEL or MOONBRIDGE_MODEL env vars."
78
+ ),
79
+ )
80
+
81
+ # Shorter model description for nested items
82
+ MODEL_PARAM_SHORT = ParameterDef(
83
+ type="string",
84
+ description=(
85
+ "Model to use. Falls back to "
86
+ "MOONBRIDGE_{ADAPTER}_MODEL or MOONBRIDGE_MODEL env vars."
87
+ ),
88
+ )
89
+
90
+ REASONING_EFFORT_PARAM = ParameterDef(
91
+ type="string",
92
+ description=(
93
+ "Reasoning effort for Codex (low, medium, high, xhigh). "
94
+ "Ignored for Kimi (use thinking instead)."
95
+ ),
96
+ enum=("low", "medium", "high", "xhigh"),
97
+ )
98
+
99
+ # Shorter reasoning_effort description for nested items
100
+ REASONING_EFFORT_PARAM_SHORT = ParameterDef(
101
+ type="string",
102
+ description=(
103
+ "Reasoning effort for Codex (low, medium, high, xhigh). "
104
+ "Ignored for Kimi."
105
+ ),
106
+ enum=("low", "medium", "high", "xhigh"),
107
+ )
108
+
109
+
110
+ # =============================================================================
111
+ # Helper functions for dynamic parameter creation
112
+ # =============================================================================
113
+
114
+
115
+ def _build_adapter_param(adapter_names: tuple[str, ...]) -> ParameterDef:
116
+ """Create adapter parameter with dynamic enum."""
117
+ return ParameterDef(
118
+ type="string",
119
+ description=ADAPTER_PARAM_BASE.description,
120
+ enum=adapter_names,
121
+ )
122
+
123
+
124
+ def _build_timeout_param(default_timeout: int) -> ParameterDef:
125
+ """Create timeout parameter with dynamic default.
126
+
127
+ Raises:
128
+ ValueError: If default_timeout is outside the valid range.
129
+ """
130
+ min_timeout = TIMEOUT_PARAM_BASE.minimum
131
+ max_timeout = TIMEOUT_PARAM_BASE.maximum
132
+ if min_timeout is not None and default_timeout < min_timeout:
133
+ raise ValueError(f"default_timeout must be >= {min_timeout}, got {default_timeout}")
134
+ if max_timeout is not None and default_timeout > max_timeout:
135
+ raise ValueError(f"default_timeout must be <= {max_timeout}, got {default_timeout}")
136
+ return ParameterDef(
137
+ type="integer",
138
+ description=TIMEOUT_PARAM_BASE.description,
139
+ default=default_timeout,
140
+ minimum=min_timeout,
141
+ maximum=max_timeout,
142
+ )
143
+
144
+
145
+ # =============================================================================
146
+ # Tool definitions
147
+ # =============================================================================
148
+
149
+ SPAWN_AGENT_TOOL = ToolDef(
150
+ name="spawn_agent",
151
+ description_template="{tool_description}",
152
+ parameters=(
153
+ ("prompt", PROMPT_PARAM),
154
+ ("adapter", ADAPTER_PARAM_BASE), # Will be replaced with dynamic version
155
+ ("thinking", THINKING_PARAM),
156
+ ("timeout_seconds", TIMEOUT_PARAM_BASE), # Will be replaced with dynamic version
157
+ ("model", MODEL_PARAM),
158
+ ("reasoning_effort", REASONING_EFFORT_PARAM),
159
+ ),
160
+ required=("prompt",),
161
+ )
162
+
163
+ SPAWN_AGENTS_PARALLEL_TOOL = ToolDef(
164
+ name="spawn_agents_parallel",
165
+ description_template="{tool_description} Run multiple agents in parallel.",
166
+ parameters=(), # Handled specially due to array items
167
+ required=("agents",),
168
+ )
169
+
170
+ LIST_ADAPTERS_TOOL = ToolDef(
171
+ name="list_adapters",
172
+ description_template="List available adapters and their status",
173
+ parameters=(),
174
+ required=(),
175
+ )
176
+
177
+ CHECK_STATUS_TOOL = ToolDef(
178
+ name="check_status",
179
+ description_template="{status_description}",
180
+ parameters=(),
181
+ required=(),
182
+ )
183
+
184
+
185
+ # =============================================================================
186
+ # Schema generation functions
187
+ # =============================================================================
188
+
189
+
190
+ def _param_to_schema(param: ParameterDef) -> dict[str, Any]:
191
+ """Convert a ParameterDef to a JSON Schema dict."""
192
+ schema: dict[str, Any] = {"type": param.type}
193
+
194
+ if param.description:
195
+ schema["description"] = param.description
196
+ if param.default is not None:
197
+ schema["default"] = param.default
198
+ if param.enum is not None:
199
+ schema["enum"] = list(param.enum)
200
+ if param.minimum is not None:
201
+ schema["minimum"] = param.minimum
202
+ if param.maximum is not None:
203
+ schema["maximum"] = param.maximum
204
+ if param.items is not None:
205
+ schema["items"] = param.items
206
+
207
+ return schema
208
+
209
+
210
+ def build_input_schema(
211
+ tool: ToolDef,
212
+ adapter_names: tuple[str, ...],
213
+ default_timeout: int,
214
+ ) -> dict[str, Any]:
215
+ """Convert ToolDef to MCP inputSchema dict.
216
+
217
+ Args:
218
+ tool: The tool definition to convert.
219
+ adapter_names: Tuple of available adapter names for enum.
220
+ default_timeout: Default timeout value for timeout parameters.
221
+
222
+ Returns:
223
+ A JSON Schema dict suitable for MCP Tool.inputSchema.
224
+ """
225
+ properties: dict[str, Any] = {}
226
+
227
+ for name, param in tool.parameters:
228
+ # Handle dynamic parameters
229
+ if name == "adapter":
230
+ param = _build_adapter_param(adapter_names)
231
+ elif name == "timeout_seconds":
232
+ param = _build_timeout_param(default_timeout)
233
+
234
+ properties[name] = _param_to_schema(param)
235
+
236
+ schema: dict[str, Any] = {
237
+ "type": "object",
238
+ "properties": properties,
239
+ }
240
+
241
+ if tool.required:
242
+ schema["required"] = list(tool.required)
243
+
244
+ return schema
245
+
246
+
247
+ def _build_agents_array_schema(
248
+ adapter_names: tuple[str, ...],
249
+ default_timeout: int,
250
+ ) -> dict[str, Any]:
251
+ """Build the schema for the agents array in spawn_agents_parallel."""
252
+ adapter_schema = _param_to_schema(_build_adapter_param(adapter_names))
253
+ timeout_schema = _param_to_schema(_build_timeout_param(default_timeout))
254
+
255
+ return {
256
+ "type": "array",
257
+ "description": "List of agent specs with prompt and optional settings",
258
+ "items": {
259
+ "type": "object",
260
+ "properties": {
261
+ "prompt": {"type": "string"},
262
+ "adapter": adapter_schema,
263
+ "thinking": {"type": "boolean", "default": False},
264
+ "timeout_seconds": timeout_schema,
265
+ "model": _param_to_schema(MODEL_PARAM_SHORT),
266
+ "reasoning_effort": _param_to_schema(REASONING_EFFORT_PARAM_SHORT),
267
+ },
268
+ "required": ["prompt"],
269
+ },
270
+ }
271
+
272
+
273
+ def build_tools(
274
+ adapter_names: tuple[str, ...],
275
+ default_timeout: int,
276
+ tool_description: str,
277
+ status_description: str,
278
+ ) -> list[Tool]:
279
+ """Build all MCP Tool objects from definitions.
280
+
281
+ Args:
282
+ adapter_names: Tuple of available adapter names.
283
+ default_timeout: Default timeout value in seconds.
284
+ tool_description: Description for the spawn_agent tool.
285
+ status_description: Description for the check_status tool.
286
+
287
+ Returns:
288
+ List of MCP Tool objects ready for registration.
289
+ """
290
+ # spawn_agent
291
+ spawn_agent_schema = build_input_schema(
292
+ SPAWN_AGENT_TOOL, adapter_names, default_timeout
293
+ )
294
+
295
+ # spawn_agents_parallel (special handling for array)
296
+ parallel_schema: dict[str, Any] = {
297
+ "type": "object",
298
+ "properties": {
299
+ "agents": _build_agents_array_schema(adapter_names, default_timeout),
300
+ },
301
+ "required": ["agents"],
302
+ }
303
+
304
+ # list_adapters
305
+ list_adapters_schema: dict[str, Any] = {"type": "object", "properties": {}}
306
+
307
+ # check_status
308
+ check_status_schema: dict[str, Any] = {"type": "object", "properties": {}}
309
+
310
+ return [
311
+ Tool(
312
+ name="spawn_agent",
313
+ description=tool_description,
314
+ inputSchema=spawn_agent_schema,
315
+ ),
316
+ Tool(
317
+ name="spawn_agents_parallel",
318
+ description=f"{tool_description} Run multiple agents in parallel.",
319
+ inputSchema=parallel_schema,
320
+ ),
321
+ Tool(
322
+ name="list_adapters",
323
+ description="List available adapters and their status",
324
+ inputSchema=list_adapters_schema,
325
+ ),
326
+ Tool(
327
+ name="check_status",
328
+ description=status_description,
329
+ inputSchema=check_status_schema,
330
+ ),
331
+ ]
332
+
333
+
334
+ __all__ = ["build_tools"]
@@ -1,14 +1,14 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: moonbridge
3
- Version: 0.5.2
4
- Summary: MCP server for spawning Kimi K2.5 agents
3
+ Version: 0.6.0
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
7
7
  Project-URL: Issues, https://github.com/misty-step/moonbridge/issues
8
8
  Author-email: Phaedrus <hello@mistystep.io>
9
9
  License-Expression: MIT
10
10
  License-File: LICENSE
11
- Keywords: agent,ai,claude,kimi,mcp
11
+ Keywords: agent,ai,claude,codex,kimi,mcp
12
12
  Classifier: Development Status :: 4 - Beta
13
13
  Classifier: Environment :: Console
14
14
  Classifier: Intended Audience :: Developers
@@ -31,7 +31,7 @@ Description-Content-Type: text/markdown
31
31
 
32
32
  **Your MCP client just got a team.**
33
33
 
34
- Spawn Kimi K2.5 agents from Claude Code, Cursor, or any MCP client. Run 10 approaches in parallel for the cost of one Claude request.
34
+ Spawn AI coding agents from Claude Code, Cursor, or any MCP client. Run 10 approaches in parallel for a fraction of the cost.
35
35
 
36
36
  ```bash
37
37
  uvx moonbridge
@@ -39,10 +39,12 @@ uvx moonbridge
39
39
 
40
40
  ## Quick Start
41
41
 
42
- 1. **Install Kimi CLI and authenticate:**
43
- ```bash
44
- uv tool install --python 3.13 kimi-cli && kimi login
45
- ```
42
+ 1. **Install at least one supported CLI:**
43
+
44
+ | Adapter | Install | Authenticate |
45
+ |---------|---------|--------------|
46
+ | Kimi (default) | `uv tool install --python 3.13 kimi-cli` | `kimi login` |
47
+ | Codex | `npm install -g @openai/codex` | Set `OPENAI_API_KEY` |
46
48
 
47
49
  2. **Add to MCP config** (`~/.mcp.json`):
48
50
  ```json
@@ -94,7 +96,8 @@ export MOONBRIDGE_SKIP_UPDATE_CHECK=1
94
96
  |------|----------|
95
97
  | `spawn_agent` | Single task: "Write tests for auth.ts" |
96
98
  | `spawn_agents_parallel` | Go wide: 10 agents, 10 approaches, pick the best |
97
- | `check_status` | Verify Kimi CLI is installed and authenticated |
99
+ | `check_status` | Verify the configured CLI is installed and authenticated |
100
+ | `list_adapters` | Show available adapters and their status |
98
101
 
99
102
  ### Example: Parallel Exploration
100
103
 
@@ -117,7 +120,10 @@ Three approaches. One request. You choose the winner.
117
120
  | Parameter | Type | Required | Description |
118
121
  |-----------|------|----------|-------------|
119
122
  | `prompt` | string | Yes | Task description for the agent |
120
- | `thinking` | boolean | No | Enable reasoning mode (default: false) |
123
+ | `adapter` | string | No | Backend to use: `kimi`, `codex` (default: `kimi`) |
124
+ | `model` | string | No | Model override (e.g., `gpt-5.2-codex`) |
125
+ | `thinking` | boolean | No | Enable reasoning mode (Kimi only) |
126
+ | `reasoning_effort` | string | No | Reasoning budget: `low`, `medium`, `high`, `xhigh` (Codex only) |
121
127
  | `timeout_seconds` | integer | No | Override default timeout (30-3600) |
122
128
 
123
129
  **`spawn_agents_parallel`**
@@ -126,7 +132,10 @@ Three approaches. One request. You choose the winner.
126
132
  |-----------|------|----------|-------------|
127
133
  | `agents` | array | Yes | List of agent configs (max 10) |
128
134
  | `agents[].prompt` | string | Yes | Task for this agent |
129
- | `agents[].thinking` | boolean | No | Enable reasoning for this agent |
135
+ | `agents[].adapter` | string | No | Backend for this agent |
136
+ | `agents[].model` | string | No | Model override for this agent |
137
+ | `agents[].thinking` | boolean | No | Enable reasoning (Kimi only) |
138
+ | `agents[].reasoning_effort` | string | No | Reasoning budget (Codex only) |
130
139
  | `agents[].timeout_seconds` | integer | No | Timeout for this agent |
131
140
 
132
141
  ## Response Format
@@ -136,7 +145,7 @@ All tools return JSON with these fields:
136
145
  | Field | Type | Description |
137
146
  |-------|------|-------------|
138
147
  | `status` | string | `success`, `error`, `timeout`, `auth_error`, or `cancelled` |
139
- | `output` | string | stdout from Kimi agent |
148
+ | `output` | string | stdout from the agent |
140
149
  | `stderr` | string\|null | stderr if any |
141
150
  | `returncode` | int | Process exit code (-1 for timeout/error) |
142
151
  | `duration_ms` | int | Execution time in milliseconds |
@@ -158,37 +167,60 @@ All tools return JSON with these fields:
158
167
 
159
168
  ## Troubleshooting
160
169
 
161
- ### "Kimi CLI not found"
170
+ ### "CLI not found"
162
171
 
163
- Install the Kimi CLI:
172
+ Install the CLI for your chosen adapter:
164
173
 
165
174
  ```bash
175
+ # Kimi
166
176
  uv tool install --python 3.13 kimi-cli
167
177
  which kimi
178
+
179
+ # Codex
180
+ npm install -g @openai/codex
181
+ which codex
168
182
  ```
169
183
 
170
184
  ### "auth_error" responses
171
185
 
172
- Authenticate with Kimi:
186
+ Authenticate with your chosen CLI:
173
187
 
174
188
  ```bash
189
+ # Kimi
175
190
  kimi login
191
+
192
+ # Codex
193
+ export OPENAI_API_KEY=sk-...
176
194
  ```
177
195
 
178
196
  ### Timeout errors
179
197
 
180
- Increase the timeout for long-running tasks:
198
+ Adapters have sensible defaults: Codex=1800s (30min), Kimi=600s (10min).
199
+
200
+ For exceptionally long tasks, override explicitly:
181
201
 
182
202
  ```json
183
- {"prompt": "...", "timeout_seconds": 1800}
203
+ {"prompt": "...", "timeout_seconds": 3600}
184
204
  ```
185
205
 
186
- Or set a global default:
206
+ Or set per-adapter defaults via environment:
187
207
 
188
208
  ```bash
189
- export MOONBRIDGE_TIMEOUT=1800
209
+ export MOONBRIDGE_CODEX_TIMEOUT=2400 # 40 minutes
210
+ export MOONBRIDGE_KIMI_TIMEOUT=900 # 15 minutes
190
211
  ```
191
212
 
213
+ ## Timeout Best Practices
214
+
215
+ | Task Type | Recommended |
216
+ |-----------|-------------|
217
+ | Quick query, status | 60-180s |
218
+ | Simple edits | 300-600s |
219
+ | Feature implementation | 1200-1800s |
220
+ | Large refactor | 1800-3600s |
221
+
222
+ Priority resolution: explicit param > adapter env > adapter default > global env > 600s fallback
223
+
192
224
  ### "MOONBRIDGE_ALLOWED_DIRS is not set" warning
193
225
 
194
226
  By default, Moonbridge warns at startup if no directory restrictions are configured. This is expected for local development. For shared/production environments, set allowed directories:
@@ -0,0 +1,14 @@
1
+ moonbridge/__init__.py,sha256=K5-NRJiYdFbIITQmjIg3_Fkef0UEWhe2hqO3rJ0MZII,198
2
+ moonbridge/py.typed,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
3
+ moonbridge/server.py,sha256=o-u0J-HsVoa7sxaweWPHo_iHDXJfao16eDFe_9bm6RA,17071
4
+ moonbridge/tools.py,sha256=uw338Dilrto2t5dL9XbK4O31-JdB7Vh9RqCXHg20gHI,10126
5
+ moonbridge/version_check.py,sha256=VQueK0O_b-2Xc-XjupJsoW3Zs1Kce5q_BgqBhANGXN8,4579
6
+ moonbridge/adapters/__init__.py,sha256=w3pLvjtC2XnUhf9UzNmniQB3oq4rG8gorSH0tWR-BEE,988
7
+ moonbridge/adapters/base.py,sha256=REoEsAcqEvyVQpTgz6ytd9ioxag--nnvX90YBXMQG8Y,1716
8
+ moonbridge/adapters/codex.py,sha256=GtU4CrJ4zt0WDcKKaOeN7gH4JFIBAo3L7KAZ99zRjiY,2935
9
+ moonbridge/adapters/kimi.py,sha256=ejCxG2OGr0Qr4n0psL6p96_mMJ3lLKMbGcNYWkuC0uA,2189
10
+ moonbridge-0.6.0.dist-info/METADATA,sha256=nJndtrv2GuSSPeNrV6ryJa6-82viUtankQXduB6Nkn4,7298
11
+ moonbridge-0.6.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
12
+ moonbridge-0.6.0.dist-info/entry_points.txt,sha256=kgL38HQy3adncDQl_o5sdtPRog56zKdHk6pKKzyR6Ww,54
13
+ moonbridge-0.6.0.dist-info/licenses/LICENSE,sha256=7WMSJoybL2cUot_wb9GUrw5mzfFmtrDzqlMS9ZE709g,1065
14
+ moonbridge-0.6.0.dist-info/RECORD,,
@@ -1,13 +0,0 @@
1
- moonbridge/__init__.py,sha256=x3eYCVqjhWKoPmGJvOV3IALPoS1DFO-iZRECuVbNgtQ,198
2
- moonbridge/py.typed,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
3
- moonbridge/server.py,sha256=rP3c0hcuUDxC5QqPNWboJZYnMS4hZw6oqkzmg-N0WwM,21194
4
- moonbridge/version_check.py,sha256=VQueK0O_b-2Xc-XjupJsoW3Zs1Kce5q_BgqBhANGXN8,4579
5
- moonbridge/adapters/__init__.py,sha256=w3pLvjtC2XnUhf9UzNmniQB3oq4rG8gorSH0tWR-BEE,988
6
- moonbridge/adapters/base.py,sha256=bj_Ms55h2lwDmEO0CZ1RFSAA9IHgNbX2LI1xgQEftLY,942
7
- moonbridge/adapters/codex.py,sha256=JTt9B3eXqset6ZrwwlnHzcno5PMdrjY2GdLSNPYkowQ,2873
8
- moonbridge/adapters/kimi.py,sha256=75QFPTMVpgbgkVGv8GEpIYY1zrIOZ0kJ-aCgd8Tx0TA,2129
9
- moonbridge-0.5.2.dist-info/METADATA,sha256=aaN1N00Q5oY2bE6faQWW163_goAPVMQDtEEWTlkUnBE,5984
10
- moonbridge-0.5.2.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
11
- moonbridge-0.5.2.dist-info/entry_points.txt,sha256=kgL38HQy3adncDQl_o5sdtPRog56zKdHk6pKKzyR6Ww,54
12
- moonbridge-0.5.2.dist-info/licenses/LICENSE,sha256=7WMSJoybL2cUot_wb9GUrw5mzfFmtrDzqlMS9ZE709g,1065
13
- moonbridge-0.5.2.dist-info/RECORD,,