From 2234cb6bad872d57137701806ed77e435bc29dc4 Mon Sep 17 00:00:00 2001 From: Pamela Fox Date: Wed, 3 Dec 2025 14:21:34 -0800 Subject: [PATCH 1/7] Add second container for agent --- agents/Dockerfile | 39 ++++++++++++++++ azure.yaml | 11 ++++- infra/agent.bicep | 59 ++++++++++++++++++++++++ infra/core/host/container-app.bicep | 2 +- infra/main.bicep | 71 +++++++++++++++++++++-------- infra/{aca.bicep => server.bicep} | 10 ++-- 6 files changed, 166 insertions(+), 26 deletions(-) create mode 100644 agents/Dockerfile create mode 100644 infra/agent.bicep rename infra/{aca.bicep => server.bicep} (84%) diff --git a/agents/Dockerfile b/agents/Dockerfile new file mode 100644 index 0000000..017e521 --- /dev/null +++ b/agents/Dockerfile @@ -0,0 +1,39 @@ +# ------------------- Stage 1: Build Stage ------------------------------ +# We use Alpine for smaller image size (~329MB vs ~431MB for Debian slim). +# Trade-off: We must install build tools to compile native extensions (cryptography, etc.) +# since pre-built musl wheels aren't available. Debian -slim would skip compilation +# but produces a larger final image due to glibc wheel sizes. +FROM python:3.13-alpine AS build + +# Install build dependencies for packages with native extensions (cryptography, etc.) +# https://cryptography.io/en/latest/installation/#building-cryptography-on-linux +RUN apk add --no-cache gcc g++ musl-dev python3-dev libffi-dev openssl-dev cargo pkgconfig + +COPY --from=ghcr.io/astral-sh/uv:0.9.14 /uv /uvx /bin/ + +WORKDIR /code + +# Install dependencies first (for layer caching) +RUN --mount=type=cache,target=/root/.cache/uv \ + --mount=type=bind,source=uv.lock,target=uv.lock \ + --mount=type=bind,source=pyproject.toml,target=pyproject.toml \ + uv sync --locked --no-install-project + +# Copy the project and sync +COPY . . +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --locked + +# ------------------- Stage 2: Final Stage ------------------------------ +FROM python:3.13-alpine AS final + +RUN addgroup -S app && adduser -S app -G app + +COPY --from=build --chown=app:app /code /code + +WORKDIR /code/agents +USER app + +ENV PATH="/code/.venv/bin:$PATH" + +ENTRYPOINT ["python", "agentframework_http.py"] diff --git a/azure.yaml b/azure.yaml index 850c8aa..67ea6fb 100644 --- a/azure.yaml +++ b/azure.yaml @@ -5,13 +5,20 @@ metadata: template: python-mcp-demo@0.0.1 services: # Not using remoteBuild due to private endpoint usage - aca: + server: project: . language: docker host: containerapp docker: path: ./servers/Dockerfile context: . + agent: + project: . + language: docker + host: containerapp + docker: + path: ./agents/Dockerfile + context: . hooks: postprovision: posix: @@ -21,4 +28,4 @@ hooks: windows: shell: pwsh run: ./infra/write_env.ps1 - continueOnError: true \ No newline at end of file + continueOnError: true diff --git a/infra/agent.bicep b/infra/agent.bicep new file mode 100644 index 0000000..d653390 --- /dev/null +++ b/infra/agent.bicep @@ -0,0 +1,59 @@ +param name string +param location string = resourceGroup().location +param tags object = {} + +param identityName string +param containerAppsEnvironmentName string +param containerRegistryName string +param serviceName string = 'agent' +param exists bool +param openAiDeploymentName string +param openAiEndpoint string +param mcpServerUrl string + +resource agentIdentity 'Microsoft.ManagedIdentity/userAssignedIdentities@2023-01-31' = { + name: identityName + location: location +} + +module app 'core/host/container-app-upsert.bicep' = { + name: '${serviceName}-container-app-module' + params: { + name: name + location: location + tags: union(tags, { 'azd-service-name': serviceName }) + identityName: agentIdentity.name + exists: exists + containerAppsEnvironmentName: containerAppsEnvironmentName + containerRegistryName: containerRegistryName + ingressEnabled: false + env: [ + { + name: 'AZURE_OPENAI_CHAT_DEPLOYMENT' + value: openAiDeploymentName + } + { + name: 'AZURE_OPENAI_ENDPOINT' + value: openAiEndpoint + } + { + name: 'API_HOST' + value: 'azure' + } + { + name: 'AZURE_CLIENT_ID' + value: agentIdentity.properties.clientId + } + { + name: 'MCP_SERVER_URL' + value: mcpServerUrl + } + ] + } +} + +output identityPrincipalId string = agentIdentity.properties.principalId +output name string = app.outputs.name +output hostName string = app.outputs.hostName +output uri string = app.outputs.uri +output imageName string = app.outputs.imageName diff --git a/infra/core/host/container-app.bicep b/infra/core/host/container-app.bicep index aa353e4..ee55e14 100644 --- a/infra/core/host/container-app.bicep +++ b/infra/core/host/container-app.bicep @@ -124,5 +124,5 @@ resource containerRegistry 'Microsoft.ContainerRegistry/registries@2022-02-01-pr output defaultDomain string = containerAppsEnvironment.properties.defaultDomain output imageName string = imageName output name string = app.name -output hostName string = app.properties.configuration.ingress.fqdn +output hostName string = ingressEnabled ? app.properties.configuration.ingress.fqdn : '' output uri string = ingressEnabled ? 'https://${app.properties.configuration.ingress.fqdn}' : '' diff --git a/infra/main.bicep b/infra/main.bicep index ac54d34..8494026 100644 --- a/infra/main.bicep +++ b/infra/main.bicep @@ -12,7 +12,9 @@ param location string @description('Id of the user or app to assign application roles') param principalId string = '' -param acaExists bool = false +param serverExists bool = false + +param agentExists bool = false @description('Location for the OpenAI resource group') @allowed([ @@ -653,15 +655,15 @@ module cosmosDbPrivateEndpoint 'br/public:avm/res/network/private-endpoint:0.11. } } -// Container app frontend -module aca 'aca.bicep' = { - name: 'aca' +// Container app for MCP server +module server 'server.bicep' = { + name: 'server' scope: resourceGroup params: { - name: replace('${take(prefix,19)}-ca', '--', '-') + name: replace('${take(prefix,15)}-server', '--', '-') location: location tags: tags - identityName: '${prefix}-id-aca' + identityName: '${prefix}-id-server' containerAppsEnvironmentName: containerApps.outputs.environmentName containerRegistryName: containerApps.outputs.registryName openAiDeploymentName: openAiDeploymentName @@ -669,7 +671,25 @@ module aca 'aca.bicep' = { cosmosDbAccount: cosmosDb.outputs.name cosmosDbDatabase: cosmosDbDatabaseName cosmosDbContainer: cosmosDbContainerName - exists: acaExists + exists: serverExists + } +} + +// Container app for agent +module agent 'agent.bicep' = { + name: 'agent' + scope: resourceGroup + params: { + name: replace('${take(prefix,15)}-agent', '--', '-') + location: location + tags: tags + identityName: '${prefix}-id-agent' + containerAppsEnvironmentName: containerApps.outputs.environmentName + containerRegistryName: containerApps.outputs.registryName + openAiDeploymentName: openAiDeploymentName + openAiEndpoint: openAi.outputs.endpoint + mcpServerUrl: '${server.outputs.uri}/mcp/' + exists: agentExists } } @@ -683,11 +703,21 @@ module openAiRoleUser 'core/security/role.bicep' = { } } -module openAiRoleBackend 'core/security/role.bicep' = { +module openAiRoleServer 'core/security/role.bicep' = { + scope: resourceGroup + name: 'openai-role-server' + params: { + principalId: server.outputs.identityPrincipalId + roleDefinitionId: '5e0bd9bd-7b93-4f28-af87-19fc36ad61bd' // Cognitive Services OpenAI User + principalType: 'ServicePrincipal' + } +} + +module openAiRoleAgent 'core/security/role.bicep' = { scope: resourceGroup - name: 'openai-role-backend' + name: 'openai-role-agent' params: { - principalId: aca.outputs.identityPrincipalId + principalId: agent.outputs.identityPrincipalId roleDefinitionId: '5e0bd9bd-7b93-4f28-af87-19fc36ad61bd' // Cognitive Services OpenAI User principalType: 'ServicePrincipal' } @@ -704,13 +734,13 @@ module cosmosDbRoleUser 'core/security/documentdb-sql-role.bicep' = { } } -// Cosmos DB Data Contributor role for backend -module cosmosDbRoleBackend 'core/security/documentdb-sql-role.bicep' = { +// Cosmos DB Data Contributor role for server +module cosmosDbRoleServer 'core/security/documentdb-sql-role.bicep' = { scope: resourceGroup - name: 'cosmosdb-role-backend' + name: 'cosmosdb-role-server' params: { databaseAccountName: cosmosDb.outputs.name - principalId: aca.outputs.identityPrincipalId + principalId: server.outputs.identityPrincipalId roleDefinitionId: '/${subscription().id}/resourceGroups/${resourceGroup.name}/providers/Microsoft.DocumentDB/databaseAccounts/${cosmosDb.outputs.name}/sqlRoleDefinitions/00000000-0000-0000-0000-000000000002' } } @@ -725,10 +755,15 @@ output AZURE_OPENAI_ENDPOINT string = openAi.outputs.endpoint output AZURE_OPENAI_RESOURCE string = openAi.outputs.name output AZURE_OPENAI_RESOURCE_LOCATION string = openAi.outputs.location -output SERVICE_ACA_IDENTITY_PRINCIPAL_ID string = aca.outputs.identityPrincipalId -output SERVICE_ACA_NAME string = aca.outputs.name -output SERVICE_ACA_URI string = aca.outputs.uri -output SERVICE_ACA_IMAGE_NAME string = aca.outputs.imageName +output SERVICE_SERVER_IDENTITY_PRINCIPAL_ID string = server.outputs.identityPrincipalId +output SERVICE_SERVER_NAME string = server.outputs.name +output SERVICE_SERVER_URI string = server.outputs.uri +output SERVICE_SERVER_IMAGE_NAME string = server.outputs.imageName + +output SERVICE_AGENT_IDENTITY_PRINCIPAL_ID string = agent.outputs.identityPrincipalId +output SERVICE_AGENT_NAME string = agent.outputs.name +output SERVICE_AGENT_URI string = agent.outputs.uri +output SERVICE_AGENT_IMAGE_NAME string = agent.outputs.imageName output AZURE_CONTAINER_ENVIRONMENT_NAME string = containerApps.outputs.environmentName output AZURE_CONTAINER_REGISTRY_ENDPOINT string = containerApps.outputs.registryLoginServer diff --git a/infra/aca.bicep b/infra/server.bicep similarity index 84% rename from infra/aca.bicep rename to infra/server.bicep index 7a63624..a358c5f 100644 --- a/infra/aca.bicep +++ b/infra/server.bicep @@ -5,7 +5,7 @@ param tags object = {} param identityName string param containerAppsEnvironmentName string param containerRegistryName string -param serviceName string = 'aca' +param serviceName string = 'server' param exists bool param openAiDeploymentName string param openAiEndpoint string @@ -13,7 +13,7 @@ param cosmosDbAccount string param cosmosDbDatabase string param cosmosDbContainer string -resource acaIdentity 'Microsoft.ManagedIdentity/userAssignedIdentities@2023-01-31' = { +resource serverIdentity 'Microsoft.ManagedIdentity/userAssignedIdentities@2023-01-31' = { name: identityName location: location } @@ -24,7 +24,7 @@ module app 'core/host/container-app-upsert.bicep' = { name: name location: location tags: union(tags, { 'azd-service-name': serviceName }) - identityName: acaIdentity.name + identityName: serverIdentity.name exists: exists containerAppsEnvironmentName: containerAppsEnvironmentName containerRegistryName: containerRegistryName @@ -44,7 +44,7 @@ module app 'core/host/container-app-upsert.bicep' = { } { name: 'AZURE_CLIENT_ID' - value: acaIdentity.properties.clientId + value: serverIdentity.properties.clientId } { name: 'AZURE_COSMOSDB_ACCOUNT' @@ -63,7 +63,7 @@ module app 'core/host/container-app-upsert.bicep' = { } } -output identityPrincipalId string = acaIdentity.properties.principalId +output identityPrincipalId string = serverIdentity.properties.principalId output name string = app.outputs.name output hostName string = app.outputs.hostName output uri string = app.outputs.uri From a5902fe07b2300b4d3617d4c394827c16d9621e9 Mon Sep 17 00:00:00 2001 From: Pamela Fox Date: Wed, 3 Dec 2025 16:40:50 -0800 Subject: [PATCH 2/7] Add health checks --- azure.yaml | 4 ++- infra/agent.bicep | 4 +++ infra/core/host/container-app-upsert.bicep | 4 +++ infra/core/host/container-app.bicep | 4 +++ .../host/container-apps-environment.bicep | 16 +++++++++- infra/core/host/container-registry.bicep | 2 +- infra/main.bicep | 4 +-- infra/main.parameters.json | 9 ++++-- infra/server.bicep | 31 +++++++++++++++++++ servers/deployed_mcp.py | 6 ++++ 10 files changed, 76 insertions(+), 8 deletions(-) diff --git a/azure.yaml b/azure.yaml index 67ea6fb..9c8b229 100644 --- a/azure.yaml +++ b/azure.yaml @@ -3,13 +3,14 @@ name: python-mcp-demo metadata: template: python-mcp-demo@0.0.1 + services: - # Not using remoteBuild due to private endpoint usage server: project: . language: docker host: containerapp docker: + remoteBuild: true path: ./servers/Dockerfile context: . agent: @@ -17,6 +18,7 @@ services: language: docker host: containerapp docker: + remoteBuild: true path: ./agents/Dockerfile context: . hooks: diff --git a/infra/agent.bicep b/infra/agent.bicep index d653390..8284793 100644 --- a/infra/agent.bicep +++ b/infra/agent.bicep @@ -48,6 +48,10 @@ module app 'core/host/container-app-upsert.bicep' = { name: 'MCP_SERVER_URL' value: mcpServerUrl } + { + name: 'RUNNING_IN_PRODUCTION' + value: 'true' + } ] } } diff --git a/infra/core/host/container-app-upsert.bicep b/infra/core/host/container-app-upsert.bicep index 822712d..1805242 100644 --- a/infra/core/host/container-app-upsert.bicep +++ b/infra/core/host/container-app-upsert.bicep @@ -40,6 +40,9 @@ param containerCpuCoreCount string = '0.5' @description('Memory allocated to a single container instance, e.g. 1Gi') param containerMemory string = '1.0Gi' +@description('Health probes for the container') +param probes array = [] + resource existingApp 'Microsoft.App/containerApps@2025-01-01' existing = if (exists) { name: name } @@ -67,6 +70,7 @@ module app 'container-app.bicep' = { env: env imageName: exists ? existingApp.properties.template.containers[0].image : '' targetPort: targetPort + probes: probes } } diff --git a/infra/core/host/container-app.bicep b/infra/core/host/container-app.bicep index ee55e14..3dfb239 100644 --- a/infra/core/host/container-app.bicep +++ b/infra/core/host/container-app.bicep @@ -40,6 +40,9 @@ param containerCpuCoreCount string = '0.5' @description('Memory allocated to a single container instance, e.g. 1Gi') param containerMemory string = '1.0Gi' +@description('Health probes for the container') +param probes array = [] + resource userIdentity 'Microsoft.ManagedIdentity/userAssignedIdentities@2023-01-31' existing = { name: identityName } @@ -102,6 +105,7 @@ resource app 'Microsoft.App/containerApps@2025-01-01' = { cpu: json(containerCpuCoreCount) memory: containerMemory } + probes: probes } ] scale: { diff --git a/infra/core/host/container-apps-environment.bicep b/infra/core/host/container-apps-environment.bicep index 8893af9..833cbd1 100644 --- a/infra/core/host/container-apps-environment.bicep +++ b/infra/core/host/container-apps-environment.bicep @@ -26,7 +26,21 @@ module containerAppsEnvironment 'br/public:avm/res/app/managed-environment:0.11. location: location tags: tags zoneRedundant: false - publicNetworkAccess: 'Enabled' + publicNetworkAccess: usePrivateIngress ? 'Disabled' : 'Enabled' + workloadProfiles: usePrivateIngress + ? [ + { + name: 'Consumption' + workloadProfileType: 'Consumption' + } + { + name: 'Warm' + workloadProfileType: 'D4' + minimumCount: 1 + maximumCount: 3 + } + ] + : [] appLogsConfiguration: useLogging ? { destination: 'log-analytics' diff --git a/infra/core/host/container-registry.bicep b/infra/core/host/container-registry.bicep index 5d04458..dbb569f 100644 --- a/infra/core/host/container-registry.bicep +++ b/infra/core/host/container-registry.bicep @@ -10,7 +10,7 @@ param encryption object = { status: 'disabled' } param networkRuleBypassOptions string = 'AzureServices' -param publicNetworkAccess string = useVnet ? 'Disabled' : 'Enabled' // Public network access is disabled if VNet integration is enabled +param publicNetworkAccess string = 'Enabled' // Keep public access enabled for pushing images from local machine param useVnet bool = false // Determines if VNet integration is enabled param sku object = { name: useVnet ? 'Premium' : 'Standard' // Use Premium if VNet is required, otherwise Standard diff --git a/infra/main.bicep b/infra/main.bicep index 8494026..fdaf98d 100644 --- a/infra/main.bicep +++ b/infra/main.bicep @@ -152,7 +152,7 @@ module logAnalyticsWorkspace 'br/public:avm/res/operational-insights/workspace:0 skuName: 'PerGB2018' dataRetention: 30 publicNetworkAccessForIngestion: useVnet ? 'Disabled' : 'Enabled' - publicNetworkAccessForQuery: useVnet ? 'Disabled' : 'Enabled' + publicNetworkAccessForQuery: 'Enabled' // Keep public query access for debugging - change to 'Disabled' for more security useResourcePermissions: true } } @@ -540,7 +540,7 @@ module monitorPrivateLinkScope 'br/public:avm/res/insights/private-link-scope:0. tags: tags accessModeSettings: { ingestionAccessMode: 'PrivateOnly' - queryAccessMode: 'PrivateOnly' + queryAccessMode: 'Open' // Allow public queries for debugging - change to 'PrivateOnly' for more security } scopedResources: [ { diff --git a/infra/main.parameters.json b/infra/main.parameters.json index eecddb6..43c4e05 100644 --- a/infra/main.parameters.json +++ b/infra/main.parameters.json @@ -20,8 +20,11 @@ "usePrivateIngress": { "value": "${USE_PRIVATE_INGRESS=false}" }, - "acaExists": { - "value": "${SERVICE_ACA_RESOURCE_EXISTS=false}" + "serverExists": { + "value": "${SERVICE_SERVER_RESOURCE_EXISTS=false}" + }, + "agentExists": { + "value": "${SERVICE_AGENT_RESOURCE_EXISTS=false}" } } - } \ No newline at end of file + } diff --git a/infra/server.bicep b/infra/server.bicep index a358c5f..322b11d 100644 --- a/infra/server.bicep +++ b/infra/server.bicep @@ -60,6 +60,37 @@ module app 'core/host/container-app-upsert.bicep' = { } ] targetPort: 8000 + probes: [ + { + type: 'Startup' + httpGet: { + path: '/health' + port: 8000 + } + initialDelaySeconds: 3 + periodSeconds: 3 + failureThreshold: 30 + } + { + type: 'Readiness' + httpGet: { + path: '/health' + port: 8000 + } + initialDelaySeconds: 5 + periodSeconds: 5 + failureThreshold: 3 + } + { + type: 'Liveness' + httpGet: { + path: '/health' + port: 8000 + } + periodSeconds: 10 + failureThreshold: 3 + } + ] } } diff --git a/servers/deployed_mcp.py b/servers/deployed_mcp.py index bed88c0..9b96caf 100644 --- a/servers/deployed_mcp.py +++ b/servers/deployed_mcp.py @@ -9,6 +9,7 @@ from azure.identity.aio import DefaultAzureCredential, ManagedIdentityCredential from dotenv import load_dotenv from fastmcp import FastMCP +from starlette.responses import JSONResponse load_dotenv(override=True) @@ -39,6 +40,11 @@ mcp = FastMCP("Expenses Tracker") +@mcp.custom_route("/health", methods=["GET"]) +async def health_check(request): + return JSONResponse({"status": "healthy", "service": "mcp-server"}) + + class PaymentMethod(Enum): AMEX = "amex" VISA = "visa" From 8f0ce4f99ba49b7aeab7e9d1ab0fafeb36635aa1 Mon Sep 17 00:00:00 2001 From: Pamela Fox Date: Wed, 3 Dec 2025 16:56:45 -0800 Subject: [PATCH 3/7] Use remoteBuild --- .dockerignore | 4 ++++ agents/Dockerfile | 12 +++++------- servers/Dockerfile | 12 +++++------- 3 files changed, 14 insertions(+), 14 deletions(-) create mode 100644 .dockerignore diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..6d7ac95 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,4 @@ +.azure +.venv +.logfire +.devcontainer diff --git a/agents/Dockerfile b/agents/Dockerfile index 017e521..6a2f5e4 100644 --- a/agents/Dockerfile +++ b/agents/Dockerfile @@ -13,16 +13,14 @@ COPY --from=ghcr.io/astral-sh/uv:0.9.14 /uv /uvx /bin/ WORKDIR /code -# Install dependencies first (for layer caching) -RUN --mount=type=cache,target=/root/.cache/uv \ - --mount=type=bind,source=uv.lock,target=uv.lock \ - --mount=type=bind,source=pyproject.toml,target=pyproject.toml \ - uv sync --locked --no-install-project +# Copy dependency files and install dependencies (for layer caching) +# Note: We avoid --mount=type=cache since Azure Container Apps remote build doesn't support BuildKit +COPY uv.lock pyproject.toml ./ +RUN uv sync --locked --no-install-project # Copy the project and sync COPY . . -RUN --mount=type=cache,target=/root/.cache/uv \ - uv sync --locked +RUN uv sync --locked # ------------------- Stage 2: Final Stage ------------------------------ FROM python:3.13-alpine AS final diff --git a/servers/Dockerfile b/servers/Dockerfile index f0fa239..abb721a 100644 --- a/servers/Dockerfile +++ b/servers/Dockerfile @@ -13,16 +13,14 @@ COPY --from=ghcr.io/astral-sh/uv:0.9.14 /uv /uvx /bin/ WORKDIR /code -# Install dependencies first (for layer caching) -RUN --mount=type=cache,target=/root/.cache/uv \ - --mount=type=bind,source=uv.lock,target=uv.lock \ - --mount=type=bind,source=pyproject.toml,target=pyproject.toml \ - uv sync --locked --no-install-project +# Copy dependency files and install dependencies (for layer caching) +# Note: We avoid --mount=type=cache since Azure Container Apps remote build doesn't support BuildKit +COPY uv.lock pyproject.toml ./ +RUN uv sync --locked --no-install-project # Copy the project and sync COPY . . -RUN --mount=type=cache,target=/root/.cache/uv \ - uv sync --locked +RUN uv sync --locked # ------------------- Stage 2: Final Stage ------------------------------ FROM python:3.13-alpine AS final From 7467d92458ea3ed8785bd6e83e2c787e9d725f70 Mon Sep 17 00:00:00 2001 From: Pamela Fox Date: Thu, 4 Dec 2025 10:19:59 -0800 Subject: [PATCH 4/7] Address feedback from Copilot --- .dockerignore | 11 +++++++++++ agents/Dockerfile | 3 ++- infra/core/host/container-apps.bicep | 3 +++ infra/core/host/container-registry.bicep | 4 ++-- infra/main.bicep | 11 +++++++++-- infra/main.parameters.json | 6 ++++++ infra/server.bicep | 4 ++-- servers/Dockerfile | 3 ++- servers/deployed_mcp.py | 12 +++++++++++- 9 files changed, 48 insertions(+), 9 deletions(-) diff --git a/.dockerignore b/.dockerignore index 6d7ac95..793715a 100644 --- a/.dockerignore +++ b/.dockerignore @@ -2,3 +2,14 @@ .venv .logfire .devcontainer +infra + +# Common Python and development files to exclude +__pycache__ +*.pyc +*.pyo +*.egg-info +.pytest_cache +.ruff_cache +.env +.git diff --git a/agents/Dockerfile b/agents/Dockerfile index 6a2f5e4..51c3448 100644 --- a/agents/Dockerfile +++ b/agents/Dockerfile @@ -14,7 +14,8 @@ COPY --from=ghcr.io/astral-sh/uv:0.9.14 /uv /uvx /bin/ WORKDIR /code # Copy dependency files and install dependencies (for layer caching) -# Note: We avoid --mount=type=cache since Azure Container Apps remote build doesn't support BuildKit +# Note: We can't use --mount=type=cache since Azure Container Apps remote build doesn't support BuildKit: +# https://github.com/Azure/acr/issues/721 COPY uv.lock pyproject.toml ./ RUN uv sync --locked --no-install-project diff --git a/infra/core/host/container-apps.bicep b/infra/core/host/container-apps.bicep index eafdbdc..eb9bad0 100644 --- a/infra/core/host/container-apps.bicep +++ b/infra/core/host/container-apps.bicep @@ -15,6 +15,8 @@ param subnetResourceId string = '' param usePrivateIngress bool = true +param usePrivateAcr bool = false + module containerAppsEnvironment 'container-apps-environment.bicep' = { name: '${name}-container-apps-environment' params: { @@ -36,6 +38,7 @@ module containerRegistry 'container-registry.bicep' = { location: location tags: tags useVnet: !empty(vnetName) + usePrivateAcr: usePrivateAcr } } diff --git a/infra/core/host/container-registry.bicep b/infra/core/host/container-registry.bicep index dbb569f..084c6c5 100644 --- a/infra/core/host/container-registry.bicep +++ b/infra/core/host/container-registry.bicep @@ -10,8 +10,8 @@ param encryption object = { status: 'disabled' } param networkRuleBypassOptions string = 'AzureServices' -param publicNetworkAccess string = 'Enabled' // Keep public access enabled for pushing images from local machine param useVnet bool = false // Determines if VNet integration is enabled +param usePrivateAcr bool = false // Determines if public network access should be disabled param sku object = { name: useVnet ? 'Premium' : 'Standard' // Use Premium if VNet is required, otherwise Standard } @@ -32,7 +32,7 @@ resource containerRegistry 'Microsoft.ContainerRegistry/registries@2022-02-01-pr dataEndpointEnabled: dataEndpointEnabled encryption: encryption networkRuleBypassOptions: networkRuleBypassOptions - publicNetworkAccess: publicNetworkAccess + publicNetworkAccess: usePrivateAcr ? 'Disabled' : 'Enabled' zoneRedundancy: zoneRedundancy } } diff --git a/infra/main.bicep b/infra/main.bicep index c6a5b51..9e95e12 100644 --- a/infra/main.bicep +++ b/infra/main.bicep @@ -46,6 +46,12 @@ param useVnet bool = false @description('Flag to enable or disable public ingress') param usePrivateIngress bool = false +@description('Flag to restrict ACR public network access (requires VPN for local image push when true)') +param usePrivateAcr bool = false + +@description('Flag to restrict Log Analytics public query access for increased security') +param usePrivateLogAnalytics bool = false + var resourceToken = toLower(uniqueString(subscription().id, name, location)) var tags = { 'azd-env-name': name } @@ -152,7 +158,7 @@ module logAnalyticsWorkspace 'br/public:avm/res/operational-insights/workspace:0 skuName: 'PerGB2018' dataRetention: 30 publicNetworkAccessForIngestion: useVnet ? 'Disabled' : 'Enabled' - publicNetworkAccessForQuery: 'Enabled' // Keep public query access for debugging - change to 'Disabled' for more security + publicNetworkAccessForQuery: usePrivateLogAnalytics ? 'Disabled' : 'Enabled' useResourcePermissions: true } } @@ -554,7 +560,7 @@ module monitorPrivateLinkScope 'br/public:avm/res/insights/private-link-scope:0. tags: tags accessModeSettings: { ingestionAccessMode: 'PrivateOnly' - queryAccessMode: 'Open' // Allow public queries for debugging - change to 'PrivateOnly' for more security + queryAccessMode: usePrivateLogAnalytics ? 'PrivateOnly' : 'Open' } scopedResources: [ { @@ -606,6 +612,7 @@ module containerApps 'core/host/container-apps.bicep' = { vnetName: useVnet ? virtualNetwork!.outputs.name : '' subnetName: useVnet ? virtualNetwork!.outputs.subnetNames[0] : '' usePrivateIngress: usePrivateIngress + usePrivateAcr: usePrivateAcr } } diff --git a/infra/main.parameters.json b/infra/main.parameters.json index 43c4e05..9791aa6 100644 --- a/infra/main.parameters.json +++ b/infra/main.parameters.json @@ -20,6 +20,12 @@ "usePrivateIngress": { "value": "${USE_PRIVATE_INGRESS=false}" }, + "usePrivateAcr": { + "value": "${USE_PRIVATE_ACR=false}" + }, + "usePrivateLogAnalytics": { + "value": "${USE_PRIVATE_LOGANALYTICS=false}" + }, "serverExists": { "value": "${SERVICE_SERVER_RESOURCE_EXISTS=false}" }, diff --git a/infra/server.bicep b/infra/server.bicep index 8804b7c..c917af7 100644 --- a/infra/server.bicep +++ b/infra/server.bicep @@ -74,9 +74,9 @@ module app 'core/host/container-app-upsert.bicep' = { path: '/health' port: 8000 } - initialDelaySeconds: 3 + initialDelaySeconds: 10 periodSeconds: 3 - failureThreshold: 30 + failureThreshold: 60 } { type: 'Readiness' diff --git a/servers/Dockerfile b/servers/Dockerfile index abb721a..89b474c 100644 --- a/servers/Dockerfile +++ b/servers/Dockerfile @@ -14,7 +14,8 @@ COPY --from=ghcr.io/astral-sh/uv:0.9.14 /uv /uvx /bin/ WORKDIR /code # Copy dependency files and install dependencies (for layer caching) -# Note: We avoid --mount=type=cache since Azure Container Apps remote build doesn't support BuildKit +# Note: We can't use --mount=type=cache since Azure Container Apps remote build doesn't support BuildKit: +# https://github.com/Azure/acr/issues/721 COPY uv.lock pyproject.toml ./ RUN uv sync --locked --no-install-project diff --git a/servers/deployed_mcp.py b/servers/deployed_mcp.py index e29d5a6..b5551b1 100644 --- a/servers/deployed_mcp.py +++ b/servers/deployed_mcp.py @@ -66,7 +66,17 @@ @mcp.custom_route("/health", methods=["GET"]) -async def health_check(request): +async def health_check(_request): + """ + Health check endpoint for service availability. + + This endpoint is used by Azure Container Apps health probes to verify that the service is running. + Returns a JSON response with the following format: + { + "status": "healthy", + "service": "mcp-server" + } + """ return JSONResponse({"status": "healthy", "service": "mcp-server"}) From 85a17c1ed4c94158b1f5b64e4e722e45768057b5 Mon Sep 17 00:00:00 2001 From: Pamela Fox Date: Thu, 4 Dec 2025 11:03:46 -0800 Subject: [PATCH 5/7] Adding README --- README.md | 261 ++++++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 206 insertions(+), 55 deletions(-) diff --git a/README.md b/README.md index d26482a..6b83fd2 100644 --- a/README.md +++ b/README.md @@ -1,59 +1,130 @@ # Python MCP Demo -A demonstration project showcasing Model Context Protocol (MCP) implementations using FastMCP, with examples of stdio, HTTP transports, and integration with LangChain and Agent Framework. +A demonstration project showcasing Model Context Protocol (MCP) implementations using FastMCP, with examples of stdio and HTTP transports, integration with LangChain and Agent Framework, and deployment to Azure Container Apps. ## Table of Contents -- [Prerequisites](#prerequisites) -- [Setup](#setup) -- [Python Scripts](#python-scripts) -- [MCP Server Configuration](#mcp-server-configuration) -- [Debugging](#debugging) +- [Getting started](#getting-started) + - [GitHub Codespaces](#github-codespaces) + - [VS Code Dev Containers](#vs-code-dev-containers) + - [Local environment](#local-environment) +- [Run local MCP servers](#run-local-mcp-servers) + - [Use with GitHub Copilot](#use-with-github-copilot) + - [Debug with VS Code](#debug-with-vs-code) + - [Inspect with MCP inspector](#inspect-with-mcp-inspector) +- [Run local Agents <-> MCP](#run-local-agents---mcp) +- [Deploy to Azure](#deploy-to-azure) + - [Azure account setup](#azure-account-setup) + - [Deploying with azd](#deploying-with-azd) + - [Costs](#costs) +- [Deploy to Azure with private networking](#deploy-to-azure-with-private-networking) -## Prerequisites +## Getting started -- Python 3.13 or higher -- [uv](https://docs.astral.sh/uv/) -- API access to one of the following: - - GitHub Models (GitHub token) - - Azure OpenAI (Azure credentials) - - Ollama (local installation) - - OpenAI API (API key) +You have a few options for setting up this project. The quickest way to get started is GitHub Codespaces, since it will setup all the tools for you, but you can also set it up locally. -## Setup +### GitHub Codespaces -1. Install dependencies using `uv`: +You can run this project virtually by using GitHub Codespaces. Click the button to open a web-based VS Code instance in your browser: + +[![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/pamelafox/python-mcp-demo) + +Once the Codespace is open, open a terminal window and continue with the deployment steps. + +### VS Code Dev Containers + +A related option is VS Code Dev Containers, which will open the project in your local VS Code using the [Dev Containers extension](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers): + +1. Start Docker Desktop (install it if not already installed) +2. Open the project: [![Open in Dev Containers](https://img.shields.io/static/v1?style=for-the-badge&label=Dev%20Containers&message=Open&color=blue&logo=visualstudiocode)](https://vscode.dev/redirect?url=vscode://ms-vscode-remote.remote-containers/cloneInVolume?url=https://github.com/pamelafox/python-mcp-demo) +3. In the VS Code window that opens, once the project files show up (this may take several minutes), open a terminal window. +4. Continue with the deployment steps. + +### Local environment + +If you're not using one of the above options, then you'll need to: + +1. Make sure the following tools are installed: + - [Azure Developer CLI (azd)](https://aka.ms/install-azd) + - [Python 3.13+](https://www.python.org/downloads/) + - [Docker Desktop](https://www.docker.com/products/docker-desktop/) + - [Git](https://git-scm.com/downloads) + +2. Clone the repository and open the project folder. + +3. Create a [Python virtual environment](https://docs.python.org/3/tutorial/venv.html#creating-virtual-environments) and activate it. + +4. Install the dependencies: ```bash uv sync ``` -2. Copy `.env-sample` to `.env` and configure your environment variables: +5. Copy `.env-sample` to `.env` and configure your environment variables: ```bash cp .env-sample .env ``` -3. Edit `.env` with your API credentials. Choose one of the following providers by setting `API_HOST`: +6. Edit `.env` with your API credentials. Choose one of the following providers by setting `API_HOST`: - `github` - GitHub Models (requires `GITHUB_TOKEN`) - `azure` - Azure OpenAI (requires Azure credentials) - `ollama` - Local Ollama instance - `openai` - OpenAI API (requires `OPENAI_API_KEY`) -## Python Scripts +## Run local MCP servers + +This project includes two MCP servers in the [`servers/`](servers/) directory: -Run any script with: `uv run ` +| File | Description | +|------|-------------| +| [servers/basic_mcp_stdio.py](servers/basic_mcp_stdio.py) | MCP server with stdio transport for VS Code integration | +| [servers/basic_mcp_http.py](servers/basic_mcp_http.py) | MCP server with HTTP transport on port 8000 | -- **servers/basic_mcp_http.py** - MCP server with HTTP transport on port 8000 -- **servers/basic_mcp_stdio.py** - MCP server with stdio transport for VS Code integration -- **agents/langchainv1_http.py** - LangChain agent with MCP integration -- **agents/langchainv1_github.py** - LangChain tool filtering demo with GitHub MCP (requires `GITHUB_TOKEN`) -- **agents/agentframework_learn.py** - Microsoft Agent Framework integration with MCP -- **agents/agentframework_http.py** - Microsoft Agent Framework integration with local Expenses MCP server +Both servers implement an "Expenses Tracker" with a tool to add expenses to a CSV file. -## MCP Server Configuration +### Use with GitHub Copilot -### Using with MCP Inspector +The `.vscode/mcp.json` file configures MCP servers for GitHub Copilot integration: + +**Available Servers:** + +- **expenses-mcp**: stdio transport server for production use +- **expenses-mcp-debug**: stdio server with debugpy on port 5678 +- **expenses-mcp-http**: HTTP transport server at `http://localhost:8000/mcp`. You must start this server manually with `uv run servers/basic_mcp_http.py` before using it. + +**Switching Servers:** + +Configure which server GitHub Copilot uses by opening the Chat panel, selecting the tools icon, and choosing the desired MCP server from the list. + +![Servers selection dialog](readme_serverselect.png) + +**Example input:** + +Use a query like this to test the expenses MCP server: + +```text +Log expense for 50 bucks of pizza on my amex today +``` + +![Example GitHub Copilot Chat Input](readme_samplequery.png) + +### Debug with VS Code + +The `.vscode/launch.json` provides a debug configuration to attach to an MCP server. + +**To debug an MCP server with GitHub Copilot Chat:** + +1. Set breakpoints in the MCP server code in `servers/basic_mcp_stdio.py` +2. Start the debug server via `mcp.json` configuration by selecting `expenses-mcp-debug` +3. Press `Cmd+Shift+D` to open Run and Debug +4. Select "Attach to MCP Server (stdio)" configuration +5. Press `F5` or the play button to start the debugger +6. Select the expenses-mcp-debug server in GitHub Copilot Chat tools +7. Use GitHub Copilot Chat to trigger the MCP tools +8. Debugger pauses at breakpoints + +### Inspect with MCP inspector The [MCP Inspector](https://github.com/modelcontextprotocol/inspector) is a developer tool for testing and debugging MCP servers. @@ -86,45 +157,125 @@ The inspector provides a web interface to: - Inspect server responses and errors - Debug server communication -### Using with GitHub Copilot +--- -The `.vscode/mcp.json` file configures MCP servers for GitHub Copilot integration: +## Run local Agents <-> MCP -**Available Servers:** +This project includes example agents in the [`agents/`](agents/) directory that demonstrate how to connect AI agents to MCP servers: -- **expenses-mcp**: stdio transport server for production use -- **expenses-mcp-debug**: stdio server with debugpy on port 5678 -- **expenses-mcp-http**: HTTP transport server at `http://localhost:8000/mcp`. You must start this server manually with `uv run servers/basic_mcp_http.py` before using it. +| File | Description | +|------|-------------| +| [agents/agentframework_learn.py](agents/agentframework_learn.py) | Microsoft Agent Framework integration with MCP | +| [agents/agentframework_http.py](agents/agentframework_http.py) | Microsoft Agent Framework integration with local Expenses MCP server | +| [agents/langchainv1_http.py](agents/langchainv1_http.py) | LangChain agent with MCP integration | +| [agents/langchainv1_github.py](agents/langchainv1_github.py) | LangChain tool filtering demo with GitHub MCP (requires `GITHUB_TOKEN`) | -**Switching Servers:** +**To run an agent:** -Configure which server GitHub Copilot uses by opening the Chat panel, selecting the tools icon, and choosing the desired MCP server from the list. +1. First start the HTTP MCP server: -![Servers selection dialog](readme_serverselect.png) + ```bash + uv run servers/basic_mcp_http.py + ``` -**Example input** +2. In another terminal, run an agent: -Use a query like this to test the expenses MCP server: + ```bash + uv run agents/agentframework_http.py + ``` -``` -Log expense for 50 bucks of pizza on my amex today -``` +The agents will connect to the MCP server and allow you to interact with the expense tracking tools through a chat interface. -![Example GitHub Copilot Chat Input](readme_samplequery.png) +--- -## Debugging +## Deploy to Azure -The `.vscode/launch.json` provides one debug configuration: +This project can be deployed to Azure Container Apps using the Azure Developer CLI (azd). The deployment provisions: -**Attach to MCP Server (stdio)**: Attaches to server started via `expenses-mcp-debug` in `mcp.json` +- **Azure Container Apps** - Hosts both the MCP server and agent +- **Azure OpenAI** - Provides the LLM for the agent +- **Azure Cosmos DB** - Stores expenses data +- **Azure Container Registry** - Stores container images +- **Log Analytics** - Monitoring and diagnostics -To debug an MCP server with GitHub Copilot Chat: +### Azure account setup -1. Set breakpoints in the MCP server code in `servers/basic_mcp_stdio.py` -1. Start the debug server via `mcp.json` configuration by selecting `expenses-mcp-debug` -1. Press `Cmd+Shift+D` to open Run and Debug -1. Select "Attach to MCP Server (stdio)" configuration -1. Press `F5` or the play button to start the debugger -1. Select the expenses-mcp-debug server in GitHub Copilot Chat tools -1. Use GitHub Copilot Chat to trigger the MCP tools -1. Debugger pauses at breakpoints \ No newline at end of file +1. Sign up for a [free Azure account](https://azure.microsoft.com/free/) and create an Azure Subscription. +2. Check that you have the necessary permissions: + - Your Azure account must have `Microsoft.Authorization/roleAssignments/write` permissions, such as [Role Based Access Control Administrator](https://learn.microsoft.com/azure/role-based-access-control/built-in-roles#role-based-access-control-administrator-preview), [User Access Administrator](https://learn.microsoft.com/azure/role-based-access-control/built-in-roles#user-access-administrator), or [Owner](https://learn.microsoft.com/azure/role-based-access-control/built-in-roles#owner). + - Your Azure account also needs `Microsoft.Resources/deployments/write` permissions on the subscription level. + +### Deploying with azd + +1. Login to Azure: + + ```bash + azd auth login + ``` + + For GitHub Codespaces users, if the previous command fails, try: + + ```bash + azd auth login --use-device-code + ``` + +2. Create a new azd environment: + + ```bash + azd env new + ``` + + This will create a folder inside `.azure` with the name of your environment. + +3. Provision and deploy the resources: + + ```bash + azd up + ``` + + It will prompt you to select a subscription and location. This will take several minutes to complete. + +4. Once deployment is complete, a `.env` file will be created with the necessary environment variables to run the agents locally against the deployed resources. + +### Costs + +Pricing varies per region and usage, so it isn't possible to predict exact costs for your usage. + +You can try the [Azure pricing calculator](https://azure.com/e/3987c81282c84410b491d28094030c9a) for the resources: + +- **Azure OpenAI Service**: S0 tier, GPT-4o-mini model. Pricing is based on token count. [Pricing](https://azure.microsoft.com/pricing/details/cognitive-services/openai-service/) +- **Azure Container Apps**: Consumption tier. [Pricing](https://azure.microsoft.com/pricing/details/container-apps/) +- **Azure Container Registry**: Standard tier. [Pricing](https://azure.microsoft.com/pricing/details/container-registry/) +- **Azure Cosmos DB**: Serverless tier. [Pricing](https://azure.microsoft.com/pricing/details/cosmos-db/) +- **Log Analytics** (Optional): Pay-as-you-go tier. Costs based on data ingested. [Pricing](https://azure.microsoft.com/pricing/details/monitor/) + +⚠️ To avoid unnecessary costs, remember to take down your app if it's no longer in use, either by deleting the resource group in the Portal or running `azd down`. + +--- + +### Deploy to Azure with private networking + +To demonstrate enhanced security for production deployments, this project supports deploying with a virtual network (VNet) configuration that restricts public access to Azure resources. + +1. Set these azd environment variables to set up a virtual network and private endpoints for the Container App, Cosmos DB, and OpenAI resources: + + ```bash + azd env set USE_VNET true + azd env set USE_PRIVATE_INGRESS true + ``` + + The Log Analytics and ACR resources will still have public access enabled, so that you can deploy and monitor the app without needing a VPN. In production, you would typically restrict these as well. + +2. Provision and deploy: + + ```bash + azd up + ``` + +### Additional costs for private networking + +When using VNet configuration, additional Azure resources are provisioned: + +- **Virtual Network**: Pay-as-you-go tier. Costs based on data processed. [Pricing](https://azure.microsoft.com/pricing/details/virtual-network/) +- **Azure Private DNS Resolver**: Pricing per month, endpoints, and zones. [Pricing](https://azure.microsoft.com/pricing/details/dns/) +- **Azure Private Endpoints**: Pricing per hour per endpoint. [Pricing](https://azure.microsoft.com/pricing/details/private-link/) From f3a11c0c5294bbbc14b079f1a4d093983e9e9f10 Mon Sep 17 00:00:00 2001 From: Pamela Fox Date: Thu, 4 Dec 2025 11:07:48 -0800 Subject: [PATCH 6/7] Address feedback --- infra/agent.bicep | 6 ------ 1 file changed, 6 deletions(-) diff --git a/infra/agent.bicep b/infra/agent.bicep index 85f515d..8284793 100644 --- a/infra/agent.bicep +++ b/infra/agent.bicep @@ -10,7 +10,6 @@ param exists bool param openAiDeploymentName string param openAiEndpoint string param mcpServerUrl string -param applicationInsightsConnectionString string = '' resource agentIdentity 'Microsoft.ManagedIdentity/userAssignedIdentities@2023-01-31' = { name: identityName @@ -53,11 +52,6 @@ module app 'core/host/container-app-upsert.bicep' = { name: 'RUNNING_IN_PRODUCTION' value: 'true' } - // We typically store sensitive values in secrets, but App Insights connection strings are not considered highly sensitive - { - name: 'APPLICATIONINSIGHTS_CONNECTION_STRING' - value: applicationInsightsConnectionString - } ] } } From d4a8c623d64c9eeeb7d2e59ef3ce3dc8de2dbb5f Mon Sep 17 00:00:00 2001 From: Pamela Fox Date: Thu, 4 Dec 2025 12:27:47 -0800 Subject: [PATCH 7/7] Address feedback --- README.md | 5 +---- agents/agentframework_http.py | 6 ++++++ 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 6b83fd2..d269cc0 100644 --- a/README.md +++ b/README.md @@ -14,9 +14,6 @@ A demonstration project showcasing Model Context Protocol (MCP) implementations - [Inspect with MCP inspector](#inspect-with-mcp-inspector) - [Run local Agents <-> MCP](#run-local-agents---mcp) - [Deploy to Azure](#deploy-to-azure) - - [Azure account setup](#azure-account-setup) - - [Deploying with azd](#deploying-with-azd) - - [Costs](#costs) - [Deploy to Azure with private networking](#deploy-to-azure-with-private-networking) ## Getting started @@ -253,7 +250,7 @@ You can try the [Azure pricing calculator](https://azure.com/e/3987c81282c84410b --- -### Deploy to Azure with private networking +## Deploy to Azure with private networking To demonstrate enhanced security for production deployments, this project supports deploying with a virtual network (VNet) configuration that restricts public access to Azure resources. diff --git a/agents/agentframework_http.py b/agents/agentframework_http.py index 1aedd7a..ebe8d68 100644 --- a/agents/agentframework_http.py +++ b/agents/agentframework_http.py @@ -20,6 +20,7 @@ load_dotenv(override=True) # Constants +RUNNING_IN_PRODUCTION = os.getenv("RUNNING_IN_PRODUCTION", "false").lower() == "true" MCP_SERVER_URL = os.getenv("MCP_SERVER_URL", "http://localhost:8000/mcp/") # Configure chat client based on API_HOST @@ -69,6 +70,11 @@ async def http_mcp_example() -> None: result = await agent.run(user_query, tools=mcp_server) print(result) + # Keep the worker alive in production + while RUNNING_IN_PRODUCTION: + await asyncio.sleep(60) + logger.info("Worker still running...") + if __name__ == "__main__": asyncio.run(http_mcp_example())