Skip to content

Python: no way to answer mcp.headers_refresh_required, so MCP auth headers are fixed for the life of a session #2492

Description

@jsmith13

Package: github-copilot-sdk (Python). Checked against 1.0.9 and 1.0.11.

Summary

For MCP servers behind Entra (workload identity), the Authorization header is a
bearer token with a finite lifetime — ours lasts ~24h. create_session(mcp_servers=...)
resolves each server's headers once, when the session is created, and the Python
SDK exposes no way to replace them afterwards. Any host process outliving its token
permanently loses those servers.

The wire protocol already models exactly this. What is missing is the Python binding.

The protocol has it

copilot/generated/session_events.py:

MCP_HEADERS_REFRESH_REQUIRED = "mcp.headers_refresh_required"
MCP_HEADERS_REFRESH_COMPLETED = "mcp.headers_refresh_completed"

copilot/generated/rpc.py:

class MCPHeadersHandlePendingHeadersRefreshRequest:
    """Host response: supply dynamic headers or decline this refresh."""
    kind: MCPHeadersHandlePendingHeadersRefreshRequestKind   # "headers" | "none"
    headers: dict[str, str] | None = None
    """Headers to overlay onto the MCP request. Dynamic headers override static
    config headers but do not replace SDK-managed request headers."""

…plus the mcp_headers_handle_pending_headers_refresh_request method.

The bindings don't

Nothing outside generated/ references it, in either version:

$ grep -rn "headers_refresh" --include=*.py . | grep -v generated/rpc.py
./generated/session_events.py:203:    MCP_HEADERS_REFRESH_REQUIRED = "mcp.headers_refresh_required"
./generated/session_events.py:204:    MCP_HEADERS_REFRESH_COMPLETED = "mcp.headers_refresh_completed"

Contrast on_mcp_auth_request, which is wired (client.py:2155, and
_register_mcp_auth_handler at the session). So a host can answer an OAuth request
but cannot answer a headers refresh.

Ask

Expose a handler for the pending headers refresh, e.g.
create_session(..., on_mcp_headers_refresh_request=...) returning
{"kind": "headers", "headers": {...}}, registered the same way
on_mcp_auth_request already is.


Why on_mcp_auth_request is not a workaround for us

#1669 added the OAuth host-token handlers and covers "refresh/replacement tokens,
upscope, reauth", so this looks like the intended path. We cannot reach it, because
the handler is never called for our servers.

Our servers answer an unauthenticated request with a relative resource_metadata
reference:

POST /mcp -> 401
WWW-Authenticate: Bearer resource_metadata="/.well-known/oauth-protected-resource"

The CLI appears to fetch that verbatim rather than resolving it against the MCP
request URL, and then abandons the transport (~/.copilot/logs/process-*.log):

[ERROR] Failed to connect with OAuth in background: Error: Failed to fetch MCP
        OAuth protected-resource metadata from /.well-known/oauth-protected-resource
[ERROR] worker quit with fatal: Transport channel closed, when Client(OAuthChallenge { ...401... })

Resolved against the request URL (http://<host>:8000/mcp, per RFC 3986) the document
is served and valid:

GET http://<host>:8000/.well-known/oauth-protected-resource -> 200
{"resource":"api://<guid>","authorization_servers":["https://login.microsoftonline.com/<tenant>/v2.0"],"scopes_supported":[...]}

Observed with a deliberately invalid token in headers and auth: True with a
handler registered:

EVENT session.mcp_server_status_changed  gis  pending
EVENT session.mcp_servers_loaded
EVENT session.mcp_server_status_changed  gis  needs-auth
EVENT session.mcp_server_status_changed  gis  failed
REPLY: <model answers using only built-in tools>
STATUS: {'gis': 'failed'} | auth callback fired: 0 time(s)

Two things would each independently help, and may deserve their own issue — happy to
split this out if you'd prefer:

  1. Resolve a relative resource_metadata URI reference against the MCP request URL.
  2. Fall back to the registered host auth handler when protected-resource discovery
    fails, instead of tearing down the transport.

Why this is easy to miss

The failure is entirely silent. No exception, no callback, no failed turn — the
server just goes failed and the model answers without those tools, reporting them
as not existing rather than as having gone away. Nothing in the symptom leads back
to the cause.

Workaround, for anyone else hitting this

Send the token in the connection headers so no challenge is ever issued, and check
server status every turn so expiry is at least visible. That trades a silent failure
for a bounded one, but the session still cannot outlive its token.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions