diff --git a/images/ai-foundry-toolbox.gif b/images/ai-foundry-toolbox.gif new file mode 100644 index 00000000..5524c071 Binary files /dev/null and b/images/ai-foundry-toolbox.gif differ diff --git a/labs/ai-foundry-toolbox/README.md b/labs/ai-foundry-toolbox/README.md new file mode 100644 index 00000000..7a00ea6f --- /dev/null +++ b/labs/ai-foundry-toolbox/README.md @@ -0,0 +1,48 @@ +--- +name: AI Foundry Toolbox +architectureDiagram: images/ai-foundry-toolbox.gif +categories: + - AI Agents & MCP + - Knowledge & Tools +services: + - Azure AI Foundry + - Azure API Management +shortDescription: Secure and govern a Foundry Toolbox MCP endpoint with Azure API Management. +detailedDescription: A Foundry Toolbox bundles tools (MCP servers, Web Search, Azure AI Search, and more) into a single MCP-compatible endpoint. This lab shows how to place Azure API Management in front of that endpoint. APIM accepts subscription key auth from agents and transparently injects a managed-identity Entra token when forwarding requests to the Toolbox, so consumers never handle Foundry credentials directly. APIM also adds rate limiting, per-subscription monitoring, and policy-based governance over every tool call. +tags: [] +authors: + - georgeollis +--- + +# APIM ❤️ AI Foundry + +## [AI Foundry Toolbox lab](ai-foundry-toolbox.ipynb) + +[![flow](../../images/ai-foundry-toolbox.gif)](ai-foundry-toolbox.ipynb) + +A [Foundry Toolbox](https://learn.microsoft.com/azure/foundry/agents/how-to/tools/toolbox) is a managed resource in Azure AI Foundry that bundles tools—MCP servers, Web Search, Azure AI Search, Code Interpreter, and more—into a single MCP-compatible endpoint. This lab places Azure API Management in front of that endpoint, so agents use an APIM subscription key instead of managing Foundry credentials, while APIM enforces governance, rate limiting, and observability over every tool call. + +Key scenarios covered: + +- Deploy APIM and Azure AI Foundry using Bicep +- Deploy the vet-clinic and pet-insurance MCP servers as Azure Functions Flex Consumption apps (no container needed) +- Create a Foundry Toolbox with both MCP servers via the azure-ai-projects SDK +- Configure APIM native MCP proxy to the Toolbox MCP endpoint (subscription key → managed-identity Entra token) +- Verify tool discovery and run chat completions with Toolbox tools routed through APIM + +### Prerequisites + +- [Python 3.12 or later](https://www.python.org/) installed +- [VS Code](https://code.visualstudio.com/) installed with the [Jupyter notebook extension](https://marketplace.visualstudio.com/items?itemName=ms-toolsai.jupyter) enabled +- [uv](https://docs.astral.sh/uv/) — run `uv sync` from the repo root to install dependencies +- [An Azure Subscription](https://azure.microsoft.com/free/) with [Contributor](https://learn.microsoft.com/en-us/azure/role-based-access-control/built-in-roles/privileged#contributor) + [RBAC Administrator](https://learn.microsoft.com/en-us/azure/role-based-access-control/built-in-roles/privileged#role-based-access-control-administrator) or [Owner](https://learn.microsoft.com/en-us/azure/role-based-access-control/built-in-roles/privileged#owner) roles +- [Azure CLI](https://learn.microsoft.com/cli/azure/install-azure-cli) installed and [signed in to your Azure subscription](https://learn.microsoft.com/cli/azure/authenticate-azure-cli-interactively) + +### 🚀 Get started + +Proceed by opening the [Jupyter notebook](ai-foundry-toolbox.ipynb), and follow the steps provided. + +### 🗑️ Clean up resources + +When you're finished with the lab, you should remove all your deployed resources from Azure to avoid extra charges and keep your Azure subscription uncluttered. +Use the [clean-up-resources notebook](clean-up-resources.ipynb) for that. diff --git a/labs/ai-foundry-toolbox/ai-foundry-toolbox.ipynb b/labs/ai-foundry-toolbox/ai-foundry-toolbox.ipynb new file mode 100644 index 00000000..1e282b9b --- /dev/null +++ b/labs/ai-foundry-toolbox/ai-foundry-toolbox.ipynb @@ -0,0 +1,664 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "f814e1ef", + "metadata": {}, + "source": [ + "# APIM ❤️ AI Foundry\n", + "\n", + "## AI Foundry Toolbox lab\n", + "![flow](../../images/ai-foundry-toolbox.gif)\n", + "\n", + "A [Foundry Toolbox](https://learn.microsoft.com/azure/foundry/agents/how-to/tools/toolbox) is a managed resource in Azure AI Foundry that bundles tools—MCP servers, Web Search, Azure AI Search, Code Interpreter, and more—and exposes them through a single MCP-compatible endpoint:\n", + "`{project_endpoint}/toolboxes/{name}/mcp?api-version=v1`\n", + "\n", + "This lab deploys Azure API Management in front of that endpoint. APIM accepts subscription key auth from clients and transparently injects a managed-identity Entra token when forwarding requests to the Toolbox, so consumers never need to handle Foundry credentials directly. APIM also adds rate limiting, per-subscription monitoring, and policy-based governance over every tool call.\n", + "\n", + "**What this lab covers:**\n", + "- Deploy APIM and Azure AI Foundry with Bicep\n", + "- Deploy the vet-clinic and pet-insurance MCP servers as Azure Functions Flex Consumption apps\n", + "- Create a Foundry Toolbox that wraps both MCP servers\n", + "- Configure APIM to proxy the Toolbox MCP endpoint (subscription key → managed-identity Entra token)\n", + "- Verify tool discovery through the APIM proxy\n", + "- Run an OpenAI chat completion with Toolbox tools routed through APIM\n", + "\n", + "### Prerequisites\n", + "\n", + "- [Python 3.12 or later](https://www.python.org/) installed\n", + "- [VS Code](https://code.visualstudio.com/) installed with the [Jupyter notebook extension](https://marketplace.visualstudio.com/items?itemName=ms-toolsai.jupyter) enabled\n", + "- [uv](https://docs.astral.sh/uv/) — run `uv sync` from the repo root to install dependencies\n", + "- [An Azure Subscription](https://azure.microsoft.com/free/) with [Contributor](https://learn.microsoft.com/en-us/azure/role-based-access-control/built-in-roles/privileged#contributor) + [RBAC Administrator](https://learn.microsoft.com/en-us/azure/role-based-access-control/built-in-roles/privileged#role-based-access-control-administrator) or [Owner](https://learn.microsoft.com/en-us/azure/role-based-access-control/built-in-roles/privileged#owner) roles\n", + "- [Azure CLI](https://learn.microsoft.com/cli/azure/install-azure-cli) installed and [signed in to your Azure subscription](https://learn.microsoft.com/cli/azure/authenticate-azure-cli-interactively)\n", + "▶️ Click `Run All` to execute all steps sequentially, or execute them `Step by Step`..." + ] + }, + { + "cell_type": "markdown", + "id": "154c3d09", + "metadata": {}, + "source": [ + "\n", + "### 0️⃣ Initialize notebook variables\n", + "\n", + "- Resources will be suffixed by a unique string based on your subscription ID.\n", + "- Adjust the location parameters according to your preferences and [product availability by Azure region](https://azure.microsoft.com/explore/global-infrastructure/products-by-region/?cdn=disable&products=cognitive-services,api-management).\n", + "- Adjust the models and versions according to [availability by region](https://learn.microsoft.com/azure/ai-services/openai/concepts/models)." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "89e4964e", + "metadata": {}, + "outputs": [], + "source": [ + "# Make sure you have 2.3.0 installed\n", + "%uv pip install 'azure-ai-projects==2.3.0'" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "95ebbe25", + "metadata": {}, + "outputs": [], + "source": [ + "import os, sys, json\n", + "sys.path.insert(1, '../../shared') # add the shared directory to the Python path\n", + "import utils\n", + "\n", + "deployment_name = os.path.basename(os.path.dirname(globals()['__vsc_ipynb_file__']))\n", + "resource_group_name = f\"lab-{deployment_name}\" # change the name to match your naming style\n", + "resource_group_location = \"swedencentral\" # change the location to match your requirements\n", + "\n", + "# AI Services configuration\n", + "aiservices_config = [{\"name\": \"foundry1\", \"location\": \"swedencentral\"}] # change the location to match your requirements\n", + "\n", + "# Models configuration\n", + "models_config = [{\"name\": \"gpt-5-mini\", \"publisher\": \"OpenAI\", \"version\": \"2025-08-07\", \"sku\": \"GlobalStandard\", \"capacity\": 20}]\n", + "\n", + "# APIM configuration\n", + "apim_sku = 'Basicv2'\n", + "apim_subscriptions_config = [{\"name\": \"subscription1\", \"displayName\": \"Subscription 1\"}]\n", + "\n", + "# Inference API configuration\n", + "inference_api_path = \"inference\"\n", + "inference_api_type = \"AzureOpenAI\"\n", + "inference_api_version = \"2025-03-01-preview\"\n", + "foundry_project_name = deployment_name\n", + "\n", + "# Foundry Toolbox configuration\n", + "toolbox_name = \"pet-care-toolbox\" # shared Toolbox that will contain both MCP servers\n", + "toolbox_mcp_path = \"toolbox/mcp-native\" # APIM native MCP path\n", + "\n", + "# MCP server source folders\n", + "vet_clinic_src = \"mcp-servers/vet-clinic\"\n", + "pet_insurance_src = \"mcp-servers/pet-insurance\"\n", + "\n", + "utils.print_ok('Notebook initialized' )" + ] + }, + { + "cell_type": "markdown", + "id": "00782eef", + "metadata": {}, + "source": [ + "\n", + "### 1️⃣ Verify the Azure CLI and the connected Azure subscription\n", + "\n", + "The following commands ensure that you have the latest version of the Azure CLI and that the Azure CLI is connected to your Azure subscription." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e45b1a0e", + "metadata": {}, + "outputs": [], + "source": [ + "output = utils.run(\"az account show\", \"Retrieved az account\", \"Failed to get the current az account\")\n", + "\n", + "if output.success and output.json_data:\n", + " current_user = output.json_data['user']['name']\n", + " tenant_id = output.json_data['tenantId']\n", + " subscription_id = output.json_data['id']\n", + "\n", + " utils.print_info(f\"Current user: {current_user}\")\n", + " utils.print_info(f\"Tenant ID: {tenant_id}\")\n", + " utils.print_info(f\"Subscription ID: {subscription_id}\")" + ] + }, + { + "cell_type": "markdown", + "id": "bd4a8f80", + "metadata": {}, + "source": [ + "\n", + "### 2️⃣ Create deployment using 🦾 Bicep\n", + "\n", + "This lab uses [Bicep](https://learn.microsoft.com/azure/azure-resource-manager/bicep/overview?tabs=bicep) to declaratively define all the resources deployed in the specified resource group. The template provisions:\n", + "- Azure API Management (inference API + Toolbox MCP proxy API)\n", + "- Azure AI Foundry (with gpt-5-mini)\n", + "- Azure Storage Account + Flex Consumption App Service Plan + two Function Apps (vet and pet insurance MCP servers)\n", + "- Log Analytics Workspace and Application Insights" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "88e8fc45", + "metadata": {}, + "outputs": [], + "source": [ + "# Create the resource group if it doesn't exist\n", + "utils.create_resource_group(resource_group_name, resource_group_location)\n", + "\n", + "# Define the Bicep parameters\n", + "bicep_parameters = {\n", + " \"$schema\": \"https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#\",\n", + " \"contentVersion\": \"1.0.0.0\",\n", + " \"parameters\": {\n", + " \"apimSku\": { \"value\": apim_sku },\n", + " \"aiServicesConfig\": { \"value\": aiservices_config },\n", + " \"modelsConfig\": { \"value\": models_config },\n", + " \"apimSubscriptionsConfig\": { \"value\": apim_subscriptions_config },\n", + " \"inferenceAPIPath\": { \"value\": inference_api_path },\n", + " \"inferenceAPIType\": { \"value\": inference_api_type },\n", + " \"foundryProjectName\": { \"value\": foundry_project_name },\n", + " \"toolboxName\": { \"value\": toolbox_name }\n", + " }\n", + "}\n", + "\n", + "# Write the parameters to the params.json file\n", + "with open('params.json', 'w') as bicep_parameters_file:\n", + " bicep_parameters_file.write(json.dumps(bicep_parameters))\n", + "\n", + "# Run the deployment\n", + "output = utils.run(f\"az deployment group create --name {deployment_name} --resource-group {resource_group_name} --template-file main.bicep --parameters params.json\",\n", + " f\"Deployment '{deployment_name}' succeeded\", f\"Deployment '{deployment_name}' failed\")" + ] + }, + { + "cell_type": "markdown", + "id": "bb1603ae", + "metadata": {}, + "source": [ + "\n", + "### 3️⃣ Get the deployment outputs" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1529724e", + "metadata": {}, + "outputs": [], + "source": [ + "# Obtain all of the outputs from the deployment\n", + "output = utils.run(f\"az deployment group show --name {deployment_name} -g {resource_group_name}\",\n", + " f\"Retrieved deployment: {deployment_name}\", f\"Failed to retrieve deployment: {deployment_name}\")\n", + "\n", + "if output.success and output.json_data:\n", + " log_analytics_id = utils.get_deployment_output(output, 'logAnalyticsWorkspaceId', 'Log Analytics Id')\n", + " apim_service_id = utils.get_deployment_output(output, 'apimServiceId', 'APIM Service Id')\n", + " apim_resource_gateway_url = utils.get_deployment_output(output, 'apimResourceGatewayURL', 'APIM API Gateway URL')\n", + " apim_subscriptions = json.loads(utils.get_deployment_output(output, 'apimSubscriptions').replace(\"\\'\", '\"'))\n", + " for subscription in apim_subscriptions:\n", + " utils.print_info(f\"Subscription Name: {subscription['name']}\")\n", + " utils.print_info(f\"Subscription Key: ****{subscription['key'][-4:]}\")\n", + " api_key = apim_subscriptions[0].get(\"key\")\n", + " foundry_project_endpoint = utils.get_deployment_output(output, 'foundryProjectEndpoint', 'Foundry Project Endpoint')\n", + " vet_function_app_name = utils.get_deployment_output(output, 'functionAppName', 'Vet Function App Name')\n", + " vet_function_app_url = utils.get_deployment_output(output, 'functionAppUrl', 'Vet Function App URL')\n", + " pet_insurance_function_app_name = utils.get_deployment_output(output, 'petInsuranceFunctionAppName', 'Pet Insurance Function App Name')\n", + " pet_insurance_function_app_url = utils.get_deployment_output(output, 'petInsuranceFunctionAppUrl', 'Pet Insurance Function App URL')\n", + "\n", + "# Derive the Toolbox MCP endpoint (used in subsequent steps)\n", + "toolbox_mcp_endpoint = f\"{foundry_project_endpoint}/toolboxes/{toolbox_name}/mcp?api-version=v1\"\n", + "apim_toolbox_mcp_url = f\"{apim_resource_gateway_url}/{toolbox_mcp_path}\"\n", + "utils.print_info(f\"Foundry Toolbox MCP endpoint: {toolbox_mcp_endpoint}\")\n", + "utils.print_info(f\"APIM Toolbox MCP proxy URL: {apim_toolbox_mcp_url}\")" + ] + }, + { + "cell_type": "markdown", + "id": "0fde4874", + "metadata": {}, + "source": [ + "\n", + "### 4️⃣ Deploy the vet and pet insurance MCP servers to Azure Functions (Flex Consumption)\n", + "\n", + "The [vet-clinic](mcp-servers/vet-clinic/function_app.py) and [pet-insurance](mcp-servers/pet-insurance/function_app.py) are Azure Functions apps that expose MCP tools with the `@app.mcp_tool()` decorator.\n", + "\n", + "Because they are standard Azure Functions apps, deployment is a simple zip-deploy for each app - **no container or Docker build required**." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d4cc5191", + "metadata": {}, + "outputs": [], + "source": [ + "import os\n", + "import zipfile\n", + "\n", + "ignore_names = {\".git\", \"__pycache__\", \".venv\", \".python_packages\", \"local.settings.json\"}\n", + "\n", + "\n", + "def create_zip(source_dir, zip_path):\n", + " with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zf:\n", + " for root, dirs, files in os.walk(source_dir):\n", + " dirs[:] = [d for d in dirs if d not in ignore_names and not d.startswith('.')]\n", + " for file in files:\n", + " if file.endswith('.zip'):\n", + " continue\n", + " file_path = os.path.join(root, file)\n", + " arcname = os.path.relpath(file_path, source_dir)\n", + " zf.write(file_path, arcname)\n", + "\n", + "\n", + "vet_zip_path = f\"{vet_clinic_src}/vet-clinic.zip\"\n", + "pet_insurance_zip_path = f\"{pet_insurance_src}/pet-insurance.zip\"\n", + "\n", + "create_zip(vet_clinic_src, vet_zip_path)\n", + "create_zip(pet_insurance_src, pet_insurance_zip_path)\n", + "\n", + "utils.print_ok(f\"Created deployment zips: {vet_zip_path}, {pet_insurance_zip_path}\")\n", + "\n", + "# Deploy both zip packages to their Flex Consumption Function Apps - no container or Docker build required\n", + "utils.run(\n", + " f\"az functionapp deployment source config-zip -g {resource_group_name} -n {vet_function_app_name} --src {vet_zip_path}\",\n", + " f\"Vet Clinic deployed to Function App '{vet_function_app_name}'\",\n", + " f\"Failed to deploy vet-clinic to Function App\")\n", + "\n", + "utils.run(\n", + " f\"az functionapp deployment source config-zip -g {resource_group_name} -n {pet_insurance_function_app_name} --src {pet_insurance_zip_path}\",\n", + " f\"Pet Insurance deployed to Function App '{pet_insurance_function_app_name}'\",\n", + " f\"Failed to deploy pet-insurance to Function App\")" + ] + }, + { + "cell_type": "markdown", + "id": "8ab94cd0", + "metadata": {}, + "source": [ + "\n", + "### 5️⃣ Create the Foundry Toolbox\n", + "\n", + "Use the [azure-ai-projects SDK](https://learn.microsoft.com/azure/ai-studio/how-to/develop/sdk-overview) to create a [Foundry Toolbox](https://learn.microsoft.com/azure/foundry/agents/how-to/tools/toolbox) version with two [MCP tools](https://learn.microsoft.com/azure/foundry/agents/how-to/tools/model-context-protocol) pointing to the vet-clinic and pet-insurance Function Apps.\n", + "\n", + "The Azure Functions MCP extension exposes tools at:\n", + "`{vet_function_app_url}/runtime/webhooks/mcp`\n", + "`{pet_insurance_function_app_url}/runtime/webhooks/mcp`\n", + "\n", + "The Toolbox wraps both endpoints and exposes everything through a single governed MCP endpoint:\n", + "`{foundry_project_endpoint}/toolboxes/{toolbox_name}/mcp?api-version=v1`\n", + "\n", + "APIM is already pre-configured in Bicep to proxy this endpoint, so any call to `{apim_gateway_url}/toolbox/mcp-native` is transparently forwarded to the Toolbox with a managed-identity Entra token." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4ae760b7", + "metadata": {}, + "outputs": [], + "source": [ + "# Azure Functions MCP extension endpoints\n", + "vet_toolbox_mcp_server_url = f\"{vet_function_app_url}/runtime/webhooks/mcp\"\n", + "pet_insurance_toolbox_mcp_server_url = f\"{pet_insurance_function_app_url}/runtime/webhooks/mcp\"\n", + "\n", + "utils.print_info(f\"Vet Toolbox MCP server URL: {vet_toolbox_mcp_server_url}\")\n", + "utils.print_info(f\"Pet Insurance Toolbox MCP server URL: {pet_insurance_toolbox_mcp_server_url}\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "67bf6d60", + "metadata": {}, + "outputs": [], + "source": [ + "from azure.identity import DefaultAzureCredential\n", + "from azure.ai.projects import AIProjectClient\n", + "from azure.ai.projects.models import MCPTool\n", + "\n", + "project = AIProjectClient(endpoint=foundry_project_endpoint, credential=DefaultAzureCredential())\n", + "\n", + "vet_toolbox_mcp_server_url = f\"{vet_function_app_url}/runtime/webhooks/mcp\"\n", + "pet_insurance_toolbox_mcp_server_url = f\"{pet_insurance_function_app_url}/runtime/webhooks/mcp\"\n", + "\n", + "utils.print_info(f\"Vet Toolbox MCP server URL: {vet_toolbox_mcp_server_url}\")\n", + "utils.print_info(f\"Pet Insurance Toolbox MCP server URL: {pet_insurance_toolbox_mcp_server_url}\")\n", + "\n", + "toolbox_version = project.toolboxes.create_version(\n", + " name=toolbox_name,\n", + " description=\"Pet care toolbox -- veterinary and insurance tools via Azure Functions MCP\",\n", + " tools=[\n", + " MCPTool(server_label=\"vet\", server_url=vet_toolbox_mcp_server_url, require_approval=\"never\"),\n", + " MCPTool(server_label=\"insurance\", server_url=pet_insurance_toolbox_mcp_server_url, require_approval=\"never\"),\n", + " ]\n", + ")\n", + "\n", + "utils.print_ok(f\"Created Toolbox '{toolbox_name}' version: {toolbox_version.version}\")\n", + "utils.print_info(f\"Toolbox MCP endpoint: {toolbox_mcp_endpoint}\")" + ] + }, + { + "cell_type": "markdown", + "id": "79743231", + "metadata": {}, + "source": [ + "\n", + "### 🧪 Verify Toolbox tool discovery (direct — Entra token)\n", + "\n", + "Connect directly to the Foundry Toolbox MCP endpoint using your DefaultAzureCredential to confirm the veterinary and insurance tools are available.\n", + "\n", + "Tip: Use the [MCP Inspector](https://modelcontextprotocol.io/docs/tools/inspector) for interactive testing:\n", + "1. Run `npx @modelcontextprotocol/inspector` in a terminal\n", + "2. Set transport to **Streamable HTTP** and paste the Toolbox MCP endpoint" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "98e4cbb3", + "metadata": {}, + "outputs": [], + "source": [ + "# One-time notebook dependency setup for MCP tests\n", + "%pip install mcp nest_asyncio" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "0e50056b", + "metadata": {}, + "outputs": [], + "source": [ + "import asyncio\n", + "import httpx\n", + "import traceback\n", + "from azure.identity import DefaultAzureCredential\n", + "from mcp import ClientSession\n", + "from mcp.client.streamable_http import streamable_http_client\n", + "import nest_asyncio\n", + "nest_asyncio.apply()\n", + "\n", + "async def list_toolbox_tools(endpoint, bearer_token):\n", + " if \"api-version=\" not in endpoint:\n", + " endpoint = f\"{endpoint}{'&' if '?' in endpoint else '?'}api-version=v1\"\n", + "\n", + " headers = {\n", + " \"Authorization\": f\"Bearer {bearer_token}\",\n", + " \"Foundry-Features\": \"Toolboxes=V1Preview\"\n", + " }\n", + " try:\n", + " async with httpx.AsyncClient(headers=headers, timeout=60.0, follow_redirects=True) as mcp_http_client:\n", + " async with streamable_http_client(endpoint, http_client=mcp_http_client) as streams:\n", + " async with ClientSession(streams[0], streams[1]) as session:\n", + " await session.initialize()\n", + " response = await session.list_tools()\n", + " print(f\"✅ Connected to: {endpoint}\")\n", + " print(\"⚙️ Available tools:\")\n", + " for tool in response.tools:\n", + " print(f\" - {tool.name}: {tool.description}\")\n", + " except Exception as e:\n", + " print(f\"❌ Error type: {type(e).__name__}\")\n", + " print(f\"❌ Error: {e}\")\n", + " if hasattr(e, \"exceptions\"):\n", + " for index, sub_error in enumerate(e.exceptions, start=1):\n", + " print(f\" ↳ Sub-exception {index}: {type(sub_error).__name__}: {sub_error}\")\n", + " print(\"\\n--- Traceback ---\")\n", + " print(traceback.format_exc())\n", + "\n", + "# Get Entra token for Foundry (scope: https://ai.azure.com/.default)\n", + "token = DefaultAzureCredential().get_token(\"https://ai.azure.com/.default\").token\n", + "\n", + "# Test directly via the Foundry Toolbox endpoint\n", + "asyncio.run(list_toolbox_tools(toolbox_mcp_endpoint, token))" + ] + }, + { + "cell_type": "markdown", + "id": "31123057", + "metadata": {}, + "source": [ + "\n", + "### 🧪 Verify Toolbox tool discovery via APIM proxy (subscription key)\n", + "\n", + "APIM accepts an APIM subscription key and transparently injects a managed-identity Entra token when forwarding to the Foundry Toolbox. Clients no longer need to manage Foundry credentials.\n", + "\n", + "Tip: Use the [tracing tool](../../tools/tracing.ipynb) to track the policy execution." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "bdc2f4f6", + "metadata": {}, + "outputs": [], + "source": [ + "async def list_toolbox_tools_via_apim(endpoint, subscription_key):\n", + " # APIM subscription key replaces the need for an Entra token\n", + " headers = {\"Ocp-Apim-Subscription-Key\": subscription_key}\n", + " try:\n", + " async with httpx.AsyncClient(headers=headers) as mcp_http_client:\n", + " async with streamable_http_client(endpoint, http_client=mcp_http_client) as streams:\n", + " async with ClientSession(streams[0], streams[1]) as session:\n", + " await session.initialize()\n", + " response = await session.list_tools()\n", + " print(f\"✅ Connected via APIM proxy: {endpoint}\")\n", + " print(\"⚙️ Available tools (via APIM):\")\n", + " for tool in response.tools:\n", + " print(f\" - {tool.name}: {tool.description}\")\n", + " except Exception as e:\n", + " print(f\"❌ Error: {e}\")\n", + "\n", + "# Use APIM native MCP endpoint\n", + "apim_mcp_test_url = f\"{apim_resource_gateway_url}/toolbox/mcp-native\"\n", + "print(f\"Testing APIM MCP endpoint: {apim_mcp_test_url}\")\n", + "\n", + "# Test via the APIM native MCP endpoint (subscription key only — no Entra token needed)\n", + "asyncio.run(list_toolbox_tools_via_apim(apim_mcp_test_url, api_key))" + ] + }, + { + "cell_type": "markdown", + "id": "5aee8513", + "metadata": {}, + "source": [ + "\n", + "### 🧪 Run chat completions with Toolbox tools via APIM (multiple examples)\n", + "\n", + "Both the inference calls (to the model) and the tool calls (to the Foundry Toolbox) are routed through APIM. APIM provides centralized governance over the complete agent-tool interaction loop.\n", + "\n", + "This section runs multiple prompts and prints a clear trace of each tool call (name, arguments, and result snippet)." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d864fb64", + "metadata": {}, + "outputs": [], + "source": [ + "import json, asyncio\n", + "import httpx\n", + "from mcp import ClientSession\n", + "from mcp.client.streamable_http import streamable_http_client\n", + "from openai import AzureOpenAI\n", + "import nest_asyncio\n", + "nest_asyncio.apply()\n", + "\n", + "async def call_tool(session, function_name, function_args):\n", + " try:\n", + " result = await session.call_tool(function_name, function_args)\n", + " return str(result.content)\n", + " except Exception as e:\n", + " return json.dumps({\"error\": str(e)})\n", + "\n", + "async def run_with_toolbox(prompt, scenario_name):\n", + " \"\"\"Run one chat completion with tools from the Foundry Toolbox, all routed through APIM.\"\"\"\n", + " headers = {\"Ocp-Apim-Subscription-Key\": api_key}\n", + " print(\"\\n\" + \"=\" * 90)\n", + " print(f\"🧪 Scenario: {scenario_name}\")\n", + " print(f\"📝 Prompt: {prompt}\")\n", + " print(\"=\" * 90)\n", + "\n", + " try:\n", + " # APIM native MCP server endpoint\n", + " apim_mcp_tools_url = f\"{apim_resource_gateway_url}/toolbox/mcp-native\"\n", + " async with httpx.AsyncClient(headers=headers) as mcp_http_client:\n", + " async with streamable_http_client(apim_mcp_tools_url, http_client=mcp_http_client) as streams:\n", + " async with ClientSession(streams[0], streams[1]) as session:\n", + " await session.initialize()\n", + "\n", + " # Discover tools from the Toolbox\n", + " tools_response = await session.list_tools()\n", + " openai_tools = [{\n", + " \"type\": \"function\",\n", + " \"function\": {\"name\": tool.name, \"parameters\": tool.inputSchema}\n", + " } for tool in tools_response.tools]\n", + " print(f\"✅ Discovered {len(openai_tools)} tool(s) from Toolbox via APIM\")\n", + "\n", + " # Route inference calls through APIM as well\n", + " client = AzureOpenAI(\n", + " azure_endpoint=f\"{apim_resource_gateway_url}/{inference_api_path}\",\n", + " api_key=api_key,\n", + " api_version=inference_api_version\n", + " )\n", + "\n", + " # Step 1: let the model decide which tools to call\n", + " print(\"▶️ Step 1: model selects tools based on the prompt\")\n", + " messages = [{\"role\": \"user\", \"content\": prompt}]\n", + " response = client.chat.completions.create(\n", + " model=models_config[0]['name'],\n", + " messages=messages,\n", + " tools=openai_tools\n", + " )\n", + " response_message = response.choices[0].message\n", + " tool_calls = response_message.tool_calls\n", + "\n", + " if tool_calls:\n", + " print(f\"🔧 Tool calls requested: {len(tool_calls)}\")\n", + " messages.append(response_message)\n", + "\n", + " # Step 2: call each tool via the APIM-proxied Toolbox endpoint\n", + " print(\"▶️ Step 2: calling tools via APIM -> Foundry Toolbox\")\n", + " for i, tool_call in enumerate(tool_calls, start=1):\n", + " fn_name = tool_call.function.name\n", + " fn_args = json.loads(tool_call.function.arguments)\n", + " print(f\" [{i}] Tool: {fn_name}\")\n", + " print(f\" Args: {json.dumps(fn_args, indent=2)}\")\n", + "\n", + " fn_result = await call_tool(session, fn_name, fn_args)\n", + " preview = fn_result[:240] + \"...\" if len(fn_result) > 240 else fn_result\n", + " print(f\" Result Preview: {preview}\")\n", + "\n", + " messages.append({\n", + " \"tool_call_id\": tool_call.id,\n", + " \"role\": \"tool\",\n", + " \"name\": fn_name,\n", + " \"content\": fn_result\n", + " })\n", + "\n", + " # Step 3: final completion with tool results\n", + " print(\"▶️ Step 3: final answer from model\")\n", + " final = client.chat.completions.create(\n", + " model=models_config[0]['name'],\n", + " messages=messages\n", + " )\n", + " print(\"💬 Final Answer:\")\n", + " print(final.choices[0].message.content)\n", + " else:\n", + " print(\"ℹ️ No tool call was required for this prompt.\")\n", + " print(\"💬\", response_message.content)\n", + "\n", + " except Exception as e:\n", + " print(f\"❌ Error: {e}\")\n", + "\n", + "\n", + "example_scenarios = [\n", + " {\n", + " \"name\": \"Clinical Summary + Coverage Check\",\n", + " \"prompt\": \"Buddy is a dog and the owner is Laura Smith. Policy number is PET-1042. Get Buddy's recent clinical summary and check coverage for a periodontal dental cleaning under this policy. Return both the clinical summary and coverage outcome in one response.\"\n", + " },\n", + " {\n", + " \"name\": \"Pre-Authorization Workflow\",\n", + " \"prompt\": \"For pet Luna with policy PET-1042, request pre-authorization for orthopedic surgery estimated at 3200, then tell me next steps.\"\n", + " },\n", + " {\n", + " \"name\": \"Claims Follow-up\",\n", + " \"prompt\": \"Check claim status for CLM-77821 and submit these supporting documents: invoice.pdf, referral-letter.pdf, discharge-summary.pdf.\"\n", + " }\n", + "]\n", + "\n", + "for scenario in example_scenarios:\n", + " asyncio.run(run_with_toolbox(scenario[\"prompt\"], scenario[\"name\"]))" + ] + }, + { + "cell_type": "markdown", + "id": "ded3ce16", + "metadata": {}, + "source": [ + "\n", + "### 📋 Manage Toolbox versions\n", + "\n", + "Toolbox versions are immutable snapshots. Create a new version, test it, then promote it to default — agents always connect to the default version via the APIM proxy, so promotion requires no APIM or agent changes." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "8fcac648", + "metadata": {}, + "outputs": [], + "source": [ + "# List all versions of the Toolbox\n", + "versions = list(project.toolboxes.list_versions(name=toolbox_name))\n", + "utils.print_info(f\"Toolbox '{toolbox_name}' has {len(versions)} version(s):\")\n", + "for v in versions:\n", + " tools_in_version = [t.get('server_label', t.get('type', '?')) for t in (v.tools or [])]\n", + " print(f\" Version {v.version} — tools: {tools_in_version}\")" + ] + }, + { + "cell_type": "markdown", + "id": "bf500ab6", + "metadata": {}, + "source": [ + "\n", + "### 🗑️ Clean up resources\n", + "\n", + "When you're finished with the lab, you should remove all your deployed resources from Azure to avoid extra charges and keep your Azure subscription uncluttered.\n", + "Use the [clean-up-resources notebook](clean-up-resources.ipynb) for that." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.10" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/labs/ai-foundry-toolbox/clean-up-resources.ipynb b/labs/ai-foundry-toolbox/clean-up-resources.ipynb new file mode 100644 index 00000000..faa7e0b7 --- /dev/null +++ b/labs/ai-foundry-toolbox/clean-up-resources.ipynb @@ -0,0 +1,52 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "50028a44", + "metadata": {}, + "source": [ + "### 🗑️ Clean up resources\n", + "\n", + "When you're finished with the lab, you should remove all your deployed resources from Azure to avoid extra charges and keep your Azure subscription uncluttered." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "12ead6d7", + "metadata": {}, + "outputs": [], + "source": [ + "import os, sys\n", + "sys.path.insert(1, '../../shared') # add the shared directory to the Python path\n", + "import utils\n", + "\n", + "deployment_name = os.path.basename(os.path.dirname(globals()['__vsc_ipynb_file__']))\n", + "resource_group = f\"lab-{deployment_name}\"\n", + "\n", + "utils.cleanup_resources(deployment_name, resource_group_name=resource_group)" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.10" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/labs/ai-foundry-toolbox/main.bicep b/labs/ai-foundry-toolbox/main.bicep new file mode 100644 index 00000000..609d8f75 --- /dev/null +++ b/labs/ai-foundry-toolbox/main.bicep @@ -0,0 +1,365 @@ +// ------------------ +// PARAMETERS +// ------------------ + +param aiServicesConfig array = [] +param modelsConfig array = [] +param apimSku string +param apimSubscriptionsConfig array = [] +param inferenceAPIType string = 'AzureOpenAI' +param inferenceAPIPath string = 'inference' +param foundryProjectName string = 'default' +param toolboxName string = 'pet-care-toolbox' + +// ------------------ +// VARIABLES +// ------------------ + +var resourceSuffix = uniqueString(subscription().id, resourceGroup().id) +var apiManagementName = 'apim-${resourceSuffix}' + +// ------------------ +// RESOURCES +// ------------------ + +// 1. Log Analytics Workspace +module lawModule '../../modules/operational-insights/v1/workspaces.bicep' = { + name: 'lawModule' +} + +// 2. Application Insights +module appInsightsModule '../../modules/monitor/v1/appinsights.bicep' = { + name: 'appInsightsModule' + params: { + lawId: lawModule.outputs.id + customMetricsOptedInType: 'WithDimensions' + } +} + +// 3. API Management +module apimModule '../../modules/apim/v3/apim.bicep' = { + name: 'apimModule' + params: { + apimSku: apimSku + apimSubscriptionsConfig: apimSubscriptionsConfig + lawId: lawModule.outputs.id + appInsightsId: appInsightsModule.outputs.id + appInsightsInstrumentationKey: appInsightsModule.outputs.instrumentationKey + } +} + +// 4. AI Foundry +module foundryModule '../../modules/cognitive-services/v3/foundry.bicep' = { + name: 'foundryModule' + params: { + aiServicesConfig: aiServicesConfig + modelsConfig: modelsConfig + apimPrincipalId: apimModule.outputs.principalId + foundryProjectName: foundryProjectName + appInsightsId: appInsightsModule.outputs.id + appInsightsInstrumentationKey: appInsightsModule.outputs.instrumentationKey + } +} + +// 5. APIM Inference API +module inferenceAPIModule '../../modules/apim/v3/inference-api.bicep' = { + name: 'inferenceAPIModule' + params: { + policyXml: loadTextContent('policy.xml') + apimLoggerId: apimModule.outputs.loggerId + appInsightsId: appInsightsModule.outputs.id + appInsightsInstrumentationKey: appInsightsModule.outputs.instrumentationKey + aiServicesConfig: foundryModule.outputs.extendedAIServicesConfig + inferenceAPIType: inferenceAPIType + inferenceAPIPath: inferenceAPIPath + } +} + +// 6. Storage Account for Flex Consumption Function App deployment packages +resource storageAccount 'Microsoft.Storage/storageAccounts@2023-05-01' = { + name: 'st${resourceSuffix}' + location: resourceGroup().location + sku: { name: 'Standard_LRS' } + kind: 'StorageV2' + properties: { + allowBlobPublicAccess: false + minimumTlsVersion: 'TLS1_2' + supportsHttpsTrafficOnly: true + } +} + +resource blobService 'Microsoft.Storage/storageAccounts/blobServices@2023-05-01' = { + parent: storageAccount + name: 'default' +} + +resource deploymentContainer 'Microsoft.Storage/storageAccounts/blobServices/containers@2023-05-01' = { + parent: blobService + name: 'deployments' + properties: { publicAccess: 'None' } +} + +resource petInsuranceDeploymentContainer 'Microsoft.Storage/storageAccounts/blobServices/containers@2023-05-01' = { + parent: blobService + name: 'insurance-deployments' + properties: { publicAccess: 'None' } +} + +// 7. Flex Consumption App Service Plan +resource flexConsumptionPlan 'Microsoft.Web/serverfarms@2024-04-01' = { + name: 'flex-plan-${resourceSuffix}' + location: resourceGroup().location + kind: 'linux' + sku: { + name: 'FC1' + tier: 'FlexConsumption' + } + properties: { + reserved: true + } +} + +// 8. Vet Toolbox Function App (Flex Consumption, Python 3.12) +resource vetToolboxFunctionApp 'Microsoft.Web/sites@2023-12-01' = { + name: 'func-${resourceSuffix}' + location: resourceGroup().location + kind: 'functionapp,linux' + identity: { type: 'SystemAssigned' } + properties: { + serverFarmId: flexConsumptionPlan.id + httpsOnly: true + functionAppConfig: { + deployment: { + storage: { + type: 'blobContainer' + value: '${storageAccount.properties.primaryEndpoints.blob}deployments' + authentication: { type: 'SystemAssignedIdentity' } + } + } + scaleAndConcurrency: { + maximumInstanceCount: 40 + instanceMemoryMB: 2048 + } + runtime: { name: 'python', version: '3.12' } + } + siteConfig: { + appSettings: [ + { name: 'AzureWebJobsStorage__accountName', value: storageAccount.name } + { name: 'AzureWebJobsStorage__credential', value: 'managedidentity' } + { name: 'APPLICATIONINSIGHTS_CONNECTION_STRING', value: appInsightsModule.outputs.connectionString } + ] + } + } + dependsOn: [deploymentContainer] +} + +// 9. Pet Insurance Toolbox Function App (Flex Consumption, Python 3.12) +resource petInsuranceToolboxFunctionApp 'Microsoft.Web/sites@2023-12-01' = { + name: 'func-ins-${resourceSuffix}' + location: resourceGroup().location + kind: 'functionapp,linux' + identity: { type: 'SystemAssigned' } + properties: { + serverFarmId: flexConsumptionPlan.id + httpsOnly: true + functionAppConfig: { + deployment: { + storage: { + type: 'blobContainer' + value: '${storageAccount.properties.primaryEndpoints.blob}insurance-deployments' + authentication: { type: 'SystemAssignedIdentity' } + } + } + scaleAndConcurrency: { + maximumInstanceCount: 40 + instanceMemoryMB: 2048 + } + runtime: { name: 'python', version: '3.12' } + } + siteConfig: { + appSettings: [ + { name: 'AzureWebJobsStorage__accountName', value: storageAccount.name } + { name: 'AzureWebJobsStorage__credential', value: 'managedidentity' } + { name: 'APPLICATIONINSIGHTS_CONNECTION_STRING', value: appInsightsModule.outputs.connectionString } + ] + } + } + dependsOn: [petInsuranceDeploymentContainer] +} + +// 10. Grant Function App managed identity Storage Blob Data Owner on the storage account +resource storageBlobDataOwnerRole 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + scope: storageAccount + name: guid(subscription().id, resourceGroup().id, vetToolboxFunctionApp.id, 'StorageBlobDataOwner') + properties: { + roleDefinitionId: resourceId('Microsoft.Authorization/roleDefinitions', 'b7e6dc6d-f1e8-4753-8033-0f276bb0955b') // Storage Blob Data Owner + principalId: vetToolboxFunctionApp.identity.principalId + principalType: 'ServicePrincipal' + } +} + +resource petInsuranceStorageBlobDataOwnerRole 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + scope: storageAccount + name: guid(subscription().id, resourceGroup().id, petInsuranceToolboxFunctionApp.id, 'StorageBlobDataOwner') + properties: { + roleDefinitionId: resourceId('Microsoft.Authorization/roleDefinitions', 'b7e6dc6d-f1e8-4753-8033-0f276bb0955b') // Storage Blob Data Owner + principalId: petInsuranceToolboxFunctionApp.identity.principalId + principalType: 'ServicePrincipal' + } +} + +// 11. APIM: Toolbox MCP proxy — backend pointing to Foundry Toolbox MCP endpoint +var foundryAccountName = foundryModule.outputs.extendedAIServicesConfig[0].cognitiveServiceName +var foundryProjectPath = '${foundryProjectName}-${aiServicesConfig[0].name}' +var toolboxMcpBackendUrl = 'https://${foundryAccountName}.services.ai.azure.com/api/projects/${foundryProjectPath}/toolboxes/${toolboxName}/mcp' +var toolboxNativeMcpPath = 'toolbox/mcp-native' +var appInsightsLoggerId = resourceId(resourceGroup().name, 'Microsoft.ApiManagement/service/loggers', apiManagementName, 'appinsights-logger') +var apimAppInsightsLogSettings = { + headers: [ + 'Content-type' + 'User-agent' + 'x-ms-region' + 'x-ratelimit-remaining-tokens' + 'x-ratelimit-remaining-requests' + ] + body: { + bytes: 8192 + } +} + +// Reference existing APIM service (created by apimModule) +resource apimService 'Microsoft.ApiManagement/service@2024-06-01-preview' existing = { + name: apiManagementName + dependsOn: [apimModule] +} + +resource toolboxMcpBackend 'Microsoft.ApiManagement/service/backends@2024-06-01-preview' = { + parent: apimService + name: 'toolbox-mcp-backend' + properties: { + protocol: 'http' + url: toolboxMcpBackendUrl + tls: { + validateCertificateChain: true + validateCertificateName: true + } + } +} + +resource toolboxMcpApi 'Microsoft.ApiManagement/service/apis@2024-06-01-preview' = { + parent: apimService + name: 'foundry-toolbox-mcp' + properties: { + displayName: 'Foundry Toolbox MCP' + description: 'APIM proxy for the Foundry Toolbox MCP endpoint — subscription key auth replaces Entra token requirement' + type: 'http' + subscriptionRequired: true + serviceUrl: toolboxMcpBackendUrl + path: 'toolbox/mcp' + protocols: ['https'] + } +} + +resource toolboxMcpApiPolicy 'Microsoft.ApiManagement/service/apis/policies@2021-12-01-preview' = { + parent: toolboxMcpApi + name: 'policy' + properties: { + value: loadTextContent('toolbox-policy.xml') + format: 'rawxml' + } +} + +resource toolboxMcpApiAppInsightsDiagnostics 'Microsoft.ApiManagement/service/apis/diagnostics@2022-08-01' = { + parent: toolboxMcpApi + name: 'applicationinsights' + properties: { + alwaysLog: 'allErrors' + httpCorrelationProtocol: 'W3C' + logClientIp: true + loggerId: appInsightsLoggerId + metrics: true + verbosity: 'verbose' + sampling: { + samplingType: 'fixed' + percentage: 100 + } + frontend: { + request: apimAppInsightsLogSettings + response: apimAppInsightsLogSettings + } + backend: { + request: apimAppInsightsLogSettings + response: apimAppInsightsLogSettings + } + } +} + +// 12. APIM native MCP Server (appears in APIM "MCP Servers" blade) +resource toolboxNativeMcpServer 'Microsoft.ApiManagement/service/apis@2025-09-01-preview' = { + parent: apimService + name: 'foundry-toolbox-mcp-native' + properties: { + displayName: 'Foundry Toolbox MCP (Native)' + description: 'Native APIM MCP server wrapping the Foundry Toolbox endpoint' + type: 'mcp' + subscriptionRequired: true + backendId: toolboxMcpBackend.name + path: toolboxNativeMcpPath + protocols: ['https'] + mcpProperties: { + endpoints: { + message: { + uriTemplate: '/mcp' + } + } + } + } +} + +resource toolboxNativeMcpServerPolicy 'Microsoft.ApiManagement/service/apis/policies@2021-12-01-preview' = { + parent: toolboxNativeMcpServer + name: 'policy' + properties: { + value: loadTextContent('toolbox-policy.xml') + format: 'rawxml' + } +} + +resource toolboxNativeMcpApiAppInsightsDiagnostics 'Microsoft.ApiManagement/service/apis/diagnostics@2022-08-01' = { + parent: toolboxNativeMcpServer + name: 'applicationinsights' + properties: { + alwaysLog: 'allErrors' + httpCorrelationProtocol: 'W3C' + logClientIp: true + loggerId: appInsightsLoggerId + metrics: true + verbosity: 'verbose' + sampling: { + samplingType: 'fixed' + percentage: 100 + } + frontend: { + request: apimAppInsightsLogSettings + response: apimAppInsightsLogSettings + } + backend: { + request: apimAppInsightsLogSettings + response: apimAppInsightsLogSettings + } + } +} + +// ------------------ +// OUTPUTS +// ------------------ + +output logAnalyticsWorkspaceId string = lawModule.outputs.customerId +output apimServiceId string = apimModule.outputs.id +output apimResourceGatewayURL string = apimModule.outputs.gatewayUrl +output apimSubscriptions array = apimModule.outputs.apimSubscriptions +output foundryProjectEndpoint string = 'https://${foundryAccountName}.services.ai.azure.com/api/projects/${foundryProjectPath}' +output functionAppName string = vetToolboxFunctionApp.name +output functionAppUrl string = 'https://${vetToolboxFunctionApp.properties.defaultHostName}' +output petInsuranceFunctionAppName string = petInsuranceToolboxFunctionApp.name +output petInsuranceFunctionAppUrl string = 'https://${petInsuranceToolboxFunctionApp.properties.defaultHostName}' diff --git a/labs/ai-foundry-toolbox/mcp-servers/pet-insurance/.funcignore b/labs/ai-foundry-toolbox/mcp-servers/pet-insurance/.funcignore new file mode 100644 index 00000000..390fa586 --- /dev/null +++ b/labs/ai-foundry-toolbox/mcp-servers/pet-insurance/.funcignore @@ -0,0 +1,9 @@ +.venv/ +__pycache__/ +*.pyc +.python_packages/ +local.settings.json +bin/ +obj/ +.vscode/ +pet-insurance.zip diff --git a/labs/ai-foundry-toolbox/mcp-servers/pet-insurance/function_app.py b/labs/ai-foundry-toolbox/mcp-servers/pet-insurance/function_app.py new file mode 100644 index 00000000..c36ac2d2 --- /dev/null +++ b/labs/ai-foundry-toolbox/mcp-servers/pet-insurance/function_app.py @@ -0,0 +1,265 @@ +""" +Pet Insurance MCP Server (Azure Functions, Python) + +Exposes four MCP tools for a pet insurance workflow: + 1. check_coverage - returns a simulated coverage summary for a service + 2. request_pre_auth - requests pre-authorization for an estimated treatment cost + 3. check_claim_status - retrieves a simulated claim status update + 4. submit_claim_documents - submits claim supporting documents and returns receipt details + +This is a scaffold: all data is generated randomly in-memory to simulate an +insurance platform backend. Replace the helper functions with real claims and +policy-system integrations when wiring to production services. +""" + +import logging +import random +import uuid +from datetime import datetime, timedelta, timezone +from enum import Enum + +import azure.functions as func +from pydantic import BaseModel, Field, ValidationError, field_validator + +app = func.FunctionApp(http_auth_level=func.AuthLevel.ANONYMOUS) + + +_POLICY_STATUSES = ["active", "active with exclusions", "lapsed", "pending renewal"] +_SERVICE_TYPES = [ + "annual wellness visit", + "urgent care visit", + "diagnostic imaging", + "dental procedure", + "orthopedic surgery", + "prescription medication", + "specialist referral", + "emergency admission", +] +_COVERAGE_TYPES = [ + "accident only", + "accident and illness", + "wellness rider", + "dental rider", + "hereditary condition exclusion", +] +_CLAIM_STATUSES = [ + "submitted", + "in review", + "documents required", + "approved", + "denied", + "paid", +] + + +class CoverageRequest(BaseModel): + policy_number: str + pet_name: str + service_type: str + + +class PreAuthRequest(BaseModel): + policy_number: str + pet_name: str + service_type: str + estimated_cost: float = Field(gt=0) + + +class ClaimStatusRequest(BaseModel): + claim_number: str + + +class ClaimDocumentSubmissionRequest(BaseModel): + claim_number: str + documents: str + source: str | None = None + + +def _random_past_datetime(max_days_back: int = 365) -> datetime: + days_back = random.randint(1, max_days_back) + return datetime.now(timezone.utc) - timedelta(days=days_back) + + +def _random_future_datetime(max_days_forward: int = 30) -> datetime: + days_forward = random.randint(1, max_days_forward) + return datetime.now(timezone.utc) + timedelta(days=days_forward) + + +def _split_documents(documents: str) -> list[str]: + return [document.strip() for document in documents.split(",") if document.strip()] + + +# --------------------------------------------------------------------------- +# Tool 1: check_coverage +# --------------------------------------------------------------------------- + +@app.mcp_tool() +@app.mcp_tool_property(arg_name="policy_number", description="The pet insurance policy number.", is_required=True) +@app.mcp_tool_property(arg_name="pet_name", description="The name of the insured pet.", is_required=True) +@app.mcp_tool_property(arg_name="service_type", description="The service to check coverage for, e.g. 'dental procedure' or 'urgent care visit'.", is_required=True) +def check_coverage(policy_number: str, pet_name: str, service_type: str) -> dict: + """Return a simulated coverage summary for a policy and requested service.""" + logging.info(f"check_coverage called for {pet_name}, policy={policy_number}, service_type={service_type}") + + policy_status = random.choice(_POLICY_STATUSES) + coverage_type = random.choice(_COVERAGE_TYPES) + covered = policy_status.startswith("active") and random.random() >= 0.2 + coverage_percentage = random.choice([50, 70, 80, 90]) if covered else 0 + deductible_remaining = round(random.uniform(0, 750), 2) if covered else None + copay_percentage = 100 - coverage_percentage if covered else None + annual_limit_remaining = round(random.uniform(200, 5000), 2) if covered else 0.0 + + return { + "policy_number": policy_number, + "pet_name": pet_name, + "service_type": service_type, + "policy_status": policy_status, + "coverage_type": coverage_type, + "covered": covered, + "coverage_percentage": coverage_percentage, + "deductible_remaining": deductible_remaining, + "copay_percentage": copay_percentage, + "annual_limit_remaining": annual_limit_remaining, + "message": ( + f"{service_type.title()} is covered under policy {policy_number}." + if covered + else f"{service_type.title()} is not currently covered under policy {policy_number}." + ), + } + + +# --------------------------------------------------------------------------- +# Tool 2: request_pre_auth +# --------------------------------------------------------------------------- + +@app.mcp_tool() +@app.mcp_tool_property(arg_name="policy_number", description="The pet insurance policy number.", is_required=True) +@app.mcp_tool_property(arg_name="pet_name", description="The name of the insured pet.", is_required=True) +@app.mcp_tool_property(arg_name="service_type", description="The treatment or service requiring pre-authorization.", is_required=True) +@app.mcp_tool_property(arg_name="estimated_cost", description="Estimated treatment cost in local currency.", is_required=True) +def request_pre_auth(policy_number: str, pet_name: str, service_type: str, estimated_cost: float) -> dict: + """Simulate a pre-authorization request and return a decision.""" + logging.info( + f"request_pre_auth called for {pet_name}, policy={policy_number}, service_type={service_type}, estimated_cost={estimated_cost}" + ) + + try: + request = PreAuthRequest( + policy_number=policy_number, + pet_name=pet_name, + service_type=service_type, + estimated_cost=estimated_cost, + ) + except ValidationError as exc: + first_error = exc.errors()[0] if exc.errors() else None + return { + "policy_number": policy_number, + "pet_name": pet_name, + "service_type": service_type, + "estimated_cost": estimated_cost, + "status": "not_requested", + "preauth_id": None, + "approved_amount": None, + "expires_on": None, + "message": f"Invalid pre-auth request: {first_error['msg'] if first_error else 'check the provided arguments' }", + } + + approved = random.random() >= 0.25 and estimated_cost <= 7500 + response = { + "policy_number": request.policy_number, + "pet_name": request.pet_name, + "service_type": request.service_type, + "estimated_cost": request.estimated_cost, + "status": "approved" if approved else "pending review", + "preauth_id": str(uuid.uuid4()), + "approved_amount": round(request.estimated_cost * random.uniform(0.6, 1.0), 2) if approved else None, + "expires_on": (_random_future_datetime(max_days_forward=45)).strftime("%Y-%m-%d") if approved else None, + } + response["message"] = ( + f"Pre-authorization approved for {request.service_type} at {response['approved_amount']:.2f}." + if approved + else f"Pre-authorization for {request.service_type} has been queued for manual review." + ) + return response + + +# --------------------------------------------------------------------------- +# Tool 3: check_claim_status +# --------------------------------------------------------------------------- + +@app.mcp_tool() +@app.mcp_tool_property(arg_name="claim_number", description="The pet insurance claim number.", is_required=True) +def check_claim_status(claim_number: str) -> dict: + """Return a simulated status for an existing claim.""" + logging.info(f"check_claim_status called for claim={claim_number}") + + status = random.choice(_CLAIM_STATUSES) + submitted_at = _random_past_datetime(max_days_back=90) + last_updated = submitted_at + timedelta(days=random.randint(0, 14)) + amount_claimed = round(random.uniform(75, 4500), 2) + amount_paid = round(amount_claimed * random.uniform(0.0, 1.0), 2) if status in {"approved", "paid"} else 0.0 + + return { + "claim_number": claim_number, + "status": status, + "submitted_at": submitted_at.strftime("%Y-%m-%d"), + "last_updated": last_updated.strftime("%Y-%m-%d"), + "amount_claimed": amount_claimed, + "amount_paid": amount_paid, + "documents_received": random.choice([True, False]), + "next_step": ( + "Awaiting adjuster review" + if status in {"submitted", "in review"} + else "Additional documents required" + if status == "documents required" + else "No further action needed" + ), + } + + +# --------------------------------------------------------------------------- +# Tool 4: submit_claim_documents +# --------------------------------------------------------------------------- + +@app.mcp_tool() +@app.mcp_tool_property(arg_name="claim_number", description="The pet insurance claim number.", is_required=True) +@app.mcp_tool_property(arg_name="documents", description="Comma-separated document names, for example 'invoice.pdf, receipt.jpg, referral-letter.pdf'.", is_required=True) +@app.mcp_tool_property(arg_name="source", description="Optional submission source, such as a clinic or portal name.", is_required=False) +def submit_claim_documents(claim_number: str, documents: str, source: str | None = None) -> dict: + """Simulate uploading supporting claim documents and return a submission receipt.""" + logging.info(f"submit_claim_documents called for claim={claim_number}, source={source}") + + try: + request = ClaimDocumentSubmissionRequest( + claim_number=claim_number, + documents=documents, + source=source, + ) + except ValidationError as exc: + first_error = exc.errors()[0] if exc.errors() else None + return { + "claim_number": claim_number, + "submission_id": None, + "status": "not_submitted", + "documents": [], + "document_count": 0, + "source": source, + "received_at": None, + "message": f"Invalid document submission: {first_error['msg'] if first_error else 'check the provided arguments'}", + } + + submitted_documents = _split_documents(request.documents) + received_at = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + return { + "claim_number": request.claim_number, + "submission_id": str(uuid.uuid4()), + "status": "received", + "documents": submitted_documents, + "document_count": len(submitted_documents), + "source": request.source or "manual upload", + "received_at": received_at, + "expected_review_window_days": random.randint(2, 7), + "message": ( + f"Received {len(submitted_documents)} document(s) for claim {request.claim_number}." + ), + } diff --git a/labs/ai-foundry-toolbox/mcp-servers/pet-insurance/host.json b/labs/ai-foundry-toolbox/mcp-servers/pet-insurance/host.json new file mode 100644 index 00000000..d3002169 --- /dev/null +++ b/labs/ai-foundry-toolbox/mcp-servers/pet-insurance/host.json @@ -0,0 +1,23 @@ +{ + "version": "2.0", + "extensions": { + "mcp": { + "system": { + "webhookAuthorizationLevel": "Anonymous" + } + } + }, + "logging": { + "applicationInsights": { + "samplingSettings": { + "isEnabled": true, + "excludedTypes": "Request" + }, + "enableLiveMetricsFilters": true + } + }, + "extensionBundle": { + "id": "Microsoft.Azure.Functions.ExtensionBundle.Preview", + "version": "[4.*, 5.0.0)" + } +} diff --git a/labs/ai-foundry-toolbox/mcp-servers/pet-insurance/requirements.txt b/labs/ai-foundry-toolbox/mcp-servers/pet-insurance/requirements.txt new file mode 100644 index 00000000..1df1b020 --- /dev/null +++ b/labs/ai-foundry-toolbox/mcp-servers/pet-insurance/requirements.txt @@ -0,0 +1,6 @@ +# Do not include azure-functions-worker in this file +# The Python Worker is managed by the Azure Functions platform +# Manually managing azure-functions-worker may cause unexpected issues + +azure-functions +pydantic>=2.0,<3.0 diff --git a/labs/ai-foundry-toolbox/mcp-servers/vet-clinic/.funcignore b/labs/ai-foundry-toolbox/mcp-servers/vet-clinic/.funcignore new file mode 100644 index 00000000..83ba3b5c --- /dev/null +++ b/labs/ai-foundry-toolbox/mcp-servers/vet-clinic/.funcignore @@ -0,0 +1,7 @@ +.git* +.vscode +__pycache__ +.venv +local.settings.json +test +.pytest_cache diff --git a/labs/ai-foundry-toolbox/mcp-servers/vet-clinic/.gitignore b/labs/ai-foundry-toolbox/mcp-servers/vet-clinic/.gitignore new file mode 100644 index 00000000..c860b8f9 --- /dev/null +++ b/labs/ai-foundry-toolbox/mcp-servers/vet-clinic/.gitignore @@ -0,0 +1,10 @@ +.venv/ +__pycache__/ +*.pyc +.python_packages/ +local.settings.json +bin/ +obj/ +.vscode/ +vet-clinic.zip +vet-toolbox.zip \ No newline at end of file diff --git a/labs/ai-foundry-toolbox/mcp-servers/vet-clinic/function_app.py b/labs/ai-foundry-toolbox/mcp-servers/vet-clinic/function_app.py new file mode 100644 index 00000000..f6f0b730 --- /dev/null +++ b/labs/ai-foundry-toolbox/mcp-servers/vet-clinic/function_app.py @@ -0,0 +1,404 @@ +""" +Veterinary Practice MCP Server (Azure Functions, Python) + +Exposes three MCP tools for a veterinary practice: + 1. get_patient_summary - returns a randomised, consolidated clinical summary for a + patient: recent visits, vaccination/microchip status, and + any upcoming appointment + 2. schedule_appointment - books a new (randomised/simulated) appointment for a + patient, optionally on a preferred date, and returns a + confirmation + 3. order_stock - checks current stock of a veterinary medicine at a clinic + location and optionally places an order to top it up + +Tool design notes: + Tools are grouped around user intent rather than mirroring individual + backend endpoints: + - `get_patient_summary` merges what would otherwise be several separate + "get history" / "get vaccination status" / "get next appointment" calls + into the single question a client or vet actually asks: "bring me up + to speed on this patient". Returning everything relevant in one call + avoids forcing an agent to chain multiple round-trips for what is + conceptually one task. + - `schedule_appointment` is an action tool (it creates a booking) rather + than a read-only lookup, giving the agent an actual next step instead + of just surfacing information. + - `order_stock` intentionally combines "check stock" and "place an + order" into a single call, since an agent/user naturally thinks of + "manage stock for this medicine" as one task rather than juggling + separate check/order primitives. + Every response returns a stable, fully keyed schema (e.g. the `order` + object on `order_stock` is always present with the same fields, using a + `status` of "not_requested" vs "placed" instead of sometimes returning + null) so callers can rely on consistent output shapes. + +This is a scaffold: all data is generated randomly in-memory to simulate a +backend system (e.g. a practice management system / pharmacy inventory API). +Replace the `_random_*` helper functions with real data-source calls when +integrating with production systems. +""" + +import logging +import random +import uuid +from datetime import datetime, timedelta, timezone +from enum import Enum + +import azure.functions as func +from pydantic import BaseModel, Field, ValidationError, field_validator + +app = func.FunctionApp(http_auth_level=func.AuthLevel.ANONYMOUS) + + +# --------------------------------------------------------------------------- +# Static reference data used to randomise realistic-looking responses +# --------------------------------------------------------------------------- + +_VET_NAMES = [ + "Dr. Amelia Clarke", + "Dr. Rajesh Patel", + "Dr. Sofia Rossi", + "Dr. Liam O'Connor", + "Dr. Emily Nguyen", +] + +_VISIT_REASONS = [ + "Annual wellness check", + "Vaccination booster", + "Dental cleaning", + "Skin allergy follow-up", + "Post-surgery check-up", + "Limping / lameness assessment", + "Ear infection treatment", + "Weight management consultation", + "Routine blood panel", + "Microchip registration", +] + +_DIAGNOSES = [ + "Healthy, no concerns found", + "Mild ear infection, prescribed antibiotics", + "Seasonal skin allergy", + "Early-stage dental tartar build-up", + "Slight weight gain, dietary advice given", + "Fully recovered from previous surgery", + "Mild arthritis in hind legs", + "Up to date on all vaccinations", +] + +class MedicineName(str, Enum): + AMOXICILLIN = "Amoxicillin" + MELOXICAM = "Meloxicam" + APOQUEL = "Apoquel" + RIMADYL = "Rimadyl" + FRONTLINE_PLUS = "Frontline Plus" + HEARTGARD = "Heartgard" + METACAM = "Metacam" + CERENIA = "Cerenia" + BRAVECTO = "Bravecto" + VETMEDIN = "Vetmedin" + + +_MEDICATIONS = { + MedicineName.AMOXICILLIN: "Antibiotic", + MedicineName.MELOXICAM: "Anti-inflammatory / pain relief", + MedicineName.APOQUEL: "Allergy / anti-itch", + MedicineName.RIMADYL: "Anti-inflammatory / pain relief", + MedicineName.FRONTLINE_PLUS: "Flea & tick prevention", + MedicineName.HEARTGARD: "Heartworm prevention", + MedicineName.METACAM: "Anti-inflammatory / pain relief", + MedicineName.CERENIA: "Anti-nausea", + MedicineName.BRAVECTO: "Flea & tick prevention", + MedicineName.VETMEDIN: "Heart medication", +} + +_MEDICINE_LOOKUP = {medicine.value.lower(): medicine for medicine in MedicineName} + + +class OrderStockRequest(BaseModel): + medicine_name: MedicineName + location: str | None = None + quantity_to_order: int = Field(default=0, ge=0) + + @field_validator("medicine_name", mode="before") + @classmethod + def normalize_medicine_name(cls, value): + if isinstance(value, MedicineName): + return value + if isinstance(value, str): + parsed = _MEDICINE_LOOKUP.get(value.strip().lower()) + if parsed is not None: + return parsed + raise ValueError("Unknown medicine_name") + +_CLINIC_LOCATIONS = [ + "Riverside Veterinary Clinic - Main St", + "Riverside Veterinary Clinic - North Branch", + "Riverside Veterinary Clinic - Warehouse", +] + +_VACCINES = [ + "Rabies", + "DHPP (Distemper/Parvo)", + "Bordetella (Kennel Cough)", + "FVRCP (Feline Distemper)", + "Leptospirosis", +] + + +def _random_vaccination_status() -> list: + statuses = [] + for vaccine in random.sample(_VACCINES, k=random.randint(2, len(_VACCINES))): + last_given = _random_past_datetime(max_days_back=400) + valid_years = random.choice([1, 3]) + due_date = last_given + timedelta(days=365 * valid_years) + statuses.append( + { + "vaccine": vaccine, + "last_given": last_given.strftime("%Y-%m-%d"), + "due_date": due_date.strftime("%Y-%m-%d"), + "up_to_date": due_date > datetime.now(timezone.utc), + } + ) + return statuses + + +def _random_past_datetime(max_days_back: int = 730) -> datetime: + days_back = random.randint(1, max_days_back) + return datetime.now(timezone.utc) - timedelta(days=days_back) + + +def _random_future_datetime(max_days_forward: int = 180) -> datetime: + days_forward = random.randint(1, max_days_forward) + hour = random.choice([9, 10, 11, 13, 14, 15, 16]) + dt = datetime.now(timezone.utc) + timedelta(days=days_forward) + return dt.replace(hour=hour, minute=random.choice([0, 15, 30, 45]), second=0, microsecond=0) + + +# --------------------------------------------------------------------------- +# Tool 1: get_patient_summary +# --------------------------------------------------------------------------- + +@app.mcp_tool() +@app.mcp_tool_property(arg_name="patient_name", description="The name of the animal patient.", is_required=True) +@app.mcp_tool_property(arg_name="client_name", description="The name of the client (the patient's owner).", is_required=True) +@app.mcp_tool_property(arg_name="species", description="The species of the patient, e.g. dog, cat, rabbit.", is_required=True) +def get_patient_summary(patient_name: str, client_name: str, species: str) -> dict: + """Retrieve a (randomised) consolidated clinical summary for a patient: recent + visit history, vaccination/microchip status, and any upcoming appointment.""" + logging.info(f"get_patient_summary called for {patient_name} ({species}), client {client_name}") + + num_visits = random.randint(1, 5) + visits = [] + for _ in range(num_visits): + visit_date = _random_past_datetime() + visits.append( + { + "visit_id": str(uuid.uuid4()), + "date": visit_date.strftime("%Y-%m-%d"), + "vet": random.choice(_VET_NAMES), + "reason": random.choice(_VISIT_REASONS), + "diagnosis": random.choice(_DIAGNOSES), + "weight_kg": round(random.uniform(2.0, 45.0), 1), + "temperature_c": round(random.uniform(37.5, 39.5), 1), + } + ) + visits.sort(key=lambda v: v["date"], reverse=True) + + vaccinations = _random_vaccination_status() + + upcoming_appointment = None + if random.random() >= 0.1: + appointment_dt = _random_future_datetime() + upcoming_appointment = { + "appointment_id": str(uuid.uuid4()), + "date": appointment_dt.strftime("%Y-%m-%d"), + "time": appointment_dt.strftime("%H:%M"), + "vet": random.choice(_VET_NAMES), + "reason": random.choice(_VISIT_REASONS), + "location": random.choice(_CLINIC_LOCATIONS), + } + + return { + "patient_name": patient_name, + "client_name": client_name, + "species": species, + "microchip_status": random.choice(["registered", "not registered", "registration pending"]), + "visit_count": num_visits, + "recent_visits": visits, + "vaccinations": vaccinations, + "has_upcoming_appointment": upcoming_appointment is not None, + "upcoming_appointment": upcoming_appointment, + } + + +# --------------------------------------------------------------------------- +# Tool 2: schedule_appointment +# --------------------------------------------------------------------------- + +@app.mcp_tool() +@app.mcp_tool_property(arg_name="patient_name", description="The name of the animal patient.", is_required=True) +@app.mcp_tool_property(arg_name="client_name", description="The name of the client (the patient's owner).", is_required=True) +@app.mcp_tool_property(arg_name="species", description="The species of the patient, e.g. dog, cat, rabbit.", is_required=True) +@app.mcp_tool_property(arg_name="reason", description="The reason for the appointment, e.g. 'Annual wellness check' or 'Limping / lameness assessment'.", is_required=True) +@app.mcp_tool_property(arg_name="preferred_date", description="Preferred appointment date in YYYY-MM-DD format. If omitted, or unavailable, the next available slot is offered instead.", is_required=False) +@app.mcp_tool_property(arg_name="location", description="Preferred clinic location, e.g. 'Riverside Veterinary Clinic - Main St'. Defaults to the nearest/any available clinic if omitted.", is_required=False) +def schedule_appointment( + patient_name: str, + client_name: str, + species: str, + reason: str, + preferred_date: str = None, + location: str = None, +) -> dict: + """Book a (simulated) veterinary appointment for a patient and return a confirmation.""" + logging.info( + f"schedule_appointment called for {patient_name} ({species}), client {client_name}, " + f"reason={reason}, preferred_date={preferred_date}, location={location}" + ) + + resolved_location = location or random.choice(_CLINIC_LOCATIONS) + + requested_date = None + if preferred_date: + try: + requested_date = datetime.strptime(preferred_date, "%Y-%m-%d").replace(tzinfo=timezone.utc) + except ValueError: + requested_date = None + + # Small chance the preferred date is unavailable and an alternative slot is offered. + slot_available = requested_date is not None and random.random() >= 0.3 + + if slot_available: + confirmed_dt = requested_date.replace( + hour=random.choice([9, 10, 11, 13, 14, 15, 16]), + minute=random.choice([0, 15, 30, 45]), + ) + rescheduled = False + else: + confirmed_dt = _random_future_datetime() + rescheduled = requested_date is not None + + return { + "patient_name": patient_name, + "client_name": client_name, + "species": species, + "reason": reason, + "status": "confirmed", + "appointment_id": str(uuid.uuid4()), + "date": confirmed_dt.strftime("%Y-%m-%d"), + "time": confirmed_dt.strftime("%H:%M"), + "vet": random.choice(_VET_NAMES), + "location": resolved_location, + "preferred_date_honoured": not rescheduled, + "message": ( + f"Requested date unavailable; booked next available slot on {confirmed_dt.strftime('%Y-%m-%d')} " + f"at {confirmed_dt.strftime('%H:%M')}." + if rescheduled + else f"Appointment confirmed for {confirmed_dt.strftime('%Y-%m-%d')} at {confirmed_dt.strftime('%H:%M')}." + ), + } + + +# --------------------------------------------------------------------------- +# Tool 3: order_stock +# --------------------------------------------------------------------------- + +@app.mcp_tool() +@app.mcp_tool_property(arg_name="medicine_name", description="The name of the veterinary medicine to check/order, e.g. Amoxicillin.", is_required=True) +@app.mcp_tool_property(arg_name="location", description="The clinic location to check/order stock for, e.g. 'Riverside Veterinary Clinic - Main St'.", is_required=False) +@app.mcp_tool_property(arg_name="quantity_to_order", description="Quantity of the medicine to order, if placing an order. Omit or set to 0 to only check stock.", is_required=False) +def order_stock(medicine_name: str, location: str = None, quantity_to_order: int = 0) -> dict: + """Check current stock level of a veterinary medicine at a clinic location, and optionally place an order to top it up.""" + logging.info(f"order_stock called for {medicine_name} at {location}, quantity_to_order={quantity_to_order}") + + try: + request = OrderStockRequest( + medicine_name=medicine_name, + location=location, + quantity_to_order=quantity_to_order, + ) + except ValidationError as exc: + validation_errors = exc.errors() + first_error = validation_errors[0] if validation_errors else None + field_name = first_error["loc"][0] if first_error and first_error.get("loc") else "input" + + if field_name == "medicine_name": + message = "Unknown medicine_name. Please use one of the supported medicine names." + elif field_name == "quantity_to_order": + message = "quantity_to_order must be a non-negative integer." + else: + message = "Invalid input for order_stock. Please check the provided arguments." + + return { + "medicine_name": medicine_name, + "category": None, + "location": location or random.choice(_CLINIC_LOCATIONS), + "current_stock_units": None, + "low_stock": None, + "reorder_threshold": None, + "order": { + "status": "not_requested", + "order_id": None, + "quantity_ordered": 0, + "estimated_delivery_date": None, + "projected_stock_after_delivery": None, + }, + "allowed_medicines": [m.value for m in MedicineName], + "message": message, + } + + resolved_location = request.location or random.choice(_CLINIC_LOCATIONS) + medicine_name = request.medicine_name.value + quantity_to_order = request.quantity_to_order + category = _MEDICATIONS[request.medicine_name] + + current_stock = random.randint(0, 200) + reorder_threshold = 25 + low_stock = current_stock < reorder_threshold + + response = { + "medicine_name": medicine_name, + "category": category, + "location": resolved_location, + "current_stock_units": current_stock, + "low_stock": low_stock, + "reorder_threshold": reorder_threshold, + } + + # The "order" object always has the same shape regardless of whether an + # order was placed, so callers/agents can rely on a stable schema rather + # than branching on whether the field is null vs. populated. + if quantity_to_order and quantity_to_order > 0: + eta_days = random.randint(1, 7) + eta_date = (datetime.now(timezone.utc) + timedelta(days=eta_days)).strftime("%Y-%m-%d") + response["order"] = { + "status": "placed", + "order_id": str(uuid.uuid4()), + "quantity_ordered": quantity_to_order, + "estimated_delivery_date": eta_date, + "projected_stock_after_delivery": current_stock + quantity_to_order, + } + response["message"] = ( + f"Order placed for {quantity_to_order} unit(s) of {medicine_name} " + f"at {resolved_location}. Estimated delivery: {eta_date}." + ) + else: + response["order"] = { + "status": "not_requested", + "order_id": None, + "quantity_ordered": 0, + "estimated_delivery_date": None, + "projected_stock_after_delivery": current_stock, + } + if low_stock: + response["message"] = ( + f"Stock is low ({current_stock} units) at {resolved_location}. " + "Consider ordering more by supplying a 'quantity_to_order' value." + ) + else: + response["message"] = ( + f"{medicine_name} currently has {current_stock} units in stock at {resolved_location}." + ) + + return response diff --git a/labs/ai-foundry-toolbox/mcp-servers/vet-clinic/host.json b/labs/ai-foundry-toolbox/mcp-servers/vet-clinic/host.json new file mode 100644 index 00000000..d3002169 --- /dev/null +++ b/labs/ai-foundry-toolbox/mcp-servers/vet-clinic/host.json @@ -0,0 +1,23 @@ +{ + "version": "2.0", + "extensions": { + "mcp": { + "system": { + "webhookAuthorizationLevel": "Anonymous" + } + } + }, + "logging": { + "applicationInsights": { + "samplingSettings": { + "isEnabled": true, + "excludedTypes": "Request" + }, + "enableLiveMetricsFilters": true + } + }, + "extensionBundle": { + "id": "Microsoft.Azure.Functions.ExtensionBundle.Preview", + "version": "[4.*, 5.0.0)" + } +} diff --git a/labs/ai-foundry-toolbox/mcp-servers/vet-clinic/requirements.txt b/labs/ai-foundry-toolbox/mcp-servers/vet-clinic/requirements.txt new file mode 100644 index 00000000..1df1b020 --- /dev/null +++ b/labs/ai-foundry-toolbox/mcp-servers/vet-clinic/requirements.txt @@ -0,0 +1,6 @@ +# Do not include azure-functions-worker in this file +# The Python Worker is managed by the Azure Functions platform +# Manually managing azure-functions-worker may cause unexpected issues + +azure-functions +pydantic>=2.0,<3.0 diff --git a/labs/ai-foundry-toolbox/policy.xml b/labs/ai-foundry-toolbox/policy.xml new file mode 100644 index 00000000..b06e225f --- /dev/null +++ b/labs/ai-foundry-toolbox/policy.xml @@ -0,0 +1,19 @@ + + + + + + @("Bearer " + (string)context.Variables["managed-id-access-token"]) + + + + + + + + + + + + + diff --git a/labs/ai-foundry-toolbox/pyproject.toml b/labs/ai-foundry-toolbox/pyproject.toml new file mode 100644 index 00000000..ad1e5bf0 --- /dev/null +++ b/labs/ai-foundry-toolbox/pyproject.toml @@ -0,0 +1,16 @@ +[project] +name = "ai-foundry-toolbox-lab" +version = "0.1.0" +description = "Dependencies for the ai-foundry-toolbox lab notebook." +requires-python = ">=3.12" +dependencies = [ + "azure-ai-projects==2.3.0", + "azure-identity", + "mcp[cli]", + "nest-asyncio", + "openai", + "requests", +] + +[tool.uv] +package = false diff --git a/labs/ai-foundry-toolbox/toolbox-policy.xml b/labs/ai-foundry-toolbox/toolbox-policy.xml new file mode 100644 index 00000000..ddca3218 --- /dev/null +++ b/labs/ai-foundry-toolbox/toolbox-policy.xml @@ -0,0 +1,29 @@ + + + + + + + @("Bearer " + (string)context.Variables["foundry-token"]) + + + Toolboxes=V1Preview + + + + v1 + + + + + + + + + + + +