-
Notifications
You must be signed in to change notification settings - Fork 2.7k
Add streamable_http_client which accepts httpx.AsyncClient instead of httpx_client_factory
#1177
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Conversation
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The deprecated wrapper is missing the httpx_client_factory and auth parameters. This could break existing code using those parameters.
(it's probably a good time to replace httpx_client_factory with an object)
|
I blame copilot. 😄 |
I think the timeouts, the auth and the httpx_client_factory CAN be replaced. The timeouts I think can be subjective, but the auth and the httpx_client_factory I think need to be replaced. |
should we just replace it now, since we are deprecating one method, would be good to have a nice alternative and not to deprecate it twice? |
I didn't get what you said. I agree that it's a good moment to change the parameters of the new |
|
Should we do this and deprecate the old module? |
|
FYI for viewers: on my todo list to update this |
222b13f to
74fa629
Compare
outdated - pushed updated version making httpx_client a param instead of factory
src/mcp/client/streamable_http.py
Outdated
| self.session_id = None | ||
| self.protocol_version = None | ||
| self.request_headers = { | ||
| **self.headers, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Previously we would have streamablehttp_client create the httpx.AsyncClient via a factory after initializing the transport. That means we could use these transport.request_headers when creating the httpx.AsyncClient to ensure the client and the transport have the same headers.
If we now accept httpx_client as an argument to streamable_http_client, that client might have custom headers! In fact by default httpx.AsyncClient creates headers here that need to be overriden (hence moving the ACCEPT and CONTENT_TYPE after), for example accept: */*
Therefore the transport needs to override these headers now if they're present to be configured correctly.
streamablehttp_client to streamable_http_clientstreamable_http_client which accepts httpx.AsyncClient instead of httpx_client_factory
7a6c89d to
43dec02
Compare
| # Handle timeout | ||
| if timeout is None: | ||
| kwargs["timeout"] = httpx.Timeout(30.0) | ||
| kwargs["timeout"] = httpx.Timeout(MCP_DEFAULT_TIMEOUT, read=MCP_DEFAULT_SSE_READ_TIMEOUT) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Previously we'd rely on the transport setting this to 60 * 5 when creating our client, but we can't rely on that anymore as the client may now be created before the transport.
deac57e to
a14eeb2
Compare
| async with streamablehttp_client("http://localhost:8001/mcp", auth=oauth_auth) as (read, write, _): | ||
| async with ClientSession(read, write) as session: | ||
| await session.initialize() | ||
| async with httpx.AsyncClient(auth=oauth_auth, follow_redirects=True) as custom_client: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
would it be worth adding a comment like # optionally create custom httpx.AsyncClient since by default this would work without creating the custom client here (I assume)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actually in this case you do need the custom client because that's the way to add auth now
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ahh ok makes sense. Hmm, well then what if you also want the default timeouts we've set for MCP (re: #1177 (comment))
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You'd have to set up the timeouts yourself if you're setting up the client yourself. To avoid surprising the user with unexpected behaviors, if they create their own custom client we should assume they want to control the pieces.
There's a valid point though about potentially making _httpx_utils public and thus allowing people to use create_mcp_http_client. Not sure if that's really very valuable though because it's a very thin wrapper 🤔
|
|
||
| def create_mcp_http_client( | ||
| headers: dict[str, str] | None = None, | ||
| timeout: httpx.Timeout | None = None, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
(non blocking question) why do we not set the timeout default here instead of doing the check inside the function?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Seems like a stylistic choice, I just went with the existing pattern but it's a fair point it could be done differently.
src/mcp/client/streamable_http.py
Outdated
| timeout = client.timeout.connect if (client.timeout and client.timeout.connect is not None) else MCP_DEFAULT_TIMEOUT | ||
| sse_read_timeout = ( | ||
| client.timeout.read if (client.timeout and client.timeout.read is not None) else MCP_DEFAULT_SSE_READ_TIMEOUT | ||
| ) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If the client is provided, why do we need to do this? 🤔
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hm, the mechanical reason is because we changed the ordering in which things get created.
In the previous model we created the transport first with the settings (see line 473 before this change). Then we used the client factory to create the client with the same settings that we added to the transport.
In order for the two to match, we need to do the inverse now as the client can be created first. However, I see your point, there's some redundancy as the client is then used in the handlers...
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I guess there's a question whether StreamableHTTPTransport still needs all these arguments in the first place if we're supporting explicit creation of the client object?
headers: dict[str, str] | None = None,
timeout: float | timedelta = 30,
sse_read_timeout: float | timedelta = 60 * 5,
auth: httpx.Auth | None = None,
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I guess the decision we need to make is whether the client passed in is the source of truth on all configuration matters (headers, timeout, auth) or not. And if not, which things does the transport definitively need to override.
In the current model, the factory creates a client with the settings passed to streamablehttp_client, which creates some duplication. Then in handle_get_stream / post_writer / _handle_post_request the transport seems to override headers, timeout, sse_read_timeout again, but not always. So there's some unclear division of responsibility between which settings the client owns and which ones the transport owns.
If we follow a principle of "least surprise", I think we shouldn't be overriding anything if a client is explicitly constructed by the user of this API and passed in. So ideally the API of StreamableHTTPTransport becomes just StreamableHTTPTransport(url) and that's it, no other settings on the transport. The client owns all the settings and the transport takes the client as given.
And in the case where a user doesn't pass in a client they configured themself, we configure a "sane" client that matches the current defaults, but we let the client fully own that configuration.
Does that make sense?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
e4e0d48 to
90af4b2
Compare
Modernize streamable_http_client API by accepting httpx.AsyncClient instances directly instead of factory functions, following industry standards. - New API: httpx_client: httpx.AsyncClient | None parameter - Default client created with recommended timeouts if None - Deprecated wrapper provides backward compatibility - Updated examples to show custom client usage - Add MCP_DEFAULT_TIMEOUT constants to _httpx_utils
This commit fixes all test failures introduced by the API change from httpx_client_factory to direct httpx_client parameter: 1. Updated deprecated imports: Changed streamablehttp_client to streamable_http_client in test files 2. Fixed 307 redirect errors: Replaced httpx.AsyncClient with create_mcp_http_client which includes follow_redirects=True by default 3. Fixed test assertion: Updated test_session_group.py to mock create_mcp_http_client and verify the new API signature where streamable_http_client receives httpx_client parameter instead of individual headers/timeout parameters 4. Removed unused httpx import from main.py after inlining client creation All tests now pass with the new API.
Added missing type annotation imports that were causing NameError and preventing test collection: - RequestResponder from mcp.shared.session - SessionMessage from mcp.shared.message - GetSessionIdCallback from mcp.client.streamable_http - RequestContext from mcp.shared.context This fixes 4 NameError collection failures and 10 F821 ruff errors, allowing all 20 tests in the file to be properly collected and executed.
Restore type annotations on test function parameters in test_streamable_http.py that were accidentally removed during the function renaming from streamablehttp_client to streamable_http_client. Added type annotations to: - Fixture parameters: basic_server, basic_server_url, json_response_server, json_server_url, event_server, monkeypatch - Test function parameters: initialized_client_session This fixes all 61 pyright errors and ensures type safety matches the main branch standards.
Remove complex mocking of create_mcp_http_client in the streamablehttp test case. Instead, let the real create_mcp_http_client execute and only verify that streamable_http_client receives the correct parameters including a real httpx.AsyncClient instance. This simplifies the test by: - Removing 13 lines of mock setup code - Removing 14 lines of mock verification code - Removing 3 lines of mock cleanup code - Trusting that create_mcp_http_client works (it has its own tests) The test now focuses on verifying the integration between session_group and streamable_http_client rather than re-testing create_mcp_http_client.
This commit addresses two API design concerns: 1. Remove private module usage in examples: Examples no longer import from the private mcp.shared._httpx_utils module. Instead, they create httpx clients directly using the public httpx library. 2. Rename httpx_client parameter to http_client: The 'httpx_client' parameter name was redundant since the type annotation already specifies it's an httpx.AsyncClient. Renaming to 'http_client' provides a cleaner, more concise API. Changes: - Updated oauth_client.py and simple-auth-client examples to use public APIs - Renamed httpx_client to http_client in function signatures - Updated all internal callers and tests - Updated deprecated streamablehttp_client wrapper function
Remove client.headers.update() call that was unnecessarily mutating user-provided httpx.AsyncClient instances. The mutation was defensive but unnecessary since: 1. All transport methods pass headers explicitly to httpx requests 2. httpx merges request headers with client defaults, with request headers taking precedence 3. HTTP requests are identical with or without the mutation 4. Not mutating respects user's client object integrity Add comprehensive test coverage for header behavior: - Verify client headers are not mutated after use - Verify MCP protocol headers override httpx defaults in requests - Verify custom and MCP headers coexist correctly in requests All existing tests pass, confirming no behavior change to actual HTTP requests.
7b05e41 to
579c35c
Compare
…ation Implements "principle of least surprise" by making the httpx client the single source of truth for HTTP configuration (headers, timeout, auth). Changes: - StreamableHTTPTransport constructor now only takes url parameter - Transport reads configuration from client when making requests - Removed redundant config extraction and storage - Removed headers and sse_read_timeout from RequestContext - Removed MCP_DEFAULT_TIMEOUT and MCP_DEFAULT_SSE_READ_TIMEOUT from _httpx_utils public API (__all__) This addresses PR feedback about awkward config extraction when client is provided. The transport now only adds protocol requirements (MCP headers, session headers) on top of the client's configuration rather than extracting and overriding it. All tests pass, no type errors.
579c35c to
94df64b
Compare
The current name is not very intuitive considering the class name and the module.