loop-mcp 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.
@@ -0,0 +1,7 @@
1
+ """Loop Engineering MCP Server - Python Implementation."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ from .server import create_server
6
+
7
+ __all__ = ["create_server", "__version__"]
@@ -0,0 +1,37 @@
1
+ """Entry point for the Loop Engineering MCP server."""
2
+
3
+ import asyncio
4
+ import sys
5
+ from mcp.server.stdio import stdio_server
6
+ from .server import create_server
7
+
8
+
9
+ async def _run_server():
10
+ """Run the MCP server with stdio transport."""
11
+ async with stdio_server() as (read_stream, write_stream):
12
+ server = create_server(start_scheduler=True)
13
+ scheduler = getattr(server, "_loop_scheduler", None)
14
+ if scheduler:
15
+ scheduler.start()
16
+ try:
17
+ init_options = server.create_initialization_options()
18
+ await server.run(read_stream, write_stream, init_options)
19
+ finally:
20
+ if scheduler:
21
+ await scheduler.stop()
22
+
23
+
24
+ def main():
25
+ """Run the MCP server."""
26
+ try:
27
+ asyncio.run(_run_server())
28
+ except KeyboardInterrupt:
29
+ print("\nShutting down Loop Engineering MCP server...", file=sys.stderr)
30
+ sys.exit(0)
31
+ except Exception as e:
32
+ print(f"Error running server: {e}", file=sys.stderr)
33
+ sys.exit(1)
34
+
35
+
36
+ if __name__ == "__main__":
37
+ main()
@@ -0,0 +1,130 @@
1
+ """GitHub API integration for PR creation."""
2
+
3
+ import os
4
+ import subprocess
5
+ from dataclasses import dataclass
6
+ from typing import Optional
7
+
8
+ import httpx
9
+
10
+ from .retry import retry_async
11
+
12
+
13
+ @dataclass
14
+ class PullRequestResult:
15
+ """Result of creating a pull request."""
16
+
17
+ success: bool
18
+ pr_number: Optional[int] = None
19
+ pr_url: Optional[str] = None
20
+ branch: Optional[str] = None
21
+ error: Optional[str] = None
22
+
23
+
24
+ class GitHubClient:
25
+ """Creates branches and pull requests via the GitHub REST API."""
26
+
27
+ API_BASE = "https://api.github.com"
28
+
29
+ def __init__(
30
+ self,
31
+ token: Optional[str] = None,
32
+ repo: Optional[str] = None,
33
+ default_branch: str = "main",
34
+ ):
35
+ self.token = token or os.environ.get("GITHUB_TOKEN")
36
+ self.repo = repo or os.environ.get("GITHUB_REPO") or self._detect_repo()
37
+ self.default_branch = default_branch or os.environ.get("GITHUB_DEFAULT_BRANCH", "main")
38
+
39
+ def _detect_repo(self) -> Optional[str]:
40
+ try:
41
+ result = subprocess.run(
42
+ ["git", "remote", "get-url", "origin"],
43
+ capture_output=True,
44
+ text=True,
45
+ timeout=10,
46
+ )
47
+ if result.returncode != 0:
48
+ return None
49
+ url = result.stdout.strip()
50
+ # Handle git@github.com:owner/repo.git and https://github.com/owner/repo.git
51
+ if "github.com" in url:
52
+ parts = url.rstrip(".git").split("/")
53
+ if len(parts) >= 2:
54
+ return f"{parts[-2]}/{parts[-1]}"
55
+ return None
56
+ except Exception:
57
+ return None
58
+
59
+ def _headers(self) -> dict[str, str]:
60
+ if not self.token:
61
+ raise ValueError("GITHUB_TOKEN environment variable is required")
62
+ return {
63
+ "Authorization": f"Bearer {self.token}",
64
+ "Accept": "application/vnd.github+json",
65
+ "X-GitHub-Api-Version": "2022-11-28",
66
+ }
67
+
68
+ async def create_pull_request(
69
+ self,
70
+ *,
71
+ title: str,
72
+ body: str,
73
+ branch: str,
74
+ base: Optional[str] = None,
75
+ ) -> PullRequestResult:
76
+ """Create a pull request on GitHub."""
77
+ if not self.repo:
78
+ return PullRequestResult(
79
+ success=False,
80
+ error="Could not detect GitHub repo. Set GITHUB_REPO=owner/repo",
81
+ )
82
+
83
+ base_branch = base or self.default_branch
84
+
85
+ async def _create() -> PullRequestResult:
86
+ async with httpx.AsyncClient(timeout=30.0) as client:
87
+ response = await client.post(
88
+ f"{self.API_BASE}/repos/{self.repo}/pulls",
89
+ headers=self._headers(),
90
+ json={
91
+ "title": title,
92
+ "body": body,
93
+ "head": branch,
94
+ "base": base_branch,
95
+ },
96
+ )
97
+ if response.status_code == 422:
98
+ data = response.json()
99
+ errors = data.get("errors", [])
100
+ msg = errors[0].get("message") if errors else data.get("message", "Validation failed")
101
+ return PullRequestResult(success=False, error=msg)
102
+ response.raise_for_status()
103
+ data = response.json()
104
+ return PullRequestResult(
105
+ success=True,
106
+ pr_number=data["number"],
107
+ pr_url=data["html_url"],
108
+ branch=branch,
109
+ )
110
+
111
+ try:
112
+ return await retry_async(_create, max_attempts=3)
113
+ except Exception as e:
114
+ return PullRequestResult(success=False, error=str(e))
115
+
116
+ async def push_branch(self, branch: str, cwd: Optional[str] = None) -> tuple[bool, str]:
117
+ """Push the current branch to origin."""
118
+ try:
119
+ result = subprocess.run(
120
+ ["git", "push", "-u", "origin", branch],
121
+ capture_output=True,
122
+ text=True,
123
+ timeout=120,
124
+ cwd=cwd,
125
+ )
126
+ if result.returncode != 0:
127
+ return False, result.stderr or result.stdout
128
+ return True, "Branch pushed successfully"
129
+ except Exception as e:
130
+ return False, str(e)
@@ -0,0 +1,60 @@
1
+ """Structured logging for loop engineering."""
2
+
3
+ import json
4
+ import logging
5
+ from datetime import datetime, timezone
6
+ from pathlib import Path
7
+ from typing import Any, Optional
8
+
9
+
10
+ class LoopLogger:
11
+ """Writes structured JSON logs to .loop/logs/."""
12
+
13
+ def __init__(self, logs_dir: Path):
14
+ self.logs_dir = logs_dir
15
+ self.logs_dir.mkdir(parents=True, exist_ok=True)
16
+ self._logger = logging.getLogger("loop_engineering")
17
+ if not self._logger.handlers:
18
+ handler = logging.StreamHandler()
19
+ handler.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(message)s"))
20
+ self._logger.addHandler(handler)
21
+ self._logger.setLevel(logging.INFO)
22
+
23
+ def _write_json(self, filename: str, entry: dict[str, Any]) -> None:
24
+ log_file = self.logs_dir / filename
25
+ with open(log_file, "a", encoding="utf-8") as f:
26
+ f.write(json.dumps(entry, default=str) + "\n")
27
+
28
+ def _entry(
29
+ self,
30
+ level: str,
31
+ event: str,
32
+ loop_name: Optional[str] = None,
33
+ **extra: Any,
34
+ ) -> dict[str, Any]:
35
+ return {
36
+ "timestamp": datetime.now(timezone.utc).isoformat(),
37
+ "level": level,
38
+ "event": event,
39
+ "loop_name": loop_name,
40
+ **extra,
41
+ }
42
+
43
+ def info(self, event: str, loop_name: Optional[str] = None, **extra: Any) -> None:
44
+ entry = self._entry("INFO", event, loop_name, **extra)
45
+ self._write_json("loop-engineering.log", entry)
46
+ self._logger.info(f"[{loop_name or 'system'}] {event}")
47
+
48
+ def warning(self, event: str, loop_name: Optional[str] = None, **extra: Any) -> None:
49
+ entry = self._entry("WARNING", event, loop_name, **extra)
50
+ self._write_json("loop-engineering.log", entry)
51
+ self._logger.warning(f"[{loop_name or 'system'}] {event}")
52
+
53
+ def error(self, event: str, loop_name: Optional[str] = None, **extra: Any) -> None:
54
+ entry = self._entry("ERROR", event, loop_name, **extra)
55
+ self._write_json("loop-engineering.log", entry)
56
+ self._logger.error(f"[{loop_name or 'system'}] {event}")
57
+
58
+ def run_log(self, loop_name: str, run_id: str, data: dict[str, Any]) -> None:
59
+ entry = self._entry("INFO", "loop_run", loop_name, run_id=run_id, **data)
60
+ self._write_json(f"{loop_name}-runs.log", entry)
@@ -0,0 +1,297 @@
1
+ """Orchestrates loop runs via the host agent (Cursor/Kiro) — no external AI API calls."""
2
+
3
+ import json
4
+ import subprocess
5
+ import uuid
6
+ from datetime import datetime
7
+ from pathlib import Path
8
+ from typing import Optional
9
+
10
+ import aiofiles
11
+
12
+ from .github_client import GitHubClient
13
+ from .logger import LoopLogger
14
+ from .loop_manager import LoopManager
15
+ from .skill_manager import SkillManager
16
+ from .state_manager import StateManager
17
+ from .verification_runner import VerificationRunner
18
+
19
+
20
+ class LoopExecutor:
21
+ """Prepares loop runs for the host agent and finalizes them after execution."""
22
+
23
+ def __init__(
24
+ self,
25
+ workspace_root: Path,
26
+ loop_manager: LoopManager,
27
+ skill_manager: SkillManager,
28
+ state_manager: StateManager,
29
+ logger: LoopLogger,
30
+ ):
31
+ self.workspace_root = workspace_root
32
+ self.loop_manager = loop_manager
33
+ self.skill_manager = skill_manager
34
+ self.state_manager = state_manager
35
+ self.logger = logger
36
+ self.verification_runner = VerificationRunner(workspace_root)
37
+ self.github_client = GitHubClient()
38
+
39
+ async def _read_skill(self, loop_name: str) -> str:
40
+ skill_file = self.skill_manager.skills_dir / f"{loop_name}.md"
41
+ if skill_file.exists():
42
+ async with aiofiles.open(skill_file, "r") as f:
43
+ return await f.read()
44
+ loops = await self.loop_manager._load_loops()
45
+ return loops.get(loop_name, {}).get("skill_instructions", "")
46
+
47
+ async def _build_brief(self, loop_name: str, run_id: str, branch: Optional[str]) -> str:
48
+ loops = await self.loop_manager._load_loops()
49
+ loop_config = loops[loop_name]
50
+ state = await self.state_manager._load_state(loop_name)
51
+ skill_content = await self._read_skill(loop_name)
52
+ lessons = state.get("lessons_learned", [])
53
+ lessons_text = "\n".join(f"- {l}" for l in lessons[-10:]) if lessons else "None yet."
54
+
55
+ branch_line = f"**Branch:** `{branch}`\n" if branch else ""
56
+ return f"""🔄 **Loop run started: {loop_name}** (run_id: `{run_id}`)
57
+
58
+ You (the host agent in Cursor/Kiro) execute this loop — no external API keys needed.
59
+ Make the code changes in the workspace, then call `complete_loop_run` with this run_id.
60
+
61
+ {branch_line}**Goal:** {loop_config['goal']}
62
+
63
+ **Verification command:** `{loop_config.get('verification_command', 'none')}`
64
+
65
+ ---
66
+
67
+ ## Skill Instructions
68
+
69
+ {skill_content}
70
+
71
+ ---
72
+
73
+ ## Lessons Learned (from previous runs)
74
+
75
+ {lessons_text}
76
+
77
+ ---
78
+
79
+ ## When done
80
+
81
+ 1. Make all required code changes in the workspace
82
+ 2. Call `complete_loop_run` with:
83
+ - `loop_name`: "{loop_name}"
84
+ - `run_id`: "{run_id}"
85
+ - `summary`: brief description of what you changed
86
+ """
87
+
88
+ def _has_git_changes(self) -> bool:
89
+ try:
90
+ result = subprocess.run(
91
+ ["git", "status", "--porcelain"],
92
+ capture_output=True,
93
+ text=True,
94
+ timeout=30,
95
+ cwd=str(self.workspace_root),
96
+ )
97
+ return bool(result.stdout.strip())
98
+ except Exception:
99
+ return False
100
+
101
+ def _create_branch(self, loop_name: str) -> tuple[bool, str]:
102
+ timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
103
+ branch = f"loop/{loop_name}-{timestamp}"
104
+ try:
105
+ subprocess.run(
106
+ ["git", "checkout", "-b", branch],
107
+ capture_output=True,
108
+ text=True,
109
+ timeout=30,
110
+ cwd=str(self.workspace_root),
111
+ check=True,
112
+ )
113
+ return True, branch
114
+ except subprocess.CalledProcessError as e:
115
+ return False, e.stderr or str(e)
116
+
117
+ async def begin_run(self, loop_name: str, *, create_pr: bool = True) -> str:
118
+ """Prepare a loop run and return instructions for the host agent to execute."""
119
+ run_id = str(uuid.uuid4())[:8]
120
+ self.logger.info("loop_run_started", loop_name, run_id=run_id)
121
+
122
+ loops = await self.loop_manager._load_loops()
123
+ if loop_name not in loops:
124
+ return f"❌ Loop '{loop_name}' not found."
125
+
126
+ state = await self.state_manager._load_state(loop_name)
127
+ if state.get("active_run"):
128
+ active = state["active_run"]
129
+ return (
130
+ f"⏳ Loop '{loop_name}' already has an active run "
131
+ f"(run_id: `{active.get('run_id')}`). "
132
+ f"Complete it with `complete_loop_run` first."
133
+ )
134
+
135
+ branch = None
136
+ if create_pr:
137
+ ok, branch_or_err = self._create_branch(loop_name)
138
+ if ok:
139
+ branch = branch_or_err
140
+ else:
141
+ self.logger.warning("branch_creation_failed", loop_name, error=branch_or_err)
142
+
143
+ await self.state_manager.set_active_run(
144
+ loop_name, run_id=run_id, branch=branch, create_pr=create_pr
145
+ )
146
+ await self.state_manager.remove_pending_run(loop_name)
147
+
148
+ brief = await self._build_brief(loop_name, run_id, branch)
149
+ self.logger.info("loop_brief_ready", loop_name, run_id=run_id)
150
+ return brief
151
+
152
+ async def queue_run(self, loop_name: str) -> str:
153
+ """Queue a loop for the next host agent session (used by scheduler)."""
154
+ loops = await self.loop_manager._load_loops()
155
+ if loop_name not in loops:
156
+ return f"❌ Loop '{loop_name}' not found."
157
+
158
+ state = await self.state_manager._load_state(loop_name)
159
+ if state.get("active_run"):
160
+ return f"⏳ Loop '{loop_name}' already has an active run — skipping queue."
161
+
162
+ run_id = str(uuid.uuid4())[:8]
163
+ await self.state_manager.queue_pending_run(loop_name, run_id, source="schedule")
164
+ self.logger.info("loop_queued", loop_name, run_id=run_id)
165
+
166
+ return (
167
+ f"📋 Loop '{loop_name}' queued (run_id: `{run_id}`).\n"
168
+ f"Call `list_pending_runs` then `run_loop_now` when a host agent session is active."
169
+ )
170
+
171
+ async def complete_run(
172
+ self,
173
+ loop_name: str,
174
+ run_id: str,
175
+ summary: str,
176
+ *,
177
+ create_pr: Optional[bool] = None,
178
+ ) -> str:
179
+ """Finalize a loop run after the host agent has made changes."""
180
+ loops = await self.loop_manager._load_loops()
181
+ if loop_name not in loops:
182
+ return f"❌ Loop '{loop_name}' not found."
183
+
184
+ state = await self.state_manager._load_state(loop_name)
185
+ active = state.get("active_run")
186
+ if not active or active.get("run_id") != run_id:
187
+ return f"❌ No active run with run_id `{run_id}` for loop '{loop_name}'."
188
+
189
+ loop_config = loops[loop_name]
190
+ should_create_pr = create_pr if create_pr is not None else active.get("create_pr", True)
191
+ branch = active.get("branch")
192
+
193
+ verification_passed = False
194
+ pr_result = None
195
+ status = "failed"
196
+ error_msg: Optional[str] = None
197
+
198
+ try:
199
+ verification_cmd = loop_config.get("verification_command", "")
200
+ self.logger.info("verification_started", loop_name, command=verification_cmd)
201
+ verification = await self.verification_runner.run(verification_cmd)
202
+ verification_passed = verification.success
203
+
204
+ if not verification_passed:
205
+ status = "verification_failed"
206
+ error_msg = verification.stderr or f"Exit code {verification.exit_code}"
207
+ await self.state_manager.record_escalation(
208
+ loop_name, f"Verification failed: {error_msg}"
209
+ )
210
+ else:
211
+ has_changes = self._has_git_changes()
212
+ if should_create_pr and has_changes and branch:
213
+ subprocess.run(
214
+ ["git", "add", "-A"],
215
+ cwd=str(self.workspace_root),
216
+ capture_output=True,
217
+ timeout=30,
218
+ )
219
+ subprocess.run(
220
+ ["git", "commit", "-m", f"loop({loop_name}): automated changes"],
221
+ cwd=str(self.workspace_root),
222
+ capture_output=True,
223
+ timeout=30,
224
+ )
225
+ push_ok, push_msg = await self.github_client.push_branch(
226
+ branch, cwd=str(self.workspace_root)
227
+ )
228
+ if push_ok:
229
+ pr_result = await self.github_client.create_pull_request(
230
+ title=f"[loop] {loop_name}: {loop_config['description'][:60]}",
231
+ body=(
232
+ f"Automated loop run.\n\n**Goal:** {loop_config['goal']}\n\n"
233
+ f"**Summary:**\n{summary[:2000]}"
234
+ ),
235
+ branch=branch,
236
+ )
237
+ status = "success" if pr_result.success else "pr_failed"
238
+ if not pr_result.success:
239
+ error_msg = pr_result.error
240
+ else:
241
+ status = "push_failed"
242
+ error_msg = push_msg
243
+ elif has_changes:
244
+ status = "success"
245
+ else:
246
+ status = "success_no_changes"
247
+
248
+ except Exception as e:
249
+ status = "error"
250
+ error_msg = str(e)
251
+ self.logger.error("loop_run_failed", loop_name, run_id=run_id, error=error_msg)
252
+ await self.state_manager.record_escalation(loop_name, f"Run error: {error_msg}")
253
+
254
+ final_summary = summary
255
+ if error_msg:
256
+ final_summary = f"{summary} ({status}: {error_msg})"
257
+
258
+ await self.state_manager.record_run(
259
+ loop_name,
260
+ summary=final_summary,
261
+ status=status,
262
+ verification_passed=verification_passed,
263
+ pr_url=pr_result.pr_url if pr_result and pr_result.success else None,
264
+ )
265
+ await self.state_manager.clear_active_run(loop_name)
266
+
267
+ loops = await self.loop_manager._load_loops()
268
+ if loop_name in loops:
269
+ loops[loop_name]["last_run"] = datetime.now().isoformat()
270
+ await self.loop_manager._save_loops(loops)
271
+
272
+ self.logger.run_log(
273
+ loop_name,
274
+ run_id,
275
+ {
276
+ "status": status,
277
+ "verification_passed": verification_passed,
278
+ "pr_url": pr_result.pr_url if pr_result else None,
279
+ "error": error_msg,
280
+ },
281
+ )
282
+
283
+ output = [
284
+ f"✅ **Loop run complete: {loop_name}** (run_id: {run_id})",
285
+ f"**Status:** {status}",
286
+ f"**Verification:** {'✅ Passed' if verification_passed else '❌ Failed'}",
287
+ ]
288
+ if pr_result and pr_result.success:
289
+ output.append(f"**PR:** {pr_result.pr_url}")
290
+ if error_msg:
291
+ output.append(f"**Error:** {error_msg}")
292
+
293
+ return "\n".join(output)
294
+
295
+ # Backward-compatible alias: begin_run (not full autonomous execution)
296
+ async def run(self, loop_name: str, *, create_pr: bool = True) -> str:
297
+ return await self.begin_run(loop_name, create_pr=create_pr)