feat(mcp): support mcp 2.x alongside 1.x - #3030
Conversation
`mcp>=1.28.1,<2.0.0` left chainlit as the only package in many dependency graphs holding the MCP SDK below 2.0, released 2026-07-28 and now at 2.1.1. Relax the cap to `<3.0.0` and migrate the streamable-http call site, the only part of the surface that actually changed: - `streamablehttp_client` (removed in 2.0.0) becomes `streamable_http_client`. The new name has existed since 1.24.0 — below the current floor — and its signature is identical across 1.28.1, 1.29.1 and 2.1.1, so one code path serves both major lines with no conditional imports or version sniffing. - It takes a ready-made `http_client=` instead of a factory, so the client is built here and entered into the connection's `AsyncExitStack`: the transport only closes a client it created itself. It uses `Timeout(30, read=300)`, matching what the removed wrapper passed the factory — the factory's bare 30s default would cut a long-lived GET stream at 30s. - `mcp>=2` dispatches with `httpx2` rather than `httpx`, and the two are not interchangeable at runtime, so the SSRF guard (`follow_redirects=False` plus the per-request destination hook) is built from whichever module the installed SDK uses, resolved from the SDK itself rather than from whatever happens to be importable. `sse_client` keeps `headers=` and `httpx_client_factory=` in 2.x, so that call site is unchanged. No dependency-floor changes are needed: chainlit's constraints are ranges, and pydantic, anyio and starlette resolve to the same versions under either MCP line. The lockfile stays on 1.29.1, so this unblocks 2.x without moving anyone onto it. Tests: the guards pinned to the removed symbol now assert `http_client` on `streamable_http_client`, the httpx-typed assertions follow the resolved module, and TestConnectMcpBindsDestination gains a streamable-http case proving the bound client reaches the transport — dropping `http_client=` fails it. Backend suite passes against both 1.29.1 and 2.1.1. Closes Chainlit#3002 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
a46f8ef to
2eddc6e
Compare
There was a problem hiding this comment.
All reported issues were addressed across 5 files (changes from recent commits).
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
dokterbob
left a comment
There was a problem hiding this comment.
FYI: Initial automated review. TLDR; make 2.x the default and ensure it runs in CI and drop support for 1.x as we cannot run both in CI and thus ensure compatibility.
Reviewed by independently installing mcp 1.28.1, 1.29.1 and 2.1.1 and exercising the new code against a live MCP server on both transports, rather than checking the diff against the description.
What holds up
The central mechanism is correct. On mcp 2.1.1, mcp/shared/_httpx_utils.py does a bare import httpx2 (not aliased), so getattr(_httpx_utils, "httpx2", None) resolves; on 1.28.1 and 1.29.1 the attribute is absent and it falls back to httpx. And streamable_http_client(url, *, http_client=, terminate_on_close=) is byte-identical at the declared >=1.28.1 floor, so one code path really does serve both lines — the "no conditional imports, no version sniffing" claim checks out.
Every existing test mocks the transport, so nothing in the suite proves the httpx2 client is actually accepted by the 2.x transport. I ran that end-to-end against a real MCPServer on 2.1.1:
| streamable-http | SSE | |
|---|---|---|
| httpx2 client accepted by transport | ✅ | ✅ |
follow_redirects |
False |
False |
| timeout | connect=30, read=300 |
transport-supplied |
| custom header reaches the wire | ✅ | ✅ |
initialize + call_tool |
✅ | ✅ |
terminate_on_close DELETE through our client |
✅ 200 | n/a |
| off-origin destination blocked | ✅ | ✅ |
The DELETE returning 200 confirms the exit-stack ordering argument in the server.py comment.
One point worth adding to the PR description, because it strengthens the case: in 2.x sse_client calls client.sse(url), a method that exists on httpx2.AsyncClient and not on httpx. Resolving the module correctly isn't a type-hygiene nicety — SSE hard-fails with AttributeError if _mcp_http_module() ever guesses wrong. Good argument for reading it off the SDK.
Two things the diff doesn't mention that turn out to be safe:
- 2.x
streamable_http_clientyields a 2-tuple where 1.x yields a 3-tuple (theget_session_idcallable is gone). The existingtransport[:2]absorbs that. httpx0.28 vshttpx22.12 URL parsing showed zero divergence across 13 traversal / IDN / userinfo / port / encoded-separator cases, sovalidate_mcp_urlcontinuing to parse with chainlit's ownhttpxis not a hazard.
Tests reproduce as described: 146 pass on both SDK lines, 938 on the full backend suite under 1.29.1. Lint, format and mypy are clean on the changed files.
Findings
1. CI never runs mcp 2.x, so this PR's own safety net is inert
This is the one that matters.
tests.yaml runs uv sync against uv.lock, which still pins mcp 1.29.1; httpx2 appears zero times in the lockfile. So test_resolved_http_module_matches_what_the_sdk_builds — added explicitly so that "an SDK that changes the binding fails loudly in CI instead of degrading silently" — compares httpx against httpx and passes vacuously on every CI leg. It cannot fail. The same is true of the httpx2 branch of _mcp_http_module() and of every assertion in TestMakeMcpStreamableHttpClient.
This isn't theoretical. mcp 2.1.1 declares requires-python >=3.10, and I confirmed it resolves on 3.10, so every Python version in the CI matrix can land on 2.x. Once this merges, a fresh pip install chainlit gets mcp 2.x — meaning the untested path is what new users actually receive, while CI only ever exercises the path that existing lockfile users are already on.
The fix is cheap: one additional job or matrix leg that runs uv pip install 'mcp>=2,<3' after sync and then pytest tests/test_mcp.py. I confirmed that is green today, so it lands as a passing guard rather than a red build.
2. Stale comment at backend/chainlit/mcp.py:141
Parse the request URL through httpx, which is what the MCP transports dispatch with.
Under 2.x they dispatch with httpx2. The comment now asserts exactly the thing the rest of this PR was written to deny. The code is fine and shouldn't change (see the zero-divergence result above) — but the comment should say that it has been checked, otherwise the next reader "fixes" it by switching to _mcp_http_module().URL.
3. fake_streamable_http_client yields a 3-tuple
test_mcp.py:987 yields (AsyncMock(), AsyncMock(), AsyncMock()), matching 1.x; real 2.x yields two values. Production code is safe via transport[:2], but the mock can't catch someone reintroducing transport[2]. Low severity.
4. test_destination_hook_is_wired builds an httpx.Request
It feeds a chainlit-flavour httpx.Request to a hook that receives an httpx2.Request under 2.x. It passes because the hook only reads str(request.url), but it doesn't exercise the real flavour. Only really matters in combination with #1.
5. Release-note item, not a defect
Widening to <3.0.0 means apps importing mcp.server.fastmcp can be silently resolved onto 2.x, where that module raises ModuleNotFoundError (FastMCP was renamed to MCPServer). Chainlit itself doesn't touch it, so this isn't a chainlit bug — but it's a predictable support burden and worth a line in the release notes. This repo updates CHANGELOG.md at release time rather than per-PR, so nothing is needed in this PR itself.
Verdict
Approve once #1 is addressed. The migration itself is careful and correct — the client-ownership, read-timeout and exit-stack reasoning all hold up under live conditions, and the httpx/httpx2 split is handled the right way round. But shipping 2.x support whose entire 2.x-specific surface is unreachable by CI means the next SDK release can break it silently, which is precisely the failure mode this PR set out to prevent.
Addresses dokterbob's review on PR Chainlit#3030, verified by live testing against mcp 2.1.1 rather than by inspection: - Add a CI job that overrides the locked mcp with >=2,<3 and runs tests/test_mcp.py. The existing matrix always resolves mcp 1.29.1 from the lockfile, so the httpx2 branch of _mcp_http_module() and the guard added specifically to catch an SDK rebinding (test_resolved_http_module_matches_what_the_sdk_builds) never ran under mcp 2.x and passed vacuously. Verified both ways locally: the full suite passes with mcp>=2 actually installed, and breaking the httpx2 branch makes exactly that guard fail under this job while it would have stayed green under the old matrix. - Update the comment at validate_mcp_url's httpx.URL parse to say the httpx/httpx2 equivalence has been checked, not assert it as if httpx were still the only flavour in play. - fake_streamable_http_client now yields a 2-tuple, matching what streamable_http_client actually returns under mcp>=2 (the 1.x get_session_id callable is gone). Production code already only reads transport[:2], but the 3-tuple mock couldn't catch a stray transport[2] the way the real 2.x transport would. - test_destination_hook_is_wired now builds its httpx.Request through _mcp_http_module(), so it exercises the request flavour the client actually hands the hook under mcp>=2 instead of always httpx. - Use the _MCP_HTTP_TIMEOUT constant instead of a duplicated 30.0 literal in the factory's default (cubic). Full backend suite (938 tests), lint, format and mypy all clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Thank you for testing this live against all three SDK versions rather than reviewing the diff cold — that's exactly the kind of check I couldn't do justice to alone, and #1 is a real gap, addressed in 4628edc. #1 — CI never runs mcp 2.x. Added a
#2 — stale comment. Reworded to state the httpx/httpx2 equivalence has been verified (traversal/IDN/userinfo/port/encoded-separator, zero divergence) rather than asserting httpx as if it were still the only flavour in play. #3 — 3-tuple mock. #4 — request flavour. #5 — release note. Agreed this isn't a chainlit bug; not touching Full backend suite (938), lint, format and mypy all clean after these changes. |
There was a problem hiding this comment.
1 issue found across 3 files (changes from recent commits).
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="backend/chainlit/mcp.py">
<violation number="1" location="backend/chainlit/mcp.py:141">
P2: On mcp>=2 the SSRF guard parses the request URL with chainlit's httpx while the transport dispatches with httpx2, so the allowlist check and the wire can diverge. Previously the comment guaranteed the guard parsed "with what the MCP transports dispatch with"; this change drops that invariant and relies on undocumented cross-library parsing equivalence over edge cases (and future httpx2 versions). Parse with `_mcp_http_module().URL(url)` in validate_mcp_url instead, keeping the guard tied to the same library the guarded client actually dispatches with.</violation>
</file>
Tip: Review your code locally with the cubic CLI to iterate faster.
Re-trigger cubic
| # 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 |
There was a problem hiding this comment.
P2: On mcp>=2 the SSRF guard parses the request URL with chainlit's httpx while the transport dispatches with httpx2, so the allowlist check and the wire can diverge. Previously the comment guaranteed the guard parsed "with what the MCP transports dispatch with"; this change drops that invariant and relies on undocumented cross-library parsing equivalence over edge cases (and future httpx2 versions). Parse with _mcp_http_module().URL(url) in validate_mcp_url instead, keeping the guard tied to the same library the guarded client actually dispatches with.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At backend/chainlit/mcp.py, line 141:
<comment>On mcp>=2 the SSRF guard parses the request URL with chainlit's httpx while the transport dispatches with httpx2, so the allowlist check and the wire can diverge. Previously the comment guaranteed the guard parsed "with what the MCP transports dispatch with"; this change drops that invariant and relies on undocumented cross-library parsing equivalence over edge cases (and future httpx2 versions). Parse with `_mcp_http_module().URL(url)` in validate_mcp_url instead, keeping the guard tied to the same library the guarded client actually dispatches with.</comment>
<file context>
@@ -138,9 +138,12 @@ def validate_mcp_url(url: str, allowed_urls: list[str]) -> None:
- # 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
+ # (httpx on mcp<2, httpx2 on mcp>=2). Deliberate: URL parsing showed zero
+ # divergence between the two across traversal, IDN, userinfo, port and
</file context>
|
Finding 1 is done in 4628edc. There's an I mutation-tested the leg rather than assume it closes the gap. Making So the assertion you flagged as vacuous now has something that can fail it. Findings 2, 3 and 4 are in the same commit. The comment in Your On the TLDR, the reason you gave for dropping 1.x was that both lines can't run in CI, and that held until this leg. Both run on every PR now. Going 2.x-only would also move the floor from |
The problem is that we don't have bandwidth to maintain support for multiple versions of dependencies. Nor can we afford to run tests with all permutations of all packages. I am ok not raising the required floor to 2+ but I would suggest:
Simultaneously, we could deprecate mcp 1 support, perhaps even firing a DeprecationWarning if users have it installed. If/when it starts creating problems for actual users, it does mean we will simply raise the floor rather than support 2 versions of this dep. |
Follows dokterbob's direction on PR Chainlit#3030: chainlit should not carry the cost of maintaining and testing two SDK lines. - uv lock -P mcp moves the lockfile to mcp 2.1.1, so the existing matrix resolves 2.x on every job instead of 1.29.1. The lock also moves pydantic (2.11.10 -> 2.13.5), semantic-kernel and azure-ai-agents, which mcp 2.x forces: semantic-kernel depends on mcp and had to move to a release that accepts it. - Drop the mcp-2x-compat job added in 4628edc. It existed only because the lock pinned 1.x and the 2.x codepaths were therefore never exercised; with 2.x as the default resolve, it is a second permutation buying nothing. - Warn on import when the resolved SDK is still 1.x. The floor stays at >=1.28.1 so nobody pinned there breaks today, but 1.x is no longer covered by CI and the intent is to raise the floor later, so users get notice first. - Cast the factory and client handed to sse_client and streamable_http_client. Their runtime flavour follows the installed SDK (httpx under 1.x, httpx2 under 2.x) and is not knowable statically; with the lock on 2.x, mypy now checks against the httpx2 signatures and flagged the httpx-flavoured annotations. Verified on the new lock: 938 backend tests, lint, format and mypy all clean. test_mcp.py's 146 tests pass under both mcp 1.29.1 and 2.1.1, so dropping the compat job is not hiding a break in the still-supported floor. The warning fires under 1.29.1 and is silent under 2.1.1.
|
That works for me, and it's done. The lock is on mcp 2.1.1 now, so the existing matrix resolves 2.x on every job. Worth flagging that The For the deprecation, One thing the bump surfaced. With mypy now checking against 2.x, the httpx-flavoured annotations on the factory and client handed to 938 backend tests, 32 frontend, lint, format, mypy and actionlint all clean on the new lock. |
There was a problem hiding this comment.
All reported issues were addressed across 4 files (changes from recent commits).
Tip: Review your code locally with the cubic CLI to iterate faster.
Re-trigger cubic
The notice added in 9abed5e did not work. Two problems, both real, found by cubic on Chainlit#3030 and reproduced against mcp 1.29.1 before changing anything. Python's default filters hide DeprecationWarning outside __main__, so raising one from a library module meant the notice never reached the 1.x users it was written for. `python -c "import chainlit"` on mcp 1.29.1 printed nothing. The original verification was wrong rather than the finding: it used warnings.simplefilter("always"), which overrode the very filters that suppress the notice in a real run, so it proved the warning was emitted and not that anyone would see it. Worse, the warning fired while chainlit was being imported, since callbacks.py imports mcp.py. Under PYTHONWARNINGS=error a deprecation notice became a hard import failure for exactly the 1.x users it targets, which breaks this repo's backward-compatibility rule. Logging instead of warning fixes both: it is visible by default and can never raise. Moved to the MCP connection path so it costs nothing for apps that never use MCP, and emitted once so repeated connections do not repeat it. Verified on mcp 1.29.1: the notice appears on a plain logging setup, and `import chainlit` succeeds with warnings-as-errors scoped to it. Silent on 2.1.1. Tests cover both the visibility and the import-safety property, so this cannot regress the way the first attempt did. 944 backend tests, lint, format, mypy and actionlint clean.
|
Two of cubic's three are real, and the deprecation notice I added in 9abed5e simply did not work. Fixed in 28030b8. The The import-time point is the more serious one. Logging fixes both, since it is visible by default and cannot raise. It now runs from the MCP connection path rather than at import, so it costs nothing for apps that never touch MCP, and it fires once. On 1.29.1 the notice shows up on a plain logging setup and On cubic's third point I would rather have your call than make it myself. It notes that with the lock on 2.x nothing exercises the 1.x paths that 944 backend tests, 32 frontend, lint, format, mypy and actionlint clean. |
Closes #3002.
mcp>=1.28.1,<2.0.0leaves chainlit as the only package in many dependency graphs holding the MCP SDK below 2.0 (released 2026-07-28, now 2.1.1). This relaxes the cap to<3.0.0and migrates the one call site that actually needed it.Both major lines stay supported — the lockfile still resolves
mcp==1.29.1, so this unblocks 2.x without moving anyone onto it.What changed
streamablehttp_client→streamable_http_client(server.py). The deprecated wrapper was removed in 2.0.0. The new name has existed since 1.24.0, below the currentmcp>=1.28.1floor, and its signature is identical across 1.28.1, 1.29.1 and 2.1.1:so one code path serves both lines — no conditional imports, no version sniffing. This is what the
NOTEleft at the old call site anticipated.Client ownership.
streamable_http_clienttakes a ready-madehttp_client=instead of a factory, and only closes a client it created itself (if not client_provided:). The client is now built here and entered into the connection's existingAsyncExitStack, before the transport, so it is closed last —terminate_on_closesends its DELETE through it while the transport shuts down.Read timeout. The removed wrapper called the factory with
Timeout(30, read=300). Withhttp_client=that becomes ours to set, and falling through to the factory's bare 30s default would have cut a long-lived GET stream at 30s instead of 300s.make_mcp_streamable_http_clientsets it explicitly and a test pins it.httpx → httpx2, and the SSRF guard.
mcp>=2dispatches withhttpx2; the upstream migration guide is explicit that the two are "not interchangeable at runtime". So the guard inchainlit/mcp.py(follow_redirects=Falseplus the per-requestcheck_destinationhook) has to be built from whichever library the installed SDK uses, or it would be attached to a client the transport rejects._mcp_http_module()reads that choice off the SDK's own factory module rather than importing whichever library happens to be present —httpx2can be installed for unrelated reasons whilemcpis still on 1.x, and guessing wrong fails at connect time.make_mcp_streamable_http_clienttakes the factory already bound to this connection's destination grant rather than re-deriving it, so the streamable-http guard cannot drift from the one the SSE path uses.Why resolving the module matters concretely. In 2.x
sse_clientcallsclient.sse(url). That method exists onhttpx2.AsyncClientand not onhttpx.AsyncClient(verified against httpx 0.28.1 and httpx2 2.12.0), so if_mcp_http_module()ever resolved to the wrong one, SSE would not degrade quietly, it would raiseAttributeErrorat connect time. Reading the choice off the SDK is what keeps that from happening.sse_clientis unchanged. It keepsheaders=andhttpx_client_factory=in 2.x (only the underlying types move tohttpx2), so that call site needed no edit.No dependency-floor changes
Contrary to what the issue suggested, nothing needs bumping — chainlit's constraints are ranges, not pins, so the resolver satisfies mcp 2.x on its own.
pydantic(2.13.4),anyio(4.14.2) andstarletteresolve to the same versions undermcp<2.0.0as undermcp>=2.0.0;httpx-ssedrops out,httpx2andmcp-typescome in, and chainlit's ownhttpxstays alongsidehttpx2.Tests
Backend suite passes against both SDK lines:
tests/test_mcp.pymcp==1.29.1mcp==2.1.1¹ two modules skipped in that env for unrelated missing optional deps (
google.auth,asyncpg).Test changes, all driven by the migration:
test_streamablehttp_client_accepts_httpx_client_factory_kwargasserted a parameter that no longer exists in 2.x. It now assertshttp_clientonstreamable_http_client— same regression guard, current symbol.test_resolved_http_module_matches_what_the_sdk_builds, pinning_mcp_http_module()against the clientcreate_mcp_http_client()actually returns, so an SDK that changes the binding fails loudly in CI instead of degrading silently.httpx.Timeout/httpx.Authnow build from the resolved module, since the client flavour follows the SDK.mock_mcp_transportcaptureshttp_clientfor the streamable-http transport.TestMakeMcpStreamableHttpClient: redirects disabled, the 300s read budget, headers applied (there is noheaders=on the transport any more), and the destination hook wired.TestConnectMcpBindsDestinationgains a streamable-http case. That class exists to prove the guard reaches the transport, and it previously only covered the SSE factory; the grant now rides in onhttp_client=, which carries the same bypass risk if dropped. Verified non-vacuous: removinghttp_client=fails this test and nothing else.uv run scripts/lint.py,scripts/format.py --checkandscripts/type_check.pyare all clean.Notes for review
AGENTS.md._mcp_http_module()readsmcp.shared._httpx_utils, a private module. That is deliberate — it is the module the SDK builds its clients from, so it is the only source that cannot disagree with reality — and the pinned test above turns any upstream restructuring into a CI failure rather than a runtime surprise.chainlit/mcp.pyalready documented mirroringcreate_mcp_http_client.AGENTS.mdwas not available in this environment (no Context7/Serena/GitHub MCP), so the 2.x surface was verified by introspectingmcp1.28.1, 1.29.1 and 2.1.1 directly on Python 3.13, cross-checked against the upstream v2 migration guide.Summary by cubic
Moves chainlit onto
mcp2.x by default while keeping 1.x working. The dependency cap moves from<2.0.0to<3.0.0, the lockfile now resolvesmcp2.1.1, and the removedstreamablehttp_clientwrapper is replaced withstreamable_http_client. Anyone still resolvingmcp1.x gets a deprecation notice logged once on first MCP use. Closes #3002.What changed
httpxon 1.x,httpx2on 2.x), so redirect blocking and destination checks stay correct on both lines.http_client=path and enters the client into the connection exit stack so shutdown requests use the same guarded client.mcp2.1.1 also movespydantic,semantic-kernel, andazure-ai-agents; the temporary 2.x-only CI job is gone since 2.x now runs everywhere.DeprecationWarning, so it is visible by default and cannot break import underPYTHONWARNINGS=error.Written for commit 28030b8. Summary will update on new commits.