Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
126 changes: 118 additions & 8 deletions backend/chainlit/mcp.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
from importlib.metadata import version
from types import ModuleType
from typing import Callable, Dict, Literal, Optional, Union
from urllib.parse import unquote, urlparse

import httpx
from pydantic import BaseModel

from chainlit.logger import logger


class StdioMcpConnection(BaseModel):
name: str
Expand Down Expand Up @@ -137,9 +141,12 @@ def validate_mcp_url(url: str, allowed_urls: list[str]) -> None:
"Configure features.mcp.user_servers.allowed_urls in your config."
)

# Parse the request URL through httpx, which is what the MCP transports
# dispatch with. Validating anything else risks approving a URL that
# differs from the one that actually goes on the wire.
# Parse with chainlit's own httpx rather than the installed SDK's flavour
Comment thread
aniketwaghh marked this conversation as resolved.
# (httpx on mcp<2, httpx2 on mcp>=2). Deliberate: URL parsing showed zero
# divergence between the two across traversal, IDN, userinfo, port and
# encoded-separator cases, and validating anything other than what the
# transport actually dispatches with risks approving a URL that differs
# from the one that goes on the wire.
try:
parsed = httpx.URL(url)
except Exception as exc:
Expand Down Expand Up @@ -242,6 +249,72 @@ def check(url: str) -> None:
_MCP_CONNECT_TIMEOUT_STDIO = 120.0 # npx -y can cold-download on first run
_MCP_CONNECT_TIMEOUT_HTTP = 30.0

# The SDK's own transport defaults, from
# ``mcp.shared._httpx_utils.create_mcp_http_client``: 30s for
# connect/write/pool and 300s for read, the long read budget being what keeps
# a long-lived SSE or streamable-http GET stream from being cut mid-stream.
# Mirrored rather than imported because they live in a private module.
_MCP_HTTP_TIMEOUT = 30.0
_MCP_HTTP_SSE_READ_TIMEOUT = 300.0


_mcp_1x_notice_emitted = False


def warn_if_mcp_1x() -> None:
"""Log once, on first MCP use, when the resolved SDK is still on 1.x.

chainlit supports ``mcp>=1.28.1,<3.0.0`` but only resolves and tests
against 2.x. 1.x still works today and the floor is deliberately left
where it is, so this is a heads-up rather than a breakage: the intent is
to raise the floor to ``>=2`` once 1.x stops being worth carrying, and
anyone pinned below that wants notice before it happens.

Logged rather than raised as a ``DeprecationWarning`` for two reasons.
Python's default filters hide ``DeprecationWarning`` outside ``__main__``,
so from a library module the notice would never reach the users it is for.
And a warning raised while ``chainlit`` is being imported becomes an
exception under ``PYTHONWARNINGS=error``, which would turn a deprecation
notice into a hard import failure for the 1.x users it targets. Called
from the MCP connection path so it costs nothing for apps that never use
MCP.
"""
global _mcp_1x_notice_emitted

if _mcp_1x_notice_emitted:
return
installed = version("mcp")
if not installed.startswith("1."):
return

_mcp_1x_notice_emitted = True
logger.warning(
"chainlit resolved mcp %s. Support for mcp<2 is deprecated and will be "
"removed in a future release; pin mcp>=2 to stay ahead of it.",
installed,
)


def _mcp_http_module() -> ModuleType:
"""Return the HTTP library the installed MCP SDK dispatches with.

``mcp<2`` builds its clients from ``httpx``; ``mcp>=2`` moved to
``httpx2``. Those are separate distributions with separate class
hierarchies, and a transport only accepts a client built from its own, so
the guarded client below has to come from whichever one the installed SDK
uses.

Resolved from the SDK's own factory module rather than by importing
whichever library happens to be present: ``httpx2`` can be installed for
unrelated reasons while ``mcp`` is still on 1.x, and guessing wrong would
hand the transport a client it rejects. ``test_mcp.py`` pins this against
what ``create_mcp_http_client`` actually returns, so an SDK that changes
the binding fails loudly instead of silently degrading.
"""
from mcp.shared import _httpx_utils

return getattr(_httpx_utils, "httpx2", None) or httpx


