Add on_session_created callback option (#1710)

This commit is contained in:
Camila Rondinini 2025-12-01 17:48:33 +00:00 • committed by GitHub
commit 2cd178a962
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 68 additions and 2 deletions

View file

@ -1,7 +1,8 @@
import logging import logging
from collections.abc import Callable
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from typing import Any from typing import Any
from urllib.parse import urljoin, urlparse from urllib.parse import parse_qs, urljoin, urlparse
import anyio import anyio
import httpx import httpx
@ -21,6 +22,11 @@ def remove_request_params(url: str) -> str:
return urljoin(url, urlparse(url).path) return urljoin(url, urlparse(url).path)
def _extract_session_id_from_endpoint(endpoint_url: str) -> str | None:
query_params = parse_qs(urlparse(endpoint_url).query)
return query_params.get("sessionId", [None])[0] or query_params.get("session_id", [None])[0]
@asynccontextmanager @asynccontextmanager
async def sse_client( async def sse_client(
url: str, url: str,
@ -29,6 +35,7 @@ async def sse_client(
sse_read_timeout: float = 60 * 5, sse_read_timeout: float = 60 * 5,
httpx_client_factory: McpHttpClientFactory = create_mcp_http_client, httpx_client_factory: McpHttpClientFactory = create_mcp_http_client,
auth: httpx.Auth | None = None, auth: httpx.Auth | None = None,
on_session_created: Callable[[str], None] | None = None,
): ):
""" """
Client transport for SSE. Client transport for SSE.
@ -42,6 +49,7 @@ async def sse_client(
timeout: HTTP timeout for regular operations. timeout: HTTP timeout for regular operations.
sse_read_timeout: Timeout for SSE read operations. sse_read_timeout: Timeout for SSE read operations.
auth: Optional HTTPX authentication handler. auth: Optional HTTPX authentication handler.
on_session_created: Optional callback invoked with the session ID when received.
""" """
read_stream: MemoryObjectReceiveStream[SessionMessage | Exception] read_stream: MemoryObjectReceiveStream[SessionMessage | Exception]
read_stream_writer: MemoryObjectSendStream[SessionMessage | Exception] read_stream_writer: MemoryObjectSendStream[SessionMessage | Exception]
@ -89,6 +97,11 @@ async def sse_client(
logger.error(error_msg) # pragma: no cover logger.error(error_msg) # pragma: no cover
raise ValueError(error_msg) # pragma: no cover raise ValueError(error_msg) # pragma: no cover
if on_session_created:
session_id = _extract_session_id_from_endpoint(endpoint_url)
if session_id:
on_session_created(session_id)
task_status.started(endpoint_url) task_status.started(endpoint_url)
case "message": case "message":

View file

@ -4,6 +4,7 @@ import socket
import time import time
from collections.abc import AsyncGenerator, Generator from collections.abc import AsyncGenerator, Generator
from typing import Any from typing import Any
from unittest.mock import Mock
import anyio import anyio
import httpx import httpx
@ -16,9 +17,10 @@ from starlette.requests import Request
from starlette.responses import Response from starlette.responses import Response
from starlette.routing import Mount, Route from starlette.routing import Mount, Route
import mcp.client.sse
import mcp.types as types import mcp.types as types
from mcp.client.session import ClientSession from mcp.client.session import ClientSession
from mcp.client.sse import sse_client from mcp.client.sse import _extract_session_id_from_endpoint, sse_client
from mcp.server import Server from mcp.server import Server
from mcp.server.sse import SseServerTransport from mcp.server.sse import SseServerTransport
from mcp.server.transport_security import TransportSecuritySettings from mcp.server.transport_security import TransportSecuritySettings
@ -184,6 +186,57 @@ async def test_sse_client_basic_connection(server: None, server_url: str) -> Non
assert isinstance(ping_result, EmptyResult) assert isinstance(ping_result, EmptyResult)
@pytest.mark.anyio
async def test_sse_client_on_session_created(server: None, server_url: str) -> None:
captured_session_id: str | None = None
def on_session_created(session_id: str) -> None:
nonlocal captured_session_id
captured_session_id = session_id
async with sse_client(server_url + "/sse", on_session_created=on_session_created) as streams:
async with ClientSession(*streams) as session:
result = await session.initialize()
assert isinstance(result, InitializeResult)
assert captured_session_id is not None
assert len(captured_session_id) > 0
@pytest.mark.parametrize(
"endpoint_url,expected",
[
("/messages?sessionId=abc123", "abc123"),
("/messages?session_id=def456", "def456"),
("/messages?sessionId=abc&session_id=def", "abc"),
("/messages?other=value", None),
("/messages", None),
("", None),
],
)
def test_extract_session_id_from_endpoint(endpoint_url: str, expected: str | None) -> None:
assert _extract_session_id_from_endpoint(endpoint_url) == expected
@pytest.mark.anyio
async def test_sse_client_on_session_created_not_called_when_no_session_id(
server: None, server_url: str, monkeypatch: pytest.MonkeyPatch
) -> None:
callback_mock = Mock()
def mock_extract(url: str) -> None:
return None
monkeypatch.setattr(mcp.client.sse, "_extract_session_id_from_endpoint", mock_extract)
async with sse_client(server_url + "/sse", on_session_created=callback_mock) as streams:
async with ClientSession(*streams) as session:
result = await session.initialize()
assert isinstance(result, InitializeResult)
callback_mock.assert_not_called()
@pytest.fixture @pytest.fixture
async def initialized_sse_client_session(server: None, server_url: str) -> AsyncGenerator[ClientSession, None]: async def initialized_sse_client_session(server: None, server_url: str) -> AsyncGenerator[ClientSession, None]:
async with sse_client(server_url + "/sse", sse_read_timeout=0.5) as streams: async with sse_client(server_url + "/sse", sse_read_timeout=0.5) as streams: