Mastering Toolboxes in Microsoft Foundry

End-to-end walkthrough of Toolboxes in Microsoft Foundry: every tool type, identity, Tool Search, versioning, governance, and consuming from Agent Framework, LangGraph, and Copilot SDK.

Author

Zhuoqun Li's avatar
Zhuoqun Li
Product Manager, Microsoft Foundry
@zhuoqunli

Toolboxes in Microsoft Foundry architecture. On the left a developer assembles a diverse set of tools - Web Search, MCP servers, Azure AI Search, Code Interpreter, File Search, OpenAPI, A2A, Work IQ, Fabric IQ, Browser Automation, Skills, and the tool-search meta-capability. These are published into a toolbox as immutable versions; one version is marked default. The default version is served from a single MCP endpoint (/toolboxes/{name}/mcp) that every MCP client consumes: Foundry hosted agents, Microsoft Agent Framework, LangGraph, and the Copilot SDK. Identity, an RAI guardrail, APIM, and Azure Policy govern the toolbox by default.

Toolboxes in Microsoft Foundry are the managed way to package many tools behind a single, versioned, governed MCP endpoint. You assemble tools once, publish them as a default version, and every MCP client - a Foundry hosted agent, Microsoft Agent Framework, LangGraph, or the Copilot SDK - picks them up from the same URL without re-wiring anything.

This recipe is a broad, end-to-end walkthrough. We build a toolbox, add every tool type, wire up identity, turn on Tool Search, manage versions, apply governance policies, and finally consume the toolbox from four different runtimes.

The four pillars

Pillar What it gives you
One endpoint All tools - first-party and your own MCP servers - behind a single /mcp URL.
Identity built in Per-connection auth (keys, OAuth2, managed identity, agent identity, Foundry managed identity passthrough) flows through to each tool.
Tool Search The model is shown two meta-tools and searches for capability on demand, so a 200-tool toolbox stays as cheap as a 2-tool one.
Governed by default An RAI guardrail screens tool I/O; APIM and Azure Policy add gateway- and control-plane-level controls.

What you'll learn

  1. Configure the AIProjectClient toolbox surface and a token provider for the MCP endpoint.
  2. The six connection auth types and how a hosted agent propagates identity to each tool.
  3. Build a toolbox version with Web Search, MCP, Azure AI Search, Code Interpreter, File Search, OpenAPI, A2A, Work IQ, Fabric IQ, Browser Automation, and Skills.
  4. Do the same thing declaratively - build a toolbox version straight from the REST API.
  5. Tool Search - the meta-tool model, the ranking algorithm, and the knobs that control it.
  6. Versioning - immutable versions and promoting a new default.
  7. Policies - the RAI guardrail, an APIM-fronted MCP server, and Azure Policy at connection-creation time.
  8. Verify the live MCP endpoint, then consume it from MAF, LangGraph, and the Copilot SDK.

Preview. Toolbox, Tool Search, A2A, Browser Automation, Skills, and the Work IQ / Fabric IQ tools are in preview. APIs and headers may change. Past the two required vars (PROJECT_ENDPOINT, MODEL_DEPLOYMENT), every optional section is skip-guarded - leave its env vars blank and it's skipped cleanly, so the notebook runs top-to-bottom with only a project provisioned.

1 / Prerequisites + environment

