Skip to content

feat: support MCP SDK 2.x alongside 1.x - #33

Merged
IceRhymers merged 3 commits into
IceRhymers:masterfrom
npiesco:feat/mcp-2.0-support
Aug 1, 2026
Merged

feat: support MCP SDK 2.x alongside 1.x#33
IceRhymers merged 3 commits into
IceRhymers:masterfrom
npiesco:feat/mcp-2.0-support

Conversation

@npiesco

@npiesco npiesco commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

What

Lifts the mcp<2 cap and makes the proxy work on both MCP SDK 1.x and 2.x.

b861f51 capped the dependency at mcp>=1.8,<2, which keeps installs working but leaves 2.x unsupported. On 2.0 the proxy fails at startup:

ValueError: not enough values to unpack (expected 3, got 2)
  File ".../uc_mcp_proxy/__main__.py", line 180, in run

Because the version range is only capped in the published metadata, uvx uc-mcp-proxy resolves mcp freely for anyone who already has a 2.x-compatible environment, and the failure surfaces as an unhandled ExceptionGroup traceback rather than a diagnosis.

Why it is more than the unpack

SDK 2.0 changed three things this proxy depends on:

1.x 2.0
HTTP client library httpx httpx2
streamable_http_client yield (read, write, get_session_id) (read, write)
SessionMessage.message JSONRPCMessage root model the JSON-RPC model itself

The third one is the one that bites silently: inject_meta reached through message.message.root, which no longer exists, so --meta injection would break at runtime rather than at import.

How

src/uc_mcp_proxy/_compat.py resolves the HTTP library by reading it back off mcp.client.streamable_http instead of importing a guessed name:

for name in ("httpx2", "httpx"):
    module = getattr(_sdk_streamable_http, name, None)
    if isinstance(module, ModuleType):
        return module

try: import httpx2 would have been wrong. Both libraries install side by side — httpx2 does not replace httpx, and databricks-sdk still pulls httpx in — so an SDK 1.x user with httpx2 present would get a client built from a library that their SDK never imported. Since the proxy owns the client it hands to streamable_http_client (the design in CLAUDE.md § Error handling), that mismatch surfaces as a type error deep in the SDK's request path instead of at import time. Asking the SDK which module it bound is the only answer that cannot drift.

  • The dropped third yield element is absorbed by a starred unpack — it was bound to _get_session_id and never used.
  • bridge() / copy_stream() now take structural stream protocols, because 1.x hands out anyio memory streams and 2.0 hands out its own context-carrying wrappers. Naming either concrete class type-checks against one SDK and fails on the other.
  • No direct import httpx remains for anything crossing the SDK boundary; those go through the shim so every request, response and transport object comes from the library the installed SDK actually uses.

