Skip to content

Python: fail fast on MCP streamable HTTP 401/403#13458

Open
VedantMadane wants to merge 4 commits intomicrosoft:mainfrom
VedantMadane:fix/mcp-401-403
Open

Python: fail fast on MCP streamable HTTP 401/403#13458
VedantMadane wants to merge 4 commits intomicrosoft:mainfrom
VedantMadane:fix/mcp-401-403

Conversation

@VedantMadane
Copy link
Copy Markdown

Summary

This PR improves the Python MCP Streamable HTTP connector to fail fast on authentication / authorization issues.

  • Adds a preflight request in MCPStreamableHttpPlugin.__aenter__ and raises KernelPluginInvalidConfigurationError on HTTP 401/403.
  • Raises for other non-success HTTP responses early to surface configuration/network issues quickly.

Why

When credentials or permissions are misconfigured, the server returns 401/403 and the plugin should error clearly and immediately, instead of failing later during session setup.

Tests

  • Added unit tests that mock httpx.AsyncClient:
    • 401/403 raises KernelPluginInvalidConfigurationError
    • 200 proceeds successfully

Related

  • microsoft/semantic-kernel#13414

@VedantMadane VedantMadane requested a review from a team as a code owner January 18, 2026 08:57
@moonbox3 moonbox3 added the python Pull requests for the Python Semantic Kernel label Jan 18, 2026
@VedantMadane
Copy link
Copy Markdown
Author

@microsoft-github-policy-service agree

Vedantm-CURATELYAI and others added 2 commits February 25, 2026 18:02
This prevents connect() from hanging indefinitely when session creation or initialization fails. Part of addressing microsoft#13414.
Copy link
Copy Markdown
Contributor

@github-actions github-actions bot left a comment

Choose a reason for hiding this comment

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

Automated Code Review

Reviewers: 4 | Confidence: 92%

✗ Correctness

The ready_event.set() additions in _inner_connect are correct and fix a real deadlock where connect() would hang forever if session creation or initialization failed. However, the new MCPStreamableHttpPlugin.__aenter__ preflight check has two significant issues: (1) it breaks at least three existing tests that use MCPStreamableHttpPlugin without mocking httpx, and (2) a plain HTTP GET to an MCP streamable HTTP endpoint is not guaranteed to return 200 — MCP uses POST for JSON-RPC, and a GET may return 405 Method Not Allowed or other non-200 codes — causing raise_for_status() to incorrectly reject perfectly valid servers.

✗ Security Reliability

The PR adds two fixes: (1) ready_event.set() calls before raising in _inner_connect exception handlers to prevent deadlocks when session creation/initialization fails — these are clear reliability improvements. (2) A preflight HTTP GET check in MCPStreamableHttpPlugin.__aenter__ to detect 401/403 early. The preflight has a reliability concern: it uses response.raise_for_status() which will reject servers that return non-2xx for a bare GET (e.g., 405 Method Not Allowed), even though the actual MCP protocol POST would succeed. Additionally, the preflight constructs its own httpx.AsyncClient using only self.headers, but doesn't forward _client_kwargs — so if users pass authentication via the auth parameter (httpx.Auth) or a custom httpx_client_factory, the preflight will fail with 401/403 even though the actual connection would work. The ready_event.set() fixes and the httpx dependency (already a transitive dep of mcp) are sound.

✗ Test Coverage

The PR adds two changes: (1) a preflight HTTP auth check in MCPStreamableHttpPlugin.__aenter__ and (2) ready_event.set() calls in two additional exception handlers in _inner_connect. While two new tests cover the preflight check for 401/403 and 200 success, three existing tests that use MCPStreamableHttpPlugin (test_mcp_plugin_session_not_initialize, test_mcp_plugin_session_initialized, test_with_kwargs_streamablehttp) do not mock httpx.AsyncClient and will now attempt real HTTP requests, causing them to fail. Additionally, the ready_event.set() bug fix in the session-creation and session-initialization failure paths has zero test coverage, and the preflight raise_for_status() path for non-401/403 HTTP errors (e.g., 500) is not tested either.

✗ Design Approach

