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 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 @@ async def _handle_inner_agent( 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._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 @@ async def test_malicious_context_id_rejected_e2e(self, tmp_path: Any, context_fi # 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 @@ def test_returns_none_for_bare_mcp_error_without_wrapping(self) -> None: 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/README.md b/python/samples/04-hosting/foundry-hosted-agents/README.md index ebb0741892..d5144a7300 100644 --- a/python/samples/04-hosting/foundry-hosted-agents/README.md +++ b/python/samples/04-hosting/foundry-hosted-agents/README.md @@ -19,7 +19,8 @@ This directory contains samples that demonstrate how to use hosted [Agent Framew | 9 | [Foundry Skills](responses/09_foundry_skills/) | An agent that uploads `SKILL.md` files to the Foundry Skills REST API and downloads them at startup, decoupling tone/policy guidelines from agent code. | | 10 | [Foundry Memory](responses/10_foundry_memory/) | An agent with persistent semantic memory backed by an Azure AI Foundry Memory Store, using `FoundryMemoryProvider` to remember user facts across sessions. | | 11 | [Monty CodeAct](responses/11_monty_codeact/) | An agent with a Monty-backed CodeAct context provider, exposing a single `execute_code` tool that runs Python in a [pydantic-monty](https://github.com/pydantic/monty) interpreter and invokes typed host tools (`compute`, `fetch_data`) from inside the sandbox. Uses the alpha `agent-framework-monty` package. | -| 12 | [Using deployed agent](responses/using_deployed_agent.py) | A sample demonstrating how to invoke an agent that has already been deployed to Foundry, showing how to interact with a hosted agent in code. | +| 12 | [Foundry Toolbox MCP Skills](responses/12_foundry_toolbox_mcp_skills/) | An agent that discovers MCP-based skills from a Foundry Toolbox and injects them via `SkillsProvider(MCPSkillsSource(...))`, implementing the Agent Skills progressive-disclosure pattern. | +| 13 | [Using deployed agent](responses/using_deployed_agent.py) | A sample demonstrating how to invoke an agent that has already been deployed to Foundry, showing how to interact with a hosted agent in code. | ### Invocations API 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 diff --git a/python/samples/04-hosting/foundry-hosted-agents/responses/12_foundry_toolbox_mcp_skills/.dockerignore b/python/samples/04-hosting/foundry-hosted-agents/responses/12_foundry_toolbox_mcp_skills/.dockerignore new file mode 100644 index 0000000000..31ed562a7e --- /dev/null +++ b/python/samples/04-hosting/foundry-hosted-agents/responses/12_foundry_toolbox_mcp_skills/.dockerignore @@ -0,0 +1,7 @@ +.venv +__pycache__ +*.pyc +*.pyo +*.pyd +.Python +.env diff --git a/python/samples/04-hosting/foundry-hosted-agents/responses/12_foundry_toolbox_mcp_skills/.env.example b/python/samples/04-hosting/foundry-hosted-agents/responses/12_foundry_toolbox_mcp_skills/.env.example new file mode 100644 index 0000000000..6b690512b5 --- /dev/null +++ b/python/samples/04-hosting/foundry-hosted-agents/responses/12_foundry_toolbox_mcp_skills/.env.example @@ -0,0 +1,3 @@ +FOUNDRY_PROJECT_ENDPOINT="..." +AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-5" +FOUNDRY_TOOLBOX_NAME="..." diff --git a/python/samples/04-hosting/foundry-hosted-agents/responses/12_foundry_toolbox_mcp_skills/Dockerfile b/python/samples/04-hosting/foundry-hosted-agents/responses/12_foundry_toolbox_mcp_skills/Dockerfile new file mode 100644 index 0000000000..12c4791bc9 --- /dev/null +++ b/python/samples/04-hosting/foundry-hosted-agents/responses/12_foundry_toolbox_mcp_skills/Dockerfile @@ -0,0 +1,18 @@ +FROM python:3.12-slim + +RUN apt-get update && apt-get install -y git && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +COPY . user_agent/ +WORKDIR /app/user_agent + +RUN if [ -f requirements.txt ]; then \ + pip install -r requirements.txt; \ + else \ + echo "No requirements.txt found"; \ + fi + +EXPOSE 8088 + +CMD ["python", "main.py"] diff --git a/python/samples/04-hosting/foundry-hosted-agents/responses/12_foundry_toolbox_mcp_skills/README.md b/python/samples/04-hosting/foundry-hosted-agents/responses/12_foundry_toolbox_mcp_skills/README.md new file mode 100644 index 0000000000..a15e826223 --- /dev/null +++ b/python/samples/04-hosting/foundry-hosted-agents/responses/12_foundry_toolbox_mcp_skills/README.md @@ -0,0 +1,72 @@ +# What this sample demonstrates + +An [Agent Framework](https://github.com/microsoft/agent-framework) agent that discovers **MCP-based skills from a Foundry Toolbox** and makes them available via `SkillsProvider(MCPSkillsSource(...))`, hosted using the **Responses protocol**. + +The `SkillsProvider` is attached to the agent as a context provider and implements the [Agent Skills](https://agentskills.io/) progressive-disclosure pattern. When the agent is prompted, it discovers available skills in the Foundry Toolbox via the provider: + +1. **Advertise** — skill names and descriptions are injected into the system prompt so the agent knows what is available. +2. **Load** — when the agent decides a skill is relevant, it retrieves the full skill body with detailed instructions via the provider. +3. **Read resources** — if a skill includes supplementary content (reference documents, assets), the agent reads them on demand via the provider. + +This way the full skill body and resources are only loaded when the agent actually needs them, reducing token usage. + +## How It Works + +### Model Integration + +The agent uses `FoundryChatClient` from the Agent Framework to create an OpenAI-compatible Responses client. It connects to the toolbox's MCP endpoint via the `mcp` library's `streamable_http_client`, discovers skills served by the toolbox through `MCPSkillsSource`, and injects them as a context provider via `SkillsProvider`. The toolbox endpoint URL is derived from `FOUNDRY_PROJECT_ENDPOINT` and `FOUNDRY_TOOLBOX_NAME`. + +See [main.py](main.py) for the full implementation. + +### Agent Hosting + +The agent is hosted using the [Agent Framework](https://github.com/microsoft/agent-framework) with the `ResponsesHostServer`, which provisions a REST API endpoint compatible with the OpenAI Responses protocol. + +## Prerequisites + +- Python 3.12+ +- An Azure AI Foundry project with a deployed model (e.g., `gpt-5`) +- A Foundry Toolbox with skills attached (see below) +- Azure CLI logged in (`az login`) + +## Setting up a Foundry Toolbox with skills + +This sample requires a Foundry Toolbox that has skills attached to it. Skills are `SKILL.md` files you author once, store centrally in Foundry through the versioned Skills API, and attach to a toolbox so any MCP client can discover and load them. + +1. **Author a skill** — Create a `SKILL.md` file following the [Agent Skills](https://agentskills.io/) specification format (YAML front matter with `name` and `description`, plus Markdown body). +2. **Create the skill in Foundry** — Upload the skill via the Skills REST API, Python SDK, or `azd ai skill create`. See [Use skills with Microsoft Foundry agents](https://learn.microsoft.com/en-us/azure/foundry/agents/how-to/tools/skills). +3. **Attach the skill to a toolbox** — Add a skill reference to a toolbox version so MCP clients can discover it. See [Attach skills to a toolbox](https://learn.microsoft.com/en-us/azure/foundry/agents/how-to/tools/toolbox#attach-skills-to-a-toolbox). + +When the agent connects to the toolbox MCP endpoint, skills are advertised through a well-known `skill://index.json` discovery resource. The `MCPSkillsSource` in this sample reads `skill://index.json` at startup to discover all attached skills, then fetches each `SKILL.md` body on demand via `resources/read`. + +## Running the Agent Host + +Follow the instructions in the [Running the Agent Host Locally](../../README.md#running-the-agent-host-locally) section of the README in the parent directory to run the agent host. + +An extra environment variable must be set to point to the toolbox name: + +```bash +export FOUNDRY_TOOLBOX_NAME="my-toolbox" +``` + +Or in PowerShell: + +```powershell +$env:FOUNDRY_TOOLBOX_NAME="my-toolbox" +``` + +You can also place these in a `.env` file next to `main.py` — see [`.env.example`](.env.example). + +## Interacting with the agent + +> Depending on how you run the agent host, you can invoke the agent using `curl` (`Invoke-WebRequest` in PowerShell) or `azd`. Please refer to the [parent README](../../README.md) for more details. Use this README for sample queries you can send to the agent. + +Send a POST request to the server with a JSON body containing an `"input"` field to interact with the agent. For example: + +```bash +curl -X POST http://localhost:8088/responses -H "Content-Type: application/json" -d '{"input": "What skills do you have available?"}' +``` + +## Deploying the Agent to Foundry + +To host the agent on Foundry, follow the instructions in the [Deploying the Agent to Foundry](../../README.md#deploying-the-agent-to-foundry) section of the README in the parent directory. diff --git a/python/samples/04-hosting/foundry-hosted-agents/responses/12_foundry_toolbox_mcp_skills/agent.manifest.yaml b/python/samples/04-hosting/foundry-hosted-agents/responses/12_foundry_toolbox_mcp_skills/agent.manifest.yaml new file mode 100644 index 0000000000..f57b5ce5b7 --- /dev/null +++ b/python/samples/04-hosting/foundry-hosted-agents/responses/12_foundry_toolbox_mcp_skills/agent.manifest.yaml @@ -0,0 +1,42 @@ +name: hosted-toolbox-mcp-skills +displayName: "Hosted Toolbox MCP Skills Agent" + +description: > + A hosted agent that discovers MCP-based skills from a Foundry Toolbox + and makes them available to the agent via the agent skills provider. + +metadata: + tags: + - AI Agent Hosting + - Azure AI AgentServer + - Responses Protocol + - Agent Framework + - MCP + - Model Context Protocol + - Agent Skills + - Foundry Toolbox + - Foundry Toolbox Skills + +template: + name: hosted-toolbox-mcp-skills + kind: hosted + protocols: + - protocol: responses + version: 1.0.0 + resources: + cpu: "0.25" + memory: 0.5Gi + environment_variables: + - name: AZURE_AI_MODEL_DEPLOYMENT_NAME + value: "{{AZURE_AI_MODEL_DEPLOYMENT_NAME}}" + - name: FOUNDRY_TOOLBOX_NAME + value: "{{FOUNDRY_TOOLBOX_NAME}}" +parameters: + properties: + - name: FOUNDRY_TOOLBOX_NAME + secret: false + description: Name of the Foundry Toolbox to connect to for MCP skill discovery +resources: + - kind: model + id: gpt-5 + name: AZURE_AI_MODEL_DEPLOYMENT_NAME diff --git a/python/samples/04-hosting/foundry-hosted-agents/responses/12_foundry_toolbox_mcp_skills/agent.yaml b/python/samples/04-hosting/foundry-hosted-agents/responses/12_foundry_toolbox_mcp_skills/agent.yaml new file mode 100644 index 0000000000..5f53abb2e2 --- /dev/null +++ b/python/samples/04-hosting/foundry-hosted-agents/responses/12_foundry_toolbox_mcp_skills/agent.yaml @@ -0,0 +1,14 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml +kind: hosted +name: hosted-toolbox-mcp-skills +protocols: + - protocol: responses + version: 1.0.0 +resources: + cpu: "0.25" + memory: 0.5Gi +environment_variables: + - name: AZURE_AI_MODEL_DEPLOYMENT_NAME + value: ${AZURE_AI_MODEL_DEPLOYMENT_NAME} + - name: FOUNDRY_TOOLBOX_NAME + value: ${FOUNDRY_TOOLBOX_NAME} diff --git a/python/samples/04-hosting/foundry-hosted-agents/responses/12_foundry_toolbox_mcp_skills/main.py b/python/samples/04-hosting/foundry-hosted-agents/responses/12_foundry_toolbox_mcp_skills/main.py new file mode 100644 index 0000000000..d5ea3e8f2f --- /dev/null +++ b/python/samples/04-hosting/foundry-hosted-agents/responses/12_foundry_toolbox_mcp_skills/main.py @@ -0,0 +1,94 @@ +# Copyright (c) Microsoft. All rights reserved. + +import asyncio +import os +from collections.abc import Callable, Generator + +import httpx +from agent_framework import Agent, MCPSkillsSource, SkillsProvider +from agent_framework.foundry import FoundryChatClient +from agent_framework_foundry_hosting import ResponsesHostServer +from azure.identity import DefaultAzureCredential, get_bearer_token_provider +from dotenv import load_dotenv +from mcp.client.session import ClientSession +from mcp.client.streamable_http import streamable_http_client + +# Load environment variables from .env file +load_dotenv() + + +class _BearerAuth(httpx.Auth): + """Attach a fresh Foundry bearer token to every request.""" + + def __init__(self, token_provider: Callable[[], str]): + self._get_token = token_provider + + def auth_flow(self, request: httpx.Request) -> Generator[httpx.Request, httpx.Response, None]: + request.headers["Authorization"] = f"Bearer {self._get_token()}" + yield request + + +async def main() -> None: + project_endpoint = os.environ["FOUNDRY_PROJECT_ENDPOINT"] + deployment = os.environ.get("AZURE_AI_MODEL_DEPLOYMENT_NAME", "gpt-5") + toolbox_name = os.environ["FOUNDRY_TOOLBOX_NAME"] + + # Build the Toolbox MCP URL from the project endpoint and toolbox name. + toolbox_mcp_url = f"{project_endpoint.rstrip('/')}/toolboxes/{toolbox_name}/mcp?api-version=v1" + + credential = DefaultAzureCredential() + + # Create a token provider for Foundry bearer auth + token_provider = get_bearer_token_provider(credential, "https://ai.azure.com/.default") + + # ── Connect to the Foundry Toolbox MCP endpoint ────────────────────────── + # Create an HTTP client that attaches a fresh Foundry bearer token to every + # request and advertises the toolbox preview feature flag. + async with ( + httpx.AsyncClient( + auth=_BearerAuth(token_provider), + headers={"Foundry-Features": "Toolboxes=V1Preview"}, + timeout=httpx.Timeout(30.0, read=300.0), + follow_redirects=True, + ) as http_client, + streamable_http_client( + url=toolbox_mcp_url, + http_client=http_client, + ) as (read, write, _), + ClientSession(read, write) as session, + ): + await session.initialize() + + print(f"Connected to Foundry Toolbox '{toolbox_name}' MCP server.") + + # ── Configure MCP-based skills provider ────────────────────────────── + # MCPSkillsSource reads skill://index.json and creates one MCPSkill per + # skill-md entry; SKILL.md bodies are fetched on demand via + # resources/read. + skills_provider = SkillsProvider(MCPSkillsSource(client=session)) + + # ── Create the agent ───────────────────────────────────────────────── + client = FoundryChatClient( + project_endpoint=project_endpoint, + model=deployment, + credential=credential, + ) + + agent = Agent( + client=client, + name=os.environ.get("AGENT_NAME", "hosted-toolbox-mcp-skills"), + instructions="You are a helpful assistant.", + context_providers=[skills_provider], + # History will be managed by the hosting infrastructure, thus there + # is no need to store history by the service. Learn more at: + # https://developers.openai.com/api/reference/resources/responses/methods/create + default_options={"store": False}, + ) + + # ── Build and run the host ─────────────────────────────────────────── + server = ResponsesHostServer(agent) + await server.run_async() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/samples/04-hosting/foundry-hosted-agents/responses/12_foundry_toolbox_mcp_skills/requirements.txt b/python/samples/04-hosting/foundry-hosted-agents/responses/12_foundry_toolbox_mcp_skills/requirements.txt new file mode 100644 index 0000000000..96c42b5355 --- /dev/null +++ b/python/samples/04-hosting/foundry-hosted-agents/responses/12_foundry_toolbox_mcp_skills/requirements.txt @@ -0,0 +1,4 @@ +agent-framework +agent-framework-foundry-hosting + +mcp>=1.24.0,<2