Repository navigation
Expand file tree
/
Copy path_httpx_utils.py
More file actions
222 lines (186 loc) · 9.27 KB
/
Copy path_httpx_utils.py
File metadata and controls
222 lines (186 loc) · 9.27 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
"""Utilities for creating and using httpx2 AsyncClient instances in the MCP transports."""
from abc import ABC, abstractmethod
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
from typing import Any, Protocol
import httpx2
__all__ = ["create_mcp_http_client", "MCP_DEFAULT_TIMEOUT", "MCP_DEFAULT_SSE_READ_TIMEOUT"]
# Default MCP timeout configuration
MCP_DEFAULT_TIMEOUT = 30.0 # General operations (seconds)
MCP_DEFAULT_SSE_READ_TIMEOUT = 300.0 # SSE streams - 5 minutes (seconds)
# The headers httpx2.AsyncClient.sse() adds to an event-stream request.
_SSE_HEADERS = {"Accept": "text/event-stream", "Cache-Control": "no-store"}
# How many redirects one auth-flow request may follow within its origin (see RedirectAwareAuth).
_AUTH_REDIRECT_LIMIT = 5
class McpHttpClientFactory(Protocol): # pragma: no branch
def __call__( # pragma: no branch
self,
headers: dict[str, str] | None = None,
timeout: httpx2.Timeout | None = None,
auth: httpx2.Auth | None = None,
) -> httpx2.AsyncClient: ...
def create_mcp_http_client(
headers: dict[str, str] | None = None,
timeout: httpx2.Timeout | None = None,
auth: httpx2.Auth | None = None,
) -> httpx2.AsyncClient:
"""Create an httpx2 AsyncClient with the MCP transports' default timeouts.
The client uses a 30-second timeout for connect/write/pool and a 300-second
read timeout, because a server may hold a response stream open. Redirect
following is left at the httpx2 default (off): the MCP transports follow
redirects within the endpoint's origin themselves, see `stream_within_origin`.
Args:
headers: Optional headers to include with all requests.
timeout: Request timeout as httpx2.Timeout object. Defaults to 30s for
connect/write/pool and 300s for read (for long-lived SSE streams).
auth: Optional authentication handler.
Returns:
Configured httpx2.AsyncClient instance.
Note:
The returned AsyncClient must be used as a context manager to ensure
proper cleanup of connections.
"""
if timeout is None:
timeout = httpx2.Timeout(MCP_DEFAULT_TIMEOUT, read=MCP_DEFAULT_SSE_READ_TIMEOUT)
kwargs: dict[str, Any] = {"timeout": timeout}
if headers is not None:
kwargs["headers"] = headers
if auth is not None: # pragma: no cover
kwargs["auth"] = auth
return httpx2.AsyncClient(**kwargs)
def _within_origin(url: httpx2.URL, location: httpx2.URL) -> bool:
"""Whether `location` is on `url`'s origin, or is its https upgrade on the default ports.
httpx2 normalises a scheme's default port to None and lower-cases hosts, so
plain tuple comparison is exact. The upgrade rule is the one httpx2 itself
uses to decide a redirect has not left the origin (`_is_https_redirect`).
"""
if (url.scheme, url.host, url.port) == (location.scheme, location.host, location.port):
return True
return (
url.host == location.host
and url.scheme == "http"
and url.port is None
and location.scheme == "https"
and location.port is None
)
def next_request_within_origin(response: httpx2.Response) -> httpx2.Request | None:
"""The request that follows `response`'s redirect, if it is one the MCP transports follow.
That is when httpx2 built a next request for it (a redirect status with a
Location), the next request keeps the method (307/308, or any redirect of a
GET: httpx2 turns a POST into a body-less GET for 301/302/303, which would
drop the message), its URL stays within the origin of the request just sent
(same scheme, host and port, or http to https on the same host with default
ports), and the Location does not bring userinfo of its own (which httpx2
would otherwise send as Basic auth; userinfo the configured URL already had
is kept by a relative Location and is fine). None for anything else,
including a non-redirect.
"""
next_request = response.next_request
if next_request is None:
return None
sent = response.request
if (
next_request.method != sent.method
or (next_request.url.userinfo and next_request.url.userinfo != sent.url.userinfo)
or not _within_origin(sent.url, next_request.url)
):
return None
return next_request
@asynccontextmanager
async def stream_within_origin(
client: httpx2.AsyncClient, method: str, url: httpx2.URL | str, **kwargs: Any
) -> AsyncGenerator[httpx2.Response]:
"""`client.stream(...)`, following redirects only while they stay within the request's origin.
An MCP transport talks to one configured endpoint, and everything on a request
(headers, auth, body) was configured for that endpoint. A redirect that
`next_request_within_origin` accepts, such as a 307/308 trailing-slash
normalisation, is followed, at most `client.max_redirects` times. Any other
redirect (or one past that budget) is not followed: the redirect response
itself is yielded, the way httpx2 hands one back when `follow_redirects` is
off, and the caller treats it as the non-success it is. The client's own
`follow_redirects` setting is not consulted. Requests an `httpx2.Auth` flow
makes during the call are sent without following either; the SDK's OAuth
providers apply the same rule to their own requests.
"""
request = client.build_request(method, url, **kwargs)
followed = 0
while True:
response = await client.send(request, stream=True, follow_redirects=False)
next_request = next_request_within_origin(response)
if next_request is None or followed == client.max_redirects:
break
try:
# Drain the redirect body so the connection returns to the pool, as httpx2 does when it follows.
await response.aread()
finally:
await response.aclose()
request = next_request
followed += 1
try:
yield response
finally:
await response.aclose()
async def request_within_origin(
client: httpx2.AsyncClient, method: str, url: httpx2.URL | str, **kwargs: Any
) -> httpx2.Response:
"""`client.request(...)` with the redirect handling of `stream_within_origin`."""
async with stream_within_origin(client, method, url, **kwargs) as response:
await response.aread()
return response
@asynccontextmanager
async def sse_within_origin(
client: httpx2.AsyncClient,
url: httpx2.URL | str,
*,
headers: dict[str, str] | None = None,
max_event_size: int | None = 1024 * 1024,
) -> AsyncGenerator[httpx2.EventSource]:
"""`client.sse(url)` with the redirect handling of `stream_within_origin`."""
merged = httpx2.Headers(_SSE_HEADERS)
merged.update(headers or {})
async with stream_within_origin(client, "GET", url, headers=merged) as response:
yield httpx2.EventSource(response, max_event_size=max_event_size)
def redirect_location(response: httpx2.Response) -> httpx2.URL | None:
"""Where `response` redirects to, for use in a message: without userinfo, query or fragment,
which can carry state that does not belong in an error or a log line. None if not a redirect."""
if response.next_request is None:
return None
return response.next_request.url.copy_with(userinfo=b"", query=None, fragment=None)
def redirect_note(response: httpx2.Response) -> str:
"""A suffix naming the location of a redirect response that was not followed, else empty."""
location = redirect_location(response)
if location is None:
return ""
return f" (redirected to {location}; not followed)"
class RedirectAwareAuth(ABC, httpx2.Auth):
"""An `httpx2.Auth` whose own requests follow redirects the way MCP transport requests do.
The transports send every request with redirect following off and follow a
redirect themselves only within the endpoint's origin (`stream_within_origin`).
httpx2 applies that per-request setting to the requests an auth flow makes
too (metadata discovery, registration, token), so on their own those would
follow nothing. Subclasses write their flow as `_auth_flow`; this class
drives it and, for each request the flow makes other than the one being
authenticated, follows a redirect that `next_request_within_origin` accepts,
up to `_AUTH_REDIRECT_LIMIT` times. Any other redirect response is handed
to the flow as it is.
"""
@abstractmethod
def _auth_flow(self, request: httpx2.Request) -> AsyncGenerator[httpx2.Request, httpx2.Response]:
"""The subclass's flow, written as `httpx2.Auth.async_auth_flow` otherwise would be."""
async def async_auth_flow(self, request: httpx2.Request) -> AsyncGenerator[httpx2.Request, httpx2.Response]:
flow = self._auth_flow(request)
try:
outgoing = await flow.__anext__()
while True:
response = yield outgoing
if outgoing is not request:
for _ in range(_AUTH_REDIRECT_LIMIT):
follow = next_request_within_origin(response)
if follow is None:
break
response = yield follow
outgoing = await flow.asend(response)
except StopAsyncIteration:
return
finally:
await flow.aclose()