diff --git a/python/packages/foundry_hosting/agent_framework_foundry_hosting/_responses.py b/python/packages/foundry_hosting/agent_framework_foundry_hosting/_responses.py index 6dc9bbd8c6..bb5dd39215 100644 --- a/python/packages/foundry_hosting/agent_framework_foundry_hosting/_responses.py +++ b/python/packages/foundry_hosting/agent_framework_foundry_hosting/_responses.py @@ -11,7 +11,7 @@ import tempfile import threading from collections.abc import AsyncIterable, AsyncIterator, Generator, Mapping, Sequence from contextlib import AbstractAsyncContextManager, AsyncExitStack, suppress -from dataclasses import asdict, is_dataclass +from dataclasses import asdict, dataclass, is_dataclass from pathlib import Path from typing import Protocol, cast @@ -264,28 +264,73 @@ def _checkpoint_storage_for_context(root: str, context_id: str) -> FileCheckpoin # Foundry Toolbox Auth integration # 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: - """Return the consent URL when ``exc`` wraps a Foundry MCP gateway consent error. +@dataclass +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`` - raised from ``MCPStreamableHTTPTool.__aenter__``). This helper inspects ``exc.args`` for a - 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``. + +def consent_url_from_error(exc: BaseException) -> list[ConsentError] | None: + """Return the consent URLs when ``exc`` wraps Foundry MCP gateway consent errors. Args: exc: The exception to inspect. 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) 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 @@ -448,18 +493,19 @@ class ResponsesHostServer(ResponsesAgentServerHost): try: await self._ensure_agent_ready() except AgentFrameworkException as ex: - consent_url = consent_url_from_error(ex) - if consent_url is None: + consent_errors = consent_url_from_error(ex) + if consent_errors is None: raise - logger.warning("OAuth consent required for Foundry MCP gateway.") - oauth_item = OAuthConsentRequestOutputItem( - id=IdGenerator.new_id("oacr"), - consent_link=consent_url, - server_label="Foundry Toolbox", - ) - builder = response_event_stream.add_output_item(oauth_item.id) - yield builder.emit_added(oauth_item) - yield builder.emit_done(oauth_item) + for consent_error in consent_errors: + logger.warning("Consent URL for tool '%s': %s", consent_error.name, consent_error.consent_url) + oauth_item = OAuthConsentRequestOutputItem( + id=IdGenerator.new_id("oacr"), + 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) + yield builder.emit_done(oauth_item) yield response_event_stream.emit_completed() return diff --git a/python/packages/foundry_hosting/tests/test_responses.py b/python/packages/foundry_hosting/tests/test_responses.py index 9c65a9ea42..d329d406aa 100644 --- a/python/packages/foundry_hosting/tests/test_responses.py +++ b/python/packages/foundry_hosting/tests/test_responses.py @@ -39,6 +39,7 @@ from agent_framework_foundry_hosting import ResponsesHostServer from agent_framework_foundry_hosting._responses import ( _AZURE_RESPONSES_MESSAGE_ROLE_TYPE, # pyright: ignore[reportPrivateUsage] CONSENT_ERROR_CODE, + ConsentError, FileBasedFunctionApprovalStorage, # pyright: ignore[reportPrivateUsage] InMemoryFunctionApprovalStorage, # pyright: ignore[reportPrivateUsage] _item_to_message, # pyright: ignore[reportPrivateUsage] @@ -3260,7 +3261,10 @@ class TestCheckpointContextPathValidation: # 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. Mirrors the real-world wrapping produced by ``MCPStreamableHTTPTool.__aenter__``, @@ -3268,17 +3272,34 @@ def _make_consent_error(url: str = "https://consent.example.com/auth") -> Except ``ToolExecutionException`` (an ``AgentFrameworkException`` subclass) with the original error attached via ``inner_exception``. ``consent_url_from_error`` 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 - 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) class TestConsentUrlFromError: def test_returns_consent_url_when_inner_arg_is_consent_mcp_error(self) -> None: - exc = _make_consent_error("https://example.com/consent") - assert consent_url_from_error(exc) == "https://example.com/consent" + exc = _make_consent_error("https://example.com/consent", name="my-tool") + 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: assert consent_url_from_error(Exception("boom")) is None @@ -3295,6 +3316,13 @@ class TestConsentUrlFromError: bare = McpError(ErrorData(code=CONSENT_ERROR_CODE, message="https://x")) 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: async def test_agent_entered_lazily_on_first_request(self) -> None: diff --git a/python/samples/04-hosting/foundry-hosted-agents/responses/04_foundry_toolbox/README.md b/python/samples/04-hosting/foundry-hosted-agents/responses/04_foundry_toolbox/README.md index 8a26737c86..7b7051dda4 100644 --- a/python/samples/04-hosting/foundry-hosted-agents/responses/04_foundry_toolbox/README.md +++ b/python/samples/04-hosting/foundry-hosted-agents/responses/04_foundry_toolbox/README.md @@ -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. - **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). diff --git a/python/samples/04-hosting/foundry-hosted-agents/responses/04_foundry_toolbox/agent.manifest.yaml b/python/samples/04-hosting/foundry-hosted-agents/responses/04_foundry_toolbox/agent.manifest.yaml index c8774c04f1..34d4b54c4a 100644 --- a/python/samples/04-hosting/foundry-hosted-agents/responses/04_foundry_toolbox/agent.manifest.yaml +++ b/python/samples/04-hosting/foundry-hosted-agents/responses/04_foundry_toolbox/agent.manifest.yaml @@ -18,92 +18,92 @@ template: - name: AZURE_AI_MODEL_DEPLOYMENT_NAME value: "{{AZURE_AI_MODEL_DEPLOYMENT_NAME}}" - name: TOOLBOX_NAME - value: "agent-tools-2" -# parameters: -# properties: -# - name: mcp_endpoint -# # `azd ai agent init -m` will prompt for this value when initializing the agent manifest -# secret: false -# 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 -# # `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` -# # PAT-based connection below; if you use the `github-mcp-oauth-conn` OAuth2 connection -# # instead, you can leave this empty. -# 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) -# - name: language_mcp_entra_audience -# secret: false -# description: Entra ID audience for the Azure Language MCP server (e.g. https://cognitiveservices.azure.com/) -# - name: language_mcp_target_url -# 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) -# - name: outlook_mail_entra_audience -# secret: false -# description: Entra ID audience for the Outlook Mail MCP server -# - name: outlook_mail_entra_mcp_target -# secret: false -# description: URL of the Outlook Mail MCP server that accepts user Entra tokens + value: "agent-tools" +parameters: + properties: + - name: mcp_endpoint + # `azd ai agent init -m` will prompt for this value when initializing the agent manifest + secret: false + 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 + # `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` + # PAT-based connection below; if you use the `github-mcp-oauth-conn` OAuth2 connection + # instead, you can leave this empty. + 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) + # - name: language_mcp_entra_audience + # secret: false + # description: Entra ID audience for the Azure Language MCP server (e.g. https://cognitiveservices.azure.com/) + # - name: language_mcp_target_url + # 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) + # - name: outlook_mail_entra_audience + # secret: false + # description: Entra ID audience for the Outlook Mail MCP server + # - name: outlook_mail_entra_mcp_target + # secret: false + # description: URL of the Outlook Mail MCP server that accepts user Entra tokens resources: - kind: model id: gpt-4.1-mini name: AZURE_AI_MODEL_DEPLOYMENT_NAME - # - kind: connection - # # A connection that uses a GitHub Personal Access Token (PAT) to authenticate with the GitHub MCP server - # name: github-mcp-pat-conn - # category: RemoteTool - # authType: CustomKeys - # target: https://api.githubcopilot.com/mcp - # credentials: - # type: CustomKeys - # keys: - # Authorization: "Bearer {{ github_pat }}" - # - kind: connection - # # A connection that uses OAuth2 to authenticate with the GitHub MCP server - # name: github-mcp-oauth-conn - # category: RemoteTool - # authType: OAuth2 - # target: https://api.githubcopilot.com/mcp - # connectorName: foundrygithubmcp - # credentials: - # type: OAuth2 - # clientId: managed - # clientSecret: managed + - kind: connection + # A connection that uses a GitHub Personal Access Token (PAT) to authenticate with the GitHub MCP server + name: github-mcp-pat-conn + category: RemoteTool + authType: CustomKeys + target: https://api.githubcopilot.com/mcp + credentials: + type: CustomKeys + keys: + Authorization: "Bearer {{ github_pat }}" + - kind: connection + # A connection that uses OAuth2 to authenticate with the GitHub MCP server + name: github-mcp-oauth-conn + category: RemoteTool + authType: OAuth2 + target: https://api.githubcopilot.com/mcp + connectorName: foundrygithubmcp + credentials: + type: OAuth2 + clientId: managed + clientSecret: managed # - kind: connection # name: language-mcp-conn # category: RemoteTool # authType: AgenticIdentity # audience: "{{ language_mcp_entra_audience }}" # target: "{{ language_mcp_target_url }}" - # # - kind: connection - # # name: outlook-mail-conn - # # category: RemoteTool - # # authType: UserEntraToken - # # audience: "{{ outlook_mail_entra_audience }}" - # # target: "{{ outlook_mail_entra_mcp_target }}" - # - kind: toolbox - # name: agent-tools - # tools: - # - type: web_search - # name: web_search - # - type: code_interpreter - # name: code_interpreter - # # - type: mcp - # # # This MCP tool doesn't require authentication - # # server_label: noauth_mcp - # # server_url: "{{ mcp_endpoint }}" - # # require_approval: "never" - # - type: mcp - # # This MCP tool uses the GitHub MCP server with a PAT for authentication or OAuth2 - # server_label: github - # project_connection_id: github-mcp-pat-conn # use `github-mcp-oauth-conn` for OAuth2 authentication - # require_approval: "never" - # - type: mcp - # # This MCP tool uses the Azure Language MCP server with agent identity for authentication - # server_label: language-mcp - # project_connection_id: language-mcp-conn - # require_approval: "never" - # # - type: mcp - # # server_label: outlook-mail - # # project_connection_id: outlook-mail-conn - # # require_approval: "never" + # - kind: connection + # name: outlook-mail-conn + # category: RemoteTool + # authType: UserEntraToken + # audience: "{{ outlook_mail_entra_audience }}" + # target: "{{ outlook_mail_entra_mcp_target }}" + - kind: toolbox + name: agent-tools + tools: + - type: web_search + name: web_search + - type: code_interpreter + name: code_interpreter + - type: mcp + # This MCP tool doesn't require authentication + server_label: noauth_mcp + server_url: "{{ mcp_endpoint }}" + require_approval: "never" + - type: mcp + # This MCP tool uses the GitHub MCP server with a PAT for authentication or OAuth2 + server_label: github + project_connection_id: github-mcp-pat-conn # use `github-mcp-oauth-conn` for OAuth2 authentication + require_approval: "never" + # - type: mcp + # # This MCP tool uses the Azure Language MCP server with agent identity for authentication + # server_label: language-mcp + # project_connection_id: language-mcp-conn + # require_approval: "never" + # - type: mcp + # server_label: outlook-mail + # project_connection_id: outlook-mail-conn + # require_approval: "never" diff --git a/python/samples/04-hosting/foundry-hosted-agents/responses/04_foundry_toolbox/requirements.txt b/python/samples/04-hosting/foundry-hosted-agents/responses/04_foundry_toolbox/requirements.txt index eaa894b7c4..4ececa7368 100644 --- a/python/samples/04-hosting/foundry-hosted-agents/responses/04_foundry_toolbox/requirements.txt +++ b/python/samples/04-hosting/foundry-hosted-agents/responses/04_foundry_toolbox/requirements.txt @@ -1,4 +1,3 @@ -# agent-framework -# agent-framework-foundry-hosting - +agent-framework +agent-framework-foundry-hosting mcp>=1.24.0,<2