generated from kgod/ai-review-template
提交
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
"""LangChain MCP Adapters - Connect MCP servers with LangChain applications.
|
||||
|
||||
This package provides adapters to connect MCP (Model Context Protocol) servers
|
||||
with LangChain applications, converting MCP tools, prompts, and resources into
|
||||
LangChain-compatible formats.
|
||||
"""
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,101 @@
|
||||
"""Types for callbacks."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Protocol, runtime_checkable
|
||||
|
||||
from mcp.client.session import LoggingFnT as MCPLoggingFnT
|
||||
from mcp.shared.session import ProgressFnT as MCPProgressFnT
|
||||
from mcp.types import (
|
||||
LoggingMessageNotificationParams as MCPLoggingMessageNotificationParams,
|
||||
)
|
||||
|
||||
# Type aliases to avoid direct MCP type dependencies
|
||||
LoggingFnT = MCPLoggingFnT
|
||||
ProgressFnT = MCPProgressFnT
|
||||
LoggingMessageNotificationParams = MCPLoggingMessageNotificationParams
|
||||
|
||||
|
||||
@dataclass
|
||||
class CallbackContext:
|
||||
"""LangChain MCP client callback context."""
|
||||
|
||||
server_name: str
|
||||
tool_name: str | None = None
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class LoggingMessageCallback(Protocol):
|
||||
"""Light wrapper around the mcp.client.session.LoggingFnT.
|
||||
|
||||
Injects callback context as the last argument.
|
||||
"""
|
||||
|
||||
async def __call__(
|
||||
self,
|
||||
params: LoggingMessageNotificationParams,
|
||||
context: CallbackContext,
|
||||
) -> None:
|
||||
"""Execute callback on logging message notification."""
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class ProgressCallback(Protocol):
|
||||
"""Light wrapper around the mcp.shared.session.ProgressFnT.
|
||||
|
||||
Injects callback context as the last argument.
|
||||
"""
|
||||
|
||||
async def __call__(
|
||||
self,
|
||||
progress: float,
|
||||
total: float | None,
|
||||
message: str | None,
|
||||
context: CallbackContext,
|
||||
) -> None:
|
||||
"""Execute callback on progress notification."""
|
||||
...
|
||||
|
||||
|
||||
@dataclass
|
||||
class _MCPCallbacks:
|
||||
"""Callbacks compatible with the MCP SDK. For internal use only."""
|
||||
|
||||
logging_callback: LoggingFnT | None = None
|
||||
progress_callback: ProgressFnT | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class Callbacks:
|
||||
"""Callbacks for the LangChain MCP client."""
|
||||
|
||||
on_logging_message: LoggingMessageCallback | None = None
|
||||
on_progress: ProgressCallback | None = None
|
||||
|
||||
def to_mcp_format(self, *, context: CallbackContext) -> _MCPCallbacks:
|
||||
"""Convert the LangChain MCP client callbacks to MCP SDK callbacks.
|
||||
|
||||
Injects the LangChain CallbackContext as the last argument.
|
||||
"""
|
||||
if (on_logging_message := self.on_logging_message) is not None:
|
||||
|
||||
async def mcp_logging_callback(
|
||||
params: LoggingMessageNotificationParams,
|
||||
) -> None:
|
||||
await on_logging_message(params, context)
|
||||
else:
|
||||
mcp_logging_callback = None
|
||||
|
||||
if (on_progress := self.on_progress) is not None:
|
||||
|
||||
async def mcp_progress_callback(
|
||||
progress: float, total: float | None, message: str | None
|
||||
) -> None:
|
||||
await on_progress(progress, total, message, context)
|
||||
else:
|
||||
mcp_progress_callback = None
|
||||
|
||||
return _MCPCallbacks(
|
||||
logging_callback=mcp_logging_callback,
|
||||
progress_callback=mcp_progress_callback,
|
||||
)
|
||||
@@ -0,0 +1,260 @@
|
||||
"""Client for connecting to multiple MCP servers and loading LangChain tools/resources.
|
||||
|
||||
This module provides the `MultiServerMCPClient` class for managing connections to multiple
|
||||
MCP servers and loading tools, prompts, and resources from them.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager
|
||||
from types import TracebackType
|
||||
from typing import Any
|
||||
|
||||
from langchain_core.documents.base import Blob
|
||||
from langchain_core.messages import AIMessage, HumanMessage
|
||||
from langchain_core.tools import BaseTool
|
||||
from mcp import ClientSession
|
||||
|
||||
from langchain_mcp_adapters.callbacks import CallbackContext, Callbacks
|
||||
from langchain_mcp_adapters.interceptors import ToolCallInterceptor
|
||||
from langchain_mcp_adapters.prompts import load_mcp_prompt
|
||||
from langchain_mcp_adapters.resources import load_mcp_resources
|
||||
from langchain_mcp_adapters.sessions import (
|
||||
Connection,
|
||||
McpHttpClientFactory,
|
||||
SSEConnection,
|
||||
StdioConnection,
|
||||
StreamableHttpConnection,
|
||||
WebsocketConnection,
|
||||
create_session,
|
||||
)
|
||||
from langchain_mcp_adapters.tools import load_mcp_tools
|
||||
|
||||
ASYNC_CONTEXT_MANAGER_ERROR = (
|
||||
"As of langchain-mcp-adapters 0.1.0, MultiServerMCPClient cannot be used as a "
|
||||
"context manager (e.g., async with MultiServerMCPClient(...)). "
|
||||
"Instead, you can do one of the following:\n"
|
||||
"1. client = MultiServerMCPClient(...)\n"
|
||||
" tools = await client.get_tools()\n"
|
||||
"2. client = MultiServerMCPClient(...)\n"
|
||||
" async with client.session(server_name) as session:\n"
|
||||
" tools = await load_mcp_tools(session)"
|
||||
)
|
||||
|
||||
|
||||
class MultiServerMCPClient:
|
||||
"""Client for connecting to multiple MCP servers.
|
||||
|
||||
Loads LangChain-compatible tools, prompts and resources from MCP servers.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
connections: dict[str, Connection] | None = None,
|
||||
*,
|
||||
callbacks: Callbacks | None = None,
|
||||
tool_interceptors: list[ToolCallInterceptor] | None = None,
|
||||
) -> None:
|
||||
"""Initialize a `MultiServerMCPClient` with MCP servers connections.
|
||||
|
||||
Args:
|
||||
connections: A `dict` mapping server names to connection configurations. If
|
||||
`None`, no initial connections are established.
|
||||
callbacks: Optional callbacks for handling notifications and events.
|
||||
tool_interceptors: Optional list of tool call interceptors for modifying
|
||||
requests and responses.
|
||||
|
||||
!!! example "Basic usage (starting a new session on each tool call)"
|
||||
|
||||
```python
|
||||
from langchain_mcp_adapters.client import MultiServerMCPClient
|
||||
|
||||
client = MultiServerMCPClient(
|
||||
{
|
||||
"math": {
|
||||
"command": "python",
|
||||
# Make sure to update to the full absolute path to your
|
||||
# math_server.py file
|
||||
"args": ["/path/to/math_server.py"],
|
||||
"transport": "stdio",
|
||||
},
|
||||
"weather": {
|
||||
# Make sure you start your weather server on port 8000
|
||||
"url": "http://localhost:8000/mcp",
|
||||
"transport": "streamable_http",
|
||||
}
|
||||
}
|
||||
)
|
||||
all_tools = await client.get_tools()
|
||||
```
|
||||
|
||||
!!! example "Explicitly starting a session"
|
||||
|
||||
```python
|
||||
from langchain_mcp_adapters.client import MultiServerMCPClient
|
||||
from langchain_mcp_adapters.tools import load_mcp_tools
|
||||
|
||||
client = MultiServerMCPClient({...})
|
||||
async with client.session("math") as session:
|
||||
tools = await load_mcp_tools(session)
|
||||
```
|
||||
"""
|
||||
self.connections: dict[str, Connection] = (
|
||||
connections if connections is not None else {}
|
||||
)
|
||||
self.callbacks = callbacks or Callbacks()
|
||||
self.tool_interceptors = tool_interceptors or []
|
||||
|
||||
@asynccontextmanager
|
||||
async def session(
|
||||
self,
|
||||
server_name: str,
|
||||
*,
|
||||
auto_initialize: bool = True,
|
||||
) -> AsyncIterator[ClientSession]:
|
||||
"""Connect to an MCP server and initialize a session.
|
||||
|
||||
Args:
|
||||
server_name: Name to identify this server connection
|
||||
auto_initialize: Whether to automatically initialize the session
|
||||
|
||||
Raises:
|
||||
ValueError: If the server name is not found in the connections
|
||||
|
||||
Yields:
|
||||
An initialized `ClientSession`
|
||||
|
||||
"""
|
||||
if server_name not in self.connections:
|
||||
msg = (
|
||||
f"Couldn't find a server with name '{server_name}', "
|
||||
f"expected one of '{list(self.connections.keys())}'"
|
||||
)
|
||||
raise ValueError(msg)
|
||||
|
||||
mcp_callbacks = self.callbacks.to_mcp_format(
|
||||
context=CallbackContext(server_name=server_name)
|
||||
)
|
||||
|
||||
async with create_session(
|
||||
self.connections[server_name], mcp_callbacks=mcp_callbacks
|
||||
) as session:
|
||||
if auto_initialize:
|
||||
await session.initialize()
|
||||
yield session
|
||||
|
||||
async def get_tools(self, *, server_name: str | None = None) -> list[BaseTool]:
|
||||
"""Get a list of all tools from all connected servers.
|
||||
|
||||
Args:
|
||||
server_name: Optional name of the server to get tools from.
|
||||
If `None`, all tools from all servers will be returned.
|
||||
|
||||
!!! note
|
||||
|
||||
A new session will be created for each tool call
|
||||
|
||||
Returns:
|
||||
A list of LangChain [tools](https://docs.langchain.com/oss/python/langchain/tools)
|
||||
|
||||
"""
|
||||
if server_name is not None:
|
||||
if server_name not in self.connections:
|
||||
msg = (
|
||||
f"Couldn't find a server with name '{server_name}', "
|
||||
f"expected one of '{list(self.connections.keys())}'"
|
||||
)
|
||||
raise ValueError(msg)
|
||||
return await load_mcp_tools(
|
||||
None,
|
||||
connection=self.connections[server_name],
|
||||
callbacks=self.callbacks,
|
||||
server_name=server_name,
|
||||
tool_interceptors=self.tool_interceptors,
|
||||
)
|
||||
|
||||
all_tools: list[BaseTool] = []
|
||||
load_mcp_tool_tasks = []
|
||||
for name, connection in self.connections.items():
|
||||
load_mcp_tool_task = asyncio.create_task(
|
||||
load_mcp_tools(
|
||||
None,
|
||||
connection=connection,
|
||||
callbacks=self.callbacks,
|
||||
server_name=name,
|
||||
tool_interceptors=self.tool_interceptors,
|
||||
)
|
||||
)
|
||||
load_mcp_tool_tasks.append(load_mcp_tool_task)
|
||||
tools_list = await asyncio.gather(*load_mcp_tool_tasks)
|
||||
for tools in tools_list:
|
||||
all_tools.extend(tools)
|
||||
return all_tools
|
||||
|
||||
async def get_prompt(
|
||||
self,
|
||||
server_name: str,
|
||||
prompt_name: str,
|
||||
*,
|
||||
arguments: dict[str, Any] | None = None,
|
||||
) -> list[HumanMessage | AIMessage]:
|
||||
"""Get a prompt from a given MCP server."""
|
||||
async with self.session(server_name) as session:
|
||||
return await load_mcp_prompt(session, prompt_name, arguments=arguments)
|
||||
|
||||
async def get_resources(
|
||||
self,
|
||||
server_name: str,
|
||||
*,
|
||||
uris: str | list[str] | None = None,
|
||||
) -> list[Blob]:
|
||||
"""Get resources from a given MCP server.
|
||||
|
||||
Args:
|
||||
server_name: Name of the server to get resources from
|
||||
uris: Optional resource URI or list of URIs to load. If not provided,
|
||||
all resources will be loaded.
|
||||
|
||||
Returns:
|
||||
A list of LangChain [Blob][langchain_core.documents.base.Blob] objects.
|
||||
|
||||
"""
|
||||
async with self.session(server_name) as session:
|
||||
return await load_mcp_resources(session, uris=uris)
|
||||
|
||||
async def __aenter__(self) -> "MultiServerMCPClient":
|
||||
"""Async context manager entry point.
|
||||
|
||||
Raises:
|
||||
NotImplementedError: Context manager support has been removed.
|
||||
"""
|
||||
raise NotImplementedError(ASYNC_CONTEXT_MANAGER_ERROR)
|
||||
|
||||
def __aexit__(
|
||||
self,
|
||||
exc_type: type[BaseException] | None,
|
||||
exc_val: BaseException | None,
|
||||
exc_tb: TracebackType | None,
|
||||
) -> None:
|
||||
"""Async context manager exit point.
|
||||
|
||||
Args:
|
||||
exc_type: Exception type if an exception occurred.
|
||||
exc_val: Exception value if an exception occurred.
|
||||
exc_tb: Exception traceback if an exception occurred.
|
||||
|
||||
Raises:
|
||||
NotImplementedError: Context manager support has been removed.
|
||||
"""
|
||||
raise NotImplementedError(ASYNC_CONTEXT_MANAGER_ERROR)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"Callbacks",
|
||||
"McpHttpClientFactory",
|
||||
"MultiServerMCPClient",
|
||||
"SSEConnection",
|
||||
"StdioConnection",
|
||||
"StreamableHttpConnection",
|
||||
"WebsocketConnection",
|
||||
]
|
||||
@@ -0,0 +1,141 @@
|
||||
"""Interceptor interfaces and types for MCP client tool call lifecycle management.
|
||||
|
||||
This module provides an interceptor interface for wrapping and controlling
|
||||
MCP tool call execution with a handler callback pattern.
|
||||
|
||||
In the future, we might add more interceptors for other parts of the
|
||||
request / result lifecycle, for example to support elicitation.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, replace
|
||||
from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable
|
||||
|
||||
from langchain_core.messages import ToolMessage
|
||||
from mcp.types import CallToolResult
|
||||
from typing_extensions import NotRequired, TypedDict, Unpack
|
||||
|
||||
try:
|
||||
# langgraph installed
|
||||
import langgraph
|
||||
|
||||
LANGGRAPH_PRESENT = True
|
||||
except ImportError:
|
||||
LANGGRAPH_PRESENT = False
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Awaitable, Callable
|
||||
|
||||
if LANGGRAPH_PRESENT:
|
||||
from langgraph.types import Command
|
||||
|
||||
MCPToolCallResult = CallToolResult | ToolMessage | Command
|
||||
else:
|
||||
MCPToolCallResult = CallToolResult | ToolMessage
|
||||
|
||||
|
||||
class _MCPToolCallRequestOverrides(TypedDict, total=False):
|
||||
"""Possible overrides for MCPToolCallRequest.override() method.
|
||||
|
||||
Only includes modifiable request fields, not context fields like
|
||||
server_name and runtime which are read-only.
|
||||
"""
|
||||
|
||||
name: NotRequired[str]
|
||||
args: NotRequired[dict[str, Any]]
|
||||
headers: NotRequired[dict[str, Any] | None]
|
||||
|
||||
|
||||
@dataclass
|
||||
class MCPToolCallRequest:
|
||||
"""Tool execution request passed to MCP tool call interceptors.
|
||||
|
||||
This tool call request follows a similar pattern to LangChain's
|
||||
ToolCallRequest (flat namespace) rather than separating the call data
|
||||
and context into nested objects.
|
||||
|
||||
Modifiable fields (override these to change behavior):
|
||||
name: Tool name to invoke.
|
||||
args: Tool arguments as key-value pairs.
|
||||
headers: HTTP headers for applicable transports (SSE, HTTP).
|
||||
|
||||
Context fields (read-only, use for routing/logging):
|
||||
server_name: Name of the MCP server handling the tool.
|
||||
runtime: LangGraph runtime context (optional, None if outside graph).
|
||||
"""
|
||||
|
||||
name: str
|
||||
args: dict[str, Any]
|
||||
server_name: str # Context: MCP server name
|
||||
headers: dict[str, Any] | None = None # Modifiable: HTTP headers
|
||||
runtime: object | None = None # Context: LangGraph runtime (if any)
|
||||
|
||||
def override(
|
||||
self, **overrides: Unpack[_MCPToolCallRequestOverrides]
|
||||
) -> MCPToolCallRequest:
|
||||
"""Replace the request with a new request with the given overrides.
|
||||
|
||||
Returns a new `MCPToolCallRequest` instance with the specified
|
||||
attributes replaced. This follows an immutable pattern, leaving the
|
||||
original request unchanged.
|
||||
|
||||
Args:
|
||||
**overrides: Keyword arguments for attributes to override.
|
||||
Supported keys:
|
||||
- name: Tool name
|
||||
- args: Tool arguments
|
||||
- headers: HTTP headers
|
||||
|
||||
Returns:
|
||||
New MCPToolCallRequest instance with specified overrides
|
||||
applied.
|
||||
|
||||
Note:
|
||||
Context fields (server_name, runtime) cannot be overridden as
|
||||
they are read-only.
|
||||
|
||||
Examples:
|
||||
```python
|
||||
# Modify tool arguments
|
||||
new_request = request.override(args={"value": 10})
|
||||
|
||||
# Change tool name
|
||||
new_request = request.override(name="different_tool")
|
||||
```
|
||||
"""
|
||||
return replace(self, **overrides)
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class ToolCallInterceptor(Protocol):
|
||||
"""Protocol for tool call interceptors using handler callback pattern.
|
||||
|
||||
Interceptors wrap tool execution to enable request/response modification,
|
||||
retry logic, caching, rate limiting, and other cross-cutting concerns.
|
||||
Multiple interceptors compose in "onion" pattern (first is outermost).
|
||||
|
||||
The handler can be called multiple times (retry), skipped (caching/short-circuit),
|
||||
or wrapped with error handling. Each handler call is independent.
|
||||
|
||||
Similar to LangChain's middleware pattern but adapted for MCP remote tools.
|
||||
"""
|
||||
|
||||
async def __call__(
|
||||
self,
|
||||
request: MCPToolCallRequest,
|
||||
handler: Callable[[MCPToolCallRequest], Awaitable[MCPToolCallResult]],
|
||||
) -> MCPToolCallResult:
|
||||
"""Intercept tool execution with control over handler invocation.
|
||||
|
||||
Args:
|
||||
request: Tool call request containing name, args, headers, and context
|
||||
(server_name, runtime). Access context fields like request.server_name.
|
||||
handler: Async callable executing the tool. Can be called multiple
|
||||
times, skipped, or wrapped for error handling.
|
||||
|
||||
Returns:
|
||||
Final MCPToolCallResult from tool execution or interceptor logic.
|
||||
"""
|
||||
...
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Prompts adapter for converting MCP prompts to LangChain [messages](https://docs.langchain.com/oss/python/langchain/messages).
|
||||
|
||||
This module provides functionality to convert MCP prompt messages into LangChain
|
||||
message objects, handling both user and assistant message types.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from langchain_core.messages import AIMessage, HumanMessage
|
||||
from mcp import ClientSession
|
||||
from mcp.types import PromptMessage
|
||||
|
||||
|
||||
def convert_mcp_prompt_message_to_langchain_message(
|
||||
message: PromptMessage,
|
||||
) -> HumanMessage | AIMessage:
|
||||
"""Convert an MCP prompt message to a LangChain message.
|
||||
|
||||
Args:
|
||||
message: MCP prompt message to convert
|
||||
|
||||
Returns:
|
||||
A LangChain message
|
||||
|
||||
"""
|
||||
if message.content.type == "text":
|
||||
if message.role == "user":
|
||||
return HumanMessage(content=message.content.text)
|
||||
if message.role == "assistant":
|
||||
return AIMessage(content=message.content.text)
|
||||
msg = f"Unsupported prompt message role: {message.role}"
|
||||
raise ValueError(msg)
|
||||
|
||||
msg = f"Unsupported prompt message content type: {message.content.type}"
|
||||
raise ValueError(msg)
|
||||
|
||||
|
||||
async def load_mcp_prompt(
|
||||
session: ClientSession,
|
||||
name: str,
|
||||
*,
|
||||
arguments: dict[str, Any] | None = None,
|
||||
) -> list[HumanMessage | AIMessage]:
|
||||
"""Load MCP prompt and convert to LangChain [messages](https://docs.langchain.com/oss/python/langchain/messages).
|
||||
|
||||
Args:
|
||||
session: The MCP client session.
|
||||
name: Name of the prompt to load.
|
||||
arguments: Optional arguments to pass to the prompt.
|
||||
|
||||
Returns:
|
||||
A list of LangChain [messages](https://docs.langchain.com/oss/python/langchain/messages)
|
||||
converted from the MCP prompt.
|
||||
"""
|
||||
response = await session.get_prompt(name, arguments)
|
||||
return [
|
||||
convert_mcp_prompt_message_to_langchain_message(message)
|
||||
for message in response.messages
|
||||
]
|
||||
@@ -0,0 +1,103 @@
|
||||
"""Resources adapter for converting MCP resources to LangChain [Blob objects][langchain_core.documents.base.Blob].
|
||||
|
||||
This module provides functionality to convert MCP resources into LangChain Blob
|
||||
objects, handling both text and binary resource content types.
|
||||
""" # noqa: E501
|
||||
|
||||
import base64
|
||||
|
||||
from langchain_core.documents.base import Blob
|
||||
from mcp import ClientSession
|
||||
from mcp.types import BlobResourceContents, ResourceContents, TextResourceContents
|
||||
|
||||
|
||||
def convert_mcp_resource_to_langchain_blob(
|
||||
resource_uri: str, contents: ResourceContents
|
||||
) -> Blob:
|
||||
"""Convert an MCP resource content to a LangChain Blob.
|
||||
|
||||
Args:
|
||||
resource_uri: URI of the resource
|
||||
contents: The resource contents
|
||||
|
||||
Returns:
|
||||
A LangChain Blob
|
||||
|
||||
"""
|
||||
if isinstance(contents, TextResourceContents):
|
||||
data = contents.text
|
||||
elif isinstance(contents, BlobResourceContents):
|
||||
data = base64.b64decode(contents.blob)
|
||||
else:
|
||||
msg = f"Unsupported content type for URI {resource_uri}"
|
||||
raise TypeError(msg)
|
||||
|
||||
return Blob.from_data(
|
||||
data=data, mime_type=contents.mimeType, metadata={"uri": resource_uri}
|
||||
)
|
||||
|
||||
|
||||
async def get_mcp_resource(session: ClientSession, uri: str) -> list[Blob]:
|
||||
"""Fetch a single MCP resource and convert it to LangChain [Blob objects][langchain_core.documents.base.Blob].
|
||||
|
||||
Args:
|
||||
session: MCP client session.
|
||||
uri: URI of the resource to fetch.
|
||||
|
||||
Returns:
|
||||
A list of LangChain [Blob][langchain_core.documents.base.Blob] objects.
|
||||
""" # noqa: E501
|
||||
contents_result = await session.read_resource(uri)
|
||||
if not contents_result.contents or len(contents_result.contents) == 0:
|
||||
return []
|
||||
|
||||
return [
|
||||
convert_mcp_resource_to_langchain_blob(uri, content)
|
||||
for content in contents_result.contents
|
||||
]
|
||||
|
||||
|
||||
async def load_mcp_resources(
|
||||
session: ClientSession,
|
||||
*,
|
||||
uris: str | list[str] | None = None,
|
||||
) -> list[Blob]:
|
||||
"""Load MCP resources and convert them to LangChain [Blob objects][langchain_core.documents.base.Blob].
|
||||
|
||||
Args:
|
||||
session: MCP client session.
|
||||
uris: List of URIs to load. If `None`, all resources will be loaded.
|
||||
|
||||
!!! note
|
||||
|
||||
Dynamic resources will NOT be loaded when `None` is specified,
|
||||
as they require parameters and are ignored by the MCP SDK's
|
||||
`session.list_resources()` method.
|
||||
|
||||
Returns:
|
||||
A list of LangChain [Blob][langchain_core.documents.base.Blob] objects.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If an error occurs while fetching a resource.
|
||||
""" # noqa: E501
|
||||
blobs = []
|
||||
|
||||
if uris is None:
|
||||
resources_list = await session.list_resources()
|
||||
uri_list = [r.uri for r in resources_list.resources]
|
||||
elif isinstance(uris, str):
|
||||
uri_list = [uris]
|
||||
else:
|
||||
uri_list = uris
|
||||
|
||||
current_uri = None
|
||||
try:
|
||||
for uri in uri_list:
|
||||
current_uri = uri
|
||||
resource_blobs = await get_mcp_resource(session, uri)
|
||||
blobs.extend(resource_blobs)
|
||||
except Exception as e:
|
||||
msg = f"Error fetching resource {current_uri}"
|
||||
raise RuntimeError(msg) from e
|
||||
|
||||
return blobs
|
||||
@@ -0,0 +1,431 @@
|
||||
"""Session management for different MCP transport types.
|
||||
|
||||
This module provides connection configurations and session management for various
|
||||
MCP transport types including stdio, SSE, WebSocket, and streamable HTTP.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import timedelta
|
||||
from typing import TYPE_CHECKING, Any, Literal, Protocol
|
||||
|
||||
from mcp import ClientSession, StdioServerParameters
|
||||
from mcp.client.sse import sse_client
|
||||
from mcp.client.stdio import stdio_client
|
||||
from mcp.client.streamable_http import streamablehttp_client
|
||||
from typing_extensions import NotRequired, TypedDict
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import AsyncIterator
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
|
||||
from langchain_mcp_adapters.callbacks import _MCPCallbacks
|
||||
|
||||
EncodingErrorHandler = Literal["strict", "ignore", "replace"]
|
||||
|
||||
DEFAULT_ENCODING = "utf-8"
|
||||
DEFAULT_ENCODING_ERROR_HANDLER: EncodingErrorHandler = "strict"
|
||||
|
||||
DEFAULT_HTTP_TIMEOUT = 5
|
||||
DEFAULT_SSE_READ_TIMEOUT = 60 * 5
|
||||
|
||||
DEFAULT_STREAMABLE_HTTP_TIMEOUT = timedelta(seconds=30)
|
||||
DEFAULT_STREAMABLE_HTTP_SSE_READ_TIMEOUT = timedelta(seconds=60 * 5)
|
||||
|
||||
|
||||
class McpHttpClientFactory(Protocol):
|
||||
"""Protocol for creating httpx.AsyncClient instances for MCP connections."""
|
||||
|
||||
def __call__(
|
||||
self,
|
||||
headers: dict[str, str] | None = None,
|
||||
timeout: httpx.Timeout | None = None,
|
||||
auth: httpx.Auth | None = None,
|
||||
) -> httpx.AsyncClient:
|
||||
"""Create an httpx.AsyncClient instance.
|
||||
|
||||
Args:
|
||||
headers: HTTP headers to include in requests.
|
||||
timeout: Request timeout configuration.
|
||||
auth: Authentication configuration.
|
||||
|
||||
Returns:
|
||||
Configured httpx.AsyncClient instance.
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
class StdioConnection(TypedDict):
|
||||
"""Configuration for stdio transport connections to MCP servers."""
|
||||
|
||||
transport: Literal["stdio"]
|
||||
|
||||
command: str
|
||||
"""The executable to run to start the server."""
|
||||
|
||||
args: list[str]
|
||||
"""Command line arguments to pass to the executable."""
|
||||
|
||||
env: NotRequired[dict[str, str] | None]
|
||||
"""The environment to use when spawning the process.
|
||||
|
||||
If not specified or set to None, a subset of the default environment
|
||||
variables from the current process will be used.
|
||||
|
||||
Please refer to the MCP SDK documentation for details on which
|
||||
environment variables are included by default. The behavior
|
||||
varies by operating system.
|
||||
|
||||
https://github.com/modelcontextprotocol/python-sdk/blob/c47c767ff437ee88a19e6b9001e2472cb6f7d5ed/src/mcp/client/stdio/__init__.py#L51
|
||||
"""
|
||||
|
||||
cwd: NotRequired[str | Path | None]
|
||||
"""The working directory to use when spawning the process."""
|
||||
|
||||
encoding: NotRequired[str]
|
||||
"""The text encoding used when sending/receiving messages to the server.
|
||||
|
||||
Default is 'utf-8'.
|
||||
"""
|
||||
|
||||
encoding_error_handler: NotRequired[EncodingErrorHandler]
|
||||
"""
|
||||
The text encoding error handler.
|
||||
|
||||
See https://docs.python.org/3/library/codecs.html#codec-base-classes for
|
||||
explanations of possible values.
|
||||
|
||||
Default is 'strict', which raises an error on encoding/decoding errors.
|
||||
"""
|
||||
|
||||
session_kwargs: NotRequired[dict[str, Any] | None]
|
||||
"""Additional keyword arguments to pass to the ClientSession."""
|
||||
|
||||
|
||||
class SSEConnection(TypedDict):
|
||||
"""Configuration for Server-Sent Events (SSE) transport connections to MCP."""
|
||||
|
||||
transport: Literal["sse"]
|
||||
|
||||
url: str
|
||||
"""The URL of the SSE endpoint to connect to."""
|
||||
|
||||
headers: NotRequired[dict[str, Any] | None]
|
||||
"""HTTP headers to send to the SSE endpoint."""
|
||||
|
||||
timeout: NotRequired[float]
|
||||
"""HTTP timeout.
|
||||
|
||||
Default is 5 seconds. If the server takes longer to respond,
|
||||
you can increase this value.
|
||||
"""
|
||||
|
||||
sse_read_timeout: NotRequired[float]
|
||||
"""SSE read timeout.
|
||||
|
||||
Default is 300 seconds (5 minutes). This is how long the client will
|
||||
wait for a new event before disconnecting.
|
||||
"""
|
||||
|
||||
session_kwargs: NotRequired[dict[str, Any] | None]
|
||||
"""Additional keyword arguments to pass to the ClientSession."""
|
||||
|
||||
httpx_client_factory: NotRequired[McpHttpClientFactory | None]
|
||||
"""Custom factory for httpx.AsyncClient (optional)."""
|
||||
|
||||
auth: NotRequired[httpx.Auth]
|
||||
"""Optional authentication for the HTTP client."""
|
||||
|
||||
|
||||
class StreamableHttpConnection(TypedDict):
|
||||
"""Connection configuration for Streamable HTTP transport."""
|
||||
|
||||
transport: Literal["streamable_http"]
|
||||
|
||||
url: str
|
||||
"""The URL of the endpoint to connect to."""
|
||||
|
||||
headers: NotRequired[dict[str, Any] | None]
|
||||
"""HTTP headers to send to the endpoint."""
|
||||
|
||||
timeout: NotRequired[timedelta]
|
||||
"""HTTP timeout."""
|
||||
|
||||
sse_read_timeout: NotRequired[timedelta]
|
||||
"""How long (in seconds) the client will wait for a new event before disconnecting.
|
||||
All other HTTP operations are controlled by `timeout`."""
|
||||
|
||||
terminate_on_close: NotRequired[bool]
|
||||
"""Whether to terminate the session on close."""
|
||||
|
||||
session_kwargs: NotRequired[dict[str, Any] | None]
|
||||
"""Additional keyword arguments to pass to the ClientSession."""
|
||||
|
||||
httpx_client_factory: NotRequired[McpHttpClientFactory | None]
|
||||
"""Custom factory for httpx.AsyncClient (optional)."""
|
||||
|
||||
auth: NotRequired[httpx.Auth]
|
||||
"""Optional authentication for the HTTP client."""
|
||||
|
||||
|
||||
class WebsocketConnection(TypedDict):
|
||||
"""Configuration for WebSocket transport connections to MCP servers."""
|
||||
|
||||
transport: Literal["websocket"]
|
||||
|
||||
url: str
|
||||
"""The URL of the Websocket endpoint to connect to."""
|
||||
|
||||
session_kwargs: NotRequired[dict[str, Any] | None]
|
||||
"""Additional keyword arguments to pass to the ClientSession"""
|
||||
|
||||
|
||||
Connection = (
|
||||
StdioConnection | SSEConnection | StreamableHttpConnection | WebsocketConnection
|
||||
)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def _create_stdio_session(
|
||||
*,
|
||||
command: str,
|
||||
args: list[str],
|
||||
env: dict[str, str] | None = None,
|
||||
cwd: str | Path | None = None,
|
||||
encoding: str = DEFAULT_ENCODING,
|
||||
encoding_error_handler: Literal[
|
||||
"strict", "ignore", "replace"
|
||||
] = DEFAULT_ENCODING_ERROR_HANDLER,
|
||||
session_kwargs: dict[str, Any] | None = None,
|
||||
) -> AsyncIterator[ClientSession]:
|
||||
"""Create a new session to an MCP server using stdio.
|
||||
|
||||
Args:
|
||||
command: Command to execute.
|
||||
args: Arguments for the command.
|
||||
env: Environment variables for the command.
|
||||
If not specified, inherits a subset of the current environment.
|
||||
The details are implemented in the MCP sdk.
|
||||
cwd: Working directory for the command.
|
||||
encoding: Character encoding.
|
||||
encoding_error_handler: How to handle encoding errors.
|
||||
session_kwargs: Additional keyword arguments to pass to the ClientSession.
|
||||
|
||||
Yields:
|
||||
An initialized ClientSession.
|
||||
"""
|
||||
server_params = StdioServerParameters(
|
||||
command=command,
|
||||
args=args,
|
||||
env=env,
|
||||
cwd=cwd,
|
||||
encoding=encoding,
|
||||
encoding_error_handler=encoding_error_handler,
|
||||
)
|
||||
|
||||
# Create and store the connection
|
||||
async with (
|
||||
stdio_client(server_params) as (read, write),
|
||||
ClientSession(read, write, **(session_kwargs or {})) as session,
|
||||
):
|
||||
yield session
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def _create_sse_session(
|
||||
*,
|
||||
url: str,
|
||||
headers: dict[str, Any] | None = None,
|
||||
timeout: float = DEFAULT_HTTP_TIMEOUT,
|
||||
sse_read_timeout: float = DEFAULT_SSE_READ_TIMEOUT,
|
||||
session_kwargs: dict[str, Any] | None = None,
|
||||
httpx_client_factory: McpHttpClientFactory | None = None,
|
||||
auth: httpx.Auth | None = None,
|
||||
) -> AsyncIterator[ClientSession]:
|
||||
"""Create a new session to an MCP server using SSE.
|
||||
|
||||
Args:
|
||||
url: URL of the SSE server.
|
||||
headers: HTTP headers to send to the SSE endpoint.
|
||||
timeout: HTTP timeout.
|
||||
sse_read_timeout: SSE read timeout.
|
||||
session_kwargs: Additional keyword arguments to pass to the ClientSession.
|
||||
httpx_client_factory: Custom factory for httpx.AsyncClient (optional).
|
||||
auth: Authentication for the HTTP client.
|
||||
|
||||
Yields:
|
||||
An initialized ClientSession.
|
||||
"""
|
||||
# Create and store the connection
|
||||
kwargs = {}
|
||||
if httpx_client_factory is not None:
|
||||
kwargs["httpx_client_factory"] = httpx_client_factory
|
||||
|
||||
async with (
|
||||
sse_client(url, headers, timeout, sse_read_timeout, auth=auth, **kwargs) as (
|
||||
read,
|
||||
write,
|
||||
),
|
||||
ClientSession(read, write, **(session_kwargs or {})) as session,
|
||||
):
|
||||
yield session
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def _create_streamable_http_session(
|
||||
*,
|
||||
url: str,
|
||||
headers: dict[str, Any] | None = None,
|
||||
timeout: timedelta = DEFAULT_STREAMABLE_HTTP_TIMEOUT,
|
||||
sse_read_timeout: timedelta = DEFAULT_STREAMABLE_HTTP_SSE_READ_TIMEOUT,
|
||||
terminate_on_close: bool = True,
|
||||
session_kwargs: dict[str, Any] | None = None,
|
||||
httpx_client_factory: McpHttpClientFactory | None = None,
|
||||
auth: httpx.Auth | None = None,
|
||||
) -> AsyncIterator[ClientSession]:
|
||||
"""Create a new session to an MCP server using Streamable HTTP.
|
||||
|
||||
Args:
|
||||
url: URL of the endpoint to connect to.
|
||||
headers: HTTP headers to send to the endpoint.
|
||||
timeout: HTTP timeout.
|
||||
sse_read_timeout: How long the client will wait for a new event before
|
||||
disconnecting.
|
||||
terminate_on_close: Whether to terminate the session on close.
|
||||
session_kwargs: Additional keyword arguments to pass to the ClientSession.
|
||||
httpx_client_factory: Custom factory for httpx.AsyncClient (optional).
|
||||
auth: Authentication for the HTTP client.
|
||||
|
||||
Yields:
|
||||
An initialized ClientSession.
|
||||
"""
|
||||
# Create and store the connection
|
||||
kwargs = {}
|
||||
if httpx_client_factory is not None:
|
||||
kwargs["httpx_client_factory"] = httpx_client_factory
|
||||
|
||||
async with (
|
||||
streamablehttp_client(
|
||||
url,
|
||||
headers,
|
||||
timeout,
|
||||
sse_read_timeout,
|
||||
terminate_on_close,
|
||||
auth=auth,
|
||||
**kwargs,
|
||||
) as (read, write, _),
|
||||
ClientSession(read, write, **(session_kwargs or {})) as session,
|
||||
):
|
||||
yield session
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def _create_websocket_session(
|
||||
*,
|
||||
url: str,
|
||||
session_kwargs: dict[str, Any] | None = None,
|
||||
) -> AsyncIterator[ClientSession]:
|
||||
"""Create a new session to an MCP server using Websockets.
|
||||
|
||||
Args:
|
||||
url: URL of the Websocket endpoint.
|
||||
session_kwargs: Additional keyword arguments to pass to the ClientSession.
|
||||
|
||||
Yields:
|
||||
An initialized ClientSession.
|
||||
|
||||
Raises:
|
||||
ImportError: If websockets package is not installed.
|
||||
"""
|
||||
try:
|
||||
from mcp.client.websocket import websocket_client # noqa: PLC0415
|
||||
except ImportError:
|
||||
msg = (
|
||||
"Could not import websocket_client. "
|
||||
"To use Websocket connections, please install the required dependency: "
|
||||
"'pip install mcp[ws]' or 'pip install websockets'"
|
||||
)
|
||||
raise ImportError(msg) from None
|
||||
|
||||
async with (
|
||||
websocket_client(url) as (read, write),
|
||||
ClientSession(read, write, **(session_kwargs or {})) as session,
|
||||
):
|
||||
yield session
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def create_session(
|
||||
connection: Connection, *, mcp_callbacks: _MCPCallbacks | None = None
|
||||
) -> AsyncIterator[ClientSession]:
|
||||
"""Create a new session to an MCP server.
|
||||
|
||||
Args:
|
||||
connection: Connection config to use to connect to the server
|
||||
mcp_callbacks: mcp sdk compatible callbacks to use for the ClientSession
|
||||
|
||||
Raises:
|
||||
ValueError: If transport is not recognized
|
||||
ValueError: If required parameters for the specified transport are missing
|
||||
|
||||
Yields:
|
||||
A ClientSession
|
||||
"""
|
||||
if "transport" not in connection:
|
||||
msg = (
|
||||
"Configuration error: Missing 'transport' key in server configuration. "
|
||||
"Each server must include 'transport' with one of: "
|
||||
"'stdio', 'sse', 'websocket', 'streamable_http'. "
|
||||
"Please refer to the langchain-mcp-adapters documentation for more details."
|
||||
)
|
||||
raise ValueError(msg)
|
||||
|
||||
transport = connection["transport"]
|
||||
params = {k: v for k, v in connection.items() if k != "transport"}
|
||||
|
||||
if mcp_callbacks is not None:
|
||||
params["session_kwargs"] = params.get("session_kwargs", {})
|
||||
# right now the only callback supported on the ClientSession
|
||||
# is the logging callback, but long term we'll also want to
|
||||
# support sampling, elicitation, list roots, etc.
|
||||
if mcp_callbacks.logging_callback is not None:
|
||||
params["session_kwargs"]["logging_callback"] = (
|
||||
mcp_callbacks.logging_callback
|
||||
)
|
||||
|
||||
if transport == "sse":
|
||||
if "url" not in params:
|
||||
msg = "'url' parameter is required for SSE connection"
|
||||
raise ValueError(msg)
|
||||
async with _create_sse_session(**params) as session:
|
||||
yield session
|
||||
elif transport == "streamable_http":
|
||||
if "url" not in params:
|
||||
msg = "'url' parameter is required for Streamable HTTP connection"
|
||||
raise ValueError(msg)
|
||||
async with _create_streamable_http_session(**params) as session:
|
||||
yield session
|
||||
elif transport == "stdio":
|
||||
if "command" not in params:
|
||||
msg = "'command' parameter is required for stdio connection"
|
||||
raise ValueError(msg)
|
||||
if "args" not in params:
|
||||
msg = "'args' parameter is required for stdio connection"
|
||||
raise ValueError(msg)
|
||||
async with _create_stdio_session(**params) as session:
|
||||
yield session
|
||||
elif transport == "websocket":
|
||||
if "url" not in params:
|
||||
msg = "'url' parameter is required for Websocket connection"
|
||||
raise ValueError(msg)
|
||||
async with _create_websocket_session(**params) as session:
|
||||
yield session
|
||||
else:
|
||||
msg = (
|
||||
f"Unsupported transport: {transport}. "
|
||||
f"Must be one of: 'stdio', 'sse', 'websocket', 'streamable_http'"
|
||||
)
|
||||
raise ValueError(msg)
|
||||
@@ -0,0 +1,462 @@
|
||||
"""Tools adapter for converting MCP tools to LangChain tools.
|
||||
|
||||
This module provides functionality to convert MCP tools into LangChain-compatible
|
||||
tools, handle tool execution, and manage tool conversion between the two formats.
|
||||
"""
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any, get_args
|
||||
|
||||
from langchain_core.messages import ToolMessage
|
||||
from langchain_core.tools import (
|
||||
BaseTool,
|
||||
InjectedToolArg,
|
||||
StructuredTool,
|
||||
ToolException,
|
||||
)
|
||||
from langchain_core.tools.base import get_all_basemodel_annotations
|
||||
from mcp import ClientSession
|
||||
from mcp.server.fastmcp.tools import Tool as FastMCPTool
|
||||
from mcp.server.fastmcp.utilities.func_metadata import ArgModelBase, FuncMetadata
|
||||
from mcp.types import (
|
||||
AudioContent,
|
||||
EmbeddedResource,
|
||||
ImageContent,
|
||||
ResourceLink,
|
||||
TextContent,
|
||||
)
|
||||
from mcp.types import Tool as MCPTool
|
||||
from pydantic import BaseModel, create_model
|
||||
|
||||
from langchain_mcp_adapters.callbacks import CallbackContext, Callbacks, _MCPCallbacks
|
||||
from langchain_mcp_adapters.interceptors import (
|
||||
MCPToolCallRequest,
|
||||
MCPToolCallResult,
|
||||
ToolCallInterceptor,
|
||||
)
|
||||
from langchain_mcp_adapters.sessions import Connection, create_session
|
||||
|
||||
try:
|
||||
# langgraph installed
|
||||
import langgraph
|
||||
from langgraph.types import Command
|
||||
|
||||
LANGGRAPH_PRESENT = True
|
||||
except ImportError:
|
||||
LANGGRAPH_PRESENT = False
|
||||
|
||||
NonTextContent = ImageContent | AudioContent | ResourceLink | EmbeddedResource
|
||||
|
||||
# Conditional type based on langgraph availability
|
||||
if LANGGRAPH_PRESENT:
|
||||
ConvertedToolResult = str | list[str] | ToolMessage | Command
|
||||
else:
|
||||
ConvertedToolResult = str | list[str] | ToolMessage
|
||||
|
||||
MAX_ITERATIONS = 1000
|
||||
|
||||
|
||||
def _convert_call_tool_result(
|
||||
call_tool_result: MCPToolCallResult,
|
||||
) -> tuple[ConvertedToolResult, list[NonTextContent] | None]:
|
||||
"""Convert MCP MCPToolCallResult to LangChain tool result format.
|
||||
|
||||
Args:
|
||||
call_tool_result: The result from calling an MCP tool. Can be either
|
||||
a CallToolResult (MCP format), a ToolMessage (LangChain format),
|
||||
or a Command (LangGraph format, if langgraph is installed).
|
||||
|
||||
Returns:
|
||||
A tuple containing the text content (which may be a ToolMessage or Command)
|
||||
and any non-text content. When a ToolMessage or Command is returned by an
|
||||
interceptor, it's placed in the first position of the tuple as the content,
|
||||
with None as the artifact.
|
||||
|
||||
Raises:
|
||||
ToolException: If the tool call resulted in an error.
|
||||
"""
|
||||
# If the interceptor returned a ToolMessage directly, return it as the content
|
||||
# with None as the artifact to match the content_and_artifact format
|
||||
if isinstance(call_tool_result, ToolMessage):
|
||||
return call_tool_result, None
|
||||
|
||||
# If the interceptor returned a Command (LangGraph), return it directly
|
||||
if LANGGRAPH_PRESENT and isinstance(call_tool_result, Command):
|
||||
return call_tool_result, None
|
||||
|
||||
# Otherwise, convert from CallToolResult
|
||||
text_contents: list[TextContent] = []
|
||||
non_text_contents = []
|
||||
for content in call_tool_result.content:
|
||||
if isinstance(content, TextContent):
|
||||
text_contents.append(content)
|
||||
else:
|
||||
non_text_contents.append(content)
|
||||
|
||||
tool_content: str | list[str] = [content.text for content in text_contents]
|
||||
if not text_contents:
|
||||
tool_content = ""
|
||||
elif len(text_contents) == 1:
|
||||
tool_content = tool_content[0]
|
||||
|
||||
if call_tool_result.isError:
|
||||
raise ToolException(tool_content)
|
||||
|
||||
return tool_content, non_text_contents or None
|
||||
|
||||
|
||||
def _build_interceptor_chain(
|
||||
base_handler: Callable[[MCPToolCallRequest], Awaitable[MCPToolCallResult]],
|
||||
tool_interceptors: list[ToolCallInterceptor] | None,
|
||||
) -> Callable[[MCPToolCallRequest], Awaitable[MCPToolCallResult]]:
|
||||
"""Build composed handler chain with interceptors in onion pattern.
|
||||
|
||||
Args:
|
||||
base_handler: Innermost handler executing the actual tool call.
|
||||
tool_interceptors: Optional list of interceptors to wrap the handler.
|
||||
|
||||
Returns:
|
||||
Composed handler with all interceptors applied. First interceptor
|
||||
in list becomes outermost layer.
|
||||
"""
|
||||
handler = base_handler
|
||||
|
||||
if tool_interceptors:
|
||||
for interceptor in reversed(tool_interceptors):
|
||||
current_handler = handler
|
||||
|
||||
async def wrapped_handler(
|
||||
req: MCPToolCallRequest,
|
||||
_interceptor: ToolCallInterceptor = interceptor,
|
||||
_handler: Callable[
|
||||
[MCPToolCallRequest], Awaitable[MCPToolCallResult]
|
||||
] = current_handler,
|
||||
) -> MCPToolCallResult:
|
||||
return await _interceptor(req, _handler)
|
||||
|
||||
handler = wrapped_handler
|
||||
|
||||
return handler
|
||||
|
||||
|
||||
async def _list_all_tools(session: ClientSession) -> list[MCPTool]:
|
||||
"""List all available tools from an MCP session with pagination support.
|
||||
|
||||
Args:
|
||||
session: The MCP client session.
|
||||
|
||||
Returns:
|
||||
A list of all available MCP tools.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If maximum iterations exceeded while listing tools.
|
||||
"""
|
||||
current_cursor: str | None = None
|
||||
all_tools: list[MCPTool] = []
|
||||
|
||||
iterations = 0
|
||||
|
||||
while True:
|
||||
iterations += 1
|
||||
if iterations > MAX_ITERATIONS:
|
||||
msg = "Reached max of 1000 iterations while listing tools."
|
||||
raise RuntimeError(msg)
|
||||
|
||||
list_tools_page_result = await session.list_tools(cursor=current_cursor)
|
||||
|
||||
if list_tools_page_result.tools:
|
||||
all_tools.extend(list_tools_page_result.tools)
|
||||
|
||||
# Pagination spec: https://modelcontextprotocol.io/specification/2025-06-18/server/utilities/pagination
|
||||
# compatible with None or ""
|
||||
if not list_tools_page_result.nextCursor:
|
||||
break
|
||||
|
||||
current_cursor = list_tools_page_result.nextCursor
|
||||
return all_tools
|
||||
|
||||
|
||||
def convert_mcp_tool_to_langchain_tool(
|
||||
session: ClientSession | None,
|
||||
tool: MCPTool,
|
||||
*,
|
||||
connection: Connection | None = None,
|
||||
callbacks: Callbacks | None = None,
|
||||
tool_interceptors: list[ToolCallInterceptor] | None = None,
|
||||
server_name: str | None = None,
|
||||
) -> BaseTool:
|
||||
"""Convert an MCP tool to a LangChain tool.
|
||||
|
||||
NOTE: this tool can be executed only in a context of an active MCP client session.
|
||||
|
||||
Args:
|
||||
session: MCP client session
|
||||
tool: MCP tool to convert
|
||||
connection: Optional connection config to use to create a new session
|
||||
if a `session` is not provided
|
||||
callbacks: Optional callbacks for handling notifications and events
|
||||
tool_interceptors: Optional list of interceptors for tool call processing
|
||||
server_name: Name of the server this tool belongs to
|
||||
|
||||
Returns:
|
||||
a LangChain tool
|
||||
|
||||
"""
|
||||
if session is None and connection is None:
|
||||
msg = "Either a session or a connection config must be provided"
|
||||
raise ValueError(msg)
|
||||
|
||||
async def call_tool(
|
||||
runtime: Any = None, # noqa: ANN401
|
||||
**arguments: dict[str, Any],
|
||||
) -> tuple[ConvertedToolResult, list[NonTextContent] | None]:
|
||||
"""Execute tool call with interceptor chain and return formatted result.
|
||||
|
||||
Args:
|
||||
runtime: LangGraph tool runtime if available, otherwise None.
|
||||
**arguments: Tool arguments as keyword args.
|
||||
|
||||
Returns:
|
||||
A tuple of (text_content, non_text_content), where text_content may be
|
||||
a ToolMessage or Command (if langgraph is installed) if an interceptor
|
||||
returned one directly.
|
||||
"""
|
||||
mcp_callbacks = (
|
||||
callbacks.to_mcp_format(
|
||||
context=CallbackContext(server_name=server_name, tool_name=tool.name)
|
||||
)
|
||||
if callbacks is not None
|
||||
else _MCPCallbacks()
|
||||
)
|
||||
|
||||
# Create the innermost handler that actually executes the tool call
|
||||
async def execute_tool(request: MCPToolCallRequest) -> MCPToolCallResult:
|
||||
"""Execute the actual MCP tool call with optional session creation.
|
||||
|
||||
Args:
|
||||
request: Tool call request with name, args, headers, and context.
|
||||
|
||||
Returns:
|
||||
MCPToolCallResult from MCP SDK.
|
||||
|
||||
Raises:
|
||||
ValueError: If neither session nor connection provided.
|
||||
RuntimeError: If tool call returns None.
|
||||
"""
|
||||
tool_name = request.name
|
||||
tool_args = request.args
|
||||
effective_connection = connection
|
||||
|
||||
# If headers were modified, create a new connection with updated headers
|
||||
modified_headers = request.headers
|
||||
if modified_headers is not None and connection is not None:
|
||||
# Create a new connection config with updated headers
|
||||
updated_connection = dict(connection)
|
||||
if connection["transport"] in ("sse", "streamable_http"):
|
||||
existing_headers = connection.get("headers", {})
|
||||
updated_connection["headers"] = {
|
||||
**existing_headers,
|
||||
**modified_headers,
|
||||
}
|
||||
effective_connection = updated_connection
|
||||
|
||||
captured_exception = None
|
||||
|
||||
if session is None:
|
||||
# If a session is not provided, we will create one on the fly
|
||||
if effective_connection is None:
|
||||
msg = "Either session or connection must be provided"
|
||||
raise ValueError(msg)
|
||||
|
||||
async with create_session(
|
||||
effective_connection, mcp_callbacks=mcp_callbacks
|
||||
) as tool_session:
|
||||
await tool_session.initialize()
|
||||
try:
|
||||
call_tool_result = await tool_session.call_tool(
|
||||
tool_name,
|
||||
tool_args,
|
||||
progress_callback=mcp_callbacks.progress_callback,
|
||||
)
|
||||
except Exception as e: # noqa: BLE001
|
||||
# Capture exception to re-raise outside context manager
|
||||
captured_exception = e
|
||||
|
||||
# Re-raise the exception outside the context manager
|
||||
# This is necessary because the context manager may suppress exceptions
|
||||
# This change was introduced to work-around an issue in MCP SDK
|
||||
# that may suppress exceptions when the client disconnects.
|
||||
# If this is causing an issue, with your use case, please file an issue
|
||||
# on the langchain-mcp-adapters GitHub repo.
|
||||
if captured_exception is not None:
|
||||
raise captured_exception
|
||||
else:
|
||||
call_tool_result = await session.call_tool(
|
||||
tool_name,
|
||||
tool_args,
|
||||
progress_callback=mcp_callbacks.progress_callback,
|
||||
)
|
||||
|
||||
return call_tool_result
|
||||
|
||||
# Build and execute the interceptor chain
|
||||
handler = _build_interceptor_chain(execute_tool, tool_interceptors)
|
||||
request = MCPToolCallRequest(
|
||||
name=tool.name,
|
||||
args=arguments,
|
||||
server_name=server_name or "unknown",
|
||||
headers=None,
|
||||
runtime=runtime,
|
||||
)
|
||||
call_tool_result = await handler(request)
|
||||
|
||||
return _convert_call_tool_result(call_tool_result)
|
||||
|
||||
meta = getattr(tool, "meta", None)
|
||||
base = tool.annotations.model_dump() if tool.annotations is not None else {}
|
||||
meta = {"_meta": meta} if meta is not None else {}
|
||||
metadata = {**base, **meta} or None
|
||||
|
||||
return StructuredTool(
|
||||
name=tool.name,
|
||||
description=tool.description or "",
|
||||
args_schema=tool.inputSchema,
|
||||
coroutine=call_tool,
|
||||
response_format="content_and_artifact",
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
|
||||
async def load_mcp_tools(
|
||||
session: ClientSession | None,
|
||||
*,
|
||||
connection: Connection | None = None,
|
||||
callbacks: Callbacks | None = None,
|
||||
tool_interceptors: list[ToolCallInterceptor] | None = None,
|
||||
server_name: str | None = None,
|
||||
) -> list[BaseTool]:
|
||||
"""Load all available MCP tools and convert them to LangChain [tools](https://docs.langchain.com/oss/python/langchain/tools).
|
||||
|
||||
Args:
|
||||
session: The MCP client session. If `None`, connection must be provided.
|
||||
connection: Connection config to create a new session if session is `None`.
|
||||
callbacks: Optional `Callbacks` for handling notifications and events.
|
||||
tool_interceptors: Optional list of interceptors for tool call processing.
|
||||
server_name: Name of the server these tools belong to.
|
||||
|
||||
Returns:
|
||||
List of LangChain [tools](https://docs.langchain.com/oss/python/langchain/tools).
|
||||
Tool annotations are returned as part of the tool metadata object.
|
||||
|
||||
Raises:
|
||||
ValueError: If neither session nor connection is provided.
|
||||
"""
|
||||
if session is None and connection is None:
|
||||
msg = "Either a session or a connection config must be provided"
|
||||
raise ValueError(msg)
|
||||
|
||||
mcp_callbacks = (
|
||||
callbacks.to_mcp_format(context=CallbackContext(server_name=server_name))
|
||||
if callbacks is not None
|
||||
else _MCPCallbacks()
|
||||
)
|
||||
|
||||
if session is None:
|
||||
# If a session is not provided, we will create one on the fly
|
||||
if connection is None:
|
||||
msg = "Either session or connection must be provided"
|
||||
raise ValueError(msg)
|
||||
async with create_session(
|
||||
connection, mcp_callbacks=mcp_callbacks
|
||||
) as tool_session:
|
||||
await tool_session.initialize()
|
||||
tools = await _list_all_tools(tool_session)
|
||||
else:
|
||||
tools = await _list_all_tools(session)
|
||||
|
||||
return [
|
||||
convert_mcp_tool_to_langchain_tool(
|
||||
session,
|
||||
tool,
|
||||
connection=connection,
|
||||
callbacks=callbacks,
|
||||
tool_interceptors=tool_interceptors,
|
||||
server_name=server_name,
|
||||
)
|
||||
for tool in tools
|
||||
]
|
||||
|
||||
|
||||
def _get_injected_args(tool: BaseTool) -> list[str]:
|
||||
"""Extract field names with InjectedToolArg annotation from tool schema.
|
||||
|
||||
Args:
|
||||
tool: LangChain tool to inspect.
|
||||
|
||||
Returns:
|
||||
List of field names marked as injected arguments.
|
||||
"""
|
||||
|
||||
def _is_injected_arg_type(type_: type) -> bool:
|
||||
"""Check if type annotation contains InjectedToolArg."""
|
||||
return any(
|
||||
isinstance(arg, InjectedToolArg)
|
||||
or (isinstance(arg, type) and issubclass(arg, InjectedToolArg))
|
||||
for arg in get_args(type_)[1:]
|
||||
)
|
||||
|
||||
return [
|
||||
field
|
||||
for field, field_info in get_all_basemodel_annotations(tool.args_schema).items()
|
||||
if _is_injected_arg_type(field_info)
|
||||
]
|
||||
|
||||
|
||||
def to_fastmcp(tool: BaseTool) -> FastMCPTool:
|
||||
"""Convert LangChain tool to FastMCP tool.
|
||||
|
||||
Args:
|
||||
tool: LangChain tool to convert.
|
||||
|
||||
Returns:
|
||||
FastMCP tool equivalent.
|
||||
|
||||
Raises:
|
||||
TypeError: If args_schema is not BaseModel subclass.
|
||||
NotImplementedError: If tool has injected arguments.
|
||||
"""
|
||||
if not issubclass(tool.args_schema, BaseModel):
|
||||
msg = (
|
||||
"Tool args_schema must be a subclass of pydantic.BaseModel. "
|
||||
"Tools with dict args schema are not supported."
|
||||
)
|
||||
raise TypeError(msg)
|
||||
|
||||
parameters = tool.tool_call_schema.model_json_schema()
|
||||
field_definitions = {
|
||||
field: (field_info.annotation, field_info)
|
||||
for field, field_info in tool.tool_call_schema.model_fields.items()
|
||||
}
|
||||
arg_model = create_model(
|
||||
f"{tool.name}Arguments", **field_definitions, __base__=ArgModelBase
|
||||
)
|
||||
fn_metadata = FuncMetadata(arg_model=arg_model)
|
||||
|
||||
# We'll use an Any type for the function return type.
|
||||
# We're providing the parameters separately
|
||||
async def fn(**arguments: dict[str, Any]) -> Any: # noqa: ANN401
|
||||
return await tool.ainvoke(arguments)
|
||||
|
||||
injected_args = _get_injected_args(tool)
|
||||
if len(injected_args) > 0:
|
||||
msg = "LangChain tools with injected arguments are not supported"
|
||||
raise NotImplementedError(msg)
|
||||
|
||||
return FastMCPTool(
|
||||
fn=fn,
|
||||
name=tool.name,
|
||||
description=tool.description,
|
||||
parameters=parameters,
|
||||
fn_metadata=fn_metadata,
|
||||
is_async=True,
|
||||
)
|
||||
Reference in New Issue
Block a user