mcp-orbit 0.2.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.
mcp_orbit/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """MCP server for orbit project management."""
2
+
3
+ __version__ = "0.1.0"
mcp_orbit/app.py ADDED
@@ -0,0 +1,5 @@
1
+ """FastMCP application instance, shared across tool modules."""
2
+
3
+ from mcp.server.fastmcp import FastMCP
4
+
5
+ mcp = FastMCP("orbit")
mcp_orbit/config.py ADDED
@@ -0,0 +1,25 @@
1
+ """Configuration for the orbit MCP server."""
2
+
3
+ from pathlib import Path
4
+
5
+ from pydantic_settings import BaseSettings
6
+
7
+
8
+ class Settings(BaseSettings):
9
+ """Server configuration from environment variables."""
10
+
11
+ # Path to the task database
12
+ db_path: Path = Path.home() / ".claude" / "tasks.db"
13
+
14
+ # Centralized orbit root directory
15
+ orbit_root: Path = Path.home() / ".claude" / "orbit"
16
+
17
+ # Active and completed subdirectory names
18
+ active_dir_name: str = "active"
19
+ completed_dir_name: str = "completed"
20
+
21
+ class Config:
22
+ env_prefix = "ORBIT_"
23
+
24
+
25
+ settings = Settings()
mcp_orbit/db.py ADDED
@@ -0,0 +1,35 @@
1
+ """Database wrapper for orbit_db."""
2
+
3
+ from orbit_db import Repository, Task, TaskDB, TaskStatus
4
+
5
+ from .config import settings
6
+
7
+ # Module-level singleton
8
+ _db: TaskDB | None = None
9
+
10
+
11
+ def get_db() -> TaskDB:
12
+ """Get or create the TaskDB singleton."""
13
+ global _db
14
+ if _db is None:
15
+ _db = TaskDB(db_path=settings.db_path)
16
+ _db.initialize()
17
+ return _db
18
+
19
+
20
+ def repo_to_dict(repo: Repository) -> dict:
21
+ """Convert Repository dataclass to dict."""
22
+ from dataclasses import asdict
23
+
24
+ return asdict(repo)
25
+
26
+
27
+ # Re-export for convenience
28
+ __all__ = [
29
+ "get_db",
30
+ "repo_to_dict",
31
+ "Task",
32
+ "Repository",
33
+ "TaskDB",
34
+ "TaskStatus",
35
+ ]
mcp_orbit/errors.py ADDED
@@ -0,0 +1,85 @@
1
+ """Error codes and handling for the orbit MCP server."""
2
+
3
+ from enum import Enum
4
+ from typing import Any
5
+
6
+
7
+ class ErrorCode(str, Enum):
8
+ """Standard error codes for structured error responses."""
9
+
10
+ TASK_NOT_FOUND = "TASK_NOT_FOUND"
11
+ REPO_NOT_FOUND = "REPO_NOT_FOUND"
12
+ FILE_NOT_FOUND = "FILE_NOT_FOUND"
13
+ VALIDATION_ERROR = "VALIDATION_ERROR"
14
+ DB_ERROR = "DB_ERROR"
15
+ PERMISSION_ERROR = "PERMISSION_ERROR"
16
+ INVALID_STATE = "INVALID_STATE"
17
+ OPERATION_FAILED = "OPERATION_FAILED"
18
+
19
+
20
+ class OrbitError(Exception):
21
+ """Base exception for orbit errors with structured response."""
22
+
23
+ def __init__(
24
+ self, code: ErrorCode, message: str, details: dict[str, Any] | None = None
25
+ ):
26
+ self.code = code
27
+ self.message = message
28
+ self.details = details or {}
29
+ super().__init__(message)
30
+
31
+ def to_dict(self) -> dict:
32
+ """Convert to dictionary for MCP response."""
33
+ return {
34
+ "error": True,
35
+ "code": self.code.value,
36
+ "message": self.message,
37
+ "details": self.details,
38
+ }
39
+
40
+
41
+ class TaskNotFoundError(OrbitError):
42
+ """Task not found in database."""
43
+
44
+ def __init__(self, task_id: int | str, message: str | None = None):
45
+ super().__init__(
46
+ ErrorCode.TASK_NOT_FOUND,
47
+ message or f"Task not found: {task_id}",
48
+ {"task_id": task_id},
49
+ )
50
+
51
+
52
+ class OrbitFileNotFoundError(OrbitError):
53
+ """File not found on filesystem."""
54
+
55
+ def __init__(self, path: str, message: str | None = None):
56
+ super().__init__(
57
+ ErrorCode.FILE_NOT_FOUND,
58
+ message or f"File not found: {path}",
59
+ {"path": path},
60
+ )
61
+
62
+
63
+ class ValidationError(OrbitError):
64
+ """Input validation failed."""
65
+
66
+ def __init__(self, message: str, field: str | None = None):
67
+ details = {"field": field} if field else {}
68
+ super().__init__(ErrorCode.VALIDATION_ERROR, message, details)
69
+
70
+
71
+ class InvalidStateError(OrbitError):
72
+ """Operation invalid for current state."""
73
+
74
+ def __init__(
75
+ self,
76
+ message: str,
77
+ current_state: str | None = None,
78
+ expected_state: str | None = None,
79
+ ):
80
+ details = {}
81
+ if current_state:
82
+ details["current_state"] = current_state
83
+ if expected_state:
84
+ details["expected_state"] = expected_state
85
+ super().__init__(ErrorCode.INVALID_STATE, message, details)
mcp_orbit/helpers.py ADDED
@@ -0,0 +1,194 @@
1
+ """Shared helper functions used across tool modules."""
2
+
3
+ import logging
4
+ from pathlib import Path
5
+
6
+ from . import orbit
7
+ from .config import settings
8
+ from .db import Task, get_db
9
+ from .errors import TaskNotFoundError, ValidationError
10
+ from .models import TaskDetail, TaskProgress, TaskSummary
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ def _validate_path(
16
+ path: str, field_name: str = "path", must_be_under: Path | None = None
17
+ ) -> Path:
18
+ """Validate and resolve a filesystem path.
19
+
20
+ Checks for empty strings and null bytes, then resolves the path.
21
+ If must_be_under is provided, verifies the resolved path is contained
22
+ within that directory.
23
+
24
+ Raises:
25
+ ValidationError: If path is empty, contains null bytes, or resolves
26
+ outside the required root directory.
27
+ """
28
+ if not path or not path.strip():
29
+ raise ValidationError(f"{field_name} cannot be empty", field=field_name)
30
+ if "\x00" in path:
31
+ raise ValidationError(f"{field_name} contains null bytes", field=field_name)
32
+ resolved = Path(path).resolve()
33
+ if must_be_under is not None:
34
+ root = must_be_under.resolve()
35
+ if resolved != root and not str(resolved).startswith(str(root) + "/"):
36
+ raise ValidationError(
37
+ f"{field_name} must be within {root}", field=field_name
38
+ )
39
+ return resolved
40
+
41
+
42
+ def _resolve_task_dir(
43
+ db, task_id: int | None, task_name: str | None
44
+ ) -> tuple[Path, str]:
45
+ """Resolve task directory and name from task_id or task_name.
46
+
47
+ Returns:
48
+ Tuple of (task_dir, task_name).
49
+
50
+ Raises:
51
+ TaskNotFoundError: If task cannot be found.
52
+ """
53
+ task = None
54
+ if task_id:
55
+ task = db.get_task(task_id)
56
+ elif task_name:
57
+ task = db.get_task_by_name(task_name)
58
+
59
+ if not task:
60
+ identifier = task_id if task_id is not None else (task_name or "unknown")
61
+ raise TaskNotFoundError(identifier)
62
+
63
+ task_dir = settings.orbit_root / task.full_path
64
+ return task_dir, task.name
65
+
66
+
67
+ def _task_to_summary(
68
+ task: Task, db=None, time_seconds: int | None = None
69
+ ) -> TaskSummary:
70
+ """Convert a Task to TaskSummary with time info.
71
+
72
+ Args:
73
+ task: Task object to convert.
74
+ db: Database instance (auto-resolved if None).
75
+ time_seconds: Pre-fetched time in seconds. If None, fetches from DB.
76
+ """
77
+ if db is None:
78
+ db = get_db()
79
+
80
+ # Get time tracking info (use pre-fetched if available)
81
+ if time_seconds is None:
82
+ time_seconds = db.get_task_time(task.id)
83
+ time_formatted = db.format_duration(time_seconds)
84
+
85
+ # Get effective last updated (uses file mtime if more recent)
86
+ effective_last = db.get_effective_last_updated(task)
87
+ last_worked_ago = db.format_time_ago(effective_last)
88
+
89
+ # Get repo info if available
90
+ repo_name = None
91
+ repo_path = None
92
+ if task.repo_id:
93
+ repo = db.get_repo(task.repo_id)
94
+ if repo:
95
+ repo_name = repo.short_name
96
+ repo_path = repo.path
97
+
98
+ # Check if orbit files exist
99
+ has_orbit_files = False
100
+ if task.full_path:
101
+ task_dir = settings.orbit_root / task.full_path
102
+ has_orbit_files = task_dir.exists() and any(
103
+ (task_dir / f).exists()
104
+ for f in [
105
+ f"{task.name}-context.md",
106
+ f"{task.name}-tasks.md",
107
+ f"{task.name}-plan.md",
108
+ "context.md",
109
+ "tasks.md",
110
+ ]
111
+ )
112
+
113
+ return TaskSummary(
114
+ id=task.id,
115
+ name=task.name,
116
+ status=task.status,
117
+ type=task.task_type,
118
+ repo_name=repo_name,
119
+ repo_path=repo_path,
120
+ jira_key=task.jira_key,
121
+ tags=task.tags,
122
+ time_total_seconds=time_seconds,
123
+ time_formatted=time_formatted,
124
+ last_worked_on=effective_last,
125
+ last_worked_ago=last_worked_ago,
126
+ has_orbit_files=has_orbit_files,
127
+ )
128
+
129
+
130
+ def _task_to_detail(
131
+ task: Task, include_subtasks: bool = True, include_updates: bool = True
132
+ ) -> TaskDetail:
133
+ """Convert a Task to TaskDetail with full information."""
134
+ db = get_db()
135
+
136
+ # Get base summary fields
137
+ summary = _task_to_summary(task, db)
138
+
139
+ # Parse progress from orbit files
140
+ progress = None
141
+ if task.full_path:
142
+ progress = _parse_task_progress(
143
+ settings.orbit_root / task.full_path, task.name
144
+ )
145
+
146
+ # Get subtasks if this is a parent task
147
+ subtasks = []
148
+ if include_subtasks:
149
+ hierarchy = db.get_active_tasks_hierarchical(task.repo_id)
150
+ if task.id in hierarchy.get("children", {}):
151
+ for subtask in hierarchy["children"][task.id]:
152
+ subtasks.append(_task_to_summary(subtask, db))
153
+
154
+ # Get recent updates for non-coding tasks
155
+ recent_updates = []
156
+ if include_updates and task.task_type == "non-coding":
157
+ recent_updates = db.get_task_updates(task.id, limit=5)
158
+
159
+ return TaskDetail(
160
+ **summary.model_dump(by_alias=True),
161
+ full_path=task.full_path,
162
+ parent_id=task.parent_id,
163
+ branch=task.branch,
164
+ pr_url=task.pr_url,
165
+ created_at=task.created_at,
166
+ updated_at=task.updated_at,
167
+ completed_at=task.completed_at,
168
+ progress=progress,
169
+ subtasks=subtasks,
170
+ recent_updates=recent_updates,
171
+ )
172
+
173
+
174
+ def _parse_task_progress(task_dir: Path, task_name: str) -> TaskProgress | None:
175
+ """Parse progress from the tasks.md file."""
176
+ if not task_dir.exists():
177
+ return None
178
+
179
+ # Try both naming conventions
180
+ tasks_files = [
181
+ task_dir / f"{task_name}-tasks.md",
182
+ task_dir / "tasks.md",
183
+ ]
184
+
185
+ for tasks_file in tasks_files:
186
+ if tasks_file.exists():
187
+ try:
188
+ content = tasks_file.read_text()
189
+ return orbit.parse_task_progress(content)
190
+ except Exception as e:
191
+ logger.warning(f"Failed to parse {tasks_file}: {e}")
192
+ continue
193
+
194
+ return None
@@ -0,0 +1,333 @@
1
+ """Iteration log integration for autonomous task execution.
2
+
3
+ Progress tracking is done ONLY via checkboxes in the tasks.md file.
4
+ Prompts do not have status fields - tasks.md is the single source of truth.
5
+ """
6
+
7
+ import re
8
+ from datetime import datetime
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+
13
+ def get_iteration_log_path(task_dir: str | Path, task_name: str) -> Path:
14
+ """Get path to the iteration log file."""
15
+ return Path(task_dir) / f"{task_name}-iteration-log.md"
16
+
17
+
18
+ def log_iteration(
19
+ task_dir: str | Path,
20
+ task_name: str,
21
+ iteration: int,
22
+ status: str,
23
+ task_title: str | None = None,
24
+ what_done: list[str] | None = None,
25
+ files_modified: list[str] | None = None,
26
+ validation: dict[str, str] | None = None,
27
+ error_details: str | None = None,
28
+ next_steps: list[str] | None = None,
29
+ ) -> str:
30
+ """Log an iteration to the iteration log.
31
+
32
+ Args:
33
+ task_dir: Task directory path
34
+ task_name: Task name
35
+ iteration: Iteration number
36
+ status: SUCCESS, FAILED, or BLOCKED
37
+ task_title: Title of the task being worked on
38
+ what_done: List of what was done/attempted
39
+ files_modified: List of modified files
40
+ validation: Dict of validation results {tests: PASS, typecheck: PASS, etc.}
41
+ error_details: Error details if status is FAILED
42
+ next_steps: Suggested next steps if FAILED
43
+
44
+ Returns:
45
+ The log entry that was written
46
+ """
47
+ log_path = get_iteration_log_path(task_dir, task_name)
48
+ timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
49
+
50
+ # Build the log entry
51
+ entry = f"\n## Iteration {iteration}"
52
+ if task_title:
53
+ entry += f" - {task_title}"
54
+ entry += f"\n**Status:** {status}\n"
55
+ entry += f"**Time:** {timestamp}\n\n"
56
+
57
+ if what_done:
58
+ header = (
59
+ "### What was done" if status == "SUCCESS" else "### What was attempted"
60
+ )
61
+ entry += f"{header}\n"
62
+ for item in what_done:
63
+ entry += f"- {item}\n"
64
+ entry += "\n"
65
+
66
+ if files_modified:
67
+ entry += "### Files modified\n"
68
+ for f in files_modified:
69
+ entry += f"- {f}\n"
70
+ entry += "\n"
71
+
72
+ if validation:
73
+ entry += "### Validation\n"
74
+ for check, result in validation.items():
75
+ entry += f"- {check}: {result}\n"
76
+ entry += "\n"
77
+
78
+ if error_details:
79
+ entry += f"### Error details\n{error_details}\n\n"
80
+
81
+ if next_steps:
82
+ entry += "### Next steps to try\n"
83
+ for step in next_steps:
84
+ entry += f"- {step}\n"
85
+ entry += "\n"
86
+
87
+ # Append to log
88
+ if log_path.exists():
89
+ with open(log_path, "a") as f:
90
+ f.write(entry)
91
+ else:
92
+ # Create new log with header
93
+ log_path.write_text(f"""# Iteration Log - {task_name}
94
+
95
+ **Started:** {timestamp.split()[0]}
96
+ **Max Iterations:** 20
97
+
98
+ ---
99
+ {entry}""")
100
+
101
+ return entry
102
+
103
+
104
+ def log_completion(
105
+ task_dir: str | Path,
106
+ task_name: str,
107
+ total_iterations: int,
108
+ duration_seconds: int,
109
+ ) -> str:
110
+ """Log task completion to the iteration log."""
111
+ log_path = get_iteration_log_path(task_dir, task_name)
112
+ timestamp = datetime.now().strftime("%Y-%m-%d %H:%M")
113
+
114
+ entry = f"""
115
+ ---
116
+
117
+ # COMPLETED
118
+ **Finished:** {timestamp}
119
+ **Total iterations:** {total_iterations}
120
+ **Duration:** {duration_seconds}s
121
+ """
122
+
123
+ if log_path.exists():
124
+ with open(log_path, "a") as f:
125
+ f.write(entry)
126
+
127
+ return entry
128
+
129
+
130
+ def log_timeout(
131
+ task_dir: str | Path,
132
+ task_name: str,
133
+ max_iterations: int,
134
+ duration_seconds: int,
135
+ ) -> str:
136
+ """Log timeout to the iteration log."""
137
+ log_path = get_iteration_log_path(task_dir, task_name)
138
+ timestamp = datetime.now().strftime("%Y-%m-%d %H:%M")
139
+
140
+ entry = f"""
141
+ ---
142
+
143
+ # TIMEOUT
144
+ **Stopped:** {timestamp}
145
+ **Reached max iterations:** {max_iterations}
146
+ **Duration:** {duration_seconds}s
147
+ """
148
+
149
+ if log_path.exists():
150
+ with open(log_path, "a") as f:
151
+ f.write(entry)
152
+
153
+ return entry
154
+
155
+
156
+ def get_iteration_status(task_dir: str | Path, task_name: str) -> dict[str, Any]:
157
+ """Get the current iteration loop status from the log file.
158
+
159
+ Returns:
160
+ Dict with status info: {
161
+ exists: bool,
162
+ started: str | None,
163
+ max_iterations: int | None,
164
+ iterations: int,
165
+ last_status: str | None,
166
+ completed: bool,
167
+ timed_out: bool,
168
+ blocked: bool,
169
+ }
170
+ """
171
+ log_path = get_iteration_log_path(task_dir, task_name)
172
+
173
+ if not log_path.exists():
174
+ return {
175
+ "exists": False,
176
+ "started": None,
177
+ "max_iterations": None,
178
+ "iterations": 0,
179
+ "last_status": None,
180
+ "completed": False,
181
+ "timed_out": False,
182
+ "blocked": False,
183
+ }
184
+
185
+ content = log_path.read_text()
186
+
187
+ # Parse started time
188
+ started_match = re.search(r"\*\*Started:\*\* (.+)", content)
189
+ started = started_match.group(1) if started_match else None
190
+
191
+ # Parse max iterations
192
+ max_match = re.search(r"\*\*Max Iterations:\*\* (\d+)", content)
193
+ max_iterations = int(max_match.group(1)) if max_match else None
194
+
195
+ # Count iterations
196
+ iterations = len(re.findall(r"## Iteration \d+", content))
197
+
198
+ # Get last status
199
+ status_matches = list(
200
+ re.finditer(r"\*\*Status:\*\* (SUCCESS|FAILED|BLOCKED)", content)
201
+ )
202
+ last_status = status_matches[-1].group(1) if status_matches else None
203
+
204
+ # Check for completion/timeout
205
+ completed = "# COMPLETED" in content
206
+ timed_out = "# TIMEOUT" in content
207
+ blocked = "BLOCKED" in content and not completed
208
+
209
+ return {
210
+ "exists": True,
211
+ "started": started,
212
+ "max_iterations": max_iterations,
213
+ "iterations": iterations,
214
+ "last_status": last_status,
215
+ "completed": completed,
216
+ "timed_out": timed_out,
217
+ "blocked": blocked,
218
+ }
219
+
220
+
221
+ def _task_id_to_display(task_id: str) -> str:
222
+ """Convert task_id format to display format.
223
+
224
+ "01" -> "1"
225
+ "01-02" -> "1.2"
226
+ """
227
+ if "-" in task_id:
228
+ parts = task_id.split("-")
229
+ return f"{int(parts[0])}.{int(parts[1])}"
230
+ return str(int(task_id))
231
+
232
+
233
+ def _is_task_completed(tasks_file: Path, task_id: str) -> bool:
234
+ """Check if a task is marked as completed in the tasks file.
235
+
236
+ Returns True if the checkbox is marked [x], False if [ ] or not found.
237
+ """
238
+ if not tasks_file.exists():
239
+ return False
240
+
241
+ content = tasks_file.read_text()
242
+ display_id = _task_id_to_display(task_id)
243
+
244
+ # Escape dots for regex
245
+ id_escaped = display_id.replace(".", r"\.")
246
+
247
+ # Check if task is marked completed: "- [x] N." or "- [x] N:"
248
+ if re.search(rf"^\s*- \[x\] {id_escaped}[.:]", content, re.MULTILINE):
249
+ return True
250
+
251
+ return False
252
+
253
+
254
+ def get_prompts_status(
255
+ task_dir: str | Path, task_name: str | None = None
256
+ ) -> dict[str, Any]:
257
+ """Get status of optimized prompts for a task.
258
+
259
+ Progress is tracked via checkboxes in the tasks.md file, not via status fields
260
+ in prompt files.
261
+
262
+ Returns:
263
+ Dict with prompt status: {
264
+ exists: bool,
265
+ total: int,
266
+ completed: int, # Tasks marked [x] in tasks.md
267
+ remaining: int, # Tasks still [ ] in tasks.md
268
+ next_prompt: str | None, # Path to next prompt for uncompleted task
269
+ }
270
+ """
271
+ task_dir = Path(task_dir)
272
+ prompts_dir = task_dir / "prompts"
273
+
274
+ if not prompts_dir.exists():
275
+ return {
276
+ "exists": False,
277
+ "total": 0,
278
+ "completed": 0,
279
+ "remaining": 0,
280
+ "next_prompt": None,
281
+ }
282
+
283
+ prompt_files = list(prompts_dir.glob("task-*-prompt.md"))
284
+
285
+ if not prompt_files:
286
+ return {
287
+ "exists": True,
288
+ "total": 0,
289
+ "completed": 0,
290
+ "remaining": 0,
291
+ "next_prompt": None,
292
+ }
293
+
294
+ # Find tasks file
295
+ if task_name:
296
+ tasks_file = task_dir / f"{task_name}-tasks.md"
297
+ else:
298
+ # Try to infer from directory name
299
+ tasks_file = task_dir / f"{task_dir.name}-tasks.md"
300
+
301
+ completed = 0
302
+ remaining = 0
303
+ next_prompt = None
304
+
305
+ for pf in sorted(prompt_files):
306
+ content = pf.read_text()
307
+
308
+ # Extract task_id from YAML frontmatter
309
+ task_id_match = re.search(
310
+ r"^task_id:\s*[\"']?([^\"'\n]+)[\"']?", content, re.MULTILINE
311
+ )
312
+ if task_id_match:
313
+ task_id = task_id_match.group(1).strip()
314
+
315
+ if _is_task_completed(tasks_file, task_id):
316
+ completed += 1
317
+ else:
318
+ remaining += 1
319
+ if next_prompt is None:
320
+ next_prompt = str(pf)
321
+ else:
322
+ # No task_id found, count as remaining
323
+ remaining += 1
324
+ if next_prompt is None:
325
+ next_prompt = str(pf)
326
+
327
+ return {
328
+ "exists": True,
329
+ "total": len(prompt_files),
330
+ "completed": completed,
331
+ "remaining": remaining,
332
+ "next_prompt": next_prompt,
333
+ }