Python: Show more authentication methods in Foundry Toolbox MCP (#5719)

* Show more authentication methods in Foundry Toolbox MCP

* Remove hardcoded toolbox version num

* Add Foundry MCP OAuth consent handling

* Use message instead of the dedicated item type

* Go back to using OAuthConsentRequestOutputItem

* WIP: sample testing

* Update error code

* Address review on Foundry Toolbox MCP samples

Reviewed feedback addressed:

- Drop the branch-pinned `git+https://...@feature/...` entries from
  `04_foundry_toolbox/requirements.txt`; restore the simple comment + `mcp`
  runtime dep. The git pins were only useful while iterating on the PR and
  shouldn't ship. (eavanvalkenburg)

- Fix the `/toolsets/` typo in both `04_foundry_toolbox/README.md` and
  `06_files/README.md`. Verified empirically against the
  research_toolbox in the test workspace: the toolbox MCP gateway lives at
  `/toolboxes/{name}/mcp?api-version=v1` and requires the
  `Foundry-Features: Toolboxes=V1Preview` header. `/toolsets/{name}/mcp`
  returns 403 with `preview_feature_required: Toolsets=V1Preview` (a
  different opt-in feature).

- Wrap `httpx.AsyncClient(...)` in `async with ... as http_client:` in both
  samples so the connection pool is cleaned up. (Copilot reviewer)

- Make the `TOOLBOX_NAME` env var consistent in both samples. Previously the
  tool name silently fell back to `"toolbox"` when `TOOLBOX_NAME` was unset,
  but `resolve_toolbox_endpoint()` still required `TOOLBOX_NAME` and would
  raise `KeyError`. The samples now resolve the endpoint once and derive the
  tool name from the resolved URL when `TOOLBOX_NAME` isn't set, so the
  local tool name always matches the upstream toolbox identity regardless
  of which env var the user set. (Copilot reviewer)

- Rename `_responses.is_consent_error` to `consent_url_from_error`: the
  helper returns `str | None` (the consent URL), not a bool, so the new
  name matches behavior. Update the test class accordingly. (eavanvalkenburg)

- Tighten `_handle_inner_agent`'s lazy-entry catch from `Exception` to
  `AgentFrameworkException`, the type the MCP layer actually wraps consent
  errors in via `MCPStreamableHTTPTool.__aenter__` →
  `ToolExecutionException(inner_exception=mcp_error)`. Network failures,
  cancellations, and other non-framework exceptions now propagate normally
  instead of being briefly caught and re-raised. The test helper
  `_make_consent_error` is updated to use `ToolExecutionException` so it
  matches the real-world wrapping. (eavanvalkenburg)

- Clarify the `github_pat` description in `agent.manifest.yaml` to note
  it's only needed when the PAT-based connection (`github-mcp-pat-conn`)
  is chosen; users selecting the OAuth2 connection (`github-mcp-oauth-conn`)
  can leave it empty. (Copilot reviewer)

Validation: ran both samples end-to-end against a real Foundry toolbox
(`research_toolbox`) -- the samples connect successfully and the agent
lists the toolbox's MCP tools (`api_specs___fetch_azure_rest_api_docs`,
etc.). `uv run poe test -P foundry_hosting` passes (119 tests), pyright +
mypy clean.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* docs: fix broken Foundry samples link in 04_foundry_toolbox README

The previous URL pointed to an old location of the toolbox supported-scenarios
doc; the doc moved to /samples/python/hosted-agents/SUPPORTED_TOOLBOX_SCENARIOS.md
and the old /samples/python/toolbox/azd path now 404s.

