langchain-mcp-tools 0.2.3__py3-none-any.whl → 0.2.5__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.
@@ -15,6 +15,7 @@ from typing import (
15
15
  TypedDict,
16
16
  )
17
17
  from urllib.parse import urlparse
18
+ import time
18
19
 
19
20
  # Third-party imports
20
21
  try:
@@ -22,12 +23,15 @@ try:
22
23
  MemoryObjectReceiveStream,
23
24
  MemoryObjectSendStream,
24
25
  )
26
+ import httpx
25
27
  from jsonschema_pydantic import jsonschema_to_pydantic # type: ignore
26
28
  from langchain_core.tools import BaseTool, ToolException
27
29
  from mcp import ClientSession
28
30
  from mcp.client.sse import sse_client
29
31
  from mcp.client.stdio import stdio_client, StdioServerParameters
32
+ from mcp.client.streamable_http import streamablehttp_client
30
33
  from mcp.client.websocket import websocket_client
34
+ from mcp.shared._httpx_utils import McpHttpClientFactory
31
35
  import mcp.types as mcp_types
32
36
  from pydantic import BaseModel
33
37
  # from pydantic_core import to_json
@@ -37,7 +41,45 @@ except ImportError as e:
37
41
  sys.exit(1)
38
42
 
39
43
 
44
+ class McpInitializationError(Exception):
45
+ """Raised when MCP server initialization fails."""
46
+
47
+ def __init__(self, message: str, server_name: str | None = None):
48
+ self.server_name = server_name
49
+ super().__init__(message)
50
+
51
+ def __str__(self) -> str:
52
+ if self.server_name:
53
+ return f'MCP server "{self.server_name}": {super().__str__()}'
54
+ return super().__str__()
55
+
56
+
40
57
  class McpServerCommandBasedConfig(TypedDict):
58
+ """Configuration for an MCP server launched via command line.
59
+
60
+ This configuration is used for local MCP servers that are started as child
61
+ processes using the stdio client. It defines the command to run, optional
62
+ arguments, environment variables, working directory, and error logging
63
+ options.
64
+
65
+ Attributes:
66
+ command: The executable command to run (e.g., "npx", "uvx", "python").
67
+ args: Optional list of command-line arguments to pass to the command.
68
+ env: Optional dictionary of environment variables to set for the
69
+ process.
70
+ cwd: Optional working directory where the command will be executed.
71
+ errlog: Optional file-like object for redirecting the server's stderr
72
+ output.
73
+
74
+ Example:
75
+ {
76
+ "command": "npx",
77
+ "args": ["-y", "@modelcontextprotocol/server-filesystem", "."],
78
+ "env": {"NODE_ENV": "production"},
79
+ "cwd": "/path/to/working/directory",
80
+ "errlog": open("server.log", "w")
81
+ }
82
+ """
41
83
  command: str
42
84
  args: NotRequired[list[str] | None]
43
85
  env: NotRequired[dict[str, str] | None]
@@ -46,21 +88,128 @@ class McpServerCommandBasedConfig(TypedDict):
46
88
 
47
89
 
48
90
  class McpServerUrlBasedConfig(TypedDict):
49
- url: str
50
- headers: NotRequired[dict[str, str] | None]
91
+ """Configuration for a remote MCP server accessed via URL.
92
+
93
+ This configuration is used for remote MCP servers that are accessed via
94
+ HTTP/HTTPS (Streamable HTTP, Server-Sent Events) or WebSocket connections.
95
+ It defines the URL to connect to and optional HTTP headers for authentication.
96
+
97
+ Note: Per MCP spec, clients should try Streamable HTTP first, then fallback
98
+ to SSE on 4xx errors for maximum compatibility.
99
+
100
+ Attributes:
101
+ url: The URL of the remote MCP server. For HTTP/HTTPS servers,
102
+ use http:// or https:// prefix. For WebSocket servers,
103
+ use ws:// or wss:// prefix.
104
+ transport: Optional transport type. Supported values:
105
+ "streamable_http" or "http" (recommended, attempted first),
106
+ "sse" (deprecated, fallback), "websocket"
107
+ type: Optional alternative field name for transport (for compatibility)
108
+ headers: Optional dictionary of HTTP headers to include in the request,
109
+ typically used for authentication (e.g., bearer tokens).
110
+ timeout: Optional timeout for HTTP requests (default: 30.0 seconds).
111
+ sse_read_timeout: Optional timeout for SSE connections (SSE only).
112
+ terminate_on_close: Optional flag to terminate on connection close.
113
+ httpx_client_factory: Optional factory for creating HTTP clients.
114
+ auth: Optional httpx authentication for requests.
115
+ __pre_validate_authentication: Optional flag to skip auth validation
116
+ (default: True). Set to False for OAuth flows that require
117
+ complex authentication flows.
118
+
119
+ Example for auto-detection (recommended):
120
+ {
121
+ "url": "https://api.example.com/mcp",
122
+ # Auto-tries Streamable HTTP first, falls back to SSE on 4xx
123
+ "headers": {"Authorization": "Bearer token123"},
124
+ "timeout": 60.0
125
+ }
51
126
 
127
+ Example for explicit Streamable HTTP:
128
+ {
129
+ "url": "https://api.example.com/mcp",
130
+ "transport": "streamable_http",
131
+ "headers": {"Authorization": "Bearer token123"},
132
+ "timeout": 60.0
133
+ }
52
134
 
53
- McpServerConfig = McpServerCommandBasedConfig | McpServerUrlBasedConfig
135
+ Example for explicit SSE (legacy):
136
+ {
137
+ "url": "https://example.com/mcp/sse",
138
+ "transport": "sse",
139
+ "headers": {"Authorization": "Bearer token123"}
140
+ }
54
141
 
55
- McpServersConfig = dict[str, McpServerConfig]
142
+ Example for WebSocket:
143
+ {
144
+ "url": "wss://example.com/mcp/ws",
145
+ "transport": "websocket"
146
+ }
147
+ """
148
+ url: str
149
+ transport: NotRequired[str] # Preferred field name
150
+ type: NotRequired[str] # Alternative field name for compatibility
151
+ headers: NotRequired[dict[str, str] | None]
152
+ timeout: NotRequired[float]
153
+ sse_read_timeout: NotRequired[float]
154
+ terminate_on_close: NotRequired[bool]
155
+ httpx_client_factory: NotRequired[McpHttpClientFactory]
156
+ auth: NotRequired[httpx.Auth]
157
+ __prevalidate_authentication: NotRequired[bool]
158
+
159
+ # Type for a single MCP server configuration, which can be either
160
+ # command-based or URL-based.
161
+ SingleMcpServerConfig = McpServerCommandBasedConfig | McpServerUrlBasedConfig
162
+ """Configuration for a single MCP server, either command-based or URL-based.
163
+
164
+ This type represents the configuration for a single MCP server, which can
165
+ be either:
166
+ 1. A local server launched via command line (McpServerCommandBasedConfig)
167
+ 2. A remote server accessed via URL (McpServerUrlBasedConfig)
168
+
169
+ The type is determined by the presence of either the "command" key
170
+ (for command-based) or the "url" key (for URL-based).
171
+ """
172
+
173
+ # Configuration dictionary for multiple MCP servers
174
+ McpServersConfig = dict[str, SingleMcpServerConfig]
175
+ """Configuration dictionary for multiple MCP servers.
176
+
177
+ A dictionary mapping server names (as strings) to their respective
178
+ configurations. Each server name acts as a logical identifier used for logging
179
+ and debugging. The configuration for each server can be either command-based
180
+ or URL-based.
181
+
182
+ Example:
183
+ {
184
+ "filesystem": {
185
+ "command": "npx",
186
+ "args": ["-y", "@modelcontextprotocol/server-filesystem", "."]
187
+ },
188
+ "fetch": {
189
+ "command": "uvx",
190
+ "args": ["mcp-server-fetch"]
191
+ },
192
+ "auto-detection-server": {
193
+ "url": "https://api.example.com/mcp",
194
+ # Will try Streamable HTTP first, fallback to SSE on 4xx
195
+ "headers": {"Authorization": "Bearer token123"},
196
+ "timeout": 60.0
197
+ },
198
+ "explicit-sse-server": {
199
+ "url": "https://legacy.example.com/mcp/sse",
200
+ "transport": "sse",
201
+ "headers": {"Authorization": "Bearer token123"}
202
+ }
203
+ }
204
+ """
56
205
 
57
206
 
58
207
  def fix_schema(schema: dict) -> dict:
59
208
  """Converts JSON Schema "type": ["string", "null"] to "anyOf" format.
60
-
209
+
61
210
  Args:
62
211
  schema: A JSON schema dictionary
63
-
212
+
64
213
  Returns:
65
214
  Modified schema with converted type formats
66
215
  """
@@ -73,67 +222,570 @@ def fix_schema(schema: dict) -> dict:
73
222
  return schema
74
223
 
75
224
 
76
- # Type alias for the bidirectional communication channels with the MCP server
77
- # FIXME: not defined in mcp.types, really?
225
+ # Type alias for bidirectional communication channels with MCP servers
226
+ # Note: This type is not officially exported by mcp.types but represents
227
+ # the standard transport interface used by all MCP client implementations
78
228
  Transport: TypeAlias = tuple[
79
229
  MemoryObjectReceiveStream[mcp_types.JSONRPCMessage | Exception],
80
230
  MemoryObjectSendStream[mcp_types.JSONRPCMessage]
81
231
  ]
82
232
 
83
233
 
84
- async def spawn_mcp_server_and_get_transport(
234
+ def is_4xx_error(error: Exception) -> bool:
235
+ """Enhanced 4xx error detection matching TypeScript implementation.
236
+
237
+ Used to decide whether to fall back from Streamable HTTP to SSE transport
238
+ per MCP specification. Handles various error types and patterns that indicate
239
+ 4xx-like conditions.
240
+
241
+ Args:
242
+ error: The error to check
243
+
244
+ Returns:
245
+ True if the error represents a 4xx HTTP status or equivalent
246
+ """
247
+ if not error:
248
+ return False
249
+
250
+ # Handle ExceptionGroup (Python 3.11+) by checking sub-exceptions
251
+ if hasattr(error, 'exceptions'):
252
+ return any(is_4xx_error(sub_error) for sub_error in error.exceptions)
253
+
254
+ # Check for explicit HTTP status codes
255
+ if hasattr(error, 'status') and isinstance(error.status, int):
256
+ return 400 <= error.status < 500
257
+
258
+ # Check for httpx response errors
259
+ if hasattr(error, 'response') and hasattr(error.response, 'status_code'):
260
+ return 400 <= error.response.status_code < 500
261
+
262
+ # Check error message for 4xx patterns
263
+ error_str = str(error).lower()
264
+
265
+ # Look for specific 4xx status codes (enhanced pattern matching)
266
+ if any(code in error_str for code in ['400', '401', '402', '403', '404', '405', '406', '407', '408', '409']):
267
+ return True
268
+
269
+ # Look for 4xx error names (expanded list matching TypeScript version)
270
+ return any(pattern in error_str for pattern in [
271
+ 'bad request',
272
+ 'unauthorized',
273
+ 'forbidden',
274
+ 'not found',
275
+ 'method not allowed',
276
+ 'not acceptable',
277
+ 'request timeout',
278
+ 'conflict'
279
+ ])
280
+
281
+
282
+ async def validate_auth_before_connection(
283
+ url_str: str,
284
+ headers: dict[str, str] | None = None,
285
+ timeout: float = 30.0,
286
+ auth: httpx.Auth | None = None,
287
+ logger: logging.Logger = logging.getLogger(__name__)
288
+ ) -> tuple[bool, str]:
289
+ """Pre-validate authentication with a simple HTTP request before creating MCP connection.
290
+
291
+ This function helps avoid async generator cleanup bugs in the MCP client library
292
+ by detecting authentication failures early, before the problematic MCP transport
293
+ creation process begins.
294
+
295
+ For OAuth authentication, this function skips validation since OAuth requires
296
+ a complex flow that cannot be pre-validated with a simple HTTP request.
297
+ Use __pre_validate_authentication=False to skip this validation.
298
+
299
+ Args:
300
+ url_str: The MCP server URL to test
301
+ headers: Optional HTTP headers (typically containing Authorization)
302
+ timeout: Request timeout in seconds
303
+ auth: Optional httpx authentication object (OAuth providers are skipped)
304
+ logger: Logger for debugging
305
+
306
+ Returns:
307
+ Tuple of (success: bool, message: str) where:
308
+ - success=True means authentication is valid or OAuth (skipped)
309
+ - success=False means authentication failed with descriptive message
310
+
311
+ Note:
312
+ This function only validates simple authentication (401, 402, 403 errors).
313
+ OAuth authentication is skipped since it requires complex flows.
314
+ """
315
+
316
+ # Skip auth validation for all httpx.Auth providers
317
+ if auth is not None:
318
+ auth_class_name = auth.__class__.__name__
319
+ logger.info(f"Skipping auth validation for httpx.Auth provider: {auth_class_name}")
320
+ return True, "httpx.Auth authentication skipped (requires full flow)"
321
+
322
+ # Create InitializeRequest as per MCP specification (similar to test_streamable_http_support)
323
+ init_request = {
324
+ "jsonrpc": "2.0",
325
+ "id": f"auth-test-{int(time.time() * 1000)}",
326
+ "method": "initialize",
327
+ "params": {
328
+ "protocolVersion": "2024-11-05",
329
+ "capabilities": {},
330
+ "clientInfo": {
331
+ "name": "mcp-auth-test",
332
+ "version": "1.0.0"
333
+ }
334
+ }
335
+ }
336
+
337
+ # Required headers per MCP specification
338
+ request_headers = {
339
+ 'Content-Type': 'application/json',
340
+ 'Accept': 'application/json, text/event-stream'
341
+ }
342
+ if headers:
343
+ request_headers.update(headers)
344
+
345
+ try:
346
+ async with httpx.AsyncClient() as client:
347
+ logger.debug(f"Pre-validating authentication for: {url_str}")
348
+ response = await client.post(
349
+ url_str,
350
+ json=init_request,
351
+ headers=request_headers,
352
+ timeout=timeout,
353
+ auth=auth
354
+ )
355
+
356
+ if response.status_code == 401:
357
+ return False, f"Authentication failed (401 Unauthorized): {response.text if hasattr(response, 'text') else 'Unknown error'}"
358
+ elif response.status_code == 402:
359
+ return False, f"Authentication failed (402 Payment Required): {response.text if hasattr(response, 'text') else 'Unknown error'}"
360
+ elif response.status_code == 403:
361
+ return False, f"Authentication failed (403 Forbidden): {response.text if hasattr(response, 'text') else 'Unknown error'}"
362
+
363
+ logger.info(f"Authentication validation passed: {response.status_code}")
364
+ return True, "Authentication validation passed"
365
+
366
+ except httpx.HTTPStatusError as e:
367
+ return False, f"HTTP Error ({e.response.status_code}): {e}"
368
+ except (httpx.ConnectError, httpx.TimeoutException) as e:
369
+ return False, f"Connection failed: {e}"
370
+ except Exception as e:
371
+ return False, f"Unexpected error during auth validation: {e}"
372
+
373
+
374
+ async def test_streamable_http_support(
375
+ url: str,
376
+ headers: dict[str, str] | None = None,
377
+ timeout: float = 30.0,
378
+ auth: httpx.Auth | None = None,
379
+ logger: logging.Logger = logging.getLogger(__name__)
380
+ ) -> bool:
381
+ """Test if URL supports Streamable HTTP per official MCP specification.
382
+
383
+ Follows the MCP specification's recommended approach for backwards compatibility.
384
+ Uses proper InitializeRequest with official protocol version and required headers.
385
+
386
+ See: https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#backwards-compatibility
387
+
388
+ Args:
389
+ url: The MCP server URL to test
390
+ headers: Optional HTTP headers
391
+ timeout: Request timeout
392
+ auth: Optional httpx authentication
393
+ logger: Logger for debugging
394
+
395
+ Returns:
396
+ True if Streamable HTTP is supported, False if should fallback to SSE
397
+
398
+ Raises:
399
+ Exception: For non-4xx errors that should be re-raised
400
+ """
401
+ # Create InitializeRequest as per MCP specification
402
+ init_request = {
403
+ "jsonrpc": "2.0",
404
+ "id": f"transport-test-{int(time.time() * 1000)}", # Use milliseconds like TS version
405
+ "method": "initialize",
406
+ "params": {
407
+ "protocolVersion": "2024-11-05", # Official MCP Protocol version
408
+ "capabilities": {},
409
+ "clientInfo": {
410
+ "name": "mcp-transport-test",
411
+ "version": "1.0.0"
412
+ }
413
+ }
414
+ }
415
+
416
+ # Required headers per MCP specification
417
+ request_headers = {
418
+ 'Content-Type': 'application/json',
419
+ 'Accept': 'application/json, text/event-stream' # Required by spec
420
+ }
421
+ if headers:
422
+ request_headers.update(headers)
423
+
424
+ try:
425
+ async with httpx.AsyncClient(follow_redirects=True) as client:
426
+ logger.debug(f"Testing Streamable HTTP: POST InitializeRequest to {url}")
427
+ response = await client.post(
428
+ url,
429
+ json=init_request,
430
+ headers=request_headers,
431
+ timeout=timeout,
432
+ auth=auth
433
+ )
434
+
435
+ logger.debug(f"Transport test response: {response.status_code} {response.headers.get('content-type', 'N/A')}")
436
+
437
+ if response.status_code == 200:
438
+ # Success indicates Streamable HTTP support
439
+ logger.debug("Streamable HTTP test successful")
440
+ return True
441
+ elif 400 <= response.status_code < 500:
442
+ # 4xx error indicates fallback to SSE per MCP spec
443
+ logger.debug(f"Received {response.status_code}, should fallback to SSE")
444
+ return False
445
+ else:
446
+ # Other errors should be re-raised
447
+ response.raise_for_status()
448
+ return True # If we get here, it succeeded
449
+
450
+ except httpx.TimeoutException:
451
+ logger.debug("Request timeout - treating as connection error")
452
+ raise
453
+ except httpx.ConnectError:
454
+ logger.debug("Connection error")
455
+ raise
456
+ except Exception as e:
457
+ # Check if it's a 4xx-like error using improved detection
458
+ if is_4xx_error(e):
459
+ logger.debug(f"4xx-like error detected: {e}")
460
+ return False
461
+ raise
462
+
463
+
464
+ def validate_mcp_server_config(
85
465
  server_name: str,
86
- server_config: McpServerConfig,
466
+ server_config: SingleMcpServerConfig,
467
+ logger: logging.Logger
468
+ ) -> None:
469
+ """Validates MCP server configuration following TypeScript transport selection logic.
470
+
471
+ Transport Selection Priority:
472
+ 1. Explicit transport/type field (must match URL protocol if URL provided)
473
+ 2. URL protocol auto-detection (http/https → StreamableHTTP, ws/wss → WebSocket)
474
+ 3. Command presence → Stdio transport
475
+ 4. Error if none of the above match
476
+
477
+ Conflicts that cause errors:
478
+ - Both url and command specified
479
+ - transport/type doesn't match URL protocol
480
+ - transport requires URL but no URL provided
481
+ - transport requires command but no command provided
482
+
483
+ Args:
484
+ server_name: Server instance name for error messages
485
+ server_config: Configuration to validate
486
+ logger: Logger for warnings
487
+
488
+ Raises:
489
+ McpInitializationError: If configuration is invalid
490
+ """
491
+ has_url = "url" in server_config and server_config["url"] is not None
492
+ has_command = "command" in server_config and server_config["command"] is not None
493
+
494
+ # Get transport type (prefer 'transport' over 'type' for compatibility)
495
+ transport_type = server_config.get("transport") or server_config.get("type")
496
+
497
+ # Conflict check: Both url and command specified
498
+ if has_url and has_command:
499
+ raise McpInitializationError(
500
+ f'Cannot specify both "url" ({server_config["url"]}) '
501
+ f'and "command" ({server_config["command"]}). Use "url" for remote servers '
502
+ f'or "command" for local servers.',
503
+ server_name=server_name
504
+ )
505
+
506
+ # Must have either URL or command
507
+ if not has_url and not has_command:
508
+ raise McpInitializationError(
509
+ 'Either "url" or "command" must be specified',
510
+ server_name=server_name
511
+ )
512
+
513
+ if has_url:
514
+ url_str = str(server_config["url"])
515
+ try:
516
+ parsed_url = urlparse(url_str)
517
+ url_scheme = parsed_url.scheme.lower()
518
+ except Exception:
519
+ raise McpInitializationError(
520
+ f'Invalid URL format: {url_str}',
521
+ server_name=server_name
522
+ )
523
+
524
+ if transport_type:
525
+ transport_lower = transport_type.lower()
526
+
527
+ # Check transport/URL protocol compatibility
528
+ if transport_lower in ["http", "streamable_http"] and url_scheme not in ["http", "https"]:
529
+ raise McpInitializationError(
530
+ f'Transport "{transport_type}" requires '
531
+ f'http:// or https:// URL, but got: {url_scheme}://',
532
+ server_name=server_name
533
+ )
534
+ elif transport_lower == "sse" and url_scheme not in ["http", "https"]:
535
+ raise McpInitializationError(
536
+ f'Transport "sse" requires '
537
+ f'http:// or https:// URL, but got: {url_scheme}://',
538
+ server_name=server_name
539
+ )
540
+ elif transport_lower in ["ws", "websocket"] and url_scheme not in ["ws", "wss"]:
541
+ raise McpInitializationError(
542
+ f'Transport "{transport_type}" requires '
543
+ f'ws:// or wss:// URL, but got: {url_scheme}://',
544
+ server_name=server_name
545
+ )
546
+ elif transport_lower == "stdio":
547
+ raise McpInitializationError(
548
+ f'Transport "stdio" requires "command", '
549
+ f'but "url" was provided',
550
+ server_name=server_name
551
+ )
552
+
553
+ # Validate URL scheme is supported
554
+ if url_scheme not in ["http", "https", "ws", "wss"]:
555
+ raise McpInitializationError(
556
+ f'Unsupported URL scheme "{url_scheme}". '
557
+ f'Supported schemes: http, https, ws, wss',
558
+ server_name=server_name
559
+ )
560
+
561
+ elif has_command:
562
+ if transport_type:
563
+ transport_lower = transport_type.lower()
564
+
565
+ # Check transport requires command
566
+ if transport_lower == "stdio":
567
+ pass # Valid
568
+ elif transport_lower in ["http", "streamable_http", "sse", "ws", "websocket"]:
569
+ raise McpInitializationError(
570
+ f'Transport "{transport_type}" requires "url", '
571
+ f'but "command" was provided',
572
+ server_name=server_name
573
+ )
574
+ else:
575
+ logger.warning(
576
+ f'MCP server "{server_name}": Unknown transport type "{transport_type}", '
577
+ f'treating as stdio'
578
+ )
579
+
580
+
581
+ async def connect_to_mcp_server(
582
+ server_name: str,
583
+ server_config: SingleMcpServerConfig,
87
584
  exit_stack: AsyncExitStack,
88
585
  logger: logging.Logger = logging.getLogger(__name__)
89
586
  ) -> Transport:
90
- """Spawns an MCP server process and establishes communication channels.
587
+ """Establishes a connection to an MCP server with robust error handling.
588
+
589
+ Implements consistent transport selection logic and includes authentication
590
+ pre-validation to prevent async generator cleanup bugs in the MCP client library.
591
+
592
+ Transport Selection Priority:
593
+ 1. Explicit transport/type field (must match URL protocol if URL provided)
594
+ 2. URL protocol auto-detection (http/https → StreamableHTTP, ws/wss → WebSocket)
595
+ 3. Command presence → Stdio transport
596
+ 4. Error if none of the above match
597
+
598
+ For HTTP URLs without explicit transport, follows MCP specification backwards
599
+ compatibility: try Streamable HTTP first, fallback to SSE on 4xx errors.
600
+
601
+ Authentication Pre-validation:
602
+ For HTTP/HTTPS servers, authentication is pre-validated before attempting
603
+ the actual MCP connection to avoid async generator cleanup issues that can
604
+ occur in the underlying MCP client library when authentication fails.
605
+
606
+ Supports multiple transport types:
607
+ - stdio: For local command-based servers
608
+ - streamable_http, http: For Streamable HTTP servers
609
+ - sse: For Server-Sent Events HTTP servers (legacy)
610
+ - websocket, ws: For WebSocket servers
91
611
 
92
612
  Args:
93
- server_name: Server instance name to use for better logging
613
+ server_name: Server instance name to use for better logging and error context
94
614
  server_config: Configuration dictionary for server setup
95
- exit_stack: Context manager for cleanup handling
615
+ exit_stack: AsyncExitStack for managing transport lifecycle and cleanup
96
616
  logger: Logger instance for debugging and monitoring
97
617
 
98
618
  Returns:
99
- A tuple of receive and send streams for server communication
619
+ A Transport tuple containing receive and send streams for server communication
100
620
 
101
621
  Raises:
102
- Exception: If server spawning fails
622
+ McpInitializationError: If configuration is invalid or server initialization fails
623
+ Exception: If unexpected errors occur during connection
103
624
  """
104
625
  try:
105
626
  logger.info(f'MCP server "{server_name}": '
106
627
  f"initializing with: {server_config}")
107
628
 
108
- url_str = str(server_config.get("url")) # None becomes "None"
109
- headers = (cast(McpServerUrlBasedConfig, server_config)
110
- .get("headers", None))
111
- # no exception thrown even for a malformed URL
112
- url_scheme = urlparse(url_str).scheme
629
+ # Validate configuration first
630
+ validate_mcp_server_config(server_name, server_config, logger)
631
+
632
+ # Determine if URL-based or command-based
633
+ has_url = "url" in server_config and server_config["url"] is not None
634
+ has_command = "command" in server_config and server_config["command"] is not None
635
+
636
+ # Get transport type (prefer 'transport' over 'type')
637
+ transport_type = server_config.get("transport") or server_config.get("type")
638
+
639
+ if has_url:
640
+ # URL-based configuration
641
+ url_config = cast(McpServerUrlBasedConfig, server_config)
642
+ url_str = str(url_config["url"])
643
+ parsed_url = urlparse(url_str)
644
+ url_scheme = parsed_url.scheme.lower()
645
+
646
+ # Extract common parameters
647
+ headers = url_config.get("headers", None)
648
+ timeout = url_config.get("timeout", None)
649
+ auth = url_config.get("auth", None)
650
+
651
+ if url_scheme in ["http", "https"]:
652
+ # HTTP/HTTPS: Handle explicit transport or auto-detection
653
+ if url_config.get("__pre_validate_authentication", True):
654
+ # Pre-validate authentication to avoid MCP async generator cleanup bugs
655
+ logger.info(f'MCP server "{server_name}": Pre-validating authentication')
656
+ auth_valid, auth_message = await validate_auth_before_connection(
657
+ url_str,
658
+ headers=headers,
659
+ timeout=timeout or 30.0,
660
+ auth=auth,
661
+ logger=logger
662
+ )
113
663
 
114
- if url_scheme in ("http", "https"):
115
- transport = await exit_stack.enter_async_context(
116
- sse_client(url_str, headers=headers)
117
- )
664
+ if not auth_valid:
665
+ # logger.error(f'MCP server "{server_name}": {auth_message}')
666
+ raise McpInitializationError(auth_message, server_name=server_name)
118
667
 
119
- elif url_scheme in ("ws", "wss"):
120
- transport = await exit_stack.enter_async_context(
121
- websocket_client(url_str)
122
- )
668
+ # Now proceed with the original connection logic
669
+ if transport_type and transport_type.lower() in ["streamable_http", "http"]:
670
+ # Explicit Streamable HTTP (no fallback)
671
+ logger.info(f'MCP server "{server_name}": '
672
+ f"connecting via Streamable HTTP (explicit) to {url_str}")
673
+
674
+ kwargs = {}
675
+ if headers is not None:
676
+ kwargs["headers"] = headers
677
+ if timeout is not None:
678
+ kwargs["timeout"] = timeout
679
+ if auth is not None:
680
+ kwargs["auth"] = auth
681
+
682
+ transport = await exit_stack.enter_async_context(
683
+ streamablehttp_client(url_str, **kwargs)
684
+ )
685
+
686
+ elif transport_type and transport_type.lower() == "sse":
687
+ # Explicit SSE (no fallback)
688
+ logger.info(f'MCP server "{server_name}": '
689
+ f"connecting via SSE (explicit) to {url_str}")
690
+ logger.warning(f'MCP server "{server_name}": '
691
+ f"Using SSE transport (deprecated as of MCP 2025-03-26), consider migrating to streamable_http")
692
+
693
+ transport = await exit_stack.enter_async_context(
694
+ sse_client(url_str, headers=headers)
695
+ )
696
+
697
+ else:
698
+ # Auto-detection: URL protocol suggests HTTP transport, try Streamable HTTP first
699
+ logger.debug(f'MCP server "{server_name}": '
700
+ f"auto-detecting HTTP transport using MCP specification method")
701
+
702
+ try:
703
+ logger.info(f'MCP server "{server_name}": '
704
+ f"testing Streamable HTTP support for {url_str}")
705
+
706
+ supports_streamable = await test_streamable_http_support(
707
+ url_str,
708
+ headers=headers,
709
+ timeout=timeout,
710
+ auth=auth,
711
+ logger=logger
712
+ )
713
+
714
+ if supports_streamable:
715
+ logger.info(f'MCP server "{server_name}": '
716
+ f"detected Streamable HTTP transport support")
717
+
718
+ kwargs = {}
719
+ if headers is not None:
720
+ kwargs["headers"] = headers
721
+ if timeout is not None:
722
+ kwargs["timeout"] = timeout
723
+ if auth is not None:
724
+ kwargs["auth"] = auth
725
+
726
+ transport = await exit_stack.enter_async_context(
727
+ streamablehttp_client(url_str, **kwargs)
728
+ )
123
729
 
124
- else:
730
+ else:
731
+ logger.info(f'MCP server "{server_name}": '
732
+ f"received 4xx error, falling back to SSE transport")
733
+ logger.warning(f'MCP server "{server_name}": '
734
+ f"Using SSE transport (deprecated as of MCP 2025-03-26), server should support Streamable HTTP")
735
+
736
+ transport = await exit_stack.enter_async_context(
737
+ sse_client(url_str, headers=headers)
738
+ )
739
+
740
+ except Exception as error:
741
+ logger.error(f'MCP server "{server_name}": '
742
+ f"transport detection failed: {error}")
743
+ raise
744
+
745
+ elif url_scheme in ["ws", "wss"]:
746
+ # WebSocket transport
747
+ if transport_type and transport_type.lower() not in ["websocket", "ws"]:
748
+ logger.warning(f'MCP server "{server_name}": '
749
+ f'URL scheme "{url_scheme}" suggests WebSocket, '
750
+ f'but transport "{transport_type}" specified')
751
+
752
+ logger.info(f'MCP server "{server_name}": '
753
+ f"connecting via WebSocket to {url_str}")
754
+
755
+ transport = await exit_stack.enter_async_context(
756
+ websocket_client(url_str)
757
+ )
758
+
759
+ else:
760
+ # This should be caught by validation, but include for safety
761
+ raise McpInitializationError(
762
+ f'Unsupported URL scheme "{url_scheme}". '
763
+ f'Supported schemes: http/https (for streamable_http/sse), ws/wss (for websocket)',
764
+ server_name=server_name
765
+ )
766
+
767
+ elif has_command:
768
+ # Command-based configuration (stdio transport)
769
+ if transport_type and transport_type.lower() not in ["stdio", ""]:
770
+ logger.warning(f'MCP server "{server_name}": '
771
+ f'Command provided suggests stdio transport, '
772
+ f'but transport "{transport_type}" specified')
773
+
774
+ logger.info(f'MCP server "{server_name}": '
775
+ f"spawning local process via stdio")
776
+
125
777
  # NOTE: `uv` and `npx` seem to require PATH to be set.
126
778
  # To avoid confusion, it was decided to automatically append it
127
779
  # to the env if not explicitly set by the config.
128
780
  config = cast(McpServerCommandBasedConfig, server_config)
129
- # env = config.get("env", {}) does't work since it can yield None
781
+ # env = config.get("env", {}) doesn't work since it can yield None
130
782
  env_val = config.get("env")
131
783
  env = {} if env_val is None else dict(env_val)
132
784
  if "PATH" not in env:
133
785
  env["PATH"] = os.environ.get("PATH", "")
134
786
 
135
787
  # Use stdio client for commands
136
- # args = config.get("args", []) does't work since it can yield None
788
+ # args = config.get("args", []) doesn't work since it can yield None
137
789
  args_val = config.get("args")
138
790
  args = [] if args_val is None else list(args_val)
139
791
  server_parameters = StdioServerParameters(
@@ -143,23 +795,23 @@ async def spawn_mcp_server_and_get_transport(
143
795
  cwd=config.get("cwd", None)
144
796
  )
145
797
 
146
- # Initialize stdio client and register it with exit stack for
147
- # cleanup
148
- # NOTE: Why the key name `errlog` for `server_config` was chosen:
149
- # Unlike TypeScript SDK's `StdioServerParameters`, the Python
150
- # SDK's `StdioServerParameters` doesn't include `stderr: int`.
151
- # Instead, it calls `stdio_client()` with a separate argument
152
- # `errlog: TextIO`. I once included `stderr: int` for
153
- # compatibility with the TypeScript version, but decided to
154
- # follow the Python SDK more closely.
155
- errlog_val = (cast(McpServerCommandBasedConfig, server_config)
156
- .get("errlog"))
798
+ # Initialize stdio client and register it with exit stack for cleanup
799
+ errlog_val = config.get("errlog")
157
800
  kwargs = {"errlog": errlog_val} if errlog_val is not None else {}
158
801
  transport = await exit_stack.enter_async_context(
159
802
  stdio_client(server_parameters, **kwargs)
160
803
  )
804
+
805
+ else:
806
+ # This should be caught by validation, but include for safety
807
+ raise McpInitializationError(
808
+ 'Invalid configuration - '
809
+ 'either "url" or "command" must be specified',
810
+ server_name=server_name
811
+ )
812
+
161
813
  except Exception as e:
162
- logger.error(f"Error spawning MCP server: {str(e)}")
814
+ logger.error(f'MCP server "{server_name}": error during initialization: {str(e)}')
163
815
  raise
164
816
 
165
817
  return transport
@@ -171,22 +823,44 @@ async def get_mcp_server_tools(
171
823
  exit_stack: AsyncExitStack,
172
824
  logger: logging.Logger = logging.getLogger(__name__)
173
825
  ) -> list[BaseTool]:
174
- """Retrieves and converts MCP server tools to LangChain format.
826
+ """Retrieves and converts MCP server tools to LangChain BaseTool format.
827
+
828
+ Establishes a client session with the MCP server, lists available tools,
829
+ and wraps each tool in a LangChain-compatible adapter class. The adapter
830
+ handles async execution, error handling, and result formatting.
831
+
832
+ Tool Conversion Features:
833
+ - JSON Schema to Pydantic model conversion for argument validation
834
+ - Async-only execution (raises NotImplementedError for sync calls)
835
+ - Automatic result formatting from MCP TextContent to strings
836
+ - Error handling with ToolException for MCP tool failures
837
+ - Comprehensive logging of tool input/output and execution metrics
175
838
 
176
839
  Args:
177
- server_name: Server instance name to use for better logging
178
- transport: Communication channels tuple
179
- exit_stack: Context manager for cleanup handling
840
+ server_name: Server instance name for logging and error context
841
+ transport: Communication channels tuple (2-tuple for SSE/stdio, 3-tuple for streamable HTTP)
842
+ exit_stack: AsyncExitStack for managing session lifecycle and cleanup
180
843
  logger: Logger instance for debugging and monitoring
181
844
 
182
845
  Returns:
183
- List of LangChain tools converted from MCP tools
846
+ List of LangChain BaseTool instances that wrap MCP server tools
184
847
 
185
848
  Raises:
186
- Exception: If tool conversion fails
849
+ McpInitializationError: If transport format is unexpected or session initialization fails
850
+ Exception: If tool retrieval or conversion fails
187
851
  """
188
852
  try:
189
- read, write = transport
853
+ # Handle both 2-tuple (SSE, stdio) and 3-tuple (streamable HTTP) returns
854
+ # Third element in streamable HTTP contains session info/metadata
855
+ if len(transport) == 2:
856
+ read, write = transport
857
+ elif len(transport) == 3:
858
+ read, write, _ = transport # Third element is session info/metadata
859
+ else:
860
+ raise McpInitializationError(
861
+ f"Unexpected transport tuple length: {len(transport)}",
862
+ server_name=server_name
863
+ )
190
864
 
191
865
  # Use an intermediate `asynccontextmanager` to log the cleanup message
192
866
  @asynccontextmanager
@@ -233,19 +907,19 @@ async def get_mcp_server_tools(
233
907
 
234
908
  async def _arun(self, **kwargs: Any) -> Any:
235
909
  """Asynchronously executes the tool with given arguments.
236
-
910
+
237
911
  Logs input/output and handles errors.
238
-
912
+
239
913
  Args:
240
914
  **kwargs: Arguments to be passed to the MCP tool
241
-
915
+
242
916
  Returns:
243
917
  Formatted response from the MCP tool as a string
244
-
918
+
245
919
  Raises:
246
920
  ToolException: If the tool execution fails
247
921
  """
248
- logger.info(f'MCP tool "{server_name}"/"{tool.name}" '
922
+ logger.info(f'MCP tool "{server_name}"/"{self.name}" '
249
923
  f"received input: {kwargs}")
250
924
 
251
925
  try:
@@ -281,7 +955,7 @@ async def get_mcp_server_tools(
281
955
 
282
956
  # Log rough result size for monitoring
283
957
  size = len(result_content_text.encode())
284
- logger.info(f'MCP tool "{server_name}"/"{tool.name}" '
958
+ logger.info(f'MCP tool "{server_name}"/"{self.name}" '
285
959
  f"received result (size: {size})")
286
960
 
287
961
  # If no text content, return a clear message
@@ -295,7 +969,7 @@ async def get_mcp_server_tools(
295
969
 
296
970
  except Exception as e:
297
971
  logger.warn(
298
- f'MCP tool "{server_name}"/"{tool.name}" '
972
+ f'MCP tool "{server_name}"/"{self.name}" '
299
973
  f"caused error: {str(e)}"
300
974
  )
301
975
  if self.handle_tool_error:
@@ -319,62 +993,118 @@ async def get_mcp_server_tools(
319
993
  # A very simple pre-configured logger for fallback
320
994
  def init_logger() -> logging.Logger:
321
995
  """Creates a simple pre-configured logger.
322
-
996
+
323
997
  Returns:
324
998
  A configured Logger instance
325
999
  """
326
1000
  logging.basicConfig(
327
- level=logging.INFO, # logging.DEBUG,
1001
+ level=logging.INFO, # More reasonable default level
328
1002
  format="\x1b[90m[%(levelname)s]\x1b[0m %(message)s"
329
1003
  )
330
- return logging.getLogger()
1004
+ # Only set MCP-related loggers to DEBUG for better MCP visibility
1005
+ logger = logging.getLogger()
1006
+ logging.getLogger("langchain_mcp_tools").setLevel(logging.DEBUG)
1007
+
1008
+ # Keep HTTP libraries quieter
1009
+ for lib in ["httpx", "urllib3", "requests", "anthropic", "openai"]:
1010
+ logging.getLogger(lib).setLevel(logging.WARNING)
1011
+
1012
+ return logger
331
1013
 
332
1014
 
333
1015
  # Type hint for cleanup function
334
1016
  McpServerCleanupFn = Callable[[], Awaitable[None]]
1017
+ """Type for the async cleanup function returned by convert_mcp_to_langchain_tools.
1018
+
1019
+ This function encapsulates the cleanup of all MCP server connections managed by
1020
+ the AsyncExitStack. When called, it properly closes all transport connections,
1021
+ sessions, and resources in the correct order.
1022
+
1023
+ Important: Always call this function when you're done using the tools to prevent
1024
+ resource leaks and ensure graceful shutdown of MCP server connections.
1025
+
1026
+ Example usage:
1027
+ tools, cleanup = await convert_mcp_to_langchain_tools(server_configs)
1028
+ try:
1029
+ # Use tools with your LangChain application...
1030
+ result = await tools[0].arun(param="value")
1031
+ finally:
1032
+ # Always cleanup, even if exceptions occur
1033
+ await cleanup()
1034
+ """
335
1035
 
336
1036
 
337
1037
  async def convert_mcp_to_langchain_tools(
338
- server_configs: dict[str, McpServerConfig],
1038
+ server_configs: McpServersConfig,
339
1039
  logger: logging.Logger | None = None
340
1040
  ) -> tuple[list[BaseTool], McpServerCleanupFn]:
341
- """Initialize multiple MCP servers and convert their tools to
342
- LangChain format.
343
-
344
- This async function manages parallel initialization of multiple MCP
345
- servers, converts their tools to LangChain format, and provides a cleanup
346
- mechanism. It orchestrates the full lifecycle of multiple servers.
1041
+ """Initialize multiple MCP servers and convert their tools to LangChain format.
1042
+
1043
+ This is the main entry point for the library. It orchestrates the complete
1044
+ lifecycle of multiple MCP server connections, from initialization through
1045
+ tool conversion to cleanup. Provides robust error handling and authentication
1046
+ pre-validation to prevent common MCP client library issues.
1047
+
1048
+ Key Features:
1049
+ - Parallel initialization of multiple servers for efficiency
1050
+ - Authentication pre-validation for HTTP servers to prevent async generator bugs
1051
+ - Automatic transport selection and fallback per MCP specification
1052
+ - Comprehensive error handling with McpInitializationError
1053
+ - User-controlled cleanup via returned async function
1054
+ - Support for both local (stdio) and remote (HTTP/WebSocket) servers
1055
+
1056
+ Transport Support:
1057
+ - stdio: Local command-based servers (npx, uvx, python, etc.)
1058
+ - streamable_http: Modern HTTP servers (recommended, tried first)
1059
+ - sse: Legacy Server-Sent Events HTTP servers (fallback)
1060
+ - websocket: WebSocket servers for real-time communication
1061
+
1062
+ Error Handling:
1063
+ All configuration and connection errors are wrapped in McpInitializationError
1064
+ with server context for easy debugging. Authentication failures are detected
1065
+ early to prevent async generator cleanup issues in the MCP client library.
347
1066
 
348
1067
  Args:
349
- server_configs: Dictionary mapping server names to their
350
- configurations, where each configuration contains command, args,
351
- and env settings
352
- logger: Logger instance to use for logging events and errors.
353
- If None, uses module logger with fallback to a pre-configured
354
- logger when no root handlers exist.
1068
+ server_configs: Dictionary mapping server names to configurations.
1069
+ Each config can be either McpServerCommandBasedConfig for local
1070
+ servers or McpServerUrlBasedConfig for remote servers.
1071
+ logger: Optional logger instance. If None, creates a pre-configured
1072
+ logger with appropriate levels for MCP debugging.
355
1073
 
356
1074
  Returns:
357
1075
  A tuple containing:
1076
+ - List[BaseTool]: All tools from all servers, ready for LangChain use
1077
+ - McpServerCleanupFn: Async function to properly shutdown all connections
358
1078
 
359
- * List of converted LangChain tools from all servers
360
- * Async cleanup function to properly shutdown all server connections
1079
+ Raises:
1080
+ McpInitializationError: If any server fails to initialize with detailed context
361
1081
 
362
1082
  Example:
363
-
364
1083
  server_configs = {
365
- "fetch": {
366
- "command": "uvx", "args": ["mcp-server-fetch"]
1084
+ "local-filesystem": {
1085
+ "command": "npx",
1086
+ "args": ["-y", "@modelcontextprotocol/server-filesystem", "."]
367
1087
  },
368
- "weather": {
369
- "command": "npx", "args": ["-y","@h1deya/mcp-server-weather"]
1088
+ "remote-api": {
1089
+ "url": "https://api.example.com/mcp",
1090
+ "headers": {"Authorization": "Bearer your-token"},
1091
+ "timeout": 30.0
370
1092
  }
371
1093
  }
372
-
373
- tools, cleanup = await convert_mcp_to_langchain_tools(server_configs)
374
-
375
- # Use tools...
376
-
377
- await cleanup()
1094
+
1095
+ try:
1096
+ tools, cleanup = await convert_mcp_to_langchain_tools(server_configs)
1097
+
1098
+ # Use tools with your LangChain application
1099
+ for tool in tools:
1100
+ result = await tool.arun(**tool_args)
1101
+
1102
+ except McpInitializationError as e:
1103
+ print(f"Failed to initialize MCP server '{e.server_name}': {e}")
1104
+
1105
+ finally:
1106
+ # Always cleanup when done
1107
+ await cleanup()
378
1108
  """
379
1109
 
380
1110
  if logger is None:
@@ -388,13 +1118,14 @@ async def convert_mcp_to_langchain_tools(
388
1118
  transports: list[Transport] = []
389
1119
  async_exit_stack = AsyncExitStack()
390
1120
 
391
- # Spawn all MCP servers concurrently
1121
+ # Initialize all MCP servers concurrently
392
1122
  for server_name, server_config in server_configs.items():
393
- # NOTE: the following `await` only blocks until the server subprocess
1123
+ # NOTE for stdio MCP servers:
1124
+ # the following `await` only blocks until the server subprocess
394
1125
  # is spawned, i.e. after returning from the `await`, the spawned
395
1126
  # subprocess starts its initialization independently of (so in
396
1127
  # parallel with) the Python execution of the following lines.
397
- transport = await spawn_mcp_server_and_get_transport(
1128
+ transport = await connect_to_mcp_server(
398
1129
  server_name,
399
1130
  server_config,
400
1131
  async_exit_stack,