Replace httpx and httpx-sse with httpx2 (#2972)
Co-authored-by: Max Isbey <224885523+maxisbey@users.noreply.github.com>
This commit is contained in:
parent
1216c53693
commit
2713b53b12
18 changed files with 1029 additions and 948 deletions
|
|
@ -18,7 +18,7 @@ from typing import Any, TypeAlias
|
|||
from urllib.parse import urlsplit
|
||||
|
||||
import anyio
|
||||
import httpx
|
||||
import httpx2
|
||||
from mcp_types.version import LATEST_MODERN_VERSION
|
||||
|
||||
from mcp import StdioServerParameters, stdio_client
|
||||
|
|
@ -38,8 +38,8 @@ Target: TypeAlias = "Server[Any] | MCPServer | Transport | str"
|
|||
TargetFactory = Callable[[], Target]
|
||||
"""Yields a FRESH target against the same server/app on every call (``multi_connection`` stories)."""
|
||||
|
||||
AuthBuilder = Callable[[httpx.AsyncClient], httpx.Auth]
|
||||
"""Builds an ``httpx.Auth`` bound to the in-process HTTP client (auth-story harness seam)."""
|
||||
AuthBuilder = Callable[[httpx2.AsyncClient], httpx2.Auth]
|
||||
"""Builds an ``httpx2.Auth`` bound to the in-process HTTP client (auth-story harness seam)."""
|
||||
|
||||
|
||||
def argv_after(flag: str, *, default: str | None = None) -> str:
|
||||
|
|
@ -127,8 +127,8 @@ def _story_cfg(name: str) -> dict[str, Any]:
|
|||
return manifest["defaults"] | manifest["story"].get(name, {})
|
||||
|
||||
|
||||
def _authed_targets(url: str, http: httpx.AsyncClient) -> TargetFactory:
|
||||
"""Fresh streamable-HTTP transports over an already-authed ``httpx`` client."""
|
||||
def _authed_targets(url: str, http: httpx2.AsyncClient) -> TargetFactory:
|
||||
"""Fresh streamable-HTTP transports over an already-authed ``httpx2`` client."""
|
||||
return lambda: streamable_http_client(url, http_client=http)
|
||||
|
||||
|
||||
|
|
@ -176,13 +176,13 @@ def run_client(main: Callable[..., Awaitable[None]]) -> None:
|
|||
if url is None or (build_auth is None and not cfg["needs_http"]):
|
||||
await main(targets if cfg["multi_connection"] else targets(), mode=mode)
|
||||
return
|
||||
# Auth and needs_http stories want the raw httpx client underneath the transport:
|
||||
# build_auth threads an httpx.Auth onto it (Client(url, auth=...) doesn't exist
|
||||
# Auth and needs_http stories want the raw httpx2 client underneath the transport:
|
||||
# build_auth threads an httpx2.Auth onto it (Client(url, auth=...) doesn't exist
|
||||
# yet), and needs_http stories assert on raw responses, so root the client at the
|
||||
# server origin and relative paths like "/mcp" resolve.
|
||||
parts = urlsplit(url)
|
||||
base = f"{parts.scheme}://{parts.netloc}"
|
||||
http = await stack.enter_async_context(httpx.AsyncClient(base_url=base))
|
||||
http = await stack.enter_async_context(httpx2.AsyncClient(base_url=base))
|
||||
make = targets
|
||||
if build_auth is not None:
|
||||
http.auth = build_auth(http)
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ ServerFactory = Callable[[], AnyServer]
|
|||
AppFactory = Callable[[], Starlette]
|
||||
|
||||
NO_DNS_REBIND = TransportSecuritySettings(enable_dns_rebinding_protection=False)
|
||||
"""Harness servers bind 127.0.0.1 and the in-process httpx client sends no Origin header."""
|
||||
"""Harness servers bind 127.0.0.1 and the in-process httpx2 client sends no Origin header."""
|
||||
|
||||
|
||||
def argv_after(flag: str, *, default: str | None = None) -> str:
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import secrets
|
|||
import time
|
||||
from urllib.parse import parse_qs, urlsplit
|
||||
|
||||
import httpx
|
||||
import httpx2
|
||||
from pydantic import AnyHttpUrl
|
||||
|
||||
from mcp.server.auth.provider import (
|
||||
|
|
@ -49,14 +49,14 @@ class InMemoryTokenStorage:
|
|||
|
||||
|
||||
class HeadlessOAuth:
|
||||
"""Completes the authorize redirect in-process via the bound ``httpx`` client."""
|
||||
"""Completes the authorize redirect in-process via the bound ``httpx2`` client."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.authorize_url: str | None = None
|
||||
self._http: httpx.AsyncClient | None = None
|
||||
self._http: httpx2.AsyncClient | None = None
|
||||
self._result = AuthorizationCodeResult(code="", state=None)
|
||||
|
||||
def bind(self, http_client: httpx.AsyncClient) -> None:
|
||||
def bind(self, http_client: httpx2.AsyncClient) -> None:
|
||||
self._http = http_client
|
||||
|
||||
async def redirect_handler(self, authorization_url: str) -> None:
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ uv run python -m stories.bearer_auth.client --http http://127.0.0.1:8000/mcp
|
|||
`Client(url)` has no `auth=` passthrough, so a target built from a bare URL
|
||||
can't carry the token. Both runners close that gap the same way: `run_client`
|
||||
(above) and the pytest harness thread the module's `build_auth` export onto the
|
||||
`httpx.AsyncClient` underneath the transport and hand `main` a target that is
|
||||
`httpx2.AsyncClient` underneath the transport and hand `main` a target that is
|
||||
already routed through it.
|
||||
|
||||
## Try it without the SDK client
|
||||
|
|
@ -56,9 +56,9 @@ kill "$SERVER_PID"
|
|||
transport that already carries the bearer token; nothing in the body knows
|
||||
auth exists.
|
||||
- `client.py` `build_auth` / `StaticBearerAuth` — bearer auth client-side is
|
||||
five lines of `httpx.Auth`. `Client(url, auth=...)` is the ergonomic the SDK
|
||||
five lines of `httpx2.Auth`. `Client(url, auth=...)` is the ergonomic the SDK
|
||||
is missing; until it lands, the auth has to be threaded onto the
|
||||
`httpx.AsyncClient` underneath the transport, outside `main`.
|
||||
`httpx2.AsyncClient` underneath the transport, outside `main`.
|
||||
- `server.py` — `MCPServer(token_verifier=..., auth=AuthSettings(...))` is the
|
||||
whole recipe; `streamable_http_app()` reads those constructor kwargs and
|
||||
mounts the bearer gate + PRM route.
|
||||
|
|
@ -73,7 +73,7 @@ kill "$SERVER_PID"
|
|||
## Caveats
|
||||
|
||||
- `transport_security=NO_DNS_REBIND` — DNS-rebinding protection is on by default
|
||||
for localhost binds; the harness disables it because the in-process httpx
|
||||
for localhost binds; the harness disables it because the in-process httpx2
|
||||
client sends no `Origin` header. Drop the kwarg for a real deployment.
|
||||
- `RESOURCE_URL` is hard-coded to port 8000 (the harness's in-process origin).
|
||||
If you change `--port`, edit `RESOURCE_URL` to match or the PRM document's
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
from collections.abc import Generator
|
||||
|
||||
import httpx
|
||||
import httpx2
|
||||
|
||||
from mcp.client import Client
|
||||
from stories._harness import Target, run_client
|
||||
|
|
@ -10,22 +10,22 @@ from stories._harness import Target, run_client
|
|||
from .server import DEMO_TOKEN, REQUIRED_SCOPE
|
||||
|
||||
|
||||
class StaticBearerAuth(httpx.Auth):
|
||||
"""``httpx.Auth`` that attaches a fixed ``Authorization: Bearer <token>`` to every request."""
|
||||
class StaticBearerAuth(httpx2.Auth):
|
||||
"""``httpx2.Auth`` that attaches a fixed ``Authorization: Bearer <token>`` to every request."""
|
||||
|
||||
def __init__(self, token: str) -> None:
|
||||
self.token = token
|
||||
|
||||
def auth_flow(self, request: httpx.Request) -> Generator[httpx.Request, httpx.Response, None]:
|
||||
def auth_flow(self, request: httpx2.Request) -> Generator[httpx2.Request, httpx2.Response, None]:
|
||||
request.headers["Authorization"] = f"Bearer {self.token}"
|
||||
yield request
|
||||
|
||||
|
||||
def build_auth(_http: httpx.AsyncClient) -> httpx.Auth:
|
||||
"""The demo bearer token as an ``httpx.Auth``.
|
||||
def build_auth(_http: httpx2.AsyncClient) -> httpx2.Auth:
|
||||
"""The demo bearer token as an ``httpx2.Auth``.
|
||||
|
||||
``Client(url, auth=...)`` doesn't exist yet, so the harness threads this onto the underlying
|
||||
``httpx.AsyncClient`` and the target ``main`` receives is already routed through it.
|
||||
``httpx2.AsyncClient`` and the target ``main`` receives is already routed through it.
|
||||
"""
|
||||
return StaticBearerAuth(DEMO_TOKEN)
|
||||
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ uv run python -m stories.identity_assertion.client --http http://127.0.0.1:8000/
|
|||
|
||||
`Client(url)` has no `auth=` passthrough, so both runners thread the module's
|
||||
`build_auth` export (an `IdentityAssertionOAuthProvider`) onto the
|
||||
`httpx.AsyncClient` underneath the transport and hand `main` a target that is
|
||||
`httpx2.AsyncClient` underneath the transport and hand `main` a target that is
|
||||
already routed through it.
|
||||
|
||||
## Try it without the SDK client
|
||||
|
|
@ -57,7 +57,7 @@ kill "$SERVER_PID"
|
|||
return a fresh ID-JAG. In production this is an RFC 8693 token exchange
|
||||
against your IdP; here it calls the stand-in signer in `idp.py`.
|
||||
- `client.py` `build_auth` — `IdentityAssertionOAuthProvider` is the same
|
||||
`httpx.Auth` shape as every other provider. Note `issuer=ISSUER` with the
|
||||
`httpx2.Auth` shape as every other provider. Note `issuer=ISSUER` with the
|
||||
trailing slash: the provider compares it to the metadata document's `issuer`
|
||||
by simple string comparison and refuses a mismatch before sending anything.
|
||||
- `server.py` `exchange_identity_assertion` — the whole authorization-server
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"""HTTP-only SEP-990: `build_auth` presents an IdP-issued ID-JAG (jwt-bearer grant); `whoami` proves the subject."""
|
||||
|
||||
import httpx
|
||||
import httpx2
|
||||
|
||||
from mcp.client import Client
|
||||
from mcp.client.auth.extensions.identity_assertion import IdentityAssertionOAuthProvider
|
||||
|
|
@ -27,14 +27,14 @@ async def fetch_id_jag(audience: str, resource: str) -> str:
|
|||
)
|
||||
|
||||
|
||||
def build_auth(_http: httpx.AsyncClient) -> httpx.Auth:
|
||||
def build_auth(_http: httpx2.AsyncClient) -> httpx2.Auth:
|
||||
"""An `IdentityAssertionOAuthProvider` for the pre-registered confidential client.
|
||||
|
||||
`issuer` is configuration, not discovery: the provider fetches metadata from this issuer's
|
||||
well-known and never asks the MCP server which authorization server to use. The string must
|
||||
equal the `issuer` its metadata serves byte for byte (note the trailing slash).
|
||||
`Client(url, auth=...)` doesn't exist yet, so the harness threads this onto the underlying
|
||||
`httpx.AsyncClient` and hands `main` a target that is already routed through it.
|
||||
`httpx2.AsyncClient` and hands `main` a target that is already routed through it.
|
||||
"""
|
||||
return IdentityAssertionOAuthProvider(
|
||||
server_url=MCP_URL,
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ kill "$SERVER_PID"
|
|||
|
||||
- `client.py` `main` — `async with Client(target, mode=mode) as client:` is an
|
||||
ordinary high-level client; nothing about JSON mode is visible from this side.
|
||||
The same `main` also takes the raw `httpx.AsyncClient` so it can prove what
|
||||
The same `main` also takes the raw `httpx2.AsyncClient` so it can prove what
|
||||
the wire looks like underneath.
|
||||
- `client.py` `RAW_ENVELOPE_BODY` / `MODERN_HEADERS` — the exact 2026 wire
|
||||
shape: three `io.modelcontextprotocol/*` `_meta` keys replace the initialize
|
||||
|
|
@ -52,7 +52,7 @@ kill "$SERVER_PID"
|
|||
## Caveats
|
||||
|
||||
- DNS-rebinding protection is on by default; the harness disables it via
|
||||
`NO_DNS_REBIND` because the in-process httpx client sends no `Origin` header.
|
||||
`NO_DNS_REBIND` because the in-process httpx2 client sends no `Origin` header.
|
||||
- The `streamable_http_app()` call shape here will move when the free-function
|
||||
entry lands (see `_hosting.py`).
|
||||
- `Mcp-Name` is omitted for `tools/list` because the SDK only emits it on
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ sends — this is the only story that shows it. ``main`` posts that body by hand
|
|||
asserts the response is a single ``application/json`` body with no session id.
|
||||
"""
|
||||
|
||||
import httpx
|
||||
import httpx2
|
||||
from mcp_types import TextContent
|
||||
from mcp_types.version import LATEST_MODERN_VERSION
|
||||
|
||||
|
|
@ -37,7 +37,7 @@ MODERN_HEADERS: dict[str, str] = {
|
|||
}
|
||||
|
||||
|
||||
async def main(target: Target, *, mode: str = "auto", http: httpx.AsyncClient) -> None:
|
||||
async def main(target: Target, *, mode: str = "auto", http: httpx2.AsyncClient) -> None:
|
||||
async with Client(target, mode=mode) as client:
|
||||
assert client.protocol_version == LATEST_MODERN_VERSION
|
||||
|
||||
|
|
|
|||
|
|
@ -92,7 +92,7 @@ eras need different auth, rate limits, or scaling.
|
|||
- `ctx.request_context.protocol_version` is the interim 2-hop reach; a later
|
||||
release will shorten it.
|
||||
- DNS-rebinding protection is on by default; the harness disables it
|
||||
(`NO_DNS_REBIND`) because the in-process httpx client sends no `Origin`.
|
||||
(`NO_DNS_REBIND`) because the in-process httpx2 client sends no `Origin`.
|
||||
Drop the kwarg for a real deployment.
|
||||
- `mcp.shared.inbound` is a deep import path — a shorter re-export is planned
|
||||
before beta.
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ status = "current" # "current" | "legacy" | "deprec
|
|||
lowlevel = true # also run main against server_lowlevel.build_server()/build_app()
|
||||
server_export = "factory" # "factory" -> build_server() | "app" -> build_app()
|
||||
multi_connection = false # main(target, ...) vs main(targets, ...); targets() -> fresh target per call
|
||||
needs_http = false # main(..., http=) gets the raw httpx.AsyncClient (http-asgi only)
|
||||
needs_http = false # main(..., http=) gets the raw httpx2.AsyncClient (http-asgi only)
|
||||
timeout_s = 30
|
||||
mcp_path = "/mcp"
|
||||
fixed_port = 0 # `client --http` self-host port; 0 = an OS-assigned free port
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ auth_server_provider=...)` constructor call co-hosts the RFC 9728
|
|||
protected-resource metadata route, the AS routes (`/register`, `/authorize`,
|
||||
`/token`, `/.well-known/oauth-authorization-server`) and the bearer-gated
|
||||
`/mcp` endpoint on a single Starlette app. On the **client** side:
|
||||
`OAuthClientProvider` is an `httpx.Auth` that reacts to the first `401` by
|
||||
`OAuthClientProvider` is an `httpx2.Auth` that reacts to the first `401` by
|
||||
walking PRM discovery → AS metadata → DCR → PKCE authorize → token exchange →
|
||||
bearer retry — all inside the first awaited request, with no user-visible
|
||||
`UnauthorizedError`.
|
||||
|
|
@ -39,7 +39,7 @@ straight back with `?code=...`; without it the authorize step returns
|
|||
|
||||
`Client(url)` has no `auth=` passthrough, so a target built from a bare URL
|
||||
can't carry the flow. Both runners close that gap the same way: `run_client`
|
||||
(above) and the pytest harness build an authed `httpx.AsyncClient` from
|
||||
(above) and the pytest harness build an authed `httpx2.AsyncClient` from
|
||||
this module's `build_auth` export and hand `main` targets that are already
|
||||
routed through it.
|
||||
|
||||
|
|
@ -57,9 +57,9 @@ routed through it.
|
|||
request — no second `/authorize`, no second `/register`. The demo AS mints a
|
||||
fresh `client_id` per DCR call, so `whoami` returning the *same* `client_id`
|
||||
is the reuse proof.
|
||||
- **`client.py` — `build_auth()`.** `OAuthClientProvider` is an `httpx.Auth`.
|
||||
- **`client.py` — `build_auth()`.** `OAuthClientProvider` is an `httpx2.Auth`.
|
||||
`Client(url, auth=...)` is the ergonomic the SDK is missing; until it lands
|
||||
the auth has to be threaded onto the underlying `httpx.AsyncClient` by hand.
|
||||
the auth has to be threaded onto the underlying `httpx2.AsyncClient` by hand.
|
||||
- **`server.py` — `MCPServer(auth=..., auth_server_provider=...)`.** The
|
||||
constructor wires everything; `streamable_http_app()` reads it back. (Don't
|
||||
also pass `token_verifier=` — `auth_server_provider` and `token_verifier` are
|
||||
|
|
@ -74,7 +74,7 @@ routed through it.
|
|||
## Caveats
|
||||
|
||||
- `transport_security=NO_DNS_REBIND` — DNS-rebinding protection is on by default
|
||||
and the in-process httpx bridge sends no `Origin` header. Drop the kwarg for a
|
||||
and the in-process httpx2 bridge sends no `Origin` header. Drop the kwarg for a
|
||||
real deployment.
|
||||
- `HeadlessOAuth` only works because the demo AS auto-consents; a real
|
||||
`redirect_handler` would open a browser and a real `callback_handler` would
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"""HTTP-only OAuth authorization-code flow; `build_auth` supplies the provider, reconnecting needs `targets`."""
|
||||
|
||||
import httpx
|
||||
import httpx2
|
||||
from pydantic import AnyUrl
|
||||
|
||||
from mcp.client import Client
|
||||
|
|
@ -14,11 +14,11 @@ from stories._harness import TargetFactory, run_client
|
|||
from stories._shared.auth import MCP_URL, REDIRECT_URI, HeadlessOAuth, InMemoryTokenStorage
|
||||
|
||||
|
||||
def build_auth(http_client: httpx.AsyncClient) -> httpx.Auth:
|
||||
def build_auth(http_client: httpx2.AsyncClient) -> httpx2.Auth:
|
||||
"""An `OAuthClientProvider` over fresh storage, completing the authorize redirect headlessly.
|
||||
|
||||
`Client(url, auth=...)` doesn't exist yet, so the harness threads this onto the underlying
|
||||
`httpx.AsyncClient` and every target `main` receives is already routed through it.
|
||||
`httpx2.AsyncClient` and every target `main` receives is already routed through it.
|
||||
"""
|
||||
headless = HeadlessOAuth()
|
||||
headless.bind(http_client)
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ the client and server side.
|
|||
|
||||
- `client.py` `main` — opens with `async with Client(target, mode=mode) as
|
||||
client:` and that's the whole program. `target` is a transport that already
|
||||
carries the OAuth `httpx.Auth`; the body never touches a token.
|
||||
carries the OAuth `httpx2.Auth`; the body never touches a token.
|
||||
- `client.py` `build_auth` — five lines of `ClientCredentialsOAuthProvider`
|
||||
config is all the caller writes; the SDK does RFC 9728 PRM →
|
||||
RFC 8414 AS-metadata discovery and token exchange on the first 401.
|
||||
|
|
@ -50,13 +50,13 @@ the client and server side.
|
|||
|
||||
- `Client(url, auth=build_auth(http))` is the ergonomic the SDK is missing —
|
||||
`Client(url)` has no `auth=` passthrough. Until it lands, the authed
|
||||
`httpx.AsyncClient` → `streamable_http_client(url, http_client=hc)` chain has
|
||||
`httpx2.AsyncClient` → `streamable_http_client(url, http_client=hc)` chain has
|
||||
to be built *outside* `main` and handed in as `target`; both `run_client`
|
||||
(the standalone `--http` run) and the test harness do that from the
|
||||
`build_auth` export.
|
||||
- `transport_security=NO_DNS_REBIND` — DNS-rebinding protection is on by
|
||||
default for localhost binds; the harness disables it because the in-process
|
||||
httpx client sends no `Origin` header. Drop the kwarg for a real deployment.
|
||||
httpx2 client sends no `Origin` header. Drop the kwarg for a real deployment.
|
||||
- `OAuthMetadata.authorization_endpoint` is a required field even though a
|
||||
`client_credentials`-only AS has no authorize endpoint; the server sets a
|
||||
dummy URL.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"""HTTP-only: ``build_auth`` returns a ``ClientCredentialsOAuthProvider``; ``whoami`` round-trips client_id + scopes."""
|
||||
|
||||
import httpx
|
||||
import httpx2
|
||||
|
||||
from mcp.client import Client
|
||||
from mcp.client.auth.extensions.client_credentials import ClientCredentialsOAuthProvider
|
||||
|
|
@ -13,12 +13,12 @@ from stories._shared.auth import MCP_URL, InMemoryTokenStorage
|
|||
from .server import DEMO_CLIENT_ID, DEMO_CLIENT_SECRET, DEMO_SCOPE
|
||||
|
||||
|
||||
def build_auth(_http: httpx.AsyncClient) -> httpx.Auth:
|
||||
"""The ``httpx.Auth`` for the ``client_credentials`` grant — five lines of provider config.
|
||||
def build_auth(_http: httpx2.AsyncClient) -> httpx2.Auth:
|
||||
"""The ``httpx2.Auth`` for the ``client_credentials`` grant — five lines of provider config.
|
||||
|
||||
The SDK then handles 401 → RFC 9728 PRM → RFC 8414 AS-metadata discovery → token POST →
|
||||
Bearer attachment automatically. ``Client(url)`` has no ``auth=`` passthrough yet, so the
|
||||
harness threads this onto the transport's ``httpx.AsyncClient`` and hands ``main`` the
|
||||
harness threads this onto the transport's ``httpx2.AsyncClient`` and hands ``main`` the
|
||||
already-authed ``target``.
|
||||
"""
|
||||
return ClientCredentialsOAuthProvider(
|
||||
|
|
|
|||
|
|
@ -57,7 +57,7 @@ kill "$SERVER_PID"
|
|||
release; this story calls it directly because the event-store and
|
||||
retry-interval kwargs are the point.
|
||||
- DNS-rebinding protection is disabled (`transport_security=NO_DNS_REBIND`)
|
||||
because the in-process httpx client sends no `Origin` header. Drop the kwarg
|
||||
because the in-process httpx2 client sends no `Origin` header. Drop the kwarg
|
||||
for a real deployment.
|
||||
- `event_store.py` here is example-grade only (sequential IDs, no eviction). A
|
||||
production server would back the `EventStore` interface with persistent
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ kill "$SERVER_PID"
|
|||
## Caveats
|
||||
|
||||
- DNS-rebinding protection is disabled via `transport_security=NO_DNS_REBIND`
|
||||
because the in-process httpx client sends no `Origin` header. Drop the kwarg
|
||||
because the in-process httpx2 client sends no `Origin` header. Drop the kwarg
|
||||
for a real deployment.
|
||||
- Neither `MCPServer` nor lowlevel `Server` auto-advertises
|
||||
`resources.listChanged: true` in capabilities, and `MCPServer` exposes no knob
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ kill "$SERVER_PID"
|
|||
## Caveats
|
||||
|
||||
- `transport_security=NO_DNS_REBIND` — DNS-rebinding protection is on by default
|
||||
for localhost binds; the harness disables it because the in-process httpx
|
||||
for localhost binds; the harness disables it because the in-process httpx2
|
||||
client sends no `Origin` header. Drop the kwarg for a real deployment.
|
||||
- `streamable_http_app()` reshapes in a later release; the call is isolated in
|
||||
`build_app()` so the change touches one line per server file.
|
||||
|
|
|
|||
Loading…
Reference in a new issue