claude-team-mcp 0.8.2__py3-none-any.whl → 0.9.1__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.
@@ -33,7 +33,7 @@ def register_tools(mcp: FastMCP, ensure_connection) -> None:
33
33
  @mcp.tool()
34
34
  async def discover_workers(
35
35
  ctx: Context[ServerSession, "AppContext"],
36
- max_age: int = 3600,
36
+ max_age: int | None = 3600,
37
37
  ) -> dict:
38
38
  """
39
39
  Discover existing Claude Code and Codex sessions running in the active terminal backend.
@@ -69,6 +69,9 @@ def register_tools(mcp: FastMCP, ensure_connection) -> None:
69
69
  - count: Total number of sessions found
70
70
  - unmanaged_count: Number not yet in registry (available to adopt)
71
71
  """
72
+ # Handle None values from MCP clients that send explicit null for omitted params
73
+ max_age = max_age if max_age is not None else 3600
74
+
72
75
  app_ctx = ctx.request_context.lifespan_context
73
76
  registry = app_ctx.registry
74
77
 
@@ -4,6 +4,7 @@ List workers tool.
4
4
  Provides list_workers for viewing all managed Claude Code sessions.
5
5
  """
6
6
 
7
+ import logging
7
8
  from pathlib import Path
8
9
  from typing import TYPE_CHECKING
9
10
 
@@ -16,6 +17,8 @@ if TYPE_CHECKING:
16
17
  from ..registry import SessionStatus
17
18
  from ..utils import error_response
18
19
 
20
+ logger = logging.getLogger("claude-team-mcp")
21
+
19
22
 
20
23
  def register_tools(mcp: FastMCP) -> None:
21
24
  """Register list_workers tool on the MCP server."""
@@ -44,6 +47,22 @@ def register_tools(mcp: FastMCP) -> None:
44
47
  app_ctx = ctx.request_context.lifespan_context
45
48
  registry = app_ctx.registry
46
49
 
50
+ # Lazy fallback: if registry is empty and recovery hasn't been attempted,
51
+ # try to recover from the event log. This handles edge cases where startup
52
+ # recovery may have failed or wasn't triggered.
53
+ from ..server import is_recovery_attempted, recover_registry
54
+
55
+ if not is_recovery_attempted() and len(registry.list_all()) == 0:
56
+ logger.info("Registry empty on first list_workers call, attempting lazy recovery...")
57
+ report = recover_registry(registry)
58
+ if report is not None:
59
+ logger.info(
60
+ "Lazy recovery complete: added=%d, skipped=%d, closed=%d",
61
+ report.added,
62
+ report.skipped,
63
+ report.closed,
64
+ )
65
+
47
66
  # Get sessions, optionally filtered by status
48
67
  if status_filter:
49
68
  try:
@@ -84,17 +103,26 @@ def register_tools(mcp: FastMCP) -> None:
84
103
  filtered_sessions.append(session)
85
104
  sessions = filtered_sessions
86
105
 
87
- # Sort by created_at
88
- sessions = sorted(sessions, key=lambda s: s.created_at)
106
+ # Sort by created_at (normalize to UTC-aware for mixed live/recovered)
107
+ from datetime import timezone as _tz
108
+
109
+ def _sort_key(s):
110
+ dt = s.created_at
111
+ if dt.tzinfo is None:
112
+ dt = dt.replace(tzinfo=_tz.utc)
113
+ return dt
114
+
115
+ sessions = sorted(sessions, key=_sort_key)
89
116
 
90
117
  # Convert to dicts and add message count + idle status
91
118
  workers = []
92
119
  for session in sessions:
93
120
  info = session.to_dict()
94
- # Try to get conversation stats
95
- state = session.get_conversation_state()
96
- if state:
97
- info["message_count"] = state.message_count
121
+ # Try to get conversation stats (only available on live ManagedSessions)
122
+ if hasattr(session, "get_conversation_state"):
123
+ state = session.get_conversation_state()
124
+ if state:
125
+ info["message_count"] = state.message_count
98
126
  # Check idle using stop hook detection
99
127
  info["is_idle"] = session.is_idle()
100
128
  workers.append(info)
@@ -31,7 +31,7 @@ def register_tools(mcp: FastMCP) -> None:
31
31
  async def list_worktrees(
32
32
  ctx: Context[ServerSession, "AppContext"],
33
33
  repo_path: str,
34
- remove_orphans: bool = False,
34
+ remove_orphans: bool | None = False,
35
35
  ) -> dict:
36
36
  """
