diff --git a/.github/actions/github-app-token/action 2.yml b/.github/actions/github-app-token/action 2.yml
new file mode 100644
index 00000000000..9021f9fdfe0
--- /dev/null
+++ b/.github/actions/github-app-token/action 2.yml
@@ -0,0 +1,112 @@
+name: Get GitHub automation token
+description: Creates a GitHub App installation token with a temporary PAT fallback
+
+inputs:
+ mode:
+ description: Authentication mode (app, app-with-fallback, or pat)
+ required: false
+ default: app-with-fallback
+ azure-client-id:
+ description: Client ID of the Azure workload identity
+ required: false
+ azure-tenant-id:
+ description: Azure tenant ID
+ required: false
+ azure-subscription-id:
+ description: Azure subscription containing the Key Vault
+ required: false
+ key-vault-name:
+ description: Azure Key Vault name
+ required: false
+ key-name:
+ description: Key Vault key used to sign the GitHub App JWT
+ required: false
+ github-app-client-id:
+ description: GitHub App client ID
+ required: false
+ github-app-installation-id:
+ description: GitHub App installation ID
+ required: false
+ repository:
+ description: Repository to include in the installation token
+ required: false
+ fallback-token:
+ description: PAT used temporarily when app authentication is unavailable
+ required: false
+
+outputs:
+ token:
+ description: GitHub App installation token or fallback PAT
+ value: ${{ steps.select-token.outputs.token }}
+ source:
+ description: Selected authentication source
+ value: ${{ steps.select-token.outputs.source }}
+
+runs:
+ using: composite
+ steps:
+ - name: Validate authentication mode
+ shell: bash
+ env:
+ AUTH_MODE: ${{ inputs.mode || 'app-with-fallback' }}
+ run: |
+ if [[ "$AUTH_MODE" != "app" && "$AUTH_MODE" != "app-with-fallback" && "$AUTH_MODE" != "pat" ]]; then
+ echo "::error::Unsupported GitHub authentication mode."
+ exit 1
+ fi
+
+ - name: Sign in to Azure
+ id: azure-login
+ if: ${{ (inputs.mode || 'app-with-fallback') != 'pat' }}
+ continue-on-error: true
+ uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2
+ with:
+ client-id: ${{ inputs.azure-client-id }}
+ tenant-id: ${{ inputs.azure-tenant-id }}
+ subscription-id: ${{ inputs.azure-subscription-id }}
+
+ - name: Create GitHub App installation token
+ id: app-token
+ if: ${{ (inputs.mode || 'app-with-fallback') != 'pat' && steps.azure-login.outcome == 'success' }}
+ continue-on-error: true
+ shell: bash
+ env:
+ AZURE_SUBSCRIPTION_ID: ${{ inputs.azure-subscription-id }}
+ KEY_VAULT_NAME: ${{ inputs.key-vault-name }}
+ KEY_NAME: ${{ inputs.key-name }}
+ GITHUB_APP_CLIENT_ID: ${{ inputs.github-app-client-id }}
+ GITHUB_APP_INSTALLATION_ID: ${{ inputs.github-app-installation-id }}
+ TARGET_REPOSITORY: ${{ inputs.repository }}
+ run: |
+ token="$(node "$GITHUB_ACTION_PATH/create-token.js")"
+ echo "::add-mask::$token"
+ echo "token=$token" >> "$GITHUB_OUTPUT"
+
+ - name: Select authentication token
+ id: select-token
+ shell: bash
+ env:
+ AUTH_MODE: ${{ inputs.mode || 'app-with-fallback' }}
+ APP_TOKEN: ${{ steps.app-token.outputs.token }}
+ FALLBACK_TOKEN: ${{ inputs.fallback-token }}
+ run: |
+ if [[ "$AUTH_MODE" != "pat" && -n "$APP_TOKEN" ]]; then
+ token="$APP_TOKEN"
+ source="app"
+ echo "::notice::GitHub authentication source: app"
+ elif [[ "$AUTH_MODE" == "app-with-fallback" && -n "$FALLBACK_TOKEN" ]]; then
+ token="$FALLBACK_TOKEN"
+ source="pat-fallback"
+ echo "::warning::GitHub authentication source: PAT fallback"
+ elif [[ "$AUTH_MODE" == "pat" && -n "$FALLBACK_TOKEN" ]]; then
+ token="$FALLBACK_TOKEN"
+ source="pat-forced"
+ echo "::warning::GitHub authentication source: PAT (forced rollout mode)"
+ else
+ echo "::error::GitHub App authentication is unavailable and no fallback PAT was provided."
+ exit 1
+ fi
+
+ echo "::add-mask::$token"
+ echo "token=$token" >> "$GITHUB_OUTPUT"
+ echo "source=$source" >> "$GITHUB_OUTPUT"
diff --git a/.github/actions/github-app-token/create-token 2.js b/.github/actions/github-app-token/create-token 2.js
new file mode 100644
index 00000000000..287f52b49ec
--- /dev/null
+++ b/.github/actions/github-app-token/create-token 2.js
@@ -0,0 +1,133 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+const crypto = require('node:crypto');
+const { execFileSync } = require('node:child_process');
+
+function base64Url(value) {
+ return Buffer.from(value).toString('base64url');
+}
+
+function base64ToBase64Url(value) {
+ return Buffer.from(value, 'base64').toString('base64url');
+}
+
+function createJwtSigningInput(clientId, nowSeconds) {
+ const header = base64Url(JSON.stringify({ alg: 'RS256', typ: 'JWT' }));
+ const payload = base64Url(JSON.stringify({
+ iat: nowSeconds - 60,
+ exp: nowSeconds + 540,
+ iss: clientId,
+ }));
+ return `${header}.${payload}`;
+}
+
+function signJwt(signingInput, config, execute = execFileSync) {
+ const digest = crypto.createHash('sha256').update(signingInput).digest('base64');
+ const signature = execute(
+ 'az',
+ [
+ 'keyvault', 'key', 'sign',
+ '--subscription', config.azureSubscriptionId,
+ '--vault-name', config.keyVaultName,
+ '--name', config.keyName,
+ '--algorithm', 'RS256',
+ '--digest', digest,
+ '--query', 'signature',
+ '--output', 'tsv',
+ '--only-show-errors',
+ ],
+ { encoding: 'utf8' },
+ ).trim();
+
+ if (!signature) {
+ throw new Error('Key Vault returned an empty signature.');
+ }
+
+ return `${signingInput}.${base64ToBase64Url(signature)}`;
+}
+
+async function createInstallationToken(config, dependencies = {}) {
+ const execute = dependencies.execute ?? execFileSync;
+ const request = dependencies.fetch ?? fetch;
+ const nowSeconds = dependencies.nowSeconds ?? Math.floor(Date.now() / 1000);
+ const repositoryParts = config.targetRepository.split('/');
+
+ if (repositoryParts.length !== 2 || repositoryParts.some((part) => part.length === 0)) {
+ throw new Error('TARGET_REPOSITORY must use the owner/repository format.');
+ }
+
+ const [, repository] = repositoryParts;
+ const signingInput = createJwtSigningInput(config.githubAppClientId, nowSeconds);
+ const jwt = signJwt(signingInput, config, execute);
+
+ const response = await request(
+ `https://api.github.com/app/installations/${config.githubAppInstallationId}/access_tokens`,
+ {
+ method: 'POST',
+ headers: {
+ Accept: 'application/vnd.github+json',
+ Authorization: `Bearer ${jwt}`,
+ 'X-GitHub-Api-Version': '2022-11-28',
+ },
+ body: JSON.stringify({
+ repositories: [repository],
+ permissions: {
+ contents: 'read',
+ issues: 'write',
+ members: 'read',
+ pull_requests: 'write',
+ },
+ }),
+ },
+ );
+
+ if (!response.ok) {
+ throw new Error(`GitHub installation token request failed with HTTP ${response.status}.`);
+ }
+
+ const result = await response.json();
+ if (typeof result.token !== 'string' || result.token.length === 0) {
+ throw new Error('GitHub returned an empty installation token.');
+ }
+
+ return result.token;
+}
+
+function readConfig(environment) {
+ const config = {
+ azureSubscriptionId: environment.AZURE_SUBSCRIPTION_ID,
+ keyVaultName: environment.KEY_VAULT_NAME,
+ keyName: environment.KEY_NAME,
+ githubAppClientId: environment.GITHUB_APP_CLIENT_ID,
+ githubAppInstallationId: environment.GITHUB_APP_INSTALLATION_ID,
+ targetRepository: environment.TARGET_REPOSITORY,
+ };
+
+ if (Object.values(config).some((value) => !value)) {
+ throw new Error('Required GitHub App authentication configuration is missing.');
+ }
+
+ return config;
+}
+
+async function main() {
+ try {
+ const token = await createInstallationToken(readConfig(process.env));
+ process.stdout.write(token);
+ } catch {
+ console.error('GitHub App token generation failed.');
+ process.exitCode = 1;
+ }
+}
+
+if (require.main === module) {
+ void main();
+}
+
+module.exports = {
+ base64ToBase64Url,
+ createInstallationToken,
+ createJwtSigningInput,
+ readConfig,
+ signJwt,
+};
diff --git a/dotnet/samples/04-hosting/af-hosting/README 2.md b/dotnet/samples/04-hosting/af-hosting/README 2.md
new file mode 100644
index 00000000000..3e9256204cf
--- /dev/null
+++ b/dotnet/samples/04-hosting/af-hosting/README 2.md
@@ -0,0 +1,53 @@
+# Agent Framework hosting samples (bring your own route)
+
+These samples show how to expose an Agent Framework agent or workflow over the OpenAI Responses HTTP
+protocol from an ASP.NET Core app that you write, where your app owns the HTTP route, authentication, and
+where conversations are stored.
+
+## Two ways to expose an agent over the Responses protocol
+
+Agent Framework gives you two options:
+
+1. **`MapOpenAIResponses` (batteries included).** A single call maps a ready-made `/responses` endpoint that
+ handles the protocol, routing, and session storage for you. Pick this when you want a working endpoint
+ quickly and the built-in behavior fits. See [AgentWebChat](../../05-end-to-end/AgentWebChat) for a sample
+ that uses it.
+
+2. **Call the conversion helpers from your own route (these samples).** You write the ASP.NET Core route and
+ call the `OpenAIResponses` helper methods to translate between the Responses HTTP payloads and the agent.
+ The framework only does the protocol translation, so you keep full control of routing, authentication,
+ and where conversations are stored. Pick this when you need hosting behavior the built-in endpoint does
+ not provide.
+
+## Samples
+
+| Sample | What it shows |
+|---|---|
+| [`local_responses/`](./local_responses) | An agent behind an ASP.NET Core route you write, using the `OpenAIResponses` helper methods plus `AgentSessionStore` for conversation continuity. The simplest sample to start with. |
+| [`local_responses_workflow/`](./local_responses_workflow) | A workflow behind an ASP.NET Core route you write, using the `OpenAIResponses` helper methods, `HostedWorkflowState`, an explicit `CheckpointManager`, and a checkpoint cursor your app keeps so a run resumes across turns. |
+
+Each sample is a **client/server pair** split into two projects:
+
+```
+local_responses/
+├── Server/ # exposes POST /responses using the OpenAIResponses helper methods
+└── Client/ # consumes it two ways: a chat client and an agent
+```
+
+The `Client` shows the two ways to consume the endpoint from .NET, both against the same server:
+
+- A plain `Microsoft.Extensions.AI.IChatClient` (the lower-level chat-client path).
+- A Microsoft Agent Framework `AIAgent` (the higher-level agent path).
+
+## Relationship to `../FoundryHostedAgents/`
+
+The sibling [`../FoundryHostedAgents/`](../FoundryHostedAgents) directory contains samples for agents that
+run inside the Foundry Hosted Agents platform, which hosts the agent and exposes the protocol for you. Use
+those when you want the Foundry-managed hosting surface; use these when you want to host the agent in your
+own ASP.NET Core app.
+
+| Aspect | `af-hosting/` (this directory) | `FoundryHostedAgents/` |
+|---|---|---|
+| Server stack | An ASP.NET Core app you write plus the hosting protocol helpers | Foundry Hosted Agents runtime |
+| Who exposes the route | Your app | The platform |
+| When to pick this | You need custom hosting code | You want the Foundry-managed hosting surface |
\ No newline at end of file
diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.IntegrationTests/Microsoft.Agents.AI.Hosting.OpenAI.IntegrationTests 2.csproj b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.IntegrationTests/Microsoft.Agents.AI.Hosting.OpenAI.IntegrationTests 2.csproj
new file mode 100644
index 00000000000..94fc6720017
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.IntegrationTests/Microsoft.Agents.AI.Hosting.OpenAI.IntegrationTests 2.csproj
@@ -0,0 +1,19 @@
+
+
+
+ $(TargetFrameworksCore)
+ True
+ $(NoWarn);OPENAI001;
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.IntegrationTests/OpenAIResponsesHostingLiveTests 2.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.IntegrationTests/OpenAIResponsesHostingLiveTests 2.cs
new file mode 100644
index 00000000000..1aa58c177dc
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.IntegrationTests/OpenAIResponsesHostingLiveTests 2.cs
@@ -0,0 +1,96 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Linq;
+using System.Text.Json;
+using System.Threading.Tasks;
+using Microsoft.Extensions.AI;
+using OpenAI;
+using Shared.IntegrationTests;
+
+namespace Microsoft.Agents.AI.Hosting.OpenAI.IntegrationTests;
+
+///
+/// Live integration tests for the app-owned routing helper surface ( plus
+/// ) exercised against a real OpenAI model. These confirm the crucial
+/// consumption paths — request conversion, an agent run, response rendering, and multi-turn session
+/// continuity — behave correctly end to end with a real chat client.
+///
+///
+/// Skipped unless the OpenAI configuration is present (OPENAI_API_KEY), so runs without secrets stay
+/// green. The in-process, in-memory (no live model) coverage lives in the
+/// Microsoft.Agents.AI.Hosting.OpenAI.UnitTests project (OpenAIResponsesHostingTests).
+///
+public sealed class OpenAIResponsesHostingLiveTests
+{
+ private static string? ApiKey => Environment.GetEnvironmentVariable(TestSettings.OpenAIApiKey);
+ private static string ModelName => Environment.GetEnvironmentVariable(TestSettings.OpenAIChatModelName) ?? "gpt-4o-mini";
+
+ [Fact]
+ public async Task NonStreamingRun_RendersResponsesShapedPayloadAsync()
+ {
+ // Arrange
+ Assert.SkipWhen(string.IsNullOrEmpty(ApiKey), "OPENAI_API_KEY is not configured; skipping live hosting test.");
+ AIAgent agent = CreateAgent();
+ AgentSessionStore sessionStore = new InMemoryAgentSessionStore();
+ JsonElement body = ParseBody("""{ "input": "Reply with exactly the word: apple" }""");
+
+ // Act
+ OpenAIResponsesRunRequest run = OpenAIResponses.ToAgentRunRequest(body);
+ string sessionStoreId = OpenAIResponses.GetSessionStoreId(run) ?? OpenAIResponses.CreateResponseId();
+ AgentSession session = await sessionStore.GetSessionAsync(agent, sessionStoreId);
+ string responseId = OpenAIResponses.CreateResponseId();
+ AgentResponse result = await agent.RunAsync(run.Messages, session, run.Options);
+ JsonElement payload = OpenAIResponses.WriteResponse(result, responseId, responseId);
+
+ // Assert
+ Assert.Equal(responseId, payload.GetProperty("id").GetString());
+ Assert.Equal("response", payload.GetProperty("object").GetString());
+ Assert.Contains("output", payload.EnumerateObject().Select(p => p.Name));
+ }
+
+ [Fact]
+ public async Task MultiTurn_ContinuesSessionAcrossTurnsAsync()
+ {
+ // Arrange
+ Assert.SkipWhen(string.IsNullOrEmpty(ApiKey), "OPENAI_API_KEY is not configured; skipping live hosting test.");
+ AIAgent agent = CreateAgent();
+ AgentSessionStore sessionStore = new InMemoryAgentSessionStore();
+
+ // Act: first turn establishes context, second turn continues from the first response id.
+ string firstResponseId = await RunTurnAsync(agent, sessionStore, """{ "input": "Remember the number 7." }""");
+ JsonElement secondBody = ParseBody($$"""{ "input": "What number did I ask you to remember?", "previous_response_id": "{{firstResponseId}}" }""");
+ OpenAIResponsesRunRequest secondRun = OpenAIResponses.ToAgentRunRequest(secondBody);
+ string secondSessionStoreId = OpenAIResponses.GetSessionStoreId(secondRun)!;
+ AgentSession session = await sessionStore.GetSessionAsync(agent, secondSessionStoreId);
+ AgentResponse secondResult = await agent.RunAsync(secondRun.Messages, session, secondRun.Options);
+
+ // Assert: continuation succeeded and the model produced a textual answer.
+ Assert.Equal(secondSessionStoreId, firstResponseId);
+ Assert.False(string.IsNullOrWhiteSpace(secondResult.Text));
+ }
+
+ private static async Task RunTurnAsync(AIAgent agent, AgentSessionStore sessionStore, string bodyJson)
+ {
+ JsonElement body = ParseBody(bodyJson);
+ OpenAIResponsesRunRequest run = OpenAIResponses.ToAgentRunRequest(body);
+ string sessionStoreId = OpenAIResponses.GetSessionStoreId(run) ?? OpenAIResponses.CreateResponseId();
+ AgentSession session = await sessionStore.GetSessionAsync(agent, sessionStoreId);
+ string responseId = OpenAIResponses.CreateResponseId();
+ _ = await agent.RunAsync(run.Messages, session, run.Options);
+ await sessionStore.SaveSessionAsync(agent, responseId, session);
+ return responseId;
+ }
+
+ private static ChatClientAgent CreateAgent() =>
+ new(
+ new OpenAIClient(ApiKey).GetChatClient(ModelName).AsIChatClient(),
+ instructions: "You are a concise assistant.",
+ name: "assistant");
+
+ private static JsonElement ParseBody(string json)
+ {
+ using JsonDocument doc = JsonDocument.Parse(json);
+ return doc.RootElement.Clone();
+ }
+}
diff --git a/python/packages/azurefunctions/agent_framework_azurefunctions/_hitl_context 2.py b/python/packages/azurefunctions/agent_framework_azurefunctions/_hitl_context 2.py
new file mode 100644
index 00000000000..d14a59e77ce
--- /dev/null
+++ b/python/packages/azurefunctions/agent_framework_azurefunctions/_hitl_context 2.py
@@ -0,0 +1,223 @@
+# Copyright (c) Microsoft. All rights reserved.
+
+"""Human-in-the-loop (HITL) addressing helper for workflow executors.
+
+When a MAF :class:`~agent_framework.Workflow` runs on the Azure Functions durable
+host, an executor can ask a human for input via ``ctx.request_info(...)``. To notify
+that human out-of-band (for example by emailing them an approval link), the executor
+needs the orchestration's ``instanceId`` and the request's ``requestId`` so it can
+build the ``/respond`` URL the reviewer will POST back to.
+
+:class:`WorkflowHitlContext` packages that addressing. It reads the orchestration
+metadata the durable host surfaces on the executor's runner context (see
+``CapturingRunnerContext.host_metadata``) and builds the canonical respond/status
+URLs that :class:`~agent_framework_azurefunctions.AgentFunctionApp` exposes -- so the
+executor never has to thread the instance id or base URL by hand.
+
+Typical use, from inside a notify executor reached by an edge from the executor that
+called ``request_info``::
+
+ hitl = WorkflowHitlContext.from_context(ctx)
+ if hitl is not None: # None when not on the Azure Functions durable host
+ url = hitl.build_respond_url(request_id)
+ send_email(to=reviewer, body=f"Approve or reject here: {url}")
+"""
+
+from __future__ import annotations
+
+import os
+from dataclasses import dataclass
+from typing import Any, cast
+
+from agent_framework_durabletask._workflows.runner_context import (
+ HOST_METADATA_INSTANCE_ID,
+ HOST_METADATA_REQUEST_PATH_PREFIX,
+ HOST_METADATA_WORKFLOW_NAME,
+)
+
+from ._routes import build_workflow_respond_url, build_workflow_status_url
+
+# App setting carrying the function app's host (e.g. ``myapp.azurewebsites.net``).
+# Azure Functions sets this automatically in the cloud; for local ``func start`` runs
+# add it to the ``Values`` map in ``local.settings.json`` (e.g. ``localhost:7071``).
+WEBSITE_HOSTNAME_ENV = "WEBSITE_HOSTNAME"
+
+# Loopback hosts that resolve to ``http`` (not ``https``) when WEBSITE_HOSTNAME is
+# host-only; covers the addresses ``func start`` can bind locally.
+_LOOPBACK_HOSTS = frozenset({"localhost", "0.0.0.0", "::1"}) # noqa: S104 # nosec B104
+
+
+def _is_loopback(host: str) -> bool:
+ """Return whether ``host`` (optionally ``host:port``) is a local loopback address.
+
+ Handles ``localhost``, IPv4 ``127.0.0.0/8`` and ``0.0.0.0``, and IPv6 ``::1``
+ (including the bracketed ``[::1]:port`` form ``func start`` prints).
+ """
+ normalized = host.strip().lower()
+ if normalized.startswith("["): # bracketed IPv6 like [::1]:7071
+ normalized = normalized[1 : normalized.find("]")] if "]" in normalized else normalized[1:]
+ elif normalized.count(":") == 1: # host:port (bare IPv6 has multiple colons)
+ normalized = normalized.split(":", 1)[0]
+ return normalized in _LOOPBACK_HOSTS or normalized.startswith("127.")
+
+
+@dataclass(frozen=True)
+class WorkflowHitlContext:
+ """Builds Azure Functions HITL respond/status URLs from inside a workflow executor.
+
+ Obtain one with :meth:`from_context`. It exposes the addressable *root*
+ orchestration's ``instance_id`` and ``workflow_name`` and builds the URLs an
+ external reviewer uses to resume the workflow. When the executor runs inside a
+ nested sub-workflow, ``request_path_prefix`` carries the ``{executor}~{ordinal}~``
+ hops from the root down to this level, so :meth:`build_respond_url` qualifies a bare
+ request id back to the top-level instance automatically. The base URL is resolved
+ lazily (see :attr:`base_url`) from an explicit override or the ``WEBSITE_HOSTNAME``
+ app setting.
+ """
+
+ instance_id: str
+ workflow_name: str
+ base_url_override: str | None = None
+ request_path_prefix: str = ""
+
+ @classmethod
+ def from_context(
+ cls,
+ ctx: Any,
+ *,
+ base_url: str | None = None,
+ ) -> WorkflowHitlContext | None:
+ """Build a HITL context from a workflow executor's ``WorkflowContext``.
+
+ Reads the orchestration metadata the durable host attached to the executor's
+ runner context. Returns ``None`` when that metadata is absent -- i.e. the same
+ executor is running in-process rather than on the Azure Functions durable host
+ -- so callers can skip notification and degrade gracefully.
+
+ Args:
+ ctx: The ``WorkflowContext`` passed to the executor's handler.
+ base_url: Optional explicit base URL (scheme + host, e.g.
+ ``https://contoso.example.com``). Use this when the public URL differs
+ from ``WEBSITE_HOSTNAME`` -- for example behind a custom domain or API
+ Management gateway, where ``WEBSITE_HOSTNAME`` still reports the default
+ ``*.azurewebsites.net`` host. When omitted, the base URL is resolved
+ from ``WEBSITE_HOSTNAME`` on first use.
+
+ Returns:
+ A :class:`WorkflowHitlContext`, or ``None`` if not running on a durable host.
+ """
+ runner_context = getattr(ctx, "_runner_context", None)
+ raw_metadata = getattr(runner_context, "host_metadata", None)
+ if not isinstance(raw_metadata, dict):
+ return None
+ metadata = cast("dict[str, Any]", raw_metadata)
+
+ instance_id = metadata.get(HOST_METADATA_INSTANCE_ID)
+ workflow_name = metadata.get(HOST_METADATA_WORKFLOW_NAME)
+ if not isinstance(instance_id, str) or not isinstance(workflow_name, str):
+ return None
+
+ # Present when the executor runs inside a nested sub-workflow; absent/empty at
+ # the top level. Defaults to "" so the request id is used unqualified.
+ raw_prefix = metadata.get(HOST_METADATA_REQUEST_PATH_PREFIX)
+ request_path_prefix = raw_prefix if isinstance(raw_prefix, str) else ""
+
+ return cls(
+ instance_id=instance_id,
+ workflow_name=workflow_name,
+ base_url_override=base_url,
+ request_path_prefix=request_path_prefix,
+ )
+
+ @staticmethod
+ async def pending_request_id(ctx: Any) -> str | None:
+ """Return the id of the most recently emitted ``request_info`` on ``ctx``.
+
+ Call this **immediately after** ``await ctx.request_info(...)`` to recover the
+ request id the framework generated, so it can be forwarded (e.g. in a message
+ to a downstream notify executor that builds the respond URL) without the caller
+ generating an id by hand.
+
+ Why "immediately after" is the rule, and why it is safe on the durable host:
+ the returned id is simply the newest entry in the executor's pending
+ request-info set, so reading right after a call always yields *that* call's id.
+ On the Azure Functions durable host every executor runs in its own activity with
+ its own runner context, so that set only ever holds this executor's own
+ requests (never another executor's), and the request you just emitted is always
+ the latest. If a single executor emits several ``request_info`` calls in one
+ turn, read this after **each** call (the only case where reading once at the end
+ would lose the earlier ids); or pass an explicit ``request_id`` to
+ ``request_info`` to address them directly.
+
+ Returns ``None`` only when no request is pending (or the runner context does not
+ track request-info events, e.g. in process off the durable host).
+ """
+ runner_context = getattr(ctx, "_runner_context", None)
+ getter = getattr(runner_context, "get_pending_request_info_events", None)
+ if getter is None:
+ return None
+ events = await getter()
+ if not events:
+ return None
+ # Dicts preserve insertion order, so the last key is the most recent request.
+ return next(reversed(events))
+
+ @property
+ def base_url(self) -> str:
+ """The scheme + host the respond/status URLs are built on (no trailing slash).
+
+ Resolution order: the explicit ``base_url`` passed to :meth:`from_context`, then
+ the ``WEBSITE_HOSTNAME`` app setting (``http`` for localhost, otherwise
+ ``https``).
+
+ Raises:
+ RuntimeError: If neither an override nor ``WEBSITE_HOSTNAME`` is available.
+ """
+ if self.base_url_override:
+ return self.base_url_override.rstrip("/")
+
+ hostname = os.environ.get(WEBSITE_HOSTNAME_ENV)
+ if not hostname:
+ raise RuntimeError(
+ "Cannot build a HITL URL: no base URL is available. Set the "
+ f"'{WEBSITE_HOSTNAME_ENV}' app setting (present automatically on Azure "
+ "Functions; add it to the 'Values' map in local.settings.json for local "
+ "`func start` runs, e.g. 'localhost:7071'), or pass base_url=... to "
+ "WorkflowHitlContext.from_context()."
+ )
+
+ # WEBSITE_HOSTNAME may include a scheme (unusual but possible); otherwise it is
+ # host-only, so infer one (http for local loopback, https otherwise).
+ if hostname.startswith(("http://", "https://")):
+ return hostname.rstrip("/")
+ scheme = "http" if _is_loopback(hostname) else "https"
+ return f"{scheme}://{hostname.rstrip('/')}"
+
+ def build_respond_url(self, request_id: str) -> str:
+ """Build the URL a reviewer POSTs their response to, resuming the workflow.
+
+ Mirrors the ``respondUrl`` AgentFunctionApp returns from its run/status
+ endpoints: ``{base}/{prefix}/workflow/{name}/respond/{instanceId}/{requestId}``
+ (``prefix`` is the app's ``routePrefix``, ``api`` by default), always targeting
+ the addressable top-level instance.
+
+ Args:
+ request_id: The pending request's id -- the id passed to (or generated by)
+ ``ctx.request_info``. Pass the **bare** id even from inside a nested
+ sub-workflow: any :attr:`request_path_prefix` is prepended for you to
+ qualify it (``{executor}~{ordinal}~{requestId}``) back to the root.
+
+ Returns:
+ The fully-qualified respond URL.
+ """
+ qualified_id = f"{self.request_path_prefix}{request_id}"
+ return build_workflow_respond_url(self.base_url, self.workflow_name, self.instance_id, qualified_id)
+
+ def build_status_url(self) -> str:
+ """Build the workflow status URL for this orchestration instance.
+
+ Returns ``{base}/{prefix}/workflow/{name}/status/{instanceId}`` (``prefix`` is the
+ app's ``routePrefix``, ``api`` by default), the same endpoint AgentFunctionApp
+ exposes for polling runtime status and pending HITL requests.
+ """
+ return build_workflow_status_url(self.base_url, self.workflow_name, self.instance_id)
diff --git a/python/packages/azurefunctions/agent_framework_azurefunctions/_routes 2.py b/python/packages/azurefunctions/agent_framework_azurefunctions/_routes 2.py
new file mode 100644
index 00000000000..7bdcbdec3fa
--- /dev/null
+++ b/python/packages/azurefunctions/agent_framework_azurefunctions/_routes 2.py
@@ -0,0 +1,117 @@
+# Copyright (c) Microsoft. All rights reserved.
+
+"""Single source of truth for the AgentFunctionApp HTTP route prefix and HITL URLs.
+
+The server endpoints (:mod:`._app`) and the in-workflow addressing helper
+(:mod:`._hitl_context`) build the same ``{prefix}/workflow/{name}/...`` URLs. Keeping the
+shape and the prefix logic here stops the two sides from drifting -- previously they were
+only kept in sync by an integration test asserting the two strings match -- and lets a
+customized ``routePrefix`` be honored instead of a hardcoded ``api`` that would 404 on
+resume. The server derives the prefix from the incoming request URL (the value the host
+actually routed); the helper, which runs inside an executor with no request context,
+reads it from ``host.json``.
+"""
+
+from __future__ import annotations
+
+import functools
+import json
+import logging
+import os
+from typing import Any, cast
+from urllib.parse import urlsplit
+
+logger = logging.getLogger(__name__)
+
+# Azure Functions' default HTTP route prefix, applied when host.json does not override
+# ``extensions.http.routePrefix``.
+DEFAULT_ROUTE_PREFIX = "api"
+
+
+@functools.lru_cache(maxsize=1)
+def route_prefix() -> str:
+ """Return the app's HTTP route prefix, honoring ``host.json``.
+
+ Azure Functions prepends ``extensions.http.routePrefix`` (default ``api``) to every
+ HTTP route, and it can be customized or set to an empty string. That value is not
+ exposed through an environment variable, so it is read from ``host.json`` under the
+ script root (``AzureWebJobsScriptRoot``, falling back to the current working
+ directory) and cached for the process. Any failure to locate or parse the file falls
+ back to the ``api`` default. Tests that vary ``host.json`` call ``route_prefix.cache_clear()``.
+ """
+ return _read_route_prefix()
+
+
+def _read_route_prefix() -> str:
+ # AzureWebJobsScriptRoot is the host-set path to the app root (mixed case is the real
+ # variable name and is case-sensitive on Linux, so it must not be upper-cased).
+ script_root = os.environ.get("AzureWebJobsScriptRoot") or os.getcwd() # noqa: SIM112
+ host_json_path = os.path.join(script_root, "host.json")
+ try:
+ with open(host_json_path, encoding="utf-8") as f:
+ loaded = json.load(f)
+ except (OSError, ValueError):
+ logger.debug("Could not read '%s'; defaulting route prefix to '%s'.", host_json_path, DEFAULT_ROUTE_PREFIX)
+ return DEFAULT_ROUTE_PREFIX
+ if not isinstance(loaded, dict):
+ return DEFAULT_ROUTE_PREFIX
+ extensions = cast("dict[str, Any]", loaded).get("extensions")
+ if not isinstance(extensions, dict):
+ return DEFAULT_ROUTE_PREFIX
+ http = cast("dict[str, Any]", extensions).get("http")
+ if not isinstance(http, dict):
+ return DEFAULT_ROUTE_PREFIX
+ prefix = cast("dict[str, Any]", http).get("routePrefix")
+ return prefix.strip("/") if isinstance(prefix, str) else DEFAULT_ROUTE_PREFIX
+
+
+def _prefix_segment(prefix: str | None) -> str:
+ """Return the route-prefix path segment with a trailing slash, or ``""`` when empty."""
+ resolved = route_prefix() if prefix is None else prefix.strip("/")
+ return f"{resolved}/" if resolved else ""
+
+
+def build_workflow_respond_url(
+ base_url: str,
+ workflow_name: str,
+ instance_id: str,
+ request_id: str,
+ *,
+ prefix: str | None = None,
+) -> str:
+ """Build the canonical HITL respond URL a reviewer POSTs to.
+
+ ``{base}/{prefix}/workflow/{name}/respond/{instanceId}/{requestId}``. When ``prefix``
+ is omitted it is resolved from ``host.json``. ``request_id`` may be a literal
+ ``{requestId}`` placeholder to produce the templated form the run endpoint returns.
+ """
+ return f"{base_url}/{_prefix_segment(prefix)}workflow/{workflow_name}/respond/{instance_id}/{request_id}"
+
+
+def build_workflow_status_url(
+ base_url: str,
+ workflow_name: str,
+ instance_id: str,
+ *,
+ prefix: str | None = None,
+) -> str:
+ """Build the workflow status URL: ``{base}/{prefix}/workflow/{name}/status/{instanceId}``."""
+ return f"{base_url}/{_prefix_segment(prefix)}workflow/{workflow_name}/status/{instance_id}"
+
+
+def split_request_url(request_url: str) -> tuple[str, str]:
+ """Return ``(base_url, route_prefix)`` derived from an incoming request URL.
+
+ On the server the request URL is the authoritative source for the prefix, since the
+ host served it through the configured ``routePrefix``. The scheme and host form the
+ base URL, and the path before the first ``/workflow/`` segment is the prefix (empty
+ when the routes sit directly under the host). Falls back to ``(request_url, "")`` when
+ the value is not an absolute URL.
+ """
+ parts = urlsplit(request_url)
+ if not (parts.scheme and parts.netloc):
+ return request_url.rstrip("/"), ""
+ base_url = f"{parts.scheme}://{parts.netloc}"
+ index = parts.path.find("/workflow/")
+ prefix = parts.path[:index].strip("/") if index != -1 else ""
+ return base_url, prefix
diff --git a/python/packages/azurefunctions/tests/test_hitl_context 2.py b/python/packages/azurefunctions/tests/test_hitl_context 2.py
new file mode 100644
index 00000000000..0ca4466e5e2
--- /dev/null
+++ b/python/packages/azurefunctions/tests/test_hitl_context 2.py
@@ -0,0 +1,231 @@
+# Copyright (c) Microsoft. All rights reserved.
+
+"""Unit tests for WorkflowHitlContext (HITL respond-URL helper)."""
+
+# pyright: reportPrivateUsage=false
+
+from types import SimpleNamespace
+from typing import Any
+
+import pytest
+
+from agent_framework_azurefunctions import WorkflowHitlContext
+from agent_framework_azurefunctions._hitl_context import WEBSITE_HOSTNAME_ENV, _is_loopback
+
+
+def _ctx(metadata: Any) -> SimpleNamespace:
+ """Build a stand-in WorkflowContext exposing ``_runner_context.host_metadata``."""
+ return SimpleNamespace(_runner_context=SimpleNamespace(host_metadata=metadata))
+
+
+class TestFromContext:
+ """Construction from a workflow executor's context."""
+
+ def test_returns_context_when_metadata_present(self) -> None:
+ hitl = WorkflowHitlContext.from_context(_ctx({"instance_id": "inst-1", "workflow_name": "content_moderation"}))
+ assert hitl is not None
+ assert hitl.instance_id == "inst-1"
+ assert hitl.workflow_name == "content_moderation"
+
+ def test_returns_none_when_no_runner_context(self) -> None:
+ # A bare object without _runner_context (e.g. an unexpected ctx) yields None.
+ assert WorkflowHitlContext.from_context(SimpleNamespace()) is None
+
+ def test_returns_none_when_metadata_absent(self) -> None:
+ # In-process RunnerContext has no host_metadata -> getattr default None.
+ assert WorkflowHitlContext.from_context(_ctx(None)) is None
+
+ def test_returns_none_when_metadata_not_a_dict(self) -> None:
+ assert WorkflowHitlContext.from_context(_ctx("not-a-dict")) is None
+
+ def test_returns_none_when_instance_id_missing(self) -> None:
+ assert WorkflowHitlContext.from_context(_ctx({"workflow_name": "wf"})) is None
+
+ def test_returns_none_when_workflow_name_missing(self) -> None:
+ assert WorkflowHitlContext.from_context(_ctx({"instance_id": "inst-1"})) is None
+
+
+class TestBaseUrl:
+ """base_url resolution from override and WEBSITE_HOSTNAME."""
+
+ def test_explicit_override_wins(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.setenv(WEBSITE_HOSTNAME_ENV, "ignored.azurewebsites.net")
+ hitl = WorkflowHitlContext.from_context(
+ _ctx({"instance_id": "i", "workflow_name": "wf"}),
+ base_url="https://contoso.example.com/",
+ )
+ assert hitl is not None
+ # Trailing slash trimmed; override used verbatim over the env host.
+ assert hitl.base_url == "https://contoso.example.com"
+
+ def test_website_hostname_gets_https(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.setenv(WEBSITE_HOSTNAME_ENV, "myapp.azurewebsites.net")
+ hitl = WorkflowHitlContext.from_context(_ctx({"instance_id": "i", "workflow_name": "wf"}))
+ assert hitl is not None
+ assert hitl.base_url == "https://myapp.azurewebsites.net"
+
+ def test_localhost_gets_http(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.setenv(WEBSITE_HOSTNAME_ENV, "localhost:7071")
+ hitl = WorkflowHitlContext.from_context(_ctx({"instance_id": "i", "workflow_name": "wf"}))
+ assert hitl is not None
+ assert hitl.base_url == "http://localhost:7071"
+
+ def test_override_with_scheme_preserved(self) -> None:
+ hitl = WorkflowHitlContext.from_context(
+ _ctx({"instance_id": "i", "workflow_name": "wf"}),
+ base_url="http://127.0.0.1:7071",
+ )
+ assert hitl is not None
+ assert hitl.base_url == "http://127.0.0.1:7071"
+
+ def test_raises_when_no_base_url_available(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.delenv(WEBSITE_HOSTNAME_ENV, raising=False)
+ hitl = WorkflowHitlContext.from_context(_ctx({"instance_id": "i", "workflow_name": "wf"}))
+ assert hitl is not None
+ with pytest.raises(RuntimeError, match=WEBSITE_HOSTNAME_ENV):
+ _ = hitl.base_url
+
+
+class TestUrlBuilders:
+ """respond/status URL shapes match the AgentFunctionApp routes."""
+
+ def test_build_respond_url(self) -> None:
+ hitl = WorkflowHitlContext.from_context(
+ _ctx({"instance_id": "inst-1", "workflow_name": "content_moderation"}),
+ base_url="https://app.example.com",
+ )
+ assert hitl is not None
+ assert hitl.build_respond_url("req-9") == (
+ "https://app.example.com/api/workflow/content_moderation/respond/inst-1/req-9"
+ )
+
+ def test_build_respond_url_accepts_qualified_id(self) -> None:
+ # A nested sub-workflow request id (executor~ordinal~rid) flows through unchanged.
+ hitl = WorkflowHitlContext.from_context(
+ _ctx({"instance_id": "inst-1", "workflow_name": "wf"}),
+ base_url="https://app.example.com",
+ )
+ assert hitl is not None
+ assert hitl.build_respond_url("reviewer~0~req-9") == (
+ "https://app.example.com/api/workflow/wf/respond/inst-1/reviewer~0~req-9"
+ )
+
+ def test_build_status_url(self) -> None:
+ hitl = WorkflowHitlContext.from_context(
+ _ctx({"instance_id": "inst-1", "workflow_name": "wf"}),
+ base_url="https://app.example.com",
+ )
+ assert hitl is not None
+ assert hitl.build_status_url() == "https://app.example.com/api/workflow/wf/status/inst-1"
+
+
+class TestNestedPrefix:
+ """request_path_prefix qualifies a bare request id back to the root instance."""
+
+ def test_prefix_read_from_metadata(self) -> None:
+ # host_metadata for a nested executor carries the root instance/workflow and the
+ # accumulated path prefix; instance_id/workflow_name are the *root* values.
+ hitl = WorkflowHitlContext.from_context(
+ _ctx({
+ "instance_id": "root-inst",
+ "workflow_name": "moderation_pipeline",
+ "request_path_prefix": "review_sub~0~",
+ }),
+ base_url="https://app.example.com",
+ )
+ assert hitl is not None
+ assert hitl.request_path_prefix == "review_sub~0~"
+ # A bare request id is qualified back to the top-level instance automatically.
+ assert hitl.build_respond_url("req-9") == (
+ "https://app.example.com/api/workflow/moderation_pipeline/respond/root-inst/review_sub~0~req-9"
+ )
+
+ def test_deep_prefix(self) -> None:
+ hitl = WorkflowHitlContext.from_context(
+ _ctx({
+ "instance_id": "root-inst",
+ "workflow_name": "wf",
+ "request_path_prefix": "outer~2~inner~1~",
+ }),
+ base_url="https://app.example.com",
+ )
+ assert hitl is not None
+ assert hitl.build_respond_url("rid") == (
+ "https://app.example.com/api/workflow/wf/respond/root-inst/outer~2~inner~1~rid"
+ )
+
+ def test_absent_prefix_defaults_empty(self) -> None:
+ # Top-level metadata may omit the key; the bare id is used unqualified.
+ hitl = WorkflowHitlContext.from_context(
+ _ctx({"instance_id": "inst-1", "workflow_name": "wf"}),
+ base_url="https://app.example.com",
+ )
+ assert hitl is not None
+ assert hitl.request_path_prefix == ""
+ assert hitl.build_respond_url("rid") == ("https://app.example.com/api/workflow/wf/respond/inst-1/rid")
+
+
+def _ctx_with_pending(pending: dict[str, Any] | None, *, has_getter: bool = True) -> SimpleNamespace:
+ """Build a ctx whose runner context returns the given pending request-info events."""
+ if not has_getter:
+ return SimpleNamespace(_runner_context=SimpleNamespace())
+
+ async def _get() -> dict[str, Any]:
+ return pending or {}
+
+ return SimpleNamespace(_runner_context=SimpleNamespace(get_pending_request_info_events=_get))
+
+
+class TestPendingRequestId:
+ """Reading back the framework-generated request id after request_info."""
+
+ async def test_returns_latest_request_id(self) -> None:
+ # Dicts preserve insertion order; the most recently emitted request wins.
+ ctx = _ctx_with_pending({"r1": object(), "r2": object()})
+ assert await WorkflowHitlContext.pending_request_id(ctx) == "r2"
+
+ async def test_returns_single_request_id(self) -> None:
+ ctx = _ctx_with_pending({"only-one": object()})
+ assert await WorkflowHitlContext.pending_request_id(ctx) == "only-one"
+
+ async def test_returns_none_when_no_pending(self) -> None:
+ ctx = _ctx_with_pending({})
+ assert await WorkflowHitlContext.pending_request_id(ctx) is None
+
+ async def test_returns_none_when_no_runner_context(self) -> None:
+ assert await WorkflowHitlContext.pending_request_id(SimpleNamespace()) is None
+
+ async def test_returns_none_when_getter_absent(self) -> None:
+ # A runner context that doesn't track request-info events degrades to None.
+ ctx = _ctx_with_pending(None, has_getter=False)
+ assert await WorkflowHitlContext.pending_request_id(ctx) is None
+
+
+class TestLoopback:
+ """Loopback detection covers the addresses ``func start`` can bind, not just localhost."""
+
+ @pytest.mark.parametrize(
+ ("host", "expected"),
+ [
+ ("localhost", True),
+ ("localhost:7071", True),
+ ("127.0.0.1", True),
+ ("127.0.0.1:7071", True),
+ ("127.5.9.9", True),
+ ("0.0.0.0", True),
+ ("0.0.0.0:7071", True),
+ ("::1", True),
+ ("[::1]:7071", True),
+ ("myapp.azurewebsites.net", False),
+ ("contoso.example.com:443", False),
+ ],
+ )
+ def test_is_loopback(self, host: str, expected: bool) -> None:
+ assert _is_loopback(host) is expected
+
+ def test_ipv6_loopback_base_url_gets_http(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ # WEBSITE_HOSTNAME may report a bracketed IPv6 loopback locally; it must resolve to http.
+ monkeypatch.setenv(WEBSITE_HOSTNAME_ENV, "[::1]:7071")
+ hitl = WorkflowHitlContext.from_context(_ctx({"instance_id": "i", "workflow_name": "wf"}))
+ assert hitl is not None
+ assert hitl.base_url == "http://[::1]:7071"
diff --git a/python/packages/azurefunctions/tests/test_routes 2.py b/python/packages/azurefunctions/tests/test_routes 2.py
new file mode 100644
index 00000000000..f00e9893266
--- /dev/null
+++ b/python/packages/azurefunctions/tests/test_routes 2.py
@@ -0,0 +1,128 @@
+# Copyright (c) Microsoft. All rights reserved.
+
+"""Unit tests for the shared route-prefix resolution and HITL URL builders."""
+
+# pyright: reportPrivateUsage=false
+
+import json
+from collections.abc import Iterator
+from pathlib import Path
+from typing import Any
+
+import pytest
+
+from agent_framework_azurefunctions import _routes
+
+SCRIPT_ROOT_ENV = "AzureWebJobsScriptRoot"
+
+
+@pytest.fixture(autouse=True)
+def _reset_prefix_cache() -> Iterator[None]:
+ """Keep the route-prefix cache from leaking across tests."""
+ _routes.route_prefix.cache_clear()
+ yield
+ _routes.route_prefix.cache_clear()
+
+
+def _write_host_json(directory: Path, config: dict[str, Any]) -> None:
+ (directory / "host.json").write_text(json.dumps(config), encoding="utf-8")
+
+
+class TestRoutePrefix:
+ """Resolving ``extensions.http.routePrefix`` from host.json, with an ``api`` default."""
+
+ def test_defaults_to_api_without_host_json(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.setenv(SCRIPT_ROOT_ENV, str(tmp_path))
+ assert _routes.route_prefix() == "api"
+
+ def test_defaults_to_api_when_prefix_absent(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
+ _write_host_json(tmp_path, {"version": "2.0", "extensions": {"http": {}}})
+ monkeypatch.setenv(SCRIPT_ROOT_ENV, str(tmp_path))
+ assert _routes.route_prefix() == "api"
+
+ def test_reads_custom_prefix(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
+ _write_host_json(tmp_path, {"extensions": {"http": {"routePrefix": "gateway"}}})
+ monkeypatch.setenv(SCRIPT_ROOT_ENV, str(tmp_path))
+ assert _routes.route_prefix() == "gateway"
+
+ def test_reads_empty_prefix(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
+ _write_host_json(tmp_path, {"extensions": {"http": {"routePrefix": ""}}})
+ monkeypatch.setenv(SCRIPT_ROOT_ENV, str(tmp_path))
+ assert _routes.route_prefix() == ""
+
+ def test_strips_surrounding_slashes(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
+ _write_host_json(tmp_path, {"extensions": {"http": {"routePrefix": "/custom/"}}})
+ monkeypatch.setenv(SCRIPT_ROOT_ENV, str(tmp_path))
+ assert _routes.route_prefix() == "custom"
+
+ def test_defaults_to_api_on_malformed_json(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
+ (tmp_path / "host.json").write_text("{ not json", encoding="utf-8")
+ monkeypatch.setenv(SCRIPT_ROOT_ENV, str(tmp_path))
+ assert _routes.route_prefix() == "api"
+
+ def test_caches_first_read(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
+ _write_host_json(tmp_path, {"extensions": {"http": {"routePrefix": "one"}}})
+ monkeypatch.setenv(SCRIPT_ROOT_ENV, str(tmp_path))
+ assert _routes.route_prefix() == "one"
+ # A later host.json change is not observed within a running host.
+ _write_host_json(tmp_path, {"extensions": {"http": {"routePrefix": "two"}}})
+ assert _routes.route_prefix() == "one"
+
+
+class TestUrlBuilders:
+ """Respond/status URL shapes, parameterized by an explicit prefix."""
+
+ def test_respond_url_default_prefix(self) -> None:
+ assert (
+ _routes.build_workflow_respond_url("https://h", "wf", "i", "r", prefix="api")
+ == "https://h/api/workflow/wf/respond/i/r"
+ )
+
+ def test_respond_url_custom_prefix(self) -> None:
+ assert (
+ _routes.build_workflow_respond_url("https://h", "wf", "i", "r", prefix="gw")
+ == "https://h/gw/workflow/wf/respond/i/r"
+ )
+
+ def test_respond_url_empty_prefix(self) -> None:
+ assert (
+ _routes.build_workflow_respond_url("https://h", "wf", "i", "r", prefix="")
+ == "https://h/workflow/wf/respond/i/r"
+ )
+
+ def test_respond_url_template_placeholder(self) -> None:
+ assert (
+ _routes.build_workflow_respond_url("https://h", "wf", "i", "{requestId}", prefix="api")
+ == "https://h/api/workflow/wf/respond/i/{requestId}"
+ )
+
+ def test_status_url_custom_prefix(self) -> None:
+ assert (
+ _routes.build_workflow_status_url("https://h", "wf", "i", prefix="gw")
+ == "https://h/gw/workflow/wf/status/i"
+ )
+
+ def test_status_url_empty_prefix(self) -> None:
+ assert _routes.build_workflow_status_url("https://h", "wf", "i", prefix="") == "https://h/workflow/wf/status/i"
+
+
+class TestSplitRequestUrl:
+ """Deriving base URL and route prefix from an incoming request URL."""
+
+ def test_default_prefix(self) -> None:
+ assert _routes.split_request_url("https://h:7071/api/workflow/wf/run") == ("https://h:7071", "api")
+
+ def test_custom_prefix(self) -> None:
+ assert _routes.split_request_url("https://h/gw/workflow/wf/status/i") == ("https://h", "gw")
+
+ def test_empty_prefix(self) -> None:
+ assert _routes.split_request_url("https://h/workflow/wf/run") == ("https://h", "")
+
+ def test_multi_segment_prefix(self) -> None:
+ assert _routes.split_request_url("https://h/a/b/workflow/wf/run") == ("https://h", "a/b")
+
+ def test_no_workflow_segment(self) -> None:
+ assert _routes.split_request_url("https://h/api/health") == ("https://h", "")
+
+ def test_non_absolute_falls_back(self) -> None:
+ assert _routes.split_request_url("/api/workflow/wf/run") == ("/api/workflow/wf/run", "")
diff --git a/python/packages/core/agent_framework/gemini/__init__ 2.py b/python/packages/core/agent_framework/gemini/__init__ 2.py
new file mode 100644
index 00000000000..43673773599
--- /dev/null
+++ b/python/packages/core/agent_framework/gemini/__init__ 2.py
@@ -0,0 +1,35 @@
+# Copyright (c) Microsoft. All rights reserved.
+
+"""Google Gemini namespace for optional Agent Framework connectors.
+
+This module lazily re-exports objects from ``agent-framework-gemini``.
+"""
+
+import importlib
+from typing import Any
+
+_IMPORTS: dict[str, tuple[str, str]] = {
+ "GeminiChatClient": ("agent_framework_gemini", "agent-framework-gemini"),
+ "GeminiChatOptions": ("agent_framework_gemini", "agent-framework-gemini"),
+ "GeminiSettings": ("agent_framework_gemini", "agent-framework-gemini"),
+ "GoogleGeminiSettings": ("agent_framework_gemini", "agent-framework-gemini"),
+ "RawGeminiChatClient": ("agent_framework_gemini", "agent-framework-gemini"),
+ "ThinkingConfig": ("agent_framework_gemini", "agent-framework-gemini"),
+}
+
+
+def __getattr__(name: str) -> Any:
+ if name in _IMPORTS:
+ import_path, package_name = _IMPORTS[name]
+ try:
+ return getattr(importlib.import_module(import_path), name)
+ except ModuleNotFoundError as exc:
+ raise ModuleNotFoundError(
+ f"The package {package_name} is required to use `{name}`. "
+ f"Please use `pip install {package_name}`, or update your requirements.txt or pyproject.toml file."
+ ) from exc
+ raise AttributeError(f"Module `gemini` has no attribute {name}.")
+
+
+def __dir__() -> list[str]:
+ return list(_IMPORTS.keys())
diff --git a/python/packages/core/agent_framework/gemini/__init__ 2.pyi b/python/packages/core/agent_framework/gemini/__init__ 2.pyi
new file mode 100644
index 00000000000..c9082c9191a
--- /dev/null
+++ b/python/packages/core/agent_framework/gemini/__init__ 2.pyi
@@ -0,0 +1,19 @@
+# Copyright (c) Microsoft. All rights reserved.
+
+from agent_framework_gemini import (
+ GeminiChatClient,
+ GeminiChatOptions,
+ GeminiSettings,
+ GoogleGeminiSettings,
+ RawGeminiChatClient,
+ ThinkingConfig,
+)
+
+__all__ = [
+ "GeminiChatClient",
+ "GeminiChatOptions",
+ "GeminiSettings",
+ "GoogleGeminiSettings",
+ "RawGeminiChatClient",
+ "ThinkingConfig",
+]
diff --git a/python/packages/core/agent_framework/mistral/__init__ 2.py b/python/packages/core/agent_framework/mistral/__init__ 2.py
new file mode 100644
index 00000000000..c4d742a75f1
--- /dev/null
+++ b/python/packages/core/agent_framework/mistral/__init__ 2.py
@@ -0,0 +1,32 @@
+# Copyright (c) Microsoft. All rights reserved.
+
+"""Mistral AI namespace for optional Agent Framework connectors.
+
+This module lazily re-exports objects from ``agent-framework-mistral``.
+"""
+
+import importlib
+from typing import Any
+
+_IMPORTS: dict[str, tuple[str, str]] = {
+ "MistralEmbeddingClient": ("agent_framework_mistral", "agent-framework-mistral"),
+ "MistralEmbeddingOptions": ("agent_framework_mistral", "agent-framework-mistral"),
+ "MistralEmbeddingSettings": ("agent_framework_mistral", "agent-framework-mistral"),
+}
+
+
+def __getattr__(name: str) -> Any:
+ if name in _IMPORTS:
+ import_path, package_name = _IMPORTS[name]
+ try:
+ return getattr(importlib.import_module(import_path), name)
+ except ModuleNotFoundError as exc:
+ raise ModuleNotFoundError(
+ f"The package {package_name} is required to use `{name}`. "
+ f"Please use `pip install {package_name}`, or update your requirements.txt or pyproject.toml file."
+ ) from exc
+ raise AttributeError(f"Module `mistral` has no attribute {name}.")
+
+
+def __dir__() -> list[str]:
+ return list(_IMPORTS.keys())
diff --git a/python/packages/core/agent_framework/mistral/__init__ 2.pyi b/python/packages/core/agent_framework/mistral/__init__ 2.pyi
new file mode 100644
index 00000000000..0868d21e02d
--- /dev/null
+++ b/python/packages/core/agent_framework/mistral/__init__ 2.pyi
@@ -0,0 +1,9 @@
+# Copyright (c) Microsoft. All rights reserved.
+
+from agent_framework_mistral import MistralEmbeddingClient, MistralEmbeddingOptions, MistralEmbeddingSettings
+
+__all__ = [
+ "MistralEmbeddingClient",
+ "MistralEmbeddingOptions",
+ "MistralEmbeddingSettings",
+]
diff --git a/python/packages/core/agent_framework/monty/__init__ 2.py b/python/packages/core/agent_framework/monty/__init__ 2.py
new file mode 100644
index 00000000000..53640f45c98
--- /dev/null
+++ b/python/packages/core/agent_framework/monty/__init__ 2.py
@@ -0,0 +1,34 @@
+# Copyright (c) Microsoft. All rights reserved.
+
+"""Monty CodeAct namespace for optional Agent Framework connectors.
+
+This module lazily re-exports objects from ``agent-framework-monty``.
+"""
+
+import importlib
+from typing import Any
+
+_IMPORTS: dict[str, tuple[str, str]] = {
+ "FileMount": ("agent_framework_monty", "agent-framework-monty"),
+ "FileMountInput": ("agent_framework_monty", "agent-framework-monty"),
+ "MontyCodeActProvider": ("agent_framework_monty", "agent-framework-monty"),
+ "MontyExecuteCodeTool": ("agent_framework_monty", "agent-framework-monty"),
+ "MountMode": ("agent_framework_monty", "agent-framework-monty"),
+}
+
+
+def __getattr__(name: str) -> Any:
+ if name in _IMPORTS:
+ import_path, package_name = _IMPORTS[name]
+ try:
+ return getattr(importlib.import_module(import_path), name)
+ except ModuleNotFoundError as exc:
+ raise ModuleNotFoundError(
+ f"The package {package_name} is required to use `{name}`. "
+ f"Please use `pip install {package_name}`, or update your requirements.txt or pyproject.toml file."
+ ) from exc
+ raise AttributeError(f"Module `monty` has no attribute {name}.")
+
+
+def __dir__() -> list[str]:
+ return list(_IMPORTS.keys())
diff --git a/python/packages/core/agent_framework/monty/__init__ 2.pyi b/python/packages/core/agent_framework/monty/__init__ 2.pyi
new file mode 100644
index 00000000000..ac31cae8560
--- /dev/null
+++ b/python/packages/core/agent_framework/monty/__init__ 2.pyi
@@ -0,0 +1,17 @@
+# Copyright (c) Microsoft. All rights reserved.
+
+from agent_framework_monty import (
+ FileMount,
+ FileMountInput,
+ MontyCodeActProvider,
+ MontyExecuteCodeTool,
+ MountMode,
+)
+
+__all__ = [
+ "FileMount",
+ "FileMountInput",
+ "MontyCodeActProvider",
+ "MontyExecuteCodeTool",
+ "MountMode",
+]
diff --git a/python/packages/core/agent_framework/tools/__init__ 2.py b/python/packages/core/agent_framework/tools/__init__ 2.py
new file mode 100644
index 00000000000..b92a0865ef9
--- /dev/null
+++ b/python/packages/core/agent_framework/tools/__init__ 2.py
@@ -0,0 +1,48 @@
+# Copyright (c) Microsoft. All rights reserved.
+
+"""Built-in tools namespace for optional Agent Framework connectors.
+
+This module lazily re-exports objects from ``agent-framework-tools``.
+"""
+
+import importlib
+from typing import Any
+
+_IMPORTS: dict[str, tuple[str, str]] = {
+ "DOCKER_DEFAULT_IMAGE": ("agent_framework_tools.shell", "agent-framework-tools"),
+ "DockerNotAvailableError": ("agent_framework_tools.shell", "agent-framework-tools"),
+ "DockerShellTool": ("agent_framework_tools.shell", "agent-framework-tools"),
+ "LocalShellTool": ("agent_framework_tools.shell", "agent-framework-tools"),
+ "ShellCommandError": ("agent_framework_tools.shell", "agent-framework-tools"),
+ "ShellDecision": ("agent_framework_tools.shell", "agent-framework-tools"),
+ "ShellEnvironmentProvider": ("agent_framework_tools.shell", "agent-framework-tools"),
+ "ShellEnvironmentProviderOptions": ("agent_framework_tools.shell", "agent-framework-tools"),
+ "ShellEnvironmentSnapshot": ("agent_framework_tools.shell", "agent-framework-tools"),
+ "ShellExecutionError": ("agent_framework_tools.shell", "agent-framework-tools"),
+ "ShellExecutor": ("agent_framework_tools.shell", "agent-framework-tools"),
+ "ShellFamily": ("agent_framework_tools.shell", "agent-framework-tools"),
+ "ShellMode": ("agent_framework_tools.shell", "agent-framework-tools"),
+ "ShellPolicy": ("agent_framework_tools.shell", "agent-framework-tools"),
+ "ShellRequest": ("agent_framework_tools.shell", "agent-framework-tools"),
+ "ShellResult": ("agent_framework_tools.shell", "agent-framework-tools"),
+ "ShellTimeoutError": ("agent_framework_tools.shell", "agent-framework-tools"),
+ "default_instructions_formatter": ("agent_framework_tools.shell", "agent-framework-tools"),
+ "is_docker_available": ("agent_framework_tools.shell", "agent-framework-tools"),
+}
+
+
+def __getattr__(name: str) -> Any:
+ if name in _IMPORTS:
+ import_path, package_name = _IMPORTS[name]
+ try:
+ return getattr(importlib.import_module(import_path), name)
+ except ModuleNotFoundError as exc:
+ raise ModuleNotFoundError(
+ f"The package {package_name} is required to use `{name}`. "
+ f"Please use `pip install {package_name}`, or update your requirements.txt or pyproject.toml file."
+ ) from exc
+ raise AttributeError(f"Module `tools` has no attribute {name}.")
+
+
+def __dir__() -> list[str]:
+ return list(_IMPORTS.keys())
diff --git a/python/packages/core/agent_framework/tools/__init__ 2.pyi b/python/packages/core/agent_framework/tools/__init__ 2.pyi
new file mode 100644
index 00000000000..a1768b3b60d
--- /dev/null
+++ b/python/packages/core/agent_framework/tools/__init__ 2.pyi
@@ -0,0 +1,45 @@
+# Copyright (c) Microsoft. All rights reserved.
+
+from agent_framework_tools.shell import (
+ DOCKER_DEFAULT_IMAGE,
+ DockerNotAvailableError,
+ DockerShellTool,
+ LocalShellTool,
+ ShellCommandError,
+ ShellDecision,
+ ShellEnvironmentProvider,
+ ShellEnvironmentProviderOptions,
+ ShellEnvironmentSnapshot,
+ ShellExecutionError,
+ ShellExecutor,
+ ShellFamily,
+ ShellMode,
+ ShellPolicy,
+ ShellRequest,
+ ShellResult,
+ ShellTimeoutError,
+ default_instructions_formatter,
+ is_docker_available,
+)
+
+__all__ = [
+ "DOCKER_DEFAULT_IMAGE",
+ "DockerNotAvailableError",
+ "DockerShellTool",
+ "LocalShellTool",
+ "ShellCommandError",
+ "ShellDecision",
+ "ShellEnvironmentProvider",
+ "ShellEnvironmentProviderOptions",
+ "ShellEnvironmentSnapshot",
+ "ShellExecutionError",
+ "ShellExecutor",
+ "ShellFamily",
+ "ShellMode",
+ "ShellPolicy",
+ "ShellRequest",
+ "ShellResult",
+ "ShellTimeoutError",
+ "default_instructions_formatter",
+ "is_docker_available",
+]
diff --git a/python/packages/core/tests/core/test_gemini_namespace 2.py b/python/packages/core/tests/core/test_gemini_namespace 2.py
new file mode 100644
index 00000000000..a59c7bd0e3c
--- /dev/null
+++ b/python/packages/core/tests/core/test_gemini_namespace 2.py
@@ -0,0 +1,42 @@
+# Copyright (c) Microsoft. All rights reserved.
+
+import sys
+from types import ModuleType
+
+import pytest
+
+import agent_framework.gemini as gemini
+
+
+def test_gemini_namespace_dir_lists_lazy_exports() -> None:
+ names = dir(gemini)
+ for expected in (
+ "GeminiChatClient",
+ "GeminiChatOptions",
+ "GeminiSettings",
+ "GoogleGeminiSettings",
+ "RawGeminiChatClient",
+ "ThinkingConfig",
+ ):
+ assert expected in names
+
+
+def test_gemini_namespace_lazy_loads_known_attribute(monkeypatch: pytest.MonkeyPatch) -> None:
+ sentinel = object()
+ fake_module = ModuleType("agent_framework_gemini")
+ fake_module.GeminiChatClient = sentinel # type: ignore[attr-defined] # ty: ignore[unresolved-attribute]
+ monkeypatch.setitem(sys.modules, "agent_framework_gemini", fake_module)
+
+ assert gemini.GeminiChatClient is sentinel
+
+
+def test_gemini_namespace_unknown_attribute_raises_attribute_error() -> None:
+ with pytest.raises(AttributeError, match="Module `gemini` has no attribute DoesNotExist."):
+ _ = gemini.DoesNotExist # type: ignore[attr-defined] # ty: ignore[unresolved-attribute]
+
+
+def test_gemini_namespace_missing_package_raises_helpful_error(monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.setitem(sys.modules, "agent_framework_gemini", None)
+
+ with pytest.raises(ModuleNotFoundError, match="agent-framework-gemini"):
+ _ = gemini.GeminiChatClient
diff --git a/python/packages/core/tests/core/test_mistral_namespace 2.py b/python/packages/core/tests/core/test_mistral_namespace 2.py
new file mode 100644
index 00000000000..1f6cbb5a6cb
--- /dev/null
+++ b/python/packages/core/tests/core/test_mistral_namespace 2.py
@@ -0,0 +1,39 @@
+# Copyright (c) Microsoft. All rights reserved.
+
+import sys
+from types import ModuleType
+
+import pytest
+
+import agent_framework.mistral as mistral
+
+
+def test_mistral_namespace_dir_lists_lazy_exports() -> None:
+ names = dir(mistral)
+ for expected in (
+ "MistralEmbeddingClient",
+ "MistralEmbeddingOptions",
+ "MistralEmbeddingSettings",
+ ):
+ assert expected in names
+
+
+def test_mistral_namespace_lazy_loads_known_attribute(monkeypatch: pytest.MonkeyPatch) -> None:
+ sentinel = object()
+ fake_module = ModuleType("agent_framework_mistral")
+ fake_module.MistralEmbeddingClient = sentinel # type: ignore[attr-defined] # ty: ignore[unresolved-attribute]
+ monkeypatch.setitem(sys.modules, "agent_framework_mistral", fake_module)
+
+ assert mistral.MistralEmbeddingClient is sentinel
+
+
+def test_mistral_namespace_unknown_attribute_raises_attribute_error() -> None:
+ with pytest.raises(AttributeError, match="Module `mistral` has no attribute DoesNotExist."):
+ _ = mistral.DoesNotExist # type: ignore[attr-defined] # ty: ignore[unresolved-attribute]
+
+
+def test_mistral_namespace_missing_package_raises_helpful_error(monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.setitem(sys.modules, "agent_framework_mistral", None)
+
+ with pytest.raises(ModuleNotFoundError, match="agent-framework-mistral"):
+ _ = mistral.MistralEmbeddingClient
diff --git a/python/packages/core/tests/core/test_monty_namespace 2.py b/python/packages/core/tests/core/test_monty_namespace 2.py
new file mode 100644
index 00000000000..364b59fd7bc
--- /dev/null
+++ b/python/packages/core/tests/core/test_monty_namespace 2.py
@@ -0,0 +1,41 @@
+# Copyright (c) Microsoft. All rights reserved.
+
+import sys
+from types import ModuleType
+
+import pytest
+
+import agent_framework.monty as monty
+
+
+def test_monty_namespace_dir_lists_lazy_exports() -> None:
+ names = dir(monty)
+ for expected in (
+ "FileMount",
+ "FileMountInput",
+ "MontyCodeActProvider",
+ "MontyExecuteCodeTool",
+ "MountMode",
+ ):
+ assert expected in names
+
+
+def test_monty_namespace_lazy_loads_known_attribute(monkeypatch: pytest.MonkeyPatch) -> None:
+ sentinel = object()
+ fake_module = ModuleType("agent_framework_monty")
+ fake_module.MontyCodeActProvider = sentinel # type: ignore[attr-defined] # ty: ignore[unresolved-attribute]
+ monkeypatch.setitem(sys.modules, "agent_framework_monty", fake_module)
+
+ assert monty.MontyCodeActProvider is sentinel
+
+
+def test_monty_namespace_unknown_attribute_raises_attribute_error() -> None:
+ with pytest.raises(AttributeError, match="Module `monty` has no attribute DoesNotExist."):
+ _ = monty.DoesNotExist # type: ignore[attr-defined] # ty: ignore[unresolved-attribute]
+
+
+def test_monty_namespace_missing_package_raises_helpful_error(monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.setitem(sys.modules, "agent_framework_monty", None)
+
+ with pytest.raises(ModuleNotFoundError, match="agent-framework-monty"):
+ _ = monty.MontyCodeActProvider
diff --git a/python/packages/core/tests/core/test_tools_namespace 2.py b/python/packages/core/tests/core/test_tools_namespace 2.py
new file mode 100644
index 00000000000..1a2a5801c50
--- /dev/null
+++ b/python/packages/core/tests/core/test_tools_namespace 2.py
@@ -0,0 +1,42 @@
+# Copyright (c) Microsoft. All rights reserved.
+
+import sys
+from types import ModuleType
+
+import pytest
+
+import agent_framework.tools as tools
+
+
+def test_tools_namespace_dir_lists_lazy_exports() -> None:
+ names = dir(tools)
+ for expected in (
+ "DockerShellTool",
+ "LocalShellTool",
+ "ShellEnvironmentProvider",
+ "ShellEnvironmentProviderOptions",
+ "ShellExecutor",
+ "ShellPolicy",
+ ):
+ assert expected in names
+
+
+def test_tools_namespace_lazy_loads_known_attribute(monkeypatch: pytest.MonkeyPatch) -> None:
+ sentinel = object()
+ fake_module = ModuleType("agent_framework_tools.shell")
+ fake_module.LocalShellTool = sentinel # type: ignore[attr-defined] # ty: ignore[unresolved-attribute]
+ monkeypatch.setitem(sys.modules, "agent_framework_tools.shell", fake_module)
+
+ assert tools.LocalShellTool is sentinel
+
+
+def test_tools_namespace_unknown_attribute_raises_attribute_error() -> None:
+ with pytest.raises(AttributeError, match="Module `tools` has no attribute DoesNotExist."):
+ _ = tools.DoesNotExist # type: ignore[attr-defined] # ty: ignore[unresolved-attribute]
+
+
+def test_tools_namespace_missing_package_raises_helpful_error(monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.setitem(sys.modules, "agent_framework_tools.shell", None)
+
+ with pytest.raises(ModuleNotFoundError, match="agent-framework-tools"):
+ _ = tools.LocalShellTool
diff --git a/python/packages/hosting-mcp/AGENTS 2.md b/python/packages/hosting-mcp/AGENTS 2.md
new file mode 100644
index 00000000000..b4b403280e0
--- /dev/null
+++ b/python/packages/hosting-mcp/AGENTS 2.md
@@ -0,0 +1,53 @@
+# MCP Hosting Helpers (`agent-framework-hosting-mcp`)
+
+Side-effect-free adapters and conversion helpers for hosting Agent Framework
+agents and workflows through the native MCP SDK.
+
+## Public API
+
+- `AgentMCPTool(target, ...)` generates one native MCP `Tool` from an agent,
+ converts and executes calls, and optionally persists sessions through an
+ existing `AgentState`.
+- `WorkflowMCPTool(target, ...)` derives one native MCP `Tool` from a workflow's
+ single start-executor input type and converts completed workflow outputs.
+- `mcp_to_run(arguments, *, argument_name="task",
+ chat_option_arguments=())` converts MCP tool arguments to `AgentRunArgs` and
+ copies only explicitly selected arguments into chat options.
+- `mcp_from_run(result)` converts an Agent Framework response or message to MCP
+ `ContentBlock` values.
+
+## Boundary
+
+This package does not provide a server, routes, transport lifecycle,
+authentication, authorization, session-key policy, concurrency policy, or
+outbound delivery. Applications compose the adapter and conversion helpers
+with native MCP SDK constructs.
+
+`AgentMCPTool` owns only the schema for its single generated agent tool. It
+does not register that schema with a server. Applications call
+`await adapter.list_tools()` and `await adapter.call_tool(...)` from native MCP
+handlers.
+
+`WorkflowMCPTool` owns only the schema derived from the start executor. It
+requires exactly one input type. Workflow factories and continuation policy
+remain application-owned. Pending external-input requests raise because the
+adapter does not own a human-in-the-loop continuation contract.
+
+When configured with `AgentState`, the adapter performs session get/run/set.
+Applications still derive and authorize the session identifier and serialize
+concurrent calls for the same session.
+
+MCP tool arguments are JSON-only and have no native multimodal content-block
+union. Do not add a package-owned JSON convention for image or audio input.
+
+`mcp_from_run(...)` intentionally returns a flat content block list. It
+preserves content-level metadata, while applications own MCP result-level
+metadata and structured content.
+
+The helper targets `CallToolResult.content`. Do not add sampling-only
+`ToolUseContent` to its output; MCP sampling has a separate response content
+union.
+
+`CallToolResult` is a single final result. Streamable HTTP transports MCP
+messages rather than partial result content; progress notifications and
+experimental tasks remain application-owned protocol concerns.
diff --git a/python/packages/hosting-mcp/LICENSE 2 b/python/packages/hosting-mcp/LICENSE 2
new file mode 100644
index 00000000000..9e841e7a26e
--- /dev/null
+++ b/python/packages/hosting-mcp/LICENSE 2
@@ -0,0 +1,21 @@
+ MIT License
+
+ Copyright (c) Microsoft Corporation.
+
+ Permission is hereby granted, free of charge, to any person obtaining a copy
+ of this software and associated documentation files (the "Software"), to deal
+ in the Software without restriction, including without limitation the rights
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ copies of the Software, and to permit persons to whom the Software is
+ furnished to do so, subject to the following conditions:
+
+ The above copyright notice and this permission notice shall be included in all
+ copies or substantial portions of the Software.
+
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ SOFTWARE
diff --git a/python/packages/hosting-mcp/README 2.md b/python/packages/hosting-mcp/README 2.md
new file mode 100644
index 00000000000..8b077efb99e
--- /dev/null
+++ b/python/packages/hosting-mcp/README 2.md
@@ -0,0 +1,174 @@
+# agent-framework-hosting-mcp
+
+MCP conversion helpers for app-owned Agent Framework hosting.
+
+The package deliberately does not choose a web framework or wrap the MCP SDK
+server lifecycle. It provides two conversion functions and small adapters:
+
+- `mcp_to_run(...)` converts native MCP tool arguments into Agent Framework run
+ arguments.
+- `mcp_from_run(...)` converts an `AgentResponse` or `Message` into native MCP
+ `ContentBlock` values.
+- `AgentMCPTool(...)` generates the native `Tool` definition from an agent and
+ keeps listing, parsing, execution, result conversion, and optional
+ `AgentState` session persistence aligned.
+- `WorkflowMCPTool(...)` generates the native `Tool` definition from a
+ workflow's start-executor input type and converts completed workflow outputs.
+
+Application code keeps ownership of the MCP SDK's `Server`, handler
+registration, request context, transport, session-key policy, authentication,
+authorization, and deployment.
+
+For direct conversion, the argument name is part of the app-owned MCP tool
+contract. Define it once and use the same value in both the native tool schema
+and `mcp_to_run(...)`:
+
+```python
+agent_input_argument = "task"
+chat_option_arguments = {
+ "reasoning_effort": {
+ "type": "string",
+ "enum": ["low", "medium", "high"],
+ },
+}
+
+tool = Tool(
+ name="run_agent",
+ inputSchema={
+ "type": "object",
+ "properties": {
+ agent_input_argument: {"type": "string"},
+ **chat_option_arguments,
+ },
+ "required": [agent_input_argument],
+ },
+)
+run = mcp_to_run(
+ arguments,
+ argument_name=agent_input_argument,
+ chat_option_arguments=chat_option_arguments,
+)
+```
+
+Only names listed in `chat_option_arguments` are copied to `run["options"]`;
+other MCP arguments remain available in the message's raw representation but
+are not forwarded to the model client. The native MCP schema remains
+responsible for validating exposed option types and ranges.
+
+For an agent exposed as one MCP tool, use the adapter so the schema and
+conversion cannot drift:
+
+```python
+agent_tool = AgentMCPTool(
+ agent,
+ name="run_agent",
+ argument_description="The request for the hosted agent.",
+ parameters={"audience": {"type": "string"}},
+ chat_option_parameters={
+ "reasoning_effort": {
+ "type": "string",
+ "enum": ["low", "medium", "high"],
+ }
+ },
+)
+
+@server.list_tools()
+async def list_tools():
+ return await agent_tool.list_tools()
+
+@server.call_tool()
+async def call_tool(name, arguments):
+ return await agent_tool.call_tool(name, arguments)
+```
+
+`AgentMCPTool` uses the agent's name and description unless overridden.
+`parameters` adds app-owned JSON Schema properties that remain available in the
+raw MCP arguments. `chat_option_parameters` adds properties and explicitly
+copies their values into Agent Framework chat options.
+
+For a workflow exposed as one MCP tool, use `WorkflowMCPTool`:
+
+```python
+workflow_tool = WorkflowMCPTool(
+ WorkflowState(create_workflow, cache_target=False),
+ name="run_workflow",
+)
+```
+
+The start executor must declare exactly one input type. Dataclass, Pydantic, and
+other object-shaped inputs become the MCP tool's top-level arguments. Primitive
+inputs are wrapped in the configurable `argument_name` property. The adapter
+validates MCP arguments against that derived type before calling
+`workflow.run(...)`.
+
+Workflow instances preserve execution state, so applications that need
+independent calls should supply a `WorkflowState` factory with
+`cache_target=False`, as above. Checkpoint restoration, human-in-the-loop
+responses, and continuation identifiers remain application-owned contracts.
+If a workflow requests external input, the adapter raises instead of returning
+an empty successful tool result.
+
+Pass an existing `AgentState` plus `session_id_parameter` to persist an
+`AgentSession`:
+
+```python
+state = AgentState(agent)
+agent_tool = AgentMCPTool(
+ state,
+ parameters={"session_id": {"type": "string", "minLength": 1}},
+ required_parameters={"session_id"},
+ session_id_parameter="session_id",
+)
+```
+
+The application must authenticate or authorize that session identifier and
+serialize concurrent calls for the same session. The adapter only performs the
+`AgentState` session-store get/run/set sequence. A configured session parameter
+is always marked required in the generated MCP schema.
+
+The session identifier is an opaque, application-defined key. Neither MCP nor
+Agent Framework prescribes its format. `AgentMCPTool` treats it as the key for
+one mutable conversation: each call loads that session and stores the updated
+session under the same key. It does not implement
+`previous_response_id`-style branching. Branching requires an app-owned
+contract with separate source and destination identifiers so the application
+can authorize both, copy the source session, and store the result under the
+destination key.
+
+MCP `tools/call` inputs are JSON objects defined by the app's `inputSchema`;
+the protocol does not define image, audio, or resource content blocks for tool
+arguments. This helper therefore converts one selected string argument and
+does not impose a non-standard multimodal JSON convention.
+
+For non-image/audio binary output, `mcp_from_run(...)` uses an app-provided
+`content.additional_properties["uri"]` when present and otherwise uses the
+short fallback `af://binary`; the payload itself is stored only in the MCP
+resource's `blob` field.
+
+`mcp_from_run(...)` targets `CallToolResult.content`, whose MCP content union
+does not include sampling-only `ToolUseContent`. Agent Framework
+`function_call` content is therefore omitted from tool results. MCP sampling
+callbacks use a separate response contract and may convert function calls to
+`ToolUseContent`.
+
+MCP tool calls return one final `CallToolResult`; they do not stream partial
+content blocks. Streamable HTTP may carry multiple MCP messages, and apps may
+send progress notifications while work runs, but neither mechanism turns
+Agent Framework response updates into incremental tool results. Experimental
+MCP tasks defer retrieval of the same final result.
+
+```python
+run = mcp_to_run(arguments)
+result = await agent.run(
+ run["messages"],
+ options=run["options"],
+)
+content = mcp_from_run(result)
+
+# Native MCP SDK application code returns `content` from its call_tool handler.
+```
+
+The surrounding MCP application still owns the low-level `Server`, handler
+registration, Starlette/FastAPI composition, stdio or streamable HTTP
+transport, request authentication, session-key trust, concurrency, and
+deployment.
diff --git a/python/packages/hosting-mcp/pyproject 2.toml b/python/packages/hosting-mcp/pyproject 2.toml
new file mode 100644
index 00000000000..1374951e983
--- /dev/null
+++ b/python/packages/hosting-mcp/pyproject 2.toml
@@ -0,0 +1,81 @@
+[project]
+name = "agent-framework-hosting-mcp"
+description = "Agent and workflow MCP tool adapters for app-owned hosting."
+authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
+readme = "README.md"
+requires-python = ">=3.10"
+version = "1.0.0a260721"
+license-files = ["LICENSE"]
+urls.homepage = "https://aka.ms/agent-framework"
+urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
+urls.release_notes = "https://github.com/microsoft/agent-framework/releases?q=tag%3Apython-1&expanded=true"
+urls.issues = "https://github.com/microsoft/agent-framework/issues"
+classifiers = [
+ "License :: OSI Approved :: MIT License",
+ "Development Status :: 3 - Alpha",
+ "Intended Audience :: Developers",
+ "Programming Language :: Python :: 3",
+ "Programming Language :: Python :: 3.10",
+ "Programming Language :: Python :: 3.11",
+ "Programming Language :: Python :: 3.12",
+ "Programming Language :: Python :: 3.13",
+ "Programming Language :: Python :: 3.14",
+ "Typing :: Typed",
+]
+dependencies = [
+ "agent-framework-core>=1.11.0,<2",
+ "agent-framework-hosting==1.0.0a260721",
+ "mcp>=1.11.0,<2",
+ "pydantic>=2,<3",
+]
+
+[tool.uv]
+prerelease = "if-necessary-or-explicit"
+environments = [
+ "sys_platform == 'darwin'",
+ "sys_platform == 'linux'",
+ "sys_platform == 'win32'"
+]
+
+[tool.uv-dynamic-versioning]
+fallback-version = "0.0.0"
+
+[tool.pytest.ini_options]
+testpaths = 'tests'
+addopts = "-ra -q -r fEX"
+asyncio_mode = "auto"
+asyncio_default_fixture_loop_scope = "function"
+filterwarnings = []
+timeout = 120
+markers = [
+ "integration: marks tests as integration tests that require external services",
+]
+
+[tool.ruff]
+extend = "../../pyproject.toml"
+
+[tool.coverage.run]
+omit = [
+ "**/__init__.py"
+]
+
+[tool.pyright]
+extends = "../../pyproject.toml"
+include = ["agent_framework_hosting_mcp"]
+exclude = ['tests']
+
+[tool.bandit]
+targets = ["agent_framework_hosting_mcp"]
+exclude_dirs = ["tests"]
+
+[tool.poe]
+executor.type = "uv"
+include = "../../shared_tasks.toml"
+
+[tool.poe.tasks.test]
+help = "Run the default unit test suite for this package."
+cmd = 'pytest -m "not integration" --cov=agent_framework_hosting_mcp --cov-report=term-missing:skip-covered tests'
+
+[build-system]
+requires = ["flit-core >= 3.11,<4.0"]
+build-backend = "flit_core.buildapi"
diff --git a/python/packages/openai/agent_framework_openai/_chat_client.py b/python/packages/openai/agent_framework_openai/_chat_client.py
index 00032966193..950ecd92152 100644
--- a/python/packages/openai/agent_framework_openai/_chat_client.py
+++ b/python/packages/openai/agent_framework_openai/_chat_client.py
@@ -1695,7 +1695,10 @@ def _prepare_message_for_openai(
)
if function_call:
all_messages.append(function_call)
- case "function_approval_response" | "function_approval_request":
+ case "function_approval_request":
+ # The model's approval request is a server-issued item; under a
+ # continuation the server already has it, so replaying it inline
+ # duplicates it (#3295). Same treatment as function_call above.
if request_uses_service_side_storage:
continue
prepared = self._prepare_content_for_openai(
@@ -1705,6 +1708,19 @@ def _prepare_message_for_openai(
)
if prepared:
all_messages.append(prepared)
+ case "function_approval_response":
+ # The approval response is the user's decision that resumes an
+ # approval-paused run. It references the prior request by id and is
+ # not itself a server-stored item, so it must reach the model even
+ # under service-side storage; skipping it leaves the run paused
+ # forever (#7125). Same treatment as function_result above.
+ prepared = self._prepare_content_for_openai(
+ message.role,
+ content,
+ replays_local_storage=replays_local_storage,
+ )
+ if prepared:
+ all_messages.append(prepared)
case "mcp_server_tool_call" | "mcp_server_tool_result":
# Hosted MCP call/result contents serialize as a single
# top-level mcp_call input item; the result side emits an
diff --git a/python/packages/openai/tests/openai/test_openai_chat_client.py b/python/packages/openai/tests/openai/test_openai_chat_client.py
index 80ea1f626a0..70932e725e5 100644
--- a/python/packages/openai/tests/openai/test_openai_chat_client.py
+++ b/python/packages/openai/tests/openai/test_openai_chat_client.py
@@ -3287,6 +3287,34 @@ def test_prepare_message_for_openai_with_function_approval_response() -> None:
assert prepared_message["approve"] is True
+def test_prepare_message_for_openai_keeps_approval_response_under_service_side_storage() -> None:
+ """The approval response resumes an approval-paused run and must survive service-side storage.
+
+ It references the prior approval request by id and is not itself a server-stored item, so
+ stripping it (as the #3295 duplicate-item fix used to) leaves the run paused forever (#7125).
+ """
+ client = OpenAIChatClient(model="test-model", api_key="test-key")
+
+ function_call = Content.from_function_call(
+ call_id="call_789",
+ name="execute_command",
+ arguments='{"command": "ls"}',
+ )
+ approval_response = Content.from_function_approval_response(
+ approved=True,
+ id="approval_003",
+ function_call=function_call,
+ )
+ message = Message(role="user", contents=[approval_response])
+
+ result = client._prepare_message_for_openai(message, request_uses_service_side_storage=True)
+
+ assert len(result) == 1
+ assert result[0]["type"] == "mcp_approval_response"
+ assert result[0]["approval_request_id"] == "approval_003"
+ assert result[0]["approve"] is True
+
+
def test_prepare_messages_for_openai_keeps_active_function_call_for_tool_loop() -> None:
"""An active tool loop retains its current function call until the model produces a follow-up."""
client = OpenAIChatClient(model="test-model", api_key="test-key")
@@ -7799,8 +7827,12 @@ def test_prepare_messages_keeps_function_call_without_storage() -> None:
def test_prepare_messages_strips_approval_items_under_storage() -> None:
- """Approval request/response items also carry server-issued IDs and must be stripped under
- storage. Without storage they are kept (#3295)."""
+ """Approval request items carry server-issued IDs and must be stripped under storage (#3295).
+
+ The approval response is different: it is the user's new decision that resumes an
+ approval-paused run, not a server-stored item, so it stays even under storage (#7125).
+ Without storage both are kept.
+ """
client = OpenAIChatClient(model="test-model", api_key="test-key")
function_call = Content.from_function_call(
@@ -7825,7 +7857,7 @@ def test_prepare_messages_strips_approval_items_under_storage() -> None:
storage_on = client._prepare_messages_for_openai(messages, request_uses_service_side_storage=True)
storage_on_types = [item.get("type") for item in storage_on]
assert "mcp_approval_request" not in storage_on_types
- assert "mcp_approval_response" not in storage_on_types
+ assert "mcp_approval_response" in storage_on_types
storage_off = client._prepare_messages_for_openai(messages, request_uses_service_side_storage=False)
storage_off_types = [item.get("type") for item in storage_off]
diff --git a/python/samples/02-agents/context_providers/azure_content_understanding/01_document_qa 2.py b/python/samples/02-agents/context_providers/azure_content_understanding/01_document_qa 2.py
new file mode 100644
index 00000000000..a8f039e89d5
--- /dev/null
+++ b/python/samples/02-agents/context_providers/azure_content_understanding/01_document_qa 2.py
@@ -0,0 +1,116 @@
+# Copyright (c) Microsoft. All rights reserved.
+# /// script
+# requires-python = ">=3.10"
+# dependencies = [
+# "agent-framework-azure-contentunderstanding",
+# "agent-framework-foundry",
+# ]
+# ///
+# Run with: uv run samples/02-agents/context_providers/azure_content_understanding/01_document_qa.py
+
+
+import asyncio
+import os
+from pathlib import Path
+
+from agent_framework import Agent, Content, Message
+from agent_framework.foundry import ContentUnderstandingContextProvider, FoundryChatClient
+from azure.identity.aio import AzureCliCredential
+from dotenv import load_dotenv
+
+load_dotenv()
+
+"""
+Document Q&A — PDF upload with CU-powered extraction
+
+This sample demonstrates the simplest CU integration: upload a PDF and
+ask questions about it. Azure Content Understanding extracts structured
+markdown with table preservation — superior to LLM-only vision for
+scanned PDFs, handwritten content, and complex layouts.
+
+Environment variables:
+ FOUNDRY_PROJECT_ENDPOINT — Microsoft Foundry project endpoint
+ FOUNDRY_MODEL — Model deployment name (e.g. gpt-4.1)
+ AZURE_CONTENTUNDERSTANDING_ENDPOINT — CU endpoint URL
+"""
+
+# Path to a sample PDF — uses the shared sample asset if available,
+# otherwise falls back to a public URL
+SAMPLE_PDF_PATH = Path(__file__).resolve().parent / "sample_assets" / "invoice.pdf"
+
+
+async def main() -> None:
+ credential = AzureCliCredential()
+
+ # Set up Azure Content Understanding context provider
+ cu = ContentUnderstandingContextProvider(
+ endpoint=os.environ["AZURE_CONTENTUNDERSTANDING_ENDPOINT"],
+ credential=credential,
+ analyzer_id="prebuilt-documentSearch", # RAG-optimized document analyzer
+ max_wait=None, # wait until CU analysis finishes (no background deferral)
+ )
+
+ # Set up the LLM client
+ client = FoundryChatClient(
+ project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
+ model=os.environ["FOUNDRY_MODEL"],
+ credential=credential,
+ )
+
+ # Create agent with CU context provider.
+ # The provider extracts document content via CU and injects it into the
+ # LLM context so the agent can answer questions about the document.
+ async with credential, cu:
+ agent = Agent(
+ client=client,
+ name="DocumentQA",
+ instructions=(
+ "You are a helpful document analyst. Use the analyzed document "
+ "content and extracted fields to answer questions precisely."
+ ),
+ context_providers=[cu],
+ )
+
+ # --- Turn 1: Upload PDF and ask a question ---
+ # 4. Upload PDF and ask questions
+ # The CU provider extracts markdown + fields from the PDF and injects
+ # the full content into context so the agent can answer precisely.
+ print("--- Upload PDF and ask questions ---")
+
+ pdf_bytes = SAMPLE_PDF_PATH.read_bytes()
+
+ response = await agent.run(
+ Message(
+ role="user",
+ contents=[
+ Content.from_text(
+ "What is this document about? Who is the vendor, and what is the total amount due?"
+ ),
+ Content.from_data(
+ pdf_bytes,
+ "application/pdf",
+ # Always provide filename — used as the document key
+ additional_properties={"filename": SAMPLE_PDF_PATH.name},
+ ),
+ ],
+ )
+ )
+ usage = response.usage_details or {}
+ print(f"Agent: {response}")
+ print(f" [Input tokens: {usage.get('input_token_count', 'N/A')}]\n")
+
+
+if __name__ == "__main__":
+ asyncio.run(main())
+
+"""
+Sample output:
+
+--- Upload PDF and ask questions ---
+Agent: This document is an **invoice** for services and fees billed to
+ **MICROSOFT CORPORATION** (Invoice **INV-100**), including line items
+ (e.g., Consulting Services, Document Fee, Printing Fee) and a billing summary.
+ - **Vendor:** **CONTOSO LTD.**
+ - **Total amount due:** **$610.00**
+ [Input tokens: 988]
+"""
diff --git a/python/samples/02-agents/context_providers/azure_content_understanding/02_multi_turn_session 2.py b/python/samples/02-agents/context_providers/azure_content_understanding/02_multi_turn_session 2.py
new file mode 100644
index 00000000000..87a705dcd98
--- /dev/null
+++ b/python/samples/02-agents/context_providers/azure_content_understanding/02_multi_turn_session 2.py
@@ -0,0 +1,142 @@
+# Copyright (c) Microsoft. All rights reserved.
+# /// script
+# requires-python = ">=3.10"
+# dependencies = [
+# "agent-framework-azure-contentunderstanding",
+# "agent-framework-foundry",
+# ]
+# ///
+# Run with: uv run samples/02-agents/context_providers/azure_content_understanding/02_multi_turn_session.py
+
+
+import asyncio
+import os
+from pathlib import Path
+
+from agent_framework import Agent, AgentSession, Content, Message
+from agent_framework.foundry import ContentUnderstandingContextProvider, FoundryChatClient
+from azure.identity.aio import AzureCliCredential
+from dotenv import load_dotenv
+
+load_dotenv()
+
+"""
+Multi-Turn Session — Cached results across turns
+
+This sample demonstrates multi-turn document Q&A using an AgentSession.
+The session persists CU analysis results and conversation history across
+turns so the agent can answer follow-up questions about previously
+uploaded documents without re-analyzing them.
+
+Key concepts:
+ - AgentSession keeps CU state and conversation history across agent.run() calls
+ - Turn 1: CU analyzes the PDF and injects full content into context
+ - Turn 2: Unrelated question — agent answers from general knowledge
+ - Turn 3: Detailed question — agent uses document content from conversation
+ history (injected in Turn 1) to answer precisely
+
+Environment variables:
+ FOUNDRY_PROJECT_ENDPOINT — Microsoft Foundry project endpoint
+ FOUNDRY_MODEL — Model deployment name (e.g. gpt-4.1)
+ AZURE_CONTENTUNDERSTANDING_ENDPOINT — CU endpoint URL
+"""
+
+SAMPLE_PDF_PATH = Path(__file__).resolve().parent / "sample_assets" / "invoice.pdf"
+
+
+async def main() -> None:
+ # 1. Set up credentials and CU context provider
+ credential = AzureCliCredential()
+
+ cu = ContentUnderstandingContextProvider(
+ endpoint=os.environ["AZURE_CONTENTUNDERSTANDING_ENDPOINT"],
+ credential=credential,
+ analyzer_id="prebuilt-documentSearch",
+ max_wait=None, # wait until CU analysis finishes (no background deferral)
+ )
+
+ # 2. Set up the LLM client
+ client = FoundryChatClient(
+ project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
+ model=os.environ["FOUNDRY_MODEL"],
+ credential=credential,
+ )
+
+ # 3. Create agent and persistent session
+ async with credential, cu:
+ agent = Agent(
+ client=client,
+ name="DocumentQA",
+ instructions=(
+ "You are a helpful document analyst. Use the analyzed document "
+ "content and extracted fields to answer questions precisely."
+ ),
+ context_providers=[cu],
+ )
+
+ # Create a persistent session — this keeps CU state across turns
+ session = AgentSession()
+
+ # 4. Turn 1: Upload PDF
+ # CU analyzes the PDF and injects full content into context.
+ print("--- Turn 1: Upload PDF ---")
+ pdf_bytes = SAMPLE_PDF_PATH.read_bytes()
+ response = await agent.run(
+ Message(
+ role="user",
+ contents=[
+ Content.from_text("What is this document about?"),
+ Content.from_data(
+ pdf_bytes,
+ "application/pdf",
+ additional_properties={"filename": SAMPLE_PDF_PATH.name},
+ ),
+ ],
+ ),
+ session=session, # <-- persist state across turns
+ )
+ usage = response.usage_details or {}
+ print(f"Agent: {response}")
+ print(f" [Input tokens: {usage.get('input_token_count', 'N/A')}]\n")
+
+ # 5. Turn 2: Unrelated question
+ # No document needed — agent answers from general knowledge.
+ print("--- Turn 2: Unrelated question ---")
+ response = await agent.run("What is the capital of France?", session=session)
+ usage = response.usage_details or {}
+ print(f"Agent: {response}")
+ print(f" [Input tokens: {usage.get('input_token_count', 'N/A')}]\n")
+
+ # 6. Turn 3: Detailed follow-up
+ # The agent answers from the full document content that was injected
+ # into conversation history in Turn 1. No re-analysis or tool call needed.
+ print("--- Turn 3: Detailed follow-up ---")
+ response = await agent.run(
+ "What is the shipping address on the invoice?",
+ session=session,
+ )
+ usage = response.usage_details or {}
+ print(f"Agent: {response}")
+ print(f" [Input tokens: {usage.get('input_token_count', 'N/A')}]\n")
+
+
+if __name__ == "__main__":
+ asyncio.run(main())
+
+"""
+Sample output:
+
+--- Turn 1: Upload PDF ---
+Agent: This document is an **invoice** from **CONTOSO LTD.** to **MICROSOFT
+ CORPORATION**. Amount Due: $610.00. Invoice INV-100, dated 11/15/2019.
+ [Input tokens: 975]
+
+--- Turn 2: Unrelated question ---
+Agent: Paris.
+ [Input tokens: 1134]
+
+--- Turn 3: Detailed follow-up ---
+Agent: Shipping address (SHIP TO): Microsoft Delivery, 123 Ship St,
+ Redmond WA, 98052.
+ [Input tokens: 1155]
+"""
diff --git a/python/samples/02-agents/context_providers/azure_content_understanding/03_multimodal_chat 2.py b/python/samples/02-agents/context_providers/azure_content_understanding/03_multimodal_chat 2.py
new file mode 100644
index 00000000000..757bae1dc4b
--- /dev/null
+++ b/python/samples/02-agents/context_providers/azure_content_understanding/03_multimodal_chat 2.py
@@ -0,0 +1,185 @@
+# Copyright (c) Microsoft. All rights reserved.
+# /// script
+# requires-python = ">=3.10"
+# dependencies = [
+# "agent-framework-azure-contentunderstanding",
+# "agent-framework-foundry",
+# ]
+# ///
+# Run with: uv run samples/02-agents/context_providers/azure_content_understanding/03_multimodal_chat.py
+
+
+import asyncio
+import os
+import time
+from pathlib import Path
+
+from agent_framework import Agent, AgentSession, Content, Message
+from agent_framework.foundry import ContentUnderstandingContextProvider, FoundryChatClient
+from azure.identity.aio import AzureCliCredential
+from dotenv import load_dotenv
+
+load_dotenv()
+
+"""
+Multi-Modal Chat — PDF, audio, and video in a single turn
+
+This sample demonstrates CU's multi-modal capability: upload a PDF invoice,
+an audio call recording, and a video file all at once. The provider analyzes
+all three in parallel using the right CU analyzer for each media type.
+
+The provider auto-detects the media type and selects the right CU analyzer:
+ - PDF/images → prebuilt-documentSearch
+ - Audio → prebuilt-audioSearch
+ - Video → prebuilt-videoSearch
+
+Environment variables:
+ FOUNDRY_PROJECT_ENDPOINT — Microsoft Foundry project endpoint
+ FOUNDRY_MODEL — Model deployment name (e.g. gpt-4.1)
+ AZURE_CONTENTUNDERSTANDING_ENDPOINT — CU endpoint URL
+"""
+
+# Local PDF from package assets
+SAMPLE_PDF = Path(__file__).resolve().parent / "sample_assets" / "invoice.pdf"
+
+# Public audio/video from Azure CU samples repo (raw GitHub URLs)
+_CU_ASSETS = "https://raw.githubusercontent.com/Azure-Samples/azure-ai-content-understanding-assets/main"
+AUDIO_URL = f"{_CU_ASSETS}/audio/callCenterRecording.mp3"
+VIDEO_URL = f"{_CU_ASSETS}/videos/sdk_samples/FlightSimulator.mp4"
+
+
+async def main() -> None:
+ # 1. Set up credentials and CU context provider
+ credential = AzureCliCredential()
+
+ # No analyzer_id specified — the provider auto-detects from media type:
+ # PDF/images → prebuilt-documentSearch
+ # Audio → prebuilt-audioSearch
+ # Video → prebuilt-videoSearch
+ cu = ContentUnderstandingContextProvider(
+ endpoint=os.environ["AZURE_CONTENTUNDERSTANDING_ENDPOINT"],
+ credential=credential,
+ max_wait=None, # wait until each analysis finishes
+ )
+
+ # 2. Set up the LLM client
+ client = FoundryChatClient(
+ project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
+ model=os.environ["FOUNDRY_MODEL"],
+ credential=credential,
+ )
+
+ # 3. Create agent and session
+ async with credential, cu:
+ agent = Agent(
+ client=client,
+ name="MultiModalAgent",
+ instructions=(
+ "You are a helpful assistant that can analyze documents, audio, "
+ "and video files. Answer questions using the extracted content."
+ ),
+ context_providers=[cu],
+ )
+
+ session = AgentSession()
+
+ # --- Turn 1: Upload all 3 modalities at once ---
+ # The provider analyzes all files in parallel using the appropriate
+ # CU analyzer for each media type. All results are injected into
+ # the same context so the agent can answer about all of them.
+ turn1_prompt = (
+ "I'm uploading three files: an invoice PDF, a call center "
+ "audio recording, and a flight simulator video. "
+ "Give a brief summary of each file."
+ )
+ print("--- Turn 1: Upload PDF + audio + video (parallel analysis) ---")
+ print(" (CU analysis may take a few minutes for these audio/video files...)")
+ print(f"User: {turn1_prompt}")
+ t0 = time.perf_counter()
+ response = await agent.run(
+ Message(
+ role="user",
+ contents=[
+ Content.from_text(turn1_prompt),
+ Content.from_data(
+ SAMPLE_PDF.read_bytes(),
+ "application/pdf",
+ additional_properties={"filename": "invoice.pdf"},
+ ),
+ Content.from_uri(
+ AUDIO_URL,
+ media_type="audio/mp3",
+ additional_properties={"filename": "callCenterRecording.mp3"},
+ ),
+ Content.from_uri(
+ VIDEO_URL,
+ media_type="video/mp4",
+ additional_properties={"filename": "FlightSimulator.mp4"},
+ ),
+ ],
+ ),
+ session=session,
+ )
+ elapsed = time.perf_counter() - t0
+ usage = response.usage_details or {}
+ print(f" [Analyzed in {elapsed:.1f}s | Input tokens: {usage.get('input_token_count', 'N/A')}]")
+ print(f"Agent: {response}\n")
+
+ # --- Turn 2: Detail question about the PDF ---
+ turn2_prompt = "What are the line items and their amounts on the invoice?"
+ print("--- Turn 2: PDF detail ---")
+ print(f"User: {turn2_prompt}")
+ response = await agent.run(turn2_prompt, session=session)
+ usage = response.usage_details or {}
+ print(f" [Input tokens: {usage.get('input_token_count', 'N/A')}]")
+ print(f"Agent: {response}\n")
+
+ # --- Turn 3: Detail question about the audio ---
+ turn3_prompt = "What was the customer's issue in the call recording?"
+ print("--- Turn 3: Audio detail ---")
+ print(f"User: {turn3_prompt}")
+ response = await agent.run(turn3_prompt, session=session)
+ usage = response.usage_details or {}
+ print(f" [Input tokens: {usage.get('input_token_count', 'N/A')}]")
+ print(f"Agent: {response}\n")
+
+ # --- Turn 4: Detail question about the video ---
+ turn4_prompt = "What key scenes or actions are shown in the flight simulator video?"
+ print("--- Turn 4: Video detail ---")
+ print(f"User: {turn4_prompt}")
+ response = await agent.run(turn4_prompt, session=session)
+ usage = response.usage_details or {}
+ print(f" [Input tokens: {usage.get('input_token_count', 'N/A')}]")
+ print(f"Agent: {response}\n")
+
+ # --- Turn 5: Cross-document question ---
+ turn5_prompt = (
+ "Across all three files, which one contains financial data, "
+ "which one involves a customer interaction, and which one is "
+ "a visual demonstration?"
+ )
+ print("--- Turn 5: Cross-document question ---")
+ print(f"User: {turn5_prompt}")
+ response = await agent.run(turn5_prompt, session=session)
+ usage = response.usage_details or {}
+ print(f" [Input tokens: {usage.get('input_token_count', 'N/A')}]")
+ print(f"Agent: {response}\n")
+
+
+if __name__ == "__main__":
+ asyncio.run(main())
+
+"""
+Sample output:
+
+--- Turn 1: Upload PDF + audio + video (parallel analysis) ---
+User: I'm uploading three files...
+ (CU analysis may take 1-2 minutes for audio/video files...)
+ [Analyzed in ~94s | Input tokens: ~2939]
+Agent: ### invoice.pdf: An invoice from CONTOSO LTD. to MICROSOFT CORPORATION...
+ ### callCenterRecording.mp3: A customer service call about point balance...
+ ### FlightSimulator.mp4: A clip discussing neural text-to-speech...
+
+--- Turn 2-5: Detail and cross-document questions ---
+(Agent answers from conversation history without re-analysis)
+"""
diff --git a/python/samples/02-agents/context_providers/azure_content_understanding/04_invoice_processing 2.py b/python/samples/02-agents/context_providers/azure_content_understanding/04_invoice_processing 2.py
new file mode 100644
index 00000000000..502cd328ca9
--- /dev/null
+++ b/python/samples/02-agents/context_providers/azure_content_understanding/04_invoice_processing 2.py
@@ -0,0 +1,192 @@
+# Copyright (c) Microsoft. All rights reserved.
+# /// script
+# requires-python = ">=3.10"
+# dependencies = [
+# "agent-framework-azure-contentunderstanding",
+# "agent-framework-foundry",
+# "pydantic",
+# ]
+# ///
+# Run with: uv run samples/02-agents/context_providers/azure_content_understanding/04_invoice_processing.py
+
+
+import asyncio
+import os
+from pathlib import Path
+
+from agent_framework import Agent, AgentSession, Content, Message
+from agent_framework.foundry import ContentUnderstandingContextProvider, FoundryChatClient
+from azure.identity.aio import AzureCliCredential
+from dotenv import load_dotenv
+from pydantic import BaseModel, Field
+
+load_dotenv()
+
+"""
+Invoice Processing — Structured output with prebuilt-invoice analyzer
+
+This sample demonstrates CU's structured field extraction combined with
+LLM structured output (Pydantic model). The prebuilt-invoice analyzer extracts
+typed fields (VendorName, InvoiceTotal, DueDate, LineItems, etc.) with
+confidence scores. We use output_sections=["fields"] only (no markdown needed)
+since we want the LLM to produce a structured JSON response from the extracted
+fields, not summarize document text.
+
+Environment variables:
+ FOUNDRY_PROJECT_ENDPOINT — Microsoft Foundry project endpoint
+ FOUNDRY_MODEL — Model deployment name (e.g. gpt-4.1)
+ AZURE_CONTENTUNDERSTANDING_ENDPOINT — CU endpoint URL
+"""
+
+SAMPLE_PDF_PATH = Path(__file__).resolve().parent / "sample_assets" / "invoice.pdf"
+
+
+# Structured output model — the LLM will return JSON matching this schema
+# Structured output models — the LLM returns JSON matching this schema.
+#
+# Note: the prebuilt-invoice analyzer extracts an extensive set of fields
+# (VendorName, BillingAddress, ShippingAddress, TaxDetails, PONumber, etc.).
+# This sample defines a simplified schema to extract only the fields of
+# interest to the caller. The LLM maps the full CU field output to this
+# subset automatically.
+# Learn more about prebuilt analyzers: https://learn.microsoft.com/azure/ai-services/content-understanding/concepts/prebuilt-analyzers
+
+
+class LineItem(BaseModel):
+ description: str
+ quantity: float | None = None
+ unit_price: float | None = None
+ amount: float | None = None
+
+
+class LowConfidenceField(BaseModel):
+ field_name: str
+ confidence: float
+
+
+class InvoiceResult(BaseModel):
+ vendor_name: str
+ total_amount: float | None = None
+ currency: str = "USD"
+ due_date: str | None = None
+ line_items: list[LineItem] = Field(default_factory=list)
+ low_confidence_fields: list[LowConfidenceField] = Field(
+ default_factory=list,
+ description="Fields with confidence < 0.8, including their confidence score",
+ )
+
+
+async def main() -> None:
+ # 1. Set up credentials and CU context provider
+ credential = AzureCliCredential()
+
+ # Default analyzer is prebuilt-documentSearch (RAG-optimized).
+ # Per-file override via additional_properties["analyzer_id"] lets us
+ # use prebuilt-invoice for structured field extraction on specific files.
+ #
+ # Only request "fields" (not "markdown") — we want the extracted typed
+ # fields for structured output, not the raw document text.
+ cu = ContentUnderstandingContextProvider(
+ endpoint=os.environ["AZURE_CONTENTUNDERSTANDING_ENDPOINT"],
+ credential=credential,
+ analyzer_id="prebuilt-documentSearch", # default for all files
+ max_wait=None, # wait until CU analysis finishes
+ output_sections=["fields"], # fields only — structured output doesn't need markdown
+ )
+
+ # 2. Set up the LLM client
+ client = FoundryChatClient(
+ project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
+ model=os.environ["FOUNDRY_MODEL"],
+ credential=credential,
+ )
+
+ # 3. Create agent and session
+ async with credential, cu:
+ agent = Agent(
+ client=client,
+ name="InvoiceProcessor",
+ instructions=(
+ "You are an invoice processing assistant. Extract invoice data from "
+ "the provided CU fields (JSON with confidence scores). Return structured "
+ "output matching the requested schema. Flag fields with confidence < 0.8 "
+ "in the low_confidence_fields list."
+ ),
+ context_providers=[cu],
+ )
+
+ session = AgentSession()
+
+ # 4. Upload an invoice PDF — uses structured output (Pydantic model)
+ print("--- Upload Invoice (Structured Output) ---")
+
+ pdf_bytes = SAMPLE_PDF_PATH.read_bytes()
+
+ response = await agent.run(
+ Message(
+ role="user",
+ contents=[
+ Content.from_text(
+ "Process this invoice. Extract the vendor name, total amount, due date, and all line items."
+ ),
+ Content.from_data(
+ pdf_bytes,
+ "application/pdf",
+ # Per-file analyzer override: use prebuilt-invoice for
+ # structured field extraction (VendorName, InvoiceTotal, etc.)
+ # instead of the provider default (prebuilt-documentSearch).
+ additional_properties={
+ "filename": SAMPLE_PDF_PATH.name,
+ "analyzer_id": "prebuilt-invoice",
+ },
+ ),
+ ],
+ ),
+ session=session,
+ options={"response_format": InvoiceResult},
+ )
+
+ # Parse the structured output from JSON text
+ try:
+ invoice = InvoiceResult.model_validate_json(response.text)
+ print(f"Vendor: {invoice.vendor_name}")
+ print(f"Total: {invoice.currency} {invoice.total_amount}")
+ print(f"Due date: {invoice.due_date}")
+ print(f"Line items ({len(invoice.line_items)}):")
+ for item in invoice.line_items:
+ print(f" - {item.description}: {item.amount}")
+ if invoice.low_confidence_fields:
+ print("⚠ Low confidence fields:")
+ for f in invoice.low_confidence_fields:
+ print(f" - {f.field_name}: {f.confidence:.3f}")
+ except Exception:
+ print(f"Agent (raw): {response.text}\n")
+
+ # 5. Follow-up: free-text question about the invoice
+ print("\n--- Follow-up (Free Text) ---")
+ response = await agent.run(
+ "What is the payment term? Are there any fields with low confidence?",
+ session=session,
+ )
+ print(f"Agent: {response}\n")
+
+
+if __name__ == "__main__":
+ asyncio.run(main())
+
+"""
+Sample output:
+
+--- Upload Invoice (Structured Output) ---
+Vendor: CONTOSO LTD.
+Total: USD 110.0
+Due date: 2019-12-15
+Line items (3):
+ - Consulting Services: 60.0
+ - Document Fee: 30.0
+ - Printing Fee: 10.0
+⚠ Low confidence: VendorName, CustomerName
+
+--- Follow-up (Free Text) ---
+Agent: The payment terms are not explicitly stated on the invoice...
+"""
diff --git a/python/samples/02-agents/context_providers/azure_content_understanding/05_large_doc_file_search 2.py b/python/samples/02-agents/context_providers/azure_content_understanding/05_large_doc_file_search 2.py
new file mode 100644
index 00000000000..39e4352eea5
--- /dev/null
+++ b/python/samples/02-agents/context_providers/azure_content_understanding/05_large_doc_file_search 2.py
@@ -0,0 +1,165 @@
+# Copyright (c) Microsoft. All rights reserved.
+# /// script
+# requires-python = ">=3.10"
+# dependencies = [
+# "agent-framework-azure-contentunderstanding",
+# "agent-framework-foundry",
+# ]
+# ///
+# Run with: uv run samples/02-agents/context_providers/azure_content_understanding/05_large_doc_file_search.py
+
+
+import asyncio
+import os
+from pathlib import Path
+
+from agent_framework import Agent, AgentSession, Content, Message
+from agent_framework.foundry import (
+ ContentUnderstandingContextProvider,
+ FileSearchConfig,
+ FoundryChatClient,
+)
+from azure.identity.aio import AzureCliCredential
+from dotenv import load_dotenv
+
+load_dotenv()
+
+"""
+Large Document + file_search RAG — CU extraction + OpenAI vector store
+
+For large documents (100+ pages) or long audio/video, injecting the full
+CU-extracted content into the LLM context is impractical. This sample shows
+how to use the built-in file_search integration: CU extracts markdown and
+automatically uploads it to an OpenAI vector store for token-efficient RAG.
+
+When ``FileSearchConfig`` is provided, the provider:
+ 1. Extracts markdown via CU (handles scanned PDFs, audio, video)
+ 2. Uploads the extracted markdown to a vector store
+ 3. Registers a ``file_search`` tool on the agent context
+ 4. Cleans up the vector store on close
+
+Architecture:
+ Large PDF -> CU extracts markdown -> auto-upload to vector store -> file_search
+ Follow-up -> file_search retrieves top-k chunks -> LLM answers
+
+NOTE: Requires an async OpenAI client for vector store operations.
+
+This sample uses a single small invoice PDF for simplicity. In practice,
+you can upload multiple files in the same session (each is indexed
+separately in the vector store), and this pattern is most valuable for
+large documents (up to 300 pages), long audio recordings, or video files
+where full-context injection would exceed the LLM's context window.
+CU supports PDFs up to 300 pages / 200 MB, and audio files up to 300 MB
+— see the full service limits:
+https://learn.microsoft.com/azure/ai-services/content-understanding/service-limits#input-file-limits
+
+Environment variables:
+ FOUNDRY_PROJECT_ENDPOINT — Microsoft Foundry project endpoint
+ FOUNDRY_MODEL — Model deployment name (e.g. gpt-4.1)
+ AZURE_CONTENTUNDERSTANDING_ENDPOINT — CU endpoint URL
+"""
+
+SAMPLE_PDF_PATH = Path(__file__).resolve().parent / "sample_assets" / "invoice.pdf"
+
+
+async def main() -> None:
+ # 1. Set up credentials and LLM client
+ credential = AzureCliCredential()
+
+ client = FoundryChatClient(
+ project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
+ model=os.environ["FOUNDRY_MODEL"],
+ credential=credential,
+ )
+
+ # 2. Get the async OpenAI client from FoundryChatClient for vector store operations
+ openai_client = client.client
+
+ # 3. Create vector store and file_search tool
+ vector_store = await openai_client.vector_stores.create(
+ name="cu_large_doc_demo",
+ expires_after={"anchor": "last_active_at", "days": 1},
+ )
+ file_search_tool = client.get_file_search_tool(vector_store_ids=[vector_store.id])
+
+ # 4. Configure CU provider with file_search integration
+ # When file_search is set, CU-extracted markdown is automatically uploaded
+ # to the vector store and the file_search tool is registered on the context.
+ cu = ContentUnderstandingContextProvider(
+ endpoint=os.environ["AZURE_CONTENTUNDERSTANDING_ENDPOINT"],
+ credential=credential,
+ analyzer_id="prebuilt-documentSearch",
+ max_wait=None, # wait until CU analysis + vector store upload finishes
+ file_search=FileSearchConfig.from_foundry(
+ openai_client,
+ vector_store_id=vector_store.id,
+ file_search_tool=file_search_tool,
+ ),
+ )
+
+ pdf_bytes = SAMPLE_PDF_PATH.read_bytes()
+
+ # The provider handles everything: CU extraction + vector store upload + file_search tool
+ async with credential, cu:
+ agent = Agent(
+ client=client,
+ name="LargeDocAgent",
+ instructions=(
+ "You are a document analyst. Use the file_search tool to find "
+ "relevant sections from the document and answer precisely. "
+ "Cite specific sections when answering."
+ ),
+ context_providers=[cu],
+ )
+
+ session = AgentSession()
+
+ # Turn 1: Upload — CU extracts and uploads to vector store automatically
+ print("--- Turn 1: Upload document ---")
+ response = await agent.run(
+ Message(
+ role="user",
+ contents=[
+ Content.from_text("What are the key points in this document?"),
+ Content.from_data(
+ pdf_bytes,
+ "application/pdf",
+ additional_properties={"filename": SAMPLE_PDF_PATH.name},
+ ),
+ ],
+ ),
+ session=session,
+ )
+ print(f"Agent: {response}\n")
+
+ # Turn 2: Follow-up — file_search retrieves relevant chunks (token efficient)
+ print("--- Turn 2: Follow-up (RAG) ---")
+ response = await agent.run(
+ "What numbers or financial metrics are mentioned?",
+ session=session,
+ )
+ print(f"Agent: {response}\n")
+
+ # Explicitly delete the vector store created for this sample
+ await openai_client.vector_stores.delete(vector_store.id)
+ print("Done. Vector store deleted.")
+
+
+if __name__ == "__main__":
+ asyncio.run(main())
+
+"""
+Sample output:
+
+--- Turn 1: Upload document ---
+Agent: An invoice from Contoso Ltd. to Microsoft Corporation (INV-100).
+ Line items: Consulting Services $60, Document Fee $30, Printing Fee $10.
+ Subtotal $100, Sales tax $10, Total $110, Previous balance $500, Amount due $610.
+
+--- Turn 2: Follow-up (RAG) ---
+Agent: Subtotal $100.00, Sales tax $10.00, Total $110.00,
+ Previous unpaid balance $500.00, Amount due $610.00.
+ Line items: 2 hours @ $30 = $60, 3 @ $10 = $30, 10 pages @ $1 = $10.
+
+Done. Vector store cleaned up automatically.
+"""
diff --git a/python/samples/02-agents/context_providers/azure_content_understanding/README 2.md b/python/samples/02-agents/context_providers/azure_content_understanding/README 2.md
new file mode 100644
index 00000000000..ffefee9d9e2
--- /dev/null
+++ b/python/samples/02-agents/context_providers/azure_content_understanding/README 2.md
@@ -0,0 +1,35 @@
+# Azure Content Understanding Samples
+
+These samples demonstrate how to use the `agent-framework-azure-contentunderstanding` package to add document, image, audio, and video understanding to your agents.
+
+## Prerequisites
+
+1. Azure CLI logged in: `az login`
+2. Environment variables set (or `.env` file in the `python/` directory):
+ ```
+ FOUNDRY_PROJECT_ENDPOINT=https://your-project.services.ai.azure.com
+ FOUNDRY_MODEL=gpt-4.1
+ AZURE_CONTENTUNDERSTANDING_ENDPOINT=https://your-cu-resource.cognitiveservices.azure.com/
+ ```
+
+## Samples
+
+### Script samples (easy → advanced)
+
+| # | Sample | Description | Run |
+|---|--------|-------------|-----|
+| 01 | [Document Q&A](01_document_qa.py) | Upload a PDF, ask questions with CU-powered extraction | `uv run samples/02-agents/context_providers/azure_content_understanding/01_document_qa.py` |
+| 02 | [Multi-Turn Session](02_multi_turn_session.py) | AgentSession persistence across turns | `uv run samples/02-agents/context_providers/azure_content_understanding/02_multi_turn_session.py` |
+| 03 | [Multi-Modal Chat](03_multimodal_chat.py) | PDF + audio + video parallel analysis | `uv run samples/02-agents/context_providers/azure_content_understanding/03_multimodal_chat.py` |
+| 04 | [Invoice Processing](04_invoice_processing.py) | Structured field extraction with prebuilt-invoice | `uv run samples/02-agents/context_providers/azure_content_understanding/04_invoice_processing.py` |
+| 05 | [Large Doc + file_search](05_large_doc_file_search.py) | CU extraction + OpenAI vector store RAG | `uv run samples/02-agents/context_providers/azure_content_understanding/05_large_doc_file_search.py` |
+
+### Interactive web UI samples
+
+See the [DevUI sample index](../../devui/README.md) for the Azure Content Understanding agents.
+
+## Install (preview)
+
+```bash
+pip install --pre agent-framework-azure-contentunderstanding
+```
diff --git a/python/samples/02-agents/devui/agent_content_understanding/README 2.md b/python/samples/02-agents/devui/agent_content_understanding/README 2.md
new file mode 100644
index 00000000000..a1baf0a5ce8
--- /dev/null
+++ b/python/samples/02-agents/devui/agent_content_understanding/README 2.md
@@ -0,0 +1,33 @@
+# DevUI Multi-Modal Agent
+
+Interactive web UI for uploading and chatting with documents, images, audio, and video using Azure Content Understanding.
+
+## Setup
+
+1. Set environment variables (or create a `.env` file in `python/`):
+ ```bash
+ FOUNDRY_PROJECT_ENDPOINT=https://your-project.api.azureml.ms
+ AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME=gpt-4.1
+ AZURE_CONTENTUNDERSTANDING_ENDPOINT=https://your-cu-resource.cognitiveservices.azure.com/
+ ```
+
+2. Log in with Azure CLI:
+ ```bash
+ az login
+ ```
+
+3. Run with DevUI:
+ ```bash
+ devui samples/02-agents/devui/agent_content_understanding
+ ```
+
+4. Open the DevUI URL in your browser and start uploading files.
+
+## What You Can Do
+
+- **Upload PDFs** — including scanned/image-based PDFs that LLM vision struggles with
+- **Upload images** — handwritten notes, infographics, charts
+- **Upload audio** — meeting recordings, call center calls (transcription with speaker ID)
+- **Upload video** — product demos, training videos (frame extraction + transcription)
+- **Ask questions** across all uploaded documents
+- **Check status** — "which documents are ready?" uses the auto-registered `list_documents()` tool
diff --git a/python/samples/02-agents/devui/agent_content_understanding/__init__ 2.py b/python/samples/02-agents/devui/agent_content_understanding/__init__ 2.py
new file mode 100644
index 00000000000..4a62c13ce44
--- /dev/null
+++ b/python/samples/02-agents/devui/agent_content_understanding/__init__ 2.py
@@ -0,0 +1,6 @@
+# Copyright (c) Microsoft. All rights reserved.
+"""DevUI Multi-Modal Agent with Azure Content Understanding."""
+
+from .agent import agent # ty: ignore[unresolved-import] # pyrefly: ignore
+
+__all__ = ["agent"]
diff --git a/python/samples/02-agents/devui/agent_content_understanding/agent 2.py b/python/samples/02-agents/devui/agent_content_understanding/agent 2.py
new file mode 100644
index 00000000000..d8f46155cec
--- /dev/null
+++ b/python/samples/02-agents/devui/agent_content_understanding/agent 2.py
@@ -0,0 +1,66 @@
+# Copyright (c) Microsoft. All rights reserved.
+"""DevUI Multi-Modal Agent — file upload + CU-powered analysis.
+
+This agent uses Azure Content Understanding to analyze uploaded files
+(PDFs, scanned documents, handwritten images, audio recordings, video)
+and answer questions about them through the DevUI web interface.
+
+Unlike the standard azure_responses_agent which sends files directly to the LLM,
+this agent uses CU for structured extraction — superior for scanned PDFs,
+handwritten content, audio transcription, and video analysis.
+
+Required environment variables:
+ FOUNDRY_PROJECT_ENDPOINT — Microsoft Foundry project endpoint
+ FOUNDRY_MODEL — Model deployment name (e.g. gpt-4.1)
+ AZURE_CONTENTUNDERSTANDING_ENDPOINT — CU endpoint URL
+
+Run with DevUI:
+ devui samples/02-agents/devui/agent_content_understanding
+"""
+
+import os
+
+from agent_framework import Agent
+from agent_framework.foundry import ContentUnderstandingContextProvider, FoundryChatClient
+from azure.core.credentials import AzureKeyCredential
+from azure.identity.aio import AzureCliCredential
+from dotenv import load_dotenv
+
+load_dotenv()
+
+# --- Auth ---
+_credential = AzureCliCredential()
+_cu_api_key = os.environ.get("AZURE_CONTENTUNDERSTANDING_API_KEY")
+_cu_credential = AzureKeyCredential(_cu_api_key) if _cu_api_key else _credential
+
+cu = ContentUnderstandingContextProvider(
+ endpoint=os.environ["AZURE_CONTENTUNDERSTANDING_ENDPOINT"],
+ credential=_cu_credential,
+ # max_wait controls how long before_run() waits for CU analysis before
+ # deferring to background. For interactive DevUI use, a short timeout
+ # (e.g. 5s) keeps the chat responsive — the agent tells the user the
+ # file is still being analyzed and resolves it on the next turn.
+ # Use max_wait=None to always wait for analysis to complete.
+ max_wait=5.0,
+)
+
+client = FoundryChatClient(
+ project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
+ model=os.environ["FOUNDRY_MODEL"],
+ credential=_credential,
+)
+
+agent = Agent(
+ client=client,
+ name="MultiModalDocAgent",
+ instructions=(
+ "You are a helpful document analysis assistant. "
+ "When a user uploads files, they are automatically analyzed using Azure Content Understanding. "
+ "Use list_documents() to check which documents are ready, pending, or failed "
+ "and to see which files are available for answering questions. "
+ "Tell the user if any documents are still being analyzed. "
+ "You can process PDFs, scanned documents, handwritten images, audio recordings, and video files. "
+ "When answering, cite specific content from the documents."
+ ),
+ context_providers=[cu],
+)
diff --git a/python/samples/02-agents/devui/agent_content_understanding_file_search_azure_openai/README 2.md b/python/samples/02-agents/devui/agent_content_understanding_file_search_azure_openai/README 2.md
new file mode 100644
index 00000000000..7a72416b118
--- /dev/null
+++ b/python/samples/02-agents/devui/agent_content_understanding_file_search_azure_openai/README 2.md
@@ -0,0 +1,51 @@
+# DevUI File Search Agent
+
+Interactive web UI for uploading and chatting with documents, images, audio, and video using Azure Content Understanding + OpenAI file_search RAG.
+
+## How It Works
+
+1. **Upload** any supported file (PDF, image, audio, video) via the DevUI chat
+2. **CU analyzes** the file — auto-selects the right analyzer per media type
+3. **Markdown extracted** by CU is uploaded to an OpenAI vector store
+4. **file_search** tool is registered — LLM retrieves top-k relevant chunks
+5. **Ask questions** across all uploaded documents with token-efficient RAG
+
+## Setup
+
+1. Set environment variables (or create a `.env` file in `python/`):
+ ```bash
+ FOUNDRY_PROJECT_ENDPOINT=https://your-project.services.ai.azure.com/
+ AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME=gpt-4.1
+ AZURE_CONTENTUNDERSTANDING_ENDPOINT=https://your-cu-resource.services.ai.azure.com/
+ ```
+
+2. Log in with Azure CLI:
+ ```bash
+ az login
+ ```
+
+3. Run with DevUI:
+ ```bash
+ devui samples/02-agents/devui/agent_content_understanding_file_search_azure_openai
+ ```
+
+4. Open the DevUI URL in your browser and start uploading files.
+
+## Supported File Types
+
+| Type | Formats | CU Analyzer (auto-detected) |
+|------|---------|----------------------------|
+| Documents | PDF, DOCX, XLSX, PPTX, HTML, TXT, Markdown | `prebuilt-documentSearch` |
+| Images | JPEG, PNG, TIFF, BMP | `prebuilt-documentSearch` |
+| Audio | WAV, MP3, FLAC, OGG, M4A | `prebuilt-audioSearch` |
+| Video | MP4, MOV, AVI, WebM | `prebuilt-videoSearch` |
+
+## Comparison with the multimodal agent
+
+| Feature | multimodal_agent | file_search_agent |
+|---------|-----------------|-------------------|
+| CU extraction | ✅ Full content injected | ✅ Content indexed in vector store |
+| RAG | ❌ | ✅ file_search retrieves top-k chunks |
+| Large docs (100+ pages) | ⚠️ May exceed context window | ✅ Token-efficient |
+| Multiple large files | ⚠️ Context overflow risk | ✅ All indexed, searchable |
+| Best for | Small docs, quick inspection | Large docs, multi-file Q&A |
diff --git a/python/samples/02-agents/devui/agent_content_understanding_file_search_azure_openai/__init__ 2.py b/python/samples/02-agents/devui/agent_content_understanding_file_search_azure_openai/__init__ 2.py
new file mode 100644
index 00000000000..60720f84899
--- /dev/null
+++ b/python/samples/02-agents/devui/agent_content_understanding_file_search_azure_openai/__init__ 2.py
@@ -0,0 +1,6 @@
+# Copyright (c) Microsoft. All rights reserved.
+"""DevUI Multi-Modal Agent with CU + file_search RAG."""
+
+from .agent import agent # ty: ignore[unresolved-import] # pyrefly: ignore
+
+__all__ = ["agent"]
diff --git a/python/samples/02-agents/devui/agent_content_understanding_file_search_azure_openai/agent 2.py b/python/samples/02-agents/devui/agent_content_understanding_file_search_azure_openai/agent 2.py
new file mode 100644
index 00000000000..c5aa9fa1ade
--- /dev/null
+++ b/python/samples/02-agents/devui/agent_content_understanding_file_search_azure_openai/agent 2.py
@@ -0,0 +1,106 @@
+# Copyright (c) Microsoft. All rights reserved.
+"""DevUI Multi-Modal Agent — CU extraction + file_search RAG.
+
+This agent combines Azure Content Understanding with OpenAI file_search
+for token-efficient RAG over large or multi-modal documents.
+
+Upload flow:
+ 1. CU extracts high-quality markdown (handles scanned PDFs, audio, video)
+ 2. Extracted markdown is auto-uploaded to an OpenAI vector store
+ 3. file_search tool is registered so the LLM retrieves top-k chunks
+ 4. Vector store is configured to auto-expire after inactivity
+
+This is ideal for large documents (100+ pages), long audio recordings,
+or multiple files in the same conversation where full-context injection
+would exceed the LLM's context window.
+
+Analyzer auto-detection:
+ When no analyzer_id is specified, the provider auto-selects the
+ appropriate CU analyzer based on media type:
+ - Documents/images → prebuilt-documentSearch
+ - Audio → prebuilt-audioSearch
+ - Video → prebuilt-videoSearch
+
+Required environment variables:
+ FOUNDRY_PROJECT_ENDPOINT — Microsoft Foundry project endpoint
+ FOUNDRY_MODEL — Model deployment name (e.g. gpt-4.1)
+ AZURE_CONTENTUNDERSTANDING_ENDPOINT — CU endpoint URL
+
+Run with DevUI:
+ devui samples/02-agents/devui/agent_content_understanding_file_search_azure_openai
+"""
+
+import os
+
+from agent_framework import Agent
+from agent_framework.foundry import (
+ ContentUnderstandingContextProvider,
+ FileSearchConfig,
+ FoundryChatClient,
+)
+from azure.ai.projects import AIProjectClient
+from azure.core.credentials import AzureKeyCredential
+from azure.identity import AzureCliCredential
+from azure.identity.aio import AzureCliCredential as AsyncAzureCliCredential
+from dotenv import load_dotenv
+
+load_dotenv()
+
+# --- Auth ---
+_credential = AzureCliCredential()
+_cu_api_key = os.environ.get("AZURE_CONTENTUNDERSTANDING_API_KEY")
+_cu_credential = AzureKeyCredential(_cu_api_key) if _cu_api_key else AsyncAzureCliCredential()
+
+_endpoint = os.environ["FOUNDRY_PROJECT_ENDPOINT"]
+
+# --- LLM client + sync vector store setup ---
+# DevUI loads agent modules synchronously at startup while an event loop is already
+# running, so we cannot use async APIs here. A sync AIProjectClient is used for
+# one-time vector store creation; runtime file uploads use client.client (async).
+client = FoundryChatClient(
+ project_endpoint=_endpoint,
+ model=os.environ["FOUNDRY_MODEL"],
+ credential=_credential,
+)
+
+_sync_project = AIProjectClient(endpoint=_endpoint, credential=_credential) # type: ignore[arg-type]
+_sync_openai = _sync_project.get_openai_client() # ty: ignore[unresolved-attribute] # pyrefly: ignore
+_vector_store = _sync_openai.vector_stores.create(
+ name="devui_cu_file_search",
+ expires_after={"anchor": "last_active_at", "days": 1},
+)
+_sync_openai.close()
+
+_file_search_tool = client.get_file_search_tool(
+ vector_store_ids=[_vector_store.id],
+ max_num_results=3, # limit chunks to reduce input token usage
+)
+
+# --- CU context provider with file_search ---
+# client.client is the async OpenAI client used for runtime file uploads.
+# No analyzer_id → auto-selects per media type (documents, audio, video)
+cu = ContentUnderstandingContextProvider(
+ endpoint=os.environ["AZURE_CONTENTUNDERSTANDING_ENDPOINT"],
+ credential=_cu_credential,
+ file_search=FileSearchConfig.from_foundry(
+ client.client, # reuse the LLM client's internal AsyncAzureOpenAI for file uploads
+ vector_store_id=_vector_store.id,
+ file_search_tool=_file_search_tool,
+ ),
+)
+
+agent = Agent(
+ client=client,
+ name="FileSearchDocAgent",
+ instructions=(
+ "You are a helpful document analysis assistant with RAG capabilities. "
+ "When a user uploads files, they are automatically analyzed using Azure Content Understanding "
+ "and indexed in a vector store for efficient retrieval. "
+ "Analysis takes time (seconds for documents, longer for audio/video) — if a document "
+ "is still pending, let the user know and suggest they ask again shortly. "
+ "You can process PDFs, scanned documents, handwritten images, audio recordings, and video files. "
+ "Multiple files can be uploaded and queried in the same conversation. "
+ "When answering, cite specific content from the documents."
+ ),
+ context_providers=[cu],
+)
diff --git a/python/samples/02-agents/devui/agent_content_understanding_file_search_foundry/README 2.md b/python/samples/02-agents/devui/agent_content_understanding_file_search_foundry/README 2.md
new file mode 100644
index 00000000000..6f07c701b07
--- /dev/null
+++ b/python/samples/02-agents/devui/agent_content_understanding_file_search_foundry/README 2.md
@@ -0,0 +1,35 @@
+# DevUI Foundry File Search Agent
+
+Interactive web UI for uploading and chatting with documents, images, audio, and video using Azure Content Understanding + Foundry file_search RAG.
+
+This is the **Foundry** variant. For the Azure OpenAI Responses API variant, see
+`agent_content_understanding_file_search_azure_openai`.
+
+## How It Works
+
+1. **Upload** any supported file (PDF, image, audio, video) via the DevUI chat
+2. **CU analyzes** the file — auto-selects the right analyzer per media type
+3. **Markdown extracted** by CU is uploaded to a Foundry vector store
+4. **file_search** tool is registered — LLM retrieves top-k relevant chunks
+5. **Ask questions** across all uploaded documents with token-efficient RAG
+
+## Setup
+
+1. Set environment variables (or create a `.env` file in `python/`):
+ ```bash
+ FOUNDRY_PROJECT_ENDPOINT=https://your-project.services.ai.azure.com/
+ FOUNDRY_MODEL=gpt-4.1
+ AZURE_CONTENTUNDERSTANDING_ENDPOINT=https://your-cu-resource.services.ai.azure.com/
+ ```
+
+2. Log in with Azure CLI:
+ ```bash
+ az login
+ ```
+
+3. Run with DevUI:
+ ```bash
+ devui samples/02-agents/devui/agent_content_understanding_file_search_foundry
+ ```
+
+4. Open the DevUI URL in your browser and start uploading files.
diff --git a/python/samples/02-agents/devui/agent_content_understanding_file_search_foundry/__init__ 2.py b/python/samples/02-agents/devui/agent_content_understanding_file_search_foundry/__init__ 2.py
new file mode 100644
index 00000000000..2a50eae8941
--- /dev/null
+++ b/python/samples/02-agents/devui/agent_content_understanding_file_search_foundry/__init__ 2.py
@@ -0,0 +1 @@
+# Copyright (c) Microsoft. All rights reserved.
diff --git a/python/samples/02-agents/devui/agent_content_understanding_file_search_foundry/agent 2.py b/python/samples/02-agents/devui/agent_content_understanding_file_search_foundry/agent 2.py
new file mode 100644
index 00000000000..3af2ebfe223
--- /dev/null
+++ b/python/samples/02-agents/devui/agent_content_understanding_file_search_foundry/agent 2.py
@@ -0,0 +1,111 @@
+# Copyright (c) Microsoft. All rights reserved.
+"""DevUI Multi-Modal Agent — CU extraction + file_search RAG via Microsoft Foundry.
+
+This agent combines Azure Content Understanding with Foundry's file_search
+for token-efficient RAG over large or multi-modal documents.
+
+Upload flow:
+ 1. CU extracts high-quality markdown (handles scanned PDFs, audio, video)
+ 2. Extracted markdown is uploaded to a Foundry vector store
+ 3. file_search tool is registered so the LLM retrieves top-k chunks
+ 4. Uploaded files are cleaned up on server shutdown
+
+This sample uses ``FoundryChatClient`` and ``FoundryFileSearchBackend``.
+For the OpenAI Responses API variant, see
+``agent_content_understanding_file_search_azure_openai``.
+
+Analyzer auto-detection:
+ When no analyzer_id is specified, the provider auto-selects the
+ appropriate CU analyzer based on media type:
+ - Documents/images → prebuilt-documentSearch
+ - Audio → prebuilt-audioSearch
+ - Video → prebuilt-videoSearch
+
+Required environment variables:
+ FOUNDRY_PROJECT_ENDPOINT — Microsoft Foundry project endpoint
+ FOUNDRY_MODEL — Model deployment name (e.g. gpt-4.1)
+ AZURE_CONTENTUNDERSTANDING_ENDPOINT — CU endpoint URL
+
+Run with DevUI:
+ devui samples/02-agents/devui/agent_content_understanding_file_search_foundry
+"""
+
+import os
+
+from agent_framework import Agent
+from agent_framework.foundry import (
+ ContentUnderstandingContextProvider,
+ FileSearchConfig,
+ FoundryChatClient,
+)
+from azure.core.credentials import AzureKeyCredential
+from azure.identity import AzureCliCredential
+from azure.identity.aio import AzureCliCredential as AsyncAzureCliCredential
+from dotenv import load_dotenv
+from openai import AzureOpenAI
+
+load_dotenv()
+
+# --- Auth ---
+# AzureCliCredential for Foundry. CU API key optional if on a different resource.
+_credential = AzureCliCredential()
+_cu_api_key = os.environ.get("AZURE_CONTENTUNDERSTANDING_API_KEY")
+_cu_credential = AzureKeyCredential(_cu_api_key) if _cu_api_key else AsyncAzureCliCredential()
+
+# --- Foundry LLM client ---
+client = FoundryChatClient(
+ project_endpoint=os.environ.get("FOUNDRY_PROJECT_ENDPOINT", ""),
+ model=os.environ.get("FOUNDRY_MODEL", ""),
+ credential=_credential,
+)
+
+# --- Create vector store (sync client to avoid event loop conflicts in DevUI) ---
+_token = _credential.get_token("https://ai.azure.com/.default").token
+_sync_openai = AzureOpenAI(
+ azure_endpoint=os.environ.get("FOUNDRY_PROJECT_ENDPOINT", ""),
+ azure_ad_token=_token,
+ api_version="2025-04-01-preview",
+)
+_vector_store = _sync_openai.vector_stores.create(
+ name="devui_cu_foundry_file_search",
+ expires_after={"anchor": "last_active_at", "days": 1},
+)
+_sync_openai.close()
+
+_file_search_tool = client.get_file_search_tool(
+ vector_store_ids=[_vector_store.id],
+ max_num_results=3, # limit chunks to reduce input token usage
+)
+
+# --- CU context provider with file_search ---
+# No analyzer_id → auto-selects per media type (documents, audio, video)
+cu = ContentUnderstandingContextProvider(
+ endpoint=os.environ["AZURE_CONTENTUNDERSTANDING_ENDPOINT"],
+ credential=_cu_credential,
+ # max_wait is the combined budget for CU analysis + vector store upload.
+ # For file_search mode, 10s gives enough time for small documents to be
+ # analyzed and indexed in one turn. Larger files (audio, video) will
+ # be deferred to background and resolved on the next turn.
+ max_wait=10.0,
+ file_search=FileSearchConfig.from_foundry(
+ client.client,
+ vector_store_id=_vector_store.id,
+ file_search_tool=_file_search_tool,
+ ),
+)
+
+agent = Agent(
+ client=client,
+ name="FoundryFileSearchDocAgent",
+ instructions=(
+ "You are a helpful document analysis assistant with RAG capabilities. "
+ "When a user uploads files, they are automatically analyzed using Azure Content Understanding "
+ "and indexed in a vector store for efficient retrieval. "
+ "Analysis takes time (seconds for documents, longer for audio/video) — if a document "
+ "is still pending, let the user know and suggest they ask again shortly. "
+ "You can process PDFs, scanned documents, handwritten images, audio recordings, and video files. "
+ "Multiple files can be uploaded and queried in the same conversation. "
+ "When answering, cite specific content from the documents."
+ ),
+ context_providers=[cu],
+)
diff --git a/python/samples/02-agents/providers/gemini/README 2.md b/python/samples/02-agents/providers/gemini/README 2.md
new file mode 100644
index 00000000000..c1687368b8f
--- /dev/null
+++ b/python/samples/02-agents/providers/gemini/README 2.md
@@ -0,0 +1,20 @@
+# Google Gemini Examples
+
+This folder contains examples demonstrating how to use Google Gemini models with the Agent Framework.
+
+## Examples
+
+| File | Description |
+|------|-------------|
+| [`gemini_basic.py`](gemini_basic.py) | Basic agent with a weather tool, demonstrating both streaming and non-streaming responses. |
+| [`gemini_advanced.py`](gemini_advanced.py) | Extended thinking via `ThinkingConfig` for reasoning-heavy questions (Gemini 2.5+). |
+| [`gemini_with_google_search.py`](gemini_with_google_search.py) | Google Search grounding for up-to-date answers. |
+| [`gemini_with_google_maps.py`](gemini_with_google_maps.py) | Google Maps grounding for location and mapping information. |
+| [`gemini_with_code_execution.py`](gemini_with_code_execution.py) | Built-in code execution tool for computing precise answers in a sandboxed environment. |
+
+## Environment Variables
+
+- `GOOGLE_MODEL` or `GEMINI_MODEL`: The Gemini model to use (for example,
+ `gemini-2.5-flash-lite` or `gemini-2.5-pro`)
+- For Gemini Developer API: `GEMINI_API_KEY` or `GOOGLE_API_KEY`
+- For Vertex AI: `GOOGLE_GENAI_USE_VERTEXAI=true`, `GOOGLE_CLOUD_PROJECT`, and `GOOGLE_CLOUD_LOCATION`
diff --git a/python/samples/02-agents/providers/gemini/gemini_advanced 2.py b/python/samples/02-agents/providers/gemini/gemini_advanced 2.py
new file mode 100644
index 00000000000..1d4e3467c23
--- /dev/null
+++ b/python/samples/02-agents/providers/gemini/gemini_advanced 2.py
@@ -0,0 +1,62 @@
+# Copyright (c) Microsoft. All rights reserved.
+# /// script
+# requires-python = ">=3.10"
+# dependencies = ["agent-framework-gemini"]
+# ///
+
+"""Shows how to enable extended thinking with ThinkingConfig.
+
+Allows the model to reason through complex problems before responding.
+
+Requires ``GOOGLE_MODEL`` or ``GEMINI_MODEL`` and either Gemini Developer API credentials
+(``GEMINI_API_KEY`` or ``GOOGLE_API_KEY``) or Vertex AI settings
+(``GOOGLE_GENAI_USE_VERTEXAI``, ``GOOGLE_CLOUD_PROJECT``, and ``GOOGLE_CLOUD_LOCATION``).
+"""
+
+import asyncio
+
+from agent_framework import Agent
+from agent_framework.gemini import GeminiChatClient, GeminiChatOptions, ThinkingConfig
+from dotenv import load_dotenv
+
+load_dotenv()
+
+
+async def main() -> None:
+ """Example of extended thinking with a Python version comparison question."""
+ print("=== Extended thinking ===")
+
+ # 1. Configure Gemini extended thinking for a reasoning-heavy request.
+ options: GeminiChatOptions = {
+ "thinking_config": ThinkingConfig(thinking_budget=2048),
+ }
+
+ # 2. Create the agent with the Gemini chat client and default thinking options.
+ agent = Agent(
+ client=GeminiChatClient(),
+ name="PythonAgent",
+ instructions="You are a helpful Python expert.",
+ default_options=options,
+ )
+
+ # 3. Stream the answer so you can see the final response as it arrives.
+ query = "What new language features were introduced in Python between 3.10 and 3.14?"
+ print(f"User: {query}")
+ print("Agent: ", end="", flush=True)
+ async for chunk in agent.run(query, stream=True):
+ if chunk.text:
+ print(chunk.text, end="", flush=True)
+ print("\n")
+
+
+if __name__ == "__main__":
+ asyncio.run(main())
+
+"""
+Sample output:
+=== Extended thinking ===
+User: What new language features were introduced in Python between 3.10 and 3.14?
+Agent: Python 3.11 introduced exception groups and TaskGroup.
+Python 3.12 added PEP 695 type parameter syntax.
+Python 3.13-3.14 continued improving typing, performance, and developer ergonomics.
+"""
diff --git a/python/samples/02-agents/providers/gemini/gemini_basic 2.py b/python/samples/02-agents/providers/gemini/gemini_basic 2.py
new file mode 100644
index 00000000000..15e4cf32908
--- /dev/null
+++ b/python/samples/02-agents/providers/gemini/gemini_basic 2.py
@@ -0,0 +1,96 @@
+# Copyright (c) Microsoft. All rights reserved.
+# /// script
+# requires-python = ">=3.10"
+# dependencies = ["agent-framework-gemini"]
+# ///
+
+"""Shows how to use GeminiChatClient with an agent and a custom tool.
+
+Covers both non-streaming and streaming responses.
+
+Requires ``GOOGLE_MODEL`` or ``GEMINI_MODEL`` and either Gemini Developer API credentials
+(``GEMINI_API_KEY`` or ``GOOGLE_API_KEY``) or Vertex AI settings
+(``GOOGLE_GENAI_USE_VERTEXAI``, ``GOOGLE_CLOUD_PROJECT``, and ``GOOGLE_CLOUD_LOCATION``).
+"""
+
+import asyncio
+from random import randint
+from typing import Annotated
+
+from agent_framework import Agent, tool
+from agent_framework.gemini import GeminiChatClient
+from dotenv import load_dotenv
+
+load_dotenv()
+
+
+# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production
+@tool(approval_mode="never_require")
+def get_weather(
+ location: Annotated[str, "The location to get the weather for."],
+) -> str:
+ """Get the weather for a given location."""
+ conditions = ["sunny", "cloudy", "rainy", "stormy"]
+ return f"The weather in {location} is {conditions[randint(0, 3)]} with a high of {randint(10, 30)}°C."
+
+
+async def non_streaming_example() -> None:
+ """Runs the agent and waits for the complete response before printing it."""
+ print("=== Non-streaming ===")
+
+ # 1. Create the agent with the Gemini chat client and local weather tool.
+ agent = Agent(
+ client=GeminiChatClient(),
+ name="WeatherAgent",
+ instructions="You are a helpful weather agent.",
+ tools=[get_weather],
+ )
+
+ # 2. Ask the agent for a single weather lookup and print the final response.
+ query = "What's the weather like in Karlsruhe, Germany?"
+ print(f"User: {query}")
+ result = await agent.run(query)
+ print(f"Result: {result}\n")
+
+
+async def streaming_example() -> None:
+ """Runs the agent and prints each chunk as it is received."""
+ print("=== Streaming ===")
+
+ # 1. Create the same agent configuration for a streaming tool-call example.
+ agent = Agent(
+ client=GeminiChatClient(),
+ name="WeatherAgent",
+ instructions="You are a helpful weather agent.",
+ tools=[get_weather],
+ )
+
+ # 2. Ask a multi-location question and stream the model output as it arrives.
+ query = "What's the weather like in Portland and in Paris?"
+ print(f"User: {query}")
+ print("Agent: ", end="", flush=True)
+ async for chunk in agent.run(query, stream=True):
+ if chunk.text:
+ print(chunk.text, end="", flush=True)
+ print("\n")
+
+
+async def main() -> None:
+ """Run non-streaming and streaming examples."""
+ await non_streaming_example()
+ await streaming_example()
+
+
+if __name__ == "__main__":
+ asyncio.run(main())
+
+"""
+Sample output:
+=== Non-streaming ===
+User: What's the weather like in Karlsruhe, Germany?
+Result: The weather in Karlsruhe, Germany is currently sunny with a high of 16°C.
+
+=== Streaming ===
+User: What's the weather like in Portland and in Paris?
+Agent: In Portland, it is currently rainy with a high of 11°C. In Paris, it is cloudy with a high of 27°C.
+"""
diff --git a/python/samples/02-agents/providers/gemini/gemini_with_code_execution 2.py b/python/samples/02-agents/providers/gemini/gemini_with_code_execution 2.py
new file mode 100644
index 00000000000..adf7e0bcb25
--- /dev/null
+++ b/python/samples/02-agents/providers/gemini/gemini_with_code_execution 2.py
@@ -0,0 +1,55 @@
+# Copyright (c) Microsoft. All rights reserved.
+# /// script
+# requires-python = ">=3.10"
+# dependencies = ["agent-framework-gemini"]
+# ///
+
+"""Shows how to enable Gemini's built-in code execution tool.
+
+Allows the model to write and run code in a sandboxed environment to answer questions.
+
+Requires ``GOOGLE_MODEL`` or ``GEMINI_MODEL`` and either Gemini Developer API credentials
+(``GEMINI_API_KEY`` or ``GOOGLE_API_KEY``) or Vertex AI settings
+(``GOOGLE_GENAI_USE_VERTEXAI``, ``GOOGLE_CLOUD_PROJECT``, and ``GOOGLE_CLOUD_LOCATION``).
+"""
+
+import asyncio
+
+from agent_framework import Agent
+from agent_framework.gemini import GeminiChatClient
+from dotenv import load_dotenv
+
+load_dotenv()
+
+
+async def main() -> None:
+ """Run the code execution example."""
+ print("=== Code execution ===")
+
+ # 1. Create the agent with Gemini and the built-in code execution tool.
+ agent = Agent(
+ client=GeminiChatClient(),
+ name="CodeAgent",
+ instructions="You are a helpful assistant. Use code execution to compute precise answers.",
+ tools=[GeminiChatClient.get_code_interpreter_tool()],
+ )
+
+ # 2. Ask for a computed answer and stream the generated code and final result.
+ query = "What are the first 20 prime numbers? Compute them in code."
+ print(f"User: {query}")
+ print("Agent: ", end="", flush=True)
+ async for chunk in agent.run(query, stream=True):
+ if chunk.text:
+ print(chunk.text, end="", flush=True)
+ print("\n")
+
+
+if __name__ == "__main__":
+ asyncio.run(main())
+
+"""
+Sample output:
+=== Code execution ===
+User: What are the first 20 prime numbers? Compute them in code.
+Agent: The first 20 prime numbers are 2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37, 41, 43, 47, 53, 59, 61, 67, and 71.
+"""
diff --git a/python/samples/02-agents/providers/gemini/gemini_with_google_maps 2.py b/python/samples/02-agents/providers/gemini/gemini_with_google_maps 2.py
new file mode 100644
index 00000000000..ebabf12fba3
--- /dev/null
+++ b/python/samples/02-agents/providers/gemini/gemini_with_google_maps 2.py
@@ -0,0 +1,56 @@
+# Copyright (c) Microsoft. All rights reserved.
+# /// script
+# requires-python = ">=3.10"
+# dependencies = ["agent-framework-gemini"]
+# ///
+
+"""Shows how to enable Google Maps grounding.
+
+Allows Gemini to retrieve location and mapping information before responding.
+
+Requires ``GOOGLE_MODEL`` or ``GEMINI_MODEL`` and either Gemini Developer API credentials
+(``GEMINI_API_KEY`` or ``GOOGLE_API_KEY``) or Vertex AI settings
+(``GOOGLE_GENAI_USE_VERTEXAI``, ``GOOGLE_CLOUD_PROJECT``, and ``GOOGLE_CLOUD_LOCATION``).
+"""
+
+import asyncio
+
+from agent_framework import Agent
+from agent_framework.gemini import GeminiChatClient
+from dotenv import load_dotenv
+
+load_dotenv()
+
+
+async def main() -> None:
+ """Run the Google Maps grounding example."""
+ print("=== Google Maps grounding ===")
+
+ # 1. Create the agent with Gemini and the built-in Google Maps grounding tool.
+ agent = Agent(
+ client=GeminiChatClient(),
+ name="MapsAgent",
+ instructions="You are a helpful travel assistant. Use Google Maps to provide accurate location information.",
+ tools=[GeminiChatClient.get_maps_grounding_tool()],
+ )
+
+ # 2. Ask a location-aware question and stream the grounded answer.
+ query = "What are some highly rated restaurants in the city center of Karlsruhe, Germany?"
+ print(f"User: {query}")
+ print("Agent: ", end="", flush=True)
+ async for chunk in agent.run(query, stream=True):
+ if chunk.text:
+ print(chunk.text, end="", flush=True)
+ print("\n")
+
+
+if __name__ == "__main__":
+ asyncio.run(main())
+
+"""
+Sample output:
+=== Google Maps grounding ===
+User: What are some highly rated restaurants in the city center of Karlsruhe, Germany?
+Agent: Here are several highly rated restaurants near Karlsruhe city center,
+along with their cuisine styles and approximate walking distance.
+"""
diff --git a/python/samples/02-agents/providers/gemini/gemini_with_google_search 2.py b/python/samples/02-agents/providers/gemini/gemini_with_google_search 2.py
new file mode 100644
index 00000000000..160a74ece21
--- /dev/null
+++ b/python/samples/02-agents/providers/gemini/gemini_with_google_search 2.py
@@ -0,0 +1,55 @@
+# Copyright (c) Microsoft. All rights reserved.
+# /// script
+# requires-python = ">=3.10"
+# dependencies = ["agent-framework-gemini"]
+# ///
+
+"""Shows how to enable Google Search grounding.
+
+Allows Gemini to retrieve up-to-date information from the web before responding.
+
+Requires ``GOOGLE_MODEL`` or ``GEMINI_MODEL`` and either Gemini Developer API credentials
+(``GEMINI_API_KEY`` or ``GOOGLE_API_KEY``) or Vertex AI settings
+(``GOOGLE_GENAI_USE_VERTEXAI``, ``GOOGLE_CLOUD_PROJECT``, and ``GOOGLE_CLOUD_LOCATION``).
+"""
+
+import asyncio
+
+from agent_framework import Agent
+from agent_framework.gemini import GeminiChatClient
+from dotenv import load_dotenv
+
+load_dotenv()
+
+
+async def main() -> None:
+ """Run the Google Search grounding example."""
+ print("=== Google Search grounding ===")
+
+ # 1. Create the agent with Gemini and the built-in Google Search grounding tool.
+ agent = Agent(
+ client=GeminiChatClient(),
+ name="SearchAgent",
+ instructions="You are a helpful assistant. Use Google Search to provide accurate, up-to-date answers.",
+ tools=[GeminiChatClient.get_web_search_tool()],
+ )
+
+ # 2. Ask a current-events style question and stream the grounded answer.
+ query = "What is the latest stable release of the .NET SDK?"
+ print(f"User: {query}")
+ print("Agent: ", end="", flush=True)
+ async for chunk in agent.run(query, stream=True):
+ if chunk.text:
+ print(chunk.text, end="", flush=True)
+ print("\n")
+
+
+if __name__ == "__main__":
+ asyncio.run(main())
+
+"""
+Sample output:
+=== Google Search grounding ===
+User: What is the latest stable release of the .NET SDK?
+Agent: As of April 14, 2026, the latest stable release of the .NET SDK is .NET 10.0 (SDK 10.0.201).
+"""
diff --git a/python/samples/02-agents/providers/mistral/README 2.md b/python/samples/02-agents/providers/mistral/README 2.md
new file mode 100644
index 00000000000..b58ecf6ef26
--- /dev/null
+++ b/python/samples/02-agents/providers/mistral/README 2.md
@@ -0,0 +1,15 @@
+# Mistral AI Embedding Examples
+
+This folder contains examples demonstrating how to use Mistral AI embedding models with the Agent Framework.
+
+## Examples
+
+| File | Description |
+|------|-------------|
+| [`mistral_embeddings.py`](mistral_embeddings.py) | Basic embedding generation with the Mistral AI embedding client. |
+
+## Environment Variables
+
+- `MISTRAL_API_KEY`: Your Mistral AI API key
+- `MISTRAL_EMBEDDING_MODEL`: Embedding model name (e.g., `mistral-embed`)
+- `MISTRAL_SERVER_URL` (optional): Server URL override for custom deployments
diff --git a/python/samples/02-agents/providers/mistral/mistral_embeddings 2.py b/python/samples/02-agents/providers/mistral/mistral_embeddings 2.py
new file mode 100644
index 00000000000..20419ded62d
--- /dev/null
+++ b/python/samples/02-agents/providers/mistral/mistral_embeddings 2.py
@@ -0,0 +1,80 @@
+# Copyright (c) Microsoft. All rights reserved.
+# /// script
+# requires-python = ">=3.10"
+# dependencies = ["agent-framework-mistral"]
+# ///
+
+"""Shows how to generate embeddings using the Mistral AI embedding client.
+
+Requires ``MISTRAL_API_KEY`` and ``MISTRAL_EMBEDDING_MODEL`` environment variables.
+"""
+
+import asyncio
+
+from agent_framework.mistral import MistralEmbeddingClient
+from dotenv import load_dotenv
+
+load_dotenv()
+
+
+async def basic_embedding_example() -> None:
+ """Generate embeddings for a list of texts."""
+ print("=== Basic Embedding Generation ===")
+
+ # 1. Create the embedding client (uses MISTRAL_API_KEY and MISTRAL_EMBEDDING_MODEL env vars).
+ client = MistralEmbeddingClient()
+
+ # 2. Generate embeddings for multiple texts.
+ texts = ["Hello, world!", "How are you?", "Agent Framework with Mistral AI"]
+ result = await client.get_embeddings(texts)
+
+ # 3. Print results.
+ print(f"Generated {len(result)} embeddings")
+ for i, embedding in enumerate(result):
+ print(f" Text {i + 1}: dimensions={embedding.dimensions}, vector={embedding.vector[:5]}...")
+
+ if result.usage:
+ print(
+ f" Usage: {result.usage['input_token_count']} input tokens, "
+ f"{result.usage['total_token_count']} total tokens"
+ )
+
+
+async def embedding_with_options_example() -> None:
+ """Generate embeddings with custom dimensions."""
+ print("\n=== Embedding with Custom Dimensions ===")
+
+ from agent_framework.mistral import MistralEmbeddingOptions
+
+ client = MistralEmbeddingClient()
+
+ # Request a specific output dimension (model must support it).
+ options: MistralEmbeddingOptions = {"dimensions": 256}
+ result = await client.get_embeddings(["Dimensionality reduction example"], options=options)
+
+ print(f" Dimensions: {result[0].dimensions}")
+ print(f" Vector (first 5): {result[0].vector[:5]}...")
+
+
+async def main() -> None:
+ """Run embedding examples."""
+ await basic_embedding_example()
+ await embedding_with_options_example()
+
+
+if __name__ == "__main__":
+ asyncio.run(main())
+
+"""
+Sample output:
+=== Basic Embedding Generation ===
+Generated 3 embeddings
+ Text 1: dimensions=1024, vector=[0.0123, -0.0456, 0.0789, -0.0012, 0.0345]...
+ Text 2: dimensions=1024, vector=[0.0234, -0.0567, 0.0891, -0.0023, 0.0456]...
+ Text 3: dimensions=1024, vector=[0.0345, -0.0678, 0.0912, -0.0034, 0.0567]...
+ Usage: 15 input tokens, 15 total tokens
+
+=== Embedding with Custom Dimensions ===
+ Dimensions: 256
+ Vector (first 5): [0.0456, -0.0789, 0.0123, -0.0456, 0.0789]...
+"""
diff --git a/python/samples/04-hosting/mcp/README 2.md b/python/samples/04-hosting/mcp/README 2.md
new file mode 100644
index 00000000000..ee9bbad97df
--- /dev/null
+++ b/python/samples/04-hosting/mcp/README 2.md
@@ -0,0 +1,148 @@
+# MCP hosting with native SDK constructs
+
+An MCP server that exposes tools must handle `tools/list` and `tools/call`.
+There are two common ways to build that:
+
+1. Implement the list and call handlers directly. The application defines each
+ native tool schema, validates or parses its arguments, routes the selected
+ tool name, and returns its result.
+2. Declare callable tools and let a higher-level server generate the list and
+ call handlers from those declarations. FastMCP follows this model by deriving
+ tool schemas and argument parsing from Python function signatures.
+
+Choose between them based on the other MCP features you want to expose and how
+much control you need over the server, schema, validation, lifecycle, and
+transport. Hosting one Agent Framework agent or workflow usually exposes only
+one MCP tool, so either model remains small. A directly implemented
+`call_tool` handler needs little routing logic when it has only one supported
+tool name.
+
+Both approaches use the same two protocol-boundary functions from
+`agent-framework-hosting-mcp`:
+
+- `mcp_to_run(...)` converts validated MCP tool arguments into Agent Framework
+ messages and selected chat options.
+- `mcp_from_run(...)` converts a completed Agent Framework response into native
+ MCP result content blocks.
+
+These functions are the smallest Agent Framework integration boundary. The
+later samples add optional helpers that derive tool schemas, execute agents or
+workflows, and manage Agent Framework conversation state.
+
+## Samples
+
+### 1. Manual low-level server
+
+[`manual_app.py`](manual_app.py) shows the complete boundary directly. It
+defines the native MCP `Tool`, registers low-level `list_tools` and `call_tool`
+handlers, calls `mcp_to_run(...)`, runs the agent, and calls
+`mcp_from_run(...)`.
+
+Use this when the application needs full control over a custom MCP contract.
+
+```bash
+uv run manual_app.py
+```
+
+### 2. FastMCP server
+
+[`fastmcp_app.py`](fastmcp_app.py) keeps the same two conversion functions but
+replaces the low-level server setup with FastMCP. FastMCP derives and validates
+the tool schema from the decorated `run_agent(...)` function and owns the
+streamable HTTP server.
+
+Use this when a normal Python function signature fully describes the MCP tool.
+FastMCP keeps its generated schema and argument parsing aligned.
+
+```bash
+uv run fastmcp_app.py
+```
+
+### 3. Agent-derived tool
+
+[`agent_app.py`](agent_app.py) adds `AgentMCPTool` to the low-level server. The adapter
+derives the native tool name and description from the agent, owns the configured
+argument schema, runs the agent, and applies the same conversion boundary.
+
+Use this when one Agent Framework agent should be represented as one generated
+MCP tool. Unlike the FastMCP sample, this adapter derives the contract from the
+agent and its adapter configuration rather than a decorated function signature.
+
+```bash
+uv run agent_app.py
+```
+
+### 4. Session-aware agent
+
+[`session_app.py`](session_app.py) builds on `AgentMCPTool` with `AgentState`.
+It loads and stores an `AgentSession` using an opaque, application-defined
+`session_id` and serializes calls per ID in-process.
+
+Reusing the same ID continues and updates one conversation. This is not
+`previous_response_id`-style branching. An application that needs forks should
+define separate source and destination IDs, copy the source session, and store
+the completed turn under the destination ID.
+
+```bash
+uv run session_app.py
+```
+
+### 5. Workflow-derived tool
+
+[`workflow_app.py`](workflow_app.py) uses `WorkflowMCPTool` to derive the tool
+schema from the workflow start executor's single input type. Dataclass or
+Pydantic fields become top-level MCP arguments; primitive inputs are wrapped in
+one configurable argument.
+
+The sample uses a `WorkflowState` factory with `cache_target=False` so each MCP
+call receives a fresh workflow instance. Checkpoint and human-in-the-loop
+continuation remain application-owned contracts.
+
+```bash
+uv run workflow_app.py
+```
+
+## Concept summary
+
+| Concept | Responsibility | Used by |
+|---|---|---|
+| `mcp_to_run(...)` | Converts validated MCP arguments into Agent Framework messages and selected chat options. | `manual_app.py`, `fastmcp_app.py` |
+| `mcp_from_run(...)` | Converts a completed agent response into MCP result content blocks. | `manual_app.py`, `fastmcp_app.py` |
+| `AgentMCPTool` | Derives one native MCP tool from an agent and keeps schema, execution, and conversion aligned. | `agent_app.py`, `session_app.py` |
+| `WorkflowMCPTool` | Derives one native MCP tool from a workflow start executor and converts workflow outputs. | `workflow_app.py` |
+
+## Run
+
+The agent samples require Microsoft Foundry configuration:
+
+```bash
+export FOUNDRY_PROJECT_ENDPOINT=https://.services.ai.azure.com
+export FOUNDRY_MODEL=gpt-5-nano
+az login
+```
+
+Run one sample, connect an MCP client to `http://127.0.0.1:8000/mcp`, and invoke
+its tool. The agent tools accept a `task` string and optionally
+`reasoning_effort` with `low`, `medium`, or `high`.
+
+Each entry point declares its complete Agent Framework and third-party
+dependency set using PEP 723 inline script metadata.
+
+## Common behavior
+
+- **No framework choice:** the package does not select FastMCP, Starlette,
+ Uvicorn, stdio, or streamable HTTP.
+- **Chat options:** only explicitly selected MCP arguments are passed to the
+ model client. The samples expose `reasoning_effort` as an example, but any option
+ valid for the agent can be exposed.
+- **Input shape:** MCP tool arguments are JSON objects and do not define native
+ multimodal input content blocks. The samples do not present an
+ application-specific image schema as protocol behavior.
+- **Streaming:** streamable HTTP can carry multiple MCP messages, but a tool
+ call still produces one final `CallToolResult`. Progress notifications and
+ experimental deferred tasks remain application-owned protocol features.
+- **Authentication:** the local endpoints are intentionally unauthenticated.
+ MCP transport session identifiers are not user authorization. Production
+ servers must authenticate and authorize before loading tenant or user state.
+- **Errors:** conversion, model, workflow, and protocol errors propagate to the
+ MCP SDK rather than becoming success-shaped tool output.
diff --git a/python/samples/04-hosting/mcp/agent_app 2.py b/python/samples/04-hosting/mcp/agent_app 2.py
new file mode 100644
index 00000000000..c53bf9ec44c
--- /dev/null
+++ b/python/samples/04-hosting/mcp/agent_app 2.py
@@ -0,0 +1,107 @@
+# /// script
+# requires-python = ">=3.10"
+# dependencies = [
+# "agent-framework-foundry",
+# "agent-framework-hosting-mcp",
+# "azure-identity",
+# "mcp>=1.27.0,<2",
+# "starlette>=0.40",
+# "uvicorn>=0.30",
+# ]
+# ///
+# Run with: uv run agent_app.py
+
+# Copyright (c) Microsoft. All rights reserved.
+
+"""Host an Agent Framework agent with the native MCP streamable HTTP server.
+
+The hosting helper package only converts values at the protocol boundary. This
+application owns the MCP tool schema, server, transport, authentication policy,
+and Agent Framework session policy.
+
+This compact local sample is intentionally unauthenticated and stateless:
+every tool call starts a fresh Agent Framework conversation. Add authentication
+in the outer ASGI server before deriving a trusted user or tenant session key.
+
+Required environment variables: ``FOUNDRY_PROJECT_ENDPOINT`` and
+``FOUNDRY_MODEL``.
+"""
+
+from __future__ import annotations
+
+import os
+from collections.abc import AsyncIterator
+from contextlib import asynccontextmanager
+
+import uvicorn
+from agent_framework import Agent
+from agent_framework.foundry import FoundryChatClient
+from agent_framework_hosting_mcp import AgentMCPTool
+from azure.identity.aio import DefaultAzureCredential
+from mcp import types
+from mcp.server.lowlevel import Server
+from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
+from starlette.applications import Starlette
+from starlette.routing import Mount
+
+server = Server("agent-framework-hosting-mcp-sample")
+credential = DefaultAzureCredential()
+agent = Agent(
+ client=FoundryChatClient(
+ project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
+ model=os.environ["FOUNDRY_MODEL"],
+ credential=credential,
+ ),
+ name="MCPHostedAgent",
+ description="Answer a request with the hosted Agent Framework agent.",
+ instructions="Answer the user's request clearly and concisely.",
+)
+agent_tool = AgentMCPTool(
+ agent,
+ name="run_agent",
+ argument_description="The request for the hosted agent.",
+ chat_option_parameters={
+ "reasoning_effort": {
+ "type": "string",
+ "enum": ["low", "medium", "high"],
+ "description": "Optional reasoning effort for models that support it.",
+ }
+ },
+)
+
+
+@server.list_tools()
+async def list_tools() -> list[types.Tool]:
+ """Describe the app-owned MCP tool schema."""
+ return await agent_tool.list_tools()
+
+
+@server.call_tool()
+async def call_tool(name: str, arguments: dict[str, object] | None) -> list[types.ContentBlock]:
+ """Run the app-owned tool with native MCP and Agent Framework values."""
+ return await agent_tool.call_tool(name, arguments)
+
+
+session_manager = StreamableHTTPSessionManager(
+ app=server,
+ event_store=None,
+ json_response=True,
+ stateless=True,
+)
+
+
+@asynccontextmanager
+async def lifespan(_app: Starlette) -> AsyncIterator[None]:
+ """Start and stop native MCP and model-client resources."""
+ async with session_manager.run(), credential:
+ yield
+
+
+app = Starlette(
+ routes=[Mount("/", app=session_manager.handle_request)],
+ lifespan=lifespan,
+)
+
+
+if __name__ == "__main__":
+ uvicorn.run(app, host="127.0.0.1", port=8000)
diff --git a/python/samples/04-hosting/mcp/fastmcp_app 2.py b/python/samples/04-hosting/mcp/fastmcp_app 2.py
new file mode 100644
index 00000000000..fb0901804e3
--- /dev/null
+++ b/python/samples/04-hosting/mcp/fastmcp_app 2.py
@@ -0,0 +1,97 @@
+# /// script
+# requires-python = ">=3.10"
+# dependencies = [
+# "agent-framework-foundry",
+# "agent-framework-hosting-mcp",
+# "azure-identity",
+# "mcp>=1.27.0,<2",
+# ]
+# ///
+# Run with: uv run fastmcp_app.py
+
+# Copyright (c) Microsoft. All rights reserved.
+
+"""Host an Agent Framework agent with FastMCP and the conversion helpers.
+
+FastMCP derives the native MCP tool schema from the decorated function
+signature. The Agent Framework hosting package only converts the validated
+arguments and completed agent response at the protocol boundary.
+
+This compact local sample is intentionally unauthenticated and stateless:
+every tool call starts a fresh Agent Framework conversation.
+
+Required environment variables: ``FOUNDRY_PROJECT_ENDPOINT`` and
+``FOUNDRY_MODEL``.
+"""
+
+from __future__ import annotations
+
+import os
+from collections.abc import AsyncIterator
+from contextlib import asynccontextmanager
+from typing import Literal
+
+from agent_framework import Agent
+from agent_framework.foundry import FoundryChatClient
+from agent_framework_hosting_mcp import mcp_from_run, mcp_to_run
+from azure.identity.aio import DefaultAzureCredential
+from mcp import types
+from mcp.server.fastmcp import FastMCP
+
+credential = DefaultAzureCredential()
+agent = Agent(
+ client=FoundryChatClient(
+ project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
+ model=os.environ["FOUNDRY_MODEL"],
+ credential=credential,
+ ),
+ name="MCPHostedAgent",
+ description="Answer a request with the hosted Agent Framework agent.",
+ instructions="Answer the user's request clearly and concisely.",
+)
+
+
+@asynccontextmanager
+async def lifespan(_server: FastMCP[None]) -> AsyncIterator[None]:
+ """Close the model credential when the FastMCP server stops."""
+ async with credential:
+ yield
+
+
+server = FastMCP(
+ name="agent-framework-hosting-fastmcp-sample",
+ instructions="Expose an Agent Framework agent as an MCP tool.",
+ host="127.0.0.1",
+ port=8000,
+ streamable_http_path="/mcp",
+ json_response=True,
+ stateless_http=True,
+ lifespan=lifespan,
+)
+
+
+@server.tool(
+ name="run_agent",
+ description="Run the hosted Agent Framework agent.",
+ structured_output=False,
+)
+async def run_agent(
+ task: str,
+ reasoning_effort: Literal["low", "medium", "high"] | None = None,
+) -> list[types.ContentBlock]:
+ """Run the agent with FastMCP-validated arguments."""
+ arguments: dict[str, object] = {"task": task}
+ if reasoning_effort is not None:
+ arguments["reasoning_effort"] = reasoning_effort
+
+ run = mcp_to_run(arguments, chat_option_arguments={"reasoning_effort"})
+ result = await agent.run(
+ run["messages"],
+ options=run["options"],
+ stream=False,
+ )
+ return mcp_from_run(result)
+
+
+if __name__ == "__main__":
+ server.run(transport="streamable-http")
diff --git a/python/samples/04-hosting/mcp/manual_app 2.py b/python/samples/04-hosting/mcp/manual_app 2.py
new file mode 100644
index 00000000000..1faf24bc5a6
--- /dev/null
+++ b/python/samples/04-hosting/mcp/manual_app 2.py
@@ -0,0 +1,122 @@
+# /// script
+# requires-python = ">=3.10"
+# dependencies = [
+# "agent-framework-foundry",
+# "agent-framework-hosting-mcp",
+# "azure-identity",
+# "mcp>=1.27.0,<2",
+# "starlette>=0.40",
+# "uvicorn>=0.30",
+# ]
+# ///
+# Run with: uv run manual_app.py
+
+# Copyright (c) Microsoft. All rights reserved.
+
+"""Host an Agent Framework agent using the conversion functions directly.
+
+This version is useful when an application's MCP tool contract does not fit the
+single-agent ``AgentMCPTool`` adapter. The native tool schema and handler stay
+fully visible while ``mcp_to_run`` and ``mcp_from_run`` bridge AF values.
+"""
+
+from __future__ import annotations
+
+import os
+from collections.abc import AsyncIterator
+from contextlib import asynccontextmanager
+
+import uvicorn
+from agent_framework import Agent
+from agent_framework.foundry import FoundryChatClient
+from agent_framework_hosting_mcp import mcp_from_run, mcp_to_run
+from azure.identity.aio import DefaultAzureCredential
+from mcp import types
+from mcp.server.lowlevel import Server
+from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
+from starlette.applications import Starlette
+from starlette.routing import Mount
+
+TASK_ARGUMENT = "task"
+CHAT_OPTION_ARGUMENTS = {
+ "reasoning_effort": {
+ "type": "string",
+ "enum": ["low", "medium", "high"],
+ "description": "Optional reasoning effort for models that support it.",
+ }
+}
+
+server = Server("agent-framework-hosting-mcp-manual-sample")
+credential = DefaultAzureCredential()
+agent = Agent(
+ client=FoundryChatClient(
+ project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
+ model=os.environ["FOUNDRY_MODEL"],
+ credential=credential,
+ ),
+ name="ManualMCPAgent",
+ description="Answer requests through a manually defined MCP tool.",
+ instructions="Answer the user's request clearly and concisely.",
+)
+
+
+@server.list_tools()
+async def list_tools() -> list[types.Tool]:
+ """Return the app-owned native MCP tool definition."""
+ return [
+ types.Tool(
+ name="run_agent_manually",
+ description=agent.description or "",
+ inputSchema={
+ "type": "object",
+ "properties": {
+ TASK_ARGUMENT: {
+ "type": "string",
+ "description": "The request for the hosted agent.",
+ },
+ **CHAT_OPTION_ARGUMENTS,
+ },
+ "required": [TASK_ARGUMENT],
+ "additionalProperties": False,
+ },
+ )
+ ]
+
+
+@server.call_tool()
+async def call_tool(name: str, arguments: dict[str, object] | None) -> list[types.ContentBlock]:
+ """Convert, run, and render without the agent-backed adapter."""
+ if name != "run_agent_manually":
+ raise ValueError(f"Unknown MCP tool: {name}")
+ run = mcp_to_run(
+ arguments,
+ argument_name=TASK_ARGUMENT,
+ chat_option_arguments=CHAT_OPTION_ARGUMENTS,
+ )
+ result = await agent.run(run["messages"], options=run["options"])
+ return mcp_from_run(result)
+
+
+session_manager = StreamableHTTPSessionManager(
+ app=server,
+ event_store=None,
+ json_response=True,
+ stateless=True,
+)
+
+
+@asynccontextmanager
+async def lifespan(_app: Starlette) -> AsyncIterator[None]:
+ """Start and stop native MCP and model-client resources."""
+ async with session_manager.run(), credential:
+ yield
+
+
+app = Starlette(
+ routes=[Mount("/", app=session_manager.handle_request)],
+ lifespan=lifespan,
+)
+
+
+if __name__ == "__main__":
+ uvicorn.run(app, host="127.0.0.1", port=8000)
diff --git a/python/samples/04-hosting/mcp/pyproject 2.toml b/python/samples/04-hosting/mcp/pyproject 2.toml
new file mode 100644
index 00000000000..2d1bb331dcb
--- /dev/null
+++ b/python/samples/04-hosting/mcp/pyproject 2.toml
@@ -0,0 +1,22 @@
+[project]
+name = "agent-framework-hosting-sample-mcp"
+version = "0.0.1"
+description = "Streamable HTTP MCP server using Agent Framework hosting helpers."
+requires-python = ">=3.10"
+dependencies = [
+ "agent-framework-core",
+ "agent-framework-foundry",
+ "agent-framework-hosting",
+ "agent-framework-hosting-mcp",
+ "azure-identity",
+ "mcp>=1.27.0,<2",
+ "starlette>=0.40",
+ "uvicorn>=0.30",
+]
+
+[tool.uv]
+package = false
+
+[tool.uv.sources]
+agent-framework-hosting = { git = "https://github.com/microsoft/agent-framework.git", branch = "main", subdirectory = "python/packages/hosting" }
+agent-framework-hosting-mcp = { git = "https://github.com/microsoft/agent-framework.git", branch = "main", subdirectory = "python/packages/hosting-mcp" }
diff --git a/python/samples/04-hosting/mcp/session_app 2.py b/python/samples/04-hosting/mcp/session_app 2.py
new file mode 100644
index 00000000000..c185c0ef130
--- /dev/null
+++ b/python/samples/04-hosting/mcp/session_app 2.py
@@ -0,0 +1,132 @@
+# /// script
+# requires-python = ">=3.10"
+# dependencies = [
+# "agent-framework-foundry",
+# "agent-framework-hosting-mcp",
+# "azure-identity",
+# "mcp>=1.27.0,<2",
+# "starlette>=0.40",
+# "uvicorn>=0.30",
+# ]
+# ///
+# Run with: uv run session_app.py
+
+# Copyright (c) Microsoft. All rights reserved.
+
+"""Host a session-aware Agent Framework agent through native MCP constructs.
+
+The MCP tool accepts an opaque, app-defined ``session_id`` string. Its format is
+not prescribed by MCP or Agent Framework; an application might use a UUID, a
+database key, or a key derived from authenticated tenant and conversation IDs.
+Reusing an ID continues and updates that one conversation.
+
+This is deliberately different from ``previous_response_id``-style branching,
+where an earlier point can be used to create a new conversation branch. An app
+that needs branching should accept separate source and destination IDs, load a
+copy from the source, and store the updated session under the destination.
+
+This local sample uses the caller-provided ID directly for clarity. Production
+servers must derive or authorize the session key from authenticated outer-server
+context rather than trusting an arbitrary caller-provided identifier.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import os
+from collections.abc import AsyncIterator
+from contextlib import asynccontextmanager
+
+import uvicorn
+from agent_framework import Agent, InMemoryHistoryProvider
+from agent_framework.foundry import FoundryChatClient
+from agent_framework_hosting import AgentState
+from agent_framework_hosting_mcp import AgentMCPTool
+from azure.identity.aio import DefaultAzureCredential
+from mcp import types
+from mcp.server.lowlevel import Server
+from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
+from starlette.applications import Starlette
+from starlette.routing import Mount
+
+server = Server("agent-framework-hosting-mcp-session-sample")
+credential = DefaultAzureCredential()
+agent = Agent(
+ client=FoundryChatClient(
+ project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
+ model=os.environ["FOUNDRY_MODEL"],
+ credential=credential,
+ ),
+ name="SessionAwareAgent",
+ description="Answer requests while preserving app-owned conversation state.",
+ instructions="Answer clearly and use prior conversation context when relevant.",
+ context_providers=[InMemoryHistoryProvider()],
+ default_options={"store": False},
+)
+state = AgentState(agent)
+agent_tool = AgentMCPTool(
+ state,
+ name="run_agent",
+ argument_description="The request for the hosted agent.",
+ parameters={
+ "session_id": {
+ "type": "string",
+ "minLength": 1,
+ "description": (
+ "Opaque, app-defined conversation key. Reuse the same value to continue and update one conversation."
+ ),
+ }
+ },
+ required_parameters={"session_id"},
+ chat_option_parameters={
+ "reasoning_effort": {
+ "type": "string",
+ "enum": ["low", "medium", "high"],
+ "description": "Optional reasoning effort for models that support it.",
+ }
+ },
+ session_id_parameter="session_id",
+)
+session_locks: dict[str, asyncio.Lock] = {}
+
+
+@server.list_tools()
+async def list_tools() -> list[types.Tool]:
+ """Return the agent-derived MCP tool definition."""
+ return await agent_tool.list_tools()
+
+
+@server.call_tool()
+async def call_tool(name: str, arguments: dict[str, object] | None) -> list[types.ContentBlock]:
+ """Serialize calls per app-owned session before using ``AgentState``."""
+ session_id = arguments.get("session_id") if arguments else None
+ if not isinstance(session_id, str) or not session_id:
+ raise ValueError("MCP tool argument 'session_id' must be a non-empty string.")
+ lock = session_locks.setdefault(session_id, asyncio.Lock())
+ async with lock:
+ return await agent_tool.call_tool(name, arguments)
+
+
+session_manager = StreamableHTTPSessionManager(
+ app=server,
+ event_store=None,
+ json_response=True,
+ stateless=True,
+)
+
+
+@asynccontextmanager
+async def lifespan(_app: Starlette) -> AsyncIterator[None]:
+ """Start and stop native MCP and model-client resources."""
+ async with session_manager.run(), credential:
+ yield
+
+
+app = Starlette(
+ routes=[Mount("/", app=session_manager.handle_request)],
+ lifespan=lifespan,
+)
+
+
+if __name__ == "__main__":
+ uvicorn.run(app, host="127.0.0.1", port=8000)
diff --git a/python/samples/04-hosting/mcp/workflow_app 2.py b/python/samples/04-hosting/mcp/workflow_app 2.py
new file mode 100644
index 00000000000..8bc715a53ed
--- /dev/null
+++ b/python/samples/04-hosting/mcp/workflow_app 2.py
@@ -0,0 +1,107 @@
+# /// script
+# requires-python = ">=3.10"
+# dependencies = [
+# "agent-framework-hosting-mcp",
+# "mcp>=1.27.0,<2",
+# "starlette>=0.40",
+# "uvicorn>=0.30",
+# ]
+# ///
+# Run with: uv run workflow_app.py
+
+# Copyright (c) Microsoft. All rights reserved.
+
+"""Host a typed Agent Framework workflow through native MCP constructs.
+
+``WorkflowMCPTool`` derives the MCP arguments from the start executor's input
+type. This sample uses a dataclass, so its fields become the tool's top-level
+arguments. The application still owns the MCP server and transport.
+"""
+
+from __future__ import annotations
+
+from collections.abc import AsyncIterator
+from contextlib import asynccontextmanager
+from dataclasses import dataclass
+
+import uvicorn
+from agent_framework import WorkflowBuilder, WorkflowContext, executor
+from agent_framework_hosting import WorkflowState
+from agent_framework_hosting_mcp import WorkflowMCPTool
+from mcp import types
+from mcp.server.lowlevel import Server
+from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
+from starlette.applications import Starlette
+from starlette.routing import Mount
+
+
+@dataclass
+class DraftRequest:
+ """Input contract exposed by the MCP tool."""
+
+ topic: str
+ audience: str
+ paragraph_count: int = 2
+
+
+def create_workflow():
+ """Create an independent workflow instance for one MCP call."""
+
+ @executor(id="draft")
+ async def draft(request: DraftRequest, ctx: WorkflowContext[object, str]) -> None:
+ paragraphs = "\n\n".join(
+ f"Paragraph {index + 1}: {request.topic} for {request.audience}."
+ for index in range(request.paragraph_count)
+ )
+ await ctx.yield_output(paragraphs)
+
+ return WorkflowBuilder(
+ start_executor=draft,
+ name="Draft Workflow",
+ description="Draft short content for a specified topic and audience.",
+ output_from=[draft],
+ ).build()
+
+
+server = Server("agent-framework-hosting-mcp-workflow-sample")
+workflow_tool = WorkflowMCPTool(
+ WorkflowState(create_workflow, cache_target=False),
+ name="draft_content",
+)
+
+
+@server.list_tools()
+async def list_tools() -> list[types.Tool]:
+ """Return the workflow-derived MCP tool definition."""
+ return await workflow_tool.list_tools()
+
+
+@server.call_tool()
+async def call_tool(name: str, arguments: dict[str, object] | None) -> list[types.ContentBlock]:
+ """Run a fresh workflow instance with validated MCP arguments."""
+ return await workflow_tool.call_tool(name, arguments)
+
+
+session_manager = StreamableHTTPSessionManager(
+ app=server,
+ event_store=None,
+ json_response=True,
+ stateless=True,
+)
+
+
+@asynccontextmanager
+async def lifespan(_app: Starlette) -> AsyncIterator[None]:
+ """Start and stop the native MCP transport."""
+ async with session_manager.run():
+ yield
+
+
+app = Starlette(
+ routes=[Mount("/", app=session_manager.handle_request)],
+ lifespan=lifespan,
+)
+
+
+if __name__ == "__main__":
+ uvicorn.run(app, host="127.0.0.1", port=8000)