Compare commits

...
Author SHA1 Message Date
Tao Chen 20ac21c780 Fix tests 2026-06-01 13:25:54 -07:00
Tao Chen de5b4d619a Make unused tool as comment 2026-06-01 13:15:08 -07:00
Tao Chen 98fbaf2481 Resolve conflict 2026-06-01 13:08:33 -07:00
Tao Chen 0fc5600ae2 Fix toolbox consent flow in hosted agent 2026-06-01 12:59:01 -07:00
5 changed files with 183 additions and 110 deletions
@@ -11,7 +11,7 @@ import tempfile
import threading import threading
from collections.abc import AsyncIterable, AsyncIterator, Generator, Sequence from collections.abc import AsyncIterable, AsyncIterator, Generator, Sequence
from contextlib import AbstractAsyncContextManager, AsyncExitStack, suppress from contextlib import AbstractAsyncContextManager, AsyncExitStack, suppress
from dataclasses import asdict, is_dataclass from dataclasses import asdict, dataclass, is_dataclass
from pathlib import Path from pathlib import Path
from typing import Protocol, cast from typing import Protocol, cast
@@ -264,28 +264,73 @@ def _checkpoint_storage_for_context(root: str, context_id: str) -> FileCheckpoin
# Foundry Toolbox Auth integration # Foundry Toolbox Auth integration
# Consent-URL error code returned by the Foundry MCP gateway when calling `/list` # Consent-URL error code returned by the Foundry MCP gateway when calling `/list`
CONSENT_ERROR_CODE = -32007 CONSENT_ERROR_CODE = -32006
def consent_url_from_error(exc: BaseException) -> str | None: @dataclass
"""Return the consent URL when ``exc`` wraps a Foundry MCP gateway consent error. class ConsentError:
name: str
consent_url: str
The Agent Framework MCP layer surfaces gateway consent failures by wrapping the underlying
``McpError`` inside an :class:`AgentFrameworkException` (typically a ``ToolExecutionException`` def consent_url_from_error(exc: BaseException) -> list[ConsentError] | None:
raised from ``MCPStreamableHTTPTool.__aenter__``). This helper inspects ``exc.args`` for a """Return the consent URLs when ``exc`` wraps Foundry MCP gateway consent errors.
wrapped ``McpError`` whose ``error.code`` is :data:`CONSENT_ERROR_CODE`; when found, the
consent link the gateway returned in ``error.message`` is returned. Returns ``None`` for
anything else, so callers can do ``if (url := consent_url_from_error(ex)) is None: raise``.
Args: Args:
exc: The exception to inspect. exc: The exception to inspect.
Returns: Returns:
The consent URL if ``exc`` wraps a consent ``McpError``, otherwise ``None``. The consent URL(s) extracted from the error, or ``None`` if no consent error was found.
""" """
inner_exception = next((arg for arg in exc.args if isinstance(arg, McpError)), None) inner_exception = next((arg for arg in exc.args if isinstance(arg, McpError)), None)
if inner_exception is not None and inner_exception.error.code == CONSENT_ERROR_CODE: if inner_exception is not None and inner_exception.error.code == CONSENT_ERROR_CODE:
return inner_exception.error.message # Parse the error message
# The error message is structured with the following format:
# "tools/list failed for 1 tool source(s), succeeded for 0 tool source(s) {"errors":[{"name": ..."
# where the second part is a JSON string that can be deserialized into an object with the following shape:
# ruff: disable[ERA001]
# {
# "errors" : [
# {
# "name": "Name of the MCP tool that requires consent",
# "type" : "mcp",
# "error": {
# "code": "CONSENT_REQUIRED",
# "message": consent_url,
# }
# }
# ]
# }
# ruff: enable[ERA001]
try:
consent_errors: list[ConsentError] = []
error_message_start = inner_exception.error.message.find("{")
if error_message_start == -1:
logger.warning("Consent error message does not contain JSON: %s", inner_exception.error.message)
return None
consent_details_json = inner_exception.error.message[error_message_start:]
consent_details = json.loads(consent_details_json)
if "errors" not in consent_details or not isinstance(consent_details["errors"], list):
logger.warning("Consent error message JSON does not contain 'errors' list: %s", consent_details_json)
return None
for error in consent_details["errors"]:
if (
isinstance(error, dict)
and error.get("type") == "mcp" # type: ignore
and "error" in error
and isinstance(error["error"], dict)
and error["error"].get("code") == "CONSENT_REQUIRED" # type: ignore
and "message" in error["error"]
):
consent_url = error["error"]["message"] # type: ignore
if isinstance(consent_url, str):
consent_errors.append(ConsentError(name=error.get("name", "Unknown"), consent_url=consent_url)) # type: ignore
else:
logger.warning("Consent URL in error message is not a valid URL: %s", consent_url) # type: ignore
if consent_errors:
return consent_errors
except json.JSONDecodeError:
logger.warning("Failed to parse consent details JSON: %s", inner_exception.error.message)
return None return None
@@ -448,18 +493,19 @@ class ResponsesHostServer(ResponsesAgentServerHost):
try: try:
await self._ensure_agent_ready() await self._ensure_agent_ready()
except AgentFrameworkException as ex: except AgentFrameworkException as ex:
consent_url = consent_url_from_error(ex) consent_errors = consent_url_from_error(ex)
if consent_url is None: if consent_errors is None:
raise raise
logger.warning("OAuth consent required for Foundry MCP gateway.") for consent_error in consent_errors:
oauth_item = OAuthConsentRequestOutputItem( logger.warning("Consent URL for tool '%s': %s", consent_error.name, consent_error.consent_url)
id=IdGenerator.new_id("oacr"), oauth_item = OAuthConsentRequestOutputItem(
consent_link=consent_url, id=IdGenerator.new_id("oacr"),
server_label="Foundry Toolbox", consent_link=consent_error.consent_url,
) server_label=consent_error.name,
builder = response_event_stream.add_output_item(oauth_item.id) )
yield builder.emit_added(oauth_item) builder = response_event_stream.add_output_item(oauth_item.id)
yield builder.emit_done(oauth_item) yield builder.emit_added(oauth_item)
yield builder.emit_done(oauth_item)
yield response_event_stream.emit_completed() yield response_event_stream.emit_completed()
return return
@@ -39,6 +39,7 @@ from agent_framework_foundry_hosting import ResponsesHostServer
from agent_framework_foundry_hosting._responses import ( from agent_framework_foundry_hosting._responses import (
_AZURE_RESPONSES_MESSAGE_ROLE_TYPE, # pyright: ignore[reportPrivateUsage] _AZURE_RESPONSES_MESSAGE_ROLE_TYPE, # pyright: ignore[reportPrivateUsage]
CONSENT_ERROR_CODE, CONSENT_ERROR_CODE,
ConsentError,
FileBasedFunctionApprovalStorage, # pyright: ignore[reportPrivateUsage] FileBasedFunctionApprovalStorage, # pyright: ignore[reportPrivateUsage]
InMemoryFunctionApprovalStorage, # pyright: ignore[reportPrivateUsage] InMemoryFunctionApprovalStorage, # pyright: ignore[reportPrivateUsage]
_item_to_message, # pyright: ignore[reportPrivateUsage] _item_to_message, # pyright: ignore[reportPrivateUsage]
@@ -3067,7 +3068,10 @@ class TestCheckpointContextPathValidation:
# region Agent lifecycle (lazy entry & OAuth consent surfacing) # region Agent lifecycle (lazy entry & OAuth consent surfacing)
def _make_consent_error(url: str = "https://consent.example.com/auth") -> Exception: def _make_consent_error(
url: str = "https://consent.example.com/auth",
name: str = "Foundry Toolbox",
) -> Exception:
"""Build an exception wrapping a Foundry MCP gateway consent error. """Build an exception wrapping a Foundry MCP gateway consent error.
Mirrors the real-world wrapping produced by ``MCPStreamableHTTPTool.__aenter__``, Mirrors the real-world wrapping produced by ``MCPStreamableHTTPTool.__aenter__``,
@@ -3075,17 +3079,34 @@ def _make_consent_error(url: str = "https://consent.example.com/auth") -> Except
``ToolExecutionException`` (an ``AgentFrameworkException`` subclass) with the ``ToolExecutionException`` (an ``AgentFrameworkException`` subclass) with the
original error attached via ``inner_exception``. ``consent_url_from_error`` original error attached via ``inner_exception``. ``consent_url_from_error``
then finds the wrapped ``McpError`` in ``exc.args``. then finds the wrapped ``McpError`` in ``exc.args``.
The McpError message uses the structured Foundry MCP gateway format:
a human-readable prefix followed by a JSON document describing each
failed tool source and its consent URL.
""" """
from agent_framework.exceptions import ToolExecutionException from agent_framework.exceptions import ToolExecutionException
inner = McpError(ErrorData(code=CONSENT_ERROR_CODE, message=url)) payload = json.dumps({
"errors": [
{
"name": name,
"type": "mcp",
"error": {
"code": "CONSENT_REQUIRED",
"message": url,
},
}
]
})
message = f"tools/list failed for 1 tool source(s), succeeded for 0 tool source(s) {payload}"
inner = McpError(ErrorData(code=CONSENT_ERROR_CODE, message=message))
return ToolExecutionException("MCP consent required", inner_exception=inner) return ToolExecutionException("MCP consent required", inner_exception=inner)
class TestConsentUrlFromError: class TestConsentUrlFromError:
def test_returns_consent_url_when_inner_arg_is_consent_mcp_error(self) -> None: def test_returns_consent_url_when_inner_arg_is_consent_mcp_error(self) -> None:
exc = _make_consent_error("https://example.com/consent") exc = _make_consent_error("https://example.com/consent", name="my-tool")
assert consent_url_from_error(exc) == "https://example.com/consent" assert consent_url_from_error(exc) == [ConsentError(name="my-tool", consent_url="https://example.com/consent")]
def test_returns_none_when_no_mcp_error_in_args(self) -> None: def test_returns_none_when_no_mcp_error_in_args(self) -> None:
assert consent_url_from_error(Exception("boom")) is None assert consent_url_from_error(Exception("boom")) is None
@@ -3102,6 +3123,13 @@ class TestConsentUrlFromError:
bare = McpError(ErrorData(code=CONSENT_ERROR_CODE, message="https://x")) bare = McpError(ErrorData(code=CONSENT_ERROR_CODE, message="https://x"))
assert consent_url_from_error(bare) is None assert consent_url_from_error(bare) is None
def test_returns_none_when_message_has_no_json(self) -> None:
from agent_framework.exceptions import ToolExecutionException
inner = McpError(ErrorData(code=CONSENT_ERROR_CODE, message="no json here"))
exc = ToolExecutionException("MCP consent required", inner_exception=inner)
assert consent_url_from_error(exc) is None
class TestAgentLifecycle: class TestAgentLifecycle:
async def test_agent_entered_lazily_on_first_request(self) -> None: async def test_agent_entered_lazily_on_first_request(self) -> None:
@@ -20,7 +20,7 @@ You can connect to MCP servers in Foundry Toolbox that use different authenticat
- **Agent identity authentication**: The tool requires an agent identity token to authenticate. Sample MCP server: `https://{foundry-resource-name}.cognitiveservices.azure.com/language/mcp?api-version=2025-11-15-preview` (Azure Language MCP server) with agent identity for authentication. - **Agent identity authentication**: The tool requires an agent identity token to authenticate. Sample MCP server: `https://{foundry-resource-name}.cognitiveservices.azure.com/language/mcp?api-version=2025-11-15-preview` (Azure Language MCP server) with agent identity for authentication.
- **Entra Pass-through authentication**: The tool requires an Entra pass-through token to authenticate. Sample MCP server: Microsoft Outlook MCP server with Entra pass-through for authentication. - **Entra Pass-through authentication**: The tool requires an Entra pass-through token to authenticate. Sample MCP server: Microsoft Outlook MCP server with Entra pass-through for authentication.
> Definitions of these authentication methods can be found in the [agent.manifest.yaml](agent.manifest.yaml) file in this sample. > Definitions of these authentication methods can be found in the [agent.manifest.yaml](agent.manifest.yaml) file in this sample. The GitHub MCP connection defaults to using a PAT for authentication in this sample, but you can switch to OAuth2 by changing the `project_connection_id` field in the `agent.manifest.yaml` file and following the instructions in the comments.
There are also Non-MCP tools in the toolbox that support different authentication methods. Learn more at the [Foundry sample repository](https://github.com/microsoft-foundry/foundry-samples/blob/main/samples/python/hosted-agents/SUPPORTED_TOOLBOX_SCENARIOS.md). There are also Non-MCP tools in the toolbox that support different authentication methods. Learn more at the [Foundry sample repository](https://github.com/microsoft-foundry/foundry-samples/blob/main/samples/python/hosted-agents/SUPPORTED_TOOLBOX_SCENARIOS.md).
@@ -18,92 +18,92 @@ template:
- name: AZURE_AI_MODEL_DEPLOYMENT_NAME - name: AZURE_AI_MODEL_DEPLOYMENT_NAME
value: "{{AZURE_AI_MODEL_DEPLOYMENT_NAME}}" value: "{{AZURE_AI_MODEL_DEPLOYMENT_NAME}}"
- name: TOOLBOX_NAME - name: TOOLBOX_NAME
value: "agent-tools-2" value: "agent-tools"
# parameters: parameters:
# properties: properties:
# - name: mcp_endpoint - name: mcp_endpoint
# # `azd ai agent init -m` will prompt for this value when initializing the agent manifest # `azd ai agent init -m` will prompt for this value when initializing the agent manifest
# secret: false secret: false
# description: URL of the public MCP server (e.g. https://gitmcp.io/Azure/azure-rest-api-specs) that does not require authentication description: URL of the public MCP server (e.g. https://gitmcp.io/Azure/azure-rest-api-specs) that does not require authentication
# - name: github_pat - name: github_pat
# # `azd ai agent init -m` will prompt for this value when initializing the agent manifest. # `azd ai agent init -m` will prompt for this value when initializing the agent manifest.
# # Only needed when the GitHub MCP connection is configured to use the `github-mcp-pat-conn` # Only needed when the GitHub MCP connection is configured to use the `github-mcp-pat-conn`
# # PAT-based connection below; if you use the `github-mcp-oauth-conn` OAuth2 connection # PAT-based connection below; if you use the `github-mcp-oauth-conn` OAuth2 connection
# # instead, you can leave this empty. # instead, you can leave this empty.
# secret: true secret: true
# description: GitHub Personal Access Token used to authenticate with the GitHub MCP server (only needed when using the PAT connection; press Enter if using OAuth2 instead) description: GitHub Personal Access Token used to authenticate with the GitHub MCP server (only needed when using the PAT connection; press Enter if using OAuth2 instead)
# - name: language_mcp_entra_audience # - name: language_mcp_entra_audience
# secret: false # secret: false
# description: Entra ID audience for the Azure Language MCP server (e.g. https://cognitiveservices.azure.com/) # description: Entra ID audience for the Azure Language MCP server (e.g. https://cognitiveservices.azure.com/)
# - name: language_mcp_target_url # - name: language_mcp_target_url
# secret: false # secret: false
# description: URL of the Azure Language MCP server that accepts agent identity tokens (e.g. https://{foundry-resource-name}.cognitiveservices.azure.com/language/mcp?api-version=2025-11-15-preview) # description: URL of the Azure Language MCP server that accepts agent identity tokens (e.g. https://{foundry-resource-name}.cognitiveservices.azure.com/language/mcp?api-version=2025-11-15-preview)
# - name: outlook_mail_entra_audience # - name: outlook_mail_entra_audience
# secret: false # secret: false
# description: Entra ID audience for the Outlook Mail MCP server # description: Entra ID audience for the Outlook Mail MCP server
# - name: outlook_mail_entra_mcp_target # - name: outlook_mail_entra_mcp_target
# secret: false # secret: false
# description: URL of the Outlook Mail MCP server that accepts user Entra tokens # description: URL of the Outlook Mail MCP server that accepts user Entra tokens
resources: resources:
- kind: model - kind: model
id: gpt-4.1-mini id: gpt-4.1-mini
name: AZURE_AI_MODEL_DEPLOYMENT_NAME name: AZURE_AI_MODEL_DEPLOYMENT_NAME
# - kind: connection - kind: connection
# # A connection that uses a GitHub Personal Access Token (PAT) to authenticate with the GitHub MCP server # A connection that uses a GitHub Personal Access Token (PAT) to authenticate with the GitHub MCP server
# name: github-mcp-pat-conn name: github-mcp-pat-conn
# category: RemoteTool category: RemoteTool
# authType: CustomKeys authType: CustomKeys
# target: https://api.githubcopilot.com/mcp target: https://api.githubcopilot.com/mcp
# credentials: credentials:
# type: CustomKeys type: CustomKeys
# keys: keys:
# Authorization: "Bearer {{ github_pat }}" Authorization: "Bearer {{ github_pat }}"
# - kind: connection - kind: connection
# # A connection that uses OAuth2 to authenticate with the GitHub MCP server # A connection that uses OAuth2 to authenticate with the GitHub MCP server
# name: github-mcp-oauth-conn name: github-mcp-oauth-conn
# category: RemoteTool category: RemoteTool
# authType: OAuth2 authType: OAuth2
# target: https://api.githubcopilot.com/mcp target: https://api.githubcopilot.com/mcp
# connectorName: foundrygithubmcp connectorName: foundrygithubmcp
# credentials: credentials:
# type: OAuth2 type: OAuth2
# clientId: managed clientId: managed
# clientSecret: managed clientSecret: managed
# - kind: connection # - kind: connection
# name: language-mcp-conn # name: language-mcp-conn
# category: RemoteTool # category: RemoteTool
# authType: AgenticIdentity # authType: AgenticIdentity
# audience: "{{ language_mcp_entra_audience }}" # audience: "{{ language_mcp_entra_audience }}"
# target: "{{ language_mcp_target_url }}" # target: "{{ language_mcp_target_url }}"
# # - kind: connection # - kind: connection
# # name: outlook-mail-conn # name: outlook-mail-conn
# # category: RemoteTool # category: RemoteTool
# # authType: UserEntraToken # authType: UserEntraToken
# # audience: "{{ outlook_mail_entra_audience }}" # audience: "{{ outlook_mail_entra_audience }}"
# # target: "{{ outlook_mail_entra_mcp_target }}" # target: "{{ outlook_mail_entra_mcp_target }}"
# - kind: toolbox - kind: toolbox
# name: agent-tools name: agent-tools
# tools: tools:
# - type: web_search - type: web_search
# name: web_search name: web_search
# - type: code_interpreter - type: code_interpreter
# name: code_interpreter name: code_interpreter
# # - type: mcp - type: mcp
# # # This MCP tool doesn't require authentication # This MCP tool doesn't require authentication
# # server_label: noauth_mcp server_label: noauth_mcp
# # server_url: "{{ mcp_endpoint }}" server_url: "{{ mcp_endpoint }}"
# # require_approval: "never" require_approval: "never"
# - type: mcp - type: mcp
# # This MCP tool uses the GitHub MCP server with a PAT for authentication or OAuth2 # This MCP tool uses the GitHub MCP server with a PAT for authentication or OAuth2
# server_label: github server_label: github
# project_connection_id: github-mcp-pat-conn # use `github-mcp-oauth-conn` for OAuth2 authentication project_connection_id: github-mcp-pat-conn # use `github-mcp-oauth-conn` for OAuth2 authentication
# require_approval: "never" require_approval: "never"
# - type: mcp # - type: mcp
# # This MCP tool uses the Azure Language MCP server with agent identity for authentication # # This MCP tool uses the Azure Language MCP server with agent identity for authentication
# server_label: language-mcp # server_label: language-mcp
# project_connection_id: language-mcp-conn # project_connection_id: language-mcp-conn
# require_approval: "never" # require_approval: "never"
# # - type: mcp # - type: mcp
# # server_label: outlook-mail # server_label: outlook-mail
# # project_connection_id: outlook-mail-conn # project_connection_id: outlook-mail-conn
# # require_approval: "never" # require_approval: "never"
@@ -1,4 +1,3 @@
# agent-framework agent-framework
# agent-framework-foundry-hosting agent-framework-foundry-hosting
mcp>=1.24.0,<2 mcp>=1.24.0,<2