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.
- langchain_mcp_tools/__init__.py +4 -0
- langchain_mcp_tools/langchain_mcp_tools.py +820 -89
- langchain_mcp_tools-0.2.5.dist-info/METADATA +383 -0
- langchain_mcp_tools-0.2.5.dist-info/RECORD +8 -0
- {langchain_mcp_tools-0.2.3.dist-info → langchain_mcp_tools-0.2.5.dist-info}/WHEEL +1 -1
- langchain_mcp_tools-0.2.3.dist-info/METADATA +0 -222
- langchain_mcp_tools-0.2.3.dist-info/RECORD +0 -8
- {langchain_mcp_tools-0.2.3.dist-info → langchain_mcp_tools-0.2.5.dist-info}/licenses/LICENSE +0 -0
- {langchain_mcp_tools-0.2.3.dist-info → langchain_mcp_tools-0.2.5.dist-info}/top_level.txt +0 -0
@@ -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
|
-
|
50
|
-
|
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
|
-
|
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
|
-
|
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
|
77
|
-
#
|
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
|
-
|
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:
|
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
|
-
"""
|
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:
|
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
|
619
|
+
A Transport tuple containing receive and send streams for server communication
|
100
620
|
|
101
621
|
Raises:
|
102
|
-
|
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
|
-
|
109
|
-
|
110
|
-
|
111
|
-
#
|
112
|
-
|
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
|
-
|
115
|
-
|
116
|
-
|
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
|
-
|
120
|
-
|
121
|
-
|
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
|
-
|
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", {})
|
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", [])
|
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
|
-
|
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"
|
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
|
178
|
-
transport: Communication channels tuple
|
179
|
-
exit_stack:
|
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
|
846
|
+
List of LangChain BaseTool instances that wrap MCP server tools
|
184
847
|
|
185
848
|
Raises:
|
186
|
-
|
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
|
-
|
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}"/"{
|
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}"/"{
|
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}"/"{
|
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, #
|
1001
|
+
level=logging.INFO, # More reasonable default level
|
328
1002
|
format="\x1b[90m[%(levelname)s]\x1b[0m %(message)s"
|
329
1003
|
)
|
330
|
-
|
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:
|
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
|
-
|
343
|
-
|
344
|
-
|
345
|
-
|
346
|
-
|
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
|
350
|
-
|
351
|
-
|
352
|
-
logger:
|
353
|
-
|
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
|
-
|
360
|
-
|
1079
|
+
Raises:
|
1080
|
+
McpInitializationError: If any server fails to initialize with detailed context
|
361
1081
|
|
362
1082
|
Example:
|
363
|
-
|
364
1083
|
server_configs = {
|
365
|
-
"
|
366
|
-
"command": "
|
1084
|
+
"local-filesystem": {
|
1085
|
+
"command": "npx",
|
1086
|
+
"args": ["-y", "@modelcontextprotocol/server-filesystem", "."]
|
367
1087
|
},
|
368
|
-
"
|
369
|
-
"
|
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
|
-
|
374
|
-
|
375
|
-
|
376
|
-
|
377
|
-
|
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
|
-
#
|
1121
|
+
# Initialize all MCP servers concurrently
|
392
1122
|
for server_name, server_config in server_configs.items():
|
393
|
-
# NOTE
|
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
|
1128
|
+
transport = await connect_to_mcp_server(
|
398
1129
|
server_name,
|
399
1130
|
server_config,
|
400
1131
|
async_exit_stack,
|