diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dfd891d..8d1db39 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,3 +42,36 @@ jobs: - name: Build package run: uv build + + # The proxy hands its own HTTP client to the SDK's transport, and SDK 2.0 + # both renamed that library (httpx -> httpx2) and changed the transport and + # message shapes. The locked resolution only ever proves whichever major the + # lockfile happens to hold, so each supported major is resolved and tested + # explicitly here. + test-sdk-majors: + runs-on: ubuntu-latest + strategy: + matrix: + include: + - mcp-version: "mcp>=1.24,<2" + http-version: "httpx>=0.28,<0.29" + - mcp-version: "mcp>=2,<3" + http-version: "httpx2>=2.5,<3" + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - uses: astral-sh/setup-uv@e4db8464a088ece1b920f60402e813ea4de65b8f # v4 + with: + python-version: "3.13" + + - name: Install dependencies + run: uv sync + + - name: Resolve latest supported SDK and HTTP client + run: uv pip install --upgrade "${{ matrix.mcp-version }}" "${{ matrix.http-version }}" + + - name: Run unit tests + run: uv run --no-sync pytest -m unit + + - name: Typecheck + run: uv run --no-sync mypy diff --git a/CLAUDE.md b/CLAUDE.md index d446e86..65aaa31 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -39,10 +39,42 @@ Package in `src/uc_mcp_proxy/`: - `errors.py` — HTTP error diagnosis and reporting from the remote server - `token_exchange.py` — RFC 8693 exchange of a PAT for an app-scoped OAuth token - `app_discovery.py` — App-host / classic-PAT detection and lookup of an app's `oauth2_app_client_id` + scopes from workspace metadata (drives auto-exchange) +- `_compat.py` — MCP SDK major-version shim (see below) - `__init__.py` — re-exports `DatabricksAuth` The proxy bridges an MCP stdio transport to a remote Streamable HTTP MCP server, injecting Databricks OAuth tokens on every request via `DatabricksAuth`. +### SDK compatibility + +Both MCP SDK 1.x and 2.x are supported. 2.0 changed three things the proxy +depends on: the HTTP client library (`httpx` → `httpx2`), the +`streamable_http_client` yield (dropped the third `get_session_id` element), +and `SessionMessage.message` (no longer wrapped in a `JSONRPCMessage` root +model). + +The supported dependency ranges are deliberately bounded: `mcp>=1.24,<3` +matches the API floor and SDK majors exercised by the compatibility shim, while +`httpx>=0.28,<0.29` and `httpx2>=2.5,<3` prevent untested HTTP-client releases +from silently changing the retry ordering or redirect-extension behavior. The +SDK-major CI matrix runs the same retry-invariant tests against the HTTP module +actually selected by each installed MCP major. + +`_compat.py` resolves the HTTP library by reading it back off +`mcp.client.streamable_http` rather than importing a guessed name — both +libraries install side by side, 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 would surface +as a type error deep in the SDK's request path rather than at import time. + +Import `httpx` from `_compat` (tests: from `tests/support.py`) for anything +that crosses the SDK boundary — the client handed to `streamable_http_client`, +and every request, response and transport object the SDK's hooks will see. + +Code that owns a client end-to-end and never passes it to the SDK may import +`httpx` directly; `token_exchange.py` does this deliberately, since its RFC +8693 call to the token endpoint is the proxy's own HTTP request and has +nothing to do with which library the SDK bound. + ### Error handling The proxy owns the `httpx.AsyncClient` it hands to the MCP SDK, so that client's diff --git a/pyproject.toml b/pyproject.toml index de706d4..d9ad329 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,16 +4,16 @@ version = "0.5.1" description = "MCP stdio-to-Streamable-HTTP proxy with Databricks OAuth" requires-python = ">=3.10" dependencies = [ - "mcp>=1.8,<2", + "mcp>=1.24,<3", "databricks-sdk>=0.30.0", - # Bounded deliberately. The auth retry depends on two httpx behaviours that - # are implementation details rather than documented contracts: response - # event hooks running strictly before the auth flow is handed the response, - # and ``extensions`` being copied per redirect hop. A test pins both, but - # that only protects CI -- an install that resolves a newer httpx would get - # a silently broken retry and never run the suite. Raise the cap only after - # ``test_response_hook_precedes_auth_flow`` passes on the newer version. + # Bounded deliberately. The auth retry depends on two behaviours shared by + # httpx (MCP 1.x) and httpx2 (MCP 2.x) that are implementation details rather + # than documented contracts: response hooks running strictly before the auth + # flow receives the response, and ``extensions`` being copied per redirect + # hop. The SDK-major CI matrix runs the invariant tests against each actual + # library. Raise either cap only after those tests pass on the newer major. "httpx>=0.28,<0.29", + "httpx2>=2.5,<3", "anyio", ] license = "MIT" diff --git a/src/uc_mcp_proxy/__main__.py b/src/uc_mcp_proxy/__main__.py index b55e81a..5f50125 100644 --- a/src/uc_mcp_proxy/__main__.py +++ b/src/uc_mcp_proxy/__main__.py @@ -8,18 +8,17 @@ import sys import time from collections.abc import AsyncGenerator, Callable, Generator -from typing import Any, NoReturn +from typing import Any, NoReturn, cast from urllib.parse import urljoin, urlsplit import anyio -import httpx -from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream from databricks.sdk import WorkspaceClient from mcp.client.streamable_http import streamable_http_client from mcp.server.stdio import stdio_server from mcp.shared.message import SessionMessage from mcp.types import JSONRPCRequest +from uc_mcp_proxy._compat import MessageReceiveStream, MessageSendStream, httpx, jsonrpc_payload from uc_mcp_proxy.app_discovery import ( AppDiscoveryError, discover_app, @@ -239,7 +238,7 @@ async def async_auth_flow(self, request: httpx.Request) -> AsyncGenerator[httpx. yield request -async def copy_stream(source: MemoryObjectReceiveStream[Any], dest: MemoryObjectSendStream[Any]) -> None: +async def copy_stream(source: MessageReceiveStream, dest: MessageSendStream) -> None: """Copy all messages from source to dest, closing dest when source is exhausted.""" try: async for message in source: @@ -263,7 +262,7 @@ def inject_meta( # for meta params today; _meta is valid on any request per MCP spec. if isinstance(message, Exception): return message - root = message.message.root + root = jsonrpc_payload(message) if not isinstance(root, JSONRPCRequest) or root.method != "tools/call": return message if root.params is None: @@ -281,8 +280,8 @@ def inject_meta( async def inject_meta_stream( - source: MemoryObjectReceiveStream[Any], - dest: MemoryObjectSendStream[Any], + source: MessageReceiveStream, + dest: MessageSendStream, meta: dict[str, str], ) -> None: """Like copy_stream, but applies inject_meta to each forwarded message.""" @@ -294,10 +293,10 @@ async def inject_meta_stream( async def bridge( - stdio_read: MemoryObjectReceiveStream[Any], - stdio_write: MemoryObjectSendStream[Any], - http_read: MemoryObjectReceiveStream[Any], - http_write: MemoryObjectSendStream[Any], + stdio_read: MessageReceiveStream, + stdio_write: MessageSendStream, + http_read: MessageReceiveStream, + http_write: MessageSendStream, meta: dict[str, str] | None = None, ) -> None: """Bidirectional bridge between stdio and HTTP stream pairs. @@ -649,11 +648,18 @@ async def run( ) as httpx_client, streamable_http_client( resolved_url, - http_client=httpx_client, + # Cast because the SDK names its own HTTP library in this + # signature, and which library that is varies by SDK major. + # ``_compat`` guarantees the client was built from the module + # this very SDK imported; mypy only ever sees one of them. + http_client=cast(Any, httpx_client), ) as ( http_read, http_write, - _get_session_id, + # SDK 1.x yielded a third element, ``get_session_id``, which + # this proxy never used; 2.0 dropped it. Starred so the same + # unpack accepts either shape. + *_, ), ): try: diff --git a/src/uc_mcp_proxy/_compat.py b/src/uc_mcp_proxy/_compat.py new file mode 100644 index 0000000..0c0fb83 --- /dev/null +++ b/src/uc_mcp_proxy/_compat.py @@ -0,0 +1,88 @@ +"""Compatibility across MCP SDK major versions. + +SDK 2.0 changed three things this proxy depends on: + +1. the HTTP client library moved from ``httpx`` to ``httpx2``; +2. ``streamable_http_client`` yields ``(read, write)`` where 1.x yielded + ``(read, write, get_session_id)``; +3. ``SessionMessage.message`` is the JSON-RPC model itself, where 1.x wrapped + it in the ``JSONRPCMessage`` pydantic root model. + +Only (1) and (3) need a shim here -- (2) is absorbed by a starred unpack at +the one call site in ``__main__``. + +The HTTP library is read back off the SDK module rather than imported by a +guessed name. ``httpx`` and ``httpx2`` install side by side: httpx2 does not +replace httpx, and databricks-sdk and others still pull httpx in. So +``try: import httpx2`` would hand an SDK 1.x transport a client built from a +library that SDK never imported -- and because the proxy owns the client it +passes to ``streamable_http_client``, that mismatch surfaces as a type error +deep inside the SDK's request path rather than at import time. Asking the SDK +which module it bound is the only answer that cannot drift. +""" + +from __future__ import annotations + +from types import ModuleType +from typing import TYPE_CHECKING, Any, Protocol + +from mcp.client import streamable_http as _sdk_streamable_http + +__all__ = ["MessageReceiveStream", "MessageSendStream", "httpx", "jsonrpc_payload"] + +#: Module names to look for on the SDK transport, newest SDK first. +_HTTPX_MODULE_NAMES = ("httpx2", "httpx") + + +def _resolve_httpx() -> ModuleType: + """Return the HTTP client module the installed MCP SDK builds clients from.""" + for name in _HTTPX_MODULE_NAMES: + module = getattr(_sdk_streamable_http, name, None) + if isinstance(module, ModuleType): + return module + raise RuntimeError( + "uc-mcp-proxy: cannot tell which HTTP client library this MCP SDK uses. " + f"Expected mcp.client.streamable_http to import one of {_HTTPX_MODULE_NAMES}." + ) + + +if TYPE_CHECKING: + # httpx2 mirrors the httpx API for every name the proxy touches, so the + # httpx stubs describe both. Only the runtime object has to match the SDK. + import httpx +else: + httpx = _resolve_httpx() + + +def jsonrpc_payload(message: Any) -> Any: + """Return the JSON-RPC model carried by a ``SessionMessage``. + + SDK 1.x wraps it in the ``JSONRPCMessage`` root model; 2.0 stores the + ``JSONRPCRequest``/``JSONRPCNotification``/... directly. Both are reached + through the attribute that exists, so neither version is special-cased. + """ + payload = message.message + return getattr(payload, "root", payload) + + +class MessageReceiveStream(Protocol): + """The read half of a transport, as the bridge actually uses it. + + Structural on purpose. SDK 1.x hands out anyio ``MemoryObjectReceiveStream`` + objects and 2.0 hands out its own context-carrying wrappers; naming either + concrete class here would type-check against one SDK and fail on the other. + """ + + def __aiter__(self) -> Any: ... + + async def __anext__(self) -> Any: ... + + async def aclose(self) -> None: ... + + +class MessageSendStream(Protocol): + """The write half of a transport, as the bridge actually uses it.""" + + async def send(self, item: Any, /) -> None: ... + + async def aclose(self) -> None: ... diff --git a/src/uc_mcp_proxy/errors.py b/src/uc_mcp_proxy/errors.py index d52af30..7b922d1 100644 --- a/src/uc_mcp_proxy/errors.py +++ b/src/uc_mcp_proxy/errors.py @@ -29,7 +29,8 @@ from collections.abc import Sequence import anyio -import httpx + +from uc_mcp_proxy._compat import httpx class ProxyFatalError(Exception): diff --git a/tests/integration/test_proxy.py b/tests/integration/test_proxy.py index 4523d07..0e63d07 100644 --- a/tests/integration/test_proxy.py +++ b/tests/integration/test_proxy.py @@ -7,10 +7,11 @@ from unittest.mock import patch import anyio -import httpx import pytest from mcp.client.streamable_http import streamable_http_client as _real_streamable_http_client +from tests.support import httpx + pytestmark = [pytest.mark.integration, pytest.mark.anyio] diff --git a/tests/support.py b/tests/support.py new file mode 100644 index 0000000..95b6655 --- /dev/null +++ b/tests/support.py @@ -0,0 +1,33 @@ +"""Version-agnostic helpers for building SDK objects in tests. + +SDK 2.0 turned ``JSONRPCMessage`` from a pydantic root model into a plain +union alias, so ``JSONRPCMessage(...)`` is no longer callable and the +``SessionMessage.message`` it produced no longer has ``.root``. Tests build +messages by validating a raw wire payload through the SDK's own schema, which +accepts both shapes and keeps the JSON that actually goes on the wire as the +source of truth rather than a hand-built object graph. + +``httpx`` is re-exported from the proxy's compat module so tests construct +requests, responses and transports from the same library the installed SDK +uses -- otherwise ``isinstance`` checks inside the proxy would compare objects +from two different HTTP libraries. +""" + +from __future__ import annotations + +from typing import Any + +from mcp.shared.message import SessionMessage +from mcp.types import JSONRPCMessage +from pydantic import TypeAdapter + +from uc_mcp_proxy._compat import httpx, jsonrpc_payload + +__all__ = ["httpx", "jsonrpc_payload", "session_message"] + +_JSONRPC_ADAPTER: TypeAdapter[Any] = TypeAdapter(JSONRPCMessage) + + +def session_message(payload: dict[str, Any]) -> SessionMessage: + """Build a ``SessionMessage`` from a raw JSON-RPC ``payload`` dict.""" + return SessionMessage(_JSONRPC_ADAPTER.validate_python(payload)) diff --git a/tests/unit/test_auth.py b/tests/unit/test_auth.py index 3f037fa..f030cae 100644 --- a/tests/unit/test_auth.py +++ b/tests/unit/test_auth.py @@ -2,9 +2,10 @@ from __future__ import annotations -import httpx import pytest +from tests.support import httpx + pytestmark = pytest.mark.unit diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py index 377511b..f44db74 100644 --- a/tests/unit/test_cli.py +++ b/tests/unit/test_cli.py @@ -7,9 +7,10 @@ from unittest.mock import MagicMock, patch import anyio -import httpx import pytest +from tests.support import httpx + pytestmark = pytest.mark.unit diff --git a/tests/unit/test_compat.py b/tests/unit/test_compat.py new file mode 100644 index 0000000..c9a50f2 --- /dev/null +++ b/tests/unit/test_compat.py @@ -0,0 +1,95 @@ +"""Tests for the MCP SDK major-version compatibility shim. + +The shim exists because SDK 2.0 renamed the HTTP client library and flattened +``SessionMessage.message``. These tests pin the two decisions that are easy to +get subtly wrong: resolving the HTTP module from the SDK rather than by import +guess, and reaching the JSON-RPC payload through whichever shape is present. +""" + +from __future__ import annotations + +from types import ModuleType, SimpleNamespace + +import pytest +from mcp.client import streamable_http as sdk_streamable_http +from pydantic import RootModel + +from tests.support import session_message +from uc_mcp_proxy import _compat +from uc_mcp_proxy.__main__ import _build_http_client +from uc_mcp_proxy.errors import HttpErrorReporter + +pytestmark = pytest.mark.unit + + +def test_resolved_httpx_is_the_module_the_sdk_imported(): + """The proxy must build clients from the same library the SDK will use. + + Asserted against the SDK module's own binding rather than a version + string, so this keeps holding if a later SDK renames the library again. + """ + sdk_module = getattr(sdk_streamable_http, "httpx2", None) or sdk_streamable_http.httpx + + assert _compat.httpx is sdk_module + + +def test_built_client_is_an_instance_of_the_sdks_client_class(): + """The end the shim exists for: a client the installed SDK accepts.""" + reporter = HttpErrorReporter(url="https://example.com/mcp", profile="p", auth_type="pat") + + client = _build_http_client(auth=None, verify_ssl=True, reporter=reporter) + + assert isinstance(client, _compat.httpx.AsyncClient) + + +def test_resolve_httpx_rejects_an_sdk_that_imported_neither(monkeypatch): + """A future SDK on a third library must fail loudly, not silently guess.""" + monkeypatch.setattr(_compat, "_sdk_streamable_http", SimpleNamespace()) + + with pytest.raises(RuntimeError, match="which HTTP client library"): + _compat._resolve_httpx() + + +def test_resolve_httpx_ignores_non_module_attributes(monkeypatch): + """``httpx2`` bound to something that is not a module is not a match.""" + real = _compat.httpx + monkeypatch.setattr( + _compat, + "_sdk_streamable_http", + SimpleNamespace(httpx2="not-a-module", httpx=real), + ) + + resolved = _compat._resolve_httpx() + + assert isinstance(resolved, ModuleType) + assert resolved is real + + +def test_jsonrpc_payload_reads_the_installed_sdk_shape(): + """Whatever the SDK produces, the payload exposes the JSON-RPC fields.""" + message = session_message({"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {}}) + + payload = _compat.jsonrpc_payload(message) + + assert payload.method == "tools/call" + assert payload.params == {} + + +def test_jsonrpc_payload_unwraps_a_root_model(): + """The SDK 1.x shape: ``message`` is a pydantic root model wrapper. + + Built with a real ``RootModel`` so the branch is exercised even when the + installed SDK is 2.x and never produces one. + """ + inner = SimpleNamespace(method="tools/call") + message = SimpleNamespace(message=RootModel[object](inner)) + + assert _compat.jsonrpc_payload(message) is inner + + +def test_jsonrpc_payload_passes_through_a_bare_model(): + """The SDK 2.0 shape: ``message`` is the JSON-RPC model itself.""" + inner = SimpleNamespace(method="tools/call") + message = SimpleNamespace(message=inner) + + assert _compat.jsonrpc_payload(message) is inner diff --git a/tests/unit/test_http_errors.py b/tests/unit/test_http_errors.py index a974562..e4f1993 100644 --- a/tests/unit/test_http_errors.py +++ b/tests/unit/test_http_errors.py @@ -15,9 +15,10 @@ from collections.abc import AsyncIterator import anyio -import httpx import pytest +from tests.support import httpx + pytestmark = pytest.mark.unit # ``BaseExceptionGroup`` is a builtin only on 3.11+. The package supports 3.10, diff --git a/tests/unit/test_http_errors_e2e.py b/tests/unit/test_http_errors_e2e.py index 77fc189..ea7534a 100644 --- a/tests/unit/test_http_errors_e2e.py +++ b/tests/unit/test_http_errors_e2e.py @@ -28,12 +28,12 @@ from typing import Any import anyio -import httpx +import httpx as exchange_httpx import pytest from mcp.shared.message import SessionMessage -from mcp.types import JSONRPCMessage, JSONRPCNotification, JSONRPCRequest from tests.conftest import FAKE_PAT +from tests.support import httpx, session_message from uc_mcp_proxy import __main__ as main from uc_mcp_proxy.errors import HttpErrorReporter, _leaves @@ -59,32 +59,28 @@ def _initialize(request_id: int = 1) -> SessionMessage: """The ``initialize`` request an MCP client sends first.""" - return SessionMessage( - JSONRPCMessage( - JSONRPCRequest( - jsonrpc="2.0", - id=request_id, - method="initialize", - params={ - "protocolVersion": PROTOCOL_VERSION, - "capabilities": {}, - "clientInfo": {"name": "test-client", "version": "1.0"}, - }, - ) - ) + return session_message( + { + "jsonrpc": "2.0", + "id": request_id, + "method": "initialize", + "params": { + "protocolVersion": PROTOCOL_VERSION, + "capabilities": {}, + "clientInfo": {"name": "test-client", "version": "1.0"}, + }, + } ) def _request(method: str, request_id: int) -> SessionMessage: """An arbitrary JSON-RPC request (a tool call, from the SDK's point of view).""" - return SessionMessage( - JSONRPCMessage(JSONRPCRequest(jsonrpc="2.0", id=request_id, method=method, params={})), - ) + return session_message({"jsonrpc": "2.0", "id": request_id, "method": method, "params": {}}) def _initialized_notification() -> SessionMessage: """The notification whose POST triggers the SDK's ``start_get_stream``.""" - return SessionMessage(JSONRPCMessage(JSONRPCNotification(jsonrpc="2.0", method="notifications/initialized"))) + return session_message({"jsonrpc": "2.0", "method": "notifications/initialized"}) # --------------------------------------------------------------------------- @@ -243,19 +239,25 @@ async def close(self) -> None: await self._to_proxy.aclose() -class _ExchangeTransport(httpx.MockTransport): +class _ExchangeTransport(exchange_httpx.MockTransport): """A token-exchange endpoint that records every request it was asked. ``exchange_pat`` builds a *synchronous* ``httpx.Client``, so this is driven through ``handle_request`` rather than the async path the proxy's own transport uses. Counting matters: several invariants here are about how *many* exchanges a scenario costs, not just whether one happened. + + Built from ``httpx`` rather than the SDK's HTTP library on purpose: the + exchange is the proxy's own request to the token endpoint and never + crosses the SDK boundary, so ``token_exchange`` imports ``httpx`` + directly. A transport from the other library is silently not used, and + the exchange then tries to reach the real network. """ - def __init__(self, responder: Callable[[httpx.Request], httpx.Response]) -> None: - self.requests: list[httpx.Request] = [] + def __init__(self, responder: Callable[[exchange_httpx.Request], exchange_httpx.Response]) -> None: + self.requests: list[exchange_httpx.Request] = [] - def _recording(request: httpx.Request) -> httpx.Response: + def _recording(request: exchange_httpx.Request) -> exchange_httpx.Response: self.requests.append(request) return responder(request) @@ -266,12 +268,12 @@ def count(self) -> int: return len(self.requests) -def _minting_exchange() -> Callable[[httpx.Request], httpx.Response]: +def _minting_exchange() -> Callable[[exchange_httpx.Request], exchange_httpx.Response]: """An exchange endpoint that hands out a fresh, distinguishable token each time.""" tokens = itertools.count(1) - def responder(request: httpx.Request) -> httpx.Response: - return httpx.Response(200, json={"access_token": f"app-token-{next(tokens)}", "expires_in": 3600}) + def responder(request: exchange_httpx.Request) -> exchange_httpx.Response: + return exchange_httpx.Response(200, json={"access_token": f"app-token-{next(tokens)}", "expires_in": 3600}) return responder @@ -600,10 +602,11 @@ async def test_response_hook_precedes_auth_flow(): reaches the first 401 first and aborts the process before the retry can ever be dispatched, so the retry would be present, correct, and dead. - It is an httpx *internal*, not a documented contract. ``pyproject.toml`` - caps httpx below the next minor precisely because of it; this test is the - gate for raising that cap. A reordering then shows up as a loud CI failure - rather than a feature that silently stops retrying. + It is an HTTP-client *internal*, not a documented contract. + ``pyproject.toml`` caps both httpx (MCP 1.x) and httpx2 (MCP 2.x) precisely + because of it. The SDK-major CI matrix runs this test against each library, + making it the gate for raising either cap. A reordering then shows up as a + loud CI failure rather than a feature that silently stops retrying. """ order: list[str] = [] @@ -806,7 +809,7 @@ async def test_exchange_failure_exits_one_without_traceback(monkeypatch, mock_wo ``diagnosed`` instead. Getting that wrong yields either a traceback, or a fatal message followed by exit 0. """ - exchange = _ExchangeTransport(lambda request: httpx.Response(400, json={"error": "invalid audience"})) + exchange = _ExchangeTransport(lambda request: exchange_httpx.Response(400, json={"error": "invalid audience"})) def responder(request: httpx.Request) -> httpx.Response: return _initialize_ok() @@ -907,15 +910,15 @@ async def test_teardown_exchange_failure_is_silent_and_exits_zero(monkeypatch, m """ attempts = itertools.count(1) - def exchange_responder(request: httpx.Request) -> httpx.Response: + def exchange_responder(request: exchange_httpx.Request) -> exchange_httpx.Response: if next(attempts) == 1: # A positive lifetime below the 60s renewal margin, so the cache is # stale the moment it is written and teardown is forced to re-mint. # Not ``0``: that is clamped to the default, because a zero or # negative lifetime would otherwise cost one blocking exchange per # request rather than one per hour. - return httpx.Response(200, json={"access_token": "app-token-1", "expires_in": 1}) - return httpx.Response(400, json={"error": "workspace unreachable"}) + return exchange_httpx.Response(200, json={"access_token": "app-token-1", "expires_in": 1}) + return exchange_httpx.Response(400, json={"error": "workspace unreachable"}) exchange = _ExchangeTransport(exchange_responder) @@ -1115,6 +1118,15 @@ def test_proxy_process_exits_on_401_with_stdin_still_open(): "DATABRICKS_TOKEN": "dapi-fake-token", "DATABRICKS_CONFIG_FILE": os.devnull, } + # The env is built from scratch rather than inherited so a developer's + # real DATABRICKS_* settings cannot reach the child. On Windows that + # also drops SystemRoot, without which winsock fails to initialize and + # the child dies importing asyncio's proactor loop (WinError 10106) + # before it ever reaches the code under test. + for name in ("SystemRoot", "SystemDrive"): + value = os.environ.get(name) + if value: + env[name] = value proc = subprocess.Popen( [ sys.executable, diff --git a/tests/unit/test_meta_injection.py b/tests/unit/test_meta_injection.py index e40fa6d..60ae46f 100644 --- a/tests/unit/test_meta_injection.py +++ b/tests/unit/test_meta_injection.py @@ -5,7 +5,8 @@ import anyio import pytest from mcp.shared.message import SessionMessage -from mcp.types import JSONRPCMessage, JSONRPCRequest + +from tests.support import jsonrpc_payload, session_message pytestmark = pytest.mark.unit @@ -13,40 +14,34 @@ def _tools_call(params=None) -> SessionMessage: if params is None: params = {"name": "q", "arguments": {}} - return SessionMessage( - message=JSONRPCMessage( - root=JSONRPCRequest( - jsonrpc="2.0", - id=1, - method="tools/call", - params=params, - ) - ) + return session_message( + { + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": params, + } ) def _tools_list() -> SessionMessage: - return SessionMessage( - message=JSONRPCMessage( - root=JSONRPCRequest( - jsonrpc="2.0", - id=2, - method="tools/list", - params={}, - ) - ) + return session_message( + { + "jsonrpc": "2.0", + "id": 2, + "method": "tools/list", + "params": {}, + } ) def _tools_call_no_params() -> SessionMessage: - return SessionMessage( - message=JSONRPCMessage( - root=JSONRPCRequest( - jsonrpc="2.0", - id=3, - method="tools/call", - ) - ) + return session_message( + { + "jsonrpc": "2.0", + "id": 3, + "method": "tools/call", + } ) @@ -55,7 +50,7 @@ def test_inject_meta_adds_meta_to_tools_call(): out = inject_meta(_tools_call(), {"warehouse_id": "abc123"}) - assert out.message.root.params["_meta"] == {"warehouse_id": "abc123"} + assert jsonrpc_payload(out).params["_meta"] == {"warehouse_id": "abc123"} def test_inject_meta_ignores_tools_list(): @@ -63,7 +58,7 @@ def test_inject_meta_ignores_tools_list(): out = inject_meta(_tools_list(), {"warehouse_id": "abc"}) - assert "_meta" not in (out.message.root.params or {}) + assert "_meta" not in (jsonrpc_payload(out).params or {}) def test_inject_meta_merges_with_existing_meta(): @@ -74,7 +69,7 @@ def test_inject_meta_merges_with_existing_meta(): ) out = inject_meta(msg, {"warehouse_id": "abc"}) - assert out.message.root.params["_meta"] == { + assert jsonrpc_payload(out).params["_meta"] == { "progressToken": "tok-42", "warehouse_id": "abc", } @@ -88,7 +83,7 @@ def test_inject_meta_proxy_wins_on_collision(capsys): ) out = inject_meta(msg, {"warehouse_id": "proxy-val"}) - assert out.message.root.params["_meta"]["warehouse_id"] == "proxy-val" + assert jsonrpc_payload(out).params["_meta"]["warehouse_id"] == "proxy-val" err = capsys.readouterr().err assert "warehouse_id" in err assert "override" in err.lower() @@ -106,7 +101,7 @@ def test_inject_meta_handles_missing_params(): out = inject_meta(_tools_call_no_params(), {"warehouse_id": "abc"}) - assert out.message.root.params == {"_meta": {"warehouse_id": "abc"}} + assert jsonrpc_payload(out).params == {"_meta": {"warehouse_id": "abc"}} def test_inject_meta_multiple_keys(): @@ -114,7 +109,7 @@ def test_inject_meta_multiple_keys(): out = inject_meta(_tools_call(), {"warehouse_id": "abc", "catalog": "main"}) - assert out.message.root.params["_meta"] == { + assert jsonrpc_payload(out).params["_meta"] == { "warehouse_id": "abc", "catalog": "main", } @@ -151,8 +146,8 @@ async def test_inject_meta_stream_rewrites_only_tools_call(memory_stream_pair): async for m in dest_recv: results.append(m) - assert results[0].message.root.params["_meta"] == {"warehouse_id": "abc"} - assert "_meta" not in (results[1].message.root.params or {}) + assert jsonrpc_payload(results[0]).params["_meta"] == {"warehouse_id": "abc"} + assert "_meta" not in (jsonrpc_payload(results[1]).params or {}) @pytest.mark.anyio diff --git a/uv.lock b/uv.lock index dd5ee1c..132ee05 100644 --- a/uv.lock +++ b/uv.lock @@ -3,7 +3,8 @@ revision = 3 requires-python = ">=3.10" resolution-markers = [ "python_full_version >= '3.15'", - "python_full_version < '3.15'", + "python_full_version == '3.14.*'", + "python_full_version < '3.14'", ] [[package]] @@ -537,6 +538,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, ] +[[package]] +name = "httpcore2" +version = "2.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "h11" }, + { name = "truststore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/39/a8/20ed1ed79cbc2ecdf5301c0968ab7c85547212e2a7bd126ddd2d986e206e/httpcore2-2.9.1.tar.gz", hash = "sha256:4d8acbf8b306f48c9d6046591fd5ba4037d1b1b1000d140fc2c3eab1e9a0c0e2", size = 67089, upload-time = "2026-07-24T09:21:03.867Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/fb/46c52b781975c335a2bcf1072c7bbc007cbdc8d674217f5ee1daba2c848b/httpcore2-2.9.1-py3-none-any.whl", hash = "sha256:6182472379e855fe4221246a2bb7ecede403bc61c6798062ae1787d051ccde26", size = 82809, upload-time = "2026-07-24T09:21:01.178Z" }, +] + [[package]] name = "httpx" version = "0.28.1" @@ -553,21 +567,28 @@ wheels = [ ] [[package]] -name = "httpx-sse" -version = "0.4.3" +name = "httpx2" +version = "2.9.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0f/4c/751061ffa58615a32c31b2d82e8482be8dd4a89154f003147acee90f2be9/httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d", size = 15943, upload-time = "2025-10-10T21:48:22.271Z" } +dependencies = [ + { name = "anyio" }, + { name = "httpcore2" }, + { name = "idna" }, + { name = "truststore" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/21/14/38128fbafd7e0ed41d874df6c9a653d47c2d111cfe59e2b4ac95161b4abd/httpx2-2.9.1.tar.gz", hash = "sha256:1932a768737e3666291582833da748cc4e563c337cf96706fccc04fa6e58764a", size = 95458, upload-time = "2026-07-24T09:21:04.972Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960, upload-time = "2025-10-10T21:48:21.158Z" }, + { url = "https://files.pythonhosted.org/packages/13/b8/cfd91c4ab9134d386d48f0b6ac662ff3d4be6efdee59ee1c67ebc3c0487c/httpx2-2.9.1-py3-none-any.whl", hash = "sha256:1820fe14a9ab1107bfeff39259987429450b070ec0ff38cc87eb0d8c97fdc71a", size = 91191, upload-time = "2026-07-24T09:21:02.6Z" }, ] [[package]] name = "idna" -version = "3.13" +version = "3.18" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ce/cc/762dfb036166873f0059f3b7de4565e1b5bc3d6f28a414c13da27e442f99/idna-3.13.tar.gz", hash = "sha256:585ea8fe5d69b9181ec1afba340451fba6ba764af97026f92a91d4eef164a242", size = 194210, upload-time = "2026-04-22T16:42:42.314Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5d/13/ad7d7ca3808a898b4612b6fe93cde56b53f3034dcde235acb1f0e1df24c6/idna-3.13-py3-none-any.whl", hash = "sha256:892ea0cde124a99ce773decba204c5552b69c3c67ffd5f232eb7696135bc8bb3", size = 68629, upload-time = "2026-04-22T16:42:40.909Z" }, + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, ] [[package]] @@ -693,15 +714,15 @@ wheels = [ [[package]] name = "mcp" -version = "1.27.0" +version = "2.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, - { name = "httpx" }, - { name = "httpx-sse" }, + { name = "httpx2" }, { name = "jsonschema" }, + { name = "mcp-types" }, + { name = "opentelemetry-api" }, { name = "pydantic" }, - { name = "pydantic-settings" }, { name = "pyjwt", extra = ["crypto"] }, { name = "python-multipart" }, { name = "pywin32", marker = "sys_platform == 'win32'" }, @@ -711,9 +732,22 @@ dependencies = [ { name = "typing-inspection" }, { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/8b/eb/c0cfc62075dc6e1ec1c64d352ae09ac051d9334311ed226f1f425312848a/mcp-1.27.0.tar.gz", hash = "sha256:d3dc35a7eec0d458c1da4976a48f982097ddaab87e278c5511d5a4a56e852b83", size = 607509, upload-time = "2026-04-02T14:48:08.88Z" } +sdist = { url = "https://files.pythonhosted.org/packages/74/33/32d4dff2c95bb5d897c3ef4c83649a08996b17b58f0a326d2495d4c81179/mcp-2.0.0.tar.gz", hash = "sha256:0f440e735c13ece8bb19bc62cf0b86f4313448432fbb77d35e14034f4e050728", size = 1662284, upload-time = "2026-07-28T13:45:32.346Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9c/46/f6b4ad632c67ef35209a66127e4bddc95759649dd595f71f13fba11bdf9a/mcp-1.27.0-py3-none-any.whl", hash = "sha256:5ce1fa81614958e267b21fb2aa34e0aea8e2c6ede60d52aba45fd47246b4d741", size = 215967, upload-time = "2026-04-02T14:48:07.24Z" }, + { url = "https://files.pythonhosted.org/packages/67/72/7d7897418912c1d12e87556630dfb7bf0eac71160e9bef8b447960804ee3/mcp-2.0.0-py3-none-any.whl", hash = "sha256:1cb4c75d2d2c7b8c1d756355e5d82a39f2822cc7f13e22a2051d7ca3592349d6", size = 349980, upload-time = "2026-07-28T13:45:28.853Z" }, +] + +[[package]] +name = "mcp-types" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bb/56/9b8e1c152f61f6c6b07c4b5896c88c7d0ae90bac6ee6306f852fcc5c1eb0/mcp_types-2.0.0.tar.gz", hash = "sha256:d7d939b9285c9961ae8866ba75ef85da34d12bafe276efbf4eb6a131786d8379", size = 66632, upload-time = "2026-07-28T13:45:33.804Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f5/4c/c78d78c3d52b0ac594ad7cc8ef5972adfe070e3597a8a4c6ce0cd39196ea/mcp_types-2.0.0-py3-none-any.whl", hash = "sha256:6b2de797ca2797f568b79529e1b25948e34de511bcc0bd82fef1039a6d1b8eb0", size = 69649, upload-time = "2026-07-28T13:45:30.713Z" }, ] [[package]] @@ -784,6 +818,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, ] +[[package]] +name = "opentelemetry-api" +version = "1.44.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ee/8b/aa9e2d8b8dfa7c946f7dec5d1f8f6ba8eca062f43509a06bdb5ce93d26c0/opentelemetry_api-1.44.0.tar.gz", hash = "sha256:67647e5e9566edcf421166fdf022b3537f818635daa852b289e34604dc6fb33a", size = 72406, upload-time = "2026-07-16T15:25:32.678Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/6f/a04e900f465ff3221ccc395522503e2d10e79fa21f2723c8e177aae1e0d1/opentelemetry_api-1.44.0-py3-none-any.whl", hash = "sha256:94b98c893a91b88657eaac1e3ba89618cdb85be6918196705354f34728b2cdef", size = 60018, upload-time = "2026-07-16T15:25:11.657Z" }, +] + [[package]] name = "outcome" version = "1.3.0.post0" @@ -999,20 +1045,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" }, ] -[[package]] -name = "pydantic-settings" -version = "2.14.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pydantic" }, - { name = "python-dotenv" }, - { name = "typing-inspection" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/42/98/c8345dccdc31de4228c039a98f6467a941e39558da41c1744fbe29fa5666/pydantic_settings-2.14.0.tar.gz", hash = "sha256:24285fd4b0e0c06507dd9fdfd331ee23794305352aaec8fc4eb92d4047aeb67d", size = 235709, upload-time = "2026-04-20T13:37:40.293Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/01/dd/bebff3040138f00ae8a102d426b27349b9a49acc310fcae7f92112d867e3/pydantic_settings-2.14.0-py3-none-any.whl", hash = "sha256:fc8d5d692eb7092e43c8647c1c35a3ecd00e040fcf02ed86f4cb5458ca62182e", size = 60940, upload-time = "2026-04-20T13:37:38.586Z" }, -] - [[package]] name = "pygments" version = "2.20.0" @@ -1083,15 +1115,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9a/db/2df9a1fca597a273f957a559c20c2d95d629928384507b2afa43ba6909d1/pytest_randomly-4.1.0-py3-none-any.whl", hash = "sha256:f55e89e53367b090c0c053697d7f9d77595543d0e0516c93978b50c0f6b252f9", size = 8353, upload-time = "2026-04-20T13:01:50.382Z" }, ] -[[package]] -name = "python-dotenv" -version = "1.2.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, -] - [[package]] name = "python-multipart" version = "0.0.27" @@ -1415,6 +1438,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1c/93/dab25dc87ac48da0fe0f6419e07d0bfd98799bed4e05e7b9e0f85a1a4b4b/trio-0.33.0-py3-none-any.whl", hash = "sha256:3bd5d87f781d9b0192d592aef28691f8951d6c2e41b7e1da4c25cde6c180ae9b", size = 510294, upload-time = "2026-02-14T18:40:53.313Z" }, ] +[[package]] +name = "truststore" +version = "0.10.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/a3/1585216310e344e8102c22482f6060c7a6ea0322b63e026372e6dcefcfd6/truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301", size = 26169, upload-time = "2025-08-12T18:49:02.73Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" }, +] + [[package]] name = "typing-extensions" version = "4.15.0" @@ -1444,6 +1476,7 @@ dependencies = [ { name = "anyio" }, { name = "databricks-sdk" }, { name = "httpx" }, + { name = "httpx2" }, { name = "mcp" }, ] @@ -1462,7 +1495,8 @@ requires-dist = [ { name = "anyio" }, { name = "databricks-sdk", specifier = ">=0.30.0" }, { name = "httpx", specifier = ">=0.28,<0.29" }, - { name = "mcp", specifier = ">=1.8,<2" }, + { name = "httpx2", specifier = ">=2.5,<3" }, + { name = "mcp", specifier = ">=1.24,<3" }, ] [package.metadata.requires-dev]