This PR contains two separate changes bundled together. The ready_event.set() additions in _inner_connect are a legitimate deadlock fix — without them, if ClientSession.__aenter__ or session.initialize() raise, connect() hangs forever waiting on an event that never gets set. However, the preflight GET request added to MCPStreamableHttpPlugin.__aenter__ is a fundamentally flawed design: streamablehttp_client (and its successor streamable_http_client) communicates via HTTP POST, not GET. A 200 response to a GET on the MCP endpoint provides no guarantee that the subsequent POST-based MCP handshake will succeed with the same auth headers. Worse, response.raise_for_status() inside a broad except Exception converts transient failures (503, 429) and wrong-URL failures (404, 405) into misleading KernelPluginInvalidConfigurationError messages. It also introduces a TOCTOU race (auth valid at preflight may expire before actual connection), adds a redundant round-trip, and only applies to MCPStreamableHttpPlugin while MCPSSEPlugin has the same auth-error surface. The right approach is to let auth errors from streamablehttp_client propagate through _inner_connect's existing catch blocks (already fixed by the ready_event.set() changes), and optionally inspect the exception type/message there to give more specific guidance — no extra HTTP request needed.

Flagged Issues

  • The preflight GET in __aenter__ breaks existing tests test_mcp_plugin_session_not_initialize (parametrized with MCPStreamableHttpPlugin), test_mcp_plugin_session_initialized (parametrized with MCPStreamableHttpPlugin), and test_with_kwargs_streamablehttp — none of which mock httpx.AsyncClient, so the preflight will attempt real network requests and fail.
  • The preflight uses GET, but MCP streamable HTTP communicates via POST. A GET to the MCP endpoint tests a different handler/route: a valid MCP server may return 404 or 405 for GET while working perfectly for POST, and a server returning 200 for GET may still reject POST with 401/403. Combined with raise_for_status() inside a broad except Exception, ALL non-2xx responses — including transient errors (503, 429) and method-not-allowed (405) — are converted into KernelPluginInvalidConfigurationError('check your configuration'), which is incorrect and actively misleading.
  • The preflight httpx.AsyncClient only receives self.headers but does not forward the auth kwarg from _client_kwargs. Users authenticating via auth=httpx.BasicAuth(...) will always hit spurious 401/403 on the preflight, blocking a valid connection.
  • The ready_event.set() additions in the session-creation and session-initialization failure handlers (lines 320 and 328) have no test coverage. These are important deadlock-prevention fixes and should have dedicated tests exercising each failure path.
  • The preflight introduces a TOCTOU race: a token valid at preflight time may expire before the actual MCP connection, so the check provides no real guarantee and the caller still fails at the true connection point — which is already handled by _inner_connect's catch blocks (now correctly fixed by ready_event.set()).

Suggestions

  • Consider landing the ready_event.set() deadlock fixes independently of the preflight feature — those alone resolve the hang that presumably motivated this PR, and auth errors will now propagate correctly through _inner_connect.
  • Consider removing the __aenter__ preflight override entirely. The _inner_connect pathway already surfaces all connection/auth failures; if better auth error messaging is desired, catch auth-specific exceptions (e.g., httpx.HTTPStatusError with 401/403) inside _inner_connect's existing try/except blocks and raise KernelPluginInvalidConfigurationError with targeted messaging — no extra HTTP request needed.
  • If a preflight check is retained, only gate on 401/403 status codes (the stated purpose) and do not call raise_for_status(). Also skip the preflight when self.session is already provided, since no transport check is needed in that case.
  • Forward relevant kwargs (at minimum auth) from _client_kwargs to the preflight httpx client to ensure consistent authentication behavior.
  • If a health-check preflight is genuinely desired in future, it should use the same HTTP method (POST) and headers as the real MCP handshake, and should not re-wrap transient HTTP errors as configuration errors.
  • Add tests for the ready_event.set() failure paths (mock ClientSession(...) to raise and session.initialize() to raise) and for the preflight raise_for_status() code path (e.g., simulate a 500 response).

Automated review by moonbox3's agents

@@ -760,6 +763,27 @@ def get_mcp_client(self) -> _AsyncGeneratorContextManager[Any, None]:
args.update(self._client_kwargs)
return streamablehttp_client(**args)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This preflight GET uses the wrong HTTP method (streamablehttp_client uses POST) and runs unconditionally — even when self.session is already provided, breaking existing tests that supply a mock session without mocking httpx.AsyncClient. Consider removing the preflight entirely (auth errors now propagate via _inner_connect thanks to the ready_event.set() fixes) or at minimum guarding with if not self.session:.

Suggested change
async def __aenter__(self) -> Self:
"""Fail fast on authentication/authorization errors before connecting."""
if not self.session:
timeout = self.timeout if self.timeout is not None else 30
try:
async with httpx.AsyncClient(timeout=timeout, headers=self.headers) as client:
response = await client.get(self.url)
if response.status_code in (httpx.codes.UNAUTHORIZED, httpx.codes.FORBIDDEN):
raise KernelPluginInvalidConfigurationError(
f"Failed to connect to the MCP server: received HTTP {response.status_code} (unauthorized/forbidden)."
)
except KernelPluginInvalidConfigurationError:
raise
except Exception as ex: # pragma: no cover - guarded for unexpected failures
raise KernelPluginInvalidConfigurationError(
"Failed to connect to the MCP server. Please check your configuration."
) from ex

Comment on lines +775 to +776
)
# Raise for other HTTP errors to surface configuration/network issues early.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The preflight httpx.AsyncClient only receives self.headers, but users can pass auth=httpx.BasicAuth(...) via **kwargs (stored in _client_kwargs). The preflight won't include that auth, causing spurious 401/403 failures. Forward the auth kwarg (and potentially other relevant kwargs) to the preflight client.

Suggested change
)
# Raise for other HTTP errors to surface configuration/network issues early.
client_kwargs: dict[str, Any] = {"timeout": timeout, "headers": self.headers}
if "auth" in self._client_kwargs:
client_kwargs["auth"] = self._client_kwargs["auth"]
try:
async with httpx.AsyncClient(**client_kwargs) as client:

response.raise_for_status()
except KernelPluginInvalidConfigurationError:
raise
except Exception as ex: # pragma: no cover - guarded for unexpected failures
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

raise_for_status() rejects any non-2xx response (405, 404, 503, 429, etc.). Since the MCP spec does not mandate 200 OK for a plain GET on a streamable HTTP endpoint, this produces false negatives for valid MCP servers and misleadingly wraps transient errors as configuration errors. The preflight should only gate on 401/403 (its stated purpose).

Suggested change
except Exception as ex: # pragma: no cover - guarded for unexpected failures
# Only block on authentication errors; other HTTP status codes
# will be surfaced during the actual MCP connection.

)
except Exception as ex:
await self._exit_stack.aclose()
ready_event.set()
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This ready_event.set() fix prevents connect() from hanging when session creation fails. Add a test that mocks ClientSession(...) to raise and asserts that KernelPluginInvalidConfigurationError is raised (rather than a hang).

await session.initialize()
except Exception as ex:
await self._exit_stack.aclose()
ready_event.set()
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Same as above — this ready_event.set() fix for initialization failure also needs a test. Mock session.initialize() to raise and assert proper error propagation.

Comment on lines +262 to +265
@patch("semantic_kernel.connectors.mcp.streamablehttp_client")
async def test_streamable_http_raises_on_401_403(mock_streamable_client, mock_session, monkeypatch, status_code):
# Avoid real network: preflight should catch unauthorized/forbidden before streamable client is used.
monkeypatch.setattr("semantic_kernel.connectors.mcp.httpx.AsyncClient", lambda **_: _DummyHttpxClient(status_code))
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The monkeypatch fixture must appear before the @patch parameters in the function signature. With @patch decorators, mock arguments are injected in reverse decorator order (bottom-up), so the correct order is (mock_session, mock_streamable_client, monkeypatch, status_code). As written, mock_streamable_client receives the ClientSession mock and vice versa, swapping their intended assignments.

Suggested change
@patch("semantic_kernel.connectors.mcp.streamablehttp_client")
async def test_streamable_http_raises_on_401_403(mock_streamable_client, mock_session, monkeypatch, status_code):
# Avoid real network: preflight should catch unauthorized/forbidden before streamable client is used.
monkeypatch.setattr("semantic_kernel.connectors.mcp.httpx.AsyncClient", lambda **_: _DummyHttpxClient(status_code))
async def test_streamable_http_raises_on_401_403(mock_session, mock_streamable_client, monkeypatch, status_code):

monkeypatch.setattr("semantic_kernel.connectors.mcp.httpx.AsyncClient", lambda **_: _DummyHttpxClient(status_code))

with pytest.raises(KernelPluginInvalidConfigurationError):
async with MCPStreamableHttpPlugin(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Good coverage for 401/403, but the raise_for_status() path for other server errors (e.g., 500, 502) is not tested. Add a parametrized case with a 5xx status code to verify behavior.

Copilot AI review requested due to automatic review settings April 12, 2026 09:52
@VedantMadane
Copy link
Copy Markdown
Author

Addressed the automated review feedback:

  1. Guarded preflight with if not self.session — callers that supply an existing session now skip the HTTP probe entirely, so tests with mock sessions won't break.
  2. Forwarded auth from _client_kwargshttpx.BasicAuth or similar auth set via kwargs is now included in the preflight client.
  3. Removed raise_for_status() — only 401/403 are gated; other HTTP status codes (405, 5xx, etc.) will surface naturally during the actual MCP connection instead of being misreported as config errors.
  4. Added 3 new tests:
    • test_streamable_http_skips_preflight_with_existing_session — verifies preflight is skipped when a session is provided
    • test_streamable_http_session_create_failure_does_not_hang — covers ready_event.set() fix for session creation failure
    • test_streamable_http_session_init_failure_does_not_hang — covers ready_event.set() fix for session.initialize() failure

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR updates the Python MCP Streamable HTTP connector to surface connection/authentication problems earlier by adding an HTTP preflight during context-manager entry and by preventing connect() from hanging when session setup fails.

Changes:

  • Added a preflight httpx request in MCPStreamableHttpPlugin.__aenter__ to fail early on HTTP 401/403.
  • Ensured ready_event is set when session creation/initialization fails to avoid connect() waiting forever.
  • Added unit tests for 401/403 behavior, success behavior, skipping preflight when an existing session is provided, and non-hanging failure cases.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 6 comments.

File Description
python/semantic_kernel/connectors/mcp.py Adds httpx preflight in __aenter__ and unblocks connect() by setting ready_event on more failure paths.
python/tests/unit/connectors/mcp/test_mcp.py Adds unit tests and a dummy httpx.AsyncClient to validate preflight behavior and non-hanging failures.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +769 to +775
timeout = self.timeout if self.timeout is not None else 30
client_kwargs: dict[str, Any] = {"timeout": timeout, "headers": self.headers}
if "auth" in self._client_kwargs:
client_kwargs["auth"] = self._client_kwargs["auth"]
try:
async with httpx.AsyncClient(**client_kwargs) as client:
response = await client.get(self.url)
Copy link

Copilot AI Apr 12, 2026

Choose a reason for hiding this comment

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

The preflight timeout is passed directly into httpx.AsyncClient. In this repo, MCPStreamableHttpPlugin is used with timeout=timedelta(...) (e.g., the OAuth sample), which httpx does not accept as a timeout value. Consider normalizing self.timeout to a value compatible with httpx (e.g., convert timedelta to seconds or build an httpx.Timeout) before constructing the client so preflight doesn’t break existing callers.

Copilot uses AI. Check for mistakes.
Comment on lines +775 to +779
response = await client.get(self.url)
if response.status_code in (httpx.codes.UNAUTHORIZED, httpx.codes.FORBIDDEN):
raise KernelPluginInvalidConfigurationError(
f"Failed to connect to the MCP server: received HTTP {response.status_code} (unauthorized/forbidden)."
)
Copy link

Copilot AI Apr 12, 2026

Choose a reason for hiding this comment

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

The PR description says non-success HTTP responses should be raised early, but the preflight currently only errors on 401/403 and silently proceeds on other error codes (e.g., 404/500). Consider treating any non-2xx response as a configuration/connectivity error (while still providing a tailored message for 401/403).

Copilot uses AI. Check for mistakes.
Comment on lines +775 to +779
response = await client.get(self.url)
if response.status_code in (httpx.codes.UNAUTHORIZED, httpx.codes.FORBIDDEN):
raise KernelPluginInvalidConfigurationError(
f"Failed to connect to the MCP server: received HTTP {response.status_code} (unauthorized/forbidden)."
)
Copy link

Copilot AI Apr 12, 2026

Choose a reason for hiding this comment

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

The preflight uses client.get(self.url), which by default reads the full response body. For a “streamable” endpoint this can block until timeout if the server keeps the response open. Consider issuing the request in streaming mode (so you only read headers/status) and closing the response immediately after checking status_code, to avoid unnecessary latency/hangs during preflight.

Suggested change
response = await client.get(self.url)
if response.status_code in (httpx.codes.UNAUTHORIZED, httpx.codes.FORBIDDEN):
raise KernelPluginInvalidConfigurationError(
f"Failed to connect to the MCP server: received HTTP {response.status_code} (unauthorized/forbidden)."
)
async with client.stream("GET", self.url) as response:
if response.status_code in (httpx.codes.UNAUTHORIZED, httpx.codes.FORBIDDEN):
raise KernelPluginInvalidConfigurationError(
f"Failed to connect to the MCP server: received HTTP {response.status_code} (unauthorized/forbidden)."
)

Copilot uses AI. Check for mistakes.
Comment on lines 316 to 331
)
)
except Exception as ex:
await self._exit_stack.aclose()
ready_event.set()
raise KernelPluginInvalidConfigurationError(
"Failed to create a session. Please check your configuration."
) from ex
try:
await session.initialize()
except Exception as ex:
await self._exit_stack.aclose()
ready_event.set()
raise KernelPluginInvalidConfigurationError(
"Failed to initialize session. Please check your configuration."
) from ex
Copy link

