MCP Client Transport and Connectivity#
MCPClient (api/core/mcp/mcp_client.py) is the single entry point for all outbound MCP server communication in Dify. It supports two transport backends β SSE (sse_client) and Streamable-HTTP (streamablehttp_client) β and manages their lifetimes via a contextlib.ExitStack .
Transport Selection and Fallback#
Protocol selection happens in _initialize(). The logic is a two-step heuristic:
-
URL-path detection. The last path segment of the server URL is matched against a
connection_methodsdict :- Ends in
/mcpβstreamablehttp_client - Ends in
/sseβsse_client
- Ends in
-
Ambiguous-path fallback. If the segment matches neither key, the current code at
cd61738ftries SSE first, falling back to Streamable-HTTP only onMCPConnectionErrororValueError.
β οΈ Known bug in the fallback order (as of this snapshot). The
_initializedocstring says "fallback to SSE if streamable connection fails", meaning Streamable-HTTP should be the default. The actualelsebranch does the opposite β tries SSE first β and the fallback exception handling was too narrow before PR #42177.httpxtransport errors (ConnectError,ReadTimeout,RemoteProtocolError, etc.) leaked out ofsse_clientunchanged, preventing fallback to Streamable-HTTP and surfacing as opaque 500 responses .
Three fixes have been proposed/merged:
| PR | Status | Change |
|---|---|---|
| PR #39344 | Closed | Prefer streamable-http for unknown paths; re-raise MCPAuthError; fall back to SSE on any other exception |
| PR #40111 | Merged | Try streamable-http first; catch httpx.TransportError during fallback; re-raise MCPAuthError without retry |
| PR #42177 | Merged | Wrap httpx.RequestError in MCPConnectionError within sse_client so fallback logic and error mapping work correctly |
After these fixes, the corrected fallback order for ambiguous URLs is:
streamablehttp_client β (on transport error) β sse_client
β (on MCPAuthError) β re-raise immediately
Stream unpacking: streamablehttp_client returns a 3-tuple (read_stream, write_stream, _); sse_client returns a 2-tuple. connect_server() handles both cases .
Timeout Configuration#
Two timeout parameters are accepted by MCPClient.__init__ and forwarded to the transport factory in connect_server():
| Parameter | Purpose | Transport default (when None) |
|---|---|---|
timeout | Connection / general HTTP timeout | sse_client: 5 s Β· streamablehttp_client: 30 s |
sse_read_timeout | SSE stream read timeout | sse_client: 60 s Β· streamablehttp_client: 300 s |
Both are persisted per-provider in the MCPToolProvider database model (api/models/tools.py) and surfaced via the tool-provider API. Improved timeout handling was introduced in PR #23546 .
The full config flow is:
API controller β MCPToolManageService β MCPToolProvider (DB) β MCPTool β MCPClient β transport
Header Forwarding#
MCPClient supports dynamic header values. At construction time, any header value matching {{ request.headers.<name> }} is resolved against the active Flask request context . This enables per-request credential forwarding to MCP servers that require upstream authentication headers.
Error Handling and Exception Translation#
sse_client wraps low-level transport failures in MCPConnectionError to maintain a consistent error contract across the stack. PR #42177 introduced a catch block for httpx.RequestError (which includes ConnectError, ConnectTimeout, ReadTimeout, WriteTimeout, PoolTimeout, RemoteProtocolError, and other transport-layer failures). Without this translation:
- The
MCPClient._initialize()fallback logic fails because it only catchesMCPConnectionErrorandValueError - The console MCP endpoints (
ToolMCPAuthApi, etc.) cannot map the error to a 4xx response with a readable message, so users see{"message":"Internal Server Error","code":"unknown","status":500}instead of the actual connection failure reason
The catch block in sse_client.py re-raises transport errors as:
raise MCPConnectionError(f"Failed to connect to SSE endpoint: {exc}") from exc
This preserves the original error message (connection refused, timeout, DNS failure, etc.) while allowing callers to handle it uniformly. A similar fix in auth_flow.py wraps HTTPStatusError from failed dynamic client registration in ValueError so the auth endpoint can return a proper 400 response instead of a 500.
Key Source Files#
| File | Purpose |
|---|---|
api/core/mcp/mcp_client.py | MCPClient β transport selection, fallback, session setup |
api/core/mcp/client/sse_client.py | SSE transport implementation |
api/core/mcp/client/streamable_client.py | Streamable-HTTP transport implementation |
api/services/tools/mcp_tools_manage_service.py | Provider CRUD; timeout/header plumbing to MCPClient |
api/models/tools.py (MCPToolProvider) | Persists timeout and sse_read_timeout columns |
api/tests/unit_tests/core/mcp/test_mcp_client.py | Unit tests for transport selection and fallback |
Related Issues and PRs#
- Issue #39301 β
httpx.ReadTimeoutnot caught; SSE-first fallback blocks authorization for 300 s - Issue #40001 β
httpx.RemoteProtocolErrorescaping as 500 during MCP auth - PR #42177 β Wrap
httpx.RequestErrorinMCPConnectionErrorwithinsse_client; wrapHTTPStatusErrorinValueErrorinauth_flow.py - PR #40111 β Canonical fix: prefer streamable-http, catch
httpx.TransportError - PR #39344 β Earlier community fix, same intent, closed in favour of #40111
- PR #29960 β Added
ValueErrorto the caught exceptions (RFC 9728 discovery) - PR #23546 β Improved timeout handling
- PR #22645 β Introduced path-lookup + fallback structure