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_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 iTerm2.
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 .iterm_utils import read_screen_text
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 iTerm2 connection and registry of managed sessions.
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
- iterm_connection: "ItermConnection"
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() -> tuple["ItermConnection", "ItermApp"]:
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
- Tuple of (connection, app)
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") -> tuple["ItermConnection", "ItermApp"]:
119
+ async def ensure_connection(app_ctx: "AppContext") -> TerminalBackend:
113
120
  """
114
- Ensure we have a working iTerm2 connection, refreshing if stale.
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 connection and app
130
+ app_ctx: The application context containing the backend
122
131
 
123
132
  Returns:
124
- Tuple of (connection, app) - either existing or refreshed
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 = app_ctx.iterm_connection
129
- app = app_ctx.iterm_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
- return connection, refreshed_app
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
- 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
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(server: FastMCP) -> AsyncIterator[AppContext]:
164
+ async def app_lifespan(
165
+ server: FastMCP,
166
+ enable_poller: bool = False,
167
+ ) -> AsyncIterator[AppContext]:
151
168
  """
152
- Manage iTerm2 connection lifecycle.
169
+ Manage terminal backend connection lifecycle.
153
170
 
154
- Connects to iTerm2 on startup and maintains the connection
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 iTerm2 API calls that use the
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
- # 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
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
- iterm_connection=connection,
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(host: str = "127.0.0.1", port: int = 8766) -> FastMCP:
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 iTerm2 pane for the specified session.
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.iterm_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)...")