def make_mcp_http_client_factory(
check_destination: Callable[[str], None],
Expand Down Expand Up @@ -275,10 +348,19 @@ def make_mcp_http_client_factory(
threaded through here) so this module stays free of asyncio primitives
and stays synchronously testable.

Passed as ``httpx_client_factory=`` to ``sse_client`` and
``streamablehttp_client``. That parameter is present unchanged across the
supported ``mcp>=1.11.0,<2.0.0`` range.
Passed as ``httpx_client_factory=`` to ``sse_client``, and used to build
the ``http_client=`` handed to ``streamable_http_client`` (see
``make_mcp_streamable_http_client``). Both parameters are present
unchanged across the supported ``mcp>=1.28.1,<3.0.0`` range.

The client is built from whichever HTTP library the installed SDK
dispatches with, not from ``httpx`` directly — ``mcp>=2`` uses ``httpx2``,
and the two are not interchangeable at runtime. The annotations below name
the ``httpx`` classes because that is the flavour chainlit itself depends
on; the ``httpx2`` equivalents are API-compatible for everything used
here.
"""
http = _mcp_http_module()

def factory(
headers: Optional[Dict[str, str]] = None,
Expand All @@ -293,12 +375,40 @@ async def _check_request(request: httpx.Request) -> None:
on_blocked(exc)
raise

return httpx.AsyncClient(
return http.AsyncClient(
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
follow_redirects=False,
timeout=timeout if timeout is not None else httpx.Timeout(30.0),
timeout=timeout if timeout is not None else http.Timeout(_MCP_HTTP_TIMEOUT),
headers=headers,
auth=auth,
event_hooks={"request": [_check_request]},
)

return factory


def make_mcp_streamable_http_client(
factory: McpHttpClientFactory,
headers: Optional[Dict[str, str]] = None,
) -> httpx.AsyncClient:
"""Build the guarded client passed to ``streamable_http_client``.

``streamable_http_client`` takes a ready-made ``http_client=`` rather than
the factory the deprecated ``streamablehttp_client`` wrapper used, so the
timeout that wrapper used to supply has to be supplied here: it called the
factory with ``Timeout(30, read=300)``, and leaving the factory's own
30-second default in place would cut a long-lived GET stream at 30s.

Takes the factory already bound to this connection's destination grant
rather than re-deriving it, so the guard cannot drift from the one the SSE
path uses.

The caller owns the returned client: ``streamable_http_client`` only closes
a client it created itself, so this one must be closed by whoever passes it
(``server.py`` enters it into the connection's ``AsyncExitStack``).
"""
http = _mcp_http_module()

return factory(
headers=headers,
timeout=http.Timeout(_MCP_HTTP_TIMEOUT, read=_MCP_HTTP_SSE_READ_TIMEOUT),
)
36 changes: 27 additions & 9 deletions backend/chainlit/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
import webbrowser
from contextlib import AsyncExitStack, asynccontextmanager
from pathlib import Path
from typing import TYPE_CHECKING, Dict, List, Optional, Union, cast
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union, cast

import socketio
from fastapi import (
Expand Down Expand Up @@ -1433,7 +1433,7 @@ async def connect_mcp(
get_default_environment,
stdio_client,
)
from mcp.client.streamable_http import streamablehttp_client
from mcp.client.streamable_http import streamable_http_client

from chainlit.config import SseMcpServer, StdioMcpServer, StreamableHttpMcpServer
from chainlit.context import init_ws_context
Expand All @@ -1449,11 +1449,15 @@ async def connect_mcp(
_destination_in_allowlist,
_destination_on_origin,
make_mcp_http_client_factory,
make_mcp_streamable_http_client,
validate_mcp_headers,
validate_mcp_url,
warn_if_mcp_1x,
)
from chainlit.session import McpSession, WebsocketSession, stop_mcp_task

warn_if_mcp_1x()

session = WebsocketSession.get_by_id(payload.sessionId)
context = init_ws_context(session)
config: ChainlitConfig = session.get_config()
Expand Down Expand Up @@ -1658,7 +1662,13 @@ async def _mcp_session_runner() -> None:
sse_client(
url=mcp_connection.url,
headers=mcp_connection.headers,
httpx_client_factory=mcp_http_client_factory,
# The factory builds its client from whichever HTTP
# library the installed SDK dispatches with, which is
# only known at runtime (see _mcp_http_module). Under
# mcp>=2 that is httpx2, so the httpx-flavoured
# annotation no longer matches the SDK's parameter
# type even though the object is the right one.
httpx_client_factory=cast(Any, mcp_http_client_factory),
)
)
elif isinstance(mcp_connection, StdioMcpConnection):
Expand All @@ -1676,14 +1686,22 @@ async def _mcp_session_runner() -> None:
)
elif isinstance(mcp_connection, HttpMcpConnection):
assert mcp_http_client_factory is not None
# NOTE: streamablehttp_client is deprecated from mcp 1.24.0
# (renamed streamable_http_client, taking http_client= instead
# of a factory) and removed in 2.0.0 — update this on bump.
# streamable_http_client takes a ready-made client rather
# than a factory, and only closes one it created itself, so
# this one is ours to close. Entered before the transport so
# the exit stack unwinds it last — terminate_on_close sends a
# DELETE through it while the transport is shutting down.
http_client = await exit_stack.enter_async_context(
make_mcp_streamable_http_client(
mcp_http_client_factory,
headers=mcp_connection.headers,
)
)
transport = await exit_stack.enter_async_context(
streamablehttp_client(
streamable_http_client(
url=mcp_connection.url,
headers=mcp_connection.headers,
httpx_client_factory=mcp_http_client_factory,
# Same runtime-flavour mismatch as the SSE branch.
http_client=cast(Any, http_client),
)
)
else:
Expand Down
2 changes: 1 addition & 1 deletion backend/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ dependencies = [
"aiofiles>=23.1.0",
"syncer>=2.0.3,<3.0.0",
"asyncer>=0.0.8,<0.1.0",
"mcp>=1.28.1,<2.0.0",
"mcp>=1.28.1,<3.0.0",
"nest-asyncio>=1.6.0,<2.0.0",
"click>=8.1.3,<9.0.0",
"tomli>=2.0.1,<3.0.0",
Expand Down
Loading