Copilot AI Apr 12, 2026

Choose a reason for hiding this comment

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

Setting ready_event on session creation/initialization failure prevents connect() from hanging, but it also lets connect()/__aenter__ return successfully even though _inner_connect has already failed. That can cause the async with body to run with plugin.session unset and only raise later when close() awaits the failed task. Consider having connect() detect when _current_task has completed with an exception (e.g., if self._current_task.done(): await self._current_task) after ready_event.wait(), so failures surface immediately on enter.

Copilot uses AI. Check for mistakes.
Comment on lines +318 to +329
@patch("semantic_kernel.connectors.mcp.streamablehttp_client")
async def test_streamable_http_session_create_failure_does_not_hang(mock_streamable_client):
"""ready_event.set() prevents connect() from hanging when session creation fails."""
mock_streamable_client.side_effect = RuntimeError("connection refused")

with pytest.raises(KernelPluginInvalidConfigurationError, match="create a session"):
async with MCPStreamableHttpPlugin(
name="TestMCPPlugin",
description="Test MCP Plugin",
url="http://localhost:8080/mcp",
):
pass
Copy link

Copilot AI Apr 12, 2026

Choose a reason for hiding this comment

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

This test sets streamablehttp_client.side_effect to raise, which fails during transport/client creation and should map to the connector error message about failing to connect to the MCP server (not “create a session”). The match="create a session" assertion is likely incorrect and may cause a false negative.

Copilot uses AI. Check for mistakes.
mock_generator.__aenter__.return_value = (mock_read, mock_write, mock_callback)
mock_generator.__aexit__.return_value = (mock_read, mock_write, mock_callback)
mock_streamable_client.return_value = mock_generator
mock_session.return_value.initialize = AsyncMock(side_effect=RuntimeError("init failed"))
Copy link

Copilot AI Apr 12, 2026

Choose a reason for hiding this comment

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

session.initialize() is awaited on the entered ClientSession instance (the object returned from ClientSession().__aenter__() via AsyncExitStack.enter_async_context). This test sets initialize on mock_session.return_value, which may not be the same object, making the failure injection brittle. Prefer setting the side_effect on mock_session.return_value.__aenter__.return_value.initialize to ensure the initialization path actually raises.

Suggested change
mock_session.return_value.initialize = AsyncMock(side_effect=RuntimeError("init failed"))
mock_session.return_value.__aenter__.return_value.initialize = AsyncMock(
side_effect=RuntimeError("init failed")
)

Copilot uses AI. Check for mistakes.
- Guard preflight check with `if not self.session` so existing-session
  callers skip the HTTP probe entirely
- Forward `auth` from `_client_kwargs` to the preflight httpx client
- Remove `raise_for_status()` — only gate on 401/403 as originally
  intended; other status codes propagate via the MCP connection itself
- Add tests for `ready_event.set()` fixes (session creation failure
  and `session.initialize()` failure no longer hang)
- Add test verifying preflight is skipped when a session is provided
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

python Pull requests for the Python Semantic Kernel

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants