claude-team-mcp 0.4.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.
- claude_team_mcp/__init__.py +24 -0
- claude_team_mcp/__main__.py +8 -0
- claude_team_mcp/cli_backends/__init__.py +44 -0
- claude_team_mcp/cli_backends/base.py +132 -0
- claude_team_mcp/cli_backends/claude.py +110 -0
- claude_team_mcp/cli_backends/codex.py +110 -0
- claude_team_mcp/colors.py +108 -0
- claude_team_mcp/formatting.py +120 -0
- claude_team_mcp/idle_detection.py +488 -0
- claude_team_mcp/iterm_utils.py +1119 -0
- claude_team_mcp/names.py +427 -0
- claude_team_mcp/profile.py +364 -0
- claude_team_mcp/registry.py +426 -0
- claude_team_mcp/schemas/__init__.py +5 -0
- claude_team_mcp/schemas/codex.py +267 -0
- claude_team_mcp/server.py +390 -0
- claude_team_mcp/session_state.py +1058 -0
- claude_team_mcp/subprocess_cache.py +119 -0
- claude_team_mcp/tools/__init__.py +52 -0
- claude_team_mcp/tools/adopt_worker.py +122 -0
- claude_team_mcp/tools/annotate_worker.py +57 -0
- claude_team_mcp/tools/bd_help.py +42 -0
- claude_team_mcp/tools/check_idle_workers.py +98 -0
- claude_team_mcp/tools/close_workers.py +194 -0
- claude_team_mcp/tools/discover_workers.py +129 -0
- claude_team_mcp/tools/examine_worker.py +56 -0
- claude_team_mcp/tools/list_workers.py +76 -0
- claude_team_mcp/tools/list_worktrees.py +106 -0
- claude_team_mcp/tools/message_workers.py +311 -0
- claude_team_mcp/tools/read_worker_logs.py +158 -0
- claude_team_mcp/tools/spawn_workers.py +634 -0
- claude_team_mcp/tools/wait_idle_workers.py +148 -0
- claude_team_mcp/utils/__init__.py +17 -0
- claude_team_mcp/utils/constants.py +87 -0
- claude_team_mcp/utils/errors.py +87 -0
- claude_team_mcp/utils/worktree_detection.py +79 -0
- claude_team_mcp/worker_prompt.py +350 -0
- claude_team_mcp/worktree.py +532 -0
- claude_team_mcp-0.4.0.dist-info/METADATA +414 -0
- claude_team_mcp-0.4.0.dist-info/RECORD +42 -0
- claude_team_mcp-0.4.0.dist-info/WHEEL +4 -0
- claude_team_mcp-0.4.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Claude Team MCP Server
|
|
3
|
+
|
|
4
|
+
FastMCP-based server for managing multiple Claude Code sessions via iTerm2.
|
|
5
|
+
Allows a "manager" Claude Code session to spawn and coordinate multiple
|
|
6
|
+
"worker" Claude Code sessions.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
from collections.abc import AsyncIterator
|
|
11
|
+
from contextlib import asynccontextmanager
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from typing import TYPE_CHECKING
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from iterm2.app import App as ItermApp
|
|
17
|
+
from iterm2.connection import Connection as ItermConnection
|
|
18
|
+
|
|
19
|
+
from mcp.server.fastmcp import Context, FastMCP
|
|
20
|
+
from mcp.server.session import ServerSession
|
|
21
|
+
|
|
22
|
+
from .iterm_utils import read_screen_text
|
|
23
|
+
from .registry import SessionRegistry
|
|
24
|
+
from .tools import register_all_tools
|
|
25
|
+
from .utils import error_response, HINTS
|
|
26
|
+
|
|
27
|
+
# Configure logging
|
|
28
|
+
logging.basicConfig(
|
|
29
|
+
level=logging.DEBUG,
|
|
30
|
+
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
|
31
|
+
)
|
|
32
|
+
logger = logging.getLogger("claude-team-mcp")
|
|
33
|
+
# Add file handler for debugging
|
|
34
|
+
_fh = logging.FileHandler("/tmp/claude-team-debug.log")
|
|
35
|
+
_fh.setLevel(logging.DEBUG)
|
|
36
|
+
_fh.setFormatter(logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s"))
|
|
37
|
+
logger.addHandler(_fh)
|
|
38
|
+
logging.getLogger().addHandler(_fh) # Also capture root logger
|
|
39
|
+
logger.info("=== Claude Team MCP Server Starting ===")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# =============================================================================
|
|
43
|
+
# Singleton Registry (persists across MCP sessions for HTTP mode)
|
|
44
|
+
# =============================================================================
|
|
45
|
+
|
|
46
|
+
_global_registry: SessionRegistry | None = None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def get_global_registry() -> SessionRegistry:
|
|
50
|
+
"""Get or create the global singleton registry."""
|
|
51
|
+
global _global_registry
|
|
52
|
+
if _global_registry is None:
|
|
53
|
+
_global_registry = SessionRegistry()
|
|
54
|
+
logger.info("Created global singleton registry")
|
|
55
|
+
return _global_registry
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# =============================================================================
|
|
59
|
+
# Application Context
|
|
60
|
+
# =============================================================================
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass
|
|
64
|
+
class AppContext:
|
|
65
|
+
"""
|
|
66
|
+
Application context shared across all tool invocations.
|
|
67
|
+
|
|
68
|
+
Maintains the iTerm2 connection and registry of managed sessions.
|
|
69
|
+
This is the persistent state that makes the MCP server useful.
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
iterm_connection: "ItermConnection"
|
|
73
|
+
iterm_app: "ItermApp"
|
|
74
|
+
registry: SessionRegistry
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# =============================================================================
|
|
78
|
+
# Lifespan Management
|
|
79
|
+
# =============================================================================
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
async def refresh_iterm_connection() -> tuple["ItermConnection", "ItermApp"]:
|
|
83
|
+
"""
|
|
84
|
+
Create a fresh iTerm2 connection.
|
|
85
|
+
|
|
86
|
+
The iTerm2 Python API uses websockets with ping_interval=None, meaning
|
|
87
|
+
connections can go stale without any keepalive mechanism. This function
|
|
88
|
+
creates a new connection when needed.
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
Tuple of (connection, app)
|
|
92
|
+
|
|
93
|
+
Raises:
|
|
94
|
+
RuntimeError: If connection fails
|
|
95
|
+
"""
|
|
96
|
+
from iterm2.app import async_get_app
|
|
97
|
+
from iterm2.connection import Connection
|
|
98
|
+
|
|
99
|
+
logger.debug("Creating fresh iTerm2 connection...")
|
|
100
|
+
try:
|
|
101
|
+
connection = await Connection.async_create()
|
|
102
|
+
app = await async_get_app(connection)
|
|
103
|
+
if app is None:
|
|
104
|
+
raise RuntimeError("Could not get iTerm2 app")
|
|
105
|
+
logger.debug("Fresh iTerm2 connection established")
|
|
106
|
+
return connection, app
|
|
107
|
+
except Exception as e:
|
|
108
|
+
logger.error(f"Failed to refresh iTerm2 connection: {e}")
|
|
109
|
+
raise RuntimeError("Could not connect to iTerm2") from e
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
async def ensure_connection(app_ctx: "AppContext") -> tuple["ItermConnection", "ItermApp"]:
|
|
113
|
+
"""
|
|
114
|
+
Ensure we have a working iTerm2 connection, refreshing if stale.
|
|
115
|
+
|
|
116
|
+
The iTerm2 websocket connection can go stale due to lack of keepalive
|
|
117
|
+
(ping_interval=None in the iterm2 library). This function tests the
|
|
118
|
+
connection and refreshes it if needed.
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
app_ctx: The application context containing connection and app
|
|
122
|
+
|
|
123
|
+
Returns:
|
|
124
|
+
Tuple of (connection, app) - either existing or refreshed
|
|
125
|
+
"""
|
|
126
|
+
from iterm2.app import async_get_app
|
|
127
|
+
|
|
128
|
+
connection = app_ctx.iterm_connection
|
|
129
|
+
app = app_ctx.iterm_app
|
|
130
|
+
|
|
131
|
+
# Test if connection is still alive by trying a simple operation
|
|
132
|
+
try:
|
|
133
|
+
# async_get_app is a lightweight call that tests the connection
|
|
134
|
+
refreshed_app = await async_get_app(connection)
|
|
135
|
+
if refreshed_app is not None:
|
|
136
|
+
return connection, refreshed_app
|
|
137
|
+
# App is None, need to refresh
|
|
138
|
+
raise RuntimeError("App is None, refreshing connection")
|
|
139
|
+
except Exception as e:
|
|
140
|
+
logger.warning(f"iTerm2 connection appears stale ({e}), refreshing...")
|
|
141
|
+
# Connection is dead, create a new one
|
|
142
|
+
connection, app = await refresh_iterm_connection()
|
|
143
|
+
# Update the context with fresh connection
|
|
144
|
+
app_ctx.iterm_connection = connection
|
|
145
|
+
app_ctx.iterm_app = app
|
|
146
|
+
return connection, app
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@asynccontextmanager
|
|
150
|
+
async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]:
|
|
151
|
+
"""
|
|
152
|
+
Manage iTerm2 connection lifecycle.
|
|
153
|
+
|
|
154
|
+
Connects to iTerm2 on startup and maintains the connection
|
|
155
|
+
for the duration of the server's lifetime.
|
|
156
|
+
|
|
157
|
+
Note: The iTerm2 Python API uses websockets with ping_interval=None,
|
|
158
|
+
meaning connections can go stale. Individual tool functions should use
|
|
159
|
+
ensure_connection() before making iTerm2 API calls that use the
|
|
160
|
+
connection directly.
|
|
161
|
+
"""
|
|
162
|
+
logger.info("Claude Team MCP Server starting...")
|
|
163
|
+
|
|
164
|
+
# Import iterm2 here to fail fast if not available
|
|
165
|
+
try:
|
|
166
|
+
from iterm2.app import async_get_app
|
|
167
|
+
from iterm2.connection import Connection
|
|
168
|
+
except ImportError as e:
|
|
169
|
+
logger.error(
|
|
170
|
+
"iterm2 package not found. Install with: uv add iterm2\n"
|
|
171
|
+
"Also enable: iTerm2 → Preferences → General → Magic → Enable Python API"
|
|
172
|
+
)
|
|
173
|
+
raise RuntimeError("iterm2 package required") from e
|
|
174
|
+
|
|
175
|
+
# Connect to iTerm2
|
|
176
|
+
logger.info("Connecting to iTerm2...")
|
|
177
|
+
try:
|
|
178
|
+
connection = await Connection.async_create()
|
|
179
|
+
app = await async_get_app(connection)
|
|
180
|
+
if app is None:
|
|
181
|
+
raise RuntimeError("Could not get iTerm2 app")
|
|
182
|
+
logger.info("Connected to iTerm2 successfully")
|
|
183
|
+
except Exception as e:
|
|
184
|
+
logger.error(f"Failed to connect to iTerm2: {e}")
|
|
185
|
+
logger.error("Make sure iTerm2 is running and Python API is enabled")
|
|
186
|
+
raise RuntimeError("Could not connect to iTerm2") from e
|
|
187
|
+
|
|
188
|
+
# Create application context with singleton registry (persists across sessions)
|
|
189
|
+
ctx = AppContext(
|
|
190
|
+
iterm_connection=connection,
|
|
191
|
+
iterm_app=app,
|
|
192
|
+
registry=get_global_registry(),
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
try:
|
|
196
|
+
yield ctx
|
|
197
|
+
finally:
|
|
198
|
+
# Cleanup: close any remaining sessions gracefully
|
|
199
|
+
logger.info("Claude Team MCP Server shutting down...")
|
|
200
|
+
if ctx.registry.count() > 0:
|
|
201
|
+
logger.info(f"Cleaning up {ctx.registry.count()} managed session(s)...")
|
|
202
|
+
logger.info("Shutdown complete")
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
# =============================================================================
|
|
206
|
+
# FastMCP Server Factory
|
|
207
|
+
# =============================================================================
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def create_mcp_server(host: str = "127.0.0.1", port: int = 8766) -> FastMCP:
|
|
211
|
+
"""Create and configure the FastMCP server instance."""
|
|
212
|
+
server = FastMCP(
|
|
213
|
+
"Claude Team Manager",
|
|
214
|
+
lifespan=app_lifespan,
|
|
215
|
+
host=host,
|
|
216
|
+
port=port,
|
|
217
|
+
)
|
|
218
|
+
# Register all tools from the tools package
|
|
219
|
+
register_all_tools(server, ensure_connection)
|
|
220
|
+
return server
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
# Default server instance for stdio mode (backwards compatibility)
|
|
224
|
+
mcp = create_mcp_server()
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
# =============================================================================
|
|
228
|
+
# MCP Resources
|
|
229
|
+
# =============================================================================
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
@mcp.resource("sessions://list")
|
|
233
|
+
async def resource_sessions(ctx: Context[ServerSession, AppContext]) -> list[dict]:
|
|
234
|
+
"""
|
|
235
|
+
List all managed Claude Code sessions.
|
|
236
|
+
|
|
237
|
+
Returns a list of session summaries including ID, name, project path,
|
|
238
|
+
status, and conversation stats if available. This is a read-only
|
|
239
|
+
resource alternative to the list_workers tool.
|
|
240
|
+
"""
|
|
241
|
+
app_ctx = ctx.request_context.lifespan_context
|
|
242
|
+
registry = app_ctx.registry
|
|
243
|
+
|
|
244
|
+
sessions = registry.list_all()
|
|
245
|
+
results = []
|
|
246
|
+
|
|
247
|
+
for session in sessions:
|
|
248
|
+
info = session.to_dict()
|
|
249
|
+
# Add conversation stats if JSONL is available
|
|
250
|
+
state = session.get_conversation_state()
|
|
251
|
+
if state:
|
|
252
|
+
info["message_count"] = state.message_count
|
|
253
|
+
# Check idle using stop hook detection
|
|
254
|
+
info["is_idle"] = session.is_idle()
|
|
255
|
+
results.append(info)
|
|
256
|
+
|
|
257
|
+
return results
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
@mcp.resource("sessions://{session_id}/status")
|
|
261
|
+
async def resource_session_status(
|
|
262
|
+
session_id: str, ctx: Context[ServerSession, AppContext]
|
|
263
|
+
) -> dict:
|
|
264
|
+
"""
|
|
265
|
+
Get detailed status of a specific Claude Code session.
|
|
266
|
+
|
|
267
|
+
Returns comprehensive information including session metadata,
|
|
268
|
+
conversation statistics, and processing state. Use the /screen
|
|
269
|
+
resource to get terminal screen content.
|
|
270
|
+
|
|
271
|
+
Args:
|
|
272
|
+
session_id: ID of the target session
|
|
273
|
+
"""
|
|
274
|
+
app_ctx = ctx.request_context.lifespan_context
|
|
275
|
+
registry = app_ctx.registry
|
|
276
|
+
|
|
277
|
+
session = registry.get(session_id)
|
|
278
|
+
if not session:
|
|
279
|
+
return error_response(
|
|
280
|
+
f"Session not found: {session_id}",
|
|
281
|
+
hint=HINTS["session_not_found"],
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
result = session.to_dict()
|
|
285
|
+
|
|
286
|
+
# Get conversation stats from JSONL
|
|
287
|
+
stats = session.get_conversation_stats()
|
|
288
|
+
result["conversation_stats"] = stats
|
|
289
|
+
result["message_count"] = stats["total_messages"] if stats else 0
|
|
290
|
+
|
|
291
|
+
# Check idle using stop hook detection
|
|
292
|
+
result["is_idle"] = session.is_idle()
|
|
293
|
+
|
|
294
|
+
return result
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
@mcp.resource("sessions://{session_id}/screen")
|
|
298
|
+
async def resource_session_screen(
|
|
299
|
+
session_id: str, ctx: Context[ServerSession, AppContext]
|
|
300
|
+
) -> dict:
|
|
301
|
+
"""
|
|
302
|
+
Get the current terminal screen content for a session.
|
|
303
|
+
|
|
304
|
+
Returns the visible text in the iTerm2 pane for the specified session.
|
|
305
|
+
Useful for checking what Claude is currently displaying or doing.
|
|
306
|
+
|
|
307
|
+
Args:
|
|
308
|
+
session_id: ID of the target session
|
|
309
|
+
"""
|
|
310
|
+
app_ctx = ctx.request_context.lifespan_context
|
|
311
|
+
registry = app_ctx.registry
|
|
312
|
+
|
|
313
|
+
session = registry.get(session_id)
|
|
314
|
+
if not session:
|
|
315
|
+
return error_response(
|
|
316
|
+
f"Session not found: {session_id}",
|
|
317
|
+
hint=HINTS["session_not_found"],
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
try:
|
|
321
|
+
screen_text = await read_screen_text(session.iterm_session)
|
|
322
|
+
# Get non-empty lines
|
|
323
|
+
lines = [line for line in screen_text.split("\n") if line.strip()]
|
|
324
|
+
|
|
325
|
+
return {
|
|
326
|
+
"session_id": session_id,
|
|
327
|
+
"screen_content": screen_text,
|
|
328
|
+
"screen_preview": "\n".join(lines[-15:]) if lines else "",
|
|
329
|
+
"line_count": len(lines),
|
|
330
|
+
"is_responsive": True,
|
|
331
|
+
}
|
|
332
|
+
except Exception as e:
|
|
333
|
+
return error_response(
|
|
334
|
+
f"Could not read screen: {e}",
|
|
335
|
+
hint=HINTS["iterm_connection"],
|
|
336
|
+
session_id=session_id,
|
|
337
|
+
is_responsive=False,
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
# =============================================================================
|
|
342
|
+
# Server Entry Point
|
|
343
|
+
# =============================================================================
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def run_server(transport: str = "stdio", port: int = 8766):
|
|
347
|
+
"""
|
|
348
|
+
Run the MCP server.
|
|
349
|
+
|
|
350
|
+
Args:
|
|
351
|
+
transport: Transport mode - "stdio" or "streamable-http"
|
|
352
|
+
port: Port for HTTP transport (default 8766)
|
|
353
|
+
"""
|
|
354
|
+
if transport == "streamable-http":
|
|
355
|
+
logger.info(f"Starting Claude Team MCP Server (HTTP on port {port})...")
|
|
356
|
+
# Create server with configured port for HTTP mode
|
|
357
|
+
server = create_mcp_server(host="127.0.0.1", port=port)
|
|
358
|
+
server.run(transport="streamable-http")
|
|
359
|
+
else:
|
|
360
|
+
logger.info("Starting Claude Team MCP Server (stdio)...")
|
|
361
|
+
mcp.run(transport="stdio")
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def main():
|
|
365
|
+
"""CLI entry point with argument parsing."""
|
|
366
|
+
import argparse
|
|
367
|
+
|
|
368
|
+
parser = argparse.ArgumentParser(description="Claude Team MCP Server")
|
|
369
|
+
parser.add_argument(
|
|
370
|
+
"--http",
|
|
371
|
+
action="store_true",
|
|
372
|
+
help="Run in HTTP mode (streamable-http) instead of stdio",
|
|
373
|
+
)
|
|
374
|
+
parser.add_argument(
|
|
375
|
+
"--port",
|
|
376
|
+
type=int,
|
|
377
|
+
default=8766,
|
|
378
|
+
help="Port for HTTP mode (default: 8766)",
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
args = parser.parse_args()
|
|
382
|
+
|
|
383
|
+
if args.http:
|
|
384
|
+
run_server(transport="streamable-http", port=args.port)
|
|
385
|
+
else:
|
|
386
|
+
run_server(transport="stdio")
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
if __name__ == "__main__":
|
|
390
|
+
main()
|