mcp-orbit 0.2.0__tar.gz

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.
Files changed (32) hide show
  1. mcp_orbit-0.2.0/.gitignore +18 -0
  2. mcp_orbit-0.2.0/LICENSE +21 -0
  3. mcp_orbit-0.2.0/PKG-INFO +51 -0
  4. mcp_orbit-0.2.0/README.md +21 -0
  5. mcp_orbit-0.2.0/pyproject.toml +50 -0
  6. mcp_orbit-0.2.0/src/mcp_orbit/__init__.py +3 -0
  7. mcp_orbit-0.2.0/src/mcp_orbit/app.py +5 -0
  8. mcp_orbit-0.2.0/src/mcp_orbit/config.py +25 -0
  9. mcp_orbit-0.2.0/src/mcp_orbit/db.py +35 -0
  10. mcp_orbit-0.2.0/src/mcp_orbit/errors.py +85 -0
  11. mcp_orbit-0.2.0/src/mcp_orbit/helpers.py +194 -0
  12. mcp_orbit-0.2.0/src/mcp_orbit/iteration_log.py +333 -0
  13. mcp_orbit-0.2.0/src/mcp_orbit/models.py +131 -0
  14. mcp_orbit-0.2.0/src/mcp_orbit/orbit.py +461 -0
  15. mcp_orbit-0.2.0/src/mcp_orbit/server.py +31 -0
  16. mcp_orbit-0.2.0/src/mcp_orbit/templates/__init__.py +1 -0
  17. mcp_orbit-0.2.0/src/mcp_orbit/templates/context.md +33 -0
  18. mcp_orbit-0.2.0/src/mcp_orbit/templates/plan.md +39 -0
  19. mcp_orbit-0.2.0/src/mcp_orbit/templates/tasks.md +33 -0
  20. mcp_orbit-0.2.0/src/mcp_orbit/tools_docs.py +289 -0
  21. mcp_orbit-0.2.0/src/mcp_orbit/tools_iteration.py +167 -0
  22. mcp_orbit-0.2.0/src/mcp_orbit/tools_planning.py +473 -0
  23. mcp_orbit-0.2.0/src/mcp_orbit/tools_tasks.py +521 -0
  24. mcp_orbit-0.2.0/src/mcp_orbit/tools_tracking.py +287 -0
  25. mcp_orbit-0.2.0/tests/conftest.py +171 -0
  26. mcp_orbit-0.2.0/tests/test_errors.py +67 -0
  27. mcp_orbit-0.2.0/tests/test_helpers.py +38 -0
  28. mcp_orbit-0.2.0/tests/test_iteration_log.py +117 -0
  29. mcp_orbit-0.2.0/tests/test_models.py +63 -0
  30. mcp_orbit-0.2.0/tests/test_orbit_files.py +130 -0
  31. mcp_orbit-0.2.0/tests/test_orbit_pure.py +145 -0
  32. mcp_orbit-0.2.0/uv.lock +759 -0
@@ -0,0 +1,18 @@
1
+ __pycache__/
2
+ *.pyc
3
+ *.pyo
4
+ *.egg-info/
5
+ dist/
6
+ build/
7
+ .eggs/
8
+ *.egg
9
+ .venv/
10
+ venv/
11
+ .env
12
+ *.duckdb
13
+ *.duckdb.wal
14
+ *.db-journal
15
+ .DS_Store
16
+ CLAUDE.local.md
17
+ .serena/
18
+ .playwright-mcp/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Tomer Brami
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,51 @@
1
+ Metadata-Version: 2.4
2
+ Name: mcp-orbit
3
+ Version: 0.2.0
4
+ Summary: MCP server for the orbit Claude Code plugin: project management with time tracking
5
+ Project-URL: Homepage, https://github.com/tomerbr1/claude-orbit
6
+ Project-URL: Repository, https://github.com/tomerbr1/claude-orbit
7
+ Project-URL: Issues, https://github.com/tomerbr1/claude-orbit/issues
8
+ Author-email: Tomer Brami <tomerbrami@gmail.com>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: claude-code,mcp,model-context-protocol,orbit,task-tracking
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Topic :: Software Development
20
+ Classifier: Topic :: Utilities
21
+ Requires-Python: >=3.11
22
+ Requires-Dist: mcp>=1.0.0
23
+ Requires-Dist: orbit-db>=1.0.0
24
+ Requires-Dist: pydantic-settings>=2.0.0
25
+ Requires-Dist: pydantic>=2.0.0
26
+ Provides-Extra: dev
27
+ Requires-Dist: pytest-asyncio>=0.21.0; extra == 'dev'
28
+ Requires-Dist: pytest>=7.0.0; extra == 'dev'
29
+ Description-Content-Type: text/markdown
30
+
31
+ # MCP Orbit Server
32
+
33
+ MCP server for orbit project management with time tracking.
34
+
35
+ ## Installation
36
+
37
+ ```bash
38
+ pip install -e .
39
+ ```
40
+
41
+ ## Usage
42
+
43
+ ```bash
44
+ mcp-orbit
45
+ ```
46
+
47
+ Or via uvx:
48
+
49
+ ```bash
50
+ uvx --from ~/.claude/plugins/local/orbit/mcp-server mcp-orbit
51
+ ```
@@ -0,0 +1,21 @@
1
+ # MCP Orbit Server
2
+
3
+ MCP server for orbit project management with time tracking.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pip install -e .
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ```bash
14
+ mcp-orbit
15
+ ```
16
+
17
+ Or via uvx:
18
+
19
+ ```bash
20
+ uvx --from ~/.claude/plugins/local/orbit/mcp-server mcp-orbit
21
+ ```
@@ -0,0 +1,50 @@
1
+ [project]
2
+ name = "mcp-orbit"
3
+ version = "0.2.0"
4
+ description = "MCP server for the orbit Claude Code plugin: project management with time tracking"
5
+ readme = "README.md"
6
+ requires-python = ">=3.11"
7
+ license = "MIT"
8
+ license-files = ["LICENSE"]
9
+ authors = [
10
+ {name = "Tomer Brami", email = "tomerbrami@gmail.com"}
11
+ ]
12
+ keywords = ["claude-code", "orbit", "mcp", "model-context-protocol", "task-tracking"]
13
+ classifiers = [
14
+ "Development Status :: 4 - Beta",
15
+ "Intended Audience :: Developers",
16
+ "Operating System :: OS Independent",
17
+ "Programming Language :: Python :: 3",
18
+ "Programming Language :: Python :: 3.11",
19
+ "Programming Language :: Python :: 3.12",
20
+ "Programming Language :: Python :: 3.13",
21
+ "Topic :: Software Development",
22
+ "Topic :: Utilities",
23
+ ]
24
+ dependencies = [
25
+ "orbit-db>=1.0.0",
26
+ "mcp>=1.0.0",
27
+ "pydantic>=2.0.0",
28
+ "pydantic-settings>=2.0.0",
29
+ ]
30
+
31
+ [project.optional-dependencies]
32
+ dev = [
33
+ "pytest>=7.0.0",
34
+ "pytest-asyncio>=0.21.0",
35
+ ]
36
+
37
+ [project.urls]
38
+ Homepage = "https://github.com/tomerbr1/claude-orbit"
39
+ Repository = "https://github.com/tomerbr1/claude-orbit"
40
+ Issues = "https://github.com/tomerbr1/claude-orbit/issues"
41
+
42
+ [project.scripts]
43
+ mcp-orbit = "mcp_orbit.server:main"
44
+
45
+ [build-system]
46
+ requires = ["hatchling"]
47
+ build-backend = "hatchling.build"
48
+
49
+ [tool.hatch.build.targets.wheel]
50
+ packages = ["src/mcp_orbit"]
@@ -0,0 +1,3 @@
1
+ """MCP server for orbit project management."""
2
+
3
+ __version__ = "0.1.0"
@@ -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")
@@ -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()
@@ -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
+ ]
@@ -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)
@@ -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