hammad-python 0.0.29__py3-none-any.whl → 0.0.31__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.
Files changed (137) hide show
  1. ham/__init__.py +10 -0
  2. {hammad_python-0.0.29.dist-info → hammad_python-0.0.31.dist-info}/METADATA +6 -32
  3. hammad_python-0.0.31.dist-info/RECORD +6 -0
  4. hammad/__init__.py +0 -84
  5. hammad/_internal.py +0 -256
  6. hammad/_main.py +0 -226
  7. hammad/cache/__init__.py +0 -40
  8. hammad/cache/base_cache.py +0 -181
  9. hammad/cache/cache.py +0 -169
  10. hammad/cache/decorators.py +0 -261
  11. hammad/cache/file_cache.py +0 -80
  12. hammad/cache/ttl_cache.py +0 -74
  13. hammad/cli/__init__.py +0 -33
  14. hammad/cli/animations.py +0 -573
  15. hammad/cli/plugins.py +0 -867
  16. hammad/cli/styles/__init__.py +0 -55
  17. hammad/cli/styles/settings.py +0 -139
  18. hammad/cli/styles/types.py +0 -358
  19. hammad/cli/styles/utils.py +0 -634
  20. hammad/data/__init__.py +0 -90
  21. hammad/data/collections/__init__.py +0 -49
  22. hammad/data/collections/collection.py +0 -326
  23. hammad/data/collections/indexes/__init__.py +0 -37
  24. hammad/data/collections/indexes/qdrant/__init__.py +0 -1
  25. hammad/data/collections/indexes/qdrant/index.py +0 -723
  26. hammad/data/collections/indexes/qdrant/settings.py +0 -94
  27. hammad/data/collections/indexes/qdrant/utils.py +0 -210
  28. hammad/data/collections/indexes/tantivy/__init__.py +0 -1
  29. hammad/data/collections/indexes/tantivy/index.py +0 -426
  30. hammad/data/collections/indexes/tantivy/settings.py +0 -40
  31. hammad/data/collections/indexes/tantivy/utils.py +0 -176
  32. hammad/data/configurations/__init__.py +0 -35
  33. hammad/data/configurations/configuration.py +0 -564
  34. hammad/data/models/__init__.py +0 -50
  35. hammad/data/models/extensions/__init__.py +0 -4
  36. hammad/data/models/extensions/pydantic/__init__.py +0 -42
  37. hammad/data/models/extensions/pydantic/converters.py +0 -759
  38. hammad/data/models/fields.py +0 -546
  39. hammad/data/models/model.py +0 -1078
  40. hammad/data/models/utils.py +0 -280
  41. hammad/data/sql/__init__.py +0 -24
  42. hammad/data/sql/database.py +0 -576
  43. hammad/data/sql/types.py +0 -127
  44. hammad/data/types/__init__.py +0 -75
  45. hammad/data/types/file.py +0 -431
  46. hammad/data/types/multimodal/__init__.py +0 -36
  47. hammad/data/types/multimodal/audio.py +0 -200
  48. hammad/data/types/multimodal/image.py +0 -182
  49. hammad/data/types/text.py +0 -1308
  50. hammad/formatting/__init__.py +0 -33
  51. hammad/formatting/json/__init__.py +0 -27
  52. hammad/formatting/json/converters.py +0 -158
  53. hammad/formatting/text/__init__.py +0 -63
  54. hammad/formatting/text/converters.py +0 -723
  55. hammad/formatting/text/markdown.py +0 -131
  56. hammad/formatting/yaml/__init__.py +0 -26
  57. hammad/formatting/yaml/converters.py +0 -5
  58. hammad/genai/__init__.py +0 -217
  59. hammad/genai/a2a/__init__.py +0 -32
  60. hammad/genai/a2a/workers.py +0 -552
  61. hammad/genai/agents/__init__.py +0 -59
  62. hammad/genai/agents/agent.py +0 -1973
  63. hammad/genai/agents/run.py +0 -1024
  64. hammad/genai/agents/types/__init__.py +0 -42
  65. hammad/genai/agents/types/agent_context.py +0 -13
  66. hammad/genai/agents/types/agent_event.py +0 -128
  67. hammad/genai/agents/types/agent_hooks.py +0 -220
  68. hammad/genai/agents/types/agent_messages.py +0 -31
  69. hammad/genai/agents/types/agent_response.py +0 -125
  70. hammad/genai/agents/types/agent_stream.py +0 -327
  71. hammad/genai/graphs/__init__.py +0 -125
  72. hammad/genai/graphs/_utils.py +0 -190
  73. hammad/genai/graphs/base.py +0 -1828
  74. hammad/genai/graphs/plugins.py +0 -316
  75. hammad/genai/graphs/types.py +0 -638
  76. hammad/genai/models/__init__.py +0 -1
  77. hammad/genai/models/embeddings/__init__.py +0 -43
  78. hammad/genai/models/embeddings/model.py +0 -226
  79. hammad/genai/models/embeddings/run.py +0 -163
  80. hammad/genai/models/embeddings/types/__init__.py +0 -37
  81. hammad/genai/models/embeddings/types/embedding_model_name.py +0 -75
  82. hammad/genai/models/embeddings/types/embedding_model_response.py +0 -76
  83. hammad/genai/models/embeddings/types/embedding_model_run_params.py +0 -66
  84. hammad/genai/models/embeddings/types/embedding_model_settings.py +0 -47
  85. hammad/genai/models/language/__init__.py +0 -57
  86. hammad/genai/models/language/model.py +0 -1098
  87. hammad/genai/models/language/run.py +0 -878
  88. hammad/genai/models/language/types/__init__.py +0 -40
  89. hammad/genai/models/language/types/language_model_instructor_mode.py +0 -47
  90. hammad/genai/models/language/types/language_model_messages.py +0 -28
  91. hammad/genai/models/language/types/language_model_name.py +0 -239
  92. hammad/genai/models/language/types/language_model_request.py +0 -127
  93. hammad/genai/models/language/types/language_model_response.py +0 -217
  94. hammad/genai/models/language/types/language_model_response_chunk.py +0 -56
  95. hammad/genai/models/language/types/language_model_settings.py +0 -89
  96. hammad/genai/models/language/types/language_model_stream.py +0 -600
  97. hammad/genai/models/language/utils/__init__.py +0 -28
  98. hammad/genai/models/language/utils/requests.py +0 -421
  99. hammad/genai/models/language/utils/structured_outputs.py +0 -135
  100. hammad/genai/models/model_provider.py +0 -4
  101. hammad/genai/models/multimodal.py +0 -47
  102. hammad/genai/models/reranking.py +0 -26
  103. hammad/genai/types/__init__.py +0 -1
  104. hammad/genai/types/base.py +0 -215
  105. hammad/genai/types/history.py +0 -290
  106. hammad/genai/types/tools.py +0 -507
  107. hammad/logging/__init__.py +0 -35
  108. hammad/logging/decorators.py +0 -834
  109. hammad/logging/logger.py +0 -1018
  110. hammad/mcp/__init__.py +0 -53
  111. hammad/mcp/client/__init__.py +0 -35
  112. hammad/mcp/client/client.py +0 -624
  113. hammad/mcp/client/client_service.py +0 -400
  114. hammad/mcp/client/settings.py +0 -178
  115. hammad/mcp/servers/__init__.py +0 -26
  116. hammad/mcp/servers/launcher.py +0 -1161
  117. hammad/runtime/__init__.py +0 -32
  118. hammad/runtime/decorators.py +0 -142
  119. hammad/runtime/run.py +0 -299
  120. hammad/service/__init__.py +0 -49
  121. hammad/service/create.py +0 -527
  122. hammad/service/decorators.py +0 -283
  123. hammad/types.py +0 -288
  124. hammad/typing/__init__.py +0 -435
  125. hammad/web/__init__.py +0 -43
  126. hammad/web/http/__init__.py +0 -1
  127. hammad/web/http/client.py +0 -944
  128. hammad/web/models.py +0 -275
  129. hammad/web/openapi/__init__.py +0 -1
  130. hammad/web/openapi/client.py +0 -740
  131. hammad/web/search/__init__.py +0 -1
  132. hammad/web/search/client.py +0 -1023
  133. hammad/web/utils.py +0 -472
  134. hammad_python-0.0.29.dist-info/RECORD +0 -135
  135. {hammad → ham}/py.typed +0 -0
  136. {hammad_python-0.0.29.dist-info → hammad_python-0.0.31.dist-info}/WHEEL +0 -0
  137. {hammad_python-0.0.29.dist-info → hammad_python-0.0.31.dist-info}/licenses/LICENSE +0 -0
@@ -1,1161 +0,0 @@
1
- """hammad.mcp.servers.launcher
2
-
3
- Contains quick launcher methods that allow running mcp servers
4
- based on a set of functions and descriptions. This process uses
5
- "subprocess" and ensures that each server is correctly terminated
6
- after the session is complete. This also allows for multiple
7
- servers to be ran in parallel within the same process, even if they
8
- are serving over HTTP ports.
9
- """
10
-
11
- import sys
12
- import socket
13
- import subprocess
14
- import inspect
15
- import signal
16
- import time
17
- import atexit
18
- import threading
19
- from dataclasses import dataclass, field
20
- from typing import Callable, List, Literal, Dict, Any, Optional, Tuple, Union
21
- from queue import Queue
22
- import os
23
-
24
- import logging
25
-
26
- logger = logging.getLogger(__name__)
27
-
28
- __all__ = [
29
- # Main launch function
30
- "launch_mcp_servers",
31
- # Server configuration classes
32
- "MCPServerStdioSettings",
33
- "MCPServerSseSettings",
34
- "MCPServerStreamableHttpSettings",
35
- "ServerSettings",
36
- # Server management
37
- "MCPServerService",
38
- "ServerInfo",
39
- "get_server_service",
40
- "shutdown_all_servers",
41
- # Utility functions
42
- "find_next_free_port",
43
- # Legacy functions (deprecated - will be removed in future)
44
- "launch_stdio_mcp_server",
45
- "launch_sse_mcp_server",
46
- "launch_streamable_http_mcp_server",
47
- "keep_servers_running",
48
- ]
49
-
50
-
51
- @dataclass
52
- class ServerInfo:
53
- """Information about a launched server."""
54
-
55
- name: str
56
- transport: str
57
- process: subprocess.Popen
58
- host: Optional[str] = None
59
- port: Optional[int] = None
60
-
61
- @property
62
- def url(self) -> Optional[str]:
63
- """Get the server URL if applicable."""
64
- if self.host and self.port:
65
- return f"http://{self.host}:{self.port}"
66
- return None
67
-
68
-
69
- @dataclass
70
- class BaseServerSettings:
71
- """Base configuration for all MCP servers."""
72
-
73
- name: str
74
- instructions: Optional[str] = None
75
- tools: List[Callable] = field(default_factory=list)
76
- dependencies: List[str] = field(default_factory=list)
77
- log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = "INFO"
78
- debug_mode: bool = False
79
- cwd: Optional[str] = None
80
-
81
-
82
- @dataclass
83
- class MCPServerStdioSettings(BaseServerSettings):
84
- """Configuration for stdio transport MCP servers."""
85
-
86
- transport: Literal["stdio"] = field(default="stdio", init=False)
87
-
88
-
89
- @dataclass
90
- class MCPServerSseSettings(BaseServerSettings):
91
- """Configuration for SSE transport MCP servers."""
92
-
93
- transport: Literal["sse"] = field(default="sse", init=False)
94
- host: str = "127.0.0.1"
95
- start_port: int = 8000
96
- mount_path: str = "/"
97
- sse_path: str = "/sse"
98
- message_path: str = "/messages/"
99
- json_response: bool = False
100
- stateless_http: bool = False
101
- warn_on_duplicate_resources: bool = True
102
- warn_on_duplicate_tools: bool = True
103
-
104
-
105
- @dataclass
106
- class MCPServerStreamableHttpSettings(BaseServerSettings):
107
- """Configuration for StreamableHTTP transport MCP servers."""
108
-
109
- transport: Literal["streamable-http"] = field(default="streamable-http", init=False)
110
- host: str = "127.0.0.1"
111
- start_port: int = 8000
112
- mount_path: str = "/"
113
- streamable_http_path: str = "/mcp"
114
- json_response: bool = False
115
- stateless_http: bool = False
116
- warn_on_duplicate_resources: bool = True
117
- warn_on_duplicate_tools: bool = True
118
-
119
-
120
- # Type alias for any server settings
121
- ServerSettings = Union[
122
- MCPServerStdioSettings, MCPServerSseSettings, MCPServerStreamableHttpSettings
123
- ]
124
-
125
-
126
- def _monitor_process_output(process: subprocess.Popen, name: str, stream_name: str):
127
- """Monitor and log process output in a separate thread."""
128
- stream = process.stdout if stream_name == "stdout" else process.stderr
129
- if not stream:
130
- return
131
-
132
- try:
133
- for line in stream:
134
- if line:
135
- # Add server name prefix to make multi-server logs clearer
136
- logger.info(f"[{name}] {line.rstrip()}")
137
- except Exception as e:
138
- logger.error(f"Error reading {stream_name} from {name}: {e}")
139
-
140
-
141
- def find_next_free_port(start_port: int = 8000, host: str = "127.0.0.1") -> int:
142
- """
143
- Finds the next free port starting from the given start_port.
144
- """
145
- port = start_port
146
- while True:
147
- try:
148
- with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
149
- s.bind((host, port))
150
- logger.debug(f"Found free port {port} on host {host}.")
151
- return port
152
- except OSError:
153
- port += 1
154
- if port > 65535:
155
- logger.error(f"No free ports found above {start_port} on host {host}.")
156
- raise IOError("No free ports found")
157
-
158
-
159
- # Global variables for signal handling
160
- _signal_handlers_registered = False
161
-
162
-
163
- def _register_signal_handlers():
164
- """Register signal handlers for graceful shutdown."""
165
- global _signal_handlers_registered
166
- if _signal_handlers_registered:
167
- return
168
-
169
- def signal_handler(signum, frame):
170
- logger.info(
171
- f"Received signal {signum}. Shutting down all MCP server services..."
172
- )
173
- global _singleton_service
174
- if _singleton_service is not None:
175
- try:
176
- _singleton_service.shutdown_all()
177
- except Exception as e:
178
- logger.error(f"Error during signal-triggered shutdown: {e}")
179
- logger.info("Signal-triggered shutdown complete.")
180
-
181
- # Register handlers for common termination signals
182
- signal.signal(signal.SIGTERM, signal_handler)
183
- signal.signal(signal.SIGINT, signal_handler)
184
-
185
- # Also register atexit handler as a fallback
186
- def atexit_handler():
187
- global _singleton_service
188
- if _singleton_service is not None:
189
- _singleton_service.shutdown_all()
190
-
191
- atexit.register(atexit_handler)
192
- _signal_handlers_registered = True
193
-
194
-
195
- @dataclass
196
- class MCPServerService:
197
- """
198
- Class that manages the lifecycles and startup/shutdown of
199
- configured MCP servers by running them as subprocesses.
200
- """
201
-
202
- active_servers: List[subprocess.Popen] = field(default_factory=list)
203
- server_info: List[ServerInfo] = field(default_factory=list)
204
- output_threads: List[threading.Thread] = field(default_factory=list)
205
- python_executable: str = sys.executable
206
- process_startup_timeout: float = (
207
- 10.0 # seconds to wait for process startup verification
208
- )
209
- _keep_running: bool = field(default=True, init=False)
210
-
211
- def __post_init__(self):
212
- """Register signal handlers when service is created."""
213
- _register_signal_handlers()
214
-
215
- def _generate_runner_script(
216
- self,
217
- name: str,
218
- instructions: str | None,
219
- tools_source_code: List[str],
220
- tool_function_names: List[str],
221
- dependencies: List[str],
222
- log_level: str,
223
- debug_mode: bool,
224
- transport: Literal["stdio", "sse", "streamable-http"],
225
- server_settings: Dict[str, Any],
226
- ) -> str:
227
- """
228
- Generates the Python script content that will be run by the subprocess.
229
- """
230
- import textwrap
231
-
232
- # Properly dedent and format tool definitions
233
- formatted_tools = []
234
- for source in tools_source_code:
235
- # Remove common leading whitespace to fix indentation
236
- dedented_source = textwrap.dedent(source)
237
- formatted_tools.append(dedented_source)
238
-
239
- tool_definitions_str = "\n\n".join(formatted_tools)
240
-
241
- tool_registrations_str = ""
242
- for func_name in tool_function_names:
243
- tool_registrations_str += f" server.add_tool({func_name})\n"
244
-
245
- # Safely represent instructions string, handling None and quotes
246
- if instructions is None:
247
- instructions_repr = "None"
248
- else:
249
- escaped_instructions = instructions.replace("'''", "'''")
250
- instructions_repr = f"'''{escaped_instructions}'''"
251
-
252
- script_lines = [
253
- "import sys",
254
- "from mcp.server.fastmcp import FastMCP",
255
- "from typing import Literal, List, Callable, Any, Dict",
256
- "import anyio", # FastMCP.run might use it implicitly or explicitly
257
- "import logging", # For basic logging within the script if needed
258
- "",
259
- "# Set up logger for tool functions",
260
- "logger = logging.getLogger(__name__)",
261
- "",
262
- "# Import dependencies for UCP tool functions",
263
- "try:",
264
- " from eval_interface.ucp.search_client import UCPSearchClient",
265
- " from eval_interface.ucp.types import SearchContentItem, KnowledgeBit",
266
- "except ImportError as e:",
267
- " logger.warning(f'Could not import UCP dependencies: {e}')",
268
- " UCPSearchClient = None",
269
- " SearchContentItem = None",
270
- " KnowledgeBit = None",
271
- "",
272
- "# --- Tool Function Definitions ---",
273
- tool_definitions_str,
274
- "# --- End Tool Function Definitions ---",
275
- "",
276
- "def main():",
277
- " # Configure basic logging for the runner script itself",
278
- " log_format = '%(asctime)s - %(name)s - %(levelname)s - %(message)s'",
279
- f" logging.basicConfig(level='{log_level.upper()}', format=log_format)",
280
- " script_logger = logging.getLogger('mcp_runner_script')",
281
- f' script_logger.info(f"MCP Runner script for {name!r} starting with transport {transport!r}")',
282
- f" server_specific_settings = {server_settings!r}",
283
- f" server = FastMCP(",
284
- f" name={name!r},",
285
- f" instructions={instructions_repr},",
286
- f" dependencies={dependencies!r},",
287
- f" log_level='{log_level.upper()}',",
288
- f" debug={debug_mode},",
289
- " **server_specific_settings",
290
- " )",
291
- "",
292
- " script_logger.info('Registering tools...')",
293
- tool_registrations_str,
294
- " script_logger.info('Tools registered.')",
295
- "",
296
- f' script_logger.info(f"Starting FastMCP server {name!r} with transport {transport!r}...")',
297
- f" server.run(transport={transport!r})", # mount_path for SSE is handled by FastMCP settings
298
- f' script_logger.info(f"FastMCP server {name!r} has shut down.")',
299
- "",
300
- "if __name__ == '__main__':",
301
- " main()",
302
- ]
303
- return "\n".join(script_lines)
304
-
305
- def _verify_process_started(self, process: subprocess.Popen, name: str) -> bool:
306
- """
307
- Verify that the process started successfully and is running.
308
-
309
- Args:
310
- process: The subprocess to verify
311
- name: Name of the server for logging
312
-
313
- Returns:
314
- True if process started successfully, False otherwise
315
- """
316
- start_time = time.time()
317
-
318
- while time.time() - start_time < self.process_startup_timeout:
319
- poll_result = process.poll()
320
-
321
- if poll_result is None:
322
- # Process is still running - this is good
323
- logger.debug(f"Server '{name}' (PID {process.pid}) is running")
324
- return True
325
- elif poll_result != 0:
326
- # Process exited with error
327
- stderr_output = ""
328
- if process.stderr:
329
- try:
330
- stderr_output = process.stderr.read()
331
- except Exception as e:
332
- logger.warning(
333
- f"Could not read stderr from failed process: {e}"
334
- )
335
- logger.error(
336
- f"Server '{name}' (PID {process.pid}) failed to start. Exit code: {poll_result}. Stderr: {stderr_output}"
337
- )
338
- return False
339
-
340
- # Give it a moment before checking again
341
- time.sleep(0.1)
342
-
343
- # Timeout reached
344
- if process.poll() is None:
345
- logger.warning(
346
- f"Server '{name}' (PID {process.pid}) startup verification timed out after {self.process_startup_timeout}s, but process is still running"
347
- )
348
- return True
349
- else:
350
- logger.error(
351
- f"Server '{name}' (PID {process.pid}) failed to start within {self.process_startup_timeout}s"
352
- )
353
- return False
354
-
355
- def launch_server_process(
356
- self,
357
- name: str,
358
- instructions: str | None,
359
- tools: List[Callable],
360
- dependencies: List[str],
361
- log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"],
362
- debug_mode: bool,
363
- transport: Literal["stdio", "sse", "streamable-http"],
364
- server_settings: Dict[str, Any], # host, port etc. for sse/http
365
- cwd: str | None = None,
366
- ) -> subprocess.Popen:
367
- """
368
- Prepares and launches an MCP server in a subprocess.
369
- """
370
- tools_source_code = []
371
- tool_function_names = []
372
- for tool_func in tools:
373
- try:
374
- # Attempt to get source. This is fragile for complex tools or non-file-based functions.
375
- source = inspect.getsource(tool_func)
376
- tools_source_code.append(source)
377
- tool_function_names.append(tool_func.__name__)
378
- except (TypeError, OSError) as e:
379
- logger.error(
380
- f"Could not get source for tool '{tool_func.__name__}': {e}. This tool will be SKIPPED."
381
- )
382
- continue
383
-
384
- script_content = self._generate_runner_script(
385
- name=name,
386
- instructions=instructions,
387
- tools_source_code=tools_source_code,
388
- tool_function_names=tool_function_names,
389
- dependencies=dependencies,
390
- log_level=log_level,
391
- debug_mode=debug_mode,
392
- transport=transport,
393
- server_settings=server_settings,
394
- )
395
- logger.debug(
396
- f"Generated runner script for server '{name}' ({transport}):\n{script_content}"
397
- )
398
-
399
- process = None
400
- try:
401
- process = subprocess.Popen(
402
- [self.python_executable, "-c", script_content],
403
- cwd=cwd,
404
- stdout=subprocess.PIPE,
405
- stderr=subprocess.PIPE,
406
- text=True, # Decode stdout/stderr as text
407
- )
408
-
409
- logger.info(f"Launched {transport} server '{name}' with PID {process.pid}.")
410
-
411
- # Verify the process started successfully before adding to active_servers
412
- if self._verify_process_started(process, name):
413
- self.active_servers.append(process)
414
-
415
- # Create server info
416
- info = ServerInfo(
417
- name=name,
418
- transport=transport,
419
- process=process,
420
- host=server_settings.get("host"),
421
- port=server_settings.get("port"),
422
- )
423
- self.server_info.append(info)
424
-
425
- # Start output monitoring threads
426
- stdout_thread = threading.Thread(
427
- target=_monitor_process_output,
428
- args=(process, name, "stdout"),
429
- daemon=True,
430
- )
431
- stderr_thread = threading.Thread(
432
- target=_monitor_process_output,
433
- args=(process, name, "stderr"),
434
- daemon=True,
435
- )
436
- stdout_thread.start()
437
- stderr_thread.start()
438
- self.output_threads.extend([stdout_thread, stderr_thread])
439
-
440
- logger.info(
441
- f"Server '{name}' (PID {process.pid}) verified as started successfully."
442
- )
443
- return process
444
- else:
445
- # Process failed to start properly, clean it up
446
- logger.error(
447
- f"Server '{name}' failed startup verification, cleaning up..."
448
- )
449
- self._cleanup_single_process(process, name, force_kill_timeout=2.0)
450
- raise RuntimeError(f"Server '{name}' failed to start properly")
451
-
452
- except Exception as e:
453
- logger.critical(f"Failed to launch subprocess for server '{name}': {e}")
454
- if process and process.poll() is None:
455
- # If we created a process but had an error, clean it up
456
- self._cleanup_single_process(process, name, force_kill_timeout=2.0)
457
- raise
458
-
459
- def _cleanup_single_process(
460
- self, process: subprocess.Popen, name: str, force_kill_timeout: float = 5.0
461
- ):
462
- """
463
- Clean up a single process with proper error handling.
464
-
465
- Args:
466
- process: The process to clean up
467
- name: Name for logging
468
- force_kill_timeout: How long to wait before force killing
469
- """
470
- if process.poll() is not None:
471
- logger.debug(f"Process '{name}' (PID {process.pid}) already terminated.")
472
- return
473
-
474
- logger.info(f"Terminating server process '{name}' (PID {process.pid})...")
475
- try:
476
- process.terminate() # Ask nicely first
477
- try:
478
- process.wait(
479
- timeout=force_kill_timeout
480
- ) # Wait for graceful termination
481
- logger.info(
482
- f"Server process '{name}' (PID {process.pid}) terminated gracefully."
483
- )
484
- except subprocess.TimeoutExpired:
485
- logger.warning(
486
- f"Server process '{name}' (PID {process.pid}) did not terminate gracefully after {force_kill_timeout}s, killing."
487
- )
488
- process.kill() # Force kill
489
- process.wait() # Ensure it's reaped
490
- logger.info(f"Server process '{name}' (PID {process.pid}) killed.")
491
- except Exception as e:
492
- logger.error(
493
- f"Error during cleanup of process '{name}' (PID {process.pid}): {e}"
494
- )
495
-
496
- def get_running_servers(self) -> List[subprocess.Popen]:
497
- """
498
- Get a list of currently running server processes.
499
-
500
- Returns:
501
- List of running Popen objects
502
- """
503
- running = []
504
- for server_process in self.active_servers:
505
- if server_process.poll() is None:
506
- running.append(server_process)
507
- return running
508
-
509
- def cleanup_dead_servers(self):
510
- """
511
- Remove dead processes from the active_servers list.
512
- """
513
- original_count = len(self.active_servers)
514
- self.active_servers = [
515
- server for server in self.active_servers if server.poll() is None
516
- ]
517
- self.server_info = [
518
- info for info in self.server_info if info.process.poll() is None
519
- ]
520
- cleaned_count = original_count - len(self.active_servers)
521
- if cleaned_count > 0:
522
- logger.info(
523
- f"Cleaned up {cleaned_count} dead server process(es) from active list."
524
- )
525
-
526
- def shutdown_all(self, force_kill_timeout: float = 5.0):
527
- """
528
- Shutdown all managed server processes.
529
-
530
- Args:
531
- force_kill_timeout: How long to wait before force killing each process
532
- """
533
- if not self.active_servers:
534
- logger.info("No active MCP server services to shut down.")
535
- return
536
-
537
- logger.info(
538
- f"Shutting down {len(self.active_servers)} MCP server service(s)..."
539
- )
540
-
541
- # Create a copy of the list to iterate over, in case of concurrent modifications
542
- servers_to_shutdown = self.active_servers.copy()
543
-
544
- for server_process in servers_to_shutdown:
545
- try:
546
- # Try to extract server name from the process args for better logging
547
- server_name = "unknown"
548
- if hasattr(server_process, "args") and len(server_process.args) > 2:
549
- # Try to extract name from the script content
550
- script_snippet = (
551
- server_process.args[2][:50]
552
- if server_process.args[2]
553
- else "unknown"
554
- )
555
- server_name = f"script:{script_snippet}..."
556
-
557
- self._cleanup_single_process(
558
- server_process, server_name, force_kill_timeout
559
- )
560
- except Exception as e:
561
- logger.error(
562
- f"Error shutting down server process (PID {server_process.pid}): {e}"
563
- )
564
-
565
- self.active_servers = []
566
- self.server_info = []
567
- self._keep_running = False
568
- logger.info("All managed MCP server services shut down.")
569
-
570
- def __enter__(self):
571
- """Context manager entry."""
572
- return self
573
-
574
- def __exit__(self, exc_type, exc_val, exc_tb):
575
- """Context manager exit with cleanup."""
576
- self.shutdown_all()
577
-
578
-
579
- # Global singleton service instance - declared after class definition
580
- _singleton_service: "MCPServerService | None" = None
581
-
582
-
583
- # ------------------------------------------------------------------------------
584
- # Singleton Server Service Management
585
- # ------------------------------------------------------------------------------
586
-
587
-
588
- def get_server_service() -> MCPServerService:
589
- """
590
- Get the singleton MCPServerService instance.
591
- Creates one if it doesn't exist.
592
-
593
- Returns:
594
- The singleton MCPServerService instance
595
- """
596
- global _singleton_service
597
- if _singleton_service is None:
598
- _singleton_service = MCPServerService()
599
- logger.debug("Created singleton MCPServerService instance")
600
- return _singleton_service
601
-
602
-
603
- def shutdown_all_servers(force_kill_timeout: float = 5.0):
604
- """
605
- Shutdown all servers managed by the singleton service.
606
-
607
- Args:
608
- force_kill_timeout: How long to wait before force killing each process
609
- """
610
- global _singleton_service
611
- if _singleton_service is not None:
612
- _singleton_service.shutdown_all(force_kill_timeout)
613
-
614
-
615
- # ------------------------------------------------------------------------------
616
- # Server Management Functions
617
- # ------------------------------------------------------------------------------
618
-
619
-
620
- def launch_mcp_servers(
621
- servers: List[ServerSettings],
622
- check_interval: float = 1.0,
623
- auto_restart: bool = False,
624
- ) -> List[subprocess.Popen]:
625
- """
626
- Launch multiple MCP servers with different configurations.
627
-
628
- This provides a unified interface for launching and managing multiple MCP servers
629
- with different transport types. It automatically provides a uvicorn-like experience
630
- with proper startup messages and graceful shutdown.
631
-
632
- Args:
633
- servers: List of server configurations (MCPServerStdioSettings, MCPServerSseSettings,
634
- or MCPServerStreamableHttpSettings)
635
- check_interval: How often to check server health (seconds)
636
- auto_restart: Whether to automatically restart failed servers
637
-
638
- Returns:
639
- List of subprocess.Popen objects for the launched servers
640
-
641
- Example:
642
- servers = [
643
- MCPServerStdioSettings(
644
- name="stdio_server",
645
- instructions="A stdio server",
646
- tools=[my_tool]
647
- ),
648
- MCPServerSseSettings(
649
- name="sse_server",
650
- instructions="An SSE server",
651
- tools=[another_tool],
652
- start_port=8080
653
- )
654
- ]
655
-
656
- launch_mcp_servers(servers)
657
- """
658
- if not servers:
659
- logger.warning("No server configurations provided.")
660
- return []
661
-
662
- service = get_server_service()
663
- processes = []
664
-
665
- # Launch all servers
666
- for config in servers:
667
- try:
668
- # Prepare server settings based on transport type
669
- if isinstance(config, MCPServerSseSettings):
670
- actual_port = find_next_free_port(config.start_port, config.host)
671
- server_settings = {
672
- "host": config.host,
673
- "port": actual_port,
674
- "mount_path": config.mount_path,
675
- "sse_path": config.sse_path,
676
- "message_path": config.message_path,
677
- "json_response": config.json_response,
678
- "stateless_http": config.stateless_http,
679
- "warn_on_duplicate_resources": config.warn_on_duplicate_resources,
680
- "warn_on_duplicate_tools": config.warn_on_duplicate_tools,
681
- }
682
- transport = "sse"
683
- elif isinstance(config, MCPServerStreamableHttpSettings):
684
- actual_port = find_next_free_port(config.start_port, config.host)
685
- server_settings = {
686
- "host": config.host,
687
- "port": actual_port,
688
- "mount_path": config.mount_path,
689
- "streamable_http_path": config.streamable_http_path,
690
- "json_response": config.json_response,
691
- "stateless_http": config.stateless_http,
692
- "warn_on_duplicate_resources": config.warn_on_duplicate_resources,
693
- "warn_on_duplicate_tools": config.warn_on_duplicate_tools,
694
- }
695
- transport = "streamable-http"
696
- else: # MCPServerStdioSettings
697
- server_settings = {}
698
- transport = "stdio"
699
-
700
- # Launch the server
701
- process = service.launch_server_process(
702
- name=config.name,
703
- instructions=config.instructions,
704
- tools=config.tools,
705
- dependencies=config.dependencies,
706
- log_level=config.log_level,
707
- debug_mode=config.debug_mode,
708
- transport=transport,
709
- server_settings=server_settings,
710
- cwd=config.cwd,
711
- )
712
- processes.append(process)
713
-
714
- except Exception as e:
715
- logger.error(f"Failed to launch server '{config.name}': {e}")
716
-
717
- # Display startup information
718
- if service.server_info:
719
- print("\n" + "=" * 60)
720
- print("MCP Server Manager")
721
- print("=" * 60)
722
-
723
- for info in service.server_info:
724
- print(f"\n✓ {info.name} ({info.transport})")
725
- print(f" PID: {info.process.pid}")
726
- if info.url:
727
- print(f" URL: {info.url}")
728
-
729
- print("\n" + "=" * 60)
730
- print("Press CTRL+C to shutdown all servers")
731
- print("=" * 60 + "\n")
732
-
733
- # Keep servers running
734
- try:
735
- while service._keep_running:
736
- # Check for dead servers
737
- dead_servers = []
738
- for info in service.server_info:
739
- if info.process.poll() is not None:
740
- dead_servers.append(info)
741
- logger.error(
742
- f"Server '{info.name}' (PID {info.process.pid}) "
743
- f"exited with code {info.process.poll()}"
744
- )
745
-
746
- # Clean up dead servers
747
- if dead_servers:
748
- service.cleanup_dead_servers()
749
-
750
- # If auto_restart is enabled, restart dead servers
751
- if auto_restart:
752
- for dead_info in dead_servers:
753
- # Find the original config
754
- for config in servers:
755
- if config.name == dead_info.name:
756
- logger.info(f"Restarting server '{config.name}'...")
757
- try:
758
- # Re-launch the server with same config
759
- launch_mcp_servers(
760
- [config],
761
- check_interval=check_interval,
762
- auto_restart=False,
763
- )
764
- except Exception as e:
765
- logger.error(
766
- f"Failed to restart server '{config.name}': {e}"
767
- )
768
- break
769
-
770
- # If all servers are dead and no auto-restart, exit
771
- if not service.server_info and not auto_restart:
772
- logger.error("All servers have exited. Shutting down.")
773
- break
774
-
775
- time.sleep(check_interval)
776
-
777
- except KeyboardInterrupt:
778
- print("\n\nShutting down servers...")
779
- finally:
780
- # Ensure cleanup happens
781
- shutdown_all_servers()
782
- print("All servers stopped.")
783
-
784
- return processes
785
-
786
-
787
- def _is_main_module() -> bool:
788
- """Check if we're running as the main module."""
789
- import __main__
790
- import sys
791
-
792
- # Check if we're in an interactive session
793
- if hasattr(__main__, "__file__"):
794
- # We have a file, check if it's being run directly
795
- try:
796
- # Get the calling frame
797
- import inspect
798
-
799
- frame = inspect.currentframe()
800
- if frame and frame.f_back and frame.f_back.f_back:
801
- caller_frame = frame.f_back.f_back
802
- return caller_frame.f_globals.get("__name__") == "__main__"
803
- except:
804
- pass
805
- return False
806
-
807
-
808
- def _auto_keep_running_if_main():
809
- """Automatically keep servers running if this is the main module."""
810
- if _is_main_module():
811
- service = get_server_service()
812
- if service.server_info:
813
- # Schedule keep_servers_running to run after a brief delay
814
- # This allows all launch calls to complete first
815
- import threading
816
-
817
- timer = threading.Timer(0.1, keep_servers_running)
818
- timer.daemon = False
819
- timer.start()
820
-
821
-
822
- def keep_servers_running(check_interval: float = 1.0) -> None:
823
- """
824
- Keep the main process running and monitor all launched servers.
825
-
826
- This provides a uvicorn-like experience where the script keeps running
827
- until interrupted with Ctrl+C. It displays startup information and
828
- monitors server health.
829
-
830
- Args:
831
- check_interval: How often to check server health (seconds)
832
- """
833
- service = get_server_service()
834
-
835
- if not service.server_info:
836
- logger.warning("No servers have been launched. Call launch_* functions first.")
837
- return
838
-
839
- # Display startup information
840
- print("\n" + "=" * 60)
841
- print("MCP Server Manager")
842
- print("=" * 60)
843
-
844
- for info in service.server_info:
845
- print(f"\n✓ {info.name} ({info.transport})")
846
- print(f" PID: {info.process.pid}")
847
- if info.url:
848
- print(f" URL: {info.url}")
849
-
850
- print("\n" + "=" * 60)
851
- print("Press CTRL+C to shutdown all servers")
852
- print("=" * 60 + "\n")
853
-
854
- try:
855
- while service._keep_running:
856
- # Check for dead servers
857
- dead_servers = []
858
- for info in service.server_info:
859
- if info.process.poll() is not None:
860
- dead_servers.append(info)
861
- logger.error(
862
- f"Server '{info.name}' (PID {info.process.pid}) "
863
- f"exited with code {info.process.poll()}"
864
- )
865
-
866
- # Clean up dead servers
867
- if dead_servers:
868
- service.cleanup_dead_servers()
869
-
870
- # If all servers are dead, exit
871
- if not service.server_info:
872
- logger.error("All servers have exited. Shutting down.")
873
- break
874
-
875
- time.sleep(check_interval)
876
-
877
- except KeyboardInterrupt:
878
- print("\n\nShutting down servers...")
879
- finally:
880
- # Ensure cleanup happens
881
- shutdown_all_servers()
882
- print("All servers stopped.")
883
-
884
-
885
- # ------------------------------------------------------------------------------
886
- # Simplified Launch Functions (No Service Manager Required)
887
- # ------------------------------------------------------------------------------
888
-
889
-
890
- def launch_stdio_mcp_server(
891
- name: str,
892
- instructions: str | None = None,
893
- tools: List[Callable] = None,
894
- dependencies: List[str] = None,
895
- log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = "INFO",
896
- debug_mode: bool = False,
897
- cwd: str | None = None,
898
- auto_keep_running: bool = True,
899
- ) -> subprocess.Popen:
900
- """
901
- Quickly launches an MCP server using FastMCP with stdio transport in a subprocess.
902
-
903
- The server is automatically managed by a singleton service instance.
904
-
905
- Args:
906
- name: Name of the MCP server.
907
- instructions: Optional instructions for the MCP server.
908
- tools: List of tool functions to register. (Caveats apply, see MCPServerService).
909
- dependencies: List of dependencies for FastMCP.
910
- log_level: Logging level for the server.
911
- debug_mode: Whether to run FastMCP in debug mode.
912
- cwd: Optional current working directory for the subprocess.
913
-
914
- Returns:
915
- The Popen object for the launched subprocess.
916
- """
917
- if tools is None:
918
- tools = []
919
- if dependencies is None:
920
- dependencies = []
921
-
922
- logger.info(f"Preparing to launch STDIN/OUT MCP Server: {name}")
923
- service = get_server_service()
924
- process = service.launch_server_process(
925
- name=name,
926
- instructions=instructions,
927
- tools=tools,
928
- dependencies=dependencies,
929
- log_level=log_level,
930
- debug_mode=debug_mode,
931
- transport="stdio",
932
- server_settings={},
933
- cwd=cwd,
934
- )
935
-
936
- # Auto-start keep_servers_running if this is the main module
937
- _auto_keep_running_if_main()
938
-
939
- return process
940
-
941
-
942
- def launch_sse_mcp_server(
943
- name: str,
944
- instructions: str | None = None,
945
- host: str = "127.0.0.1",
946
- start_port: int = 8000,
947
- mount_path: str = "/",
948
- sse_path: str = "/sse",
949
- message_path: str = "/messages/",
950
- json_response: bool = False,
951
- stateless_http: bool = False,
952
- warn_on_duplicate_resources: bool = True,
953
- warn_on_duplicate_tools: bool = True,
954
- tools: List[Callable] = None,
955
- dependencies: List[str] = None,
956
- log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = "INFO",
957
- debug_mode: bool = False,
958
- cwd: str | None = None,
959
- ) -> subprocess.Popen:
960
- """
961
- Quickly launches an MCP server using FastMCP with SSE transport in a subprocess.
962
- It will find the next available port starting from `start_port`.
963
-
964
- The server is automatically managed by a singleton service instance.
965
-
966
- Args:
967
- name: Name of the MCP server.
968
- instructions: Optional instructions for the MCP server.
969
- host: Host for the SSE server.
970
- start_port: Port number to start searching for a free port.
971
- mount_path: Mount path for the Starlette application in FastMCP.
972
- sse_path: SSE endpoint path within FastMCP settings.
973
- message_path: Message endpoint path for SSE, within FastMCP settings.
974
- warn_on_duplicate_resources: FastMCP setting.
975
- warn_on_duplicate_tools: FastMCP setting.
976
- tools: List of tool functions to register. (Caveats apply, see MCPServerService).
977
- dependencies: List of dependencies for FastMCP.
978
- log_level: Logging level for the server.
979
- debug_mode: Whether to run FastMCP in debug mode.
980
- cwd: Optional current working directory for the subprocess.
981
-
982
- Returns:
983
- The Popen object for the launched subprocess.
984
- """
985
- if tools is None:
986
- tools = []
987
- if dependencies is None:
988
- dependencies = []
989
-
990
- actual_port = find_next_free_port(start_port, host)
991
- logger.info(f"Preparing to launch SSE MCP Server: {name} on {host}:{actual_port}")
992
-
993
- server_http_settings = {
994
- "host": host,
995
- "port": actual_port,
996
- "mount_path": mount_path,
997
- "sse_path": sse_path,
998
- "message_path": message_path,
999
- "json_response": json_response,
1000
- "stateless_http": stateless_http,
1001
- "warn_on_duplicate_resources": warn_on_duplicate_resources,
1002
- "warn_on_duplicate_tools": warn_on_duplicate_tools,
1003
- }
1004
-
1005
- service = get_server_service()
1006
- process = service.launch_server_process(
1007
- name=name,
1008
- instructions=instructions,
1009
- tools=tools,
1010
- dependencies=dependencies,
1011
- log_level=log_level,
1012
- debug_mode=debug_mode,
1013
- transport="sse",
1014
- server_settings=server_http_settings,
1015
- cwd=cwd,
1016
- )
1017
-
1018
- # Auto-start keep_servers_running if this is the main module
1019
- _auto_keep_running_if_main()
1020
-
1021
- return process
1022
-
1023
-
1024
- def launch_streamable_http_mcp_server(
1025
- name: str,
1026
- instructions: str | None = None,
1027
- host: str = "127.0.0.1",
1028
- start_port: int = 8000,
1029
- mount_path: str = "/",
1030
- streamable_http_path: str = "/mcp",
1031
- json_response: bool = False,
1032
- stateless_http: bool = False,
1033
- warn_on_duplicate_resources: bool = True,
1034
- warn_on_duplicate_tools: bool = True,
1035
- tools: List[Callable] = None,
1036
- dependencies: List[str] = None,
1037
- log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = "INFO",
1038
- debug_mode: bool = False,
1039
- cwd: str | None = None,
1040
- ) -> subprocess.Popen:
1041
- """
1042
- Quickly launches an MCP server using FastMCP with StreamableHTTP transport in a subprocess.
1043
- It will find the next available port starting from `start_port`.
1044
-
1045
- The server is automatically managed by a singleton service instance.
1046
-
1047
- Args:
1048
- name: Name of the MCP server.
1049
- instructions: Optional instructions for the MCP server.
1050
- host: Host for the StreamableHTTP server.
1051
- start_port: Port number to start searching for a free port.
1052
- mount_path: Mount path for the root Starlette application in FastMCP.
1053
- The StreamableHTTP endpoint will be relative to this.
1054
- streamable_http_path: The specific path for the StreamableHTTP MCP endpoint.
1055
- json_response: FastMCP setting for StreamableHTTP to send JSON responses.
1056
- stateless_http: FastMCP setting for StreamableHTTP true stateless mode.
1057
- warn_on_duplicate_resources: FastMCP setting.
1058
- warn_on_duplicate_tools: FastMCP setting.
1059
- tools: List of tool functions to register. (Caveats apply, see MCPServerService).
1060
- dependencies: List of dependencies for FastMCP.
1061
- log_level: Logging level for the server.
1062
- debug_mode: Whether to run FastMCP in debug mode.
1063
- cwd: Optional current working directory for the subprocess.
1064
-
1065
- Returns:
1066
- The Popen object for the launched subprocess.
1067
- """
1068
- if tools is None:
1069
- tools = []
1070
- if dependencies is None:
1071
- dependencies = []
1072
-
1073
- actual_port = find_next_free_port(start_port, host)
1074
- logger.info(
1075
- f"Preparing to launch StreamableHTTP MCP Server: {name} on {host}:{actual_port}"
1076
- )
1077
-
1078
- server_http_settings = {
1079
- "host": host,
1080
- "port": actual_port,
1081
- "mount_path": mount_path,
1082
- "streamable_http_path": streamable_http_path,
1083
- "json_response": json_response,
1084
- "stateless_http": stateless_http,
1085
- "warn_on_duplicate_resources": warn_on_duplicate_resources,
1086
- "warn_on_duplicate_tools": warn_on_duplicate_tools,
1087
- }
1088
-
1089
- service = get_server_service()
1090
- process = service.launch_server_process(
1091
- name=name,
1092
- instructions=instructions,
1093
- tools=tools,
1094
- dependencies=dependencies,
1095
- log_level=log_level,
1096
- debug_mode=debug_mode,
1097
- transport="streamable-http",
1098
- server_settings=server_http_settings,
1099
- cwd=cwd,
1100
- )
1101
-
1102
- # Auto-start keep_servers_running if this is the main module
1103
- _auto_keep_running_if_main()
1104
-
1105
- return process
1106
-
1107
-
1108
- # Example Usage (optional, for testing this module directly):
1109
- if __name__ == "__main__":
1110
- # Configure logging
1111
- logging.basicConfig(
1112
- level=logging.INFO,
1113
- format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
1114
- )
1115
-
1116
- logger.info("MCP Launcher Example - New Configuration-Based API")
1117
-
1118
- # Dummy tool functions for testing
1119
- def example_tool_one(param: str) -> str:
1120
- """Example tool that echoes input."""
1121
- return f"Example tool one received: {param}"
1122
-
1123
- import math
1124
-
1125
- def example_tool_two(num: float) -> str:
1126
- """Example tool that calculates square root."""
1127
- return f"Square root of {num} is {math.sqrt(num)}"
1128
-
1129
- # Configure servers using the new settings classes
1130
- servers = [
1131
- # A stdio server
1132
- MCPServerStdioSettings(
1133
- name="MyStdioServer",
1134
- instructions="This is a test stdio server.",
1135
- tools=[example_tool_one, example_tool_two],
1136
- log_level="DEBUG",
1137
- debug_mode=True,
1138
- ),
1139
- # An SSE server
1140
- MCPServerSseSettings(
1141
- name="MySSEServer",
1142
- instructions="This is a test SSE server.",
1143
- tools=[example_tool_one],
1144
- start_port=8080,
1145
- log_level="DEBUG",
1146
- debug_mode=True,
1147
- ),
1148
- # A StreamableHTTP server
1149
- MCPServerStreamableHttpSettings(
1150
- name="MyStreamableHTTPServer",
1151
- instructions="This is a test StreamableHTTP server.",
1152
- tools=[example_tool_two],
1153
- start_port=9090,
1154
- log_level="DEBUG",
1155
- debug_mode=True,
1156
- json_response=True,
1157
- ),
1158
- ]
1159
-
1160
- # Launch all servers with unified interface
1161
- launch_mcp_servers(servers, auto_restart=True)