From a0b7d125757f4d1b37ca6df84c77fb1e37e13e80 Mon Sep 17 00:00:00 2001 From: Gwyneth Pena-Siguenza Date: Thu, 4 Dec 2025 11:07:33 -0500 Subject: [PATCH 01/16] Add Keycloak OAuth authentication for MCP server - Add Keycloak container with pre-configured MCP realm (DCR enabled) - Add keycloak_deployed_mcp.py with RemoteAuthProvider + JWTVerifier - Add separate aca-noauth.bicep for non-authenticated MCP server - Add LangChain agent example with Keycloak token acquisition - Configure HTTP routes for multi-container deployment - Scale Keycloak to 1 replica (fixes theme cache hash mismatch) - Use direct Keycloak URL for issuer validation Tested: DCR, token endpoint, MCP auth all working via LangChain agent --- .vscode/mcp.json | 68 ++++++------ agents/langchainv1_keycloak.py | 169 ++++++++++++++++++++++++++++ azure.yaml | 18 ++- infra/Dockerfile.keycloak | 15 +++ infra/aca-noauth.bicep | 71 ++++++++++++ infra/aca.bicep | 18 +++ infra/http-routes.bicep | 73 +++++++++++++ infra/keycloak-realm.json | 135 +++++++++++++++++++++++ infra/keycloak.bicep | 111 +++++++++++++++++++ infra/main.bicep | 95 ++++++++++++++++ infra/main.parameters.json | 18 +++ scripts/keycloak_setup.sh | 56 ++++++++++ servers/Dockerfile | 2 +- servers/Dockerfile.noauth | 42 +++++++ servers/keycloak_deployed_mcp.py | 182 +++++++++++++++++++++++++++++++ 15 files changed, 1039 insertions(+), 34 deletions(-) create mode 100644 agents/langchainv1_keycloak.py create mode 100644 infra/Dockerfile.keycloak create mode 100644 infra/aca-noauth.bicep create mode 100644 infra/http-routes.bicep create mode 100644 infra/keycloak-realm.json create mode 100644 infra/keycloak.bicep create mode 100755 scripts/keycloak_setup.sh create mode 100644 servers/Dockerfile.noauth create mode 100644 servers/keycloak_deployed_mcp.py diff --git a/.vscode/mcp.json b/.vscode/mcp.json index 0028ee2..46cb1e7 100644 --- a/.vscode/mcp.json +++ b/.vscode/mcp.json @@ -1,33 +1,37 @@ { - "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" + ] + }, + "auth-expenses": { + "url": "https://mcp-gps-key-n7pc5ej-ca.ashymeadow-ae27942e.eastus2.azurecontainerapps.io/mcp", + "type": "http" + } + }, + "inputs": [] +} \ No newline at end of file diff --git a/agents/langchainv1_keycloak.py b/agents/langchainv1_keycloak.py new file mode 100644 index 0000000..6a7bca9 --- /dev/null +++ b/agents/langchainv1_keycloak.py @@ -0,0 +1,169 @@ +""" +LangChain agent that connects to Keycloak-protected MCP server. + +This script demonstrates: +1. Getting an OAuth token from Keycloak via client_credentials grant +2. Connecting to the MCP server with Bearer token authentication +3. Using MCP tools through LangChain + +Usage: + python agents/langchainv1_keycloak.py +""" + +import asyncio +import logging +import os +from datetime import datetime + +import azure.identity +import httpx +from dotenv import load_dotenv +from langchain.agents import create_agent +from langchain_core.messages import HumanMessage, SystemMessage +from langchain_mcp_adapters.client import MultiServerMCPClient +from langchain_openai import ChatOpenAI +from pydantic import SecretStr +from rich.logging import RichHandler + +# Configure logging +logging.basicConfig(level=logging.INFO, format="%(message)s", datefmt="[%X]", handlers=[RichHandler()]) +logger = logging.getLogger("langchain_keycloak") + +# Load environment variables +load_dotenv(override=True) + +# MCP Server and Keycloak configuration +MCP_SERVER_URL = os.getenv( + "MCP_SERVER_URL", + "https://mcp-gps-key-n7pc5ej-ca.ashymeadow-ae27942e.eastus2.azurecontainerapps.io/mcp" +) +KEYCLOAK_REALM_URL = os.getenv( + "KEYCLOAK_REALM_URL", + "https://mcp-gps-key-n7pc5ej-kc.ashymeadow-ae27942e.eastus2.azurecontainerapps.io/realms/mcp" +) + +# Test client credentials (from DCR) +CLIENT_ID = os.getenv("TEST_CLIENT_ID", "4f061e11-d30c-4978-bb2e-2164ce26cc51") +CLIENT_SECRET = os.getenv("TEST_CLIENT_SECRET", "UKZLK1eEga9FfZTFsqTHXK70ubq0PAJu") + +# Configure language model based on API_HOST +API_HOST = os.getenv("API_HOST", "github") + +if API_HOST == "azure": + token_provider = azure.identity.get_bearer_token_provider( + azure.identity.DefaultAzureCredential(), "https://cognitiveservices.azure.com/.default" + ) + base_model = ChatOpenAI( + model=os.environ.get("AZURE_OPENAI_CHAT_DEPLOYMENT"), + base_url=os.environ["AZURE_OPENAI_ENDPOINT"] + "/openai/v1/", + api_key=token_provider, + ) +elif API_HOST == "github": + base_model = ChatOpenAI( + model=os.getenv("GITHUB_MODEL", "gpt-4o"), + base_url="https://models.inference.ai.azure.com", + api_key=SecretStr(os.environ["GITHUB_TOKEN"]), + ) +elif API_HOST == "ollama": + base_model = ChatOpenAI( + model=os.environ.get("OLLAMA_MODEL", "llama3.1"), + base_url=os.environ.get("OLLAMA_ENDPOINT", "http://localhost:11434/v1"), + api_key=SecretStr(os.environ["OLLAMA_API_KEY"]), + ) +else: + base_model = ChatOpenAI(model=os.getenv("OPENAI_MODEL", "gpt-4o-mini")) + + +async def get_keycloak_token() -> str: + """Get an access token from Keycloak using client_credentials grant.""" + token_url = f"{KEYCLOAK_REALM_URL}/protocol/openid-connect/token" + + logger.info(f"🔑 Getting access token from Keycloak...") + + async with httpx.AsyncClient() as client: + response = await 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 Exception(f"Failed to get token: {response.status_code} - {response.text}") + + token_data = response.json() + access_token = token_data["access_token"] + expires_in = token_data.get("expires_in", "unknown") + + logger.info(f"✅ Got access token (expires in {expires_in}s)") + return access_token + + +async def run_agent() -> None: + """ + Run the agent to process expense-related queries using authenticated MCP tools. + """ + # First, get OAuth token from Keycloak + access_token = await get_keycloak_token() + + logger.info(f"📡 Connecting to MCP server: {MCP_SERVER_URL}") + + # Initialize MCP client with Bearer token auth + client = MultiServerMCPClient( + { + "expenses": { + "url": MCP_SERVER_URL, + "transport": "streamable_http", + "headers": { + "Authorization": f"Bearer {access_token}", + }, + } + } + ) + + # Get tools and create agent + logger.info("🔧 Getting available tools...") + tools = await client.get_tools() + logger.info(f"✅ Found {len(tools)} tools: {[t.name for t in tools]}") + + agent = create_agent(base_model, tools) + + # Prepare query with context + today = datetime.now().strftime("%Y-%m-%d") + user_query = "Add an expense: yesterday I bought a laptop for $1200 using my visa." + + logger.info(f"💬 User query: {user_query}") + + # Invoke agent + response = await agent.ainvoke( + {"messages": [SystemMessage(content=f"Today's date is {today}."), HumanMessage(content=user_query)]} + ) + + # Display result + logger.info("=" * 60) + logger.info("📊 Agent Response:") + logger.info("=" * 60) + + final_message = response["messages"][-1] + print(final_message.content) + + +async def main(): + print("=" * 60) + print("LangChain Agent with Keycloak-Protected MCP Server") + print("=" * 60) + print(f"\nConfiguration:") + print(f" MCP Server: {MCP_SERVER_URL}") + print(f" Keycloak: {KEYCLOAK_REALM_URL}") + print(f" Client ID: {CLIENT_ID[:20]}...") + print(f" LLM Host: {API_HOST}") + print() + + await run_agent() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/azure.yaml b/azure.yaml index 850c8aa..9c8c5e3 100644 --- a/azure.yaml +++ b/azure.yaml @@ -4,7 +4,7 @@ name: python-mcp-demo metadata: template: python-mcp-demo@0.0.1 services: - # Not using remoteBuild due to private endpoint usage + # MCP Server WITH Keycloak authentication (keycloak_deployed_mcp.py) aca: project: . language: docker @@ -12,6 +12,22 @@ services: docker: path: ./servers/Dockerfile context: . + # MCP Server WITHOUT authentication (deployed_mcp.py) + mcpnoauth: + project: . + language: docker + host: containerapp + docker: + path: ./servers/Dockerfile.noauth + context: . + # Keycloak identity provider with MCP realm pre-configured + keycloak: + project: . + language: docker + host: containerapp + docker: + path: ./infra/Dockerfile.keycloak + context: . hooks: postprovision: posix: diff --git a/infra/Dockerfile.keycloak b/infra/Dockerfile.keycloak new file mode 100644 index 0000000..1390e84 --- /dev/null +++ b/infra/Dockerfile.keycloak @@ -0,0 +1,15 @@ +FROM quay.io/keycloak/keycloak:26.0 + +# Copy the MCP realm configuration for import on startup +COPY infra/keycloak-realm.json /opt/keycloak/data/import/mcp-realm.json + +# Expose port 8080 +EXPOSE 8080 + +ENTRYPOINT ["/opt/keycloak/bin/kc.sh"] + +# Start in dev mode with H2 database +# --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/infra/aca-noauth.bicep b/infra/aca-noauth.bicep new file mode 100644 index 0000000..b332e37 --- /dev/null +++ b/infra/aca-noauth.bicep @@ -0,0 +1,71 @@ +// MCP Server WITHOUT Keycloak authentication (deployed_mcp.py) +param name string +param location string = resourceGroup().location +param tags object = {} + +param identityName string +param containerAppsEnvironmentName string +param containerRegistryName string +param serviceName string = 'mcpnoauth' +param exists bool +param openAiDeploymentName string +param openAiEndpoint string +param cosmosDbAccount string +param cosmosDbDatabase string +param cosmosDbContainer string + +resource mcpNoAuthIdentity '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: mcpNoAuthIdentity.name + exists: exists + 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: mcpNoAuthIdentity.properties.clientId + } + { + name: 'AZURE_COSMOSDB_ACCOUNT' + value: cosmosDbAccount + } + { + name: 'AZURE_COSMOSDB_DATABASE' + value: cosmosDbDatabase + } + { + name: 'AZURE_COSMOSDB_CONTAINER' + value: cosmosDbContainer + } + ] + targetPort: 8000 + } +} + +output identityPrincipalId string = mcpNoAuthIdentity.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/aca.bicep b/infra/aca.bicep index 7a63624..d100454 100644 --- a/infra/aca.bicep +++ b/infra/aca.bicep @@ -13,6 +13,11 @@ param cosmosDbAccount string param cosmosDbDatabase string param cosmosDbContainer string +// Keycloak authentication parameters +param keycloakRealmUrl string +param mcpServerBaseUrl string +param mcpServerAudience string = 'mcp-server' + resource acaIdentity 'Microsoft.ManagedIdentity/userAssignedIdentities@2023-01-31' = { name: identityName location: location @@ -58,6 +63,19 @@ module app 'core/host/container-app-upsert.bicep' = { name: 'AZURE_COSMOSDB_CONTAINER' value: cosmosDbContainer } + // Keycloak authentication environment variables + { + name: 'KEYCLOAK_REALM_URL' + value: keycloakRealmUrl + } + { + name: 'MCP_SERVER_BASE_URL' + value: mcpServerBaseUrl + } + { + name: 'MCP_SERVER_AUDIENCE' + value: mcpServerAudience + } ] targetPort: 8000 } diff --git a/infra/http-routes.bicep b/infra/http-routes.bicep new file mode 100644 index 0000000..b6e3af5 --- /dev/null +++ b/infra/http-routes.bicep @@ -0,0 +1,73 @@ +param containerAppsEnvironmentName string +param mcpServerAppName string +param keycloakAppName string +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 + } + ] + } + ] + } + dependsOn: [ + mcpServerApp + keycloakApp + ] +} + +output fqdn string = httpRouteConfig.properties.fqdn +output routeConfigUrl string = 'https://${httpRouteConfig.properties.fqdn}' diff --git a/infra/keycloak-realm.json b/infra/keycloak-realm.json new file mode 100644 index 0000000..16cb5fb --- /dev/null +++ b/infra/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/infra/keycloak.bicep b/infra/keycloak.bicep new file mode 100644 index 0000000..4a53bdf --- /dev/null +++ b/infra/keycloak.bicep @@ -0,0 +1,111 @@ +param name string +param location string = resourceGroup().location +param tags object = {} + +param containerAppsEnvironmentName string +param containerRegistryName string +param serviceName string = 'keycloak' +param keycloakAdminUser string = 'admin' +@secure() +param keycloakAdminPassword string +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 ac54d34..1e769f9 100644 --- a/infra/main.bicep +++ b/infra/main.bicep @@ -13,6 +13,8 @@ param location string param principalId string = '' param acaExists bool = false +param mcpnoauthExists bool = false +param keycloakExists bool = false @description('Location for the OpenAI resource group') @allowed([ @@ -44,6 +46,19 @@ param useVnet bool = false @description('Flag to enable or disable public ingress') param usePrivateIngress bool = false +@description('Keycloak admin username') +param keycloakAdminUser string = 'admin' + +@secure() +@description('Keycloak admin password - must be set via azd env') +param keycloakAdminPassword string + +@description('Keycloak realm name for MCP authentication') +param keycloakRealmName string = 'mcp' + +@description('Audience claim for MCP server tokens') +param mcpServerAudience string = 'mcp-server' + var resourceToken = toLower(uniqueString(subscription().id, name, location)) var tags = { 'azd-env-name': name } @@ -670,6 +685,59 @@ module aca 'aca.bicep' = { cosmosDbDatabase: cosmosDbDatabaseName cosmosDbContainer: cosmosDbContainerName exists: acaExists + // Keycloak authentication configuration + // Use direct Keycloak URL for issuer validation (tokens are issued with this URL) + keycloakRealmUrl: '${keycloak.outputs.uri}/realms/${keycloakRealmName}' + mcpServerBaseUrl: 'https://mcproutes.${containerApps.outputs.defaultDomain}' + mcpServerAudience: mcpServerAudience + } +} + +// MCP Server without authentication (deployed_mcp.py) +module mcpnoauth 'aca-noauth.bicep' = { + name: 'mcpnoauth' + scope: resourceGroup + params: { + name: replace('${take(prefix,19)}-na', '--', '-') + location: location + tags: tags + identityName: '${prefix}-id-mcpnoauth' + containerAppsEnvironmentName: containerApps.outputs.environmentName + containerRegistryName: containerApps.outputs.registryName + openAiDeploymentName: openAiDeploymentName + openAiEndpoint: openAi.outputs.endpoint + cosmosDbAccount: cosmosDb.outputs.name + cosmosDbDatabase: cosmosDbDatabaseName + cosmosDbContainer: cosmosDbContainerName + exists: mcpnoauthExists + } +} + +// Keycloak authentication server +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: keycloakAdminPassword + exists: keycloakExists + } +} + +// HTTP Route configuration for rule-based routing +module httpRoutes 'http-routes.bicep' = { + name: 'http-routes' + scope: resourceGroup + params: { + containerAppsEnvironmentName: containerApps.outputs.environmentName + mcpServerAppName: aca.outputs.name + keycloakAppName: keycloak.outputs.name } } @@ -715,6 +783,17 @@ module cosmosDbRoleBackend 'core/security/documentdb-sql-role.bicep' = { } } +// Cosmos DB Data Contributor role for mcpnoauth (no-auth MCP server) +module cosmosDbRoleMcpNoAuth 'core/security/documentdb-sql-role.bicep' = { + scope: resourceGroup + name: 'cosmosdb-role-mcpnoauth' + params: { + databaseAccountName: cosmosDb.outputs.name + principalId: mcpnoauth.outputs.identityPrincipalId + roleDefinitionId: '/${subscription().id}/resourceGroups/${resourceGroup.name}/providers/Microsoft.DocumentDB/databaseAccounts/${cosmosDb.outputs.name}/sqlRoleDefinitions/00000000-0000-0000-0000-000000000002' + } +} + output AZURE_LOCATION string = location output AZURE_TENANT_ID string = tenant().tenantId output AZURE_RESOURCE_GROUP string = resourceGroup.name @@ -730,6 +809,14 @@ 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_KEYCLOAK_NAME string = keycloak.outputs.name +output SERVICE_KEYCLOAK_URI string = keycloak.outputs.uri +output SERVICE_KEYCLOAK_IMAGE_NAME string = keycloak.outputs.imageName + +output SERVICE_MCPNOAUTH_NAME string = mcpnoauth.outputs.name +output SERVICE_MCPNOAUTH_URI string = mcpnoauth.outputs.uri +output SERVICE_MCPNOAUTH_IMAGE_NAME string = mcpnoauth.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 @@ -738,3 +825,11 @@ output AZURE_COSMOSDB_ACCOUNT string = cosmosDb.outputs.name output AZURE_COSMOSDB_ENDPOINT string = cosmosDb.outputs.endpoint output AZURE_COSMOSDB_DATABASE string = cosmosDbDatabaseName output AZURE_COSMOSDB_CONTAINER string = cosmosDbContainerName + +// Keycloak and MCP Server routing outputs +output HTTP_ROUTES_URL string = httpRoutes.outputs.routeConfigUrl +output KEYCLOAK_URL string = '${httpRoutes.outputs.routeConfigUrl}/auth' +output KEYCLOAK_REALM_URL string = '${httpRoutes.outputs.routeConfigUrl}/auth/realms/${keycloakRealmName}' +output MCP_SERVER_URL string = httpRoutes.outputs.routeConfigUrl +output KEYCLOAK_ADMIN_CONSOLE string = '${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 eecddb6..d2c02d1 100644 --- a/infra/main.parameters.json +++ b/infra/main.parameters.json @@ -22,6 +22,24 @@ }, "acaExists": { "value": "${SERVICE_ACA_RESOURCE_EXISTS=false}" + }, + "mcpnoauthExists": { + "value": "${SERVICE_MCPNOAUTH_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}" + }, + "mcpServerAudience": { + "value": "${MCP_SERVER_AUDIENCE=mcp-server}" } } } \ No newline at end of file diff --git a/scripts/keycloak_setup.sh b/scripts/keycloak_setup.sh new file mode 100755 index 0000000..cd564d9 --- /dev/null +++ b/scripts/keycloak_setup.sh @@ -0,0 +1,56 @@ +#!/bin/bash +set -e + +BASE_URL="https://mcproutes.niceground-98809e7b.eastus2.azurecontainerapps.io" + +echo "Getting admin token..." +ACCESS_TOKEN=$(curl -s -X POST "$BASE_URL/auth/realms/master/protocol/openid-connect/token" \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "username=admin" \ + -d "password=pythonmcp" \ + -d "grant_type=password" \ + -d "client_id=admin-cli" | jq -r '.access_token') + +if [ "$ACCESS_TOKEN" == "null" ] || [ -z "$ACCESS_TOKEN" ]; then + echo "Failed to get token" + exit 1 +fi +echo "Token obtained!" + +SCOPE_ID="fa8082ab-86f9-4ac9-961f-15b2f1a32482" + +echo "Adding mcp-server as default scope..." +curl -s -X PUT "$BASE_URL/auth/admin/realms/mcp/default-default-client-scopes/$SCOPE_ID" \ + -H "Authorization: Bearer $ACCESS_TOKEN" +echo "Done!" + +echo "Getting Trusted Hosts policy ID..." +TRUSTED_HOSTS_ID=$(curl -s "$BASE_URL/auth/admin/realms/mcp/components?type=org.keycloak.services.clientregistration.policy.ClientRegistrationPolicy" \ + -H "Authorization: Bearer $ACCESS_TOKEN" | jq -r '.[] | select(.name=="Trusted Hosts") | .id') + +echo "Trusted Hosts ID: $TRUSTED_HOSTS_ID" + +echo "Updating Trusted Hosts policy..." +curl -s -X PUT "$BASE_URL/auth/admin/realms/mcp/components/$TRUSTED_HOSTS_ID" \ + -H "Authorization: Bearer $ACCESS_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "id":"'"$TRUSTED_HOSTS_ID"'", + "name":"Trusted Hosts", + "providerId":"trusted-hosts", + "providerType":"org.keycloak.services.clientregistration.policy.ClientRegistrationPolicy", + "parentId":"mcp", + "config":{ + "host-sending-registration-request-must-match":["false"], + "client-uris-must-match":["false"], + "trusted-hosts":["*"] + } + }' +echo "Done!" + +echo "" +echo "=== Testing DCR ===" +curl -s -X POST "$BASE_URL/auth/realms/mcp/clients-registrations/openid-connect" \ + -H "Content-Type: application/json" \ + -d '{"client_name":"test-mcp-client","redirect_uris":["http://localhost:8080/callback"]}' | jq '.' +EOF \ No newline at end of file diff --git a/servers/Dockerfile b/servers/Dockerfile index f0fa239..26ca581 100644 --- a/servers/Dockerfile +++ b/servers/Dockerfile @@ -38,4 +38,4 @@ ENV PATH="/code/.venv/bin:$PATH" EXPOSE 8000 -ENTRYPOINT ["uvicorn", "deployed_mcp:app", "--host", "0.0.0.0", "--port", "8000"] +ENTRYPOINT ["uvicorn", "keycloak_deployed_mcp:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/servers/Dockerfile.noauth b/servers/Dockerfile.noauth new file mode 100644 index 0000000..21f1cf0 --- /dev/null +++ b/servers/Dockerfile.noauth @@ -0,0 +1,42 @@ +# ------------------- 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 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/servers +USER app + +ENV PATH="/code/.venv/bin:$PATH" + +EXPOSE 8000 + +# MCP server WITHOUT Keycloak authentication +ENTRYPOINT ["uvicorn", "deployed_mcp:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/servers/keycloak_deployed_mcp.py b/servers/keycloak_deployed_mcp.py new file mode 100644 index 0000000..def65bc --- /dev/null +++ b/servers/keycloak_deployed_mcp.py @@ -0,0 +1,182 @@ +import logging +import os +import uuid +from datetime import date +from enum import Enum +from typing import Annotated + +from azure.cosmos.aio import CosmosClient +from azure.identity.aio import DefaultAzureCredential, ManagedIdentityCredential +from dotenv import load_dotenv +from fastmcp import FastMCP +from fastmcp.server.auth import RemoteAuthProvider +from fastmcp.server.auth.providers.jwt import JWTVerifier +from pydantic import AnyHttpUrl + +load_dotenv(override=True) + +logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(message)s") +logger = logging.getLogger("ExpensesMCP") + +# Cosmos DB configuration from environment variables +AZURE_COSMOSDB_ACCOUNT = os.environ["AZURE_COSMOSDB_ACCOUNT"] +AZURE_COSMOSDB_DATABASE = os.environ["AZURE_COSMOSDB_DATABASE"] +AZURE_COSMOSDB_CONTAINER = os.environ["AZURE_COSMOSDB_CONTAINER"] +RUNNING_IN_PRODUCTION = os.getenv("RUNNING_IN_PRODUCTION", "false").lower() == "true" +AZURE_CLIENT_ID = os.getenv("AZURE_CLIENT_ID", "") + +# Keycloak authentication configuration +KEYCLOAK_REALM_URL = os.environ["KEYCLOAK_REALM_URL"] # e.g., https://routeconfig..azurecontainerapps.io/auth/realms/mcp +MCP_SERVER_BASE_URL = os.environ["MCP_SERVER_BASE_URL"] # e.g., https://routeconfig..azurecontainerapps.io +MCP_SERVER_AUDIENCE = os.getenv("MCP_SERVER_AUDIENCE", "mcp-server") + +# Configure Keycloak JWT verification +token_verifier = JWTVerifier( + jwks_uri=f"{KEYCLOAK_REALM_URL}/protocol/openid-connect/certs", + issuer=KEYCLOAK_REALM_URL, + audience=MCP_SERVER_AUDIENCE, +) + +# Configure RemoteAuthProvider for DCR support +auth = RemoteAuthProvider( + token_verifier=token_verifier, + authorization_servers=[AnyHttpUrl(KEYCLOAK_REALM_URL)], + base_url=MCP_SERVER_BASE_URL, +) +logger.info(f"Keycloak auth configured: realm={KEYCLOAK_REALM_URL}, audience={MCP_SERVER_AUDIENCE}") + +# Configure Cosmos DB client and container +if RUNNING_IN_PRODUCTION and AZURE_CLIENT_ID: + credential = ManagedIdentityCredential(client_id=AZURE_CLIENT_ID) +else: + credential = DefaultAzureCredential() + +cosmos_client = CosmosClient( + url=f"https://{AZURE_COSMOSDB_ACCOUNT}.documents.azure.com:443/", + credential=credential, +) +cosmos_db = cosmos_client.get_database_client(AZURE_COSMOSDB_DATABASE) +cosmos_container = cosmos_db.get_container_client(AZURE_COSMOSDB_CONTAINER) +logger.info(f"Connected to Cosmos DB: {AZURE_COSMOSDB_ACCOUNT}") + +mcp = FastMCP("Expenses Tracker", auth=auth) + + +class PaymentMethod(Enum): + AMEX = "amex" + VISA = "visa" + CASH = "cash" + + +class Category(Enum): + FOOD = "food" + TRANSPORT = "transport" + ENTERTAINMENT = "entertainment" + SHOPPING = "shopping" + GADGET = "gadget" + OTHER = "other" + + +@mcp.tool +async def add_expense( + date: Annotated[date, "Date of the expense in YYYY-MM-DD format"], + amount: Annotated[float, "Positive numeric amount of the expense"], + category: Annotated[Category, "Category label"], + description: Annotated[str, "Human-readable description of the expense"], + payment_method: Annotated[PaymentMethod, "Payment method used"], +): + """Add a new expense to Cosmos DB.""" + if amount <= 0: + return "Error: Amount must be positive" + + date_iso = date.isoformat() + logger.info(f"Adding expense: ${amount} for {description} on {date_iso}") + + try: + expense_id = str(uuid.uuid4()) + expense_item = { + "id": expense_id, + "date": date_iso, + "amount": amount, + "category": category.value, + "description": description, + "payment_method": payment_method.value, + } + + await cosmos_container.create_item(body=expense_item) + return f"Successfully added expense: ${amount} for {description} on {date_iso}" + + except Exception as e: + logger.error(f"Error adding expense: {str(e)}") + return f"Error: Unable to add expense - {str(e)}" + + +@mcp.resource("resource://expenses") +async def get_expenses_data(): + """Get raw expense data from Cosmos DB.""" + logger.info("Expenses data accessed") + + try: + query = "SELECT * FROM c ORDER BY c.date DESC" + expenses_data = [] + + async for item in cosmos_container.query_items(query=query, enable_cross_partition_query=True): + expenses_data.append(item) + + if not expenses_data: + return "No expenses found." + + csv_content = f"Expense data ({len(expenses_data)} entries):\n\n" + for expense in expenses_data: + csv_content += ( + f"Date: {expense.get('date', 'N/A')}, " + f"Amount: ${expense.get('amount', 0)}, " + f"Category: {expense.get('category', 'N/A')}, " + f"Description: {expense.get('description', 'N/A')}, " + f"Payment: {expense.get('payment_method', 'N/A')}\n" + ) + + return csv_content + + except Exception as e: + logger.error(f"Error reading expenses: {str(e)}") + return f"Error: Unable to retrieve expense data - {str(e)}" + + +@mcp.prompt +def analyze_spending_prompt( + category: str | None = None, start_date: str | None = None, end_date: str | None = None +) -> str: + """Generate a prompt to analyze spending patterns with optional filters.""" + + filters = [] + if category: + filters.append(f"Category: {category}") + if start_date: + filters.append(f"From: {start_date}") + if end_date: + filters.append(f"To: {end_date}") + + filter_text = f" ({', '.join(filters)})" if filters else "" + + return f""" + Please analyze my spending patterns{filter_text} and provide: + + 1. Total spending breakdown by category + 2. Average daily/weekly spending + 3. Most expensive single transaction + 4. Payment method distribution + 5. Spending trends or unusual patterns + 6. Recommendations for budget optimization + + Use the expense data to generate actionable insights. + """ + + +# ASGI application for uvicorn +app = mcp.http_app() + + +if __name__ == "__main__": + logger.info("MCP Expenses server starting (HTTP mode on port 8000)") + mcp.run(transport="http", host="0.0.0.0", port=8000) From d6663a3f767dc6ac98047299b5e6f81136ae21c3 Mon Sep 17 00:00:00 2001 From: Gwyneth Pena-Siguenza Date: Thu, 4 Dec 2025 11:29:58 -0500 Subject: [PATCH 02/16] feat: use DCR dynamically and add multi-stage Dockerfile build - Update langchainv1_keycloak.py to use Dynamic Client Registration instead of hardcoded client credentials - Add register_client_via_dcr() to create clients at runtime - Remove TEST_CLIENT_ID and TEST_CLIENT_SECRET constants - Update Dockerfile.keycloak to multi-stage build with kc.sh build for consistent theme cache hashes across replicas --- agents/langchainv1_keycloak.py | 56 +++++++++++++++++++++++++--------- infra/Dockerfile.keycloak | 15 +++++++-- 2 files changed, 53 insertions(+), 18 deletions(-) diff --git a/agents/langchainv1_keycloak.py b/agents/langchainv1_keycloak.py index 6a7bca9..72451c7 100644 --- a/agents/langchainv1_keycloak.py +++ b/agents/langchainv1_keycloak.py @@ -2,9 +2,10 @@ LangChain agent that connects to Keycloak-protected MCP server. This script demonstrates: -1. Getting an OAuth token from Keycloak via client_credentials grant -2. Connecting to the MCP server with Bearer token authentication -3. Using MCP tools through LangChain +1. Dynamic Client Registration (DCR) with Keycloak +2. Getting an OAuth token using the registered client +3. Connecting to the MCP server with Bearer token authentication +4. Using MCP tools through LangChain Usage: python agents/langchainv1_keycloak.py @@ -42,10 +43,6 @@ "https://mcp-gps-key-n7pc5ej-kc.ashymeadow-ae27942e.eastus2.azurecontainerapps.io/realms/mcp" ) -# Test client credentials (from DCR) -CLIENT_ID = os.getenv("TEST_CLIENT_ID", "4f061e11-d30c-4978-bb2e-2164ce26cc51") -CLIENT_SECRET = os.getenv("TEST_CLIENT_SECRET", "UKZLK1eEga9FfZTFsqTHXK70ubq0PAJu") - # Configure language model based on API_HOST API_HOST = os.getenv("API_HOST", "github") @@ -74,19 +71,47 @@ base_model = ChatOpenAI(model=os.getenv("OPENAI_MODEL", "gpt-4o-mini")) -async def get_keycloak_token() -> str: +async def register_client_via_dcr() -> tuple[str, str]: + """Register a new client dynamically using Keycloak's DCR endpoint.""" + dcr_url = f"{KEYCLOAK_REALM_URL}/clients-registrations/openid-connect" + + logger.info("📝 Registering client via DCR...") + + async with httpx.AsyncClient() as client: + response = await client.post( + dcr_url, + json={ + "client_name": f"langchain-agent-{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 Exception(f"DCR failed: {response.status_code} - {response.text}") + + data = response.json() + client_id = data["client_id"] + client_secret = data["client_secret"] + + logger.info(f"✅ Registered client: {client_id[:20]}...") + return client_id, client_secret + + +async def get_keycloak_token(client_id: str, client_secret: str) -> str: """Get an access token from Keycloak using client_credentials grant.""" token_url = f"{KEYCLOAK_REALM_URL}/protocol/openid-connect/token" - logger.info(f"🔑 Getting access token from Keycloak...") + logger.info("🔑 Getting access token from Keycloak...") async with httpx.AsyncClient() as client: response = await client.post( token_url, data={ "grant_type": "client_credentials", - "client_id": CLIENT_ID, - "client_secret": CLIENT_SECRET, + "client_id": client_id, + "client_secret": client_secret, }, headers={"Content-Type": "application/x-www-form-urlencoded"}, ) @@ -106,8 +131,9 @@ async def run_agent() -> None: """ Run the agent to process expense-related queries using authenticated MCP tools. """ - # First, get OAuth token from Keycloak - access_token = await get_keycloak_token() + # Register client via DCR and get token + client_id, client_secret = await register_client_via_dcr() + access_token = await get_keycloak_token(client_id, client_secret) logger.info(f"📡 Connecting to MCP server: {MCP_SERVER_URL}") @@ -155,11 +181,11 @@ async def main(): print("=" * 60) print("LangChain Agent with Keycloak-Protected MCP Server") print("=" * 60) - print(f"\nConfiguration:") + print("\nConfiguration:") print(f" MCP Server: {MCP_SERVER_URL}") print(f" Keycloak: {KEYCLOAK_REALM_URL}") - print(f" Client ID: {CLIENT_ID[:20]}...") print(f" LLM Host: {API_HOST}") + print(" Auth: Dynamic Client Registration (DCR)") print() await run_agent() diff --git a/infra/Dockerfile.keycloak b/infra/Dockerfile.keycloak index 1390e84..0b02bb5 100644 --- a/infra/Dockerfile.keycloak +++ b/infra/Dockerfile.keycloak @@ -1,14 +1,23 @@ -FROM quay.io/keycloak/keycloak:26.0 +FROM quay.io/keycloak/keycloak:26.0 AS builder -# Copy the MCP realm configuration for import on startup +# Copy the MCP realm configuration for import COPY infra/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 +# 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 From d234dc401dd8b917d8090135fd57cef1aa9cd58c Mon Sep 17 00:00:00 2001 From: Gwyneth Pena-Siguenza Date: Thu, 4 Dec 2025 11:44:13 -0500 Subject: [PATCH 03/16] refactor: apply Bicep best practices and fix token issuer mismatch Bicep improvements: - Add @description() decorators to all parameters in aca.bicep, aca-noauth.bicep, keycloak.bicep, and http-routes.bicep - Remove redundant dependsOn in http-routes.bicep (implicit via existing) Token issuer fix: - Update write_env.sh and write_env.ps1 to use direct Keycloak URL for KEYCLOAK_REALM_URL instead of routed URL - Fixes 401 Unauthorized errors caused by issuer mismatch between token's iss claim and MCP server's expected issuer - Add MCP_SERVER_URL to env scripts for local agent testing --- infra/aca-noauth.bicep | 25 +++++++++++++++++++++++++ infra/aca.bicep | 30 +++++++++++++++++++++++++++++- infra/http-routes.bicep | 12 ++++++++---- infra/keycloak.bicep | 16 ++++++++++++++++ infra/write_env.ps1 | 3 +++ infra/write_env.sh | 3 +++ 6 files changed, 84 insertions(+), 5 deletions(-) diff --git a/infra/aca-noauth.bicep b/infra/aca-noauth.bicep index b332e37..e669eb5 100644 --- a/infra/aca-noauth.bicep +++ b/infra/aca-noauth.bicep @@ -1,17 +1,42 @@ // MCP Server WITHOUT Keycloak authentication (deployed_mcp.py) + +@description('Name of the MCP server 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('User assigned identity name for ACR pull and Azure service access') param identityName string + +@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 = 'mcpnoauth' + +@description('Whether the container app already exists (for updates)') param exists bool + +@description('Azure OpenAI deployment name') param openAiDeploymentName string + +@description('Azure OpenAI endpoint URL') param openAiEndpoint string + +@description('Cosmos DB account name') param cosmosDbAccount string + +@description('Cosmos DB database name') param cosmosDbDatabase string + +@description('Cosmos DB container name') param cosmosDbContainer string resource mcpNoAuthIdentity 'Microsoft.ManagedIdentity/userAssignedIdentities@2023-01-31' = { diff --git a/infra/aca.bicep b/infra/aca.bicep index d100454..9bee27a 100644 --- a/infra/aca.bicep +++ b/infra/aca.bicep @@ -1,21 +1,49 @@ +@description('Name of the MCP server 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('User assigned identity name for ACR pull and Azure service access') param identityName string + +@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 = 'aca' + +@description('Whether the container app already exists (for updates)') param exists bool + +@description('Azure OpenAI deployment name') param openAiDeploymentName string + +@description('Azure OpenAI endpoint URL') param openAiEndpoint string + +@description('Cosmos DB account name') param cosmosDbAccount string + +@description('Cosmos DB database name') param cosmosDbDatabase string + +@description('Cosmos DB container name') param cosmosDbContainer string -// Keycloak authentication parameters +@description('Keycloak realm URL for token validation') param keycloakRealmUrl string + +@description('Base URL of the MCP server (for OAuth metadata)') param mcpServerBaseUrl string + +@description('Expected audience claim in JWT tokens') param mcpServerAudience string = 'mcp-server' resource acaIdentity 'Microsoft.ManagedIdentity/userAssignedIdentities@2023-01-31' = { diff --git a/infra/http-routes.bicep b/infra/http-routes.bicep index b6e3af5..b9ad984 100644 --- a/infra/http-routes.bicep +++ b/infra/http-routes.bicep @@ -1,6 +1,13 @@ +@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 = { @@ -63,10 +70,7 @@ resource httpRouteConfig 'Microsoft.App/managedEnvironments/httpRouteConfigs@202 } ] } - dependsOn: [ - mcpServerApp - keycloakApp - ] + // Note: dependsOn not needed - 'existing' resources create implicit dependencies } output fqdn string = httpRouteConfig.properties.fqdn diff --git a/infra/keycloak.bicep b/infra/keycloak.bicep index 4a53bdf..9cb9001 100644 --- a/infra/keycloak.bicep +++ b/infra/keycloak.bicep @@ -1,13 +1,29 @@ +@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') diff --git a/infra/write_env.ps1 b/infra/write_env.ps1 index d8f7e37..f953590 100644 --- a/infra/write_env.ps1 +++ b/infra/write_env.ps1 @@ -11,4 +11,7 @@ Add-Content -Path $ENV_FILE_PATH -Value "AZURE_TENANT_ID=$(azd env get-value AZU Add-Content -Path $ENV_FILE_PATH -Value "AZURE_COSMOSDB_ACCOUNT=$(azd env get-value AZURE_COSMOSDB_ACCOUNT)" 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)" +# Use direct Keycloak URL for token requests (issuer must match what MCP server expects) +Add-Content -Path $ENV_FILE_PATH -Value "KEYCLOAK_REALM_URL=$(azd env get-value KEYCLOAK_DIRECT_URL)/realms/mcp" +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 a3b4ace..c496159 100755 --- a/infra/write_env.sh +++ b/infra/write_env.sh @@ -15,4 +15,7 @@ echo "AZURE_TENANT_ID=$(azd env get-value AZURE_TENANT_ID)" >> "$ENV_FILE_PATH" echo "AZURE_COSMOSDB_ACCOUNT=$(azd env get-value AZURE_COSMOSDB_ACCOUNT)" >> "$ENV_FILE_PATH" 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" +# Use direct Keycloak URL for token requests (issuer must match what MCP server expects) +echo "KEYCLOAK_REALM_URL=$(azd env get-value KEYCLOAK_DIRECT_URL)/realms/mcp" >> "$ENV_FILE_PATH" +echo "MCP_SERVER_URL=$(azd env get-value MCP_SERVER_URL)/mcp" >> "$ENV_FILE_PATH" echo "API_HOST=azure" >> "$ENV_FILE_PATH" \ No newline at end of file From 74484909e5e4bd8e3225819f2bfaaa1eaab6b022 Mon Sep 17 00:00:00 2001 From: Gwyneth Pena-Siguenza Date: Thu, 4 Dec 2025 12:07:37 -0500 Subject: [PATCH 04/16] ran ruff --- .vscode/mcp.json | 4 ---- agents/langchainv1_keycloak.py | 36 +++++++++++++++----------------- servers/keycloak_deployed_mcp.py | 4 +++- 3 files changed, 20 insertions(+), 24 deletions(-) diff --git a/.vscode/mcp.json b/.vscode/mcp.json index 46cb1e7..4b87ffb 100644 --- a/.vscode/mcp.json +++ b/.vscode/mcp.json @@ -28,10 +28,6 @@ "servers/basic_mcp_stdio.py" ] }, - "auth-expenses": { - "url": "https://mcp-gps-key-n7pc5ej-ca.ashymeadow-ae27942e.eastus2.azurecontainerapps.io/mcp", - "type": "http" - } }, "inputs": [] } \ No newline at end of file diff --git a/agents/langchainv1_keycloak.py b/agents/langchainv1_keycloak.py index 72451c7..0358e8c 100644 --- a/agents/langchainv1_keycloak.py +++ b/agents/langchainv1_keycloak.py @@ -35,12 +35,10 @@ # MCP Server and Keycloak configuration MCP_SERVER_URL = os.getenv( - "MCP_SERVER_URL", - "https://mcp-gps-key-n7pc5ej-ca.ashymeadow-ae27942e.eastus2.azurecontainerapps.io/mcp" + "MCP_SERVER_URL", "https://mcp-gps-key-n7pc5ej-ca.ashymeadow-ae27942e.eastus2.azurecontainerapps.io/mcp" ) KEYCLOAK_REALM_URL = os.getenv( - "KEYCLOAK_REALM_URL", - "https://mcp-gps-key-n7pc5ej-kc.ashymeadow-ae27942e.eastus2.azurecontainerapps.io/realms/mcp" + "KEYCLOAK_REALM_URL", "https://mcp-gps-key-n7pc5ej-kc.ashymeadow-ae27942e.eastus2.azurecontainerapps.io/realms/mcp" ) # Configure language model based on API_HOST @@ -74,9 +72,9 @@ async def register_client_via_dcr() -> tuple[str, str]: """Register a new client dynamically using Keycloak's DCR endpoint.""" dcr_url = f"{KEYCLOAK_REALM_URL}/clients-registrations/openid-connect" - + logger.info("📝 Registering client via DCR...") - + async with httpx.AsyncClient() as client: response = await client.post( dcr_url, @@ -87,14 +85,14 @@ async def register_client_via_dcr() -> tuple[str, str]: }, headers={"Content-Type": "application/json"}, ) - + if response.status_code not in (200, 201): raise Exception(f"DCR failed: {response.status_code} - {response.text}") - + data = response.json() client_id = data["client_id"] client_secret = data["client_secret"] - + logger.info(f"✅ Registered client: {client_id[:20]}...") return client_id, client_secret @@ -102,9 +100,9 @@ async def register_client_via_dcr() -> tuple[str, str]: async def get_keycloak_token(client_id: str, client_secret: str) -> str: """Get an access token from Keycloak using client_credentials grant.""" token_url = f"{KEYCLOAK_REALM_URL}/protocol/openid-connect/token" - + logger.info("🔑 Getting access token from Keycloak...") - + async with httpx.AsyncClient() as client: response = await client.post( token_url, @@ -115,14 +113,14 @@ async def get_keycloak_token(client_id: str, client_secret: str) -> str: }, headers={"Content-Type": "application/x-www-form-urlencoded"}, ) - + if response.status_code != 200: raise Exception(f"Failed to get token: {response.status_code} - {response.text}") - + token_data = response.json() access_token = token_data["access_token"] expires_in = token_data.get("expires_in", "unknown") - + logger.info(f"✅ Got access token (expires in {expires_in}s)") return access_token @@ -134,9 +132,9 @@ async def run_agent() -> None: # Register client via DCR and get token client_id, client_secret = await register_client_via_dcr() access_token = await get_keycloak_token(client_id, client_secret) - + logger.info(f"📡 Connecting to MCP server: {MCP_SERVER_URL}") - + # Initialize MCP client with Bearer token auth client = MultiServerMCPClient( { @@ -154,7 +152,7 @@ async def run_agent() -> None: logger.info("🔧 Getting available tools...") tools = await client.get_tools() logger.info(f"✅ Found {len(tools)} tools: {[t.name for t in tools]}") - + agent = create_agent(base_model, tools) # Prepare query with context @@ -172,7 +170,7 @@ async def run_agent() -> None: logger.info("=" * 60) logger.info("📊 Agent Response:") logger.info("=" * 60) - + final_message = response["messages"][-1] print(final_message.content) @@ -187,7 +185,7 @@ async def main(): print(f" LLM Host: {API_HOST}") print(" Auth: Dynamic Client Registration (DCR)") print() - + await run_agent() diff --git a/servers/keycloak_deployed_mcp.py b/servers/keycloak_deployed_mcp.py index def65bc..c9438fd 100644 --- a/servers/keycloak_deployed_mcp.py +++ b/servers/keycloak_deployed_mcp.py @@ -26,7 +26,9 @@ AZURE_CLIENT_ID = os.getenv("AZURE_CLIENT_ID", "") # Keycloak authentication configuration -KEYCLOAK_REALM_URL = os.environ["KEYCLOAK_REALM_URL"] # e.g., https://routeconfig..azurecontainerapps.io/auth/realms/mcp +KEYCLOAK_REALM_URL = os.environ[ + "KEYCLOAK_REALM_URL" +] # e.g., https://routeconfig..azurecontainerapps.io/auth/realms/mcp MCP_SERVER_BASE_URL = os.environ["MCP_SERVER_BASE_URL"] # e.g., https://routeconfig..azurecontainerapps.io MCP_SERVER_AUDIENCE = os.getenv("MCP_SERVER_AUDIENCE", "mcp-server") From 0df84859abdd831c06c24e6a48fd6a17c80342a6 Mon Sep 17 00:00:00 2001 From: Gwyneth Pena-Siguenza Date: Thu, 4 Dec 2025 12:22:29 -0500 Subject: [PATCH 05/16] remove unused keycloak setup script --- scripts/keycloak_setup.sh | 56 --------------------------------------- 1 file changed, 56 deletions(-) delete mode 100755 scripts/keycloak_setup.sh diff --git a/scripts/keycloak_setup.sh b/scripts/keycloak_setup.sh deleted file mode 100755 index cd564d9..0000000 --- a/scripts/keycloak_setup.sh +++ /dev/null @@ -1,56 +0,0 @@ -#!/bin/bash -set -e - -BASE_URL="https://mcproutes.niceground-98809e7b.eastus2.azurecontainerapps.io" - -echo "Getting admin token..." -ACCESS_TOKEN=$(curl -s -X POST "$BASE_URL/auth/realms/master/protocol/openid-connect/token" \ - -H "Content-Type: application/x-www-form-urlencoded" \ - -d "username=admin" \ - -d "password=pythonmcp" \ - -d "grant_type=password" \ - -d "client_id=admin-cli" | jq -r '.access_token') - -if [ "$ACCESS_TOKEN" == "null" ] || [ -z "$ACCESS_TOKEN" ]; then - echo "Failed to get token" - exit 1 -fi -echo "Token obtained!" - -SCOPE_ID="fa8082ab-86f9-4ac9-961f-15b2f1a32482" - -echo "Adding mcp-server as default scope..." -curl -s -X PUT "$BASE_URL/auth/admin/realms/mcp/default-default-client-scopes/$SCOPE_ID" \ - -H "Authorization: Bearer $ACCESS_TOKEN" -echo "Done!" - -echo "Getting Trusted Hosts policy ID..." -TRUSTED_HOSTS_ID=$(curl -s "$BASE_URL/auth/admin/realms/mcp/components?type=org.keycloak.services.clientregistration.policy.ClientRegistrationPolicy" \ - -H "Authorization: Bearer $ACCESS_TOKEN" | jq -r '.[] | select(.name=="Trusted Hosts") | .id') - -echo "Trusted Hosts ID: $TRUSTED_HOSTS_ID" - -echo "Updating Trusted Hosts policy..." -curl -s -X PUT "$BASE_URL/auth/admin/realms/mcp/components/$TRUSTED_HOSTS_ID" \ - -H "Authorization: Bearer $ACCESS_TOKEN" \ - -H "Content-Type: application/json" \ - -d '{ - "id":"'"$TRUSTED_HOSTS_ID"'", - "name":"Trusted Hosts", - "providerId":"trusted-hosts", - "providerType":"org.keycloak.services.clientregistration.policy.ClientRegistrationPolicy", - "parentId":"mcp", - "config":{ - "host-sending-registration-request-must-match":["false"], - "client-uris-must-match":["false"], - "trusted-hosts":["*"] - } - }' -echo "Done!" - -echo "" -echo "=== Testing DCR ===" -curl -s -X POST "$BASE_URL/auth/realms/mcp/clients-registrations/openid-connect" \ - -H "Content-Type: application/json" \ - -d '{"client_name":"test-mcp-client","redirect_uris":["http://localhost:8080/callback"]}' | jq '.' -EOF \ No newline at end of file From 49d490dc75b310e468cb9821705fa448e08729b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gwyneth=20Pe=C3=B1a-Siguenza?= Date: Thu, 4 Dec 2025 12:25:58 -0500 Subject: [PATCH 06/16] Update infra/Dockerfile.keycloak Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- infra/Dockerfile.keycloak | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/infra/Dockerfile.keycloak b/infra/Dockerfile.keycloak index 0b02bb5..135d41a 100644 --- a/infra/Dockerfile.keycloak +++ b/infra/Dockerfile.keycloak @@ -19,6 +19,6 @@ 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 +# --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"] From 5a52481a4572a07d17df8131a6314599df20cb4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gwyneth=20Pe=C3=B1a-Siguenza?= Date: Thu, 4 Dec 2025 12:26:16 -0500 Subject: [PATCH 07/16] Update agents/langchainv1_keycloak.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- agents/langchainv1_keycloak.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/agents/langchainv1_keycloak.py b/agents/langchainv1_keycloak.py index 0358e8c..f70329e 100644 --- a/agents/langchainv1_keycloak.py +++ b/agents/langchainv1_keycloak.py @@ -34,13 +34,14 @@ load_dotenv(override=True) # MCP Server and Keycloak configuration -MCP_SERVER_URL = os.getenv( - "MCP_SERVER_URL", "https://mcp-gps-key-n7pc5ej-ca.ashymeadow-ae27942e.eastus2.azurecontainerapps.io/mcp" -) -KEYCLOAK_REALM_URL = os.getenv( - "KEYCLOAK_REALM_URL", "https://mcp-gps-key-n7pc5ej-kc.ashymeadow-ae27942e.eastus2.azurecontainerapps.io/realms/mcp" -) - +MCP_SERVER_URL = os.getenv("MCP_SERVER_URL") +if not MCP_SERVER_URL: + logger.error("Environment variable MCP_SERVER_URL is not set. Please set it to the MCP server URL.") + raise RuntimeError("Missing required environment variable: MCP_SERVER_URL") +KEYCLOAK_REALM_URL = os.getenv("KEYCLOAK_REALM_URL") +if not KEYCLOAK_REALM_URL: + logger.error("Environment variable KEYCLOAK_REALM_URL is not set. Please set it to the Keycloak realm URL.") + raise RuntimeError("Missing required environment variable: KEYCLOAK_REALM_URL") # Configure language model based on API_HOST API_HOST = os.getenv("API_HOST", "github") From 23be9670319d3d9d715ce509064c6b0f22dee2ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gwyneth=20Pe=C3=B1a-Siguenza?= Date: Thu, 4 Dec 2025 12:26:51 -0500 Subject: [PATCH 08/16] Update servers/keycloak_deployed_mcp.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- servers/keycloak_deployed_mcp.py | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/servers/keycloak_deployed_mcp.py b/servers/keycloak_deployed_mcp.py index c9438fd..583ad98 100644 --- a/servers/keycloak_deployed_mcp.py +++ b/servers/keycloak_deployed_mcp.py @@ -18,18 +18,23 @@ logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(message)s") logger = logging.getLogger("ExpensesMCP") +def require_env_var(name: str) -> str: + value = os.getenv(name) + if value is None or value.strip() == "": + logger.error(f"Missing required environment variable: {name}") + exit(1) + return value + # Cosmos DB configuration from environment variables -AZURE_COSMOSDB_ACCOUNT = os.environ["AZURE_COSMOSDB_ACCOUNT"] -AZURE_COSMOSDB_DATABASE = os.environ["AZURE_COSMOSDB_DATABASE"] -AZURE_COSMOSDB_CONTAINER = os.environ["AZURE_COSMOSDB_CONTAINER"] +AZURE_COSMOSDB_ACCOUNT = require_env_var("AZURE_COSMOSDB_ACCOUNT") +AZURE_COSMOSDB_DATABASE = require_env_var("AZURE_COSMOSDB_DATABASE") +AZURE_COSMOSDB_CONTAINER = require_env_var("AZURE_COSMOSDB_CONTAINER") RUNNING_IN_PRODUCTION = os.getenv("RUNNING_IN_PRODUCTION", "false").lower() == "true" AZURE_CLIENT_ID = os.getenv("AZURE_CLIENT_ID", "") # Keycloak authentication configuration -KEYCLOAK_REALM_URL = os.environ[ - "KEYCLOAK_REALM_URL" -] # e.g., https://routeconfig..azurecontainerapps.io/auth/realms/mcp -MCP_SERVER_BASE_URL = os.environ["MCP_SERVER_BASE_URL"] # e.g., https://routeconfig..azurecontainerapps.io +KEYCLOAK_REALM_URL = require_env_var("KEYCLOAK_REALM_URL") # e.g., https://routeconfig..azurecontainerapps.io/auth/realms/mcp +MCP_SERVER_BASE_URL = require_env_var("MCP_SERVER_BASE_URL") # e.g., https://routeconfig..azurecontainerapps.io MCP_SERVER_AUDIENCE = os.getenv("MCP_SERVER_AUDIENCE", "mcp-server") # Configure Keycloak JWT verification From 9dc7be8c1871e963b07cf12ec8b2f0d45be11ef8 Mon Sep 17 00:00:00 2001 From: Gwyneth Pena-Siguenza Date: Thu, 4 Dec 2025 12:31:23 -0500 Subject: [PATCH 09/16] fix: improve error messages and fix formatting issues --- agents/langchainv1_keycloak.py | 18 ++++++++++++++---- servers/keycloak_deployed_mcp.py | 6 +++++- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/agents/langchainv1_keycloak.py b/agents/langchainv1_keycloak.py index f70329e..003cd4f 100644 --- a/agents/langchainv1_keycloak.py +++ b/agents/langchainv1_keycloak.py @@ -50,7 +50,7 @@ azure.identity.DefaultAzureCredential(), "https://cognitiveservices.azure.com/.default" ) base_model = ChatOpenAI( - model=os.environ.get("AZURE_OPENAI_CHAT_DEPLOYMENT"), + model=os.environ.get("AZURE_OPENAI_CHAT_DEPLOYMENT", "gpt-4o"), base_url=os.environ["AZURE_OPENAI_ENDPOINT"] + "/openai/v1/", api_key=token_provider, ) @@ -88,7 +88,12 @@ async def register_client_via_dcr() -> tuple[str, str]: ) if response.status_code not in (200, 201): - raise Exception(f"DCR failed: {response.status_code} - {response.text}") + raise Exception( + f"DCR registration failed at {dcr_url}: " + f"status={response.status_code}, " + f"response={response.text}, " + f"headers={dict(response.headers)}" + ) data = response.json() client_id = data["client_id"] @@ -116,7 +121,12 @@ async def get_keycloak_token(client_id: str, client_secret: str) -> str: ) if response.status_code != 200: - raise Exception(f"Failed to get token: {response.status_code} - {response.text}") + raise Exception( + f"Token request failed at {token_url}: " + f"status={response.status_code}, " + f"response={response.text}, " + f"headers={dict(response.headers)}" + ) token_data = response.json() access_token = token_data["access_token"] @@ -137,7 +147,7 @@ async def run_agent() -> None: logger.info(f"📡 Connecting to MCP server: {MCP_SERVER_URL}") # Initialize MCP client with Bearer token auth - client = MultiServerMCPClient( + client = MultiServerMCPClient( # type: ignore[arg-type] { "expenses": { "url": MCP_SERVER_URL, diff --git a/servers/keycloak_deployed_mcp.py b/servers/keycloak_deployed_mcp.py index 583ad98..82ca454 100644 --- a/servers/keycloak_deployed_mcp.py +++ b/servers/keycloak_deployed_mcp.py @@ -18,6 +18,7 @@ logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(message)s") logger = logging.getLogger("ExpensesMCP") + def require_env_var(name: str) -> str: value = os.getenv(name) if value is None or value.strip() == "": @@ -25,6 +26,7 @@ def require_env_var(name: str) -> str: exit(1) return value + # Cosmos DB configuration from environment variables AZURE_COSMOSDB_ACCOUNT = require_env_var("AZURE_COSMOSDB_ACCOUNT") AZURE_COSMOSDB_DATABASE = require_env_var("AZURE_COSMOSDB_DATABASE") @@ -33,7 +35,9 @@ def require_env_var(name: str) -> str: AZURE_CLIENT_ID = os.getenv("AZURE_CLIENT_ID", "") # Keycloak authentication configuration -KEYCLOAK_REALM_URL = require_env_var("KEYCLOAK_REALM_URL") # e.g., https://routeconfig..azurecontainerapps.io/auth/realms/mcp +KEYCLOAK_REALM_URL = require_env_var( + "KEYCLOAK_REALM_URL" +) # e.g., https://routeconfig..azurecontainerapps.io/auth/realms/mcp MCP_SERVER_BASE_URL = require_env_var("MCP_SERVER_BASE_URL") # e.g., https://routeconfig..azurecontainerapps.io MCP_SERVER_AUDIENCE = os.getenv("MCP_SERVER_AUDIENCE", "mcp-server") From 637805e23c0769ddc23e3e58b56d5d172b27891e Mon Sep 17 00:00:00 2001 From: Gwyneth Pena-Siguenza Date: Thu, 4 Dec 2025 17:52:05 -0500 Subject: [PATCH 10/16] fix: format keycloak_deployed_mcp.py --- servers/keycloak_deployed_mcp.py | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/servers/keycloak_deployed_mcp.py b/servers/keycloak_deployed_mcp.py index 02a3632..73dcbe2 100644 --- a/servers/keycloak_deployed_mcp.py +++ b/servers/keycloak_deployed_mcp.py @@ -191,15 +191,19 @@ def analyze_spending_prompt( # Wrapper ASGI app to add health endpoint for Container Apps probes async def app(scope, receive, send): if scope["type"] == "http" and scope["path"] == "/health": - await send({ - "type": "http.response.start", - "status": 200, - "headers": [[b"content-type", b"application/json"]], - }) - await send({ - "type": "http.response.body", - "body": b'{"status": "healthy"}', - }) + await send( + { + "type": "http.response.start", + "status": 200, + "headers": [[b"content-type", b"application/json"]], + } + ) + await send( + { + "type": "http.response.body", + "body": b'{"status": "healthy"}', + } + ) return await http_app(scope, receive, send) From 74f2e6d7dd8c0a87bb3be3d1d3267d193bff0904 Mon Sep 17 00:00:00 2001 From: Gwyneth Pena-Siguenza Date: Thu, 4 Dec 2025 17:55:25 -0500 Subject: [PATCH 11/16] chore: remove unused Dockerfile.noauth --- servers/Dockerfile.noauth | 42 --------------------------------------- 1 file changed, 42 deletions(-) delete mode 100644 servers/Dockerfile.noauth diff --git a/servers/Dockerfile.noauth b/servers/Dockerfile.noauth deleted file mode 100644 index 21f1cf0..0000000 --- a/servers/Dockerfile.noauth +++ /dev/null @@ -1,42 +0,0 @@ -# ------------------- 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 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/servers -USER app - -ENV PATH="/code/.venv/bin:$PATH" - -EXPOSE 8000 - -# MCP server WITHOUT Keycloak authentication -ENTRYPOINT ["uvicorn", "deployed_mcp:app", "--host", "0.0.0.0", "--port", "8000"] From 8627170ec9db08f1043ed3098cc6f384cc9da704 Mon Sep 17 00:00:00 2001 From: Gwyneth Pena-Siguenza Date: Fri, 5 Dec 2025 16:08:07 -0500 Subject: [PATCH 12/16] addresses feedback from Pamela --- README.md | 120 ++++++++++ agents/agentframework_http.py | 72 +----- agents/keycloak_auth.py | 126 +++++++++++ agents/langchainv1_keycloak.py | 204 ----------------- azure.yaml | 2 +- infra/aca-noauth.bicep | 96 -------- infra/agent.bicep | 67 +++--- infra/http-routes.bicep | 1 - infra/main.bicep | 45 ++-- infra/main.parameters.json | 7 +- infra/server.bicep | 108 ++++----- infra/write_env.ps1 | 6 +- infra/write_env.sh | 6 +- .../Dockerfile | 4 +- .../realm.json | 0 servers/Dockerfile | 2 +- servers/deployed_mcp.py | 28 ++- servers/keycloak_deployed_mcp.py | 213 ------------------ 18 files changed, 416 insertions(+), 691 deletions(-) create mode 100644 agents/keycloak_auth.py delete mode 100644 agents/langchainv1_keycloak.py delete mode 100644 infra/aca-noauth.bicep rename infra/Dockerfile.keycloak => keycloak/Dockerfile (83%) rename infra/keycloak-realm.json => keycloak/realm.json (100%) delete mode 100644 servers/keycloak_deployed_mcp.py diff --git a/README.md b/README.md index d269cc0..58660bf 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 @@ -276,3 +277,122 @@ 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). + +### Architecture + +```text +┌─────────────┐ 1. DCR + Token ┌─────────────┐ +│ LangChain │ ──────────────────────► │ Keycloak │ +│ Agent │ ◄────────────────────── │ (direct) │ +│ │ └─────────────┘ +│ │ 2. MCP + Bearer +│ │ ──────────────────────► ┌─────────────┐ +│ │ ◄────────────────────── │ mcproutes │ → MCP Server +└─────────────┘ └─────────────┘ +``` + +### 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 LangChain agent + +1. Generate the local environment file: + + ```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/langchainv1_keycloak.py + ``` + + Expected output: + + ```text + ============================================================ + LangChain Agent with Keycloak-Protected MCP Server + ============================================================ + + Configuration: + MCP Server: https://mcproutes..azurecontainerapps.io/mcp + Keycloak: https://mcp--kc..azurecontainerapps.io/realms/mcp + LLM Host: azure + Auth: Dynamic Client Registration (DCR) + + [11:40:48] INFO 📝 Registering client via DCR... + INFO ✅ Registered client: caef6f47-0243-474d-b... + INFO 🔑 Getting access token from Keycloak... + INFO ✅ Got access token (expires in 300s) + INFO 📡 Connecting to MCP server... + INFO 🔧 Getting available tools... + INFO ✅ Found 1 tools: ['add_expense'] + INFO 💬 User query: Add an expense: yesterday I bought a laptop... + ... + INFO 📊 Agent Response: + The expense of $1200 for the laptop purchase has been successfully recorded. + ``` + +### 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 8638b4a..b0b734c 100644 --- a/agents/agentframework_http.py +++ b/agents/agentframework_http.py @@ -5,7 +5,6 @@ import os from datetime import datetime -import httpx from agent_framework import ChatAgent, MCPStreamableHTTPTool from agent_framework.azure import AzureOpenAIChatClient from agent_framework.openai import OpenAIChatClient @@ -14,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") @@ -56,65 +60,6 @@ ) -# --- Keycloak Authentication Helpers (only used if KEYCLOAK_REALM_URL is set) --- - - -async def register_client_via_dcr() -> tuple[str, str]: - """Register a new client dynamically using Keycloak's DCR endpoint.""" - 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"agent-{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: {response.status_code} - {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(client_id: str, client_secret: str) -> str: - """Get an access token from Keycloak using client_credentials grant.""" - 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: {response.status_code} - {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() -> dict[str, str] | None: - """Get authorization headers if Keycloak is configured, otherwise return None.""" - if not KEYCLOAK_REALM_URL: - return None - - client_id, client_secret = await register_client_via_dcr() - access_token = await get_keycloak_token(client_id, client_secret) - return {"Authorization": f"Bearer {access_token}"} - - # --- Main Agent Logic --- @@ -126,7 +71,7 @@ async def http_mcp_example() -> None: Otherwise, connects without authentication. """ # Get auth headers if Keycloak is configured - headers = await get_auth_headers() + 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: @@ -137,12 +82,11 @@ async def http_mcp_example() -> None: 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, ): - today = datetime.now().strftime("%Y-%m-%d") user_query = "yesterday I bought a laptop for $1200 using my visa." - result = await agent.run(f"Today's date is {today}. {user_query}", tools=mcp_server) + result = await agent.run(user_query, tools=mcp_server) print(result) # Keep the worker alive in production diff --git a/agents/keycloak_auth.py b/agents/keycloak_auth.py new file mode 100644 index 0000000..d6e09e8 --- /dev/null +++ b/agents/keycloak_auth.py @@ -0,0 +1,126 @@ +""" +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}: " + f"status={response.status_code}, " + f"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}: " + f"status={response.status_code}, " + f"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/agents/langchainv1_keycloak.py b/agents/langchainv1_keycloak.py deleted file mode 100644 index 003cd4f..0000000 --- a/agents/langchainv1_keycloak.py +++ /dev/null @@ -1,204 +0,0 @@ -""" -LangChain agent that connects to Keycloak-protected MCP server. - -This script demonstrates: -1. Dynamic Client Registration (DCR) with Keycloak -2. Getting an OAuth token using the registered client -3. Connecting to the MCP server with Bearer token authentication -4. Using MCP tools through LangChain - -Usage: - python agents/langchainv1_keycloak.py -""" - -import asyncio -import logging -import os -from datetime import datetime - -import azure.identity -import httpx -from dotenv import load_dotenv -from langchain.agents import create_agent -from langchain_core.messages import HumanMessage, SystemMessage -from langchain_mcp_adapters.client import MultiServerMCPClient -from langchain_openai import ChatOpenAI -from pydantic import SecretStr -from rich.logging import RichHandler - -# Configure logging -logging.basicConfig(level=logging.INFO, format="%(message)s", datefmt="[%X]", handlers=[RichHandler()]) -logger = logging.getLogger("langchain_keycloak") - -# Load environment variables -load_dotenv(override=True) - -# MCP Server and Keycloak configuration -MCP_SERVER_URL = os.getenv("MCP_SERVER_URL") -if not MCP_SERVER_URL: - logger.error("Environment variable MCP_SERVER_URL is not set. Please set it to the MCP server URL.") - raise RuntimeError("Missing required environment variable: MCP_SERVER_URL") -KEYCLOAK_REALM_URL = os.getenv("KEYCLOAK_REALM_URL") -if not KEYCLOAK_REALM_URL: - logger.error("Environment variable KEYCLOAK_REALM_URL is not set. Please set it to the Keycloak realm URL.") - raise RuntimeError("Missing required environment variable: KEYCLOAK_REALM_URL") -# Configure language model based on API_HOST -API_HOST = os.getenv("API_HOST", "github") - -if API_HOST == "azure": - token_provider = azure.identity.get_bearer_token_provider( - azure.identity.DefaultAzureCredential(), "https://cognitiveservices.azure.com/.default" - ) - base_model = ChatOpenAI( - model=os.environ.get("AZURE_OPENAI_CHAT_DEPLOYMENT", "gpt-4o"), - base_url=os.environ["AZURE_OPENAI_ENDPOINT"] + "/openai/v1/", - api_key=token_provider, - ) -elif API_HOST == "github": - base_model = ChatOpenAI( - model=os.getenv("GITHUB_MODEL", "gpt-4o"), - base_url="https://models.inference.ai.azure.com", - api_key=SecretStr(os.environ["GITHUB_TOKEN"]), - ) -elif API_HOST == "ollama": - base_model = ChatOpenAI( - model=os.environ.get("OLLAMA_MODEL", "llama3.1"), - base_url=os.environ.get("OLLAMA_ENDPOINT", "http://localhost:11434/v1"), - api_key=SecretStr(os.environ["OLLAMA_API_KEY"]), - ) -else: - base_model = ChatOpenAI(model=os.getenv("OPENAI_MODEL", "gpt-4o-mini")) - - -async def register_client_via_dcr() -> tuple[str, str]: - """Register a new client dynamically using Keycloak's DCR endpoint.""" - dcr_url = f"{KEYCLOAK_REALM_URL}/clients-registrations/openid-connect" - - logger.info("📝 Registering client via DCR...") - - async with httpx.AsyncClient() as client: - response = await client.post( - dcr_url, - json={ - "client_name": f"langchain-agent-{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 Exception( - f"DCR registration failed at {dcr_url}: " - f"status={response.status_code}, " - f"response={response.text}, " - f"headers={dict(response.headers)}" - ) - - data = response.json() - client_id = data["client_id"] - client_secret = data["client_secret"] - - logger.info(f"✅ Registered client: {client_id[:20]}...") - return client_id, client_secret - - -async def get_keycloak_token(client_id: str, client_secret: str) -> str: - """Get an access token from Keycloak using client_credentials grant.""" - token_url = f"{KEYCLOAK_REALM_URL}/protocol/openid-connect/token" - - logger.info("🔑 Getting access token from Keycloak...") - - async with httpx.AsyncClient() as client: - response = await 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 Exception( - f"Token request failed at {token_url}: " - f"status={response.status_code}, " - f"response={response.text}, " - f"headers={dict(response.headers)}" - ) - - token_data = response.json() - access_token = token_data["access_token"] - expires_in = token_data.get("expires_in", "unknown") - - logger.info(f"✅ Got access token (expires in {expires_in}s)") - return access_token - - -async def run_agent() -> None: - """ - Run the agent to process expense-related queries using authenticated MCP tools. - """ - # Register client via DCR and get token - client_id, client_secret = await register_client_via_dcr() - access_token = await get_keycloak_token(client_id, client_secret) - - logger.info(f"📡 Connecting to MCP server: {MCP_SERVER_URL}") - - # Initialize MCP client with Bearer token auth - client = MultiServerMCPClient( # type: ignore[arg-type] - { - "expenses": { - "url": MCP_SERVER_URL, - "transport": "streamable_http", - "headers": { - "Authorization": f"Bearer {access_token}", - }, - } - } - ) - - # Get tools and create agent - logger.info("🔧 Getting available tools...") - tools = await client.get_tools() - logger.info(f"✅ Found {len(tools)} tools: {[t.name for t in tools]}") - - agent = create_agent(base_model, tools) - - # Prepare query with context - today = datetime.now().strftime("%Y-%m-%d") - user_query = "Add an expense: yesterday I bought a laptop for $1200 using my visa." - - logger.info(f"💬 User query: {user_query}") - - # Invoke agent - response = await agent.ainvoke( - {"messages": [SystemMessage(content=f"Today's date is {today}."), HumanMessage(content=user_query)]} - ) - - # Display result - logger.info("=" * 60) - logger.info("📊 Agent Response:") - logger.info("=" * 60) - - final_message = response["messages"][-1] - print(final_message.content) - - -async def main(): - print("=" * 60) - print("LangChain Agent with Keycloak-Protected MCP Server") - print("=" * 60) - print("\nConfiguration:") - print(f" MCP Server: {MCP_SERVER_URL}") - print(f" Keycloak: {KEYCLOAK_REALM_URL}") - print(f" LLM Host: {API_HOST}") - print(" Auth: Dynamic Client Registration (DCR)") - print() - - await run_agent() - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/azure.yaml b/azure.yaml index a49942e..1cab741 100644 --- a/azure.yaml +++ b/azure.yaml @@ -18,7 +18,7 @@ services: language: docker host: containerapp docker: - path: ./infra/Dockerfile.keycloak + path: ./keycloak/Dockerfile context: . agent: project: . diff --git a/infra/aca-noauth.bicep b/infra/aca-noauth.bicep deleted file mode 100644 index e669eb5..0000000 --- a/infra/aca-noauth.bicep +++ /dev/null @@ -1,96 +0,0 @@ -// MCP Server WITHOUT Keycloak authentication (deployed_mcp.py) - -@description('Name of the MCP server 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('User assigned identity name for ACR pull and Azure service access') -param identityName string - -@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 = 'mcpnoauth' - -@description('Whether the container app already exists (for updates)') -param exists bool - -@description('Azure OpenAI deployment name') -param openAiDeploymentName string - -@description('Azure OpenAI endpoint URL') -param openAiEndpoint string - -@description('Cosmos DB account name') -param cosmosDbAccount string - -@description('Cosmos DB database name') -param cosmosDbDatabase string - -@description('Cosmos DB container name') -param cosmosDbContainer string - -resource mcpNoAuthIdentity '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: mcpNoAuthIdentity.name - exists: exists - 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: mcpNoAuthIdentity.properties.clientId - } - { - name: 'AZURE_COSMOSDB_ACCOUNT' - value: cosmosDbAccount - } - { - name: 'AZURE_COSMOSDB_DATABASE' - value: cosmosDbDatabase - } - { - name: 'AZURE_COSMOSDB_CONTAINER' - value: cosmosDbContainer - } - ] - targetPort: 8000 - } -} - -output identityPrincipalId string = mcpNoAuthIdentity.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/agent.bicep b/infra/agent.bicep index 020c67b..9298a1e 100644 --- a/infra/agent.bicep +++ b/infra/agent.bicep @@ -12,6 +12,42 @@ 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 location: location @@ -28,36 +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' - } - { - name: 'KEYCLOAK_REALM_URL' - value: keycloakRealmUrl - } - ] + env: concat(baseEnv, keycloakEnv) } } diff --git a/infra/http-routes.bicep b/infra/http-routes.bicep index b9ad984..0f8ced4 100644 --- a/infra/http-routes.bicep +++ b/infra/http-routes.bicep @@ -70,7 +70,6 @@ resource httpRouteConfig 'Microsoft.App/managedEnvironments/httpRouteConfigs@202 } ] } - // Note: dependsOn not needed - 'existing' resources create implicit dependencies } output fqdn string = httpRouteConfig.properties.fqdn diff --git a/infra/main.bicep b/infra/main.bicep index 3a454c0..adf1774 100644 --- a/infra/main.bicep +++ b/infra/main.bicep @@ -46,18 +46,21 @@ 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 - must be set via azd env') -param keycloakAdminPassword string +@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') -param mcpServerAudience string = 'mcp-server' +@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 @@ -707,10 +710,10 @@ module server 'server.bicep' = { cosmosDbContainer: cosmosDbContainerName applicationInsightsConnectionString: useMonitoring ? applicationInsights!.outputs.connectionString : '' exists: serverExists - // Keycloak authentication configuration - keycloakRealmUrl: '${keycloak.outputs.uri}/realms/${keycloakRealmName}' - mcpServerBaseUrl: 'https://mcproutes.${containerApps.outputs.defaultDomain}' - mcpServerAudience: mcpServerAudience + // Keycloak authentication configuration (only when enabled) + keycloakRealmUrl: useKeycloak ? '${keycloak!.outputs.uri}/realms/${keycloakRealmName}' : '' + mcpServerBaseUrl: useKeycloak ? 'https://mcproutes.${containerApps.outputs.defaultDomain}' : '' + keycloakMcpServerAudience: keycloakMcpServerAudience } } @@ -727,13 +730,13 @@ module agent 'agent.bicep' = { containerRegistryName: containerApps.outputs.registryName openAiDeploymentName: openAiDeploymentName openAiEndpoint: openAi.outputs.endpoint - mcpServerUrl: 'https://mcproutes.${containerApps.outputs.defaultDomain}/mcp' - keycloakRealmUrl: '${keycloak.outputs.uri}/realms/${keycloakRealmName}' + mcpServerUrl: useKeycloak ? 'https://mcproutes.${containerApps.outputs.defaultDomain}/mcp' : '${server.outputs.uri}/mcp' + keycloakRealmUrl: useKeycloak ? '${keycloak.outputs.uri}/realms/${keycloakRealmName}' : '' exists: agentExists } } -// Keycloak authentication server +// Keycloak authentication server (always deployed, but only used when useKeycloak is true) module keycloak 'keycloak.bicep' = { name: 'keycloak' scope: resourceGroup @@ -745,19 +748,19 @@ module keycloak 'keycloak.bicep' = { containerAppsEnvironmentName: containerApps.outputs.environmentName containerRegistryName: containerApps.outputs.registryName keycloakAdminUser: keycloakAdminUser - keycloakAdminPassword: keycloakAdminPassword + keycloakAdminPassword: useKeycloak ? keycloakAdminPassword : 'placeholder-not-used' exists: keycloakExists } } -// HTTP Route configuration for rule-based routing -module httpRoutes 'http-routes.bicep' = { +// 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 + keycloakAppName: keycloak!.outputs.name } } @@ -849,10 +852,10 @@ 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 -output HTTP_ROUTES_URL string = httpRoutes.outputs.routeConfigUrl -output KEYCLOAK_URL string = '${httpRoutes.outputs.routeConfigUrl}/auth' -output KEYCLOAK_REALM_URL string = '${httpRoutes.outputs.routeConfigUrl}/auth/realms/${keycloakRealmName}' -output MCP_SERVER_URL string = httpRoutes.outputs.routeConfigUrl -output KEYCLOAK_ADMIN_CONSOLE string = '${httpRoutes.outputs.routeConfigUrl}/auth/admin' +// 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 114d6a0..8622eeb 100644 --- a/infra/main.parameters.json +++ b/infra/main.parameters.json @@ -26,6 +26,9 @@ "usePrivateLogAnalytics": { "value": "${USE_PRIVATE_LOGANALYTICS=false}" }, + "useKeycloak": { + "value": "${USE_KEYCLOAK=false}" + }, "serverExists": { "value": "${SERVICE_SERVER_RESOURCE_EXISTS=false}" }, @@ -44,8 +47,8 @@ "keycloakRealmName": { "value": "${KEYCLOAK_REALM_NAME=mcp}" }, - "mcpServerAudience": { - "value": "${MCP_SERVER_AUDIENCE=mcp-server}" + "keycloakMcpServerAudience": { + "value": "${KEYCLOAK_MCP_SERVER_AUDIENCE=mcp-server}" } } } diff --git a/infra/server.bicep b/infra/server.bicep index 4d26053..caad60d 100644 --- a/infra/server.bicep +++ b/infra/server.bicep @@ -13,9 +13,62 @@ param cosmosDbAccount string param cosmosDbDatabase string param cosmosDbContainer string param applicationInsightsConnectionString string = '' -param keycloakRealmUrl string -param mcpServerBaseUrl string -param mcpServerAudience string = 'mcp-server' +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' = { @@ -34,54 +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 - } - // Keycloak authentication environment variables - { - name: 'KEYCLOAK_REALM_URL' - value: keycloakRealmUrl - } - { - name: 'MCP_SERVER_BASE_URL' - value: mcpServerBaseUrl - } - { - name: 'MCP_SERVER_AUDIENCE' - value: mcpServerAudience - } - ] + env: concat(baseEnv, keycloakEnv) targetPort: 8000 probes: [ { diff --git a/infra/write_env.ps1 b/infra/write_env.ps1 index 23c90a1..5f03da8 100644 --- a/infra/write_env.ps1 +++ b/infra/write_env.ps1 @@ -12,7 +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)" -# Use direct Keycloak URL for token requests (issuer must match what MCP server expects) -Add-Content -Path $ENV_FILE_PATH -Value "KEYCLOAK_REALM_URL=$(azd env get-value KEYCLOAK_DIRECT_URL)/realms/mcp" +$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 0ff0089..d4de852 100755 --- a/infra/write_env.sh +++ b/infra/write_env.sh @@ -16,7 +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" -# Use direct Keycloak URL for token requests (issuer must match what MCP server expects) -echo "KEYCLOAK_REALM_URL=$(azd env get-value KEYCLOAK_DIRECT_URL)/realms/mcp" >> "$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/infra/Dockerfile.keycloak b/keycloak/Dockerfile similarity index 83% rename from infra/Dockerfile.keycloak rename to keycloak/Dockerfile index 135d41a..9c1c3b2 100644 --- a/infra/Dockerfile.keycloak +++ b/keycloak/Dockerfile @@ -1,7 +1,9 @@ +# 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 infra/keycloak-realm.json /opt/keycloak/data/import/mcp-realm.json +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 diff --git a/infra/keycloak-realm.json b/keycloak/realm.json similarity index 100% rename from infra/keycloak-realm.json rename to keycloak/realm.json diff --git a/servers/Dockerfile b/servers/Dockerfile index 73bb97b..89b474c 100644 --- a/servers/Dockerfile +++ b/servers/Dockerfile @@ -37,4 +37,4 @@ ENV PATH="/code/.venv/bin:$PATH" EXPOSE 8000 -ENTRYPOINT ["uvicorn", "keycloak_deployed_mcp:app", "--host", "0.0.0.0", "--port", "8000"] +ENTRYPOINT ["uvicorn", "deployed_mcp:app", "--host", "0.0.0.0", "--port", "8000"] 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")) diff --git a/servers/keycloak_deployed_mcp.py b/servers/keycloak_deployed_mcp.py deleted file mode 100644 index 73dcbe2..0000000 --- a/servers/keycloak_deployed_mcp.py +++ /dev/null @@ -1,213 +0,0 @@ -import logging -import os -import uuid -from datetime import date -from enum import Enum -from typing import Annotated - -from azure.cosmos.aio import CosmosClient -from azure.identity.aio import DefaultAzureCredential, ManagedIdentityCredential -from dotenv import load_dotenv -from fastmcp import FastMCP -from fastmcp.server.auth import RemoteAuthProvider -from fastmcp.server.auth.providers.jwt import JWTVerifier -from pydantic import AnyHttpUrl - -load_dotenv(override=True) - -logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(message)s") -logger = logging.getLogger("ExpensesMCP") - - -def require_env_var(name: str) -> str: - value = os.getenv(name) - if value is None or value.strip() == "": - logger.error(f"Missing required environment variable: {name}") - exit(1) - return value - - -# Cosmos DB configuration from environment variables -AZURE_COSMOSDB_ACCOUNT = require_env_var("AZURE_COSMOSDB_ACCOUNT") -AZURE_COSMOSDB_DATABASE = require_env_var("AZURE_COSMOSDB_DATABASE") -AZURE_COSMOSDB_CONTAINER = require_env_var("AZURE_COSMOSDB_CONTAINER") -RUNNING_IN_PRODUCTION = os.getenv("RUNNING_IN_PRODUCTION", "false").lower() == "true" -AZURE_CLIENT_ID = os.getenv("AZURE_CLIENT_ID", "") - -# Keycloak authentication configuration -KEYCLOAK_REALM_URL = require_env_var( - "KEYCLOAK_REALM_URL" -) # e.g., https://routeconfig..azurecontainerapps.io/auth/realms/mcp -MCP_SERVER_BASE_URL = require_env_var("MCP_SERVER_BASE_URL") # e.g., https://routeconfig..azurecontainerapps.io -MCP_SERVER_AUDIENCE = os.getenv("MCP_SERVER_AUDIENCE", "mcp-server") - -# Configure Keycloak JWT verification -token_verifier = JWTVerifier( - jwks_uri=f"{KEYCLOAK_REALM_URL}/protocol/openid-connect/certs", - issuer=KEYCLOAK_REALM_URL, - audience=MCP_SERVER_AUDIENCE, -) - -# Configure RemoteAuthProvider for DCR support -auth = RemoteAuthProvider( - token_verifier=token_verifier, - authorization_servers=[AnyHttpUrl(KEYCLOAK_REALM_URL)], - base_url=MCP_SERVER_BASE_URL, -) -logger.info(f"Keycloak auth configured: realm={KEYCLOAK_REALM_URL}, audience={MCP_SERVER_AUDIENCE}") - -# Configure Cosmos DB client and container -if RUNNING_IN_PRODUCTION and AZURE_CLIENT_ID: - credential = ManagedIdentityCredential(client_id=AZURE_CLIENT_ID) -else: - credential = DefaultAzureCredential() - -cosmos_client = CosmosClient( - url=f"https://{AZURE_COSMOSDB_ACCOUNT}.documents.azure.com:443/", - credential=credential, -) -cosmos_db = cosmos_client.get_database_client(AZURE_COSMOSDB_DATABASE) -cosmos_container = cosmos_db.get_container_client(AZURE_COSMOSDB_CONTAINER) -logger.info(f"Connected to Cosmos DB: {AZURE_COSMOSDB_ACCOUNT}") - -mcp = FastMCP("Expenses Tracker", auth=auth) - - -class PaymentMethod(Enum): - AMEX = "amex" - VISA = "visa" - CASH = "cash" - - -class Category(Enum): - FOOD = "food" - TRANSPORT = "transport" - ENTERTAINMENT = "entertainment" - SHOPPING = "shopping" - GADGET = "gadget" - OTHER = "other" - - -@mcp.tool -async def add_expense( - date: Annotated[date, "Date of the expense in YYYY-MM-DD format"], - amount: Annotated[float, "Positive numeric amount of the expense"], - category: Annotated[Category, "Category label"], - description: Annotated[str, "Human-readable description of the expense"], - payment_method: Annotated[PaymentMethod, "Payment method used"], -): - """Add a new expense to Cosmos DB.""" - if amount <= 0: - return "Error: Amount must be positive" - - date_iso = date.isoformat() - logger.info(f"Adding expense: ${amount} for {description} on {date_iso}") - - try: - expense_id = str(uuid.uuid4()) - expense_item = { - "id": expense_id, - "date": date_iso, - "amount": amount, - "category": category.value, - "description": description, - "payment_method": payment_method.value, - } - - await cosmos_container.create_item(body=expense_item) - return f"Successfully added expense: ${amount} for {description} on {date_iso}" - - except Exception as e: - logger.error(f"Error adding expense: {str(e)}") - return f"Error: Unable to add expense - {str(e)}" - - -@mcp.resource("resource://expenses") -async def get_expenses_data(): - """Get raw expense data from Cosmos DB.""" - logger.info("Expenses data accessed") - - try: - query = "SELECT * FROM c ORDER BY c.date DESC" - expenses_data = [] - - async for item in cosmos_container.query_items(query=query, enable_cross_partition_query=True): - expenses_data.append(item) - - if not expenses_data: - return "No expenses found." - - csv_content = f"Expense data ({len(expenses_data)} entries):\n\n" - for expense in expenses_data: - csv_content += ( - f"Date: {expense.get('date', 'N/A')}, " - f"Amount: ${expense.get('amount', 0)}, " - f"Category: {expense.get('category', 'N/A')}, " - f"Description: {expense.get('description', 'N/A')}, " - f"Payment: {expense.get('payment_method', 'N/A')}\n" - ) - - return csv_content - - except Exception as e: - logger.error(f"Error reading expenses: {str(e)}") - return f"Error: Unable to retrieve expense data - {str(e)}" - - -@mcp.prompt -def analyze_spending_prompt( - category: str | None = None, start_date: str | None = None, end_date: str | None = None -) -> str: - """Generate a prompt to analyze spending patterns with optional filters.""" - - filters = [] - if category: - filters.append(f"Category: {category}") - if start_date: - filters.append(f"From: {start_date}") - if end_date: - filters.append(f"To: {end_date}") - - filter_text = f" ({', '.join(filters)})" if filters else "" - - return f""" - Please analyze my spending patterns{filter_text} and provide: - - 1. Total spending breakdown by category - 2. Average daily/weekly spending - 3. Most expensive single transaction - 4. Payment method distribution - 5. Spending trends or unusual patterns - 6. Recommendations for budget optimization - - Use the expense data to generate actionable insights. - """ - - -# ASGI application for uvicorn -http_app = mcp.http_app() - - -# Wrapper ASGI app to add health endpoint for Container Apps probes -async def app(scope, receive, send): - if scope["type"] == "http" and scope["path"] == "/health": - await send( - { - "type": "http.response.start", - "status": 200, - "headers": [[b"content-type", b"application/json"]], - } - ) - await send( - { - "type": "http.response.body", - "body": b'{"status": "healthy"}', - } - ) - return - await http_app(scope, receive, send) - - -if __name__ == "__main__": - logger.info("MCP Expenses server starting (HTTP mode on port 8000)") - mcp.run(transport="http", host="0.0.0.0", port=8000) From bf6ec601030a49761b3d372cfeb8f24d28a4bef2 Mon Sep 17 00:00:00 2001 From: Gwyneth Pena-Siguenza Date: Fri, 5 Dec 2025 16:12:41 -0500 Subject: [PATCH 13/16] Format keycloak_auth.py --- agents/keycloak_auth.py | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/agents/keycloak_auth.py b/agents/keycloak_auth.py index d6e09e8..b98d1f7 100644 --- a/agents/keycloak_auth.py +++ b/agents/keycloak_auth.py @@ -51,9 +51,7 @@ async def register_client_via_dcr(keycloak_realm_url: str, client_name_prefix: s if response.status_code not in (200, 201): raise RuntimeError( - f"DCR registration failed at {dcr_url}: " - f"status={response.status_code}, " - f"response={response.text}" + f"DCR registration failed at {dcr_url}: status={response.status_code}, response={response.text}" ) data = response.json() @@ -92,9 +90,7 @@ async def get_keycloak_token(keycloak_realm_url: str, client_id: str, client_sec if response.status_code != 200: raise RuntimeError( - f"Token request failed at {token_url}: " - f"status={response.status_code}, " - f"response={response.text}" + f"Token request failed at {token_url}: status={response.status_code}, response={response.text}" ) token_data = response.json() @@ -102,9 +98,7 @@ async def get_keycloak_token(keycloak_realm_url: str, client_id: str, client_sec return token_data["access_token"] -async def get_auth_headers( - keycloak_realm_url: str | None, client_name_prefix: str = "agent" -) -> dict[str, str] | None: +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. From ccb5afad98e3a6eb27c8d5f9594e250c9e41db50 Mon Sep 17 00:00:00 2001 From: Gwyneth Pena-Siguenza Date: Fri, 5 Dec 2025 16:13:56 -0500 Subject: [PATCH 14/16] Update README: use agentframework_http.py for Keycloak testing --- README.md | 46 ++++++++++------------------------------------ 1 file changed, 10 insertions(+), 36 deletions(-) diff --git a/README.md b/README.md index 58660bf..ddbd178 100644 --- a/README.md +++ b/README.md @@ -284,19 +284,6 @@ When using VNet configuration, additional Azure resources are provisioned: 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). -### Architecture - -```text -┌─────────────┐ 1. DCR + Token ┌─────────────┐ -│ LangChain │ ──────────────────────► │ Keycloak │ -│ Agent │ ◄────────────────────── │ (direct) │ -│ │ └─────────────┘ -│ │ 2. MCP + Bearer -│ │ ──────────────────────► ┌─────────────┐ -│ │ ◄────────────────────── │ mcproutes │ → MCP Server -└─────────────┘ └─────────────┘ -``` - ### What gets deployed | Component | Description | @@ -343,7 +330,7 @@ This project supports deploying with OAuth 2.0 authentication using Keycloak as Login with `admin` and your configured password. -### Testing with the LangChain agent +### Testing with the agent 1. Generate the local environment file: @@ -356,33 +343,20 @@ This project supports deploying with OAuth 2.0 authentication using Keycloak as 2. Run the agent: ```bash - uv run agents/langchainv1_keycloak.py + uv run agents/agentframework_http.py ``` + The agent automatically detects `KEYCLOAK_REALM_URL` in the environment and authenticates via DCR + client credentials. + Expected output: ```text - ============================================================ - LangChain Agent with Keycloak-Protected MCP Server - ============================================================ - - Configuration: - MCP Server: https://mcproutes..azurecontainerapps.io/mcp - Keycloak: https://mcp--kc..azurecontainerapps.io/realms/mcp - LLM Host: azure - Auth: Dynamic Client Registration (DCR) - - [11:40:48] INFO 📝 Registering client via DCR... - INFO ✅ Registered client: caef6f47-0243-474d-b... - INFO 🔑 Getting access token from Keycloak... - INFO ✅ Got access token (expires in 300s) - INFO 📡 Connecting to MCP server... - INFO 🔧 Getting available tools... - INFO ✅ Found 1 tools: ['add_expense'] - INFO 💬 User query: Add an expense: yesterday I bought a laptop... - ... - INFO 📊 Agent Response: - The expense of $1200 for the laptop purchase has been successfully recorded. + INFO 🔐 Auth enabled - connecting to https://mcproutes..azurecontainerapps.io/mcp with Bearer token + INFO 📝 Registering client via DCR... + INFO ✅ Registered client: agentframework-20251205-... + INFO 🔑 Getting access token from Keycloak... + INFO ✅ Got access token (expires in 300s) + The expense of $1200.0 for 'laptop' on 2025-12-04 has been successfully added. ``` ### Known limitations (demo trade-offs) From 51b39692b515c8f38c905da8274e6de5fde27e43 Mon Sep 17 00:00:00 2001 From: Gwyneth Pena-Siguenza Date: Fri, 5 Dec 2025 16:15:08 -0500 Subject: [PATCH 15/16] README: simplify expected output for agent test --- README.md | 15 ++------------- 1 file changed, 2 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index ddbd178..9059100 100644 --- a/README.md +++ b/README.md @@ -332,7 +332,7 @@ This project supports deploying with OAuth 2.0 authentication using Keycloak as ### Testing with the agent -1. Generate the local environment file: +1. Generate the local environment file (automatically created after `azd up`): ```bash ./infra/write_env.sh @@ -346,18 +346,7 @@ This project supports deploying with OAuth 2.0 authentication using Keycloak as uv run agents/agentframework_http.py ``` - The agent automatically detects `KEYCLOAK_REALM_URL` in the environment and authenticates via DCR + client credentials. - - Expected output: - - ```text - INFO 🔐 Auth enabled - connecting to https://mcproutes..azurecontainerapps.io/mcp with Bearer token - INFO 📝 Registering client via DCR... - INFO ✅ Registered client: agentframework-20251205-... - INFO 🔑 Getting access token from Keycloak... - INFO ✅ Got access token (expires in 300s) - The expense of $1200.0 for 'laptop' on 2025-12-04 has been successfully added. - ``` + 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) From 2c25546556bb9d74d2c5a05959aa0ad1a35ecc75 Mon Sep 17 00:00:00 2001 From: Gwyneth Pena-Siguenza Date: Fri, 5 Dec 2025 16:16:16 -0500 Subject: [PATCH 16/16] README: add deployed_mcp.py to servers table --- README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 9059100..291e4fd 100644 --- a/README.md +++ b/README.md @@ -72,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