Skip to content

StreamableHttp client transport #573

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

Merged
merged 48 commits into from
May 2, 2025
Merged

StreamableHttp client transport #573

merged 48 commits into from
May 2, 2025

Conversation

ihrpr
Copy link
Contributor

@ihrpr ihrpr commented Apr 23, 2025

Adding support for Streamable Http client transport

Follow ups

  • Resumability
  • Example for fast mcp
  • client example

Stacked on top of #561

@ihrpr
Copy link
Contributor Author

ihrpr commented Apr 29, 2025

Looks reasonable - some questions. Also it would be nice to refactor this to be less nested (I will re-review quick if you decide to - also, I asked claude what it thought for a refactor - I don't love it but it could be a good start to iterate on)

refactoring after implementation of all the features: #595

Comment on lines +41 to +42
timeout: timedelta = timedelta(seconds=30),
sse_read_timeout: timedelta = timedelta(seconds=60 * 5),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❤️

async with httpx.AsyncClient(
headers=request_headers,
timeout=httpx.Timeout(timeout.seconds, read=sse_read_timeout.seconds),
follow_redirects=True,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We are constructing AsyncClient everywhere in the codebase but with various different options. I think it makes sense if we just have factory function that creates an async cleint with correct default values that we want everywhere, like follow_redirects.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

added to follow ups

headers: dict[str, Any] | None = None,
timeout: timedelta = timedelta(seconds=30),
sse_read_timeout: timedelta = timedelta(seconds=60 * 5),
):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should have a return value.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe i missed this, but all the other transport implementation don't return a terminate_callback. Should we unify this so that the interface is the same?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

streamable http is quite unique here as we have a specific DELETE request to close the session, none of the other transports have it

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adding return value in #595

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

for termination, unlike other transports, streamable http does not close session when client disconnects, it requires explicit termination by DELETE request. Session has no idea what delete request is, hence needed to have a callback to transport.

This has it's drawbacks, like users need to know that they need to terminate the session. Alternative can be that we pass a parameter terminate_session_on_exit which defaults to true. In this way if someone wants to have benefits of resuming a long running session later, they can, they just need to set a parameter to False, something like

 async with streamablehttp_client(url, terminate_session=False) as (

) as client:
tg.start_soon(post_writer, client)
try:
yield read_stream, write_stream, terminate_session
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am not sure i understand why we want terminate session as a callback. We are using a context manager, shouldn't we always be able to just terminate_session if it still exists after we yielded?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we need this so that we can have a method on a client to delete/terminate the mcp-session

Comment on lines +160 to +191
if content_type.startswith(CONTENT_TYPE_JSON):
try:
content = await response.aread()
json_message = JSONRPCMessage.model_validate_json(
content
)
await read_stream_writer.send(json_message)
except Exception as exc:
logger.error(f"Error parsing JSON response: {exc}")
await read_stream_writer.send(exc)

elif content_type.startswith(CONTENT_TYPE_SSE):
# Parse SSE events from the response
try:
event_source = EventSource(response)
async for sse in event_source.aiter_sse():
if sse.event == "message":
try:
await read_stream_writer.send(
JSONRPCMessage.model_validate_json(
sse.data
)
)
except Exception as exc:
logger.exception("Error parsing message")
await read_stream_writer.send(exc)
else:
logger.warning(f"Unknown event: {sse.event}")

except Exception as e:
logger.exception("Error reading SSE stream:")
await read_stream_writer.send(e)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would feel more intuitive for me if these branches wouldn't be inlined but separate handler functions for non-streamed vs streamed responses.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yep, sorry, refactoring on top of the stack

Base automatically changed from ihrpr/get-sse to main May 2, 2025 12:52
@ihrpr ihrpr dismissed jerome3o-anthropic’s stale review May 2, 2025 12:52

The base branch was changed.

@ihrpr ihrpr enabled auto-merge (squash) May 2, 2025 12:58
@ihrpr ihrpr disabled auto-merge May 2, 2025 12:58
@ihrpr ihrpr merged commit 9dfc925 into main May 2, 2025
10 checks passed
@ihrpr ihrpr deleted the ihrpr/client branch May 2, 2025 12:59
@Akshit97
Copy link

Akshit97 commented May 2, 2025

@ihrpr Thanks for this. When are we planning to release this? I see that this PR has been merged but not released yet.

@HarrisonUnifyAI
Copy link

I am also curious when the maintainers think they'll make a release. If they think it may be a while, I'll gladly try working off of main

@ihrpr
Copy link
Contributor Author

ihrpr commented May 7, 2025

I am also curious when the maintainers think they'll make a release. If they think it may be a while, I'll gladly try working off of main

the release is planned end of this week/early next week

@josh-newman josh-newman mentioned this pull request May 8, 2025
9 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

5 participants