Caught by the markdown-link-check CI step.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Eduard van Valkenburg <eavanvalkenburg@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Tao Chen
2026-05-20 05:00:38 -07:00
committed by GitHub
Unverified
parent 72a6157c6a
commit d74d26c917
9 changed files with 537 additions and 127 deletions
@@ -12,6 +12,7 @@ import threading
from collections.abc import AsyncIterable, AsyncIterator, Generator, Mapping, Sequence
from contextlib import suppress
from pathlib import Path
from contextlib import AbstractAsyncContextManager, AsyncExitStack, suppress
from typing import Protocol, cast
from agent_framework import (
@@ -25,12 +26,14 @@ from agent_framework import (
SupportsAgentRun,
WorkflowAgent,
)
from agent_framework.exceptions import AgentFrameworkException
from azure.ai.agentserver.responses import (
ResponseContext,
ResponseEventStream,
ResponseProviderProtocol,
ResponsesServerOptions,
)
from azure.ai.agentserver.responses._id_generator import IdGenerator
from azure.ai.agentserver.responses.hosting import ResponsesAgentServerHost
from azure.ai.agentserver.responses.models import (
ApplyPatchToolCallItemParam,
@@ -108,11 +111,13 @@ from azure.ai.agentserver.responses.streaming._builders import (
ReasoningSummaryPartBuilder,
TextContentBuilder,
)
from mcp import McpError
from typing_extensions import Any
logger = logging.getLogger(__name__)
# region Approval Storage
class ApprovalStorage(Protocol):
"""Storage for saving function approval requests."""
@@ -247,6 +252,39 @@ def _checkpoint_storage_for_context(root: str, context_id: str) -> FileCheckpoin
return FileCheckpointStorage(storage_path)
# endregion Approval Storage
# Foundry Toolbox Auth integration
# Consent-URL error code returned by the Foundry MCP gateway when calling `/list`
CONSENT_ERROR_CODE = -32007
def consent_url_from_error(exc: BaseException) -> str | None:
"""Return the consent URL when ``exc`` wraps a Foundry MCP gateway consent error.
The Agent Framework MCP layer surfaces gateway consent failures by wrapping the underlying
``McpError`` inside an :class:`AgentFrameworkException` (typically a ``ToolExecutionException``
raised from ``MCPStreamableHTTPTool.__aenter__``). This helper inspects ``exc.args`` for a
wrapped ``McpError`` whose ``error.code`` is :data:`CONSENT_ERROR_CODE`; when found, the
consent link the gateway returned in ``error.message`` is returned. Returns ``None`` for
anything else, so callers can do ``if (url := consent_url_from_error(ex)) is None: raise``.
Args:
exc: The exception to inspect.
Returns:
The consent URL if ``exc`` wraps a consent ``McpError``, otherwise ``None``.
"""
inner_exception = next((arg for arg in exc.args if isinstance(arg, McpError)), None)
if inner_exception is not None and inner_exception.error.code == CONSENT_ERROR_CODE:
return inner_exception.error.message
return None
# endregion Foundry Toolbox Auth integration
# region ResponsesHostServer
class ResponsesHostServer(ResponsesAgentServerHost):
"""A responses server host for an agent."""
@@ -315,8 +353,43 @@ class ResponsesHostServer(ResponsesAgentServerHost):
if self.config.is_hosted
else InMemoryFunctionApprovalStorage()
)
# Lazy agent lifecycle: the agent (and any MCP tools it owns) is entered on
# the first request rather than at server startup, so that authentication
# failures during MCP connect can be surfaced to the client as an
# `oauth_consent_request` stream event instead of crashing the server.
self._agent_stack: AsyncExitStack | None = None
self._agent_init_lock = asyncio.Lock()
self.shutdown_handler(self._cleanup_agent) # pyright: ignore[reportUnknownMemberType]
self.response_handler(self._handle_response) # pyright: ignore[reportUnknownMemberType]
async def _ensure_agent_ready(self) -> None:
"""Lazily enter the agent's async context exactly once.
On failure the partial exit stack is closed and ``_agent_stack`` is left
as ``None`` so a subsequent request (e.g. after the user completes OAuth
consent) can retry the connection.
"""
if self._agent_stack is not None:
return
async with self._agent_init_lock:
if self._agent_stack is not None:
return
stack = AsyncExitStack()
try:
if isinstance(self._agent, AbstractAsyncContextManager):
await stack.enter_async_context(self._agent)
except BaseException:
await stack.aclose()
raise
self._agent_stack = stack
async def _cleanup_agent(self) -> None:
"""Close the agent's async context. Registered as the server shutdown handler."""
stack = self._agent_stack
if stack is not None:
self._agent_stack = None
await stack.aclose()
async def _handle_response(
self,
request: CreateResponse,
@@ -359,45 +432,76 @@ class ResponsesHostServer(ResponsesAgentServerHost):
else:
run_kwargs["options"] = chat_options
if not is_streaming_request:
# Run the agent in non-streaming mode
response = await self._agent.run(stream=False, **run_kwargs) # type: ignore[reportUnknownMemberType]
for message in response.messages:
for content in message.contents:
async for item in _to_outputs(
response_event_stream,
content,
approval_storage=self._approval_storage,
):
yield item
# Lazy-enter the agent (and any MCP tools it owns). The MCP client wraps gateway
# consent failures (and other connection-time errors) in AgentFrameworkException; if
# one of those is a consent error we surface the consent link to the client through
# the already-opened response stream instead of crashing the request. Other exception
# types propagate normally so the host can handle / log them.
try:
await self._ensure_agent_ready()
except AgentFrameworkException as ex:
consent_url = consent_url_from_error(ex)
if consent_url is None:
raise
logger.warning("OAuth consent required for Foundry MCP gateway.")
oauth_item = OAuthConsentRequestOutputItem(
id=IdGenerator.new_id("oacr"),
consent_link=consent_url,
server_label="Foundry Toolbox",
)
builder = response_event_stream.add_output_item(oauth_item.id)
yield builder.emit_added(oauth_item)
yield builder.emit_done(oauth_item)
yield response_event_stream.emit_completed()
return
# Track the current active output item builder for streaming;
# lazily created on matching content, closed when a different type arrives.
tracker = _OutputItemTracker(response_event_stream)
tracker: _OutputItemTracker | None = _OutputItemTracker(response_event_stream) if is_streaming_request else None
# Run the agent in streaming mode
async for update in self._agent.run(stream=True, **run_kwargs): # type: ignore[reportUnknownMemberType]
for content in update.contents:
for event in tracker.handle(content):
try:
if not is_streaming_request:
# Run the agent in non-streaming mode
response = await self._agent.run(stream=False, **run_kwargs) # type: ignore[reportUnknownMemberType]
for message in response.messages:
for content in message.contents:
async for item in _to_outputs(
response_event_stream,
content,
approval_storage=self._approval_storage,
):
yield item
yield response_event_stream.emit_completed()
else:
if tracker is None: # pragma: no cover - defensive, set above
raise RuntimeError("Streaming tracker was not initialized.")
# Run the agent in streaming mode
async for update in self._agent.run(stream=True, **run_kwargs): # type: ignore[reportUnknownMemberType]
for content in update.contents:
for event in tracker.handle(content):
yield event
if tracker.needs_async:
async for item in _to_outputs(
response_event_stream,
content,
approval_storage=self._approval_storage,
):
yield item
tracker.needs_async = False
# Close any remaining active builder
for event in tracker.close():
yield event
if tracker.needs_async:
async for item in _to_outputs(
response_event_stream,
content,
approval_storage=self._approval_storage,
):
yield item
tracker.needs_async = False
# Close any remaining active builder
for event in tracker.close():
yield event
yield response_event_stream.emit_completed()
yield response_event_stream.emit_completed()
except Exception:
# Drain any in-progress streaming builder before emitting consent
# so the resulting stream stays well-formed.
if tracker is not None:
for event in tracker.close():
yield event
yield response_event_stream.emit_completed()
raise
async def _handle_inner_workflow(
self,
@@ -429,6 +533,11 @@ class ResponsesHostServer(ResponsesAgentServerHost):
if not isinstance(self._agent, WorkflowAgent):
raise RuntimeError("Agent is not a workflow agent.")
# Workflow agents are not async context managers in any built-in path,
# but call _ensure_agent_ready for symmetry with the regular path so
# any future async resources owned by the workflow are entered here.
await self._ensure_agent_ready()
# Determine the latest checkpoint (if any) so we can resume the
# workflow's prior state for this turn. The directory is keyed by
# the inbound context id (conversation_id when set, otherwise
@@ -551,6 +660,8 @@ class ResponsesHostServer(ResponsesAgentServerHost):
await checkpoint_storage.delete(checkpoint.checkpoint_id)
# endregion ResponsesHostServer
# region Active Builder State
@@ -27,14 +27,18 @@ from agent_framework import (
ResponseStream,
)
from azure.ai.agentserver.responses import InMemoryResponseProvider
from mcp import McpError
from mcp.types import ErrorData
from typing_extensions import Any
from agent_framework_foundry_hosting import ResponsesHostServer
from agent_framework_foundry_hosting._responses import (
CONSENT_ERROR_CODE,
FileBasedFunctionApprovalStorage, # pyright: ignore[reportPrivateUsage]
InMemoryFunctionApprovalStorage, # pyright: ignore[reportPrivateUsage]
_item_to_message, # pyright: ignore[reportPrivateUsage]
_output_item_to_message, # pyright: ignore[reportPrivateUsage]
consent_url_from_error,
)
@@ -2888,6 +2892,187 @@ class TestCheckpointContextPathValidation:
f"before={before} after={after}"
)
assert list(root.iterdir()) == [], f"Checkpoint directory created inside root for {context_field}={bad_id!r}"
# region Agent lifecycle (lazy entry & OAuth consent surfacing)
def _make_consent_error(url: str = "https://consent.example.com/auth") -> Exception:
"""Build an exception wrapping a Foundry MCP gateway consent error.
Mirrors the real-world wrapping produced by ``MCPStreamableHTTPTool.__aenter__``,
which catches connection-time ``McpError``s and re-raises them as a
``ToolExecutionException`` (an ``AgentFrameworkException`` subclass) with the
original error attached via ``inner_exception``. ``consent_url_from_error``
then finds the wrapped ``McpError`` in ``exc.args``.
"""
from agent_framework.exceptions import ToolExecutionException
inner = McpError(ErrorData(code=CONSENT_ERROR_CODE, message=url))
return ToolExecutionException("MCP consent required", inner_exception=inner)
class TestConsentUrlFromError:
def test_returns_consent_url_when_inner_arg_is_consent_mcp_error(self) -> None:
exc = _make_consent_error("https://example.com/consent")
assert consent_url_from_error(exc) == "https://example.com/consent"
def test_returns_none_when_no_mcp_error_in_args(self) -> None:
assert consent_url_from_error(Exception("boom")) is None
def test_returns_none_when_mcp_error_has_different_code(self) -> None:
inner = McpError(ErrorData(code=-32000, message="some other error"))
exc = Exception("wrapped", inner)
assert consent_url_from_error(exc) is None
def test_returns_none_for_bare_mcp_error_without_wrapping(self) -> None:
# `args` of a bare McpError holds the message string, not an McpError
# instance, so it does not match the wrapping pattern produced by the
# MCP client when it bubbles consent errors up.
bare = McpError(ErrorData(code=CONSENT_ERROR_CODE, message="https://x"))
assert consent_url_from_error(bare) is None
class TestAgentLifecycle:
async def test_agent_entered_lazily_on_first_request(self) -> None:
agent = _make_agent(
response=AgentResponse(messages=[Message(role="assistant", contents=[Content.from_text("hi")])])
)
server = _make_server(agent)
# Construction must not enter the agent.
assert agent.__aenter__.await_count == 0
await _post(server, input_text="hello", stream=False)
assert agent.__aenter__.await_count == 1
async def test_agent_entered_only_once_across_requests(self) -> None:
agent = _make_agent(
response=AgentResponse(messages=[Message(role="assistant", contents=[Content.from_text("hi")])])
)
server = _make_server(agent)
await _post(server, input_text="first", stream=False)
await _post(server, input_text="second", stream=False)
await _post(server, input_text="third", stream=False)
assert agent.__aenter__.await_count == 1
async def test_cleanup_exits_agent_and_allows_reentry(self) -> None:
agent = _make_agent(
response=AgentResponse(messages=[Message(role="assistant", contents=[Content.from_text("hi")])])
)
server = _make_server(agent)
await _post(server, input_text="hello", stream=False)
assert agent.__aenter__.await_count == 1
assert agent.__aexit__.await_count == 0
await server._cleanup_agent() # pyright: ignore[reportPrivateUsage]
assert agent.__aexit__.await_count == 1
# Cleanup is idempotent.
await server._cleanup_agent() # pyright: ignore[reportPrivateUsage]
assert agent.__aexit__.await_count == 1
# After cleanup, a follow-up request re-enters the agent.
await _post(server, input_text="again", stream=False)
assert agent.__aenter__.await_count == 2
async def test_failed_entry_does_not_cache_stack(self) -> None:
agent = _make_agent(
response=AgentResponse(messages=[Message(role="assistant", contents=[Content.from_text("hi")])])
)
agent.__aenter__.side_effect = [_make_consent_error(), None]
server = _make_server(agent)
await _post(server, input_text="first", stream=False)
# Failed entry must leave the stack empty so the next request retries.
await _post(server, input_text="second", stream=False)
assert agent.__aenter__.await_count == 2
class TestOAuthConsentSurfacing:
async def test_non_streaming_consent_error_emits_oauth_output_item(self) -> None:
agent = _make_agent(
response=AgentResponse(messages=[Message(role="assistant", contents=[Content.from_text("hi")])])
)
agent.__aenter__.side_effect = _make_consent_error("https://consent.example.com/auth")
server = _make_server(agent)
resp = await _post(server, input_text="hello", stream=False)
assert resp.status_code == 200
body = resp.json()
assert body["status"] == "completed"
oauth_items = [it for it in body["output"] if it["type"] == "oauth_consent_request"]
assert len(oauth_items) == 1
assert oauth_items[0]["consent_link"] == "https://consent.example.com/auth"
assert oauth_items[0]["server_label"] == "Foundry Toolbox"
# The agent must not be run when entry fails.
agent.run.assert_not_called()
async def test_streaming_consent_error_emits_oauth_output_item(self) -> None:
agent = _make_agent(stream_updates=[AgentResponseUpdate(contents=[Content.from_text("hi")], role="assistant")])
agent.__aenter__.side_effect = _make_consent_error("https://consent.example.com/auth")
server = _make_server(agent)
resp = await _post(server, input_text="hello", stream=True)
assert resp.status_code == 200
events = _parse_sse_events(resp.text)
types = _sse_event_types(events)
assert types[0] == "response.created"
assert types[1] == "response.in_progress"
assert types[-1] == "response.completed"
added = [e for e in events if e["event"] == "response.output_item.added"]
oauth_added = [e for e in added if e["data"]["item"]["type"] == "oauth_consent_request"]
assert len(oauth_added) == 1
assert oauth_added[0]["data"]["item"]["consent_link"] == "https://consent.example.com/auth"
assert oauth_added[0]["data"]["item"]["server_label"] == "Foundry Toolbox"
done = [e for e in events if e["event"] == "response.output_item.done"]
assert any(e["data"]["item"]["type"] == "oauth_consent_request" for e in done)
agent.run.assert_not_called()
async def test_non_consent_error_during_entry_propagates(self) -> None:
agent = _make_agent(
response=AgentResponse(messages=[Message(role="assistant", contents=[Content.from_text("hi")])])
)
agent.__aenter__.side_effect = RuntimeError("boom")
server = _make_server(agent)
resp = await _post(server, input_text="hello", stream=False)
# Non-consent errors are not swallowed: the response is marked failed
# and no `oauth_consent_request` item is emitted.
assert resp.status_code == 200
body = resp.json()
assert body["status"] == "failed"
assert not any(it["type"] == "oauth_consent_request" for it in body.get("output", []))
agent.run.assert_not_called()
async def test_retry_after_consent_succeeds(self) -> None:
agent = _make_agent(
response=AgentResponse(messages=[Message(role="assistant", contents=[Content.from_text("hello!")])])
)
agent.__aenter__.side_effect = [_make_consent_error("https://consent.example.com/auth"), None]
server = _make_server(agent)
# First request surfaces consent; agent.run is not called.
resp1 = await _post(server, input_text="first", stream=False)
assert resp1.status_code == 200
body1 = resp1.json()
oauth = [it for it in body1["output"] if it["type"] == "oauth_consent_request"]
assert len(oauth) == 1
agent.run.assert_not_called()
# After the user authenticates, the next request enters successfully.
resp2 = await _post(server, input_text="second", stream=False)
assert resp2.status_code == 200
body2 = resp2.json()
assert body2["status"] == "completed"
assert any(it["type"] == "message" for it in body2["output"])
assert agent.__aenter__.await_count == 2
agent.run.assert_awaited_once()
# endregion
@@ -1,5 +1,7 @@
FROM python:3.12-slim
RUN apt-get update && apt-get install -y git && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY . user_agent/
@@ -10,6 +10,20 @@ You can also create a Foundry Toolbox in the Foundry portal. Read more about it
> 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).
### 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.
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
### Model Integration
@@ -31,20 +45,20 @@ An extra environment variable must be set to point to the toolbox MCP endpoint.
**Option A Set `FOUNDRY_TOOLBOX_ENDPOINT` directly** (recommended for local development):
```bash
export FOUNDRY_TOOLBOX_ENDPOINT="https://<account>.services.ai.azure.com/api/projects/<project>/toolsets/<name>/mcp?api-version=v1"
export FOUNDRY_TOOLBOX_ENDPOINT="https://<account>.services.ai.azure.com/api/projects/<project>/toolboxes/<name>/mcp?api-version=v1"
```
Or in PowerShell:
```powershell
$env:FOUNDRY_TOOLBOX_ENDPOINT="https://<account>.services.ai.azure.com/api/projects/<project>/toolsets/<name>/mcp?api-version=v1"
$env:FOUNDRY_TOOLBOX_ENDPOINT="https://<account>.services.ai.azure.com/api/projects/<project>/toolboxes/<name>/mcp?api-version=v1"
```
**Option B Set `TOOLBOX_NAME`** (used automatically by the Foundry hosting scaffolding after `azd provision`):
The agent derives the endpoint at runtime as:
```
{FOUNDRY_PROJECT_ENDPOINT}/toolsets/{TOOLBOX_NAME}/mcp?api-version=v1
{FOUNDRY_PROJECT_ENDPOINT}/toolboxes/{TOOLBOX_NAME}/mcp?api-version=v1
```
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).
@@ -18,16 +18,92 @@ template:
- name: AZURE_AI_MODEL_DEPLOYMENT_NAME
value: "{{AZURE_AI_MODEL_DEPLOYMENT_NAME}}"
- name: TOOLBOX_NAME
value: "agent-tools"
value: "agent-tools-2"
# parameters:
# properties:
# - name: mcp_endpoint
# # `azd ai agent init -m` will prompt for this value when initializing the agent manifest
# secret: false
# description: URL of the public MCP server (e.g. https://gitmcp.io/Azure/azure-rest-api-specs) that does not require authentication
# - name: github_pat
# # `azd ai agent init -m` will prompt for this value when initializing the agent manifest.
# # Only needed when the GitHub MCP connection is configured to use the `github-mcp-pat-conn`
# # PAT-based connection below; if you use the `github-mcp-oauth-conn` OAuth2 connection
# # instead, you can leave this empty.
# secret: true
# description: GitHub Personal Access Token used to authenticate with the GitHub MCP server (only needed when using the PAT connection; press Enter if using OAuth2 instead)
# - name: language_mcp_entra_audience
# secret: false
# description: Entra ID audience for the Azure Language MCP server (e.g. https://cognitiveservices.azure.com/)
# - name: language_mcp_target_url
# secret: false
# description: URL of the Azure Language MCP server that accepts agent identity tokens (e.g. https://{foundry-resource-name}.cognitiveservices.azure.com/language/mcp?api-version=2025-11-15-preview)
# - name: outlook_mail_entra_audience
# secret: false
# description: Entra ID audience for the Outlook Mail MCP server
# - name: outlook_mail_entra_mcp_target
# secret: false
# description: URL of the Outlook Mail MCP server that accepts user Entra tokens
resources:
- kind: model
id: gpt-4.1-mini
name: AZURE_AI_MODEL_DEPLOYMENT_NAME
- kind: toolbox
name: agent-tools
tools:
- type: web_search
name: web_search
- type: code_interpreter
name: code_interpreter
# - 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"
@@ -3,12 +3,11 @@
import asyncio
import os
from collections.abc import Callable
from typing import Any
import httpx
from agent_framework import Agent, MCPStreamableHTTPTool
from agent_framework.foundry import FoundryChatClient
from agent_framework_foundry_hosting import ResponsesHostServer
from azure.core.credentials import TokenCredential
from azure.identity import DefaultAzureCredential, get_bearer_token_provider
from dotenv import load_dotenv
@@ -16,7 +15,7 @@ from dotenv import load_dotenv
load_dotenv()
def _resolve_toolbox_endpoint() -> str:
def resolve_toolbox_endpoint() -> str:
"""Resolve the toolbox MCP endpoint URL.
Prefers the explicit ``FOUNDRY_TOOLBOX_ENDPOINT`` env var; falls back to
@@ -29,47 +28,61 @@ def _resolve_toolbox_endpoint() -> str:
return endpoint
project_endpoint = os.environ["FOUNDRY_PROJECT_ENDPOINT"].rstrip("/")
toolbox_name = os.environ["TOOLBOX_NAME"]
return f"{project_endpoint}/toolsets/{toolbox_name}/mcp?api-version=v1"
return f"{project_endpoint}/toolboxes/{toolbox_name}/mcp?api-version=v1"
def make_toolbox_header_provider(credential: TokenCredential) -> Callable[[dict[str, Any]], dict[str, str]]:
"""Build a header_provider that injects a fresh Azure AI bearer token on every MCP request."""
get_token = get_bearer_token_provider(credential, "https://ai.azure.com/.default")
class ToolboxAuth(httpx.Auth):
"""Injects a fresh bearer token on every request."""
def provide(_kwargs: dict[str, Any]) -> dict[str, str]:
return {
"Authorization": f"Bearer {get_token()}",
}
def __init__(self, token_provider: Callable[[], str]):
self._get_token = token_provider
return provide
def auth_flow(self, request: httpx.Request):
request.headers["Authorization"] = f"Bearer {self._get_token()}"
yield request
async def main():
credential = DefaultAzureCredential()
client = FoundryChatClient(
project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
model=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"],
credential=credential,
)
# Create the toolbox
token_provider = get_bearer_token_provider(credential, "https://ai.azure.com/.default")
toolbox_tool = MCPStreamableHTTPTool(
name="foundry_toolbox",
description="Tools exposed by the configured Foundry toolbox",
url=_resolve_toolbox_endpoint(),
header_provider=make_toolbox_header_provider(credential),
load_prompts=False,
)
# 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.
toolbox_endpoint = resolve_toolbox_endpoint()
toolbox_name = os.environ.get("TOOLBOX_NAME") or toolbox_endpoint.rsplit("/mcp", 1)[0].rsplit("/", 1)[-1]
async with httpx.AsyncClient(
auth=ToolboxAuth(token_provider),
headers={"Foundry-Features": "Toolboxes=V1Preview"},
timeout=120.0,
) as http_client:
toolbox = MCPStreamableHTTPTool(
name=toolbox_name,
url=toolbox_endpoint,
http_client=http_client,
load_prompts=False,
)
# Create the chat client
client = FoundryChatClient(
project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
model=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"],
credential=credential,
)
agent = Agent(
client=client,
instructions="You are a friendly assistant. Keep your answers brief.",
tools=toolbox,
# History will be managed by the hosting infrastructure, thus there
# is no need to store history by the service. Learn more at:
# https://developers.openai.com/api/reference/resources/responses/methods/create
default_options={"store": False},
)
async with Agent(
client=client,
instructions="You are a friendly assistant. Keep your answers brief.",
tools=toolbox_tool,
# History will be managed by the hosting infrastructure, thus there
# is no need to store history by the service. Learn more at:
# https://developers.openai.com/api/reference/resources/responses/methods/create
default_options={"store": False},
) as agent:
server = ResponsesHostServer(agent)
await server.run_async()
@@ -1,2 +1,4 @@
agent-framework
agent-framework-foundry-hosting
# agent-framework
# agent-framework-foundry-hosting
mcp>=1.24.0,<2
@@ -21,9 +21,10 @@ This agent uses four tools:
1. **Get Current Working Directory Tool (`get_cwd`)** Returns the current working directory of the agent host process.
2. **List Files Tool (`list_files`)** Lists the files in a specified directory.
3. **Read File Tool (`read_file`)** Reads the contents of a specified file.
4. **Code Interpreter Tool (`code_interpreter`)** Allows the agent to execute Python code in a safe.
4. **Code Interpreter Tool (`code_interpreter`)** Allows the agent to execute Python code in a safe sandboxed environment.
5. **Web Search Tool (`web_search`)** Allows the agent to perform web searches using the Bing Search API.
> In this sample, the filesystem tools are function tools defined in Python using the `@tool` decorator from the Agent Framework. The code interpreter tool is a managed tool provided by [Foundry Toolbox](https://learn.microsoft.com/en-us/azure/foundry/agents/how-to/tools/toolbox). Learn more about foundry toolbox integration with hosted agents with this [sample](../04_foundry_toolbox/).
> In this sample, the filesystem tools are function tools defined in Python using the `@tool` decorator from the Agent Framework. The code interpreter tool and web search tool are managed tools provided by [Foundry Toolbox](https://learn.microsoft.com/en-us/azure/foundry/agents/how-to/tools/toolbox). Learn more about foundry toolbox integration with hosted agents with this [sample](../04_foundry_toolbox/).
## Running the Agent Host
@@ -34,20 +35,20 @@ An extra environment variable must be set to point to the toolbox MCP endpoint.
**Option A Set `FOUNDRY_TOOLBOX_ENDPOINT` directly** (recommended for local development):
```bash
export FOUNDRY_TOOLBOX_ENDPOINT="https://<account>.services.ai.azure.com/api/projects/<project>/toolsets/<name>/mcp?api-version=v1"
export FOUNDRY_TOOLBOX_ENDPOINT="https://<account>.services.ai.azure.com/api/projects/<project>/toolboxes/<name>/mcp?api-version=v1"
```
Or in PowerShell:
```powershell
$env:FOUNDRY_TOOLBOX_ENDPOINT="https://<account>.services.ai.azure.com/api/projects/<project>/toolsets/<name>/mcp?api-version=v1"
$env:FOUNDRY_TOOLBOX_ENDPOINT="https://<account>.services.ai.azure.com/api/projects/<project>/toolboxes/<name>/mcp?api-version=v1"
```
**Option B Set `TOOLBOX_NAME`** (used automatically by the Foundry hosting scaffolding after `azd provision`):
The agent derives the endpoint at runtime as:
```
{FOUNDRY_PROJECT_ENDPOINT}/toolsets/{TOOLBOX_NAME}/mcp?api-version=v1
{FOUNDRY_PROJECT_ENDPOINT}/toolboxes/{TOOLBOX_NAME}/mcp?api-version=v1
```
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).
@@ -3,12 +3,11 @@
import asyncio
import os
from collections.abc import Callable
from typing import Any
import httpx
from agent_framework import Agent, MCPStreamableHTTPTool, tool
from agent_framework.foundry import FoundryChatClient
from agent_framework_foundry_hosting import ResponsesHostServer
from azure.core.credentials import TokenCredential
from azure.identity import DefaultAzureCredential, get_bearer_token_provider
from dotenv import load_dotenv
@@ -16,7 +15,7 @@ from dotenv import load_dotenv
load_dotenv()
def _resolve_toolbox_endpoint() -> str:
def resolve_toolbox_endpoint() -> str:
"""Resolve the toolbox MCP endpoint URL.
Prefers the explicit ``FOUNDRY_TOOLBOX_ENDPOINT`` env var; falls back to
@@ -29,19 +28,18 @@ def _resolve_toolbox_endpoint() -> str:
return endpoint
project_endpoint = os.environ["FOUNDRY_PROJECT_ENDPOINT"].rstrip("/")
toolbox_name = os.environ["TOOLBOX_NAME"]
return f"{project_endpoint}/toolsets/{toolbox_name}/mcp?api-version=v1"
return f"{project_endpoint}/toolboxes/{toolbox_name}/mcp?api-version=v1"
def make_toolbox_header_provider(credential: TokenCredential) -> Callable[[dict[str, Any]], dict[str, str]]:
"""Build a header_provider that injects a fresh Azure AI bearer token on every MCP request."""
get_token = get_bearer_token_provider(credential, "https://ai.azure.com/.default")
class ToolboxAuth(httpx.Auth):
"""Injects a fresh bearer token on every request."""
def provide(_kwargs: dict[str, Any]) -> dict[str, str]:
return {
"Authorization": f"Bearer {get_token()}",
}
def __init__(self, token_provider: Callable[[], str]):
self._get_token = token_provider
return provide
def auth_flow(self, request: httpx.Request):
request.headers["Authorization"] = f"Bearer {self._get_token()}"
yield request
@tool(description="Get the current working directory.", approval_mode="never_require")
@@ -75,39 +73,47 @@ def read_file(file_path: str) -> str:
async def main():
credential = DefaultAzureCredential()
client = FoundryChatClient(
project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
model=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"],
credential=credential,
)
# Create the toolbox
token_provider = get_bearer_token_provider(credential, "https://ai.azure.com/.default")
# Connect to the toolbox MCP endpoint and expose only the code_interpreter tool.
# The toolbox deployed has two tools: (see agent.manifest.yaml)
# - `code_interpreter`
# - `web_search`
# We only need the `code_interpreter` tool for this sample.
toolbox_tool = MCPStreamableHTTPTool(
name="foundry_toolbox",
description="Tools exposed by the configured Foundry toolbox",
url=_resolve_toolbox_endpoint(),
header_provider=make_toolbox_header_provider(credential),
load_prompts=False,
allowed_tools=["code_interpreter"],
)
# 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.
toolbox_endpoint = resolve_toolbox_endpoint()
toolbox_name = os.environ.get("TOOLBOX_NAME") or toolbox_endpoint.rsplit("/mcp", 1)[0].rsplit("/", 1)[-1]
async with Agent(
client=client,
instructions=(
"You are a friendly assistant. Keep your answers brief. "
"Make sure all mathematical calculations are performed using the code interpreter "
"instead of mental arithmetic."
),
tools=[get_cwd, list_files, read_file, toolbox_tool],
# History will be managed by the hosting infrastructure, thus there
# is no need to store history by the service. Learn more at:
# https://developers.openai.com/api/reference/resources/responses/methods/create
default_options={"store": False},
) as agent:
async with httpx.AsyncClient(
auth=ToolboxAuth(token_provider),
headers={"Foundry-Features": "Toolboxes=V1Preview"},
timeout=120.0,
) as http_client:
toolbox = MCPStreamableHTTPTool(
name=toolbox_name,
url=toolbox_endpoint,
http_client=http_client,
load_prompts=False,
)
# Create the chat client
client = FoundryChatClient(
project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
model=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"],
credential=credential,
)
agent = Agent(
client=client,
instructions=(
"You are a friendly assistant. Keep your answers brief. "
"Make sure all mathematical calculations are performed using the code interpreter "
"instead of mental arithmetic."
),
tools=[get_cwd, list_files, read_file, toolbox],
# History will be managed by the hosting infrastructure, thus there
# is no need to store history by the service. Learn more at:
# https://developers.openai.com/api/reference/resources/responses/methods/create
default_options={"store": False},
)
server = ResponsesHostServer(agent)
await server.run_async()