The boundary matters in both directions. Rebasing onto the PAT-exchange merge (#27) surfaced this: token_exchange.py builds its own synchronous httpx.Client for the RFC 8693 call and never hands it to the SDK, so it correctly keeps importing httpx directly. Blanket-swapping the test file's import broke seven exchange tests — a MockTransport from the other library is silently not used, and the exchange then tried to reach the real network:

uc-mcp-proxy: could not reach the workspace token endpoint.
  endpoint: https://test-workspace.cloud.databricks.com/oidc/v1/token

test_http_errors_e2e.py now imports both deliberately: httpx (shim) for SDK-boundary objects, exchange_httpx (plain) for the exchange transport. CLAUDE.md documents which side a new import belongs on.

tests/support.py builds messages by validating a raw wire payload through the SDK's own schema (TypeAdapter(JSONRPCMessage)), which accepts both the root-model and union shapes and keeps the JSON that actually goes on the wire as the source of truth.

CI gains a test-sdk-majors job. The lockfile only ever proves whichever major it happens to hold, so each supported major is resolved and exercised explicitly (unit tests + mypy). Without it this regresses the moment the lock moves.

Verification

Run against both mcp==1.29.0 and mcp==2.0.0:

  • make test — 411 passed
  • make check — ruff lint, ruff format, mypy strict, all clean

Plus a live end-to-end run outside the suite: the real uc-mcp-proxy subprocess against a real MCP Streamable HTTP server over a real localhost socket, with real DatabricksAuth header injection — initialize, tools/list, and a tools/call round-trip all verified on both SDK majors. Nothing mocked.

The existing test_http_errors_e2e.py suite is what caught the SessionMessage change: 28 tests went red on 2.0 before the fix, which is why this is not just a one-line unpack patch.

Rebased onto 1f57152 (post-#27).

Notes for review

  • Two commits, separable. 0f2fa1e (test:) is a drive-by and unrelated to SDK 2.0 — it carries SystemRoot/SystemDrive into the subprocess test's env. That env is built from scratch so a developer's real DATABRICKS_* cannot reach the child, but on Windows that also drops SystemRoot, and winsock then fails to initialize: the child dies importing asyncio's proactor loop with WinError 10106 before reaching the code under test. No effect on Linux CI. Happy to drop it into its own PR if you'd rather keep this one focused.
  • httpx is left in dependencies. It is still correct for SDK 1.x, and on 2.x httpx2 arrives transitively through mcp. A static dep list cannot express "httpx if mcp<2 else httpx2".
  • Type annotations are checked against the httpx stubs, which describe httpx2 accurately for every name used here. The one place the two genuinely cannot be reconciled statically — passing the client into the SDK — is an explicit cast with a comment.
  • Unrelated pre-existing flake, not addressed: test_proxy_process_exits_on_401_with_stdin_still_open is timing-sensitive and fails intermittently on slow filesystems, taking the _report fallback path or exceeding its 30s budget. I reproduced it on unmodified master with mcp==1.29.0, so it predates this change and I left it alone.

…on Windows

test_proxy_process_exits_on_401_with_stdin_still_open builds the child's
environment from scratch so a developer's real DATABRICKS_* settings cannot
reach it. On Windows that also drops SystemRoot, and without it winsock
cannot initialize: the child dies importing asyncio's proactor event loop
with OSError WinError 10106 before it ever reaches the code under test, so
the test fails for a reason unrelated to what it asserts.

Carries SystemRoot and SystemDrive through while keeping the env otherwise
built from scratch. No behaviour change on Linux, where CI runs.
@npiesco
npiesco force-pushed the feat/mcp-2.0-support branch from 88a8b00 to 51033d6 Compare July 31, 2026 02:53
SDK 2.0 changed three things this proxy depends on, so the dependency was
capped at <2 rather than ported:

* the HTTP client library moved from httpx to httpx2
* streamable_http_client yields (read, write), dropping get_session_id
* SessionMessage.message is the JSON-RPC model itself, no longer wrapped
  in the JSONRPCMessage root model

Adds src/uc_mcp_proxy/_compat.py, which resolves the HTTP library by reading
it back off mcp.client.streamable_http rather than importing a guessed name.
httpx and httpx2 install side by side -- httpx2 does not replace httpx, and
databricks-sdk still pulls httpx in -- so `try: import httpx2` would hand an
SDK 1.x transport a client built from a library that SDK never imported.
Because the proxy owns the client it passes to the transport, that mismatch
surfaces as a type error deep in the SDK request path, not at import time.

The dropped third yield element is absorbed by a starred unpack; it was bound
to _get_session_id and never used. bridge() and copy_stream() now take
structural stream protocols, since 1.x hands out anyio memory streams and 2.0
hands out its own context-carrying wrappers.

The boundary matters in both directions. Objects the SDK will see come from
_compat; the RFC 8693 token exchange is the proxy's own request and never
crosses that boundary, so token_exchange keeps importing httpx directly and
its tests build httpx transports to match. A MockTransport from the other
library is silently not used, and the exchange then tries to reach the real
network -- which is how this was caught.

Tests build messages through tests/support.py, validating a raw wire payload
through the SDK's own schema rather than a hand-built object graph, so the
same test reads correctly on both majors.

CI gains a test-sdk-majors job: the lockfile only ever proves one major, so
each supported major is resolved and exercised explicitly.

Verified on mcp 1.29.0 and 2.0.0: unit suite (411 passed), mypy, and a live
end-to-end run of the real proxy subprocess against a real MCP Streamable
HTTP server over a real localhost socket (initialize, tools/list, tools/call
round-trip).
@npiesco
npiesco force-pushed the feat/mcp-2.0-support branch from 51033d6 to 5299374 Compare July 31, 2026 03:40
@IceRhymers
IceRhymers self-requested a review July 31, 2026 21:19
Comment thread pyproject.toml Outdated
requires-python = ">=3.10"
dependencies = [
"mcp>=1.8,<2",
"mcp>=1.8",

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Blocking: this removes the only major cap. The shim is tested for 1.x/2.x, but a future 3.x would now resolve automatically. Please use mcp>=1.24,<3: 1.24 is the actual lower API requirement, and <3 limits installs to the majors this PR supports.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in commit 4b1adf9. The MCP dependency is now bounded to >=1.24,<3, matching the actual API floor and the two supported SDK majors. Please re-review.

@IceRhymers

Copy link
Copy Markdown
Owner

Blocking follow-up on pyproject.toml:16: the retry invariants are capped for httpx, but SDK 2.x passes an httpx2 client, whose transitive dependency is unbounded. Please add httpx2>=2.5,<3 (or an equivalent conditional dependency) and extend the rationale/coverage, so a future httpx2 release cannot silently break retries.

Copilot-Session: e4671a15-63e3-40bc-a13d-4677903f8b2f
@npiesco

npiesco commented Aug 1, 2026

Copy link
Copy Markdown
Contributor Author

Fixed the httpx2 follow-up in commit 4b1adf9. httpx2 is now a direct dependency bounded to >=2.5,<3. The rationale and retry-invariant coverage now apply to both HTTP implementations, and CI upgrades and tests the corresponding latest supported HTTP client in each SDK-major lane instead of relying on the lockfile. Please re-review.

@IceRhymers
IceRhymers self-requested a review August 1, 2026 04:12

@IceRhymers IceRhymers left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Thank you for the contribution!

@IceRhymers
IceRhymers merged commit 7c63cca into IceRhymers:master Aug 1, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants