From 1b5fcdc1f22b5a6f2e46ea1c81a62c6d5615d48e Mon Sep 17 00:00:00 2001 From: ihrpr Date: Thu, 8 May 2025 00:02:11 +0100 Subject: [PATCH 1/5] introduce a function to create a stnadard AsyncClient with options --- .../simple-auth/mcp_simple_auth/server.py | 6 +- .../simple-tool/mcp_simple_tool/server.py | 4 +- src/mcp/client/sse.py | 3 +- src/mcp/client/streamable_http.py | 4 +- src/mcp/shared/httpx_utils.py | 65 +++++++++++++++++++ 5 files changed, 74 insertions(+), 8 deletions(-) create mode 100644 src/mcp/shared/httpx_utils.py diff --git a/examples/servers/simple-auth/mcp_simple_auth/server.py b/examples/servers/simple-auth/mcp_simple_auth/server.py index 7cd92aa79..4501d0ece 100644 --- a/examples/servers/simple-auth/mcp_simple_auth/server.py +++ b/examples/servers/simple-auth/mcp_simple_auth/server.py @@ -6,7 +6,6 @@ from typing import Any import click -import httpx from pydantic import AnyHttpUrl from pydantic_settings import BaseSettings, SettingsConfigDict from starlette.exceptions import HTTPException @@ -25,6 +24,7 @@ from mcp.server.auth.settings import AuthSettings, ClientRegistrationOptions from mcp.server.fastmcp.server import FastMCP from mcp.shared.auth import OAuthClientInformationFull, OAuthToken +from mcp.shared.httpx_utils import create_mcp_http_client logger = logging.getLogger(__name__) @@ -123,7 +123,7 @@ async def handle_github_callback(self, code: str, state: str) -> str: client_id = state_data["client_id"] # Exchange code for token with GitHub - async with httpx.AsyncClient() as client: + async with create_mcp_http_client() as client: response = await client.post( self.settings.github_token_url, data={ @@ -325,7 +325,7 @@ async def get_user_profile() -> dict[str, Any]: """ github_token = get_github_token() - async with httpx.AsyncClient() as client: + async with create_mcp_http_client() as client: response = await client.get( "https://api.github.com/user", headers={ diff --git a/examples/servers/simple-tool/mcp_simple_tool/server.py b/examples/servers/simple-tool/mcp_simple_tool/server.py index 04224af5d..8104f2146 100644 --- a/examples/servers/simple-tool/mcp_simple_tool/server.py +++ b/examples/servers/simple-tool/mcp_simple_tool/server.py @@ -1,8 +1,8 @@ import anyio import click -import httpx import mcp.types as types from mcp.server.lowlevel import Server +from mcp.shared.httpx_utils import create_mcp_http_client async def fetch_website( @@ -11,7 +11,7 @@ async def fetch_website( headers = { "User-Agent": "MCP Test Server (github.com/modelcontextprotocol/python-sdk)" } - async with httpx.AsyncClient(follow_redirects=True, headers=headers) as client: + async with create_mcp_http_client(headers=headers) as client: response = await client.get(url) response.raise_for_status() return [types.TextContent(type="text", text=response.text)] diff --git a/src/mcp/client/sse.py b/src/mcp/client/sse.py index ff04d2f96..a2109604c 100644 --- a/src/mcp/client/sse.py +++ b/src/mcp/client/sse.py @@ -10,6 +10,7 @@ from httpx_sse import aconnect_sse import mcp.types as types +from mcp.shared.httpx_utils import create_mcp_http_client from mcp.shared.message import SessionMessage logger = logging.getLogger(__name__) @@ -44,7 +45,7 @@ async def sse_client( async with anyio.create_task_group() as tg: try: logger.info(f"Connecting to SSE endpoint: {remove_request_params(url)}") - async with httpx.AsyncClient(headers=headers) as client: + async with create_mcp_http_client(headers=headers) as client: async with aconnect_sse( client, "GET", diff --git a/src/mcp/client/streamable_http.py b/src/mcp/client/streamable_http.py index ef424e3b3..23096cfe5 100644 --- a/src/mcp/client/streamable_http.py +++ b/src/mcp/client/streamable_http.py @@ -18,6 +18,7 @@ from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream from httpx_sse import EventSource, ServerSentEvent, aconnect_sse +from mcp.shared.httpx_utils import create_mcp_http_client from mcp.shared.message import ClientMessageMetadata, SessionMessage from mcp.types import ( ErrorData, @@ -446,12 +447,11 @@ async def streamablehttp_client( try: logger.info(f"Connecting to StreamableHTTP endpoint: {url}") - async with httpx.AsyncClient( + async with create_mcp_http_client( headers=transport.request_headers, timeout=httpx.Timeout( transport.timeout.seconds, read=transport.sse_read_timeout.seconds ), - follow_redirects=True, ) as client: # Define callbacks that need access to tg def start_get_stream() -> None: diff --git a/src/mcp/shared/httpx_utils.py b/src/mcp/shared/httpx_utils.py new file mode 100644 index 000000000..c01bfd1a3 --- /dev/null +++ b/src/mcp/shared/httpx_utils.py @@ -0,0 +1,65 @@ +"""Utilities for creating standardized httpx AsyncClient instances.""" + +from typing import Any + +import httpx + + +def create_mcp_http_client( + *, + headers: dict[str, Any] | None = None, + timeout: httpx.Timeout | float | None = None, + **kwargs: Any, +) -> httpx.AsyncClient: + """Create a standardized httpx AsyncClient with MCP defaults. + + This function provides common defaults used throughout the MCP codebase: + - follow_redirects=True (always enabled) + - Default timeout of 30 seconds if not specified + - Header will be merged + + Args: + headers: Optional headers to include with all requests. + timeout: Request timeout in seconds (float) or httpx.Timeout object. + Defaults to 30 seconds if not specified. + **kwargs: Additional keyword arguments to pass to AsyncClient. + + Returns: + Configured httpx.AsyncClient instance with MCP defaults. + + Examples: + # Basic usage with MCP defaults + async with create_mcp_http_client() as client: + response = await client.get("https://api.example.com") + + # With custom headers + headers = {"Authorization": "Bearer token"} + async with create_mcp_http_client(headers=headers) as client: + response = await client.get("/endpoint") + + # With custom timeout + timeout = httpx.Timeout(60.0, read=300.0) + async with create_mcp_http_client(timeout=timeout) as client: + response = await client.get("/long-request") + """ + # Set MCP defaults + defaults: dict[str, Any] = { + "follow_redirects": True, + } + + # Handle timeout + if timeout is None: + defaults["timeout"] = httpx.Timeout(30.0) + elif isinstance(timeout, int | float): + defaults["timeout"] = httpx.Timeout(timeout) + else: + defaults["timeout"] = timeout + + # Handle headers + if headers is not None: + kwargs["headers"] = headers + + # Merge defaults with provided kwargs + kwargs = {**defaults, **kwargs} + + return httpx.AsyncClient(**kwargs) From 56d811e73c017ea9a7863cd7f2f621a688167bcd Mon Sep 17 00:00:00 2001 From: ihrpr Date: Thu, 8 May 2025 00:30:40 +0100 Subject: [PATCH 2/5] clean up --- src/mcp/shared/httpx_utils.py | 26 +++++++++++------- tests/shared/test_httpx_utils.py | 46 ++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 9 deletions(-) create mode 100644 tests/shared/test_httpx_utils.py diff --git a/src/mcp/shared/httpx_utils.py b/src/mcp/shared/httpx_utils.py index c01bfd1a3..39fa7fa99 100644 --- a/src/mcp/shared/httpx_utils.py +++ b/src/mcp/shared/httpx_utils.py @@ -1,14 +1,18 @@ """Utilities for creating standardized httpx AsyncClient instances.""" +from __future__ import annotations + from typing import Any import httpx +__all__ = ["create_mcp_http_client"] + def create_mcp_http_client( *, headers: dict[str, Any] | None = None, - timeout: httpx.Timeout | float | None = None, + timeout: httpx.Timeout | None = None, **kwargs: Any, ) -> httpx.AsyncClient: """Create a standardized httpx AsyncClient with MCP defaults. @@ -16,17 +20,21 @@ def create_mcp_http_client( This function provides common defaults used throughout the MCP codebase: - follow_redirects=True (always enabled) - Default timeout of 30 seconds if not specified - - Header will be merged + - Headers will be merged with any existing headers in kwargs Args: headers: Optional headers to include with all requests. - timeout: Request timeout in seconds (float) or httpx.Timeout object. + timeout: Request timeout as httpx.Timeout object. Defaults to 30 seconds if not specified. **kwargs: Additional keyword arguments to pass to AsyncClient. Returns: Configured httpx.AsyncClient instance with MCP defaults. + Note: + The returned AsyncClient must be used as a context manager to ensure + proper cleanup of connections. + Examples: # Basic usage with MCP defaults async with create_mcp_http_client() as client: @@ -50,16 +58,16 @@ def create_mcp_http_client( # Handle timeout if timeout is None: defaults["timeout"] = httpx.Timeout(30.0) - elif isinstance(timeout, int | float): - defaults["timeout"] = httpx.Timeout(timeout) else: defaults["timeout"] = timeout - # Handle headers + # Handle headers with proper merging if headers is not None: - kwargs["headers"] = headers + existing_headers = kwargs.get("headers", {}) + merged_headers = {**existing_headers, **headers} + kwargs["headers"] = merged_headers - # Merge defaults with provided kwargs - kwargs = {**defaults, **kwargs} + # Merge kwargs with defaults (defaults take precedence) + kwargs = {**kwargs, **defaults} return httpx.AsyncClient(**kwargs) diff --git a/tests/shared/test_httpx_utils.py b/tests/shared/test_httpx_utils.py new file mode 100644 index 000000000..b7a268d8b --- /dev/null +++ b/tests/shared/test_httpx_utils.py @@ -0,0 +1,46 @@ +"""Tests for httpx utility functions.""" + +import httpx + +from mcp.shared.httpx_utils import create_mcp_http_client + + +class TestCreateMcpHttpClient: + """Test create_mcp_http_client function.""" + + def test_default_settings(self): + """Test that default settings are applied correctly.""" + client = create_mcp_http_client() + + # Check follow_redirects is True + assert client.follow_redirects is True + + # Check default timeout is 30 seconds + assert client.timeout.connect == 30.0 + assert client.timeout.read == 30.0 + assert client.timeout.write == 30.0 + assert client.timeout.pool == 30.0 + + def test_custom_parameters(self): + """Test custom headers and timeout are set correctly.""" + headers = {"Authorization": "Bearer token", "X-Custom": "value"} + timeout = httpx.Timeout(connect=5.0, read=10.0, write=15.0, pool=20.0) + + client = create_mcp_http_client(headers=headers, timeout=timeout) + + # Check headers + assert client.headers["Authorization"] == "Bearer token" + assert client.headers["X-Custom"] == "value" + + # Check custom timeout + assert client.timeout.connect == 5.0 + assert client.timeout.read == 10.0 + assert client.timeout.write == 15.0 + assert client.timeout.pool == 20.0 + + def test_follow_redirects_enforced(self): + """Test follow_redirects is always True even if False is passed.""" + client = create_mcp_http_client(follow_redirects=False) + + # Should still be True because our defaults override user input + assert client.follow_redirects is True \ No newline at end of file From 1456b89335b0a6d2c03372bfbd56544a9cd8f65e Mon Sep 17 00:00:00 2001 From: ihrpr Date: Thu, 8 May 2025 08:19:39 +0100 Subject: [PATCH 3/5] simplifying --- src/mcp/shared/httpx_utils.py | 27 ++++++----------- tests/shared/test_httpx_utils.py | 52 +++++++++----------------------- 2 files changed, 24 insertions(+), 55 deletions(-) diff --git a/src/mcp/shared/httpx_utils.py b/src/mcp/shared/httpx_utils.py index 39fa7fa99..00af6dd86 100644 --- a/src/mcp/shared/httpx_utils.py +++ b/src/mcp/shared/httpx_utils.py @@ -10,23 +10,19 @@ def create_mcp_http_client( - *, headers: dict[str, Any] | None = None, timeout: httpx.Timeout | None = None, - **kwargs: Any, ) -> httpx.AsyncClient: """Create a standardized httpx AsyncClient with MCP defaults. This function provides common defaults used throughout the MCP codebase: - follow_redirects=True (always enabled) - Default timeout of 30 seconds if not specified - - Headers will be merged with any existing headers in kwargs Args: headers: Optional headers to include with all requests. timeout: Request timeout as httpx.Timeout object. Defaults to 30 seconds if not specified. - **kwargs: Additional keyword arguments to pass to AsyncClient. Returns: Configured httpx.AsyncClient instance with MCP defaults. @@ -42,32 +38,27 @@ def create_mcp_http_client( # With custom headers headers = {"Authorization": "Bearer token"} - async with create_mcp_http_client(headers=headers) as client: + async with create_mcp_http_client(headers) as client: response = await client.get("/endpoint") - # With custom timeout + # With both custom headers and timeout timeout = httpx.Timeout(60.0, read=300.0) - async with create_mcp_http_client(timeout=timeout) as client: + async with create_mcp_http_client(headers, timeout) as client: response = await client.get("/long-request") """ # Set MCP defaults - defaults: dict[str, Any] = { + kwargs: dict[str, Any] = { "follow_redirects": True, } # Handle timeout if timeout is None: - defaults["timeout"] = httpx.Timeout(30.0) + kwargs["timeout"] = httpx.Timeout(30.0) else: - defaults["timeout"] = timeout + kwargs["timeout"] = timeout - # Handle headers with proper merging + # Handle headers if headers is not None: - existing_headers = kwargs.get("headers", {}) - merged_headers = {**existing_headers, **headers} - kwargs["headers"] = merged_headers + kwargs["headers"] = headers - # Merge kwargs with defaults (defaults take precedence) - kwargs = {**kwargs, **defaults} - - return httpx.AsyncClient(**kwargs) + return httpx.AsyncClient(**kwargs) \ No newline at end of file diff --git a/tests/shared/test_httpx_utils.py b/tests/shared/test_httpx_utils.py index b7a268d8b..f3b992691 100644 --- a/tests/shared/test_httpx_utils.py +++ b/tests/shared/test_httpx_utils.py @@ -5,42 +5,20 @@ from mcp.shared.httpx_utils import create_mcp_http_client -class TestCreateMcpHttpClient: - """Test create_mcp_http_client function.""" +def test_default_settings(): + """Test that default settings are applied correctly.""" + client = create_mcp_http_client() + + assert client.follow_redirects is True + assert client.timeout.connect == 30.0 - def test_default_settings(self): - """Test that default settings are applied correctly.""" - client = create_mcp_http_client() - - # Check follow_redirects is True - assert client.follow_redirects is True - - # Check default timeout is 30 seconds - assert client.timeout.connect == 30.0 - assert client.timeout.read == 30.0 - assert client.timeout.write == 30.0 - assert client.timeout.pool == 30.0 - def test_custom_parameters(self): - """Test custom headers and timeout are set correctly.""" - headers = {"Authorization": "Bearer token", "X-Custom": "value"} - timeout = httpx.Timeout(connect=5.0, read=10.0, write=15.0, pool=20.0) - - client = create_mcp_http_client(headers=headers, timeout=timeout) - - # Check headers - assert client.headers["Authorization"] == "Bearer token" - assert client.headers["X-Custom"] == "value" - - # Check custom timeout - assert client.timeout.connect == 5.0 - assert client.timeout.read == 10.0 - assert client.timeout.write == 15.0 - assert client.timeout.pool == 20.0 - - def test_follow_redirects_enforced(self): - """Test follow_redirects is always True even if False is passed.""" - client = create_mcp_http_client(follow_redirects=False) - - # Should still be True because our defaults override user input - assert client.follow_redirects is True \ No newline at end of file +def test_custom_parameters(): + """Test custom headers and timeout are set correctly.""" + headers = {"Authorization": "Bearer token"} + timeout = httpx.Timeout(60.0) + + client = create_mcp_http_client(headers, timeout) + + assert client.headers["Authorization"] == "Bearer token" + assert client.timeout.connect == 60.0 \ No newline at end of file From 79cb4139011e5387a1b664445e690307f606f8df Mon Sep 17 00:00:00 2001 From: ihrpr Date: Thu, 8 May 2025 16:35:15 +0100 Subject: [PATCH 4/5] update headers to dict[str, str] --- src/mcp/shared/httpx_utils.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/mcp/shared/httpx_utils.py b/src/mcp/shared/httpx_utils.py index 00af6dd86..95080bde1 100644 --- a/src/mcp/shared/httpx_utils.py +++ b/src/mcp/shared/httpx_utils.py @@ -1,7 +1,5 @@ """Utilities for creating standardized httpx AsyncClient instances.""" -from __future__ import annotations - from typing import Any import httpx @@ -10,7 +8,7 @@ def create_mcp_http_client( - headers: dict[str, Any] | None = None, + headers: dict[str, str] | None = None, timeout: httpx.Timeout | None = None, ) -> httpx.AsyncClient: """Create a standardized httpx AsyncClient with MCP defaults. @@ -61,4 +59,4 @@ def create_mcp_http_client( if headers is not None: kwargs["headers"] = headers - return httpx.AsyncClient(**kwargs) \ No newline at end of file + return httpx.AsyncClient(**kwargs) From 3a1359f48190127d5b52e1fa04bc443fe5393146 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Thu, 8 May 2025 17:33:11 +0100 Subject: [PATCH 5/5] make http_utils private -- at least name --- examples/servers/simple-auth/mcp_simple_auth/server.py | 2 +- examples/servers/simple-tool/mcp_simple_tool/server.py | 2 +- src/mcp/client/sse.py | 2 +- src/mcp/client/streamable_http.py | 2 +- src/mcp/shared/{httpx_utils.py => _httpx_utils.py} | 0 tests/shared/test_httpx_utils.py | 10 +++++----- 6 files changed, 9 insertions(+), 9 deletions(-) rename src/mcp/shared/{httpx_utils.py => _httpx_utils.py} (100%) diff --git a/examples/servers/simple-auth/mcp_simple_auth/server.py b/examples/servers/simple-auth/mcp_simple_auth/server.py index 4501d0ece..2f1e4086f 100644 --- a/examples/servers/simple-auth/mcp_simple_auth/server.py +++ b/examples/servers/simple-auth/mcp_simple_auth/server.py @@ -23,8 +23,8 @@ ) from mcp.server.auth.settings import AuthSettings, ClientRegistrationOptions from mcp.server.fastmcp.server import FastMCP +from mcp.shared._httpx_utils import create_mcp_http_client from mcp.shared.auth import OAuthClientInformationFull, OAuthToken -from mcp.shared.httpx_utils import create_mcp_http_client logger = logging.getLogger(__name__) diff --git a/examples/servers/simple-tool/mcp_simple_tool/server.py b/examples/servers/simple-tool/mcp_simple_tool/server.py index 8104f2146..5f4e28bb7 100644 --- a/examples/servers/simple-tool/mcp_simple_tool/server.py +++ b/examples/servers/simple-tool/mcp_simple_tool/server.py @@ -2,7 +2,7 @@ import click import mcp.types as types from mcp.server.lowlevel import Server -from mcp.shared.httpx_utils import create_mcp_http_client +from mcp.shared._httpx_utils import create_mcp_http_client async def fetch_website( diff --git a/src/mcp/client/sse.py b/src/mcp/client/sse.py index a2109604c..29195cbd9 100644 --- a/src/mcp/client/sse.py +++ b/src/mcp/client/sse.py @@ -10,7 +10,7 @@ from httpx_sse import aconnect_sse import mcp.types as types -from mcp.shared.httpx_utils import create_mcp_http_client +from mcp.shared._httpx_utils import create_mcp_http_client from mcp.shared.message import SessionMessage logger = logging.getLogger(__name__) diff --git a/src/mcp/client/streamable_http.py b/src/mcp/client/streamable_http.py index 23096cfe5..183653b9a 100644 --- a/src/mcp/client/streamable_http.py +++ b/src/mcp/client/streamable_http.py @@ -18,7 +18,7 @@ from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream from httpx_sse import EventSource, ServerSentEvent, aconnect_sse -from mcp.shared.httpx_utils import create_mcp_http_client +from mcp.shared._httpx_utils import create_mcp_http_client from mcp.shared.message import ClientMessageMetadata, SessionMessage from mcp.types import ( ErrorData, diff --git a/src/mcp/shared/httpx_utils.py b/src/mcp/shared/_httpx_utils.py similarity index 100% rename from src/mcp/shared/httpx_utils.py rename to src/mcp/shared/_httpx_utils.py diff --git a/tests/shared/test_httpx_utils.py b/tests/shared/test_httpx_utils.py index f3b992691..dcc6fd003 100644 --- a/tests/shared/test_httpx_utils.py +++ b/tests/shared/test_httpx_utils.py @@ -2,13 +2,13 @@ import httpx -from mcp.shared.httpx_utils import create_mcp_http_client +from mcp.shared._httpx_utils import create_mcp_http_client def test_default_settings(): """Test that default settings are applied correctly.""" client = create_mcp_http_client() - + assert client.follow_redirects is True assert client.timeout.connect == 30.0 @@ -17,8 +17,8 @@ def test_custom_parameters(): """Test custom headers and timeout are set correctly.""" headers = {"Authorization": "Bearer token"} timeout = httpx.Timeout(60.0) - + client = create_mcp_http_client(headers, timeout) - + assert client.headers["Authorization"] == "Bearer token" - assert client.timeout.connect == 60.0 \ No newline at end of file + assert client.timeout.connect == 60.0