Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/mcp/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ dependencies = [
"mcp>=2.0.0,<3.0",
"pydantic>=2.11.7",
"httpx>=0.27.2",
"httpx2>=2.5.0",
"nanoid>=2.0.0",
"aiohttp>=3.11.11",
"aiosqlite>=0.20.0",
Expand Down
7 changes: 5 additions & 2 deletions packages/mcp/src/keycardai/mcp/client/auth/oauth/discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from typing import Any

from httpx import AsyncClient, Response
from httpx2 import Response as Httpx2Response

from ...logging_config import get_logger
from ..storage_facades import OAuthStorage
Expand Down Expand Up @@ -38,7 +39,10 @@ def __init__(
self.storage = storage
self.client_factory = client_factory or default_client_factory

async def discover_resource(self, challenge_response: Response) -> dict[str, Any]:
async def discover_resource(
self,
challenge_response: Response | Httpx2Response,
) -> dict[str, Any]:
"""
Discover protected resource metadata from 401 challenge.

Expand Down Expand Up @@ -149,4 +153,3 @@ async def discover_auth_server(
continue

raise ValueError("Failed to discover any authorization server")

Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from typing import TYPE_CHECKING, Any, Protocol

from httpx import AsyncClient, Response
from httpx2 import Response as Httpx2Response

from ...logging_config import get_logger
from ...storage import NamespacedStorage
Expand Down Expand Up @@ -225,7 +226,7 @@ async def get_auth_metadata(self) -> dict[str, Any]:

async def handle_challenge(
self,
challenge: Response,
challenge: Response | Httpx2Response,
resource_url: str
) -> bool:
"""
Expand All @@ -244,7 +245,10 @@ async def handle_challenge(
Returns:
True if challenge was handled successfully, False otherwise
"""
if not isinstance(challenge, Response) or challenge.status_code != 401:
if (
not isinstance(challenge, (Response, Httpx2Response))
or challenge.status_code != 401
):
return False

try:
Expand Down Expand Up @@ -374,4 +378,3 @@ def create_auth_strategy(
)
else:
raise ValueError(f"Unknown auth type: {auth_type}")

9 changes: 4 additions & 5 deletions packages/mcp/src/keycardai/mcp/client/auth/transports.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from collections.abc import AsyncGenerator
from typing import TYPE_CHECKING

from httpx import Auth, Request, Response
from httpx2 import Auth, Request, Response

from ..logging_config import get_logger

Expand All @@ -15,9 +15,9 @@

class HttpxAuth(Auth):
"""
Adapts AuthStrategy to httpx.Auth interface.
Adapts AuthStrategy to httpx2.Auth interface.

This is a thin adapter that translates between httpx's auth flow
This is a thin adapter that translates between httpx2's auth flow
and our transport-agnostic AuthStrategy protocol.

All business logic (OAuth discovery, token management, etc.) is
Expand All @@ -38,7 +38,7 @@ def __init__(self, strategy: "AuthStrategy"):

async def async_auth_flow(self, request: Request) -> AsyncGenerator[Request, Response]:
"""
httpx auth flow - delegates to AuthStrategy.
httpx2 auth flow - delegates to AuthStrategy.

This method:
1. Adds auth metadata to the request (if available)
Expand Down Expand Up @@ -90,4 +90,3 @@ async def async_auth_flow(self, request: Request) -> AsyncGenerator[Request, Res

except Exception as e:
logger.error(f"Error handling auth challenge: {e}", exc_info=True)

66 changes: 50 additions & 16 deletions packages/mcp/src/keycardai/mcp/client/connection/http.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
"""
HTTP connection implementation for MCP client.

Provides StreamableHttpConnection which uses httpx for HTTP-based MCP connections.
Provides StreamableHttpConnection which uses httpx2 for HTTP-based MCP connections.
"""
from contextlib import AbstractAsyncContextManager
from typing import TYPE_CHECKING, Any

from mcp.client.streamable_http import streamable_http_client
import httpx2
from mcp.client.streamable_http import TransportStreams, streamable_http_client

from ..auth.strategies import create_auth_strategy
from ..auth.transports import HttpxAuth
Expand All @@ -19,6 +21,9 @@

logger = get_logger(__name__)

MCP_DEFAULT_TIMEOUT = 30.0
MCP_DEFAULT_SSE_READ_TIMEOUT = 300.0


class StreamableHttpConnection(Connection):
"""
Expand Down Expand Up @@ -52,7 +57,10 @@ def __init__(
self.server_config = server_config
self.context = context
self.coordinator = coordinator
self._mcp_client = None
self._mcp_client: (
AbstractAsyncContextManager[TransportStreams] | None
) = None
self._http_client: httpx2.AsyncClient | None = None
self._disconnecting = False

# Create connection-specific sub-namespace
Expand All @@ -76,31 +84,57 @@ def __init__(

async def connect(self) -> tuple[Any, Any]:
"""Establish HTTP connection with auth adapter."""
# Create httpx auth adapter
# Strategy already has its storage from constructor
url = self.server_config.get("url")
if not isinstance(url, str):
raise ValueError("HTTP server configuration requires a URL")

auth = HttpxAuth(strategy=self.auth_strategy)
self._http_client = httpx2.AsyncClient(
auth=auth,
follow_redirects=True,
timeout=httpx2.Timeout(
MCP_DEFAULT_TIMEOUT,
read=MCP_DEFAULT_SSE_READ_TIMEOUT,
),
)

self._mcp_client = streamable_http_client(
self.server_config.get("url"),
auth=auth,
url,
http_client=self._http_client,
)
self._read_stream, self._write_stream, _ = await self._mcp_client.__aenter__()
return (self._read_stream, self._write_stream)
try:
self._read_stream, self._write_stream = (
await self._mcp_client.__aenter__()
)
return (self._read_stream, self._write_stream)
except Exception:
await self.disconnect()
raise

async def disconnect(self) -> None:
"""Disconnect from HTTP server."""
if self._disconnecting or self._mcp_client is None:
if self._disconnecting or (
self._mcp_client is None and self._http_client is None
):
return

self._disconnecting = True

try:
await self._mcp_client.__aexit__(None, None, None)
except Exception as e:
# Log but don't raise - we're cleaning up and want to ensure
# resources are released even if there are background task errors
logger.debug(f"Error during disconnect (suppressed): {e}")
if self._mcp_client is not None:
try:
await self._mcp_client.__aexit__(None, None, None)
except Exception as e:
logger.debug(f"Error during disconnect (suppressed): {e}")

if self._http_client is not None:
try:
await self._http_client.aclose()
except Exception as e:
logger.debug(
f"Error closing HTTP client (suppressed): {e}"
)
finally:
self._mcp_client = None
self._http_client = None
self._disconnecting = False

5 changes: 4 additions & 1 deletion packages/mcp/src/keycardai/mcp/client/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -377,7 +377,10 @@ async def _handle_connection_failure(self, error: Exception) -> None:
Args:
error: The exception that occurred during connection
"""
logger.error("Failed to establish connection")
logger.error(
f"Failed to establish connection: {error}",
exc_info=(type(error), error, error.__traceback__),
)
await self._cleanup_failed_connection()

error_status = self._classify_connection_error(error)
Expand Down
139 changes: 139 additions & 0 deletions packages/mcp/tests/integration/test_streamable_http_client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
"""Integration coverage for the real MCP streamable HTTP transport."""

import asyncio
import contextlib
import socket
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from typing import Any

import pytest
import uvicorn
from mcp.server.mcpserver import MCPServer
from starlette.responses import Response
from starlette.types import ASGIApp, Receive, Scope, Send

from keycardai.mcp.client import Client
from keycardai.mcp.client.connection.http import StreamableHttpConnection
from keycardai.mcp.client.session import SessionStatus

API_KEY = "integration-test-key"


class ApiKeyMiddleware:
"""Require an API key for HTTP requests while forwarding ASGI lifespan."""

def __init__(self, app: ASGIApp):
self.app = app

async def __call__(
self,
scope: Scope,
receive: Receive,
send: Send,
) -> None:
if scope["type"] == "http":
headers = dict(scope["headers"])
if headers.get(b"x-api-key") != API_KEY.encode():
await Response(status_code=401)(scope, receive, send)
return

await self.app(scope, receive, send)


@asynccontextmanager
async def run_streamable_http_server(
require_api_key: bool,
) -> AsyncIterator[str]:
"""Serve a minimal MCP app on an ephemeral localhost port."""
mcp = MCPServer("streamable-http-integration")

@mcp.tool()
def ping() -> str:
return "pong"

app: ASGIApp = mcp.streamable_http_app()
if require_api_key:
app = ApiKeyMiddleware(app)

sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.bind(("127.0.0.1", 0))
sock.listen(2048)
host, port = sock.getsockname()

server = uvicorn.Server(
uvicorn.Config(
app,
log_level="warning",
lifespan="on",
)
)
server_task = asyncio.create_task(server.serve(sockets=[sock]))

async def wait_until_started() -> None:
while not server.started:
if server_task.done():
await server_task
await asyncio.sleep(0.01)

try:
await asyncio.wait_for(wait_until_started(), timeout=5)
yield f"http://{host}:{port}/mcp"
finally:
server.should_exit = True
await asyncio.wait_for(server_task, timeout=5)
with contextlib.suppress(OSError):
sock.close()


@pytest.mark.asyncio
@pytest.mark.parametrize(
("auth_config", "require_api_key"),
[
(None, False),
(
{
"type": "api_key",
"key": API_KEY,
"header_name": "X-API-Key",
},
True,
),
],
ids=["unauthenticated", "api-key"],
)
async def test_client_connects_over_real_streamable_http_transport(
auth_config: dict[str, Any] | None,
require_api_key: bool,
) -> None:
"""Client.connect reaches an operational session through the real transport."""
async with run_streamable_http_server(require_api_key) as url:
server_config: dict[str, Any] = {"url": url}
if auth_config is not None:
server_config["auth"] = auth_config

client = Client({"test": server_config})
try:
await client.connect()

session = client.sessions["test"]
assert session.status is SessionStatus.CONNECTED
assert session.is_operational

connection = session._connection
assert isinstance(connection, StreamableHttpConnection)
http_client = connection._http_client
assert http_client is not None
assert http_client.follow_redirects is True
assert http_client.timeout.connect == 30.0
assert http_client.timeout.read == 300.0
assert http_client.timeout.write == 30.0
assert http_client.timeout.pool == 30.0

tools = await client.list_tools("test")
assert [tool.tool.name for tool in tools] == ["ping"]
finally:
await client.disconnect()

assert http_client.is_closed
Loading
Loading