diff --git a/.dockerignore b/.dockerignore index 793715a..8ceedfa 100644 --- a/.dockerignore +++ b/.dockerignore @@ -2,7 +2,12 @@ .venv .logfire .devcontainer -infra + +# Exclude most of infra, but allow keycloak files needed for builds +infra/*.bicep +infra/*.ps1 +infra/*.sh +infra/core # Common Python and development files to exclude __pycache__ diff --git a/.vscode/mcp.json b/.vscode/mcp.json index 0028ee2..4b87ffb 100644 --- a/.vscode/mcp.json +++ b/.vscode/mcp.json @@ -1,33 +1,33 @@ { - "servers": { - "expenses-mcp": { - "type": "stdio", - "command": "uv", - "cwd": "${workspaceFolder}", - "args": [ - "run", - "servers/basic_mcp_stdio.py" - ] - }, - "expenses-mcp-http": { - "type": "http", - "url": "http://localhost:8000/mcp" - }, - "expenses-mcp-debug": { - "type": "stdio", - "command": "uv", - "cwd": "${workspaceFolder}", - "args": [ - "run", - "--", - "python", - "-m", - "debugpy", - "--listen", - "0.0.0.0:5678", - "servers/basic_mcp_stdio.py" - ] - } - }, - "inputs": [] -} + "servers": { + "expenses-mcp": { + "type": "stdio", + "command": "uv", + "cwd": "${workspaceFolder}", + "args": [ + "run", + "servers/basic_mcp_stdio.py" + ] + }, + "expenses-mcp-http": { + "type": "http", + "url": "http://localhost:8000/mcp" + }, + "expenses-mcp-debug": { + "type": "stdio", + "command": "uv", + "cwd": "${workspaceFolder}", + "args": [ + "run", + "--", + "python", + "-m", + "debugpy", + "--listen", + "0.0.0.0:5678", + "servers/basic_mcp_stdio.py" + ] + }, + }, + "inputs": [] +} \ No newline at end of file diff --git a/README.md b/README.md index d269cc0..291e4fd 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ A demonstration project showcasing Model Context Protocol (MCP) implementations - [Run local Agents <-> MCP](#run-local-agents---mcp) - [Deploy to Azure](#deploy-to-azure) - [Deploy to Azure with private networking](#deploy-to-azure-with-private-networking) +- [Deploy to Azure with Keycloak authentication](#deploy-to-azure-with-keycloak-authentication) ## Getting started @@ -71,14 +72,15 @@ If you're not using one of the above options, then you'll need to: ## Run local MCP servers -This project includes two MCP servers in the [`servers/`](servers/) directory: +This project includes MCP servers in the [`servers/`](servers/) directory: | 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/deployed_mcp.py](servers/deployed_mcp.py) | MCP server for Azure deployment with Cosmos DB and optional Keycloak auth | -Both servers implement an "Expenses Tracker" with a tool to add expenses to a CSV file. +The local servers (`basic_mcp_stdio.py` and `basic_mcp_http.py`) implement an "Expenses Tracker" with a tool to add expenses to a CSV file. ### Use with GitHub Copilot @@ -276,3 +278,85 @@ 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/) + +--- + +## Deploy to Azure with Keycloak authentication + +This project supports deploying with OAuth 2.0 authentication using Keycloak as the identity provider, implementing the [MCP OAuth specification](https://modelcontextprotocol.io/specification/2025-03-26/basic/authorization) with Dynamic Client Registration (DCR). + +### What gets deployed + +| Component | Description | +|-----------|-------------| +| **Keycloak Container App** | Keycloak 26.0 with pre-configured realm | +| **HTTP Route Configuration** | Rule-based routing: `/auth/*` → Keycloak, `/*` → MCP Server | +| **OAuth-protected MCP Server** | FastMCP with JWT validation against Keycloak's JWKS endpoint | + +### Deployment steps + +1. Set the Keycloak admin password (required): + + ```bash + azd env set KEYCLOAK_ADMIN_PASSWORD "YourSecurePassword123!" + ``` + +2. Optionally customize the realm name (default: `mcp`): + + ```bash + azd env set KEYCLOAK_REALM_NAME "mcp" + ``` + +3. Deploy to Azure: + + ```bash + azd up + ``` + + This will create the Azure Container Apps environment, deploy Keycloak with the pre-configured realm, deploy the MCP server with OAuth validation, and configure HTTP route-based routing. + +4. Verify deployment by checking the outputs: + + ```bash + azd env get-value MCP_SERVER_URL + azd env get-value KEYCLOAK_DIRECT_URL + azd env get-value KEYCLOAK_ADMIN_CONSOLE + ``` + +5. Visit the Keycloak admin console to verify the realm is configured: + + ```text + https:///auth/admin + ``` + + Login with `admin` and your configured password. + +### Testing with the agent + +1. Generate the local environment file (automatically created after `azd up`): + + ```bash + ./infra/write_env.sh + ``` + + This creates `.env` with `KEYCLOAK_REALM_URL`, `MCP_SERVER_URL`, and Azure OpenAI settings. + +2. Run the agent: + + ```bash + uv run agents/agentframework_http.py + ``` + + The agent automatically detects `KEYCLOAK_REALM_URL` in the environment and authenticates via DCR + client credentials. On success, it will add an expense and print the result. + +### Known limitations (demo trade-offs) + +| Item | Current | Production Recommendation | Why | +|------|---------|---------------------------|-----| +| Keycloak mode | `start-dev` | `start` with proper config | Dev mode has relaxed security defaults | +| Database | H2 in-memory | PostgreSQL | H2 doesn't persist data across restarts | +| Replicas | 1 (due to H2) | Multiple with shared DB | H2 is in-memory, can't share state | +| Keycloak access | Public (direct URL) | Internal only via routes | Route URL isn't known until after deployment | +| DCR | Open (anonymous) | Require initial access token | Any client can register without auth | + +> **Note:** Keycloak must be publicly accessible because its URL is dynamically generated by Azure. Token issuer validation requires a known URL, but the mcproutes URL isn't available until after deployment. Using a custom domain would fix this. diff --git a/agents/agentframework_http.py b/agents/agentframework_http.py index ebe8d68..b0b734c 100644 --- a/agents/agentframework_http.py +++ b/agents/agentframework_http.py @@ -3,6 +3,7 @@ import asyncio import logging import os +from datetime import datetime from agent_framework import ChatAgent, MCPStreamableHTTPTool from agent_framework.azure import AzureOpenAIChatClient @@ -12,6 +13,11 @@ from rich import print from rich.logging import RichHandler +try: + from keycloak_auth import get_auth_headers +except ImportError: + from agents.keycloak_auth import get_auth_headers + # Configure logging logging.basicConfig(level=logging.WARNING, format="%(message)s", datefmt="[%X]", handlers=[RichHandler()]) logger = logging.getLogger("agentframework_mcp_http") @@ -23,6 +29,9 @@ RUNNING_IN_PRODUCTION = os.getenv("RUNNING_IN_PRODUCTION", "false").lower() == "true" MCP_SERVER_URL = os.getenv("MCP_SERVER_URL", "http://localhost:8000/mcp/") +# Optional: Keycloak authentication (set KEYCLOAK_REALM_URL to enable) +KEYCLOAK_REALM_URL = os.getenv("KEYCLOAK_REALM_URL") + # Configure chat client based on API_HOST API_HOST = os.getenv("API_HOST", "github") @@ -51,19 +60,29 @@ ) +# --- Main Agent Logic --- + + async def http_mcp_example() -> None: """ - Demonstrate MCP integration with the local Expenses MCP server. + Demonstrate MCP integration with the Expenses MCP server. - Creates an agent that can help users log expenses - using the Expenses MCP server at http://localhost:8000/mcp/. + If KEYCLOAK_REALM_URL is set, authenticates via OAuth (DCR + client credentials). + Otherwise, connects without authentication. """ + # Get auth headers if Keycloak is configured + headers = await get_auth_headers(KEYCLOAK_REALM_URL, client_name_prefix="agentframework") + if headers: + logger.info(f"🔐 Auth enabled - connecting to {MCP_SERVER_URL} with Bearer token") + else: + logger.info(f"📡 No auth - connecting to {MCP_SERVER_URL}") + async with ( - MCPStreamableHTTPTool(name="Expenses MCP Server", url=MCP_SERVER_URL) as mcp_server, + MCPStreamableHTTPTool(name="Expenses MCP Server", url=MCP_SERVER_URL, headers=headers) as mcp_server, ChatAgent( chat_client=client, name="Expenses Agent", - instructions="You help users to log expenses.", + instructions=f"You help users to log expenses. Today's date is {datetime.now().strftime('%Y-%m-%d')}.", ) as agent, ): user_query = "yesterday I bought a laptop for $1200 using my visa." diff --git a/agents/keycloak_auth.py b/agents/keycloak_auth.py new file mode 100644 index 0000000..b98d1f7 --- /dev/null +++ b/agents/keycloak_auth.py @@ -0,0 +1,120 @@ +""" +Keycloak authentication helpers for MCP agents. + +Provides OAuth2 client credentials flow authentication via Keycloak's +Dynamic Client Registration (DCR) endpoint. + +Usage: + from keycloak_auth import get_auth_headers + + headers = await get_auth_headers(keycloak_realm_url) + # Returns {"Authorization": "Bearer "} or None if no URL provided +""" + +from __future__ import annotations + +import logging +from datetime import datetime + +import httpx + +logger = logging.getLogger(__name__) + + +async def register_client_via_dcr(keycloak_realm_url: str, client_name_prefix: str = "agent") -> tuple[str, str]: + """ + Register a new client dynamically using Keycloak's DCR endpoint. + + Args: + keycloak_realm_url: The Keycloak realm URL (e.g., http://localhost:8080/realms/myrealm) + client_name_prefix: Prefix for the generated client name + + Returns: + Tuple of (client_id, client_secret) + + Raises: + RuntimeError: If DCR registration fails + """ + dcr_url = f"{keycloak_realm_url}/clients-registrations/openid-connect" + logger.info("📝 Registering client via DCR...") + + async with httpx.AsyncClient() as http_client: + response = await http_client.post( + dcr_url, + json={ + "client_name": f"{client_name_prefix}-{datetime.now().strftime('%Y%m%d-%H%M%S')}", + "grant_types": ["client_credentials"], + "token_endpoint_auth_method": "client_secret_basic", + }, + headers={"Content-Type": "application/json"}, + ) + + if response.status_code not in (200, 201): + raise RuntimeError( + f"DCR registration failed at {dcr_url}: status={response.status_code}, response={response.text}" + ) + + data = response.json() + logger.info(f"✅ Registered client: {data['client_id'][:20]}...") + return data["client_id"], data["client_secret"] + + +async def get_keycloak_token(keycloak_realm_url: str, client_id: str, client_secret: str) -> str: + """ + Get an access token from Keycloak using client_credentials grant. + + Args: + keycloak_realm_url: The Keycloak realm URL + client_id: The OAuth client ID + client_secret: The OAuth client secret + + Returns: + The access token string + + Raises: + RuntimeError: If token request fails + """ + token_url = f"{keycloak_realm_url}/protocol/openid-connect/token" + logger.info("🔑 Getting access token from Keycloak...") + + async with httpx.AsyncClient() as http_client: + response = await http_client.post( + token_url, + data={ + "grant_type": "client_credentials", + "client_id": client_id, + "client_secret": client_secret, + }, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + + if response.status_code != 200: + raise RuntimeError( + f"Token request failed at {token_url}: status={response.status_code}, response={response.text}" + ) + + token_data = response.json() + logger.info(f"✅ Got access token (expires in {token_data.get('expires_in', '?')}s)") + return token_data["access_token"] + + +async def get_auth_headers(keycloak_realm_url: str | None, client_name_prefix: str = "agent") -> dict[str, str] | None: + """ + Get authorization headers if Keycloak is configured. + + This is the main entry point for agents that need OAuth authentication. + It handles the full flow: DCR registration -> token acquisition -> headers. + + Args: + keycloak_realm_url: The Keycloak realm URL, or None to skip auth + client_name_prefix: Prefix for the dynamically registered client name + + Returns: + {"Authorization": "Bearer "} if keycloak_realm_url is set, None otherwise + """ + if not keycloak_realm_url: + return None + + client_id, client_secret = await register_client_via_dcr(keycloak_realm_url, client_name_prefix) + access_token = await get_keycloak_token(keycloak_realm_url, client_id, client_secret) + return {"Authorization": f"Bearer {access_token}"} diff --git a/azure.yaml b/azure.yaml index 9c8b229..1cab741 100644 --- a/azure.yaml +++ b/azure.yaml @@ -13,6 +13,13 @@ services: remoteBuild: true path: ./servers/Dockerfile context: . + keycloak: + project: . + language: docker + host: containerapp + docker: + path: ./keycloak/Dockerfile + context: . agent: project: . language: docker diff --git a/infra/agent.bicep b/infra/agent.bicep index 8284793..9298a1e 100644 --- a/infra/agent.bicep +++ b/infra/agent.bicep @@ -10,6 +10,43 @@ param exists bool param openAiDeploymentName string param openAiEndpoint string param mcpServerUrl string +param keycloakRealmUrl string = '' + +// Base environment variables +var baseEnv = [ + { + 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 + } + { + name: 'RUNNING_IN_PRODUCTION' + value: 'true' + } +] + +// Keycloak authentication environment variables (only added when configured) +var keycloakEnv = !empty(keycloakRealmUrl) ? [ + { + name: 'KEYCLOAK_REALM_URL' + value: keycloakRealmUrl + } +] : [] resource agentIdentity 'Microsoft.ManagedIdentity/userAssignedIdentities@2023-01-31' = { name: identityName @@ -27,32 +64,7 @@ module app 'core/host/container-app-upsert.bicep' = { 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 - } - { - name: 'RUNNING_IN_PRODUCTION' - value: 'true' - } - ] + env: concat(baseEnv, keycloakEnv) } } diff --git a/infra/http-routes.bicep b/infra/http-routes.bicep new file mode 100644 index 0000000..0f8ced4 --- /dev/null +++ b/infra/http-routes.bicep @@ -0,0 +1,76 @@ +@description('Name of the Container Apps environment') +param containerAppsEnvironmentName string + +@description('Name of the MCP server container app') +param mcpServerAppName string + +@description('Name of the Keycloak container app') +param keycloakAppName string + +@description('Name for the HTTP route configuration') +param routeConfigName string = 'mcproutes' + +resource containerEnv 'Microsoft.App/managedEnvironments@2024-10-02-preview' existing = { + name: containerAppsEnvironmentName +} + +resource mcpServerApp 'Microsoft.App/containerApps@2024-10-02-preview' existing = { + name: mcpServerAppName +} + +resource keycloakApp 'Microsoft.App/containerApps@2024-10-02-preview' existing = { + name: keycloakAppName +} + +// HTTP Route Configuration for rule-based routing +// Routes /auth/* to Keycloak and /* to MCP server +resource httpRouteConfig 'Microsoft.App/managedEnvironments/httpRouteConfigs@2024-10-02-preview' = { + name: routeConfigName + parent: containerEnv + properties: { + rules: [ + // Route /auth/* to Keycloak (strip /auth prefix since Keycloak serves at root) + // Using pathSeparatedPrefix ensures /auth doesn't match /authentication + { + description: 'Keycloak Authentication Server' + routes: [ + { + match: { + pathSeparatedPrefix: '/auth' + } + action: { + prefixRewrite: '/' + } + } + ] + targets: [ + { + containerApp: keycloakApp.name + } + ] + } + // Route everything else to MCP server (catch-all) + { + description: 'MCP Expenses Server' + routes: [ + { + match: { + prefix: '/' + } + action: { + prefixRewrite: '/' + } + } + ] + targets: [ + { + containerApp: mcpServerApp.name + } + ] + } + ] + } +} + +output fqdn string = httpRouteConfig.properties.fqdn +output routeConfigUrl string = 'https://${httpRouteConfig.properties.fqdn}' diff --git a/infra/keycloak.bicep b/infra/keycloak.bicep new file mode 100644 index 0000000..9cb9001 --- /dev/null +++ b/infra/keycloak.bicep @@ -0,0 +1,127 @@ +@description('Name of the Keycloak container app') +param name string + +@description('Azure region for deployment') +param location string = resourceGroup().location + +@description('Tags to apply to all resources') +param tags object = {} + +@description('Name of the Container Apps environment') +param containerAppsEnvironmentName string + +@description('Name of the Azure Container Registry') +param containerRegistryName string + +@description('Service name for azd tagging') +param serviceName string = 'keycloak' + +@description('Keycloak admin username') +param keycloakAdminUser string = 'admin' + +@secure() +@description('Keycloak admin password') +param keycloakAdminPassword string + +@description('Whether the container app already exists (for updates)') +param exists bool + +@description('User assigned identity name for ACR pull') +param identityName string + +resource keycloakIdentity 'Microsoft.ManagedIdentity/userAssignedIdentities@2023-01-31' = { + name: identityName + location: location +} + +resource containerAppsEnvironment 'Microsoft.App/managedEnvironments@2024-10-02-preview' existing = { + name: containerAppsEnvironmentName +} + +resource containerRegistry 'Microsoft.ContainerRegistry/registries@2022-02-01-preview' existing = { + name: containerRegistryName +} + +// Grant the identity ACR pull access +module containerRegistryAccess 'core/security/registry-access.bicep' = { + name: '${name}-registry-access' + params: { + containerRegistryName: containerRegistryName + principalId: keycloakIdentity.properties.principalId + } +} + +resource existingApp 'Microsoft.App/containerApps@2024-10-02-preview' existing = if (exists) { + name: name +} + +// Keycloak container app with custom image containing realm import +resource keycloakApp 'Microsoft.App/containerApps@2024-10-02-preview' = { + name: name + location: location + tags: union(tags, { 'azd-service-name': serviceName }) + dependsOn: [containerRegistryAccess] + identity: { + type: 'UserAssigned' + userAssignedIdentities: { '${keycloakIdentity.id}': {} } + } + properties: { + managedEnvironmentId: containerAppsEnvironment.id + configuration: { + activeRevisionsMode: 'single' + ingress: { + external: true + targetPort: 8080 + transport: 'auto' + } + secrets: [ + { + name: 'keycloak-admin-password' + value: keycloakAdminPassword + } + ] + registries: [ + { + server: '${containerRegistry.name}.azurecr.io' + identity: keycloakIdentity.id + } + ] + } + template: { + containers: [ + { + image: exists ? existingApp.properties.template.containers[0].image : 'mcr.microsoft.com/azuredocs/containerapps-helloworld:latest' + name: 'keycloak' + env: [ + { + name: 'KEYCLOAK_ADMIN' + value: keycloakAdminUser + } + { + name: 'KEYCLOAK_ADMIN_PASSWORD' + secretRef: 'keycloak-admin-password' + } + { + name: 'KC_HEALTH_ENABLED' + value: 'true' + } + ] + resources: { + cpu: json('2.0') + memory: '4.0Gi' + } + } + ] + scale: { + minReplicas: 1 + maxReplicas: 1 + } + } + } +} + +output identityPrincipalId string = keycloakIdentity.properties.principalId +output name string = keycloakApp.name +output hostName string = keycloakApp.properties.configuration.ingress.fqdn +output uri string = 'https://${keycloakApp.properties.configuration.ingress.fqdn}' +output imageName string = exists ? existingApp.properties.template.containers[0].image : '' diff --git a/infra/main.bicep b/infra/main.bicep index 9e95e12..adf1774 100644 --- a/infra/main.bicep +++ b/infra/main.bicep @@ -13,8 +13,8 @@ param location string param principalId string = '' param serverExists bool = false - param agentExists bool = false +param keycloakExists bool = false @description('Location for the OpenAI resource group') @allowed([ @@ -46,6 +46,22 @@ param useVnet bool = false @description('Flag to enable or disable public ingress') param usePrivateIngress bool = false +@description('Flag to enable or disable Keycloak authentication for the MCP server') +param useKeycloak bool = false + +@description('Keycloak admin username') +param keycloakAdminUser string = 'admin' + +@secure() +@description('Keycloak admin password - required when useKeycloak is true') +param keycloakAdminPassword string = '' + +@description('Keycloak realm name for MCP authentication') +param keycloakRealmName string = 'mcp' + +@description('Audience claim for MCP server tokens (only used when useKeycloak is true)') +param keycloakMcpServerAudience string = 'mcp-server' + @description('Flag to restrict ACR public network access (requires VPN for local image push when true)') param usePrivateAcr bool = false @@ -694,6 +710,10 @@ module server 'server.bicep' = { cosmosDbContainer: cosmosDbContainerName applicationInsightsConnectionString: useMonitoring ? applicationInsights!.outputs.connectionString : '' exists: serverExists + // Keycloak authentication configuration (only when enabled) + keycloakRealmUrl: useKeycloak ? '${keycloak!.outputs.uri}/realms/${keycloakRealmName}' : '' + mcpServerBaseUrl: useKeycloak ? 'https://mcproutes.${containerApps.outputs.defaultDomain}' : '' + keycloakMcpServerAudience: keycloakMcpServerAudience } } @@ -710,11 +730,40 @@ module agent 'agent.bicep' = { containerRegistryName: containerApps.outputs.registryName openAiDeploymentName: openAiDeploymentName openAiEndpoint: openAi.outputs.endpoint - mcpServerUrl: '${server.outputs.uri}/mcp/' + mcpServerUrl: useKeycloak ? 'https://mcproutes.${containerApps.outputs.defaultDomain}/mcp' : '${server.outputs.uri}/mcp' + keycloakRealmUrl: useKeycloak ? '${keycloak.outputs.uri}/realms/${keycloakRealmName}' : '' exists: agentExists } } +// Keycloak authentication server (always deployed, but only used when useKeycloak is true) +module keycloak 'keycloak.bicep' = { + name: 'keycloak' + scope: resourceGroup + params: { + name: replace('${take(prefix,19)}-kc', '--', '-') + location: location + tags: tags + identityName: '${prefix}-id-keycloak' + containerAppsEnvironmentName: containerApps.outputs.environmentName + containerRegistryName: containerApps.outputs.registryName + keycloakAdminUser: keycloakAdminUser + keycloakAdminPassword: useKeycloak ? keycloakAdminPassword : 'placeholder-not-used' + exists: keycloakExists + } +} + +// HTTP Route configuration for rule-based routing (only when Keycloak is enabled) +module httpRoutes 'http-routes.bicep' = if (useKeycloak) { + name: 'http-routes' + scope: resourceGroup + params: { + containerAppsEnvironmentName: containerApps.outputs.environmentName + mcpServerAppName: server.outputs.name + keycloakAppName: keycloak!.outputs.name + } +} + module openAiRoleUser 'core/security/role.bicep' = { scope: resourceGroup name: 'openai-role-user' @@ -787,6 +836,10 @@ 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 SERVICE_KEYCLOAK_NAME string = keycloak.outputs.name +output SERVICE_KEYCLOAK_URI string = keycloak.outputs.uri +output SERVICE_KEYCLOAK_IMAGE_NAME string = keycloak.outputs.imageName + output AZURE_CONTAINER_ENVIRONMENT_NAME string = containerApps.outputs.environmentName output AZURE_CONTAINER_REGISTRY_ENDPOINT string = containerApps.outputs.registryLoginServer output AZURE_CONTAINER_REGISTRY_NAME string = containerApps.outputs.registryName @@ -798,3 +851,11 @@ output AZURE_COSMOSDB_CONTAINER string = cosmosDbContainerName // We typically do not output sensitive values, but App Insights connection strings are not considered highly sensitive output APPLICATIONINSIGHTS_CONNECTION_STRING string = useMonitoring ? applicationInsights!.outputs.connectionString : '' + +// Keycloak and MCP Server routing outputs (only populated when useKeycloak is true) +output HTTP_ROUTES_URL string = useKeycloak ? httpRoutes!.outputs.routeConfigUrl : '' +output KEYCLOAK_URL string = useKeycloak ? '${httpRoutes!.outputs.routeConfigUrl}/auth' : '' +output KEYCLOAK_REALM_URL string = useKeycloak ? '${httpRoutes!.outputs.routeConfigUrl}/auth/realms/${keycloakRealmName}' : '' +output MCP_SERVER_URL string = useKeycloak ? httpRoutes!.outputs.routeConfigUrl : server.outputs.uri +output KEYCLOAK_ADMIN_CONSOLE string = useKeycloak ? '${httpRoutes!.outputs.routeConfigUrl}/auth/admin' : '' +output KEYCLOAK_DIRECT_URL string = keycloak.outputs.uri diff --git a/infra/main.parameters.json b/infra/main.parameters.json index 9791aa6..8622eeb 100644 --- a/infra/main.parameters.json +++ b/infra/main.parameters.json @@ -26,11 +26,29 @@ "usePrivateLogAnalytics": { "value": "${USE_PRIVATE_LOGANALYTICS=false}" }, + "useKeycloak": { + "value": "${USE_KEYCLOAK=false}" + }, "serverExists": { "value": "${SERVICE_SERVER_RESOURCE_EXISTS=false}" }, "agentExists": { "value": "${SERVICE_AGENT_RESOURCE_EXISTS=false}" + }, + "keycloakExists": { + "value": "${SERVICE_KEYCLOAK_RESOURCE_EXISTS=false}" + }, + "keycloakAdminUser": { + "value": "${KEYCLOAK_ADMIN_USER=admin}" + }, + "keycloakAdminPassword": { + "value": "${KEYCLOAK_ADMIN_PASSWORD}" + }, + "keycloakRealmName": { + "value": "${KEYCLOAK_REALM_NAME=mcp}" + }, + "keycloakMcpServerAudience": { + "value": "${KEYCLOAK_MCP_SERVER_AUDIENCE=mcp-server}" } } } diff --git a/infra/server.bicep b/infra/server.bicep index c917af7..caad60d 100644 --- a/infra/server.bicep +++ b/infra/server.bicep @@ -13,6 +13,62 @@ param cosmosDbAccount string param cosmosDbDatabase string param cosmosDbContainer string param applicationInsightsConnectionString string = '' +param keycloakRealmUrl string = '' +param mcpServerBaseUrl string = '' +param keycloakMcpServerAudience string = 'mcp-server' + +// Base environment variables +var baseEnv = [ + { + name: 'AZURE_OPENAI_CHAT_DEPLOYMENT' + value: openAiDeploymentName + } + { + name: 'AZURE_OPENAI_ENDPOINT' + value: openAiEndpoint + } + { + name: 'RUNNING_IN_PRODUCTION' + value: 'true' + } + { + name: 'AZURE_CLIENT_ID' + value: serverIdentity.properties.clientId + } + { + name: 'AZURE_COSMOSDB_ACCOUNT' + value: cosmosDbAccount + } + { + name: 'AZURE_COSMOSDB_DATABASE' + value: cosmosDbDatabase + } + { + name: 'AZURE_COSMOSDB_CONTAINER' + value: cosmosDbContainer + } + // We typically store sensitive values in secrets, but App Insights connection strings are not considered highly sensitive + { + name: 'APPLICATIONINSIGHTS_CONNECTION_STRING' + value: applicationInsightsConnectionString + } +] + +// Keycloak authentication environment variables (only added when configured) +var keycloakEnv = !empty(keycloakRealmUrl) ? [ + { + name: 'KEYCLOAK_REALM_URL' + value: keycloakRealmUrl + } + { + name: 'MCP_SERVER_BASE_URL' + value: mcpServerBaseUrl + } + { + name: 'KEYCLOAK_MCP_SERVER_AUDIENCE' + value: keycloakMcpServerAudience + } +] : [] resource serverIdentity 'Microsoft.ManagedIdentity/userAssignedIdentities@2023-01-31' = { @@ -31,41 +87,7 @@ module app 'core/host/container-app-upsert.bicep' = { containerAppsEnvironmentName: containerAppsEnvironmentName containerRegistryName: containerRegistryName ingressEnabled: true - env: [ - { - name: 'AZURE_OPENAI_CHAT_DEPLOYMENT' - value: openAiDeploymentName - } - { - name: 'AZURE_OPENAI_ENDPOINT' - value: openAiEndpoint - } - { - name: 'RUNNING_IN_PRODUCTION' - value: 'true' - } - { - name: 'AZURE_CLIENT_ID' - value: serverIdentity.properties.clientId - } - { - name: 'AZURE_COSMOSDB_ACCOUNT' - value: cosmosDbAccount - } - { - name: 'AZURE_COSMOSDB_DATABASE' - value: cosmosDbDatabase - } - { - name: 'AZURE_COSMOSDB_CONTAINER' - value: cosmosDbContainer - } - // We typically store sensitive values in secrets, but App Insights connection strings are not considered highly sensitive - { - name: 'APPLICATIONINSIGHTS_CONNECTION_STRING' - value: applicationInsightsConnectionString - } - ] + env: concat(baseEnv, keycloakEnv) targetPort: 8000 probes: [ { diff --git a/infra/write_env.ps1 b/infra/write_env.ps1 index 6169d03..5f03da8 100644 --- a/infra/write_env.ps1 +++ b/infra/write_env.ps1 @@ -12,4 +12,9 @@ Add-Content -Path $ENV_FILE_PATH -Value "AZURE_COSMOSDB_ACCOUNT=$(azd env get-va Add-Content -Path $ENV_FILE_PATH -Value "AZURE_COSMOSDB_DATABASE=$(azd env get-value AZURE_COSMOSDB_DATABASE)" Add-Content -Path $ENV_FILE_PATH -Value "AZURE_COSMOSDB_CONTAINER=$(azd env get-value AZURE_COSMOSDB_CONTAINER)" Add-Content -Path $ENV_FILE_PATH -Value "APPLICATIONINSIGHTS_CONNECTION_STRING=$(azd env get-value APPLICATIONINSIGHTS_CONNECTION_STRING)" +$KEYCLOAK_REALM_URL = azd env get-value KEYCLOAK_REALM_URL 2>$null +if ($KEYCLOAK_REALM_URL -and $KEYCLOAK_REALM_URL -ne "") { + Add-Content -Path $ENV_FILE_PATH -Value "KEYCLOAK_REALM_URL=$KEYCLOAK_REALM_URL" +} +Add-Content -Path $ENV_FILE_PATH -Value "MCP_SERVER_URL=$(azd env get-value MCP_SERVER_URL)/mcp" Add-Content -Path $ENV_FILE_PATH -Value "API_HOST=azure" diff --git a/infra/write_env.sh b/infra/write_env.sh index 733467d..d4de852 100755 --- a/infra/write_env.sh +++ b/infra/write_env.sh @@ -16,4 +16,9 @@ echo "AZURE_COSMOSDB_ACCOUNT=$(azd env get-value AZURE_COSMOSDB_ACCOUNT)" >> "$E echo "AZURE_COSMOSDB_DATABASE=$(azd env get-value AZURE_COSMOSDB_DATABASE)" >> "$ENV_FILE_PATH" echo "AZURE_COSMOSDB_CONTAINER=$(azd env get-value AZURE_COSMOSDB_CONTAINER)" >> "$ENV_FILE_PATH" echo "APPLICATIONINSIGHTS_CONNECTION_STRING=$(azd env get-value APPLICATIONINSIGHTS_CONNECTION_STRING)" >> "$ENV_FILE_PATH" +KEYCLOAK_REALM_URL=$(azd env get-value KEYCLOAK_REALM_URL 2>/dev/null || echo "") +if [ -n "$KEYCLOAK_REALM_URL" ] && [ "$KEYCLOAK_REALM_URL" != "" ]; then + echo "KEYCLOAK_REALM_URL=${KEYCLOAK_REALM_URL}" >> "$ENV_FILE_PATH" +fi +echo "MCP_SERVER_URL=$(azd env get-value MCP_SERVER_URL)/mcp" >> "$ENV_FILE_PATH" echo "API_HOST=azure" >> "$ENV_FILE_PATH" diff --git a/keycloak/Dockerfile b/keycloak/Dockerfile new file mode 100644 index 0000000..9c1c3b2 --- /dev/null +++ b/keycloak/Dockerfile @@ -0,0 +1,26 @@ +# Keycloak container with pre-built themes and realm import +# Based on: https://www.keycloak.org/server/containers +FROM quay.io/keycloak/keycloak:26.0 AS builder + +# Copy the MCP realm configuration for import +COPY keycloak/realm.json /opt/keycloak/data/import/mcp-realm.json + +# Build Keycloak to pre-compile themes (fixes cache hash mismatch across replicas) +RUN /opt/keycloak/bin/kc.sh build + +# Production image with pre-built themes +FROM quay.io/keycloak/keycloak:26.0 + +# Copy built Keycloak with consistent theme cache hashes +COPY --from=builder /opt/keycloak/ /opt/keycloak/ + +# Expose port 8080 +EXPOSE 8080 + +ENTRYPOINT ["/opt/keycloak/bin/kc.sh"] + +# Start in dev mode with H2 database (still uses pre-built themes) +# --proxy-headers=xforwarded tells Keycloak it's behind a reverse proxy that sets X-Forwarded-* headers +# --hostname-strict=false allows dynamic hostname resolution from proxy headers +# --import-realm imports the MCP realm on startup +CMD ["start-dev", "--http-port=8080", "--proxy-headers=xforwarded", "--hostname-strict=false", "--import-realm"] diff --git a/keycloak/realm.json b/keycloak/realm.json new file mode 100644 index 0000000..16cb5fb --- /dev/null +++ b/keycloak/realm.json @@ -0,0 +1,135 @@ +{ + "realm": "mcp", + "enabled": true, + "registrationAllowed": false, + "loginWithEmailAllowed": true, + "duplicateEmailsAllowed": false, + "resetPasswordAllowed": true, + "editUsernameAllowed": false, + "bruteForceProtected": true, + "accessTokenLifespan": 300, + "ssoSessionIdleTimeout": 1800, + "ssoSessionMaxLifespan": 36000, + "offlineSessionIdleTimeout": 2592000, + "accessCodeLifespan": 60, + "accessCodeLifespanUserAction": 300, + "accessCodeLifespanLogin": 1800, + "clientScopes": [ + { + "name": "mcp-server", + "description": "MCP Server audience scope for DCR clients", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "false" + }, + "protocolMappers": [ + { + "name": "mcp-server-audience", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-mapper", + "consentRequired": false, + "config": { + "included.custom.audience": "mcp-server", + "id.token.claim": "true", + "access.token.claim": "true", + "introspection.token.claim": "true" + } + } + ] + } + ], + "defaultDefaultClientScopes": [ + "mcp-server", + "profile", + "email", + "roles", + "web-origins", + "acr", + "basic" + ], + "components": { + "org.keycloak.services.clientregistration.policy.ClientRegistrationPolicy": [ + { + "name": "Trusted Hosts", + "providerId": "trusted-hosts", + "subType": "anonymous", + "config": { + "host-sending-registration-request-must-match": ["false"], + "client-uris-must-match": ["true"], + "trusted-hosts": ["localhost", "127.0.0.1", "*.azurecontainerapps.io"] + } + }, + { + "name": "Consent Required", + "providerId": "consent-required", + "subType": "anonymous", + "config": {} + }, + { + "name": "Full Scope Disabled", + "providerId": "scope", + "subType": "anonymous", + "config": {} + }, + { + "name": "Max Clients Limit", + "providerId": "max-clients", + "subType": "anonymous", + "config": { + "max-clients": ["200"] + } + }, + { + "name": "Allowed Protocol Mapper Types", + "providerId": "allowed-protocol-mappers", + "subType": "anonymous", + "config": { + "allowed-protocol-mapper-types": [ + "oidc-usermodel-attribute-mapper", + "oidc-full-name-mapper", + "oidc-address-mapper", + "saml-user-attribute-idp-mapper", + "oidc-usermodel-property-mapper", + "saml-role-idp-mapper", + "saml-user-property-idp-mapper", + "oidc-sha256-pairwise-sub-mapper" + ] + } + }, + { + "name": "Allowed Client Scopes", + "providerId": "allowed-client-templates", + "subType": "anonymous", + "config": { + "allow-default-scopes": ["true"] + } + }, + { + "name": "Allowed Protocol Mapper Types", + "providerId": "allowed-protocol-mappers", + "subType": "authenticated", + "config": { + "allowed-protocol-mapper-types": [ + "saml-user-property-idp-mapper", + "oidc-sha256-pairwise-sub-mapper", + "oidc-usermodel-attribute-mapper", + "saml-role-idp-mapper", + "oidc-full-name-mapper", + "oidc-usermodel-property-mapper", + "saml-user-attribute-idp-mapper", + "oidc-address-mapper" + ] + } + }, + { + "name": "Allowed Client Scopes", + "providerId": "allowed-client-templates", + "subType": "authenticated", + "config": { + "allow-default-scopes": ["true"] + } + } + ] + } +} diff --git a/servers/deployed_mcp.py b/servers/deployed_mcp.py index b5551b1..6e400d5 100644 --- a/servers/deployed_mcp.py +++ b/servers/deployed_mcp.py @@ -14,7 +14,10 @@ from azure.monitor.opentelemetry import configure_azure_monitor from dotenv import load_dotenv from fastmcp import FastMCP +from fastmcp.server.auth import RemoteAuthProvider +from fastmcp.server.auth.providers.jwt import JWTVerifier from opentelemetry.instrumentation.starlette import StarletteInstrumentor +from pydantic import AnyHttpUrl from starlette.responses import JSONResponse try: @@ -33,12 +36,12 @@ # Configure OpenTelemetry tracing, either via Azure Monitor or Logfire # We don't support both at the same time due to potential conflicts with tracer providers +settings.tracing_implementation = "opentelemetry" # Ensure Azure SDK always uses OpenTelemetry tracing if os.getenv("APPLICATIONINSIGHTS_CONNECTION_STRING"): logger.info("Setting up Azure Monitor instrumentation") configure_azure_monitor() elif os.getenv("LOGFIRE_PROJECT_NAME"): logger.info("Setting up Logfire instrumentation") - settings.tracing_implementation = "opentelemetry" # Configure Azure SDK to use OpenTelemetry tracing logfire.configure(service_name="expenses-mcp", send_to_logfire=True) # Cosmos DB configuration from environment variables @@ -47,6 +50,27 @@ AZURE_COSMOSDB_CONTAINER = os.environ["AZURE_COSMOSDB_CONTAINER"] AZURE_CLIENT_ID = os.getenv("AZURE_CLIENT_ID", "") +# Optional: Keycloak authentication (enabled if KEYCLOAK_REALM_URL is set) +KEYCLOAK_REALM_URL = os.getenv("KEYCLOAK_REALM_URL") +MCP_SERVER_BASE_URL = os.getenv("MCP_SERVER_BASE_URL") +KEYCLOAK_MCP_SERVER_AUDIENCE = os.getenv("KEYCLOAK_MCP_SERVER_AUDIENCE", "mcp-server") + +auth = None +if KEYCLOAK_REALM_URL and MCP_SERVER_BASE_URL: + token_verifier = JWTVerifier( + jwks_uri=f"{KEYCLOAK_REALM_URL}/protocol/openid-connect/certs", + issuer=KEYCLOAK_REALM_URL, + audience=KEYCLOAK_MCP_SERVER_AUDIENCE, + ) + auth = RemoteAuthProvider( + token_verifier=token_verifier, + authorization_servers=[AnyHttpUrl(KEYCLOAK_REALM_URL)], + base_url=MCP_SERVER_BASE_URL, + ) + logger.info(f"Keycloak auth enabled: realm={KEYCLOAK_REALM_URL}, audience={KEYCLOAK_MCP_SERVER_AUDIENCE}") +else: + logger.info("No authentication configured (set KEYCLOAK_REALM_URL and MCP_SERVER_BASE_URL to enable)") + # Configure Cosmos DB client and container if RUNNING_IN_PRODUCTION and AZURE_CLIENT_ID: credential = ManagedIdentityCredential(client_id=AZURE_CLIENT_ID) @@ -61,7 +85,7 @@ cosmos_container = cosmos_db.get_container_client(AZURE_COSMOSDB_CONTAINER) logger.info(f"Connected to Cosmos DB: {AZURE_COSMOSDB_ACCOUNT}") -mcp = FastMCP("Expenses Tracker") +mcp = FastMCP("Expenses Tracker", auth=auth) mcp.add_middleware(OpenTelemetryMiddleware("ExpensesMCP"))