From 28017648f6be35b7819958341e2c7aaaa759525c Mon Sep 17 00:00:00 2001 From: lda Date: Sun, 6 Sep 2026 11:34:01 +0700 Subject: [PATCH] fix: repair httpx2/params/transport upgrade fallout --- src/wf_client/_http_port.py | 4 +-- src/wf_openapi/executor.py | 14 ++++----- src/wf_sources_mcp/auth.py | 14 +++++++-- src/wf_sources_mcp/client/transport.py | 18 ++++++----- src/wf_transport_rpc_http/params.py | 22 ++++---------- tests/wf_sources_mcp/test_client_transport.py | 30 +++++++++---------- 6 files changed, 52 insertions(+), 50 deletions(-) diff --git a/src/wf_client/_http_port.py b/src/wf_client/_http_port.py index 06364d4e..6c12e38c 100644 --- a/src/wf_client/_http_port.py +++ b/src/wf_client/_http_port.py @@ -7,7 +7,7 @@ from dataclasses import dataclass from json import JSONDecodeError from typing import Any, Literal, TypeVar -import httpx +import httpx2 from wf_api.models import ( CapabilityCallResult, @@ -99,7 +99,7 @@ class PublicErrorWorkflowClientPort: if known is not None: raise known from exc raise ProtocolError(exc.code, exc.message, exc.data) from exc - except (httpx.TransportError, httpx.HTTPStatusError, JSONDecodeError) as exc: + except (httpx2.HTTPError, JSONDecodeError) as exc: raise TransportError(f"{operation} transport failed: {exc}") from exc except RuntimeError as exc: # The RPC transport uses RuntimeError only when a decoded JSON-RPC diff --git a/src/wf_openapi/executor.py b/src/wf_openapi/executor.py index fee6cf52..ecc6cb55 100644 --- a/src/wf_openapi/executor.py +++ b/src/wf_openapi/executor.py @@ -4,7 +4,7 @@ from dataclasses import dataclass from json import JSONDecodeError from typing import Any -import httpx +import httpx2 from openapi_core import OpenAPI from pydantic import BaseModel, ConfigDict @@ -44,7 +44,7 @@ async def call_openapi_operation( config: OpenApiExecutionConfig, payload: dict[str, Any], *, - client: httpx.AsyncClient | None = None, + client: httpx2.AsyncClient | None = None, ) -> NodeReturn[OpenApiOperationOutput]: """Execute one raw OpenAPI operation through generic HTTP machinery.""" request = build_http_request_parts( @@ -65,11 +65,11 @@ async def call_openapi_operation( ) close_client = client is None - active_client = client or httpx.AsyncClient(timeout=config.timeout_seconds) + active_client = client or httpx2.AsyncClient(timeout=config.timeout_seconds) try: try: response = await _send_request(active_client, request) - except httpx.HTTPError as exc: + except httpx2.HTTPError as exc: return NodeReturn( outcome="transport_error", output=OpenApiOperationOutput( @@ -119,9 +119,9 @@ async def call_openapi_operation( async def _send_request( - client: httpx.AsyncClient, + client: httpx2.AsyncClient, request: HttpRequestParts, -) -> httpx.Response: +) -> httpx2.Response: kwargs: dict[str, Any] = { "method": request.method, "url": request.url, @@ -137,7 +137,7 @@ async def _send_request( return await client.request(**kwargs) -def _response_body(response: httpx.Response) -> tuple[Any, list[str]]: +def _response_body(response: httpx2.Response) -> tuple[Any, list[str]]: """Parse response body while keeping malformed JSON in validation flow.""" content_type = response.headers.get("content-type", "").lower() if not response.content: diff --git a/src/wf_sources_mcp/auth.py b/src/wf_sources_mcp/auth.py index b258f4af..fda922f6 100644 --- a/src/wf_sources_mcp/auth.py +++ b/src/wf_sources_mcp/auth.py @@ -10,7 +10,7 @@ from collections.abc import Callable from dataclasses import dataclass, field from typing import Any, Protocol -import httpx +import httpx2 from wf_api.auth import ( AuthRecord as NeutralAuthRecord, @@ -180,7 +180,7 @@ class HttpxOAuthTokenRefresher: data["client_secret"] = auth.client_secret if auth.scopes: data["scope"] = " ".join(auth.scopes) - async with httpx.AsyncClient(timeout=10.0) as client: + async with httpx2.AsyncClient(timeout=10.0) as client: response = await client.post(str(auth.token_url), data=data) response.raise_for_status() payload = response.json() @@ -198,8 +198,16 @@ class HttpxOAuthTokenRefresher: @dataclass(frozen=True, slots=True) class BoundMcpHttpAuth: + """Auth material for MCP HTTP transports. + + The MCP transport stack is httpx2-only: ``auth`` must be an httpx2.Auth + (or None). The authlib OAuth login flow in wf_cli stays on httpx by + design and never flows into this field; token refresh here is a plain + httpx2 POST. + """ + headers: dict[str, str] = field(default_factory=dict) - auth: httpx.Auth | None = None + auth: httpx2.Auth | None = None @dataclass(frozen=True, slots=True) diff --git a/src/wf_sources_mcp/client/transport.py b/src/wf_sources_mcp/client/transport.py index cf9a5258..18e65f1e 100644 --- a/src/wf_sources_mcp/client/transport.py +++ b/src/wf_sources_mcp/client/transport.py @@ -5,7 +5,7 @@ from __future__ import annotations from collections.abc import AsyncIterator from contextlib import asynccontextmanager -import httpx +import httpx2 from mcp.client.session import ClientSession from mcp.client.stdio import StdioServerParameters, stdio_client from mcp.client.streamable_http import streamable_http_client @@ -42,11 +42,12 @@ async def open_mcp_session( duplicate keys) and passes command, args, env, and cwd to StdioServerParameters. - For HTTP transports, creates an httpx.AsyncClient with auth headers and + For HTTP transports, creates an httpx2.AsyncClient with auth headers and enters streamable_http_client. Yields an initialized ClientSession. Caller owns the session lifetime. """ + transport = connection.transport if transport is None: raise ValueError(f"connection {connection.id!r} requires metadata.transport") @@ -73,7 +74,7 @@ async def open_mcp_session( if isinstance(transport, HttpSourceTransport): bound = await binder.bind_http_auth(stored_auth) - http_client = httpx.AsyncClient( + http_client = httpx2.AsyncClient( headers=bound.headers or None, auth=bound.auth, ) @@ -82,11 +83,14 @@ async def open_mcp_session( streamable_http_client( str(transport.url), http_client=http_client, - ) as (read_stream, write_stream, _get_session_id), + ) as ( + read_stream, + write_stream, + ), + ClientSession(read_stream, write_stream) as session, ): - async with ClientSession(read_stream, write_stream) as session: - await session.initialize() - yield session + await session.initialize() + yield session return raise ValueError(f"unsupported MCP transport {transport.kind!r}") diff --git a/src/wf_transport_rpc_http/params.py b/src/wf_transport_rpc_http/params.py index 6ddebff1..b29bf6b0 100644 --- a/src/wf_transport_rpc_http/params.py +++ b/src/wf_transport_rpc_http/params.py @@ -2,25 +2,15 @@ from __future__ import annotations from typing import Any -from fastapi.datastructures import _Unset from fastapi_jsonrpc import Params -class _RpcParams(Params): - def __init__(self, default: Any = ..., **extra: Any) -> None: - super().__init__(default, example=_Unset, **extra) - - def RpcParams(default: Any = ...) -> Any: - """Bind JSON-RPC method params without fastapi-jsonrpc's warning-prone wrapper. + """Bind JSON-RPC method params with a zero-arg default. - ``fastapi_jsonrpc.Params`` currently forwards ``example=Undefined`` into - FastAPI's ``Body``. FastAPI treats that as the deprecated ``example`` - argument being explicitly provided, so every method registration emits a - deprecation warning. Keep the upstream subclass so fastapi-jsonrpc still - recognises method params, but pass FastAPI's real "unset" sentinel. - - This issue is fixed upstream at https://github.com/smagafurov/fastapi-jsonrpc/pull/101. Remove this wrapper once the next version of fastapi-jsonrpc is released and we upgrade to it. + Upstream fixed the ``example``-sentinel warning in fastapi-jsonrpc 4.0, so + this is now a plain pass-through. The wrapper stays (in this one file) so + the ``params: Model = RpcParams()`` call sites keep working: upstream + ``Params`` still requires ``default`` positionally. """ - # TODO - return _RpcParams(default) + return Params(default) diff --git a/tests/wf_sources_mcp/test_client_transport.py b/tests/wf_sources_mcp/test_client_transport.py index 39a03a50..d9e0c746 100644 --- a/tests/wf_sources_mcp/test_client_transport.py +++ b/tests/wf_sources_mcp/test_client_transport.py @@ -31,8 +31,8 @@ async def _fake_stdio_client(params: Any) -> AsyncIterator[tuple[Any, Any]]: @asynccontextmanager async def _fake_streamable_http_client( url: str, *, http_client: Any = None -) -> AsyncIterator[tuple[Any, Any, Any]]: - yield "read", "write", lambda: None +) -> AsyncIterator[tuple[Any, Any]]: + yield "read", "write" @asynccontextmanager @@ -169,10 +169,10 @@ async def test_http_auth_headers_passed_to_client( payload={"token": "secret123"}, ) - import httpx as _httpx + import httpx2 as _httpx2 - captured_clients: list[_httpx.AsyncClient] = [] - _original_client = _httpx.AsyncClient + captured_clients: list[_httpx2.AsyncClient] = [] + _original_client = _httpx2.AsyncClient class _CapturingClient(_original_client): # type: ignore[type-arg] def __init__(self, **kwargs: Any) -> None: @@ -181,10 +181,10 @@ async def test_http_auth_headers_passed_to_client( import wf_sources_mcp.client.transport as mod - class _PatchedHttpx: + class _Patchedhttpx2: AsyncClient = _CapturingClient - monkeypatch.setattr(mod, "httpx", _PatchedHttpx()) + monkeypatch.setattr(mod, "httpx2", _Patchedhttpx2()) async with open_mcp_session(connection, auth) as session: assert isinstance(session, _FakeSession) @@ -236,10 +236,10 @@ async def test_http_no_auth_creates_client_with_no_auth_headers( ) -> None: connection = _http_connection() - import httpx as _httpx + import httpx2 as _httpx2 - captured_clients: list[_httpx.AsyncClient] = [] - _original_client = _httpx.AsyncClient + captured_clients: list[_httpx2.AsyncClient] = [] + _original_client = _httpx2.AsyncClient class _CapturingClient(_original_client): # type: ignore[type-arg] def __init__(self, **kwargs: Any) -> None: @@ -250,8 +250,8 @@ async def test_http_no_auth_creates_client_with_no_auth_headers( monkeypatch.setattr( mod, - "httpx", - type("_PatchedHttpx", (), {"AsyncClient": _CapturingClient})(), + "httpx2", + type("_Patchedhttpx2", (), {"AsyncClient": _CapturingClient})(), ) async with open_mcp_session(connection, None): @@ -282,7 +282,7 @@ async def test_open_mcp_session_uses_binder_for_http_headers( import wf_sources_mcp.client.transport as mod - monkeypatch.setattr(mod.httpx, "AsyncClient", _CapturingClient) + monkeypatch.setattr(mod.httpx2, "AsyncClient", _CapturingClient) connection = _http_connection() auth = StoredAuthRecord( @@ -332,8 +332,8 @@ async def test_open_mcp_session_refreshes_oauth_record_for_http( import wf_sources_mcp.auth as auth_mod import wf_sources_mcp.client.transport as transport_mod - monkeypatch.setattr(auth_mod.httpx, "AsyncClient", _CapturingClient) - monkeypatch.setattr(transport_mod.httpx, "AsyncClient", _CapturingClient) + monkeypatch.setattr(auth_mod.httpx2, "AsyncClient", _CapturingClient) + monkeypatch.setattr(transport_mod.httpx2, "AsyncClient", _CapturingClient) connection = _http_connection() auth = StoredAuthRecord(