mirror of
https://github.com/mcp-use/mcp-use.git
synced 2026-09-14 18:06:01 +08:00
b45d9663ec
* fix(python): expose client capabilities to MCP servers Add list_roots_callback support to Python connectors to properly advertise the roots capability during MCP initialization handshake. Previously, the Python library was not exposing any client capabilities because the list_roots_callback was not being passed to ClientSession. This fix ensures the roots capability is always advertised, bringing the Python library in line with the TypeScript implementation. Changes: - Add roots and list_roots_callback params to BaseConnector - Add get_roots() and set_roots() methods for managing roots - Pass list_roots_callback to ClientSession in all connectors - Export Root type from connectors module - Update unit tests to expect list_roots_callback parameter Closes MCP-1063 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat(python): add roots and list_roots_callback params to connectors - Add roots and list_roots_callback parameters to StdioConnector, HttpConnector, and SandboxConnector constructors - Add comprehensive unit tests for client capabilities exposure - Tests verify roots capability, get_roots(), set_roots(), and that all callbacks are correctly passed to ClientSession 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * chore: fix import sorting and update API documentation - Fix import sorting in test_client_capabilities.py (ruff) - Regenerate API documentation for updated connector classes 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat(python): add list_roots() method to server Context Allow MCP servers to request the list of roots from connected clients via ctx.list_roots(). This follows the same pattern as ctx.sample() and ctx.elicit() for server-to-client requests. * feat(python): add roots params to MCPClient and config - Add roots and list_roots_callback parameters to MCPClient.__init__, from_dict(), and from_config_file() methods - Update create_connector_from_config() to pass roots params to all connector types (Stdio, HTTP, Sandbox) - Allows clients to configure roots at the MCPClient level and have them propagate to all sessions * test(python): add get_client_roots tool to primitive server Add a tool that demonstrates the roots capability by requesting and returning the list of roots from the connected client. Used for integration testing of the roots feature. * test(python): add integration tests for roots capability Add comprehensive integration tests for the roots feature: - test_roots_capability: Verify server can request/receive roots - test_roots_empty_by_default: Verify empty roots when not provided - test_roots_with_custom_callback: Verify custom callback precedence - test_set_roots_updates_server_view: Verify dynamic root updates * test(python): update unit tests for roots parameters Update mock assertions in test_client.py to expect the new roots and list_roots_callback parameters in create_connector_from_config calls. * ci(python): add roots to primitive tests and README badge - Add roots to CI workflow primitive test matrix - Add roots to PR comment report primitives list - Add Roots badge to README Primitives row * feat(docs): enhance MCP client and server documentation for roots feature - Added `roots` and `list_roots_callback` parameters to MCP client initialization documentation. - Updated `create_connector_from_config` documentation to include new parameters. - Introduced `list_roots` method documentation in server context, detailing its usage and return values. - Improved overall clarity and completeness of API reference for better developer guidance. * feat(python): add get_client_roots tool to context example - Introduced a new tool, `get_client_roots`, to the context example, demonstrating how to retrieve and display the list of roots exposed by the client. - Updated documentation to include the new tool and its functionality, enhancing the example's coverage of context features. * feat(docs): add roots documentation for client and server - Introduced new documentation for the `roots` feature, detailing how clients can expose file system roots to MCP servers and how servers can access these roots. - Added examples for configuring roots in the MCP client and using the `list_roots()` method in the server context. - Enhanced overall clarity and usability of the documentation to support developers in implementing the roots functionality. --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> Co-authored-by: pietrozullo <pietro.zullo@gmail.com>
350 lines
15 KiB
Python
350 lines
15 KiB
Python
"""
|
|
HTTP connector for MCP implementations.
|
|
|
|
This module provides a connector for communicating with MCP implementations
|
|
through HTTP APIs with SSE or Streamable HTTP for transport.
|
|
"""
|
|
|
|
from typing import Any
|
|
|
|
import httpx
|
|
from mcp import ClientSession
|
|
from mcp.client.session import ElicitationFnT, ListRootsFnT, LoggingFnT, MessageHandlerFnT, SamplingFnT
|
|
from mcp.shared.exceptions import McpError
|
|
from mcp.types import Root
|
|
|
|
from mcp_use.client.auth.oauth import BearerAuth, OAuth, OAuthClientProvider
|
|
from mcp_use.client.connectors.base import BaseConnector
|
|
from mcp_use.client.exceptions import OAuthAuthenticationError, OAuthDiscoveryError
|
|
from mcp_use.client.middleware import CallbackClientSession, Middleware
|
|
from mcp_use.client.task_managers import SseConnectionManager, StreamableHttpConnectionManager
|
|
from mcp_use.logging import logger
|
|
|
|
|
|
class HttpConnector(BaseConnector):
|
|
"""Connector for MCP implementations using HTTP transport with SSE or streamable HTTP.
|
|
|
|
This connector uses HTTP/SSE or streamable HTTP to communicate with remote MCP implementations,
|
|
using a connection manager to handle the proper lifecycle management.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
base_url: str,
|
|
headers: dict[str, str] | None = None,
|
|
timeout: float = 5,
|
|
sse_read_timeout: float = 60 * 5,
|
|
auth: str | dict[str, Any] | httpx.Auth | None = None,
|
|
sampling_callback: SamplingFnT | None = None,
|
|
elicitation_callback: ElicitationFnT | None = None,
|
|
message_handler: MessageHandlerFnT | None = None,
|
|
logging_callback: LoggingFnT | None = None,
|
|
middleware: list[Middleware] | None = None,
|
|
verify: bool | None = True,
|
|
roots: list[Root] | None = None,
|
|
list_roots_callback: ListRootsFnT | None = None,
|
|
):
|
|
"""Initialize a new HTTP connector.
|
|
|
|
Args:
|
|
base_url: The base URL of the MCP HTTP API.
|
|
headers: Optional additional headers.
|
|
timeout: Timeout for HTTP operations in seconds.
|
|
sse_read_timeout: Timeout for SSE read operations in seconds.
|
|
auth: Authentication method - can be:
|
|
- A string token: Use Bearer token authentication
|
|
- A dict with OAuth config: {"client_id": "...", "client_secret": "...", "scope": "..."}
|
|
- An httpx.Auth object: Use custom authentication
|
|
sampling_callback: Optional sampling callback.
|
|
elicitation_callback: Optional elicitation callback.
|
|
message_handler: Optional callback to handle messages.
|
|
logging_callback: Optional callback to handle log messages.
|
|
middleware: Optional list of middleware.
|
|
verify: Whether to verify SSL certificates.
|
|
roots: Optional initial list of roots to advertise to the server.
|
|
list_roots_callback: Optional custom callback to handle roots/list requests.
|
|
"""
|
|
super().__init__(
|
|
sampling_callback=sampling_callback,
|
|
elicitation_callback=elicitation_callback,
|
|
message_handler=message_handler,
|
|
logging_callback=logging_callback,
|
|
middleware=middleware,
|
|
roots=roots,
|
|
list_roots_callback=list_roots_callback,
|
|
)
|
|
self.base_url = base_url.rstrip("/")
|
|
self.headers = headers or {}
|
|
self.timeout = timeout
|
|
self.sse_read_timeout = sse_read_timeout
|
|
self._auth: httpx.Auth | None = None
|
|
self._oauth: OAuth | None = None
|
|
self.verify = verify
|
|
|
|
# Handle authentication
|
|
if auth is not None:
|
|
self._set_auth(auth)
|
|
|
|
def _set_auth(self, auth: str | dict[str, Any] | httpx.Auth) -> None:
|
|
"""Set authentication method.
|
|
|
|
Args:
|
|
auth: Authentication method - can be:
|
|
- A string token: Use Bearer token authentication
|
|
- A dict with OAuth config: {"client_id": "...", "client_secret": "...", "scope": "..."}
|
|
- An httpx.Auth object: Use custom authentication
|
|
"""
|
|
if isinstance(auth, str):
|
|
# Treat as bearer token
|
|
self._auth = BearerAuth(token=auth)
|
|
self.headers["Authorization"] = f"Bearer {auth}"
|
|
elif isinstance(auth, dict):
|
|
# Check if this is an OAuth provider configuration
|
|
if "oauth_provider" in auth:
|
|
oauth_provider = auth["oauth_provider"]
|
|
if isinstance(oauth_provider, dict):
|
|
oauth_provider = OAuthClientProvider(**oauth_provider)
|
|
self._oauth = OAuth(
|
|
self.base_url,
|
|
scope=auth.get("scope"),
|
|
client_id=auth.get("client_id"),
|
|
client_secret=auth.get("client_secret"),
|
|
callback_port=auth.get("callback_port"),
|
|
client_metadata_url=auth.get("client_metadata_url"),
|
|
oauth_provider=oauth_provider,
|
|
)
|
|
self._oauth_config = auth
|
|
else:
|
|
self._oauth = OAuth(
|
|
self.base_url,
|
|
scope=auth.get("scope"),
|
|
client_id=auth.get("client_id"),
|
|
client_secret=auth.get("client_secret"),
|
|
callback_port=auth.get("callback_port"),
|
|
client_metadata_url=auth.get("client_metadata_url"),
|
|
)
|
|
self._oauth_config = auth
|
|
elif isinstance(auth, httpx.Auth):
|
|
self._auth = auth
|
|
else:
|
|
raise ValueError(f"Invalid auth type: {type(auth)}")
|
|
|
|
async def connect(self) -> None:
|
|
"""Establish a connection to the MCP implementation."""
|
|
if self._connected:
|
|
logger.debug("Already connected to MCP implementation")
|
|
return
|
|
|
|
# Handle OAuth if needed
|
|
if self._oauth:
|
|
try:
|
|
# Create a temporary client for OAuth metadata discovery
|
|
async with httpx.AsyncClient(verify=self.verify) as client:
|
|
bearer_auth = await self._oauth.initialize(client)
|
|
if not bearer_auth:
|
|
# Need to perform OAuth flow
|
|
logger.info("OAuth authentication required")
|
|
bearer_auth = await self._oauth.authenticate()
|
|
|
|
# Update auth and headers
|
|
self._auth = bearer_auth
|
|
self.headers["Authorization"] = f"Bearer {bearer_auth.token.get_secret_value()}"
|
|
except OAuthDiscoveryError:
|
|
# OAuth discovery failed - it means server doesn't support OAuth default urls
|
|
logger.debug("OAuth discovery failed, continuing without initialization.")
|
|
self._oauth = None
|
|
self._auth = None
|
|
except OAuthAuthenticationError as e:
|
|
logger.error(f"OAuth initialization failed: {e}")
|
|
raise
|
|
|
|
# Try streamable HTTP first (new transport), fall back to SSE (old transport)
|
|
# This implements backwards compatibility per MCP specification
|
|
self.transport_type = None
|
|
connection_manager = None
|
|
|
|
# Create custom httpx factory
|
|
httpx_client_factory = self._build_httpx_factory()
|
|
|
|
try:
|
|
# First, try the new streamable HTTP transport
|
|
logger.debug(f"Attempting streamable HTTP connection to: {self.base_url}")
|
|
connection_manager = StreamableHttpConnectionManager(
|
|
self.base_url,
|
|
self.headers,
|
|
self.timeout,
|
|
self.sse_read_timeout,
|
|
auth=self._auth,
|
|
httpx_client_factory=httpx_client_factory,
|
|
)
|
|
|
|
# Test if this is a streamable HTTP server by attempting initialization
|
|
read_stream, write_stream = await connection_manager.start()
|
|
|
|
# Test if this actually works by trying to create a client session and initialize it
|
|
raw_test_client = ClientSession(
|
|
read_stream,
|
|
write_stream,
|
|
sampling_callback=self.sampling_callback,
|
|
elicitation_callback=self.elicitation_callback,
|
|
list_roots_callback=self.list_roots_callback,
|
|
message_handler=self._internal_message_handler,
|
|
logging_callback=self.logging_callback,
|
|
client_info=self.client_info,
|
|
)
|
|
await raw_test_client.__aenter__()
|
|
|
|
# Wrap test client with middleware temporarily for testing
|
|
test_client = CallbackClientSession(raw_test_client, self.public_identifier, self.middleware_manager)
|
|
|
|
try:
|
|
# Try to initialize - this is where streamable HTTP vs SSE difference should show up
|
|
result = await test_client.initialize()
|
|
logger.debug(f"Streamable HTTP initialization result: {result}")
|
|
|
|
# If we get here, streamable HTTP works
|
|
self.client_session = test_client
|
|
self.transport_type = "streamable HTTP"
|
|
self._initialized = True # Mark as initialized since we just called initialize()
|
|
|
|
# Populate tools, resources, and prompts since we've initialized
|
|
server_capabilities = result.capabilities
|
|
|
|
if server_capabilities.tools:
|
|
# Get available tools directly from client session
|
|
tools_result = await self.client_session.list_tools()
|
|
self._tools = tools_result.tools if tools_result else []
|
|
else:
|
|
self._tools = []
|
|
|
|
if server_capabilities.resources:
|
|
# Get available resources directly from client session
|
|
resources_result = await self.client_session.list_resources()
|
|
self._resources = resources_result.resources if resources_result else []
|
|
else:
|
|
self._resources = []
|
|
|
|
if server_capabilities.prompts:
|
|
# Get available prompts directly from client session
|
|
prompts_result = await self.client_session.list_prompts()
|
|
self._prompts = prompts_result.prompts if prompts_result else []
|
|
else:
|
|
self._prompts = []
|
|
|
|
# Only McpError is raised from client's initialization because
|
|
# exceptions are handled internally.
|
|
except McpError as mcp_error:
|
|
logger.error("MCP protocol error during initialization: %s", mcp_error.error)
|
|
# Clean up the test client
|
|
try:
|
|
await raw_test_client.__aexit__(None, None, None)
|
|
except Exception:
|
|
pass
|
|
raise mcp_error
|
|
|
|
except Exception as init_error:
|
|
# This catches non-McpError exceptions, like a direct httpx timeout
|
|
# but in the most cases this won't happen. It's for safety.
|
|
try:
|
|
await raw_test_client.__aexit__(None, None, None)
|
|
except Exception:
|
|
pass
|
|
raise init_error
|
|
|
|
# Exception from the inner try is propagated here and in
|
|
# the most cases is an McpError, so checking instances is useless
|
|
except Exception as streamable_error:
|
|
logger.debug(f"Streamable HTTP failed: {streamable_error}")
|
|
|
|
# Clean up the failed streamable HTTP connection manager
|
|
if connection_manager:
|
|
try:
|
|
await connection_manager.close()
|
|
except Exception:
|
|
pass
|
|
|
|
# It doesn't make sense to check error types. Because client
|
|
# always return a McpError, if he can't reach the server
|
|
# because it's offline, or if it has an auth problem.
|
|
should_fallback = True
|
|
|
|
if should_fallback:
|
|
try:
|
|
# Fall back to the old SSE transport
|
|
logger.debug(f"Attempting SSE fallback connection to: {self.base_url}")
|
|
connection_manager = SseConnectionManager(
|
|
self.base_url,
|
|
self.headers,
|
|
self.timeout,
|
|
self.sse_read_timeout,
|
|
auth=self._auth,
|
|
httpx_client_factory=httpx_client_factory,
|
|
)
|
|
|
|
read_stream, write_stream = await connection_manager.start()
|
|
|
|
# Create the client session for SSE
|
|
raw_client_session = ClientSession(
|
|
read_stream,
|
|
write_stream,
|
|
sampling_callback=self.sampling_callback,
|
|
elicitation_callback=self.elicitation_callback,
|
|
list_roots_callback=self.list_roots_callback,
|
|
message_handler=self._internal_message_handler,
|
|
logging_callback=self.logging_callback,
|
|
client_info=self.client_info,
|
|
)
|
|
await raw_client_session.__aenter__()
|
|
|
|
# Wrap with middleware
|
|
self.client_session = CallbackClientSession(
|
|
raw_client_session, self.public_identifier, self.middleware_manager
|
|
)
|
|
self.transport_type = "SSE"
|
|
|
|
except* Exception as sse_error:
|
|
# Get the exception from the ExceptionGroup, and here we will get the correct type.
|
|
sse_error = sse_error.exceptions[0]
|
|
if isinstance(sse_error, httpx.HTTPStatusError) and sse_error.response.status_code in [
|
|
401,
|
|
403,
|
|
407,
|
|
]:
|
|
raise OAuthAuthenticationError(
|
|
f"Server requires authentication (HTTP {sse_error.response.status_code}) "
|
|
"but auth failed. Please provide auth configuration manually."
|
|
) from sse_error
|
|
logger.error(
|
|
f"Both transport methods failed. Streamable HTTP: {streamable_error}, SSE: {sse_error}"
|
|
)
|
|
raise sse_error
|
|
else:
|
|
raise streamable_error
|
|
|
|
# Store the successful connection manager and mark as connected
|
|
self._connection_manager = connection_manager
|
|
self._connected = True
|
|
logger.debug(f"Successfully connected to MCP implementation via {self.transport_type}: {self.base_url}")
|
|
|
|
def _build_httpx_factory(self):
|
|
verify = self.verify
|
|
|
|
def factory(
|
|
headers: dict[str, str] | None = None, timeout: httpx.Timeout | None = None, auth: httpx.Auth | None = None
|
|
) -> httpx.AsyncClient:
|
|
return httpx.AsyncClient(
|
|
headers=headers,
|
|
timeout=timeout or httpx.Timeout(30.0),
|
|
auth=auth,
|
|
verify=verify,
|
|
follow_redirects=True,
|
|
)
|
|
|
|
return factory
|
|
|
|
@property
|
|
def public_identifier(self) -> str:
|
|
"""Get the identifier for the connector."""
|
|
transport_type = getattr(self, "transport_type", "http")
|
|
return f"{transport_type}:{self.base_url}"
|