mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
d74d26c917
* 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>
123 lines
4.3 KiB
Python
123 lines
4.3 KiB
Python
# Copyright (c) Microsoft. All rights reserved.
|
|
|
|
import asyncio
|
|
import os
|
|
from collections.abc import Callable
|
|
|
|
import httpx
|
|
from agent_framework import Agent, MCPStreamableHTTPTool, tool
|
|
from agent_framework.foundry import FoundryChatClient
|
|
from agent_framework_foundry_hosting import ResponsesHostServer
|
|
from azure.identity import DefaultAzureCredential, get_bearer_token_provider
|
|
from dotenv import load_dotenv
|
|
|
|
# Load environment variables from .env file
|
|
load_dotenv()
|
|
|
|
|
|
def resolve_toolbox_endpoint() -> str:
|
|
"""Resolve the toolbox MCP endpoint URL.
|
|
|
|
Prefers the explicit ``FOUNDRY_TOOLBOX_ENDPOINT`` env var; falls back to
|
|
constructing the URL from ``FOUNDRY_PROJECT_ENDPOINT`` and ``TOOLBOX_NAME``
|
|
(the variables injected by the Foundry hosting scaffolding after ``azd provision``).
|
|
"""
|
|
if (endpoint := os.environ.get("FOUNDRY_TOOLBOX_ENDPOINT")) is not None:
|
|
if not endpoint:
|
|
raise ValueError("FOUNDRY_TOOLBOX_ENDPOINT is set but empty")
|
|
return endpoint
|
|
project_endpoint = os.environ["FOUNDRY_PROJECT_ENDPOINT"].rstrip("/")
|
|
toolbox_name = os.environ["TOOLBOX_NAME"]
|
|
return f"{project_endpoint}/toolboxes/{toolbox_name}/mcp?api-version=v1"
|
|
|
|
|
|
class ToolboxAuth(httpx.Auth):
|
|
"""Injects a fresh bearer token on every request."""
|
|
|
|
def __init__(self, token_provider: Callable[[], str]):
|
|
self._get_token = token_provider
|
|
|
|
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")
|
|
def get_cwd() -> str:
|
|
"""Get the current working directory."""
|
|
try:
|
|
return os.getcwd()
|
|
except Exception as e:
|
|
return f"Error getting current working directory: {e}"
|
|
|
|
|
|
@tool(description="List files in a directory.", approval_mode="never_require")
|
|
def list_files(directory: str) -> list[str]:
|
|
"""List files in a directory."""
|
|
try:
|
|
return os.listdir(directory)
|
|
except Exception as e:
|
|
return [f"Error listing files in {directory}: {e}"]
|
|
|
|
|
|
@tool(description="Read the contents of a file.", approval_mode="never_require")
|
|
def read_file(file_path: str) -> str:
|
|
"""Read the contents of a file."""
|
|
try:
|
|
with open(file_path) as f:
|
|
return f.read()
|
|
except Exception as e:
|
|
return f"Error reading file {file_path}: {e}"
|
|
|
|
|
|
async def main():
|
|
credential = DefaultAzureCredential()
|
|
|
|
# Create the toolbox
|
|
token_provider = get_bearer_token_provider(credential, "https://ai.azure.com/.default")
|
|
|
|
# Resolve the endpoint once and derive the tool name from the same source: when
|
|
# ``TOOLBOX_NAME`` isn't explicitly set, parse it out of the resolved URL so the
|
|
# tool's local name and the upstream toolbox always agree.
|
|
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. "
|
|
"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()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
asyncio.run(main())
|