Skip to content

Python: fix(core): authenticate MCP connect with run-supplied header_provider kwargs - #3

Closed
jpalvarezl wants to merge 9 commits into
mainfrom
jpalvarezl/fix/7841_mcp_headers_lost
Closed

Python: fix(core): authenticate MCP connect with run-supplied header_provider kwargs#3
jpalvarezl wants to merge 9 commits into
mainfrom
jpalvarezl/fix/7841_mcp_headers_lost

Conversation

@jpalvarezl

@jpalvarezl jpalvarezl commented Sep 9, 2026

Copy link
Copy Markdown
Owner

Summary

header_provider was only consulted with the run's kwargs for tools/call. Connection-lifetime requests (the initialize handshake, tool discovery, pings) resolved against an empty mapping, so a provider deriving credentials from the run could authenticate tool calls but never the handshake. Servers that authenticate initialize returned 401 before any tool ran.

Both agent connect paths now seed the run's function_invocation_kwargs into connection-lifetime header resolution. The kwargs are cached rather than the resolved headers, so providers are still re-invoked per request and token-refresh setups keep working. Connection headers resolve once per connection (the run that establishes it wins) and are released on close.

No public API change.

Fixes microsoft#7841.

Test matrix

Scenario Test
Run-supplied tool + run credential test_agent_run_supplies_mcp_connect_headers
Constructor tool + eager connect + closure credential test_agent_context_manager_authenticates_connect_with_closure_provider
Constructor tool + lazy connect + run credential test_constructor_supplied_mcp_tool_uses_run_credentials_on_lazy_connect
Standalone tool + closure credential test_standalone_static_header_provider_authenticates_without_a_run
First-connect-wins + cleared on close test_connection_kwargs_are_fixed_for_the_connection_and_cleared_on_close
Seeded vs unseeded handshake test_seeded_connection_kwargs_authenticate_the_handshake (parametrized)

Each runs against a mock server that returns 401 for unauthenticated requests, so they fail for the right reason.

Notes

  • samples/02-agents/mcp/mcp_api_key_auth.py used a kwargs-indexing provider under async with Agent(...), where connect precedes any run, so the credential could never be read. It now closes over the construction-time key, with guidance on when per-run kwargs apply instead.
  • Considered and rejected: a separate headers= constructor argument (as proposed in Python: Add origin-scoped headers for MCP connect authentication microsoft/agent-framework#7892). A provider closing over a construction-time credential already authenticates the eager handshake, and a static value would be frozen for the connection lifetime, regressing token-refresh setups like the Foundry toolbox sample.
  • Deliberately not addressed: deferring the eager connect in Agent.__aenter__. AG-UI collect_server_tools() gates on is_connected and runs before the agent's run, so deferring would silently drop MCP tools from approval flows.

Follow-up: KeyError handling on ambient requests

Ambient header resolution previously swallowed every KeyError from header_provider. With kwargs now seeded, that tolerance hid a real misconfiguration: a provider reading kwargs["credential"] against seeded kwargs that carry a different key would send the handshake unauthenticated and surface as an opaque 401.

The KeyError is now tolerated only when nothing was seeded — the one case no caller can avoid, since a connection-lifetime request genuinely has no per-call values. When kwargs were seeded, a missing key propagates. Covered by test_seeded_kwargs_missing_the_providers_key_fails_the_handshake.

Constructor-supplied MCP tools now seed the run's function_invocation_kwargs before connecting, matching the run-supplied path. Adds coverage for the connect-time credential matrix and fixes the API-key sample to close over a construction-time credential.
… provider's key

Ambient header resolution previously swallowed every KeyError from header_provider, so a run whose kwargs did not carry the key the provider reads would silently send the initialize handshake unauthenticated and surface as a 401. Only tolerate the KeyError when no connection kwargs were seeded at all, which is the case a caller cannot avoid; a key missing from seeded kwargs is a misconfiguration and now propagates.
…wargs on failed connect

Addresses review on microsoft#8225. An empty mapping could not express the difference between a run that seeded no kwargs and a connection no run ever seeded, so a lazy run with empty kwargs still swallowed the provider's KeyError and sent an unauthenticated handshake. _connection_kwargs is now None until a run seeds it. The seeded kwargs were also only released through close(); a rejected handshake unwinds via _close_and_check_cancelled instead, leaving the failed run's credential for a later unseeded reconnect, so the release now happens there. Documents the connection-lifetime semantics on the public header_provider parameter.
… claim

Addresses review on microsoft#8225. is_connected only turns true after initialize returns, so two concurrent runs could both pass the guard and the second would swap the credential out from under the first run's in-flight handshake, authenticating a shared tool as the wrong caller. The seed is now a first-writer claim, released when the connection closes or its setup fails.
Co-authored-by: Eduard van Valkenburg <eavanvalkenburg@users.noreply.github.com>
@jpalvarezl jpalvarezl closed this Sep 10, 2026
@jpalvarezl
jpalvarezl deleted the jpalvarezl/fix/7841_mcp_headers_lost branch September 10, 2026 13:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Python: MCPStreamableHTTPTool.connect() never sends a header_provider's headers, so any headers_env/header_provider-authenticated server 401s on connect

1 participant