Microsoft Foundry project A project endpoint (https://<resource>.services.ai.azure.com/api/projects/<project>) with at least one model deployment.
Azure CLI az login so DefaultAzureCredential can pick up your identity.
(Optional) Azure Developer CLI (azd) The Foundry azd extension (azd extension install azure.ai.agents) for creating connections / shipping from CI.
Python 3.10+.
(Optional) connections Azure AI Search, an MCP server, an OpenAPI host, an A2A agent - each only needed for the matching tool below. Create them in the Foundry portal or with azd ai connection create.

Every connection-backed tool is optional. The toolbox is created from whatever you provide; blank env vars skip their section.

Configure your environment

Set these in your shell or a local .env (loaded with python-dotenv). Only PROJECT_ENDPOINT and MODEL_DEPLOYMENT are required to create a toolbox.

# ---- Required ----
PROJECT_ENDPOINT="https://<resource>.services.ai.azure.com/api/projects/<project>"
MODEL_DEPLOYMENT="gpt-4.1-mini"

# ---- Optional: name of the toolbox we create/update ----
TOOLBOX_NAME="my-toolbox"

# ---- Optional: project CONNECTION IDs for the tools that need them ----
# A connection ID is the connection's name OR its full ARM resource id
# (/subscriptions/.../connections/<name>). Leave a value blank to skip that tool.
MCP_SERVER_URL=""                            # remote MCP server URL (may be APIM-fronted)
MCP_SERVER_LABEL="custom_mcp"                # label that namespaces the MCP server's tools
MCP_PROJECT_CONNECTION_ID=""                 # connection backing the MCP server (auth)
AISEARCH_PROJECT_CONNECTION_ID=""            # Azure AI Search connection (key or MI)
AISEARCH_INDEX=""                            # index name to expose
OPENAPI_PROJECT_CONNECTION_ID=""             # connection backing an OpenAPI tool (key or MI)
A2A_PROJECT_CONNECTION_ID=""                 # connection to a downstream A2A agent
A2A_ENDPOINT=""                              # optional: A2A base URL if the connection has no target
WORK_IQ_PROJECT_CONNECTION_ID=""             # Work IQ (Microsoft 365) connection (oauth2)
FABRIC_IQ_PROJECT_CONNECTION_ID=""           # Fabric IQ (Microsoft Fabric) connection (MI or oauth2)
BROWSER_AUTOMATION_PROJECT_CONNECTION_ID=""  # Azure Playwright connection (key)
BING_CUSTOM_SEARCH_PROJECT_CONNECTION_ID=""  # Bing Custom Search connection (key)
BING_CUSTOM_SEARCH_INSTANCE=""               # Bing Custom Search instance name
FILE_SEARCH_VECTOR_STORE_ID=""               # vector store id for File Search
SKILL_NAME=""                                # name of a published skill to include
SKILL_VERSION=""                             # optional: pin a skill version
RAI_POLICY_NAME=""                           # existing RAI policy for the policies section
%%capture
# Toolboxes in Microsoft Foundry ship on the public-preview azure-ai-projects SDK (the typed
# toolbox + tool bindings live under project.beta.toolboxes). mcp gives us a
# JSON-RPC client for the raw endpoint, langchain-azure-ai[tools] provides the
# LangGraph adapter, and agent-framework the MAF consumer. All on PyPI.
import importlib.metadata as _md

_need = []
for _pkg in ("azure-ai-projects", "azure-identity", "mcp", "httpx",
             "python-dotenv", "langchain-azure-ai", "agent-framework"):
    try:
        _md.version(_pkg)
    except _md.PackageNotFoundError:
        _need.append(_pkg)

if _need:
    %pip install --quiet \
        "azure-ai-projects>=2.1.0" \
        "azure-identity>=1.17.0" \
        "mcp>=1.0.0" \
        "httpx>=0.27.0" \
        "python-dotenv>=1.0.0" \
        "langchain-azure-ai[tools]>=1.2.4" \
        "agent-framework>=1.4.0"

2 / Configure clients + helpers

One AIProjectClient and one DefaultAzureCredential are reused throughout. We also define a few helpers the rest of the notebook leans on:

  • env(name, required=False) - env-var lookup with a friendly error.
  • skip(reason) - prints why a section is skipped and returns True, so each optional cell starts with if skip(...): ....
  • mcp_token() - a bearer token scoped to https://ai.azure.com/.default, the audience the toolbox MCP endpoint expects.
  • TOOLBOX_HEADERS - every call to a toolbox MCP endpoint must carry Foundry-Features: Toolboxes=V1Preview. Forgetting it is the #1 cause of 404s.
  • created_resources - a tracker the cleanup section walks in reverse.
import os
from typing import Optional

from azure.identity import DefaultAzureCredential, get_bearer_token_provider
from azure.ai.projects import AIProjectClient
from dotenv import load_dotenv

load_dotenv(override=True)


def env(name: str, *, required: bool = True, default: Optional[str] = None) -&gt; Optional[str]:
    value = os.getenv(name, default)
    if required and not value:
        raise RuntimeError(f"Missing required env var: {name}")
    return value or None


def skip(reason: str) -&gt; bool:
    print(f"⏭️  Skipping — {reason}")
    return True


# ---- Required -----------------------------------------------------------
PROJECT_ENDPOINT = env("PROJECT_ENDPOINT")
MODEL_DEPLOYMENT = env("MODEL_DEPLOYMENT", required=False, default="gpt-4.1-mini")
TOOLBOX_NAME = env("TOOLBOX_NAME", required=False, default="my-toolbox")

# The MCP endpoint audience is ai.azure.com (NOT management.azure.com).
TOOLBOX_SCOPE = "https://ai.azure.com/.default"

# Mandatory on every toolbox MCP request while the feature is in preview.
TOOLBOX_HEADERS = {"Foundry-Features": "Toolboxes=V1Preview"}

credential = DefaultAzureCredential()
project = AIProjectClient(endpoint=PROJECT_ENDPOINT, credential=credential)
_token_provider = get_bearer_token_provider(credential, TOOLBOX_SCOPE)


def mcp_token() -&gt; str:
    """A fresh bearer token for the toolbox MCP endpoint."""
    return _token_provider()


# Walked in reverse by the cleanup section.
created_resources: dict = {"toolbox": None, "versions": [], "connections": []}

print(f"✅ Project: {PROJECT_ENDPOINT}")
print(f"✅ Toolbox name: {TOOLBOX_NAME}")

3 / Auth & identity

A toolbox tool reaches a downstream system through a project connection, and the connection's auth type decides whose identity is used. This is the single most important design decision in a toolbox - get it right and every consumer inherits correct, least- privilege access automatically.

Toolboxes in Microsoft Foundry identity flow. An end user calls a hosted agent that runs under its own agent managed identity. The agent must first be authorized to the toolbox - its identity needs the Foundry user role - before it can call the toolbox MCP endpoint. The toolbox then reaches each downstream tool through a project connection, and the connection's auth type - none, custom-keys, oauth2, user-entra-token (Foundry managed identity passthrough), project-managed-identity, or agentic-identity - selects the flow. For oauth2 and user-entra-token the agent emits the caller / end-user token and passes it to the toolbox, which uses that token to authenticate to the tool instead of the agent identity.

How a hosted agent authenticates - two steps

  1. Authorize the agent to the toolbox first. Before it can call the toolbox MCP endpoint at all, the hosted agent's identity must hold the Foundry user role on the project. No role → the toolbox rejects the agent. This is independent of any tool.
  2. The toolbox then handles each tool's auth based on that connection's auth type:
    • For none, custom-keys, project-managed-identity, agentic-identity, and Foundry-managed oauth2, the toolbox authenticates to the tool using the connection's configured identity - the agent never sees the secret.
    • For oauth2 and user-entra-token (Foundry managed identity passthrough), the hosted agent emits the caller / end-user token and passes it to the toolbox; the toolbox uses that token - not the agent identity - to authenticate to the tool. This is how the tool ends up acting as the real end user.

Auth support is tool-type specific

Only MCP and A2A accept all six auth types (each one is defined in detail in 4e · Remote MCP server below). Every other tool type supports a narrower subset - pick a connection auth type the tool actually allows:

Tool type Supported auth types
MCP, A2A none, custom-keys, oauth2, user-entra-token, project-managed-identity, agentic-identity
AI Search custom-keys, project-managed-identity
Web Search (custom search) custom-keys
OpenAPI custom-keys, project-managed-identity
Work IQ oauth2
Fabric IQ project-managed-identity, oauth2
Browser Automation custom-keys

OAuth consent. The first call through an oauth2 connection returns a CONSENT_REQUIRED error (JSON-RPC code -32006/-32007) carrying a consent URL. Open it, consent once, then retry - we handle exactly this in the verify section.

Creating the connections (this is not an SDK-from-Python step)

A connection is a project resource an admin creates once; tools then reference it by id. You do not create connections from the toolbox SDK. The real ways to create one:

  1. Foundry portal - Build -> Tools -> Connect a tool (or Management -> Connected resources -> New connection). This is the easiest path and it drives the OAuth consent UI for you.
  2. Azure Developer CLI (azd) - the Foundry azd extension creates connections from the terminal or CI, e.g. azd ai connection create <name> --kind remote-tool --target <url> --auth-type <auth-type> (use --kind cognitive-search for an Azure AI Search connection). This is the source-control-friendly path.
  3. Connections REST / ARM API - PUT .../projects/{p}/connections/{name} with the auth type in the body (what the portal and azd call under the hood).

The connection's auth type is chosen at creation time (narrowed per the support table above). Whatever you pick is what the tool uses at runtime - the agent code never sets auth.

The relevant connection type for external tools (MCP, A2A, Work IQ, Fabric IQ, Browser Automation) is RemoteTool_Preview; for Azure AI Search it is CognitiveSearch. In azd, --kind remote-tool creates a RemoteTool_Preview connection and --kind cognitive-search creates a CognitiveSearch one.

Two-step auth at runtime (worth internalizing before you build):

  1. The hosted agent's identity must hold the Foundry user role on the project, or the toolbox rejects the call before any tool runs.
  2. The toolbox then authenticates to each tool per the connection's auth type. For most types it uses the connection's configured identity; for oauth2 and user-entra-token the agent emits the caller / end-user token and the toolbox uses that token to reach the tool.
# You reference a connection by its id (or name). List the ones already on your
# project so you can copy the right value into the *_PROJECT_CONNECTION_ID env vars
# used by the build cells below. (This is a real SDK call - connections are read
# through the project client even though they are CREATED out-of-band.)
try:
    conns = list(project.connections.list())
    if conns:
        print(f"{len(conns)} connection(s) on this project:\n")
        for c in conns:
            print(f"  {c.name:30s}  type={getattr(c, 'type', '?')}")
        print("\nCopy a name/id into the matching *_PROJECT_CONNECTION_ID env var.")
    else:
        print("No connections yet - create one in the portal (Build -> Tools -> Connect a tool).")
except Exception as exc:  # noqa: BLE001 - informational only
    skip(f"Could not list connections ({exc!r}). Create them in the Foundry portal.")

4 / Build a toolbox version (SDK)

A toolbox is a named resource; its capabilities live in immutable versions. You build a version from a list of typed tool objects plus an optional list of skills, then promote one version to default later.

project.beta.toolboxes.create_version(
    name=TOOLBOX_NAME,
    description="...",                 # human-readable, shown in listings
    tools=[ MCPTool(...), AzureAISearchTool(...), ... ],   # one typed object per tool
    skills=[ ToolboxSkillReference(...) ],                 # SEPARATE from tools
    policies=ToolboxPolicies(...),     # optional governance - see the policies section
)

Rules that apply to every tool:

  • Use the typed classes from azure.ai.projects.models. Each part below imports the exact classes it needs at the top of its own cell, so you can lift any single tool into your own code.
  • Connections are referenced by project_connection_id - the connection's name or its full ARM resource id. The toolbox resolves the tool's auth from that connection.
  • name + description are optional on every tool and are what Tool Search ranks on, so give each one a crisp description. (MCPTool instead uses server_label + server_description.)
  • Only one unnamed tool is allowed in the entire toolbox. Give every other tool a name so you can register several tools - even several of the same type - in one toolbox.
  • Skills are NOT tools - they go in the separate skills=[...] list as ToolboxSkillReference(name=..., version=...).

Each block below is skip-guarded on its env vars, so the cells run with just the two required vars set - you'll get a Web Search + Code Interpreter toolbox. We build the tools list across the parts (run them top-to-bottom), then create the version in the final part.

4a · Start the tool and skill lists

The build is incremental: each tool part appends to a tools list (and Skills appends to a skills list). Run this once to create the two empty lists, then run the parts you need top-to-bottom. There is no shared import cell - every part imports its own classes.

tools: list = []   # typed Tool objects, one per tool below
skills: list = []  # ToolboxSkillReference objects (passed separately from tools)
print("Empty tools[] and skills[] ready - run the tool parts you want below.")

4b · Web Search

📄 Docs: Web search

What it is. A built-in tool that grounds answers with live web results. No vector store, no data prep - the model decides when to search and Foundry runs the query server-side.

How it works. Plain WebSearchTool() uses Grounding with Bing (billed under its own terms, no connection needed). Pass a WebSearchConfiguration instead to scope results to a Bing Custom Search instance you own - that path runs through a key-based connection.

Key parameters (all optional unless noted):

  • search_context_size - "low" | "medium" | "high"; how much retrieved context to feed the model (bigger = more grounding, more tokens). Service default is medium.
  • user_location - WebSearchApproximateLocation(country=, region=, city=, timezone=) to bias results geographically.
  • custom_search_configuration - a WebSearchConfiguration(project_connection_id=, instance_name=) (both required) to use Bing Custom Search instead of the public index.
  • name, description - used by Tool Search ranking.

Create the connection (only for the custom-search path; key-based) in the portal under Build -> Tools -> Connect a tool, or with azd ai connection create ... --auth-type custom-keys, then pass its id as WebSearchConfiguration.project_connection_id. The default public web search needs no connection.

from azure.ai.projects.models import WebSearchTool, WebSearchConfiguration

if os.getenv("BING_CUSTOM_SEARCH_PROJECT_CONNECTION_ID") and os.getenv("BING_CUSTOM_SEARCH_INSTANCE"):
    tools.append(WebSearchTool(
        name="web_search_custom",
        description="Search a curated set of sites via Bing Custom Search.",
        search_context_size="medium",  # low | medium | high
        custom_search_configuration=WebSearchConfiguration(
            project_connection_id=os.environ["BING_CUSTOM_SEARCH_PROJECT_CONNECTION_ID"],
            instance_name=os.environ["BING_CUSTOM_SEARCH_INSTANCE"],
        ),
    ))
else:
    tools.append(WebSearchTool(
        name="web_search",
        description="Search the public web for current information.",
        search_context_size="medium",  # low | medium | high
    ))
print(f"+ Web Search  (tools so far: {len(tools)})")

4c · Code Interpreter

📄 Docs: Code Interpreter

What it is. A sandboxed Python runtime the model can use to do math, parse text, transform data, and generate files or charts - anything better done by running code than by guessing.

How it works. The model writes Python; Foundry executes it in an isolated container and feeds results back. To analyze your own data, upload files first with the Files API and attach them by id; files the code generates (charts, CSVs) come back as container-file citations you can download. No connection is required - Code Interpreter is fully hosted.

Upload a file with the project's OpenAI client (purpose="assistants"), then attach it:

openai = project.get_openai_client()
file = openai.files.create(purpose="assistants", file=open("data.csv", "rb"))
# attach to the tool: AutoCodeInterpreterToolParam(file_ids=[file.id])

Download a generated file - the run's response annotations carry a container_file_citation with file_id + container_id; fetch the bytes with:

content = openai.containers.files.content.retrieve(file_id=file_id, container_id=container_id)
with open("chart.png", "wb") as f:
    f.write(content.read())

Key parameters (all optional):

  • container - either a container id string, or an AutoCodeInterpreterToolParam(file_ids=[...]) to attach uploaded files to the sandbox. Omit it to let the service auto-provision a default sandbox.
  • name, description - used by Tool Search ranking.
from azure.ai.projects.models import CodeInterpreterTool, AutoCodeInterpreterToolParam

# Optional: upload a file for the sandbox to analyze (Files API on the project's OpenAI client).
file_ids = []
if os.getenv("CODE_INTERPRETER_FILE"):
    openai_client = project.get_openai_client()
    up = openai_client.files.create(purpose="assistants", file=open(os.environ["CODE_INTERPRETER_FILE"], "rb"))
    file_ids = [up.id]
    print(f"  uploaded {os.environ['CODE_INTERPRETER_FILE']} as {up.id}")

ci = CodeInterpreterTool(
    name="code_interpreter",
    description="Run Python in a sandbox for math, parsing, and data work.",
)
if file_ids:
    ci.container = AutoCodeInterpreterToolParam(file_ids=file_ids)
tools.append(ci)
print(f"+ Code Interpreter  (tools so far: {len(tools)})")

4d · File Search

📄 Docs: File Search

What it is. Retrieval-augmented grounding over your own documents. You load files into one or more vector stores; the tool retrieves the most relevant chunks for each query.

How it works. Create a vector store, upload your files into it, and pass the store id. Use the project's OpenAI client (project.get_openai_client()) for both steps:

openai = project.get_openai_client()
vector_store = openai.vector_stores.create(name="ProductInfoStore")
with open("product_info.md", "rb") as fh:
    openai.vector_stores.files.upload_and_poll(vector_store_id=vector_store.id, file=fh)
# then: FileSearchTool(vector_store_ids=[vector_store.id])

vector_store_ids is required, so this part skips unless you supply FILE_SEARCH_VECTOR_STORE_ID (or set FILE_SEARCH_FILE to create a store below). No connection is needed - the vector store is a project resource.

Key parameters:

  • vector_store_ids - required list[str]; the stores to search.
  • max_num_results - optional int (1-50); cap on retrieved chunks.
  • filters - optional metadata filter (ComparisonFilter / CompoundFilter).
  • name, description - used by Tool Search ranking.
from azure.ai.projects.models import FileSearchTool

vector_store_id = os.getenv("FILE_SEARCH_VECTOR_STORE_ID")

# No store yet? Create one and upload a file (Files + Vector Stores API).
if not vector_store_id and os.getenv("FILE_SEARCH_FILE"):
    openai_client = project.get_openai_client()
    vs = openai_client.vector_stores.create(name="toolbox-file-search")
    with open(os.environ["FILE_SEARCH_FILE"], "rb") as fh:
        openai_client.vector_stores.files.upload_and_poll(vector_store_id=vs.id, file=fh)
    vector_store_id = vs.id
    print(f"  created vector store {vs.id}")

if vector_store_id:
    tools.append(FileSearchTool(
        name="file_search",
        description="Search uploaded documents and attached vector stores.",
        vector_store_ids=[vector_store_id],
        # max_num_results=10,  # optional cap on retrieved chunks
    ))
    print(f"+ File Search  (tools so far: {len(tools)})")
else:
    skip("FILE_SEARCH_VECTOR_STORE_ID / FILE_SEARCH_FILE not set - skipping File Search")

4e · Remote MCP server

📄 Docs: Model Context Protocol

What it is. A bridge to any external Model Context Protocol server, so every tool that server exposes becomes callable from your toolbox. This is the most common way to bring third-party or in-house tools into Foundry.

How it works. You give it a server_label (namespaces the remote tools in tools/list) and a server_url (the MCP endpoint). Auth and allow-listing come from the connection named by project_connection_id. MCP accepts all six auth types.

Key parameters:

  • server_label - required str; the prefix used to identify this server's tools.
  • server_url - required str; the MCP endpoint URL.
  • project_connection_id - connection holding the auth; None means a public/no-auth server.
  • require_approval - "never" | "always", or an MCPToolRequireApproval(always=, never=) filter to gate specific tools.
  • allowed_tools - a list[str] (or MCPToolFilter(tool_names=, read_only=)) to curate a subset.
  • server_description, headers - description for ranking; extra HTTP headers per call.

The six connection auth types (MCP and A2A support all of them). Each is configured on the connection at creation time - never in the tool code:

--auth-type Parameter it needs How it works
none (none) Anonymous. The connection target is a public MCP URL and nothing is attached. Use for public servers (e.g. Microsoft Learn MCP).
custom-keys one or more header key/value pairs stored in the connection The toolbox injects the static header(s) (e.g. x-api-key: <value>) on every upstream call. The agent never sees the secret.
oauth2 a Foundry-managed OAuth app, or your own clientId / clientSecret + scopes Delegated OAuth. The first call returns CONSENT_REQUIRED; the user consents once, the toolbox stores the token and then calls the tool as that user.
user-entra-token the upstream resource / audience (Foundry managed identity passthrough) The hosted agent emits the caller's Microsoft Entra token and the toolbox forwards it to the MCP server. Use when the server consumes a delegated Entra token directly.
project-managed-identity RBAC only - grant the project's managed identity the upstream's role The project's system-assigned managed identity authenticates the call. A pure service-to-service flow with no user context.
agentic-identity the agent's own per-project identity (assigned to the agent) Each agent calls with its own distinct principal, so downstream audit and least-privilege are per-agent rather than shared.

Create the connection (admin, once - portal Build -> Tools -> Connect a tool, or azd):

azd ai connection create my-mcp --kind remote-tool --target https://api.example.com/mcp --auth-type oauth2

Then pass its name/id as project_connection_id.

from azure.ai.projects.models import MCPTool

if os.getenv("MCP_SERVER_URL"):
    tools.append(MCPTool(
        server_label=os.getenv("MCP_SERVER_LABEL", "custom_mcp"),
        server_url=os.environ["MCP_SERVER_URL"],
        server_description="Tools served by a remote MCP server registered as a connection.",
        require_approval="never",
        project_connection_id=os.getenv("MCP_PROJECT_CONNECTION_ID"),  # None == public/none auth
        # allowed_tools=["repo_search", "issue_read"],  # optional curated subset
    ))
    print(f"+ MCP server  (tools so far: {len(tools)})")
else:
    skip("MCP_SERVER_URL not set - skipping MCP server")

4f · Azure AI Search

📄 Docs: Azure AI Search

What it is. Grounded retrieval over an Azure AI Search index - your enterprise knowledge base, with keyword, semantic, or vector ranking.

How it works. The tool wraps a nested resource: AzureAISearchToolResource(indexes=[...]) holding one AISearchIndexResource that names the connection, the index, and the query mode. The connection supports key or project-managed-identity auth.

Key parameters (on AISearchIndexResource):

  • project_connection_id - the AI Search connection.
  • index_name - the index to query.
  • query_type - an AzureAISearchQueryType: SIMPLE (BM25 keyword), SEMANTIC, VECTOR, VECTOR_SIMPLE_HYBRID, or VECTOR_SEMANTIC_HYBRID.
  • top_k - number of documents to retrieve; filter - an OData filter string.

AzureAISearchToolResource.indexes is capped at one index per tool.

Create the connection (custom-keys or project-managed-identity):

azd ai connection create my-search --kind cognitive-search --target https://<svc>.search.windows.net --auth-type project-managed-identity
from azure.ai.projects.models import (
    AzureAISearchTool,
    AzureAISearchToolResource,
    AISearchIndexResource,
    AzureAISearchQueryType,
)

if os.getenv("AISEARCH_PROJECT_CONNECTION_ID") and os.getenv("AISEARCH_INDEX"):
    tools.append(AzureAISearchTool(
        name="ai_search",
        description="Retrieve grounded passages from the enterprise knowledge index.",
        azure_ai_search=AzureAISearchToolResource(
            indexes=[AISearchIndexResource(
                project_connection_id=os.environ["AISEARCH_PROJECT_CONNECTION_ID"],
                index_name=os.environ["AISEARCH_INDEX"],
                query_type=AzureAISearchQueryType.SIMPLE,  # or SEMANTIC / VECTOR / *_HYBRID
                # top_k=5,
            )],
        ),
    ))
    print(f"+ Azure AI Search  (tools so far: {len(tools)})")
else:
    skip("AISEARCH_PROJECT_CONNECTION_ID / AISEARCH_INDEX not set - skipping Azure AI Search")

4g · OpenAPI

📄 Docs: OpenAPI specified tools

What it is. Turns any REST API described by an OpenAPI spec into agent-callable functions - one function per operation - without writing a wrapper.

How it works. You hand the loaded spec dict to an OpenApiFunctionDefinition, which also carries the auth. The most common auth is a project connection via OpenApiProjectConnectionAuthDetails -> OpenApiProjectConnectionSecurityScheme(project_connection_id=...). Supports key or project-managed-identity. (Other auth variants: OpenApiAnonymousAuthDetails for public APIs, OpenApiManagedAuthDetails for a managed-identity audience.)

Key parameters (on OpenApiFunctionDefinition):

  • name - required function name.
  • spec - required the OpenAPI document as a dict (e.g. jsonref.loads(open(path).read())).
  • auth - required one of the auth-details classes above.
  • description - shown to the model and used by Tool Search; default_params - params you pre-fill.

Create the connection (key-based or project-managed-identity) in the portal under Connect a tool, or with azd ai connection create ... --auth-type custom-keys; pass its id as project_connection_id.

from azure.ai.projects.models import (
    OpenApiTool,
    OpenApiFunctionDefinition,
    OpenApiProjectConnectionAuthDetails,
    OpenApiProjectConnectionSecurityScheme,
)

if os.getenv("OPENAPI_PROJECT_CONNECTION_ID"):
    openapi_spec = {  # replace with your loaded spec dict, e.g. jsonref.loads(open(...).read())
        "openapi": "3.0.0",
        "info": {"title": "petstore", "version": "1.0.0"},
        "paths": {},
    }
    tools.append(OpenApiTool(
        openapi=OpenApiFunctionDefinition(
            name="petstore",
            description="Call the Petstore REST API to look up and manage pets.",
            spec=openapi_spec,
            auth=OpenApiProjectConnectionAuthDetails(
                security_scheme=OpenApiProjectConnectionSecurityScheme(
                    project_connection_id=os.environ["OPENAPI_PROJECT_CONNECTION_ID"],
                ),
            ),
        ),
    ))
    print(f"+ OpenAPI  (tools so far: {len(tools)})")
else:
    skip("OPENAPI_PROJECT_CONNECTION_ID not set - skipping OpenAPI")

4h · A2A (agent-to-agent)

📄 Docs: Agent-to-agent (A2A)

What it is. Lets your toolbox delegate to another agent that speaks the open Agent-to-Agent (A2A) protocol - useful for composing specialist agents (billing, HR, search) into one surface.

How it works. You reference the remote agent by connection only - no agent-card URL needed. The tool fetches the agent's capability card from the default path (/.well-known/agent-card.json) at the connection's target. Set base_url only when the connection has no target endpoint, or agent_card_path to override the card location. Accepts all six auth types.

Key parameters (all optional):

  • project_connection_id - the connection to the A2A server (carries auth + target).
  • base_url - the agent's base URL, when not supplied by the connection.
  • agent_card_path - defaults to /.well-known/agent-card.json.
  • name, description - used by Tool Search ranking.

Create the connection (admin, once - all six auth types apply):

azd ai connection create my-a2a --kind remote-tool --target https://agent.example.com --auth-type oauth2
from azure.ai.projects.models import A2APreviewTool

if os.getenv("A2A_PROJECT_CONNECTION_ID"):
    a2a = A2APreviewTool(
        name="billing_agent",
        description="Delegate billing questions to the specialized billing agent.",
        project_connection_id=os.environ["A2A_PROJECT_CONNECTION_ID"],
    )
    # Only needed when the connection has no target endpoint (e.g. custom-keys auth):
    if os.getenv("A2A_ENDPOINT"):
        a2a.base_url = os.environ["A2A_ENDPOINT"]
    tools.append(a2a)
    print(f"+ A2A  (tools so far: {len(tools)})")
else:
    skip("A2A_PROJECT_CONNECTION_ID not set - skipping A2A")

4i · Work IQ (preview)

📄 Docs: Work IQ

What it is. A Microsoft-managed tool that reasons over the signed-in user's Microsoft 365 work context - mail, chats, meetings, and documents - so the agent can answer "what did my team decide about X?" style questions.

How it works. It is fully hosted by Microsoft; you only point it at a connection. Because it acts as the user, its connection is oauth2 - the caller's token flows through (Foundry managed identity passthrough) and Work IQ honors that user's M365 permissions.

Key parameters:

  • project_connection_id - required str; the Work IQ connection.
  • name, description - used by Tool Search ranking.

Create the connection (Work IQ requires oauth2):

azd ai connection create my-workiq --kind remote-tool --target <work-iq-endpoint> --auth-type oauth2
from azure.ai.projects.models import WorkIQPreviewTool

if os.getenv("WORK_IQ_PROJECT_CONNECTION_ID"):
    tools.append(WorkIQPreviewTool(
        name="work_iq",
        description="Reason over the user's Microsoft 365 work context (mail, chats, meetings, docs).",
        project_connection_id=os.environ["WORK_IQ_PROJECT_CONNECTION_ID"],
    ))
    print(f"+ Work IQ  (tools so far: {len(tools)})")
else:
    skip("WORK_IQ_PROJECT_CONNECTION_ID not set - skipping Work IQ")

4j · Fabric IQ (preview)

📄 Docs: Fabric IQ

What it is. A Microsoft-managed tool for governed analytics and ontology over Microsoft Fabric - it reaches Fabric's data agent / MCP surface so the agent can query lakehouse data and semantic models under Fabric's governance.

How it works. Backed by an MCP server on the Fabric side; you supply the connection and optionally a server_label/server_url. Its connection supports project-managed-identity or oauth2. Note require_approval defaults to "always" - set it to "never" for unattended use.

Key parameters:

  • project_connection_id - required str; the Fabric IQ connection.
  • require_approval - "never" | "always" (default "always") or an MCPToolRequireApproval filter.
  • server_label, server_url - optional MCP server identity (falls back to the connection).
  • name, description - used by Tool Search ranking.

Create the connection (project-managed-identity or oauth2):

azd ai connection create my-fabriciq --kind remote-tool --target <fabric-iq-endpoint> --auth-type project-managed-identity
from azure.ai.projects.models import FabricIQPreviewTool

if os.getenv("FABRIC_IQ_PROJECT_CONNECTION_ID"):
    tools.append(FabricIQPreviewTool(
        name="fabric_iq",
        description="Query governed analytics and ontology data from Microsoft Fabric.",
        project_connection_id=os.environ["FABRIC_IQ_PROJECT_CONNECTION_ID"],
        require_approval="never",  # defaults to "always"
    ))
    print(f"+ Fabric IQ  (tools so far: {len(tools)})")
else:
    skip("FABRIC_IQ_PROJECT_CONNECTION_ID not set - skipping Fabric IQ")

4k · Browser Automation (preview)

📄 Docs: Browser Automation

What it is. Lets the agent drive a real browser (via Azure Playwright Testing) to complete multi-step web tasks - navigate, click, fill forms, read pages - when no API exists.

How it works. The tool needs a connection to an Azure Playwright resource, nested two levels deep: BrowserAutomationToolParameters(connection=BrowserAutomationToolConnectionParameters( project_connection_id=...)). The connection is key-based.

Key parameters:

  • browser_automation_preview - required BrowserAutomationToolParameters, whose connection.project_connection_id (required) points at the Playwright connection.
  • name, description - used by Tool Search ranking.

Create the connection (key-based, to an Azure Playwright resource):

azd ai connection create my-browser --kind remote-tool --target <playwright-endpoint> --auth-type custom-keys
from azure.ai.projects.models import (
    BrowserAutomationPreviewTool,
    BrowserAutomationToolParameters,
    BrowserAutomationToolConnectionParameters,
)

if os.getenv("BROWSER_AUTOMATION_PROJECT_CONNECTION_ID"):
    tools.append(BrowserAutomationPreviewTool(
        name="browser_automation",
        description="Navigate and act on live web pages to complete multi-step browser tasks.",
        browser_automation_preview=BrowserAutomationToolParameters(
            connection=BrowserAutomationToolConnectionParameters(
                project_connection_id=os.environ["BROWSER_AUTOMATION_PROJECT_CONNECTION_ID"],
            ),
        ),
    ))
    print(f"+ Browser Automation  (tools so far: {len(tools)})")
else:
    skip("BROWSER_AUTOMATION_PROJECT_CONNECTION_ID not set - skipping Browser Automation")

4l · Skills (the separate skills= list)

📄 Docs: Skills

What it is. A skill is a reusable, published capability - a packaged prompt + its tools, following the SKILL.md spec - that you register once and reuse across toolboxes and agents.

How it works. Skills are registered out-of-band with project.beta.skills.create(name, inline_content=SkillInlineContent(description=, instructions=), default=True) (or create_from_files(...) for a multi-file zip). In a toolbox a skill is not a tool - you reference it in the separate skills= list with ToolboxSkillReference(name, version). Omit version to track the skill's default version; pin it to freeze on an immutable version.

Key parameters (ToolboxSkillReference):

  • name - required str; the published skill name.
  • version - optional; None = default version, a value = pinned immutable version.
from azure.ai.projects.models import ToolboxSkillReference

# Reference a published skill by name. Register one first, e.g.:
#   from azure.ai.projects.models import SkillInlineContent
#   project.beta.skills.create(
#       name="refund-policy",
#       inline_content=SkillInlineContent(
#           description="Apply the company refund policy.",
#           instructions="# Refund policy\n...SKILL.md body...",
#       ),
#       default=True,
#   )
if os.getenv("SKILL_NAME"):
    skills.append(ToolboxSkillReference(
        name=os.environ["SKILL_NAME"],
        version=os.getenv("SKILL_VERSION") or None,  # None == the skill's default version
    ))
    print(f"+ Skill  (skills so far: {len(skills)})")
else:
    skip("SKILL_NAME not set - skipping Skills")

4m · Create the version

Pass the assembled tools and skills to create_version. The toolbox is auto-created on the first call; every call mints a new immutable version id. tools is required; skills, description, metadata, and policies are optional.

# create_version(name, *, tools, description=None, metadata=None, skills=None, policies=None)
version = project.beta.toolboxes.create_version(
    name=TOOLBOX_NAME,
    description="Diverse demo toolbox: search, code, knowledge, and connection-backed tools.",
    tools=tools,
    skills=skills or None,
)
created_resources["toolbox"] = TOOLBOX_NAME
created_resources["versions"].append(version.version)
print(f"Assembled {len(tools)} tool(s) + {len(skills)} skill(s)")
print(f"✅ Created {TOOLBOX_NAME} version {version.version}")

5 / The REST API path (declarative / CI)

The SDK above is a thin wrapper over the toolboxes REST API. When you want to ship a toolbox from CI - or from a language without an SDK - call the API directly. The whole build is one POST .../toolboxes/{name}/versions, and promoting a default is one PATCH .../toolboxes/{name}.

The request body is exactly the JSON the typed classes serialize to, so you can keep a versioned JSON manifest in source control. Every call needs the bearer token (scope https://ai.azure.com/.default) and the Foundry-Features: Toolboxes=V1Preview header.

azd (the Foundry extension) handles connections and agent provisioning, but a toolbox is authored with the SDK above or this REST API - there is no azd ai toolbox command. Create connections with azd ai connection create (or the portal) as covered in the auth section.

import json, pathlib, httpx

# A toolbox version as plain JSON - the declarative equivalent of the SDK build.
# These dicts are the same shapes the typed classes produce, so this file can live in git.
version_body = {
    "tools": [
        {"type": "web_search", "description": "Search the public web for current information."},
        {"type": "code_interpreter", "description": "Run Python in a sandbox for data work."},
        {
            "type": "mcp",
            "server_label": "learn",
            "server_description": "Search Microsoft Learn documentation.",
            "server_url": "https://learn.microsoft.com/api/mcp",
            "require_approval": "never",
        },
        {"type": "toolbox_search_preview"},  # make the whole toolbox search-first
    ],
    "description": "Declarative toolbox built from a JSON manifest.",
}

# Persist the manifest next to the notebook so it can be source-controlled.
data_dir = pathlib.Path("data/mastering-foundry-toolbox")
data_dir.mkdir(parents=True, exist_ok=True)
manifest_path = data_dir / "my-toolbox.json"
manifest_path.write_text(json.dumps(version_body, indent=2), encoding="utf-8")
print(f"Wrote {manifest_path}")

if not os.getenv("PROJECT_ENDPOINT"):
    skip("PROJECT_ENDPOINT not set - showing the REST calls without sending them")
else:
    base = PROJECT_ENDPOINT.rstrip("/")
    headers = {"Authorization": f"Bearer {mcp_token()}", **TOOLBOX_HEADERS,
               "Content-Type": "application/json"}

    # 1) Create a new immutable version (auto-creates the toolbox on first call).
    create_url = f"{base}/toolboxes/{TOOLBOX_NAME}/versions?api-version=v1"
    resp = httpx.post(create_url, headers=headers, json=version_body, timeout=60)
    resp.raise_for_status()
    new_version = resp.json()["version"]
    print(f"✅ Created {TOOLBOX_NAME} version {new_version}")

    # 2) Promote it to default (default_version MUST be a string).
    patch_url = f"{base}/toolboxes/{TOOLBOX_NAME}?api-version=v1"
    httpx.patch(patch_url, headers=headers,
                json={"default_version": str(new_version)}, timeout=60).raise_for_status()
    print(f"✅ Promoted version {new_version} to default")

6 / Tool Search - the headline feature

📄 Docs: Enable tool search in a toolbox

A real toolbox can hold dozens or hundreds of tools. Sending every tool definition to the model on every turn is slow, expensive, and hurts accuracy. Tool Search fixes this: instead of listing all tools, Foundry shows the model two meta-tools and lets it search for capability on demand.

Tool Search flow. The model starts each turn with a small flat tools/list containing two meta-tools (tool_search and call_tool) plus any pinned or auto-pinned tools. If the needed capability isn't already listed, the model calls tool_search(query, limit). Foundry ranks the full catalog by semantic match on tool name and description, plus additional_search_text keywords and a per-user auto-pin hot set, and returns only the matching tool definitions. The model then calls call_tool(name, args). Returned tools stay callable for the rest of the turn, and the model can search again for later steps.

How it works

  1. Enable it by adding a toolbox_search_preview tool to the version.
  2. tools/list now returns just tool_search and call_tool (+ any pinned tools).
  3. The model calls tool_search(query, limit?); Foundry ranks the catalog by semantic match on each tool's name + description and returns only the hits.
  4. The model invokes a returned tool via call_tool(name, args). Returned tools persist for the rest of the turn, and the model may search again for later steps.

Controlling the flexibility / control trade-off

Knob Effect
pin Tool is always in tools/list (skips search). Use for your 1-2 hottest tools.
additional_search_text Extra keywords that make a tool findable without bloating its user-facing description.
"*" wildcard A tool_configs entry keyed "*" sets defaults for every tool.
Auto-pinning After warmup, Foundry auto-pins each user's hot set - frequently-used tools appear without a search.
limit Cap results per tool_search call to keep the model focused.

Prompt tip. Tell the model in its system prompt: "You have a tool_search tool. Search for a capability before assuming it doesn't exist; you may search multiple times per turn." Without this nudge, weaker models sometimes give up instead of searching.

from azure.ai.projects.models import ToolboxSearchPreviewTool, ToolConfig

# Re-create the version as a SEARCH-FIRST toolbox: same tools + skills, plus the
# toolbox_search_preview meta-tool and a tool_configs map (values are ToolConfig).
tool_configs = {
    "web_search": ToolConfig(pin=True),   # always exposed - no search round-trip
    "*": ToolConfig(pin=False),           # wildcard default applied to every other tool
}
# Only add search keywords for azure_ai_search if it was actually added above.
if any(type(t).__name__ == "AzureAISearchTool" for t in tools):
    tool_configs["azure_ai_search"] = ToolConfig(
        additional_search_text="knowledge base, documentation, policy, grounding, RAG",
    )

search_tools = list(tools)
search_tools.append(ToolboxSearchPreviewTool(
    description="Search the toolbox catalog and call the matching tool.",
    tool_configs=tool_configs,
))

search_version = project.beta.toolboxes.create_version(
    name=TOOLBOX_NAME,
    tools=search_tools,
    skills=skills or None,
)
created_resources["versions"].append(search_version.version)
print(f"✅ Search-first version {search_version.version} - tools/list will now return tool_search + pinned only")

7 / Versioning

Versions are immutable - you never edit a version, you create a new one. A single version is the default, and the default is what the consumer endpoint serves. This gives you safe, atomic rollouts: build a new version, test it on its pinned per-version URL, then flip the default.

# List every version, newest first.
versions = list(project.beta.toolboxes.list_versions(name=TOOLBOX_NAME))
print("Versions:", [v.version for v in versions])

# Inspect a specific version.
detail = project.beta.toolboxes.get_version(name=TOOLBOX_NAME, version=search_version.version)
print(f"Version {detail.version}: {len(detail.tools)} tool(s)")

# Promote the search-first version to default - this is what consumers will get.
project.beta.toolboxes.update(name=TOOLBOX_NAME, default_version=search_version.version)
print(f"✅ Default is now {search_version.version}")

# (Optional) delete an old version once nothing references it.
# project.beta.toolboxes.delete_version(name=TOOLBOX_NAME, version="<old>")

8 / Policies & governance

"Governed by default" comes from three independent control points. Only the first is a field on the toolbox; the other two are standard Azure mechanisms you compose around it.

Three policy enforcement points for a toolbox. (1) Control plane: an Azure Policy authored separately by an admin is enforced at connection-creation time - creating a project connection to a banned endpoint or auth type is blocked before any toolbox references it. (2) Runtime gateway: a customer-owned Azure API Management instance sits in front of your MCP server enforcing rate-limit, IP, and header policies, and is registered as a normal MCP tool whose server_url is the APIM gateway URL. (3) Toolbox guardrail: an RAI policy named in policies.rai_config.rai_policy_name on the toolbox version screens tool inputs and outputs.

# Control point Where it lives Enforced when
1 Azure Policy A separate Azure Policy resource (admin-authored) Connection creation - a banned endpoint/auth is blocked before any toolbox can reference it.
2 APIM-fronted MCP Your Azure API Management, in front of your MCP server Call time - rate-limit / IP / header rules run in APIM; the toolbox just registers the APIM gateway URL as a normal MCP tool.
3 RAI guardrail policies.rai_config.rai_policy_name on a toolbox version Call time - screens tool inputs and outputs.

Only the RAI guardrail is a toolbox field. APIM is your own gateway that you point an MCP tool at; Azure Policy is authored separately by an admin and bites at connection- creation time, not when the toolbox is built.

from azure.ai.projects.models import ToolboxPolicies, RaiConfig

# (1) RAI guardrail - the only governance field ON the toolbox. Name an existing
#     RAI policy and Foundry screens tool inputs/outputs for that version.
RAI_POLICY_NAME = os.getenv("RAI_POLICY_NAME")
if RAI_POLICY_NAME:
    guarded = project.beta.toolboxes.create_version(
        name=TOOLBOX_NAME,
        tools=search_tools,
        skills=skills or None,
        policies=ToolboxPolicies(rai_config=RaiConfig(rai_policy_name=RAI_POLICY_NAME)),
    )
    created_resources["versions"].append(guarded.version)
    project.beta.toolboxes.update(name=TOOLBOX_NAME, default_version=guarded.version)
    print(f"✅ RAI-guarded version {guarded.version} is now default")
else:
    skip("RAI_POLICY_NAME not set - showing the shape only")
    print('policies=ToolboxPolicies(rai_config=RaiConfig(rai_policy_name="<your-rai-policy>"))')

# (2) APIM-fronted MCP - NOT a toolbox field. Stand up your MCP server behind Azure
#     API Management (rate-limit / IP / header policies live in APIM), then register the
#     APIM *gateway* URL as a normal MCP tool. Governance runs in APIM, outside the toolbox:
#
#     MCPTool(server_label="governed_mcp",
#             server_url="https://<apim-name>.azure-api.net/mcp",   # APIM gateway
#             project_connection_id="apim-mcp-conn")
#
# (3) Azure Policy - authored SEPARATELY by an admin as its own Azure Policy resource.
#     It is evaluated at CONNECTION-CREATION time: creating a project connection to a
#     banned endpoint or auth type is rejected, so a non-compliant tool can never be
#     added to any toolbox. Nothing to set here on the toolbox itself.

9 / Get the toolbox MCP endpoint

There are two MCP URLs. Use the developer URL to test a specific version in isolation; ship the consumer URL - it always serves the current default, so promoting a new version upgrades every consumer with no code change.

Audience URL Serves
Developer {project}/toolboxes/{name}/versions/{version}/mcp?api-version=v1 one pinned version
Consumer {project}/toolboxes/{name}/mcp?api-version=v1 the default version

Both require the bearer token (scope https://ai.azure.com/.default) and the Foundry-Features: Toolboxes=V1Preview header on every request.

_base = PROJECT_ENDPOINT.rstrip("/")

def consumer_mcp_url(name: str = TOOLBOX_NAME) -&gt; str:
    return f"{_base}/toolboxes/{name}/mcp?api-version=v1"

def developer_mcp_url(name: str, version: str) -&gt; str:
    return f"{_base}/toolboxes/{name}/versions/{version}/mcp?api-version=v1"

CONSUMER_URL = consumer_mcp_url()
print("Consumer  (default):", CONSUMER_URL)
print("Developer (pinned) :", developer_mcp_url(TOOLBOX_NAME, search_version.version))

10 / Verify over MCP

Let's talk to the live endpoint with a raw MCP client to prove Tool Search is in effect: tools/list should return only tool_search, call_tool, and any pinned tools - not the whole catalog. Then we run a tool_search -> call_tool round-trip and read each tool's _meta.tool_configuration (which carries require_approval).

We use the mcp SDK's streamable-HTTP client, passing the bearer token and the mandatory preview header. If a tool's connection uses oauth2, the first call returns CONSENT_REQUIRED (-32006/-32007) with a consent URL - we surface it so you can consent and retry.

from mcp.client.session import ClientSession
from mcp.client.streamable_http import streamablehttp_client


async def verify_toolbox():
    headers = {**TOOLBOX_HEADERS, "Authorization": f"Bearer {mcp_token()}"}
    async with streamablehttp_client(CONSUMER_URL, headers=headers) as (read, write, _):
        async with ClientSession(read, write) as session:
            await session.initialize()

            listed = await session.list_tools()
            names = [t.name for t in listed.tools]
            print("tools/list ->", names)
            assert "tool_search" in names, "Tool Search not active - is the default version search-first?"

            # Ask the meta-tool to find a capability.
            found = await session.call_tool("tool_search", {"query": "search the web for news", "limit": 3})
            print("\ntool_search ->", found.content[0].text[:400] if found.content else "(no text)")

            # Read approval config off a listed tool, if present.
            for t in listed.tools:
                cfg = (t.meta or {}).get("tool_configuration", {}) if hasattr(t, "meta") else {}
                if cfg:
                    print(f"  {t.name}.require_approval = {cfg.get('require_approval')}")


try:
    if not os.getenv("PROJECT_ENDPOINT"):
        skip("PROJECT_ENDPOINT not set")
    else:
        await verify_toolbox()
except Exception as exc:  # noqa: BLE001
    msg = str(exc)
    if "-32006" in msg or "-32007" in msg or "CONSENT_REQUIRED" in msg:
        print("⚠️  OAuth consent required - open the consent URL in the error, approve, then re-run this cell.")
    print(f"verify_toolbox raised: {exc}")

11 / Consume the toolbox

The whole point of one governed endpoint is that any MCP client can use it unchanged. Here are three: Microsoft Agent Framework, LangGraph, and the Copilot SDK. Each just needs the consumer URL, a bearer token, and the preview header.

# --- Microsoft Agent Framework -------------------------------------------------
# MAF speaks MCP natively via MCPStreamableHTTPTool. Point it at the consumer URL
# with the token + preview header and the agent can tool_search / call_tool.
from agent_framework import ChatAgent
from agent_framework.azure import AzureAIAgentClient
from agent_framework.tools import MCPStreamableHTTPTool

async def run_maf():
    toolbox_tool = MCPStreamableHTTPTool(
        name="foundry_toolbox",
        url=CONSUMER_URL,
        headers={**TOOLBOX_HEADERS, "Authorization": f"Bearer {mcp_token()}"},
    )
    agent = ChatAgent(
        chat_client=AzureAIAgentClient(project_client=project, model=MODEL_DEPLOYMENT),
        instructions=(
            "You have a tool_search tool. Search for a capability before assuming it is "
            "unavailable; you may search multiple times per turn."
        ),
        tools=[toolbox_tool],
    )
    reply = await agent.run("Find the latest docs on toolboxes in Microsoft Foundry and summarize tool search.")
    print(reply.text)

if os.getenv("PROJECT_ENDPOINT"):
    await run_maf()
else:
    skip("PROJECT_ENDPOINT not set")
# --- LangGraph -----------------------------------------------------------------
# langchain-azure-ai ships AzureAIProjectToolbox, which loads the toolbox as a set
# of LangChain tools (Tool Search included) ready for a prebuilt ReAct agent.
from langchain_azure_ai.tools import AzureAIProjectToolbox
from langchain_azure_ai.chat_models import AzureAIChatCompletionsModel
from langgraph.prebuilt import create_react_agent

async def run_langgraph():
    toolbox = AzureAIProjectToolbox(
        project_client=project,
        toolbox_name=TOOLBOX_NAME,   # resolves the default version's MCP endpoint
    )
    lc_tools = await toolbox.aget_tools()
    llm = AzureAIChatCompletionsModel(project_client=project, model=MODEL_DEPLOYMENT)
    agent = create_react_agent(llm, lc_tools)
    result = await agent.ainvoke(
        {"messages": [("user", "Search the toolbox and use web search to get today's AI news.")]}
    )
    print(result["messages"][-1].content)

if os.getenv("PROJECT_ENDPOINT"):
    await run_langgraph()
else:
    skip("PROJECT_ENDPOINT not set")
# --- Copilot SDK ---------------------------------------------------------------
# The Copilot SDK consumes the toolbox as an MCP server. Note: it requires tool
# names without dots, so map any "server.tool" names to "server_tool".
if not os.getenv("PROJECT_ENDPOINT"):
    skip("PROJECT_ENDPOINT not set")
else:
    try:
        from copilot.sdk import CopilotClient            # package name per your SDK build
        from copilot.sdk.mcp import MCPServerConfig

        server = MCPServerConfig(
            name="foundry_toolbox",
            url=CONSUMER_URL,
            headers={**TOOLBOX_HEADERS, "Authorization": f"Bearer {mcp_token()}"},
            name_transform=lambda n: n.replace(".", "_"),  # dots -> underscores
        )
        client = CopilotClient(mcp_servers=[server])
        print("✅ Copilot SDK client wired to the toolbox:", CONSUMER_URL)
    except Exception as exc:  # noqa: BLE001
        print(f"(Copilot SDK not installed in this env) {exc}")

12 / Host MAF / LangGraph as a Foundry hosted agent

The MAF and LangGraph agents above run locally. The same agent code can run as a Foundry hosted agent - Foundry manages the runtime, scaling, and (critically) the agent identity that flows through the toolbox connections.

Wrap the agent in a ResponsesAgentServerHost and the hosted runtime serves it at a Responses endpoint. Because the toolbox is referenced by its consumer URL, nothing about the tool wiring changes between local and hosted - only the identity the connections see (the agent's managed identity instead of your az login).

# Sketch - the same MAF agent, packaged for the Foundry hosted runtime.
# Deploy with: azd ai agent create / publish (see the hosted-agents recipe).
#
# from agent_framework.hosting import ResponsesAgentServerHost
#
# def build_agent():
#     toolbox_tool = MCPStreamableHTTPTool(
#         name="foundry_toolbox",
#         url=CONSUMER_URL,                       # same consumer URL as local
#         headers=TOOLBOX_HEADERS,                # token injected by the runtime
#     )
#     return ChatAgent(
#         chat_client=AzureAIAgentClient(project_client=project, model=MODEL_DEPLOYMENT),
#         instructions="...",
#         tools=[toolbox_tool],
#     )
#
# host = ResponsesAgentServerHost(agent_factory=build_agent)
# host.run()   # served by Foundry; connections now see the AGENT identity
print("Hosted-agent contract: same consumer URL, identity supplied by the runtime.")

13 / Clean up

Best-effort teardown of what this notebook created - toolbox versions, then the toolbox itself. Connections are left in place (they're often shared); delete throwaways in the Foundry portal or with azd ai connection delete <name>.

if not created_resources["toolbox"]:
    skip("nothing was created")
else:
    name = created_resources["toolbox"]
    for v in reversed(created_resources["versions"]):
        try:
            project.beta.toolboxes.delete_version(name=name, version=v)
            print(f"🗑️  Deleted version {v}")
        except Exception as exc:  # noqa: BLE001
            print(f"(skip) version {v}: {exc}")
    try:
        project.beta.toolboxes.delete(name=name)
        print(f"🗑️  Deleted toolbox {name}")
    except Exception as exc:  # noqa: BLE001
        print(f"(skip) toolbox {name}: {exc}")

14 / Next steps

You built a toolbox in Microsoft Foundry end-to-end: every tool type, identity, Tool Search, versioning, governance, and four consumers. From here:

  • Tune Tool Search - measure how often the model searches vs. uses pinned tools, then adjust pin / additional_search_text and your system prompt.
  • Lock down identity - move shared custom-keys connections to agentic-identity or user-entra-token so each agent/user is least-privilege.
  • Add an RAI policy - author one in the portal and set rai_config.rai_policy_name.
  • Ship declaratively - commit the my-toolbox.json manifest and wire the REST POST /toolboxes/{name}/versions + PATCH (promote default) calls into CI.
  • Go hosted - package the MAF or LangGraph agent as a Foundry hosted agent so connections run under a managed agent identity.

Reference docs

Tags

toolbox tools mcp agents agent-service hosted-agents agent-framework langgraph governance identity