diff --git a/python/samples/04-hosting/foundry-hosted-agents/responses/04_foundry_toolbox/.env.example b/python/samples/04-hosting/foundry-hosted-agents/responses/04_foundry_toolbox/.env.example index 81eb25faff4..aefa98fe6fd 100644 --- a/python/samples/04-hosting/foundry-hosted-agents/responses/04_foundry_toolbox/.env.example +++ b/python/samples/04-hosting/foundry-hosted-agents/responses/04_foundry_toolbox/.env.example @@ -1,3 +1,3 @@ FOUNDRY_PROJECT_ENDPOINT="..." AZURE_AI_MODEL_DEPLOYMENT_NAME="..." -FOUNDRY_TOOLBOX_ENDPOINT="..." \ No newline at end of file +TOOLBOX_ENDPOINT="..." \ No newline at end of file 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 7b7051dda40..c5d3fb22517 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 @@ -1,6 +1,6 @@ -# What this sample demonstrates +# Agent with Foundry Toolbox (Responses Protocol) -An [Agent Framework](https://github.com/microsoft/agent-framework) agent that uses **Foundry Toolbox** for tool discovery and hosted using the **Responses protocol**. Foundry Toolbox is a managed tool registry in Microsoft Foundry that lets you define tools centrally and share them across agents. +An [Agent Framework](https://github.com/microsoft/agent-framework) agent that uses **Foundry Toolbox** for tool discovery, hosted on Microsoft Foundry using the **Responses protocol**. Foundry Toolbox is a managed tool registry in Microsoft Foundry that lets you define tools centrally and share them across agents. ## Creating a Foundry Toolbox @@ -8,71 +8,268 @@ You can create a Foundry Toolbox by code. Refer to this sample for an example: [ You can also create a Foundry Toolbox in the Foundry portal. Read more about it [in the Foundry toolbox documentation](https://learn.microsoft.com/en-us/azure/foundry/agents/how-to/tools/toolbox). -> If you set up a project with this sample and provision the resources using `azd provision`, a Foundry Toolbox will be created with the specified tools in [`agent.manifest.yaml`](agent.manifest.yaml). +This sample consumes a toolbox over its MCP endpoint. It bundles a [`toolbox.yaml`](toolbox.yaml) that defines 6 tools behind one endpoint: + +- **Web search**, which grounds responses in real-time public web results. +- **Code interpreter**, which executes Python code in a secure sandbox and returns the output. +- **Azure Specs MCP**, which demonstrates connecting to an MCP server that doesn't require authentication. +- **GitHub MCP**, which demonstrates connecting to the GitHub MCP server using either a Personal Access Token (PAT) or OAuth2 (switch by changing the `project_connection_id` in `toolbox.yaml`). +- **Azure Language MCP with agent identity**, which demonstrates connecting to the Azure Language MCP server using agent identity for authentication. +- **Microsoft Foundry MCP with Entra pass-through**, which demonstrates connecting to the Microsoft Foundry MCP server using Entra pass-through for authentication. ### Authentication Methods You can connect to MCP servers in Foundry Toolbox that use different authentication methods. This sample demonstrates the following authentication methods: -- **No authentication**: The tool does not require any authentication. The agent can invoke the tool without providing any credentials. Sample MCP server: `https://gitmcp.io/Azure/azure-rest-api-specs` -- **Key-based authentication**: The tool requires a key to authenticate. Sample MCP server: `https://api.githubcopilot.com/mcp` (GitHub MCP server) with a Personal Access Token (PAT) for authentication. -- **OAuth2 authentication (managed)**: The tool requires OAuth2 to authenticate. Sample MCP server: `https://api.githubcopilot.com/mcp` (GitHub MCP server) with OAuth2 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. - -> 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. +- [**No authentication**](https://github.com/microsoft-foundry/foundry-samples/blob/main/samples/python/hosted-agents/SUPPORTED_TOOLBOX_SCENARIOS.md#5-mcp-no-auth): The tool does not require any authentication. The agent can invoke the tool without providing any credentials. Sample MCP server: `https://gitmcp.io/Azure/azure-rest-api-specs` +- [**Key-based authentication**](https://github.com/microsoft-foundry/foundry-samples/blob/main/samples/python/hosted-agents/SUPPORTED_TOOLBOX_SCENARIOS.md#4-mcp-key-auth-github): The tool requires a key to authenticate. Sample MCP server: `https://api.githubcopilot.com/mcp` (GitHub MCP server) with a Personal Access Token (PAT) for authentication. +- [**OAuth2 authentication (managed)**](https://github.com/microsoft-foundry/foundry-samples/blob/main/samples/python/hosted-agents/SUPPORTED_TOOLBOX_SCENARIOS.md#6-mcp-oauth-managed-connector): The tool requires OAuth2 to authenticate. Sample MCP server: `https://api.githubcopilot.com/mcp` (GitHub MCP server) with OAuth2 for authentication. +- [**Agent identity authentication**](https://github.com/microsoft-foundry/foundry-samples/blob/main/samples/python/hosted-agents/SUPPORTED_TOOLBOX_SCENARIOS.md#8-mcp-agent-identity): 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](https://learn.microsoft.com/en-us/azure/ai-services/language-service/concepts/foundry-tools-agents#azure-language-mcp-server-preview)) with agent identity for authentication. +- [**Entra Pass-through authentication**](https://github.com/microsoft-foundry/foundry-samples/blob/main/samples/python/hosted-agents/SUPPORTED_TOOLBOX_SCENARIOS.md#13-mcp-oauth-entra-passthrough): The tool requires an Entra pass-through token to authenticate; Foundry forwards the calling user's Entra token to the MCP server. Sample MCP server: the [Microsoft Foundry MCP server](https://learn.microsoft.com/en-us/azure/foundry/mcp/get-started?view=foundry&tabs=user), which exposes Foundry model-catalog, evaluation, agent, and session tools and requires only that the caller have access to the Foundry project (no extra license). 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). -## How It Works +### Finding the Entra audience for an MCP server + +An Entra pass-through connection requires an **audience** — the Entra resource that the MCP server validates tokens against. For the Microsoft Foundry MCP server (`https://mcp.ai.azure.com`), read it from the server's OAuth protected-resource metadata: + +```bash +curl https://mcp.ai.azure.com/.well-known/oauth-protected-resource +``` + +```jsonc +{ + "resource": "https://mcp.ai.azure.com", + "authorization_servers": ["https://login.microsoftonline.com/common/v2.0"], + "scopes_supported": ["https://mcp.ai.azure.com/Foundry.Mcp.Tools"] +} +``` + +Use the `resource` value (`https://mcp.ai.azure.com`) as the audience. + +> For connector-backed MCP servers (for example Microsoft 365 / WorkIQ servers such as Outlook Mail), the audience is instead published in the Foundry Tools Catalog. Look it up with the helper scripts in [`scripts/`](scripts/): run `./scripts/list-foundry-connectors.ps1 -ConnectorName ` (or `./scripts/list-foundry-connectors.sh -n `) and read `AzureActiveDirectoryResourceId` (equivalently `resourceUri`) under `properties.x-ms-connection-parameters`. Run the script with no connector name to list every connector with its name, title, and auth type. + +### Creating Connections + +Before creating the toolbox, create project connections for any tools that require authentication. The connection defines the authentication details and credentials for the tool, and the toolbox references the connection to authenticate tool invocations at runtime. The following connections are needed for this sample (used in `toolbox.yaml`): + +For `ghmcppat`, run the following command to create a PAT-based connection to the GitHub MCP server: + +```powershell +azd ai connection create ghmcppat --kind remote-tool --target https://api.githubcopilot.com/mcp --auth-type custom-keys --custom-key "Authorization=Bearer " -p https://.services.ai.azure.com/api/projects/ +``` + +For `ghmcpoauth`, create an OAuth2-based connection to the GitHub MCP server: + +```powershell +azd ai connection create ghmcpoauth --kind remote-tool --target https://api.githubcopilot.com/mcp --auth-type oauth2 --connector-name foundrygithubmcp -p https://.services.ai.azure.com/api/projects/ +``` + +> This sample uses `ghmcppat` by default, but you can switch to `ghmcpoauth` in the `toolbox.yaml` file. + +For `langmcpconn`, create an agent-identity-based connection to the Azure Language MCP server: + +```powershell +azd ai connection create langmcpconn --kind remote-tool --target https://.cognitiveservices.azure.com/language/mcp?api-version=2025-11-15-preview --auth-type project-managed-identity --audience https://cognitiveservices.azure.com/ -p https://.services.ai.azure.com/api/projects/ +``` + +For `foundrymcpconn`, create an Entra pass-through connection to the Microsoft Foundry MCP server: + +```powershell +azd ai connection create foundrymcpconn --kind remote-tool --target https://mcp.ai.azure.com --auth-type user-entra-token --audience https://mcp.ai.azure.com -p https://.services.ai.azure.com/api/projects/ +``` + +### Creating the toolbox + +You create the toolbox once from `toolbox.yaml`, then copy the versioned MCP endpoint it prints into the `TOOLBOX_ENDPOINT` environment variable. The agent connects to that endpoint at runtime. + +```powershell +azd ai toolbox create agent-tools --from-file ./toolbox.yaml --project-endpoint https://.services.ai.azure.com/api/projects/ +``` + +## 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 `MCPStreamableHTTPTool`, which discovers and invokes the toolbox's tools over MCP at runtime. The endpoint URL is provided through the `FOUNDRY_TOOLBOX_ENDPOINT` environment variable. +The agent uses `FoundryChatClient` from the Agent Framework to create an OpenAI-compatible Responses client. It connects to the toolbox's MCP endpoint via `MCPStreamableHTTPTool`, which discovers and invokes the toolbox's tools over MCP at runtime. The agent resolves the endpoint from the `TOOLBOX_ENDPOINT` environment variable. If that variable isn't set, it builds the unversioned (default-version) endpoint from `FOUNDRY_PROJECT_ENDPOINT` and `TOOLBOX_NAME`. See [main.py](main.py) for the full implementation. -### Agent Hosting +## Running the agent -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. +### Option 1: Azure Developer CLI (`azd`) -## Running the Agent Host +#### Prerequisites -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. +1. **Azure Developer CLI (`azd`)** — [Install azd](https://learn.microsoft.com/en-us/azure/developer/azure-developer-cli/install-azd) (1.25 or later) +2. Install the unified Foundry CLI extension bundle (provides `azd ai agent`, `connection`, `inspector`, `project`, `routine`, `skill`, and `toolbox`): + ```bash + # If you previously installed individual extensions, uninstall them first: + # azd ext uninstall azure.ai.agents + # azd ext uninstall azure.ai.toolboxes + azd ext install microsoft.foundry + ``` +3. Authenticate: + ```bash + azd auth login + ``` -An extra environment variable must be set to point to the toolbox MCP endpoint. You can provide it in one of two ways: +#### Initialize the agent project -**Option A – Set `FOUNDRY_TOOLBOX_ENDPOINT` directly** (recommended for local development): +No cloning required. Create a new folder and initialize from the manifest: ```bash -export FOUNDRY_TOOLBOX_ENDPOINT="https://.services.ai.azure.com/api/projects//toolboxes//mcp?api-version=v1" +mkdir my-toolbox-agent && cd my-toolbox-agent + +azd ai agent init -m https://github.com/microsoft-foundry/foundry-samples/blob/main/samples/python/hosted-agents/agent-framework/responses/04-foundry-toolbox/agent.manifest.yaml ``` -Or in PowerShell: +Follow the prompts to configure your Foundry project and model deployment. If you don't have an existing Foundry project, `azd ai agent init` will guide you through creating one. Initializing also sets the selected project as the active project for the `azd ai` commands that follow. -```powershell -$env:FOUNDRY_TOOLBOX_ENDPOINT="https://.services.ai.azure.com/api/projects//toolboxes//mcp?api-version=v1" +#### Create the toolbox with `azd ai` + +> [!TIP] +> If you use GitHub Copilot for Azure to scaffold a hosted agent that consumes this toolbox, the following skill references describe the same endpoint contract (env var, headers, MCP protocol, citation patterns, and troubleshooting) that the agent must implement: +> +> - [Toolbox reference](https://github.com/microsoft/GitHub-Copilot-for-Azure/blob/main/plugin/skills/microsoft-foundry/foundry-agent/create/references/toolbox-reference.md) — endpoint format, MCP protocol, OAuth consent handling, citation patterns, and troubleshooting. +> - [Use toolbox in a hosted agent](https://github.com/microsoft/GitHub-Copilot-for-Azure/blob/main/plugin/skills/microsoft-foundry/foundry-agent/create/references/use-toolbox-in-hosted-agent.md) — endpoint resolution, env-var contract, payload shape, code integration patterns, and tracing. + +The agent reads the toolbox's MCP endpoint from `TOOLBOX_ENDPOINT`. Create the toolbox once from the bundled [`toolbox.yaml`](toolbox.yaml): + +```bash +azd ai toolbox create agent-tools --from-file ./toolbox.yaml --project-endpoint https://.services.ai.azure.com/api/projects/ +``` + +The first version becomes the default automatically. Use `azd ai toolbox list`, `azd ai toolbox show agent-tools`, and `azd ai toolbox version list agent-tools` to inspect, and `azd ai toolbox delete agent-tools --force` to remove it. + +To stage incremental changes safely, use `azd ai toolbox connection add/remove` and `azd ai toolbox skill add/list/remove`; each creates a new toolbox version that carries forward existing connections and skills but **doesn't** change the default. Promote a version with `azd ai toolbox publish agent-tools ` when you're ready to make it active. + +`azd ai toolbox create` prints the toolbox's versioned MCP endpoint. Copy that endpoint and store it in your `azd` environment so the agent connects to it: + +```bash +azd env set TOOLBOX_ENDPOINT "https://.services.ai.azure.com/api/projects//toolboxes/agent-tools/versions/1/mcp?api-version=v1" ``` -**Option B – Set `TOOLBOX_NAME`** (used automatically by the Foundry hosting scaffolding after `azd provision`): +#### Provision Azure resources (if needed) + +If you don't already have a Foundry project and model deployment: -The agent derives the endpoint at runtime as: +```bash +azd provision ``` -{FOUNDRY_PROJECT_ENDPOINT}/toolboxes/{TOOLBOX_NAME}/mcp?api-version=v1 + +#### Run the agent locally + +```bash +azd ai agent run ``` -When deployed via `azd provision`, the scaffolding injects `TOOLBOX_NAME=agent-tools` and `FOUNDRY_PROJECT_ENDPOINT` automatically from the provisioned resources declared in [`agent.manifest.yaml`](agent.manifest.yaml). +The agent host will start on `http://localhost:8088`. + +#### Invoke the local agent + +In a separate terminal, from the project directory: -## Interacting with the agent +```bash +azd ai agent invoke --local "What tools do you have?" +``` -> 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. +#### Deploy to Foundry -Send a POST request to the server with a JSON body containing an `"input"` field to interact with the agent. For example: +Once tested locally, deploy to Microsoft Foundry: ```bash -curl -X POST http://localhost:8088/responses -H "Content-Type: application/json" -d '{"input": "What tools do you have?"}' +azd deploy ``` -## Deploying the Agent to Foundry +For the full deployment guide, see [Deploy a hosted agent](https://learn.microsoft.com/en-us/azure/foundry/agents/how-to/deploy-hosted-agent). + +#### Invoke the deployed agent + +```bash +azd ai agent invoke "What tools do you have?" +``` + +### Option 2: VS Code (Foundry Toolkit) + +#### Prerequisites + +1. **VS Code** with the **[Foundry Toolkit](https://learn.microsoft.com/en-us/azure/foundry/how-to/develop/get-started-projects-vs-code)** extension installed. +2. Sign in to Azure in VS Code. +3. The `agent-tools` toolbox must exist in your Foundry project. Create it from the bundled [`toolbox.yaml`](toolbox.yaml) (`azd ai toolbox create agent-tools --from-file ./toolbox.yaml`) or in the Foundry portal before you run the agent. + +#### Create the project + +1. Open the Command Palette (`Ctrl+Shift+P`) and run **Foundry Toolkit: Create Hosted Agent**. +2. Select this sample from the gallery. The extension scaffolds the project into a new workspace and generates `agent.yaml`, `.env`, and `.vscode/tasks.json` + `launch.json` automatically. +3. Complete the **Foundry Project Setup** to pick the subscription and Foundry project (or create a new one). + +#### Run and debug the agent + +Press **F5** to start the agent in debug mode. The agent host will start on `http://localhost:8088`. + +#### Test with Agent Inspector + +1. Open the Command Palette (`Ctrl+Shift+P`) and run **Foundry Toolkit: Open Agent Inspector**. +2. The Inspector connects to the running agent. Send messages to chat and view streamed responses. + +#### Deploy to Foundry + +1. Open the Command Palette (`Ctrl+Shift+P`) and run **Foundry Toolkit: Deploy Hosted Agent**. The extension opens a **Deploy Hosted Agent** wizard and reads `agent.yaml` to auto-populate settings. +2. If prompted, complete **Foundry Project Setup** to select subscription and project. +3. On the **Basics** tab, choose deployment method (**Code** or **Container**) and confirm the agent name. +4. On **Review + Deploy**, confirm runtime details, pick **CPU and Memory** size, and click **Deploy**. +5. After deployment, invoke the agent in the Agent Playground and stream live logs from the **Logs** tab. + +### Creating a Foundry Toolbox + +You can create a Foundry Toolbox by code. Refer to this sample for an example: [Foundry Toolbox CRUD Sample](https://github.com/Azure/azure-sdk-for-python/blob/main/sdk/ai/azure-ai-projects/samples/hosted_agents/sample_toolboxes_crud.py). + +You can also create a Foundry Toolbox in the Foundry portal. Read more about it [in the Foundry toolbox documentation](https://learn.microsoft.com/en-us/azure/foundry/agents/how-to/tools/toolbox). + +## Troubleshooting + +### A single failing MCP source can fail the whole agent + +A toolbox aggregates every tool source behind one MCP endpoint. If **any** referenced MCP server fails while the toolbox enumerates tools (`tools/list`), the toolbox fails the entire enumeration, so the agent can't load its tools and every request returns an error (HTTP 500) until that source recovers. + +For example, a flaky third-party MCP source can intermittently return `HTTP 502 (Bad Gateway)` during enumeration, which surfaces as: + +``` +tools/list failed for 1 tool source(s), succeeded for 5 tool source(s) +{"errors":[{"name":"","type":"mcp","error":{"code":"HTTP_502", ...}}]} +``` + +This is an upstream/service hiccup, not a problem with the agent code. Mitigations: + +- Retry the request — these failures are usually transient. +- If a source is persistently unavailable, temporarily remove its tool entry (and connection) from `toolbox.yaml`, recreate the toolbox, and update `TOOLBOX_ENDPOINT`. +- Inspect deployed agent logs with `azd ai agent monitor` to identify which source failed. + +### Entra pass-through forwards the caller's identity + +The Foundry MCP tool authenticates with **Entra pass-through** (`foundrymcpconn`): Foundry forwards the +calling user's Entra token to `https://mcp.ai.azure.com`. The token is forwarded both from the Foundry +portal **Agent Playground** (signed-in user) and by `azd ai agent invoke` (the developer's Entra token), +so the tools operate as that user and only act on resources the user can already access. The Foundry MCP +server requires no extra license — just access to the Foundry project. + +Because the tool acts as a specific user, running the agent **locally** (`python main.py`) or calling the +endpoint with a raw token uses whatever identity that token represents (`az login` user locally, the +agent's managed identity when hosted). If that identity has no access to the target resources, the tool +returns an authorization error even though it is discovered and called correctly. + +> Some other Entra pass-through MCP servers add their **own** entitlement checks on top of the token. For +> example, the Microsoft 365 / WorkIQ servers (Outlook Mail, Teams) require the caller to hold a +> **Microsoft 365 Copilot (Business Chat)** license; without it they fail with +> `WorkIQ license check failed. Required service plan(s): [M365_COPILOT_BUSINESS_CHAT]`. That is a +> property of those servers, not of Entra pass-through itself. + +## Next steps -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. +- [Quickstart: Create a hosted agent](https://learn.microsoft.com/en-us/azure/foundry/agents/quickstarts/quickstart-hosted-agent) — end-to-end walkthrough using `azd` +- [Tool catalog](https://learn.microsoft.com/en-us/azure/foundry/agents/concepts/tool-catalog) — browse available tools to extend your agent (Bing Search, Azure AI Search, file search, code interpreter, and more) +- [Manage hosted agents](https://learn.microsoft.com/en-us/azure/foundry/agents/how-to/manage-hosted-agent) — monitor and manage deployed agents +- [Basic agent](../01_basic/) — minimal agent with no tools +- [Add local tools](../02_tools/) — sample with locally-defined Python tool functions +- [Build multi-agent workflows](../05_workflows/) — sample with chained agent pipelines 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 34d4b54c4af..8ea363bdd26 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 @@ -17,93 +17,14 @@ template: environment_variables: - name: AZURE_AI_MODEL_DEPLOYMENT_NAME value: "{{AZURE_AI_MODEL_DEPLOYMENT_NAME}}" - - name: TOOLBOX_NAME - 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 + - name: TOOLBOX_ENDPOINT + # Full MCP endpoint of the toolbox the agent consumes. Create the toolbox + # from the bundled toolbox.yaml, then copy the versioned endpoint it prints + # and store it in your azd environment before you run or deploy: + # azd ai toolbox create my-toolbox --from-file ./toolbox.yaml + # azd env set TOOLBOX_ENDPOINT "" + value: "{{TOOLBOX_ENDPOINT}}" resources: - kind: model - id: gpt-4.1-mini + id: gpt-4.1 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 - # 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" diff --git a/python/samples/04-hosting/foundry-hosted-agents/responses/04_foundry_toolbox/agent.yaml b/python/samples/04-hosting/foundry-hosted-agents/responses/04_foundry_toolbox/agent.yaml index 826ab39192b..5e44793e170 100644 --- a/python/samples/04-hosting/foundry-hosted-agents/responses/04_foundry_toolbox/agent.yaml +++ b/python/samples/04-hosting/foundry-hosted-agents/responses/04_foundry_toolbox/agent.yaml @@ -9,5 +9,5 @@ resources: environment_variables: - name: AZURE_AI_MODEL_DEPLOYMENT_NAME value: ${AZURE_AI_MODEL_DEPLOYMENT_NAME} - - name: TOOLBOX_NAME - value: "agent-tools" \ No newline at end of file + - name: TOOLBOX_ENDPOINT + value: ${TOOLBOX_ENDPOINT} \ No newline at end of file diff --git a/python/samples/04-hosting/foundry-hosted-agents/responses/04_foundry_toolbox/main.py b/python/samples/04-hosting/foundry-hosted-agents/responses/04_foundry_toolbox/main.py index c9f13109bc3..2b6f8ad9c8f 100644 --- a/python/samples/04-hosting/foundry-hosted-agents/responses/04_foundry_toolbox/main.py +++ b/python/samples/04-hosting/foundry-hosted-agents/responses/04_foundry_toolbox/main.py @@ -3,6 +3,7 @@ import asyncio import os from collections.abc import Callable +from urllib.parse import urlsplit import httpx from agent_framework import Agent, MCPStreamableHTTPTool @@ -18,19 +19,42 @@ def resolve_toolbox_endpoint() -> str: """Resolve the toolbox MCP endpoint URL. - Prefers the explicit ``FOUNDRY_TOOLBOX_ENDPOINT`` env var; falls back to - constructing the URL from ``FOUNDRY_PROJECT_ENDPOINT`` and ``TOOLBOX_NAME`` - (the variables injected by the Foundry hosting scaffolding after ``azd provision``). + Prefers the explicit ``TOOLBOX_ENDPOINT`` env var (set in ``agent.yaml`` or + ``agent.manifest.yaml`` and via ``azd env set TOOLBOX_ENDPOINT`` after the toolbox + is created); falls back to constructing the URL from ``FOUNDRY_PROJECT_ENDPOINT`` + and ``TOOLBOX_NAME``. """ - if (endpoint := os.environ.get("FOUNDRY_TOOLBOX_ENDPOINT")) is not None: + if (endpoint := os.environ.get("TOOLBOX_ENDPOINT")) is not None: if not endpoint: - raise ValueError("FOUNDRY_TOOLBOX_ENDPOINT is set but empty") + raise ValueError("TOOLBOX_ENDPOINT is set but empty") return endpoint - project_endpoint = os.environ["FOUNDRY_PROJECT_ENDPOINT"].rstrip("/") - toolbox_name = os.environ["TOOLBOX_NAME"] + try: + project_endpoint = os.environ["FOUNDRY_PROJECT_ENDPOINT"].rstrip("/") + toolbox_name = os.environ["TOOLBOX_NAME"] + except KeyError as e: + raise ValueError( + "Either set TOOLBOX_ENDPOINT, or set both FOUNDRY_PROJECT_ENDPOINT " + "and TOOLBOX_NAME to build the toolbox MCP endpoint." + ) from e return f"{project_endpoint}/toolboxes/{toolbox_name}/mcp?api-version=v1" +def _toolbox_name_from_endpoint(endpoint: str) -> str: + """Extract the toolbox name from a toolbox MCP endpoint URL. + + Handles both the versioned (``.../toolboxes//versions//mcp``) and + unversioned (``.../toolboxes//mcp``) endpoint shapes that Foundry + produces. Falls back to ``"toolbox"`` when the path has no ``toolboxes`` + segment. + """ + segments = urlsplit(endpoint).path.split("/") + if "toolboxes" in segments: + idx = segments.index("toolboxes") + if idx + 1 < len(segments) and segments[idx + 1]: + return segments[idx + 1] + return "toolbox" + + class ToolboxAuth(httpx.Auth): """Injects a fresh bearer token on every request.""" @@ -48,11 +72,11 @@ async def main(): # Create the toolbox token_provider = get_bearer_token_provider(credential, "https://ai.azure.com/.default") - # Resolve the endpoint once and derive the tool name from the same source: when - # ``TOOLBOX_NAME`` isn't explicitly set, parse it out of the resolved URL so the - # tool's local name and the upstream toolbox always agree. + # Resolve the endpoint once and derive a friendly tool name from it. When + # ``TOOLBOX_NAME`` isn't set, extract the toolbox name from the URL path so + # the tool's local name matches the upstream toolbox. toolbox_endpoint = resolve_toolbox_endpoint() - toolbox_name = os.environ.get("TOOLBOX_NAME") or toolbox_endpoint.rsplit("/mcp", 1)[0].rsplit("/", 1)[-1] + toolbox_name = os.environ.get("TOOLBOX_NAME") or _toolbox_name_from_endpoint(toolbox_endpoint) async with httpx.AsyncClient( auth=ToolboxAuth(token_provider), 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 4ececa73680..0b85d57fae6 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,3 +1,2 @@ -agent-framework -agent-framework-foundry-hosting -mcp>=1.24.0,<2 +agent-framework-foundry +agent-framework-foundry-hosting \ No newline at end of file diff --git a/python/samples/04-hosting/foundry-hosted-agents/responses/04_foundry_toolbox/scripts/list-foundry-connectors.ps1 b/python/samples/04-hosting/foundry-hosted-agents/responses/04_foundry_toolbox/scripts/list-foundry-connectors.ps1 new file mode 100644 index 00000000000..b2af59b91a0 --- /dev/null +++ b/python/samples/04-hosting/foundry-hosted-agents/responses/04_foundry_toolbox/scripts/list-foundry-connectors.ps1 @@ -0,0 +1,105 @@ +#!/usr/bin/env pwsh +<# +.SYNOPSIS + List Foundry Tools Catalog connectors, or fetch full details for one connector. + +.DESCRIPTION + Queries the Azure AI Foundry Tools Catalog (asset-gallery) connectors registry. + - With no -ConnectorName: lists all connectors (name, title, detected auth type). + - With -ConnectorName: prints the full JSON details for that connector. + + A bearer token for https://ai.azure.com is required. It is read from the + -Token parameter, then the CATALOG_TOKEN environment variable, and finally + acquired automatically via 'az account get-access-token' (requires 'az login'). + +.EXAMPLE + ./list-foundry-connectors.ps1 + Lists all connectors. + +.EXAMPLE + ./list-foundry-connectors.ps1 -ConnectorName a365outlookmailmcp + Prints full details for the Work IQ Mail MCP connector. + +.EXAMPLE + ./list-foundry-connectors.ps1 -PageSize 2000 + Lists more connectors in a single page. +#> +[CmdletBinding()] +param( + # annotations/name of a connector to fetch full details for. Omit to list all. + [string]$ConnectorName, + # Azure ML region host prefix. + [string]$Region = "eastus", + # Number of results to request in one page. + [int]$PageSize = 100, + # Catalog bearer token (audience https://ai.azure.com). Defaults to $env:CATALOG_TOKEN, else acquired via az. + [string]$Token = $env:CATALOG_TOKEN +) + +$ErrorActionPreference = "Stop" + +if (-not $Token) { + Write-Verbose "No token supplied; acquiring via 'az account get-access-token'..." + $Token = az account get-access-token --resource https://ai.azure.com --query accessToken -o tsv +} +if (-not $Token) { + throw "Failed to acquire a catalog token. Run 'az login', or pass -Token / set `$env:CATALOG_TOKEN." +} + +$uri = "https://$Region.api.azureml.ms/asset-gallery/v1.0/tools" +$headers = @{ + "Authorization" = "Bearer $Token" + "Content-Type" = "application/json" + "x-ms-user-agent" = "AzureMachineLearningWorkspacePortal/12.0" +} + +$filters = [System.Collections.ArrayList]@( + @{ field = "entityContainerId"; operator = "eq"; values = @("connectors-registry-prod-bl") } + @{ field = "type"; operator = "eq"; values = @("tools") } + @{ field = "kind"; operator = "eq"; values = @("Versioned") } + @{ field = "labels"; operator = "eq"; values = @("latest") } +) +if ($ConnectorName) { + [void]$filters.Add(@{ field = "annotations/name"; operator = "eq"; values = @($ConnectorName) }) +} + +$body = @{ + freeTextSearch = "*" + filters = $filters + includeTotalResultCount = $true + pageSize = $PageSize + skip = 0 +} | ConvertTo-Json -Depth 10 + +# The response can be several MB and may contain a property with an empty-string +# name, so read the raw content and parse with -AsHashtable. +$content = (Invoke-WebRequest -Method Post -Uri $uri -Headers $headers -Body $body).Content +$resp = $content | ConvertFrom-Json -AsHashtable -Depth 100 + +if ($ConnectorName) { + if ($resp.totalCount -eq 0) { + Write-Warning "No connector found with annotations/name '$ConnectorName'." + return + } + $resp.value | ConvertTo-Json -Depth 100 +} +else { + Write-Host "Total connectors: $($resp.totalCount)" + $resp.value | ForEach-Object { + $params = $_.properties.'x-ms-connection-parameters' + $authType = if ($null -eq $params) { + "None" + } + else { + $types = $params.Values | ForEach-Object { $_.type } + if ($types -contains "oauthSetting") { "OAuth2" } + elseif ($types -contains "securestring") { "CustomKeys" } + else { "None" } + } + [pscustomobject]@{ + Name = $_.annotations.name + Title = $_.properties.title + Auth = $authType + } + } | Format-Table -AutoSize +} diff --git a/python/samples/04-hosting/foundry-hosted-agents/responses/04_foundry_toolbox/scripts/list-foundry-connectors.sh b/python/samples/04-hosting/foundry-hosted-agents/responses/04_foundry_toolbox/scripts/list-foundry-connectors.sh new file mode 100644 index 00000000000..3792dff01a4 --- /dev/null +++ b/python/samples/04-hosting/foundry-hosted-agents/responses/04_foundry_toolbox/scripts/list-foundry-connectors.sh @@ -0,0 +1,106 @@ +#!/usr/bin/env bash +# +# List Foundry Tools Catalog connectors, or fetch full details for one connector. +# +# - With no -n: lists all connectors (name, title, detected auth type). +# - With -n NAME: prints the full JSON details for that connector. +# +# A bearer token for https://ai.azure.com is required. It is read from the +# -t option, then the CATALOG_TOKEN environment variable, and finally acquired +# automatically via 'az account get-access-token' (requires 'az login'). +# +# Requires: curl, jq (and optionally az). +# +# Examples: +# ./list-foundry-connectors.sh +# ./list-foundry-connectors.sh -n a365outlookmailmcp +# ./list-foundry-connectors.sh -p 2000 +# +set -euo pipefail + +CONNECTOR_NAME="" +REGION="eastus" +PAGE_SIZE=100 +TOKEN="${CATALOG_TOKEN:-}" + +usage() { + cat <&2; usage; exit 1 ;; + :) echo "Option -$OPTARG requires an argument." >&2; usage; exit 1 ;; + esac +done + +if [[ -z "$TOKEN" ]]; then + TOKEN=$(az account get-access-token --resource https://ai.azure.com --query accessToken -o tsv) +fi +if [[ -z "$TOKEN" ]]; then + echo "Failed to acquire a catalog token. Run 'az login', or pass -t / set CATALOG_TOKEN." >&2 + exit 1 +fi + +URI="https://${REGION}.api.azureml.ms/asset-gallery/v1.0/tools" + +# Base filters; optionally narrow to a single connector by annotations/name. +FILTERS=$(jq -nc --arg connector "$CONNECTOR_NAME" ' + [ + {"field":"entityContainerId","operator":"eq","values":["connectors-registry-prod-bl"]}, + {"field":"type", "operator":"eq","values":["tools"]}, + {"field":"kind", "operator":"eq","values":["Versioned"]}, + {"field":"labels", "operator":"eq","values":["latest"]} + ] + ( ($connector | length) > 0 + ? [{"field":"annotations/name","operator":"eq","values":[$connector]}] + : [] ) + ') + +BODY=$(cat < str: """Resolve the toolbox MCP endpoint URL. - Prefers the explicit ``FOUNDRY_TOOLBOX_ENDPOINT`` env var; falls back to - constructing the URL from ``FOUNDRY_PROJECT_ENDPOINT`` and ``TOOLBOX_NAME`` - (the variables injected by the Foundry hosting scaffolding after ``azd provision``). + Prefers the explicit ``TOOLBOX_ENDPOINT`` env var (set in ``agent.yaml`` or + ``agent.manifest.yaml`` and via ``azd env set TOOLBOX_ENDPOINT`` after the toolbox + is created); falls back to constructing the URL from ``FOUNDRY_PROJECT_ENDPOINT`` + and ``TOOLBOX_NAME``. """ - if (endpoint := os.environ.get("FOUNDRY_TOOLBOX_ENDPOINT")) is not None: + if (endpoint := os.environ.get("TOOLBOX_ENDPOINT")) is not None: if not endpoint: - raise ValueError("FOUNDRY_TOOLBOX_ENDPOINT is set but empty") + raise ValueError("TOOLBOX_ENDPOINT is set but empty") return endpoint - project_endpoint = os.environ["FOUNDRY_PROJECT_ENDPOINT"].rstrip("/") - toolbox_name = os.environ["TOOLBOX_NAME"] + try: + project_endpoint = os.environ["FOUNDRY_PROJECT_ENDPOINT"].rstrip("/") + toolbox_name = os.environ["TOOLBOX_NAME"] + except KeyError as e: + raise ValueError( + "Either set TOOLBOX_ENDPOINT, or set both FOUNDRY_PROJECT_ENDPOINT " + "and TOOLBOX_NAME to build the toolbox MCP endpoint." + ) from e return f"{project_endpoint}/toolboxes/{toolbox_name}/mcp?api-version=v1" +def _toolbox_name_from_endpoint(endpoint: str) -> str: + """Extract the toolbox name from a toolbox MCP endpoint URL. + + Handles both the versioned (``.../toolboxes//versions//mcp``) and + unversioned (``.../toolboxes//mcp``) endpoint shapes that Foundry + produces. Falls back to ``"toolbox"`` when the path has no ``toolboxes`` + segment. + """ + segments = urlsplit(endpoint).path.split("/") + if "toolboxes" in segments: + idx = segments.index("toolboxes") + if idx + 1 < len(segments) and segments[idx + 1]: + return segments[idx + 1] + return "toolbox" + + class ToolboxAuth(httpx.Auth): """Injects a fresh bearer token on every request.""" @@ -76,11 +100,11 @@ async def main(): # Create the toolbox token_provider = get_bearer_token_provider(credential, "https://ai.azure.com/.default") - # Resolve the endpoint once and derive the tool name from the same source: when - # ``TOOLBOX_NAME`` isn't explicitly set, parse it out of the resolved URL so the - # tool's local name and the upstream toolbox always agree. + # Resolve the endpoint once and derive a friendly tool name from it. When + # ``TOOLBOX_NAME`` isn't set, extract the toolbox name from the URL path so + # the tool's local name matches the upstream toolbox. toolbox_endpoint = resolve_toolbox_endpoint() - toolbox_name = os.environ.get("TOOLBOX_NAME") or toolbox_endpoint.rsplit("/mcp", 1)[0].rsplit("/", 1)[-1] + toolbox_name = os.environ.get("TOOLBOX_NAME") or _toolbox_name_from_endpoint(toolbox_endpoint) async with httpx.AsyncClient( auth=ToolboxAuth(token_provider), diff --git a/python/tests/samples/hosting/test_toolbox_endpoint.py b/python/tests/samples/hosting/test_toolbox_endpoint.py index b08c5e58a9c..98421c757d8 100644 --- a/python/tests/samples/hosting/test_toolbox_endpoint.py +++ b/python/tests/samples/hosting/test_toolbox_endpoint.py @@ -1,12 +1,11 @@ # Copyright (c) Microsoft. All rights reserved. -"""Unit tests for _resolve_toolbox_endpoint() in the foundry-hosted-agents response samples. +"""Unit tests for resolve_toolbox_endpoint() in the foundry-hosted-agents response samples. Covers both 04_foundry_toolbox/main.py and 06_files/main.py which share the same -implementation of _resolve_toolbox_endpoint(). +implementation of resolve_toolbox_endpoint(). """ -import importlib import importlib.util import sys from pathlib import Path @@ -50,49 +49,49 @@ def _load_sample(subdir: str, module_alias: str): # --------------------------------------------------------------------------- @pytest.fixture(params=["04_foundry_toolbox", "06_files"]) def resolve_endpoint(request): - """Return _resolve_toolbox_endpoint from the requested sample module.""" + """Return resolve_toolbox_endpoint from the requested sample module.""" mod = _toolbox_mod if request.param == "04_foundry_toolbox" else _files_mod - return mod._resolve_toolbox_endpoint + return mod.resolve_toolbox_endpoint class TestResolveToolboxEndpoint: def test_explicit_endpoint_returned_as_is(self, resolve_endpoint, monkeypatch: pytest.MonkeyPatch): - monkeypatch.setenv("FOUNDRY_TOOLBOX_ENDPOINT", "https://example.com/mcp") + monkeypatch.setenv("TOOLBOX_ENDPOINT", "https://example.com/mcp") monkeypatch.delenv("FOUNDRY_PROJECT_ENDPOINT", raising=False) monkeypatch.delenv("TOOLBOX_NAME", raising=False) assert resolve_endpoint() == "https://example.com/mcp" def test_empty_string_raises_value_error(self, resolve_endpoint, monkeypatch: pytest.MonkeyPatch): - monkeypatch.setenv("FOUNDRY_TOOLBOX_ENDPOINT", "") + monkeypatch.setenv("TOOLBOX_ENDPOINT", "") - with pytest.raises(ValueError, match="FOUNDRY_TOOLBOX_ENDPOINT is set but empty"): + with pytest.raises(ValueError, match="TOOLBOX_ENDPOINT is set but empty"): resolve_endpoint() def test_fallback_constructs_url_from_project_vars(self, resolve_endpoint, monkeypatch: pytest.MonkeyPatch): - monkeypatch.delenv("FOUNDRY_TOOLBOX_ENDPOINT", raising=False) + monkeypatch.delenv("TOOLBOX_ENDPOINT", raising=False) monkeypatch.setenv("FOUNDRY_PROJECT_ENDPOINT", "https://project.azure.com/") monkeypatch.setenv("TOOLBOX_NAME", "my-toolbox") result = resolve_endpoint() - assert result == "https://project.azure.com/toolsets/my-toolbox/mcp?api-version=v1" + assert result == "https://project.azure.com/toolboxes/my-toolbox/mcp?api-version=v1" def test_fallback_strips_trailing_slash_from_project_endpoint( self, resolve_endpoint, monkeypatch: pytest.MonkeyPatch ): - monkeypatch.delenv("FOUNDRY_TOOLBOX_ENDPOINT", raising=False) + monkeypatch.delenv("TOOLBOX_ENDPOINT", raising=False) monkeypatch.setenv("FOUNDRY_PROJECT_ENDPOINT", "https://project.azure.com///") monkeypatch.setenv("TOOLBOX_NAME", "my-toolbox") result = resolve_endpoint() - assert result == "https://project.azure.com/toolsets/my-toolbox/mcp?api-version=v1" + assert result == "https://project.azure.com/toolboxes/my-toolbox/mcp?api-version=v1" - def test_neither_variable_group_set_raises_key_error(self, resolve_endpoint, monkeypatch: pytest.MonkeyPatch): - monkeypatch.delenv("FOUNDRY_TOOLBOX_ENDPOINT", raising=False) + def test_neither_variable_group_set_raises_value_error(self, resolve_endpoint, monkeypatch: pytest.MonkeyPatch): + monkeypatch.delenv("TOOLBOX_ENDPOINT", raising=False) monkeypatch.delenv("FOUNDRY_PROJECT_ENDPOINT", raising=False) monkeypatch.delenv("TOOLBOX_NAME", raising=False) - with pytest.raises(KeyError): + with pytest.raises(ValueError, match="Either set TOOLBOX_ENDPOINT"): resolve_endpoint()