Python: Fix: Parse oauth_consent_request events in Azure AI client #4197
Python: Fix: Parse oauth_consent_request events in Azure AI client #4197giles17 wants to merge 4 commits intomicrosoft:mainfrom
Conversation
…#3950) When Azure AI Agent Service returns an oauth_consent_request output item for OAuth-protected MCP tools, the base OpenAI responses parser drops it (hits case _ default branch). This causes agent runs to complete silently with zero content. Changes: - Add oauth_consent_request ContentType and Content.from_oauth_consent_request() factory with consent_link field and user_input_request=True - Override _parse_response_from_openai and _parse_chunk_from_openai in RawAzureAIClient to intercept Azure-specific oauth_consent_request items - Add _emit_oauth_consent helper in AG-UI to emit CustomEvent for frontends - Add tests proving base parser drops the event and Azure AI override catches it Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Python Test Coverage Report •
Python Unit Test Overview
|
||||||||||||||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Pull request overview
This PR fixes issue #3950 where OAuth consent request events from Azure AI Agent Service were silently dropped, preventing users from being notified when OAuth consent is required for MCP tools.
Changes:
- Added
oauth_consent_requestcontent type to the core framework with aconsent_linkfield - Extended Azure AI client to parse OAuth consent request output items in both streaming and non-streaming responses
- Updated AG-UI to emit OAuth consent requests as custom events for frontend rendering
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated no comments.
Show a summary per file
| File | Description |
|---|---|
python/packages/core/agent_framework/_types.py |
Added oauth_consent_request to ContentType literal, added consent_link field to Content class, implemented from_oauth_consent_request() factory method, and updated to_dict() serialization |
python/packages/core/tests/core/test_types.py |
Added unit tests for OAuth consent request content creation and serialization |
python/packages/azure-ai/agent_framework_azure_ai/_client.py |
Overrode _parse_response_from_openai and _parse_chunk_from_openai to handle Azure-specific oauth_consent_request output items before they're dropped by the base parser |
python/packages/azure-ai/tests/test_azure_ai_client.py |
Added tests for parsing OAuth consent requests in both streaming and non-streaming scenarios |
python/packages/ag-ui/agent_framework_ag_ui/_run_common.py |
Added _emit_oauth_consent() helper and wired it into _emit_content() to emit CustomEvent for frontends |
python/packages/ag-ui/tests/ag_ui/test_run.py |
Added tests for OAuth consent request event emission with and without consent_link |
python/packages/core/agent_framework/openai/_chat_client.py |
Auto-formatting changes only (line length adjustments) |
python/packages/core/agent_framework/observability.py |
Removed unused SpanAttributes import and auto-formatting |
| ) -> ChatResponseUpdate: | ||
| """Parse an Azure AI streaming event, handling Azure-specific event types.""" | ||
| # Intercept output_item.added events for Azure-specific item types | ||
| if event.type == "response.output_item.added" and event.item.type == "oauth_consent_request": |
There was a problem hiding this comment.
and this logic will silently ignore other content types? And that seems inconsistent with the non-streaming flow?
There was a problem hiding this comment.
When event.item.type is not "oauth_consent_request", it falls through to super()._parse_chunk_from_openai() on line 644, which handles all standard item types
moonbox3
left a comment
There was a problem hiding this comment.
Automated Code Review
Reviewers: 3 | Confidence: 82%
✓ Correctness
The diff adds OAuth consent request support across the ag-ui emitter, Azure AI client, and core types. The implementation is mostly correct — the new Content field, factory method, serialization, and event emission are consistent with existing patterns. One redundant condition exists in
_parse_response_from_openai(elif not consent_link:is always true when theif consent_link:branch is not taken, so it can never be false). In_parse_chunk_from_openai, whenoauth_consent_requestis intercepted butconsent_linkis absent, the method still returns aChatResponseUpdatewith an emptycontentslist rather than falling through to the super call or returning a no-op; this may confuse callers expecting either a meaningful update or the super-class behavior. The test for the no-link case (test_emit_oauth_consent_request_no_link) constructsContent("oauth_consent_request", user_input_request=True)without aconsent_link, which correctly tests the guard in_emit_oauth_consent. Overall correctness is good but the empty-contents streaming return path and the deadelifare minor correctness concerns.
✗ Security Reliability
The diff introduces OAuth consent request handling across the ag-ui and azure-ai packages. The primary security concern is that consent_link URLs from the Azure API are forwarded to frontends without any validation — a malicious or compromised API response could deliver javascript: URIs or other non-HTTPS URLs that frontends might render as clickable links, enabling XSS or phishing. Additionally,
result.messages[0]is accessed unconditionally in_parse_response_from_openai, which will raise an IndexError if the parent_parse_response_from_openaireturns a response with no messages. The streaming path returns aChatResponseUpdatewith an emptycontentslist (no role mismatch, just wasted allocation) whenconsent_linkis absent but still short-circuits the super() call — this is a minor reliability gap (the empty update is returned rather than delegating). There are no hardcoded secrets, no unsafe deserialization, and no resource leaks introduced.
✓ Test Coverage
The test coverage for the OAuth consent feature is generally solid — the happy path, missing-consent-link, creation, and serialization cases are all covered. However, several gaps remain: the warning log path (no consent_link in both streaming and non-streaming parsers) is untested, the
_parse_response_from_openaioverride is tested only for OAuth items and not for mixed output (OAuth + normal items together), and thetest_emit_oauth_consent_request_no_linktest constructs Content directly rather than viafrom_oauth_consent_request, making it fragile and inconsistent. There are also no tests for_parse_chunk_from_openaiwhen the consent_link is absent (the else branch), and no test verifying that non-oauth output_item.added events still fall through to the parent implementation. Theto_dictroundtrip test does not verify that deserialization/reconstruction from the dict produces equivalent content, so the roundtrip claim is partial.
Blocking Issues
- consent_link is forwarded to the frontend without URL validation — a non-HTTPS or javascript: URI from the API response would be passed through unchanged, enabling potential XSS or open-redirect attacks when the frontend renders the link.
- result.messages[0] is accessed without checking that messages is non-empty in _parse_response_from_openai; if the parent parser returns an empty messages list, this raises an unhandled IndexError.
Suggestions
- In
_parse_response_from_openai, theelif not consent_link:branch is always true when reached (it is the logical complement ofif consent_link:). Simplify to a plainelse:to remove the dead condition and signal intent clearly. - In
_parse_chunk_from_openai, when anoauth_consent_requestevent has noconsent_link, the method returns aChatResponseUpdate(contents=[])instead of falling through tosuper(). An empty-contents update may still advance caller state machines or suppress the 'Unparsed event' log from the super method. Consider whether returning early with empty contents or delegating to super is the correct contract for this case. - Validate that consent_link starts with https:// (or an allowlisted scheme/domain) before emitting it in both _parse_response_from_openai and _parse_chunk_from_openai.
- In the streaming path (_parse_chunk_from_openai), when consent_link is absent the method still returns an empty ChatResponseUpdate instead of falling through to super(); consider whether returning an empty update is intentional or whether the call should be delegated to super() when there is nothing to emit.
- Add a test for
_parse_chunk_from_openaiwhere the oauth_consent_request item has no consent_link, asserting the update has empty contents and that a warning is logged (e.g., usingassertLogs). - Add a test for
_parse_response_from_openaiwhere the oauth_consent_request item has no consent_link, similarly asserting the warning is emitted and no consent content is appended. - Add a test verifying that a non-oauth
response.output_item.addedevent (e.g., type='message') is passed through to the parent_parse_chunk_from_openairather than being silently dropped. - Add a test covering mixed output in
_parse_response_from_openai— a response with both a normal message item and an oauth_consent_request item — to ensure both are included in the result. - In
test_emit_oauth_consent_request_no_link, construct content viaContent('oauth_consent_request')or use the factory consistently, and add a comment explaining why consent_link is absent so the test intent is clear. - The serialization roundtrip test only checks
to_dict; consider adding deserialization (reconstruct Content from the dict) to make it a true roundtrip test.
Automated review by moonbox3's agents
| consent_link = item.consent_link | ||
| if consent_link: | ||
| result.messages[0].contents.append( | ||
| Content.from_oauth_consent_request( |
There was a problem hiding this comment.
elif not consent_link: is always True here because it is the logical complement of if consent_link:. The condition adds no extra guard and is misleading. Use plain else: instead.
| Content.from_oauth_consent_request( | |
| else: |
| if consent_link: | ||
| result.messages[0].contents.append( | ||
| Content.from_oauth_consent_request( | ||
| consent_link=consent_link, |
There was a problem hiding this comment.
consent_link is passed to the frontend without URL validation. If the Azure API returns a javascript: URI or a non-HTTPS URL, the frontend will receive it verbatim and could render it as a clickable link (XSS / phishing). Validate the scheme before using the value.
| consent_link=consent_link, | |
| consent_link = item.consent_link | |
| if consent_link and consent_link.startswith("https://"): |
| if item.type == "oauth_consent_request": | ||
| consent_link = item.consent_link | ||
| if consent_link: | ||
| result.messages[0].contents.append( |
There was a problem hiding this comment.
result.messages[0] is accessed unconditionally. If the parent _parse_response_from_openai returns a ChatResponse with an empty messages list (e.g. on an unexpected response shape), this raises IndexError.
| result.messages[0].contents.append( | |
| if result.messages: | |
| for item in response.output: | |
| if item.type == "oauth_consent_request": |
| if event.type == "response.output_item.added" and event.item.type == "oauth_consent_request": | ||
| event_item = event.item | ||
| consent_link = event_item.consent_link | ||
| contents: list[Content] = [] |
There was a problem hiding this comment.
Same missing HTTPS scheme validation as the non-streaming path — consent_link should be checked for a safe URL scheme before emitting.
| contents: list[Content] = [] | |
| consent_link = event_item.consent_link | |
| if consent_link and not consent_link.startswith("https://"): | |
| logger.warning("Received oauth_consent_request with non-HTTPS consent_link: %s", event_item) | |
| consent_link = None |
| assert events[0].value == {"consent_link": "https://login.microsoftonline.com/consent"} | ||
|
|
||
|
|
||
| def test_emit_oauth_consent_request_no_link(): |
There was a problem hiding this comment.
This test constructs Content directly with user_input_request=True but no consent_link, which works but is inconsistent with how real no-link content would arrive (the factory always sets consent_link). Consider using Content('oauth_consent_request') (no extra args) to better represent a malformed/missing-link scenario, or document why user_input_request=True is set here.
| def test_emit_oauth_consent_request_no_link(): | |
| content = Content("oauth_consent_request") |
| assert len(update.contents) == 1 | ||
| consent_content = update.contents[0] | ||
| assert consent_content.type == "oauth_consent_request" | ||
| assert consent_content.consent_link == "https://login.microsoftonline.com/common/oauth2/authorize?client_id=abc123" |
There was a problem hiding this comment.
The no-consent-link branch of _parse_chunk_from_openai (the else that logs a warning) is not tested. Add a test with mock_item.consent_link = None or "" to cover this path and assert the returned update has empty contents.
| assert len(response.messages) > 0 | ||
| consent_contents = [c for c in response.messages[0].contents if c.type == "oauth_consent_request"] | ||
| assert len(consent_contents) == 1 | ||
| assert consent_contents[0].consent_link == "https://login.microsoftonline.com/consent?code=abc" |
There was a problem hiding this comment.
The no-consent-link branch of _parse_response_from_openai (warning log when consent_link is falsy) is untested. Add a test with mock_item.consent_link = None asserting no oauth content is appended and a warning is emitted.
Summary
Fixes #3950 - When Azure AI Agent Service returns an oauth_consent_request output item for OAuth-protected MCP tools, the SDK silently drops it, causing agent runs to complete with zero content.
Root Cause
The oauth_consent_request item type is Azure-specific (defined in azure.ai.projects.models.OAuthConsentRequestItemResource), not part of the OpenAI SDK type union. The base RawOpenAIResponsesClient parser hits the default branch and logs Unparsed event, discarding the event.
Changes
Core (agent_framework/_types.py)
Azure AI Client (agent_framework_azure_ai/_client.py)
AG-UI (agent_framework_ag_ui/_run_common.py)
Tests