claude-team-mcp 0.6.1__py3-none-any.whl → 0.7.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/__init__.py +11 -0
- claude_team/events.py +477 -0
- claude_team/idle_detection.py +173 -0
- claude_team/poller.py +245 -0
- claude_team_mcp/idle_detection.py +16 -3
- claude_team_mcp/iterm_utils.py +5 -73
- claude_team_mcp/registry.py +43 -26
- claude_team_mcp/server.py +95 -61
- claude_team_mcp/session_state.py +364 -2
- claude_team_mcp/terminal_backends/__init__.py +31 -0
- claude_team_mcp/terminal_backends/base.py +106 -0
- claude_team_mcp/terminal_backends/iterm.py +251 -0
- claude_team_mcp/terminal_backends/tmux.py +646 -0
- claude_team_mcp/tools/__init__.py +4 -2
- claude_team_mcp/tools/adopt_worker.py +89 -32
- claude_team_mcp/tools/close_workers.py +39 -10
- claude_team_mcp/tools/discover_workers.py +176 -32
- claude_team_mcp/tools/list_workers.py +29 -0
- claude_team_mcp/tools/message_workers.py +35 -5
- claude_team_mcp/tools/poll_worker_changes.py +227 -0
- claude_team_mcp/tools/spawn_workers.py +221 -142
- claude_team_mcp/tools/wait_idle_workers.py +1 -0
- claude_team_mcp/utils/errors.py +7 -3
- claude_team_mcp/worktree.py +59 -12
- {claude_team_mcp-0.6.1.dist-info → claude_team_mcp-0.7.0.dist-info}/METADATA +1 -1
- claude_team_mcp-0.7.0.dist-info/RECORD +52 -0
- claude_team_mcp-0.6.1.dist-info/RECORD +0 -43
- {claude_team_mcp-0.6.1.dist-info → claude_team_mcp-0.7.0.dist-info}/WHEEL +0 -0
- {claude_team_mcp-0.6.1.dist-info → claude_team_mcp-0.7.0.dist-info}/entry_points.txt +0 -0
claude_team_mcp/server.py
CHANGED
|
@@ -1,26 +1,24 @@
|
|
|
1
1
|
"""
|
|
2
2
|
Claude Team MCP Server
|
|
3
3
|
|
|
4
|
-
FastMCP-based server for managing multiple Claude Code sessions via
|
|
4
|
+
FastMCP-based server for managing multiple Claude Code sessions via terminal backends.
|
|
5
5
|
Allows a "manager" Claude Code session to spawn and coordinate multiple
|
|
6
6
|
"worker" Claude Code sessions.
|
|
7
7
|
"""
|
|
8
8
|
|
|
9
|
+
import functools
|
|
9
10
|
import logging
|
|
10
11
|
from collections.abc import AsyncIterator
|
|
11
12
|
from contextlib import asynccontextmanager
|
|
12
13
|
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
14
|
|
|
19
15
|
from mcp.server.fastmcp import Context, FastMCP
|
|
20
16
|
from mcp.server.session import ServerSession
|
|
21
17
|
|
|
22
|
-
from .
|
|
18
|
+
from claude_team.poller import WorkerPoller
|
|
19
|
+
|
|
23
20
|
from .registry import SessionRegistry
|
|
21
|
+
from .terminal_backends import ItermBackend, TerminalBackend, TmuxBackend, select_backend_id
|
|
24
22
|
from .tools import register_all_tools
|
|
25
23
|
from .utils import error_response, HINTS
|
|
26
24
|
|
|
@@ -44,6 +42,7 @@ logger.info("=== Claude Team MCP Server Starting ===")
|
|
|
44
42
|
# =============================================================================
|
|
45
43
|
|
|
46
44
|
_global_registry: SessionRegistry | None = None
|
|
45
|
+
_global_poller: WorkerPoller | None = None
|
|
47
46
|
|
|
48
47
|
|
|
49
48
|
def get_global_registry() -> SessionRegistry:
|
|
@@ -55,6 +54,15 @@ def get_global_registry() -> SessionRegistry:
|
|
|
55
54
|
return _global_registry
|
|
56
55
|
|
|
57
56
|
|
|
57
|
+
def get_global_poller(registry: SessionRegistry) -> WorkerPoller:
|
|
58
|
+
"""Get or create the global singleton poller."""
|
|
59
|
+
global _global_poller
|
|
60
|
+
if _global_poller is None:
|
|
61
|
+
_global_poller = WorkerPoller(registry)
|
|
62
|
+
logger.info("Created global singleton poller")
|
|
63
|
+
return _global_poller
|
|
64
|
+
|
|
65
|
+
|
|
58
66
|
# =============================================================================
|
|
59
67
|
# Application Context
|
|
60
68
|
# =============================================================================
|
|
@@ -65,12 +73,11 @@ class AppContext:
|
|
|
65
73
|
"""
|
|
66
74
|
Application context shared across all tool invocations.
|
|
67
75
|
|
|
68
|
-
Maintains the
|
|
76
|
+
Maintains the terminal backend and registry of managed sessions.
|
|
69
77
|
This is the persistent state that makes the MCP server useful.
|
|
70
78
|
"""
|
|
71
79
|
|
|
72
|
-
|
|
73
|
-
iterm_app: "ItermApp"
|
|
80
|
+
terminal_backend: TerminalBackend
|
|
74
81
|
registry: SessionRegistry
|
|
75
82
|
|
|
76
83
|
|
|
@@ -79,16 +86,16 @@ class AppContext:
|
|
|
79
86
|
# =============================================================================
|
|
80
87
|
|
|
81
88
|
|
|
82
|
-
async def refresh_iterm_connection() ->
|
|
89
|
+
async def refresh_iterm_connection() -> ItermBackend:
|
|
83
90
|
"""
|
|
84
|
-
Create a fresh iTerm2 connection.
|
|
91
|
+
Create a fresh iTerm2 connection and backend.
|
|
85
92
|
|
|
86
93
|
The iTerm2 Python API uses websockets with ping_interval=None, meaning
|
|
87
94
|
connections can go stale without any keepalive mechanism. This function
|
|
88
95
|
creates a new connection when needed.
|
|
89
96
|
|
|
90
97
|
Returns:
|
|
91
|
-
|
|
98
|
+
ItermBackend with a fresh connection and app
|
|
92
99
|
|
|
93
100
|
Raises:
|
|
94
101
|
RuntimeError: If connection fails
|
|
@@ -103,98 +110,121 @@ async def refresh_iterm_connection() -> tuple["ItermConnection", "ItermApp"]:
|
|
|
103
110
|
if app is None:
|
|
104
111
|
raise RuntimeError("Could not get iTerm2 app")
|
|
105
112
|
logger.debug("Fresh iTerm2 connection established")
|
|
106
|
-
return connection, app
|
|
113
|
+
return ItermBackend(connection, app)
|
|
107
114
|
except Exception as e:
|
|
108
115
|
logger.error(f"Failed to refresh iTerm2 connection: {e}")
|
|
109
116
|
raise RuntimeError("Could not connect to iTerm2") from e
|
|
110
117
|
|
|
111
118
|
|
|
112
|
-
async def ensure_connection(app_ctx: "AppContext") ->
|
|
119
|
+
async def ensure_connection(app_ctx: "AppContext") -> TerminalBackend:
|
|
113
120
|
"""
|
|
114
|
-
Ensure we have a working
|
|
121
|
+
Ensure we have a working terminal backend, refreshing if stale.
|
|
115
122
|
|
|
116
123
|
The iTerm2 websocket connection can go stale due to lack of keepalive
|
|
117
124
|
(ping_interval=None in the iterm2 library). This function tests the
|
|
118
125
|
connection and refreshes it if needed.
|
|
119
126
|
|
|
127
|
+
For non-iTerm backends (e.g., tmux), this simply returns the backend.
|
|
128
|
+
|
|
120
129
|
Args:
|
|
121
|
-
app_ctx: The application context containing
|
|
130
|
+
app_ctx: The application context containing the backend
|
|
122
131
|
|
|
123
132
|
Returns:
|
|
124
|
-
|
|
133
|
+
TerminalBackend - either existing or refreshed
|
|
125
134
|
"""
|
|
135
|
+
backend = app_ctx.terminal_backend
|
|
136
|
+
if not isinstance(backend, ItermBackend):
|
|
137
|
+
return backend
|
|
138
|
+
|
|
126
139
|
from iterm2.app import async_get_app
|
|
127
140
|
|
|
128
|
-
connection =
|
|
129
|
-
app =
|
|
141
|
+
connection = backend.connection
|
|
142
|
+
app = backend.app
|
|
130
143
|
|
|
131
144
|
# Test if connection is still alive by trying a simple operation
|
|
132
145
|
try:
|
|
133
146
|
# async_get_app is a lightweight call that tests the connection
|
|
134
147
|
refreshed_app = await async_get_app(connection)
|
|
135
148
|
if refreshed_app is not None:
|
|
136
|
-
|
|
149
|
+
if refreshed_app is not app:
|
|
150
|
+
backend = ItermBackend(connection, refreshed_app)
|
|
151
|
+
app_ctx.terminal_backend = backend
|
|
152
|
+
return backend
|
|
137
153
|
# App is None, need to refresh
|
|
138
154
|
raise RuntimeError("App is None, refreshing connection")
|
|
139
155
|
except Exception as e:
|
|
140
156
|
logger.warning(f"iTerm2 connection appears stale ({e}), refreshing...")
|
|
141
157
|
# Connection is dead, create a new one
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
app_ctx.iterm_app = app
|
|
146
|
-
return connection, app
|
|
158
|
+
backend = await refresh_iterm_connection()
|
|
159
|
+
app_ctx.terminal_backend = backend
|
|
160
|
+
return backend
|
|
147
161
|
|
|
148
162
|
|
|
149
163
|
@asynccontextmanager
|
|
150
|
-
async def app_lifespan(
|
|
164
|
+
async def app_lifespan(
|
|
165
|
+
server: FastMCP,
|
|
166
|
+
enable_poller: bool = False,
|
|
167
|
+
) -> AsyncIterator[AppContext]:
|
|
151
168
|
"""
|
|
152
|
-
Manage
|
|
169
|
+
Manage terminal backend connection lifecycle.
|
|
153
170
|
|
|
154
|
-
Connects to
|
|
171
|
+
Connects to the terminal backend on startup and maintains the connection
|
|
155
172
|
for the duration of the server's lifetime.
|
|
156
173
|
|
|
157
174
|
Note: The iTerm2 Python API uses websockets with ping_interval=None,
|
|
158
175
|
meaning connections can go stale. Individual tool functions should use
|
|
159
|
-
ensure_connection() before making
|
|
176
|
+
ensure_connection() before making terminal backend calls that use the
|
|
160
177
|
connection directly.
|
|
161
178
|
"""
|
|
162
179
|
logger.info("Claude Team MCP Server starting...")
|
|
163
180
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
logger.info("
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
181
|
+
backend_id = select_backend_id()
|
|
182
|
+
logger.info("Selecting terminal backend: %s", backend_id)
|
|
183
|
+
|
|
184
|
+
if backend_id == "tmux":
|
|
185
|
+
backend: TerminalBackend = TmuxBackend()
|
|
186
|
+
elif backend_id == "iterm":
|
|
187
|
+
# Import iterm2 here to fail fast if not available
|
|
188
|
+
try:
|
|
189
|
+
from iterm2.app import async_get_app
|
|
190
|
+
from iterm2.connection import Connection
|
|
191
|
+
except ImportError as e:
|
|
192
|
+
logger.error(
|
|
193
|
+
"iterm2 package not found. Install with: uv add iterm2\n"
|
|
194
|
+
"Also enable: iTerm2 → Preferences → General → Magic → Enable Python API"
|
|
195
|
+
)
|
|
196
|
+
raise RuntimeError("iterm2 package required") from e
|
|
197
|
+
|
|
198
|
+
# Connect to iTerm2
|
|
199
|
+
logger.info("Connecting to iTerm2...")
|
|
200
|
+
try:
|
|
201
|
+
connection = await Connection.async_create()
|
|
202
|
+
app = await async_get_app(connection)
|
|
203
|
+
if app is None:
|
|
204
|
+
raise RuntimeError("Could not get iTerm2 app")
|
|
205
|
+
logger.info("Connected to iTerm2 successfully")
|
|
206
|
+
except Exception as e:
|
|
207
|
+
logger.error(f"Failed to connect to iTerm2: {e}")
|
|
208
|
+
logger.error("Make sure iTerm2 is running and Python API is enabled")
|
|
209
|
+
raise RuntimeError("Could not connect to iTerm2") from e
|
|
210
|
+
backend = ItermBackend(connection, app)
|
|
211
|
+
else:
|
|
212
|
+
raise RuntimeError(f"Unknown terminal backend: {backend_id}")
|
|
187
213
|
|
|
188
|
-
# Create application context with singleton registry (persists across sessions)
|
|
214
|
+
# Create application context with singleton registry (persists across sessions).
|
|
189
215
|
ctx = AppContext(
|
|
190
|
-
|
|
191
|
-
iterm_app=app,
|
|
216
|
+
terminal_backend=backend,
|
|
192
217
|
registry=get_global_registry(),
|
|
193
218
|
)
|
|
219
|
+
poller: WorkerPoller | None = None
|
|
220
|
+
if enable_poller:
|
|
221
|
+
poller = get_global_poller(ctx.registry)
|
|
222
|
+
poller.start()
|
|
194
223
|
|
|
195
224
|
try:
|
|
196
225
|
yield ctx
|
|
197
226
|
finally:
|
|
227
|
+
# Keep the global poller running across per-session lifespans.
|
|
198
228
|
# Cleanup: close any remaining sessions gracefully
|
|
199
229
|
logger.info("Claude Team MCP Server shutting down...")
|
|
200
230
|
if ctx.registry.count() > 0:
|
|
@@ -207,11 +237,15 @@ async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]:
|
|
|
207
237
|
# =============================================================================
|
|
208
238
|
|
|
209
239
|
|
|
210
|
-
def create_mcp_server(
|
|
240
|
+
def create_mcp_server(
|
|
241
|
+
host: str = "127.0.0.1",
|
|
242
|
+
port: int = 8766,
|
|
243
|
+
enable_poller: bool = False,
|
|
244
|
+
) -> FastMCP:
|
|
211
245
|
"""Create and configure the FastMCP server instance."""
|
|
212
246
|
server = FastMCP(
|
|
213
247
|
"Claude Team Manager",
|
|
214
|
-
lifespan=app_lifespan,
|
|
248
|
+
lifespan=functools.partial(app_lifespan, enable_poller=enable_poller),
|
|
215
249
|
host=host,
|
|
216
250
|
port=port,
|
|
217
251
|
)
|
|
@@ -301,7 +335,7 @@ async def resource_session_screen(
|
|
|
301
335
|
"""
|
|
302
336
|
Get the current terminal screen content for a session.
|
|
303
337
|
|
|
304
|
-
Returns the visible text in the
|
|
338
|
+
Returns the visible text in the terminal pane for the specified session.
|
|
305
339
|
Useful for checking what Claude is currently displaying or doing.
|
|
306
340
|
|
|
307
341
|
Args:
|
|
@@ -318,7 +352,7 @@ async def resource_session_screen(
|
|
|
318
352
|
)
|
|
319
353
|
|
|
320
354
|
try:
|
|
321
|
-
screen_text = await read_screen_text(session.
|
|
355
|
+
screen_text = await app_ctx.terminal_backend.read_screen_text(session.terminal_session)
|
|
322
356
|
# Get non-empty lines
|
|
323
357
|
lines = [line for line in screen_text.split("\n") if line.strip()]
|
|
324
358
|
|
|
@@ -354,7 +388,7 @@ def run_server(transport: str = "stdio", port: int = 8766):
|
|
|
354
388
|
if transport == "streamable-http":
|
|
355
389
|
logger.info(f"Starting Claude Team MCP Server (HTTP on port {port})...")
|
|
356
390
|
# Create server with configured port for HTTP mode
|
|
357
|
-
server = create_mcp_server(host="127.0.0.1", port=port)
|
|
391
|
+
server = create_mcp_server(host="127.0.0.1", port=port, enable_poller=True)
|
|
358
392
|
server.run(transport="streamable-http")
|
|
359
393
|
else:
|
|
360
394
|
logger.info("Starting Claude Team MCP Server (stdio)...")
|