Skip to content

feat(mcp): support mcp 2.x alongside 1.x - #3030

Open
aniketwaghh wants to merge 4 commits into
Chainlit:mainfrom
aniketwaghh:fix/mcp-2x-support
Open

feat(mcp): support mcp 2.x alongside 1.x#3030
aniketwaghh wants to merge 4 commits into
Chainlit:mainfrom
aniketwaghh:fix/mcp-2x-support

Conversation

@aniketwaghh

@aniketwaghh aniketwaghh commented Aug 27, 2026

Copy link
Copy Markdown

Closes #3002.

mcp>=1.28.1,<2.0.0 leaves 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.0 and 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_clientstreamable_http_client (server.py). The deprecated wrapper was removed in 2.0.0. The new name has existed since 1.24.0, below the current mcp>=1.28.1 floor, and its signature is identical across 1.28.1, 1.29.1 and 2.1.1:

streamable_http_client(url, *, http_client=None, terminate_on_close=True)

so one code path serves both lines — no conditional imports, no version sniffing. This is what the NOTE left at the old call site anticipated.

Client ownership. streamable_http_client takes a ready-made http_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 existing AsyncExitStack, before the transport, so it is closed last — terminate_on_close sends its DELETE through it while the transport shuts down.

Read timeout. The removed wrapper called the factory with Timeout(30, read=300). With http_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_client sets it explicitly and a test pins it.

httpx → httpx2, and the SSRF guard. mcp>=2 dispatches with httpx2; the upstream migration guide is explicit that the two are "not interchangeable at runtime". So the guard in chainlit/mcp.py (follow_redirects=False plus the per-request check_destination hook) 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 — httpx2 can be installed for unrelated reasons while mcp is still on 1.x, and guessing wrong fails at connect time.

make_mcp_streamable_http_client takes 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_client calls client.sse(url). That method exists on httpx2.AsyncClient and not on httpx.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 raise AttributeError at connect time. Reading the choice off the SDK is what keeps that from happening.

sse_client is unchanged. It keeps headers= and httpx_client_factory= in 2.x (only the underlying types move to httpx2), 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) and starlette resolve to the same versions under mcp<2.0.0 as under mcp>=2.0.0; httpx-sse drops out, httpx2 and mcp-types come in, and chainlit's own httpx stays alongside httpx2.

Tests

Backend suite passes against both SDK lines:

tests/test_mcp.py full backend suite
mcp==1.29.1 146 passed 938 passed
mcp==2.1.1 146 passed 921 passed¹

¹ 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_kwarg asserted a parameter that no longer exists in 2.x. It now asserts http_client on streamable_http_client — same regression guard, current symbol.
  • Added test_resolved_http_module_matches_what_the_sdk_builds, pinning _mcp_http_module() against the client create_mcp_http_client() actually returns, so an SDK that changes the binding fails loudly in CI instead of degrading silently.
  • The three assertions that constructed httpx.Timeout / httpx.Auth now build from the resolved module, since the client flavour follows the SDK.
  • mock_mcp_transport captures http_client for the streamable-http transport.
  • New TestMakeMcpStreamableHttpClient: redirects disabled, the 300s read budget, headers applied (there is no headers= on the transport any more), and the destination hook wired.
  • TestConnectMcpBindsDestination gains 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 on http_client=, which carries the same bypass risk if dropped. Verified non-vacuous: removing http_client= fails this test and nothing else.

uv run scripts/lint.py, scripts/format.py --check and scripts/type_check.py are all clean.

Notes for review

  • All changes are backward-compatible, per AGENTS.md.
  • _mcp_http_module() reads mcp.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.py already documented mirroring create_mcp_http_client.
  • The MCP-first workflow in AGENTS.md was not available in this environment (no Context7/Serena/GitHub MCP), so the 2.x surface was verified by introspecting mcp 1.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 mcp 2.x by default while keeping 1.x working. The dependency cap moves from <2.0.0 to <3.0.0, the lockfile now resolves mcp 2.1.1, and the removed streamablehttp_client wrapper is replaced with streamable_http_client. Anyone still resolving mcp 1.x gets a deprecation notice logged once on first MCP use. Closes #3002.

What changed

  • Builds the SSRF guard's client from whichever HTTP library the SDK dispatches with (httpx on 1.x, httpx2 on 2.x), so redirect blocking and destination checks stay correct on both lines.
  • Carries the 300-second read timeout onto the new http_client= path and enters the client into the connection exit stack so shutdown requests use the same guarded client.
  • Moving the lockfile to mcp 2.1.1 also moves pydantic, semantic-kernel, and azure-ai-agents; the temporary 2.x-only CI job is gone since 2.x now runs everywhere.
  • The 1.x deprecation notice is logged rather than raised as a DeprecationWarning, so it is visible by default and cannot break import under PYTHONWARNINGS=error.
  • Adds regression coverage, including tests proving the bound client reaches the streamable-http transport and that the resolved HTTP module matches what the SDK actually builds.

Written for commit 28030b8. Summary will update on new commits.

Review in cubic

@dosubot dosubot Bot added size:L This PR changes 100-499 lines, ignoring generated files. backend Pertains to the Python backend. dependencies Pull requests that update a dependency file unit-tests Has unit tests. labels Aug 27, 2026

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found across 5 files

Re-trigger cubic

`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>

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 5 files (changes from recent commits).

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread backend/chainlit/mcp.py
@dokterbob
dokterbob requested a balanced review from Copilot August 27, 2026 20:10

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@dokterbob dokterbob left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_client yields a 2-tuple where 1.x yields a 3-tuple (the get_session_id callable is gone). The existing transport[:2] absorbs that.
  • httpx 0.28 vs httpx2 2.12 URL parsing showed zero divergence across 13 traversal / IDN / userinfo / port / encoded-separator cases, so validate_mcp_url continuing to parse with chainlit's own httpx is 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>
@aniketwaghh

Copy link
Copy Markdown
Author

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 mcp-2x-compat job in tests.yaml that overrides the locked mcp with >=2,<3 after sync (uv pip install, then uv run --no-project pytest tests/test_mcp.py so the override survives) and runs the MCP suite. Verified both directions locally before pushing:

  • With the override in place: 146 pass with mcp==2.1.1 genuinely installed.
  • Mutation check: temporarily forced _mcp_http_module() to always return httpx, simulating the exact SDK-rebinding scenario the guard exists to catch. Under this new job it fails test_resolved_http_module_matches_what_the_sdk_builds immediately; under the old matrix (always mcp==1.29.1, where httpx is the correct answer) that same mutation would have stayed green. So the job genuinely closes the gap rather than adding a check that would also pass vacuously.

#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. fake_streamable_http_client now yields a 2-tuple. Confirmed server.py only ever reads transport[:2], so this is safe, and it now means a stray transport[2] fails the test the same way it would fail against the real 2.x transport.

#4 — request flavour. test_destination_hook_is_wired now builds its httpx.Request through _mcp_http_module(), so under mcp>=2 it exercises an httpx2.Request, matching what the hook actually receives in production instead of always the chainlit-side type.

#5 — release note. Agreed this isn't a chainlit bug; not touching CHANGELOG.md per your note that this repo updates it at release time.

Full backend suite (938), lint, format and mypy all clean after these changes.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment thread backend/chainlit/mcp.py
# 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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>

Comment thread .github/workflows/tests.yaml Outdated
@aniketwaghh

Copy link
Copy Markdown
Author

Finding 1 is done in 4628edc. There's an mcp-2x-compat job in tests.yaml now that syncs as normal, then runs uv pip install 'mcp>=2,<3' and pytest backend/tests/test_mcp.py.

I mutation-tested the leg rather than assume it closes the gap. Making _mcp_http_module() return httpx unconditionally:

mcp 1.29.1  (locked, what the existing matrix runs)   146 passed
mcp 2.1.1   (what the new leg installs)               1 failed
                                                      test_resolved_http_module_matches_what_the_sdk_builds

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 mcp.py now says the parsing equivalence was checked and why chainlit's own flavour is deliberate, so the next reader doesn't "fix" it. fake_streamable_http_client yields a 2-tuple. test_destination_hook_is_wired builds its request through _mcp_http_module().Request, so it gets whichever flavour the transport would actually hand the hook.

Your client.sse(url) point is in the description now. I checked it first: .sse is on httpx2.AsyncClient 2.12.0 and absent from httpx.AsyncClient 0.28.1, so a wrong resolve raises AttributeError at connect time. That's a stronger argument than the one I had written.

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 >=1.28.1 to >=2, which breaks anyone pinned to 1.x, and it buys little here since the only 1.x-specific code left is the two lines inside _mcp_http_module(). I'm happy to do it if you still want 2.x-only, I'd rather it be its own PR than folded into this one. Say which way you want it and I'll follow.

@aniketwaghh
aniketwaghh requested a review from dokterbob August 29, 2026 01:28
@dokterbob

Copy link
Copy Markdown
Collaborator

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 >=1.28.1 to >=2, which breaks anyone pinned to 1.x, and it buys little here since the only 1.x-specific code left is the two lines inside _mcp_http_module(). I'm happy to do it if you still want 2.x-only, I'd rather it be its own PR than folded into this one. Say which way you want it and I'll follow.

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:

  1. Raising the ceiling to allow 2.0
  2. Bumping uv lock -P the version in the lock file so that CI defaults to 2.0

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.
@aniketwaghh

Copy link
Copy Markdown
Author

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 uv lock -P mcp doesn't only move mcp: pydantic goes 2.11.10 to 2.13.5, and semantic-kernel and azure-ai-agents move with it, because semantic-kernel depends on mcp and had to land on a release that accepts 2.x. That last one pulls azure-ai-agents onto a prerelease, 1.1.0 to 1.2.0b6, because semantic-kernel 1.44.0 asks for >=1.2.0b3 and nothing stable satisfies that. It only sits in the tests extra so it never reaches an installed chainlit, but you should know it's in the lock before you take it.

The mcp-2x-compat job is gone. It only existed because the lock pinned 1.x, and with 2.x as the default resolve it is exactly the second permutation you said you can't afford.

For the deprecation, chainlit/mcp.py now emits a DeprecationWarning at import when the resolved SDK is 1.x. The floor stays at >=1.28.1, so nobody pinned there breaks today. Before dropping 1.x from CI I checked it isn't already broken rather than assuming: test_mcp.py's 146 tests pass under 1.29.1 and 2.1.1 both.

One thing the bump surfaced. With mypy now checking against 2.x, the httpx-flavoured annotations on the factory and client handed to sse_client and streamable_http_client no longer match the SDK's parameter types, since the real flavour there is httpx2. That was only passing before because the lock resolved 1.x. Both are cast at the call site.

938 backend tests, 32 frontend, lint, format, mypy and actionlint all clean on the new lock.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment thread backend/chainlit/mcp.py Outdated
Comment thread backend/chainlit/mcp.py Outdated
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.
@aniketwaghh

Copy link
Copy Markdown
Author

Two of cubic's three are real, and the deprecation notice I added in 9abed5e simply did not work. Fixed in 28030b8.

The DeprecationWarning was invisible. Python's default filters hide that category outside __main__, so raising one from a library module meant no 1.x user would ever see it: python -c "import chainlit" on mcp 1.29.1 printed nothing at all. My own check had been wrong rather than the finding, since it used warnings.simplefilter("always") and so overrode the exact filters that suppress it in a real run. That proved the warning was emitted, not that anyone would see it.

The import-time point is the more serious one. callbacks.py imports mcp.py, so the warning fired during import chainlit, and with warnings-as-errors it raised instead. A deprecation notice that turns into a hard import failure for the 1.x users it is aimed at is the opposite of what it was for.

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 import chainlit succeeds with warnings-as-errors scoped to it; on 2.1.1 it stays silent. There are tests for both the visibility and the import-safety now, so it cannot regress the same way.

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 >=1.28.1 still permits, and suggests repurposing the deleted job to pin 1.x instead. That is a fair reading, but it is also the second permutation you said you cannot afford, so re-adding it under another name felt like going around you. Happy either way: leave 1.x uncovered and let the deprecation do its work, or take a 1.x leg back. For what it is worth, 1.x is not broken today, test_mcp.py passes under 1.29.1 and 2.1.1 both.

944 backend tests, 32 frontend, lint, format, mypy and actionlint clean.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

backend Pertains to the Python backend. dependencies Pull requests that update a dependency file size:L This PR changes 100-499 lines, ignoring generated files. unit-tests Has unit tests.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support mcp 2.x (Python SDK): pin blocks mcp 2.0.0

3 participants