37
37
  List worktrees in a repository's .worktrees/ directory.
@@ -58,6 +58,9 @@ def register_tools(mcp: FastMCP) -> None:
58
58
  - orphan_count: Number of orphaned worktrees
59
59
  - removed_count: Number of orphans removed (when remove_orphans=True)
60
60
  """
61
+ # Handle None values from MCP clients that send explicit null for omitted params
62
+ remove_orphans = remove_orphans if remove_orphans is not None else False
63
+
61
64
  resolved_path = Path(repo_path).resolve()
62
65
  if not resolved_path.exists():
63
66
  return error_response(
@@ -131,8 +131,8 @@ def register_tools(mcp: FastMCP) -> None:
131
131
  ctx: Context[ServerSession, "AppContext"],
132
132
  session_ids: list[str],
133
133
  message: str,
134
- wait_mode: str = "none",
135
- timeout: float = 600.0,
134
+ wait_mode: str | None = "none",
135
+ timeout: float | None = 600.0,
136
136
  ) -> dict:
137
137
  """
138
138
  Send a message to one or more Claude Code worker sessions.
@@ -163,6 +163,10 @@ def register_tools(mcp: FastMCP) -> None:
163
163
  - all_idle: Whether all sessions are idle (only if wait_mode != "none")
164
164
  - timed_out: Whether the wait timed out (only if wait_mode != "none")
165
165
  """
166
+ # Handle None values from MCP clients that send explicit null for omitted params
167
+ wait_mode = wait_mode or "none"
168
+ timeout = timeout if timeout is not None else 600.0
169
+
166
170
  app_ctx = ctx.request_context.lifespan_context
167
171
  registry = app_ctx.registry
168
172
  backend = app_ctx.terminal_backend
@@ -18,6 +18,7 @@ from claude_team.events import WorkerEvent
18
18
  if TYPE_CHECKING:
19
19
  from ..server import AppContext
20
20
 
21
+ from ..config import load_config
21
22
  from ..utils import error_response
22
23
 
23
24
 
@@ -120,8 +121,8 @@ def register_tools(mcp: FastMCP) -> None:
120
121
  async def poll_worker_changes(
121
122
  ctx: Context[ServerSession, "AppContext"],
122
123
  since: str | None = None,
123
- stale_threshold_minutes: int = 20,
124
- include_snapshots: bool = False,
124
+ stale_threshold_minutes: int | None = None,
125
+ include_snapshots: bool | None = False,
125
126
  ) -> dict:
126
127
  """
127
128
  Poll worker event changes since a timestamp.
@@ -132,6 +133,7 @@ def register_tools(mcp: FastMCP) -> None:
132
133
  Args:
133
134
  since: ISO timestamp to filter events from (inclusive), or None for latest.
134
135
  stale_threshold_minutes: Minutes without activity before a worker is marked stuck.
136
+ Defaults to the value in ~/.claude-team/config.json (events.stale_threshold_minutes).
135
137
  include_snapshots: Whether to include snapshot events in the response.
136
138
 
137
139
  Returns:
@@ -142,9 +144,17 @@ def register_tools(mcp: FastMCP) -> None:
142
144
  - idle_count: Count of idle workers
143
145
  - poll_ts: Timestamp when poll was generated
144
146
  """
147
+ # Handle None values from MCP clients that send explicit null for omitted params
148
+ include_snapshots = include_snapshots if include_snapshots is not None else False
149
+
145
150
  app_ctx = ctx.request_context.lifespan_context
146
151
  registry = app_ctx.registry
147
152
 
153
+ # Resolve stale threshold: tool param overrides config default.
154
+ if stale_threshold_minutes is None:
155
+ config = load_config()
156
+ stale_threshold_minutes = config.events.stale_threshold_minutes
157
+
148
158
  # Validate inputs before reading the log.
149
159
  if stale_threshold_minutes <= 0:
150
160
  return error_response(
@@ -22,8 +22,8 @@ def register_tools(mcp: FastMCP) -> None:
22
22
  async def read_worker_logs(
23
23
  ctx: Context[ServerSession, "AppContext"],
24
24
  session_id: str,
25
- pages: int = 1,
26
- offset: int = 0,
25
+ pages: int | None = 1,
26
+ offset: int | None = 0,
27
27
  ) -> dict:
28
28
  """
29
29
  Get conversation history from a Claude Code session with reverse pagination.
@@ -51,6 +51,10 @@ def register_tools(mcp: FastMCP) -> None:
51
51
  - page_info: Pagination metadata (total_messages, total_pages, etc.)
52
52
  - session_id: The session ID
53
53
  """
54
+ # Handle None values from MCP clients that send explicit null for omitted params
55
+ pages = pages if pages is not None else 1
56
+ offset = offset if offset is not None else 0
57
+
54
58
  app_ctx = ctx.request_context.lifespan_context
55
59
  registry = app_ctx.registry
56
60
 
@@ -28,9 +28,9 @@ def register_tools(mcp: FastMCP) -> None:
28
28
  async def wait_idle_workers(
29
29
  ctx: Context[ServerSession, "AppContext"],
30
30
  session_ids: list[str],
31
- mode: str = "all",
32
- timeout: float = 600.0,
33
- poll_interval: float = 2.0,
31
+ mode: str | None = "all",
32
+ timeout: float | None = 600.0,
33
+ poll_interval: float | None = 2.0,
34
34
  ) -> dict:
35
35
  """
36
36
  Wait for worker sessions to become idle.
@@ -56,6 +56,11 @@ def register_tools(mcp: FastMCP) -> None:
56
56
  - waited_seconds: How long we waited
57
57
  - timed_out: Whether we hit the timeout
58
58
  """
59
+ # Handle None values from MCP clients that send explicit null for omitted params
60
+ mode = mode or "all"
61
+ timeout = timeout if timeout is not None else 600.0
62
+ poll_interval = poll_interval if poll_interval is not None else 2.0
63
+
59
64
  app_ctx = ctx.request_context.lifespan_context
60
65
  registry = app_ctx.registry
61
66
 
@@ -0,0 +1,279 @@
1
+ """
2
+ Worker events tool.
3
+
4
+ Provides worker_events for querying the event log with optional summary and snapshot.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import asdict
10
+ from datetime import datetime, timezone
11
+ from typing import TYPE_CHECKING
12
+
13
+ from mcp.server.fastmcp import Context, FastMCP
14
+ from mcp.server.session import ServerSession
15
+
16
+ from claude_team import events as events_module
17
+ from claude_team.events import WorkerEvent
18
+
19
+ if TYPE_CHECKING:
20
+ from ..server import AppContext
21
+
22
+
23
+ def _parse_iso_timestamp(value: str) -> datetime | None:
24
+ """Parse ISO timestamps for query filtering."""
25
+ value = value.strip()
26
+ if not value:
27
+ return None
28
+ # Normalize Zulu timestamps for fromisoformat.
29
+ if value.endswith("Z"):
30
+ value = value[:-1] + "+00:00"
31
+ try:
32
+ parsed = datetime.fromisoformat(value)
33
+ except ValueError:
34
+ return None
35
+ # Default to UTC when no timezone is provided.
36
+ if parsed.tzinfo is None:
37
+ return parsed.replace(tzinfo=timezone.utc)
38
+ return parsed.astimezone(timezone.utc)
39
+
40
+
41
+ def _serialize_event(event: WorkerEvent) -> dict:
42
+ """Convert a WorkerEvent into a JSON-serializable payload."""
43
+ return {
44
+ "ts": event.ts,
45
+ "type": event.type,
46
+ "worker_id": event.worker_id,
47
+ "data": event.data,
48
+ }
49
+
50
+
51
+ def _event_project(event: WorkerEvent) -> str | None:
52
+ """Extract a project identifier from event data."""
53
+ data = event.data or {}
54
+ for key in ("project", "project_path"):
55
+ value = data.get(key)
56
+ if value:
57
+ return str(value)
58
+ return None
59
+
60
+
61
+ def _filter_by_project(events: list[WorkerEvent], project_filter: str) -> list[WorkerEvent]:
62
+ """Filter events to only those matching the project filter."""
63
+ filtered = []
64
+ for event in events:
65
+ project = _event_project(event)
66
+ # Include events with no project (e.g. snapshots) or matching project.
67
+ if project is None or project_filter in project:
68
+ filtered.append(event)
69
+ return filtered
70
+
71
+
72
+ def _build_summary(
73
+ events: list[WorkerEvent],
74
+ stale_threshold_minutes: int,
75
+ ) -> dict:
76
+ """
77
+ Build summary from the event window.
78
+
79
+ Returns:
80
+ Dict with started, closed, idle, active, stuck lists and last_event_ts.
81
+ """
82
+ # Track worker states from events.
83
+ started: list[str] = []
84
+ closed: list[str] = []
85
+ idle: list[str] = []
86
+ active: list[str] = []
87
+
88
+ # Track last known state and activity time per worker.
89
+ last_state: dict[str, str] = {}
90
+ last_activity: dict[str, datetime] = {}
91
+
92
+ last_event_ts: str | None = None
93
+
94
+ for event in events:
95
+ # Track latest event timestamp.
96
+ last_event_ts = event.ts
97
+
98
+ worker_id = event.worker_id
99
+ if not worker_id:
100
+ # Handle snapshot events for state tracking.
101
+ if event.type == "snapshot":
102
+ _process_snapshot_for_summary(
103
+ event.data,
104
+ event.ts,
105
+ last_state,
106
+ last_activity,
107
+ )
108
+ continue
109
+
110
+ # Update activity time.
111
+ ts = _parse_iso_timestamp(event.ts)
112
+ if ts:
113
+ last_activity[worker_id] = ts
114
+
115
+ if event.type == "worker_started":
116
+ started.append(worker_id)
117
+ last_state[worker_id] = "active"
118
+ elif event.type == "worker_closed":
119
+ closed.append(worker_id)
120
+ last_state[worker_id] = "closed"
121
+ elif event.type == "worker_idle":
122
+ idle.append(worker_id)
123
+ last_state[worker_id] = "idle"
124
+ elif event.type == "worker_active":
125
+ active.append(worker_id)
126
+ last_state[worker_id] = "active"
127
+
128
+ # Compute stuck workers: active with last_activity > threshold.
129
+ stuck: list[str] = []
130
+ now = datetime.now(timezone.utc)
131
+ threshold_seconds = stale_threshold_minutes * 60
132
+
133
+ for worker_id, state in last_state.items():
134
+ if state != "active":
135
+ continue
136
+ activity_ts = last_activity.get(worker_id)
137
+ if activity_ts is None:
138
+ continue
139
+ elapsed = (now - activity_ts).total_seconds()
140
+ if elapsed > threshold_seconds:
141
+ stuck.append(worker_id)
142
+
143
+ return {
144
+ "started": started,
145
+ "closed": closed,
146
+ "idle": idle,
147
+ "active": active,
148
+ "stuck": stuck,
149
+ "last_event_ts": last_event_ts,
150
+ }
151
+
152
+
153
+ def _process_snapshot_for_summary(
154
+ data: dict,
155
+ event_ts: str,
156
+ last_state: dict[str, str],
157
+ last_activity: dict[str, datetime],
158
+ ) -> None:
159
+ """Update state tracking from a snapshot event."""
160
+ workers = data.get("workers")
161
+ if not isinstance(workers, list):
162
+ return
163
+
164
+ ts = _parse_iso_timestamp(event_ts)
165
+
166
+ for worker in workers:
167
+ if not isinstance(worker, dict):
168
+ continue
169
+
170
+ # Find worker ID from various possible keys.
171
+ worker_id = None
172
+ for key in ("session_id", "worker_id", "id"):
173
+ value = worker.get(key)
174
+ if value:
175
+ worker_id = str(value)
176
+ break
177
+
178
+ if not worker_id:
179
+ continue
180
+
181
+ # Extract state from snapshot.
182
+ state = worker.get("state")
183
+ if isinstance(state, str) and state:
184
+ last_state[worker_id] = state
185
+ if ts and state == "active":
186
+ last_activity[worker_id] = ts
187
+
188
+
189
+ def register_tools(mcp: FastMCP) -> None:
190
+ """Register worker_events tool on the MCP server."""
191
+
192
+ @mcp.tool()
193
+ async def worker_events(
194
+ ctx: Context[ServerSession, "AppContext"],
195
+ since: str | None = None,
196
+ limit: int | None = 1000,
197
+ include_snapshot: bool | None = False,
198
+ include_summary: bool | None = False,
199
+ stale_threshold_minutes: int | None = 10,
200
+ project_filter: str | None = None,
201
+ ) -> dict:
202
+ """
203
+ Query worker events from the event log.
204
+
205
+ Provides access to the persisted worker event log with optional summary
206
+ aggregation and snapshot inclusion. This is the primary API for external
207
+ consumers to monitor worker lifecycle events.
208
+
209
+ Args:
210
+ since: ISO 8601 timestamp; returns events at or after this time.
211
+ If omitted, returns most recent events (bounded by limit).
212
+ limit: Maximum number of events returned (default 1000).
213
+ include_snapshot: If true, include the latest snapshot event in response.
214
+ include_summary: If true, include summary aggregates (started, closed,
215
+ idle, active, stuck lists).
216
+ stale_threshold_minutes: Minutes without activity before a worker is
217
+ marked stuck (only used when include_summary=true, default 10).
218
+ project_filter: Optional project path substring to filter events.
219
+
220
+ Returns:
221
+ Dict with:
222
+ - events: List of worker events [{ts, type, worker_id, data}]
223
+ - count: Number of events returned
224
+ - summary: (if include_summary) Aggregates from event window:
225
+ - started: worker IDs that started
226
+ - closed: worker IDs that closed
227
+ - idle: worker IDs that became idle
228
+ - active: worker IDs that became active
229
+ - stuck: active workers with last_activity > stale_threshold
230
+ - last_event_ts: newest event timestamp
231
+ - snapshot: (if include_snapshot) Latest snapshot {ts, data}
232
+ """
233
+ # Handle None values from MCP clients that send explicit null for omitted params
234
+ limit = limit if limit is not None else 1000
235
+ include_snapshot = include_snapshot if include_snapshot is not None else False
236
+ include_summary = include_summary if include_summary is not None else False
237
+ stale_threshold_minutes = stale_threshold_minutes if stale_threshold_minutes is not None else 10
238
+
239
+ # Parse the since timestamp if provided.
240
+ parsed_since = None
241
+ if since is not None and since.strip():
242
+ parsed_since = _parse_iso_timestamp(since)
243
+ if parsed_since is None:
244
+ return {
245
+ "error": f"Invalid since timestamp: {since}",
246
+ "hint": "Use ISO format like 2026-01-27T11:40:00Z",
247
+ }
248
+
249
+ # Read events from the log.
250
+ events = events_module.read_events_since(parsed_since, limit=limit)
251
+
252
+ # Apply project filter if specified.
253
+ if project_filter:
254
+ events = _filter_by_project(events, project_filter)
255
+
256
+ # Build the response.
257
+ response: dict = {
258
+ "events": [_serialize_event(event) for event in events],
259
+ "count": len(events),
260
+ }
261
+
262
+ # Add summary if requested.
263
+ if include_summary:
264
+ response["summary"] = _build_summary(events, stale_threshold_minutes)
265
+
266
+ # Add snapshot if requested.
267
+ if include_snapshot:
268
+ snapshot_data = events_module.get_latest_snapshot()
269
+ if snapshot_data is not None:
270
+ # Find the timestamp from the snapshot data or use a sentinel.
271
+ snapshot_ts = snapshot_data.get("ts")
272
+ response["snapshot"] = {
273
+ "ts": snapshot_ts,
274
+ "data": snapshot_data,
275
+ }
276
+ else:
277
+ response["snapshot"] = None
278
+
279
+ return response