Skip to content

Commit bc6fba1

Browse files
Merge pull request #36 from jmolz/fix/sse-header-forwarding
fix: forward headers for SSE MCP clients 🤖🤖🤖
2 parents 32723ec + e0ecac9 commit bc6fba1

2 files changed

Lines changed: 57 additions & 2 deletions

File tree

src/nooa/mcp/client.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,15 +67,18 @@ class MCPSSEClient(MCPBaseClient):
6767
Args:
6868
url: The URL of the MCP server
6969
tool_call_timeout: Timeout for tool calls
70+
headers: Optional custom HTTP headers to include in requests (e.g., for authentication)
7071
"""
7172

7273
def __init__(
7374
self,
7475
url: str,
7576
tool_call_timeout: timedelta = timedelta(seconds=60),
77+
headers: dict[str, str] | None = None,
7678
):
7779
super().__init__(tool_call_timeout=tool_call_timeout)
7880
self._url = url
81+
self._headers = headers or {}
7982

8083
@property
8184
def transport(self) -> Literal["sse", "stdio", "streamable-http"]:
@@ -87,6 +90,7 @@ def server_config(self) -> dict[str, Any]:
8790
"""Return the server configuration."""
8891
return {
8992
"url": self._url,
93+
"headers": self._headers,
9094
"transport": self.transport,
9195
}
9296

@@ -95,6 +99,11 @@ def url(self) -> str:
9599
"""Return the server URL."""
96100
return self._url
97101

102+
@property
103+
def headers(self) -> dict[str, str]:
104+
"""Return the custom headers configured for this client."""
105+
return self._headers
106+
98107
@asynccontextmanager
99108
@override
100109
async def connect_to_server(self):
@@ -107,7 +116,10 @@ async def connect_to_server(self):
107116
RuntimeError: If session initialization fails (MCP protocol error)
108117
"""
109118
async with (
110-
sse_client(url=self._url) as (read, write),
119+
sse_client(
120+
url=self._url,
121+
headers=self._headers if self._headers else None,
122+
) as (read, write),
111123
ClientSession(read, write) as session,
112124
):
113125
await session.initialize()
@@ -320,7 +332,7 @@ def create_mcp_client(
320332
case "sse":
321333
if url is None:
322334
raise ValueError("url must be provided for sse transport")
323-
return MCPSSEClient(url=url)
335+
return MCPSSEClient(url=url, headers=headers)
324336
case "streamable-http":
325337
if url is None:
326338
raise ValueError("url must be provided for streamable-http transport")

tests/test_mcp/test_client.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ def sse_client() -> MCPSSEClient:
4545
"""Create an SSE client for testing."""
4646
return MCPSSEClient(
4747
url="http://localhost:8000",
48+
headers={"Authorization": "Bearer token"},
4849
tool_call_timeout=timedelta(seconds=45),
4950
)
5051

@@ -253,11 +254,24 @@ def test_sse_client_properties(sse_client: MCPSSEClient):
253254
"""MCPSSEClient properties return correct values."""
254255
assert sse_client.transport == "sse"
255256
assert sse_client.url == "http://localhost:8000"
257+
assert sse_client.headers == {"Authorization": "Bearer token"}
256258
assert sse_client.tool_call_timeout == timedelta(seconds=45)
257259

258260
config = sse_client.server_config
259261
assert config["transport"] == "sse"
260262
assert config["url"] == "http://localhost:8000"
263+
assert config["headers"] == {"Authorization": "Bearer token"}
264+
265+
266+
def test_sse_client_preserves_positional_timeout():
267+
"""The existing second positional argument remains the tool-call timeout."""
268+
client = MCPSSEClient(
269+
"http://localhost:8000",
270+
timedelta(seconds=7),
271+
)
272+
273+
assert client.tool_call_timeout == timedelta(seconds=7)
274+
assert client.headers == {}
261275

262276

263277
def test_streamable_http_client_properties(streamable_http_client: MCPStreamableHTTPClient):
@@ -328,6 +342,35 @@ async def test_connect_context_manager(
328342
mock_client_session.initialize.assert_awaited_once()
329343

330344

345+
@pytest.mark.asyncio
346+
async def test_sse_headers_passed_to_transport(
347+
mock_mcp_transport: tuple[MagicMock, MagicMock],
348+
mock_client_session: AsyncMock,
349+
):
350+
"""SSE clients forward custom headers to the transport."""
351+
client = create_mcp_client(
352+
"sse",
353+
url="https://example.test/sse",
354+
headers={"Authorization": "Bearer token"},
355+
)
356+
mock_read, mock_write = mock_mcp_transport
357+
358+
with (
359+
patch("nooa.mcp.client.sse_client") as mock_sse,
360+
patch("nooa.mcp.client.ClientSession") as mock_session_class,
361+
):
362+
mock_sse.return_value.__aenter__.return_value = (mock_read, mock_write)
363+
mock_session_class.return_value.__aenter__.return_value = mock_client_session
364+
365+
async with client.connect_to_server():
366+
pass
367+
368+
mock_sse.assert_called_once_with(
369+
url="https://example.test/sse",
370+
headers={"Authorization": "Bearer token"},
371+
)
372+
373+
331374
@pytest.mark.asyncio
332375
async def test_streamable_http_connect_context_manager(
333376
streamable_http_client: MCPStreamableHTTPClient,

0 commit comments

Comments
 (0)