Skip to content

Python: Fix: Parse oauth_consent_request events in Azure AI client #4197

Open
giles17 wants to merge 4 commits intomicrosoft:mainfrom
giles17:oauth_event_fix
Open

Python: Fix: Parse oauth_consent_request events in Azure AI client #4197
giles17 wants to merge 4 commits intomicrosoft:mainfrom
giles17:oauth_event_fix

Conversation

@giles17
Copy link
Contributor

@giles17 giles17 commented Feb 23, 2026

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)

  • Add oauth_consent_request to ContentType literal
  • Add consent_link field to Content.init
  • Add Content.from_oauth_consent_request() factory method (with user_input_request=True)
  • Add consent_link to to_dict() serialization

Azure AI Client (agent_framework_azure_ai/_client.py)

  • Override _parse_response_from_openai to detect oauth_consent_request output items after base parsing
  • Override _parse_chunk_from_openai to intercept response.output_item.added events with oauth_consent_request items before delegating to base parser

AG-UI (agent_framework_ag_ui/_run_common.py)

  • Add _emit_oauth_consent() helper emitting CustomEvent for frontends
  • Wire into _emit_content() for the new content type

Tests

  • Core: creation + serialization roundtrip tests
  • Azure AI: streaming + non-streaming parser tests, plus a before/after test proving the base parser drops the event while the override catches it
  • AG-UI: emission tests with and without consent_link

…#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>
Copilot AI review requested due to automatic review settings February 23, 2026 22:44
@github-actions github-actions bot changed the title Fix: Parse oauth_consent_request events in Azure AI client (#3950) Python: Fix: Parse oauth_consent_request events in Azure AI client (#3950) Feb 23, 2026
@giles17 giles17 changed the title Python: Fix: Parse oauth_consent_request events in Azure AI client (#3950) Python: Fix: Parse oauth_consent_request events in Azure AI client Feb 23, 2026
@markwallace-microsoft
Copy link
Member

markwallace-microsoft commented Feb 23, 2026

Python Test Coverage

Python Test Coverage Report •
FileStmtsMissCoverMissing
packages/ag-ui/agent_framework_ag_ui
   _run_common.py2062985%46, 51–52, 54, 61, 65, 69–70, 72, 96, 173, 221–222, 236, 263–265, 290–291, 297–302, 397, 399, 404–405
packages/azure-ai/agent_framework_azure_ai
   _client.py3884987%388, 390, 433, 441–453, 466, 526, 541–546, 589, 610–611, 636, 644, 679, 681, 702–703, 706, 747, 750, 752, 843, 874, 914, 1122, 1125, 1128–1129, 1131–1134, 1177
packages/core/agent_framework
   _types.py10288691%59, 68–69, 123, 128, 147, 149, 153, 157, 159, 161, 163, 181, 185, 211, 233, 238, 243, 247, 273, 277, 630–631, 1033, 1096, 1113, 1131, 1136, 1154, 1164, 1181–1182, 1184, 1202–1203, 1205, 1212–1213, 1215, 1250, 1261–1262, 1264, 1302, 1529, 1581, 1672–1677, 1699, 1704, 1870, 1882, 2134, 2155, 2250, 2479, 2686, 2756, 2768, 2786, 2984–2986, 2989–2991, 2995, 3000, 3004, 3088–3090, 3119, 3173, 3192–3193, 3196–3200, 3206
TOTAL21773276287% 

Python Unit Test Overview

Tests Skipped Failures Errors Time
4536 246 💤 0 ❌ 0 🔥 1m 20s ⏱️

Copy link
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 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_request content type to the core framework with a consent_link field
  • 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":
Copy link
Member

Choose a reason for hiding this comment

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

and this logic will silently ignore other content types? And that seems inconsistent with the non-streaming flow?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

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

Copy link
Contributor

@moonbox3 moonbox3 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: 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 the if consent_link: branch is not taken, so it can never be false). In _parse_chunk_from_openai, when oauth_consent_request is intercepted but consent_link is absent, the method still returns a ChatResponseUpdate with an empty contents list 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) constructs Content("oauth_consent_request", user_input_request=True) without a consent_link, which correctly tests the guard in _emit_oauth_consent. Overall correctness is good but the empty-contents streaming return path and the dead elif are 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_openai returns a response with no messages. The streaming path returns a ChatResponseUpdate with an empty contents list (no role mismatch, just wasted allocation) when consent_link is 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_openai override is tested only for OAuth items and not for mixed output (OAuth + normal items together), and the test_emit_oauth_consent_request_no_link test constructs Content directly rather than via from_oauth_consent_request, making it fragile and inconsistent. There are also no tests for _parse_chunk_from_openai when 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. The to_dict roundtrip 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, the elif not consent_link: branch is always true when reached (it is the logical complement of if consent_link:). Simplify to a plain else: to remove the dead condition and signal intent clearly.
  • In _parse_chunk_from_openai, when an oauth_consent_request event has no consent_link, the method returns a ChatResponseUpdate(contents=[]) instead of falling through to super(). 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_openai where the oauth_consent_request item has no consent_link, asserting the update has empty contents and that a warning is logged (e.g., using assertLogs).
  • Add a test for _parse_response_from_openai where 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.added event (e.g., type='message') is passed through to the parent _parse_chunk_from_openai rather 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 via Content('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(
Copy link
Contributor

Choose a reason for hiding this comment

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

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.

Suggested change
Content.from_oauth_consent_request(
else:

if consent_link:
result.messages[0].contents.append(
Content.from_oauth_consent_request(
consent_link=consent_link,
Copy link
Contributor

Choose a reason for hiding this comment

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

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.

Suggested change
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(
Copy link
Contributor

Choose a reason for hiding this comment

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

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.

Suggested change
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] = []
Copy link
Contributor

Choose a reason for hiding this comment

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

Same missing HTTPS scheme validation as the non-streaming path — consent_link should be checked for a safe URL scheme before emitting.

Suggested change
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():
Copy link
Contributor

Choose a reason for hiding this comment

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

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.

Suggested change
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"
Copy link
Contributor

Choose a reason for hiding this comment

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

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"
Copy link
Contributor

Choose a reason for hiding this comment

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

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.

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Python: [Bug]: response.oauth_consent_requested events silently dropped - OAuth consent link never surfaces to callers

5 participants