monkeyscode 1.0.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.
monkeyscode/mcp.py ADDED
@@ -0,0 +1,283 @@
1
+ """
2
+ MonkeysCode SDK — MCP Server Manager.
3
+
4
+ Launch and manage Model Context Protocol (MCP) server processes.
5
+ Discover and call tools exposed by MCP servers.
6
+
7
+ Usage::
8
+
9
+ from monkeyscode.mcp import McpManager
10
+
11
+ mcp = McpManager()
12
+ await mcp.connect("postgres", McpServerConfig(
13
+ command="npx",
14
+ args=["@modelcontextprotocol/server-postgres"],
15
+ env={"DATABASE_URL": "postgresql://localhost/mydb"},
16
+ ))
17
+
18
+ tools = await mcp.list_tools("postgres")
19
+ result = await mcp.call_tool("postgres", "query", {"sql": "SELECT 1"})
20
+
21
+ mcp.disconnect_all()
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import asyncio
27
+ import json
28
+ import os
29
+ from dataclasses import dataclass, field
30
+ from typing import Any
31
+
32
+ # ── Types ────────────────────────────────────────────────────────────
33
+
34
+
35
+ @dataclass
36
+ class McpServerConfig:
37
+ """MCP server configuration."""
38
+
39
+ command: str
40
+ args: list[str] = field(default_factory=list)
41
+ env: dict[str, str] = field(default_factory=dict)
42
+ cwd: str | None = None
43
+ timeout: float = 30.0
44
+
45
+
46
+ @dataclass
47
+ class McpTool:
48
+ """An MCP tool descriptor."""
49
+
50
+ name: str
51
+ description: str
52
+ input_schema: dict[str, Any] = field(default_factory=dict)
53
+
54
+
55
+ @dataclass
56
+ class McpToolResult:
57
+ """Result from calling an MCP tool."""
58
+
59
+ content: list[dict[str, Any]] = field(default_factory=list)
60
+ is_error: bool = False
61
+
62
+
63
+ @dataclass
64
+ class McpServerStatus:
65
+ """Status of an MCP server."""
66
+
67
+ name: str
68
+ pid: int | None = None
69
+ connected: bool = False
70
+ tool_count: int = 0
71
+
72
+
73
+ # ── JSON-RPC Helpers ─────────────────────────────────────────────────
74
+
75
+
76
+ class _JsonRpcTransport:
77
+ """Communicate with an MCP server over stdin/stdout JSON-RPC."""
78
+
79
+ def __init__(self, process: asyncio.subprocess.Process) -> None:
80
+ self._process = process
81
+ self._request_id = 0
82
+
83
+ async def request(self, method: str, params: dict[str, Any] | None = None) -> Any:
84
+ self._request_id += 1
85
+ msg = {
86
+ "jsonrpc": "2.0",
87
+ "id": self._request_id,
88
+ "method": method,
89
+ "params": params or {},
90
+ }
91
+
92
+ stdin = self._process.stdin
93
+ stdout = self._process.stdout
94
+ assert stdin is not None and stdout is not None
95
+
96
+ line = json.dumps(msg) + "\n"
97
+ stdin.write(line.encode())
98
+ await stdin.drain()
99
+
100
+ response_line = await asyncio.wait_for(stdout.readline(), timeout=30.0)
101
+ if not response_line:
102
+ raise RuntimeError("MCP server closed connection")
103
+
104
+ data = json.loads(response_line.decode())
105
+ if "error" in data:
106
+ raise RuntimeError(f"MCP error: {data['error']}")
107
+ return data.get("result")
108
+
109
+ async def notify(self, method: str, params: dict[str, Any] | None = None) -> None:
110
+ msg = {
111
+ "jsonrpc": "2.0",
112
+ "method": method,
113
+ "params": params or {},
114
+ }
115
+ stdin = self._process.stdin
116
+ assert stdin is not None
117
+ line = json.dumps(msg) + "\n"
118
+ stdin.write(line.encode())
119
+ await stdin.drain()
120
+
121
+
122
+ # ── MCP Manager ──────────────────────────────────────────────────────
123
+
124
+
125
+ class McpManager:
126
+ """
127
+ Manages MCP server lifecycle and tool invocations.
128
+
129
+ Launches servers as child processes, communicates via JSON-RPC over
130
+ stdin/stdout, and provides tool discovery and invocation.
131
+ """
132
+
133
+ def __init__(self) -> None:
134
+ self._servers: dict[str, _McpConnection] = {}
135
+
136
+ @property
137
+ def server_names(self) -> list[str]:
138
+ return list(self._servers.keys())
139
+
140
+ @property
141
+ def server_count(self) -> int:
142
+ return len(self._servers)
143
+
144
+ async def connect(self, name: str, config: McpServerConfig) -> McpServerStatus:
145
+ """Launch and connect to an MCP server."""
146
+ env = {**os.environ, **config.env}
147
+
148
+ process = await asyncio.create_subprocess_exec(
149
+ config.command,
150
+ *config.args,
151
+ stdin=asyncio.subprocess.PIPE,
152
+ stdout=asyncio.subprocess.PIPE,
153
+ stderr=asyncio.subprocess.PIPE,
154
+ cwd=config.cwd,
155
+ env=env,
156
+ )
157
+
158
+ transport = _JsonRpcTransport(process)
159
+
160
+ # Initialize the server
161
+ try:
162
+ await transport.request("initialize", {
163
+ "protocolVersion": "2024-11-05",
164
+ "capabilities": {},
165
+ "clientInfo": {"name": "monkeyscode-sdk-python", "version": "1.0.0"},
166
+ })
167
+ await transport.notify("notifications/initialized")
168
+ except Exception:
169
+ process.kill()
170
+ raise
171
+
172
+ # Discover tools
173
+ try:
174
+ result = await transport.request("tools/list")
175
+ tools = [
176
+ McpTool(
177
+ name=t["name"],
178
+ description=t.get("description", ""),
179
+ input_schema=t.get("inputSchema", {}),
180
+ )
181
+ for t in (result.get("tools", []) if result else [])
182
+ ]
183
+ except Exception:
184
+ tools = []
185
+
186
+ conn = _McpConnection(
187
+ name=name,
188
+ config=config,
189
+ process=process,
190
+ transport=transport,
191
+ tools=tools,
192
+ )
193
+ self._servers[name] = conn
194
+
195
+ return McpServerStatus(
196
+ name=name,
197
+ pid=process.pid,
198
+ connected=True,
199
+ tool_count=len(tools),
200
+ )
201
+
202
+ async def disconnect(self, name: str) -> None:
203
+ """Disconnect and kill an MCP server."""
204
+ conn = self._servers.pop(name, None)
205
+ if conn is None:
206
+ return
207
+
208
+ try:
209
+ conn.process.terminate()
210
+ await asyncio.wait_for(conn.process.wait(), timeout=5.0)
211
+ except (asyncio.TimeoutError, ProcessLookupError):
212
+ conn.process.kill()
213
+
214
+ def disconnect_all(self) -> None:
215
+ """Disconnect all MCP servers synchronously."""
216
+ for name in list(self._servers.keys()):
217
+ conn = self._servers.pop(name, None)
218
+ if conn and conn.process.returncode is None:
219
+ try:
220
+ conn.process.kill()
221
+ except ProcessLookupError:
222
+ pass
223
+
224
+ async def list_tools(self, name: str) -> list[McpTool]:
225
+ """List tools available from a named server."""
226
+ conn = self._servers.get(name)
227
+ if conn is None:
228
+ raise KeyError(f"MCP server not connected: {name}")
229
+ return conn.tools
230
+
231
+ async def call_tool(
232
+ self,
233
+ server_name: str,
234
+ tool_name: str,
235
+ arguments: dict[str, Any] | None = None,
236
+ ) -> McpToolResult:
237
+ """Call a tool on a named MCP server."""
238
+ conn = self._servers.get(server_name)
239
+ if conn is None:
240
+ raise KeyError(f"MCP server not connected: {server_name}")
241
+
242
+ result = await conn.transport.request("tools/call", {
243
+ "name": tool_name,
244
+ "arguments": arguments or {},
245
+ })
246
+
247
+ if result is None:
248
+ return McpToolResult(is_error=True)
249
+
250
+ return McpToolResult(
251
+ content=result.get("content", []),
252
+ is_error=result.get("isError", False),
253
+ )
254
+
255
+ def status(self, name: str) -> McpServerStatus:
256
+ """Get the status of a named server."""
257
+ conn = self._servers.get(name)
258
+ if conn is None:
259
+ return McpServerStatus(name=name, connected=False)
260
+ return McpServerStatus(
261
+ name=name,
262
+ pid=conn.process.pid,
263
+ connected=conn.process.returncode is None,
264
+ tool_count=len(conn.tools),
265
+ )
266
+
267
+ def all_tools(self) -> list[McpTool]:
268
+ """List tools from all connected servers."""
269
+ tools: list[McpTool] = []
270
+ for conn in self._servers.values():
271
+ tools.extend(conn.tools)
272
+ return tools
273
+
274
+
275
+ @dataclass
276
+ class _McpConnection:
277
+ """Internal connection state for an MCP server."""
278
+
279
+ name: str
280
+ config: McpServerConfig
281
+ process: asyncio.subprocess.Process
282
+ transport: _JsonRpcTransport
283
+ tools: list[McpTool] = field(default_factory=list)
@@ -0,0 +1,170 @@
1
+ """
2
+ MonkeysCode SDK — Multi-Agent Orchestrator.
3
+
4
+ Run multiple agents in parallel or fan out across targets.
5
+
6
+ Usage::
7
+
8
+ from monkeyscode.orchestrator import run_parallel, run_fan_out
9
+
10
+ result = await run_parallel(agent, [
11
+ "Fix auth.py",
12
+ "Fix db.py",
13
+ "Fix api.py",
14
+ ], concurrency=3)
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import asyncio
20
+ import time
21
+ from dataclasses import dataclass, field
22
+ from typing import Any
23
+
24
+ from monkeyscode.events import AgentResult, TokenUsage
25
+
26
+ # ── Types ────────────────────────────────────────────────────────────
27
+
28
+
29
+ @dataclass
30
+ class AgentSlotResult:
31
+ """Result from one agent slot in a parallel run."""
32
+
33
+ index: int
34
+ prompt: str
35
+ result: AgentResult | None = None
36
+ error: str | None = None
37
+ duration_ms: float = 0
38
+
39
+
40
+ @dataclass
41
+ class OrchestratorResult:
42
+ """Combined result of a parallel or fan-out run."""
43
+
44
+ results: list[AgentSlotResult] = field(default_factory=list)
45
+ total_cost: float = 0.0
46
+ total_tokens: TokenUsage = field(default_factory=TokenUsage)
47
+ duration_ms: float = 0
48
+ success_count: int = 0
49
+ fail_count: int = 0
50
+
51
+
52
+ # ── Parallel ─────────────────────────────────────────────────────────
53
+
54
+
55
+ async def run_parallel(
56
+ parent: Any,
57
+ prompts: list[str],
58
+ *,
59
+ concurrency: int = 0,
60
+ ) -> OrchestratorResult:
61
+ """
62
+ Run multiple prompts concurrently using separate agent instances.
63
+
64
+ Args:
65
+ parent: The MonkeysCode agent to clone settings from.
66
+ prompts: List of prompts to run in parallel.
67
+ concurrency: Max simultaneous agents (default: len(prompts), max: 10).
68
+ """
69
+ if not prompts:
70
+ raise ValueError("At least one prompt is required")
71
+
72
+ if concurrency <= 0:
73
+ concurrency = len(prompts)
74
+ concurrency = min(concurrency, 10)
75
+
76
+ start = time.monotonic()
77
+ semaphore = asyncio.Semaphore(concurrency)
78
+ results: list[AgentSlotResult] = [
79
+ AgentSlotResult(index=i, prompt=p) for i, p in enumerate(prompts)
80
+ ]
81
+
82
+ async def _run_slot(idx: int, prompt: str) -> None:
83
+ async with semaphore:
84
+ slot_start = time.monotonic()
85
+ try:
86
+ from monkeyscode.agent import MonkeysCode
87
+ child = MonkeysCode(
88
+ model=parent._model,
89
+ api_key=parent._api_key,
90
+ working_directory=parent._working_directory,
91
+ proxy_url=parent._proxy_url,
92
+ max_turns=parent._max_turns,
93
+ timeout=parent._timeout,
94
+ permissions_mode=parent._permissions_mode,
95
+ debug=parent._debug,
96
+ )
97
+ result = await child.run(prompt)
98
+ elapsed = (time.monotonic() - slot_start) * 1000
99
+ results[idx] = AgentSlotResult(
100
+ index=idx,
101
+ prompt=prompt,
102
+ result=result,
103
+ duration_ms=elapsed,
104
+ )
105
+ child.close()
106
+ except Exception as e:
107
+ elapsed = (time.monotonic() - slot_start) * 1000
108
+ results[idx] = AgentSlotResult(
109
+ index=idx,
110
+ prompt=prompt,
111
+ error=str(e),
112
+ duration_ms=elapsed,
113
+ )
114
+
115
+ tasks = [_run_slot(i, p) for i, p in enumerate(prompts)]
116
+ await asyncio.gather(*tasks)
117
+
118
+ total_cost = 0.0
119
+ total_input = 0
120
+ total_output = 0
121
+ success_count = 0
122
+ fail_count = 0
123
+
124
+ for slot in results:
125
+ if slot.result is not None:
126
+ total_cost += slot.result.cost
127
+ total_input += slot.result.tokens.input
128
+ total_output += slot.result.tokens.output
129
+ if slot.result.success:
130
+ success_count += 1
131
+ else:
132
+ fail_count += 1
133
+ else:
134
+ fail_count += 1
135
+
136
+ return OrchestratorResult(
137
+ results=results,
138
+ total_cost=total_cost,
139
+ total_tokens=TokenUsage(
140
+ input=total_input,
141
+ output=total_output,
142
+ total=total_input + total_output,
143
+ ),
144
+ duration_ms=(time.monotonic() - start) * 1000,
145
+ success_count=success_count,
146
+ fail_count=fail_count,
147
+ )
148
+
149
+
150
+ async def run_fan_out(
151
+ parent: Any,
152
+ prompt: str,
153
+ targets: list[str],
154
+ *,
155
+ concurrency: int = 0,
156
+ ) -> OrchestratorResult:
157
+ """
158
+ Run the same prompt against multiple targets concurrently.
159
+
160
+ Args:
161
+ parent: The MonkeysCode agent to clone settings from.
162
+ prompt: The base instruction.
163
+ targets: Items to fan out over (file paths, modules, etc.).
164
+ concurrency: Max simultaneous agents.
165
+ """
166
+ if not targets:
167
+ raise ValueError("At least one target is required")
168
+
169
+ prompts = [f"{prompt}\n\nTarget: {target}" for target in targets]
170
+ return await run_parallel(parent, prompts, concurrency=concurrency)
monkeyscode/otel.py ADDED
@@ -0,0 +1,181 @@
1
+ """
2
+ MonkeysCode SDK — OpenTelemetry Exporter.
3
+
4
+ Send OTLP HTTP traces to Grafana, Datadog, Honeycomb, etc.
5
+
6
+ Usage::
7
+
8
+ from monkeyscode.otel import OtelExporter
9
+
10
+ exporter = OtelExporter(endpoint="http://localhost:4318")
11
+ # Events are recorded automatically when wired into the agent
12
+ await exporter.flush()
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import random
18
+ import time
19
+ from dataclasses import dataclass, field
20
+ from typing import Any
21
+
22
+ import httpx
23
+
24
+ # ── Types ────────────────────────────────────────────────────────────
25
+
26
+
27
+ @dataclass
28
+ class OtelOptions:
29
+ """OpenTelemetry exporter configuration."""
30
+
31
+ endpoint: str = ""
32
+ headers: dict[str, str] = field(default_factory=dict)
33
+ service_name: str = "monkeyscode-sdk-python"
34
+ debug: bool = False
35
+
36
+
37
+ @dataclass
38
+ class _OtelSpan:
39
+ trace_id: str
40
+ span_id: str
41
+ parent_span_id: str = ""
42
+ name: str = ""
43
+ kind: int = 0
44
+ start_time_ns: int = 0
45
+ end_time_ns: int = 0
46
+ status_code: int = 0
47
+ attributes: list[dict[str, Any]] = field(default_factory=list)
48
+
49
+
50
+ # ── Exporter ─────────────────────────────────────────────────────────
51
+
52
+
53
+ class OtelExporter:
54
+ """
55
+ OTLP HTTP exporter for agent traces.
56
+
57
+ Records tool calls, run durations, and cost as spans.
58
+ Flush to send to your observability backend.
59
+ """
60
+
61
+ def __init__(self, options: OtelOptions | None = None) -> None:
62
+ opts = options or OtelOptions()
63
+ endpoint = opts.endpoint.rstrip("/")
64
+ if endpoint and not endpoint.endswith("/v1/traces"):
65
+ endpoint += "/v1/traces"
66
+
67
+ self._endpoint = endpoint
68
+ self._headers = opts.headers
69
+ self._service_name = opts.service_name
70
+ self._debug = opts.debug
71
+ self._trace_id = _generate_hex(32)
72
+ self._spans: list[_OtelSpan] = []
73
+ self._pending: dict[str, _OtelSpan] = {}
74
+
75
+ @property
76
+ def enabled(self) -> bool:
77
+ return bool(self._endpoint)
78
+
79
+ @property
80
+ def trace_id(self) -> str:
81
+ return self._trace_id
82
+
83
+ @property
84
+ def span_count(self) -> int:
85
+ return len(self._spans)
86
+
87
+ def record_tool_start(self, tool: str) -> None:
88
+ """Record the start of a tool call."""
89
+ if not self.enabled:
90
+ return
91
+ span = _OtelSpan(
92
+ trace_id=self._trace_id,
93
+ span_id=_generate_hex(16),
94
+ name=f"tool.{tool}",
95
+ kind=3, # CLIENT
96
+ start_time_ns=time.time_ns(),
97
+ attributes=[
98
+ {"key": "tool.name", "value": {"stringValue": tool}},
99
+ ],
100
+ )
101
+ self._pending[tool] = span
102
+
103
+ def record_tool_end(self, tool: str, *, is_error: bool = False) -> None:
104
+ """Record the end of a tool call."""
105
+ span = self._pending.pop(tool, None)
106
+ if span is None:
107
+ return
108
+ span.end_time_ns = time.time_ns()
109
+ if is_error:
110
+ span.status_code = 2 # ERROR
111
+ self._spans.append(span)
112
+
113
+ def record_run_complete(
114
+ self,
115
+ *,
116
+ cost: float = 0.0,
117
+ tokens_total: int = 0,
118
+ duration_ms: float = 0,
119
+ ) -> None:
120
+ """Record the overall run as a root span."""
121
+ if not self.enabled:
122
+ return
123
+ now = time.time_ns()
124
+ self._spans.append(_OtelSpan(
125
+ trace_id=self._trace_id,
126
+ span_id=_generate_hex(16),
127
+ name="agent.run",
128
+ kind=2, # SERVER
129
+ start_time_ns=now - int(duration_ms * 1e6),
130
+ end_time_ns=now,
131
+ attributes=[
132
+ {"key": "agent.cost", "value": {"stringValue": f"{cost:.4f}"}},
133
+ {"key": "agent.tokens.total", "value": {"intValue": str(tokens_total)}},
134
+ ],
135
+ ))
136
+
137
+ async def flush(self) -> None:
138
+ """Send all accumulated spans to the OTLP endpoint."""
139
+ if not self.enabled or not self._spans:
140
+ return
141
+
142
+ spans = self._spans[:]
143
+ self._spans.clear()
144
+
145
+ payload = {
146
+ "resourceSpans": [{
147
+ "resource": {
148
+ "attributes": [
149
+ {"key": "service.name", "value": {"stringValue": self._service_name}},
150
+ ],
151
+ },
152
+ "scopeSpans": [{
153
+ "scope": {"name": "monkeyscode-sdk-python", "version": "1.0.0"},
154
+ "spans": [
155
+ {
156
+ "traceId": s.trace_id,
157
+ "spanId": s.span_id,
158
+ "parentSpanId": s.parent_span_id,
159
+ "name": s.name,
160
+ "kind": s.kind,
161
+ "startTimeUnixNano": str(s.start_time_ns),
162
+ "endTimeUnixNano": str(s.end_time_ns),
163
+ "status": {"code": s.status_code},
164
+ "attributes": s.attributes,
165
+ }
166
+ for s in spans
167
+ ],
168
+ }],
169
+ }],
170
+ }
171
+
172
+ try:
173
+ async with httpx.AsyncClient() as client:
174
+ headers = {"Content-Type": "application/json", **self._headers}
175
+ await client.post(self._endpoint, json=payload, headers=headers, timeout=10.0)
176
+ except Exception:
177
+ pass # OTEL should never break the app
178
+
179
+
180
+ def _generate_hex(length: int) -> str:
181
+ return "".join(f"{random.randint(0, 255):02x}" for _ in range(length // 2))
monkeyscode/py.typed ADDED
@@ -0,0 +1 @@
1
+