Skip to content

Commit a0b7d12

Browse files
committed
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
1 parent 617952c commit a0b7d12

15 files changed

+1039
-34
lines changed

.vscode/mcp.json

Lines changed: 36 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,37 @@
11
{
2-
"servers": {
3-
"expenses-mcp": {
4-
"type": "stdio",
5-
"command": "uv",
6-
"cwd": "${workspaceFolder}",
7-
"args": [
8-
"run",
9-
"servers/basic_mcp_stdio.py"
10-
]
11-
},
12-
"expenses-mcp-http": {
13-
"type": "http",
14-
"url": "http://localhost:8000/mcp"
15-
},
16-
"expenses-mcp-debug": {
17-
"type": "stdio",
18-
"command": "uv",
19-
"cwd": "${workspaceFolder}",
20-
"args": [
21-
"run",
22-
"--",
23-
"python",
24-
"-m",
25-
"debugpy",
26-
"--listen",
27-
"0.0.0.0:5678",
28-
"servers/basic_mcp_stdio.py"
29-
]
30-
}
31-
},
32-
"inputs": []
33-
}
2+
"servers": {
3+
"expenses-mcp": {
4+
"type": "stdio",
5+
"command": "uv",
6+
"cwd": "${workspaceFolder}",
7+
"args": [
8+
"run",
9+
"servers/basic_mcp_stdio.py"
10+
]
11+
},
12+
"expenses-mcp-http": {
13+
"type": "http",
14+
"url": "http://localhost:8000/mcp"
15+
},
16+
"expenses-mcp-debug": {
17+
"type": "stdio",
18+
"command": "uv",
19+
"cwd": "${workspaceFolder}",
20+
"args": [
21+
"run",
22+
"--",
23+
"python",
24+
"-m",
25+
"debugpy",
26+
"--listen",
27+
"0.0.0.0:5678",
28+
"servers/basic_mcp_stdio.py"
29+
]
30+
},
31+
"auth-expenses": {
32+
"url": "https://mcp-gps-key-n7pc5ej-ca.ashymeadow-ae27942e.eastus2.azurecontainerapps.io/mcp",
33+
"type": "http"
34+
}
35+
},
36+
"inputs": []
37+
}

agents/langchainv1_keycloak.py

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
"""
2+
LangChain agent that connects to Keycloak-protected MCP server.
3+
4+
This script demonstrates:
5+
1. Getting an OAuth token from Keycloak via client_credentials grant
6+
2. Connecting to the MCP server with Bearer token authentication
7+
3. Using MCP tools through LangChain
8+
9+
Usage:
10+
python agents/langchainv1_keycloak.py
11+
"""
12+
13+
import asyncio
14+
import logging
15+
import os
16+
from datetime import datetime
17+
18+
import azure.identity
19+
import httpx
20+
from dotenv import load_dotenv
21+
from langchain.agents import create_agent
22+
from langchain_core.messages import HumanMessage, SystemMessage
23+
from langchain_mcp_adapters.client import MultiServerMCPClient
24+
from langchain_openai import ChatOpenAI
25+
from pydantic import SecretStr
26+
from rich.logging import RichHandler
27+
28+
# Configure logging
29+
logging.basicConfig(level=logging.INFO, format="%(message)s", datefmt="[%X]", handlers=[RichHandler()])
30+
logger = logging.getLogger("langchain_keycloak")
31+
32+
# Load environment variables
33+
load_dotenv(override=True)
34+
35+
# MCP Server and Keycloak configuration
36+
MCP_SERVER_URL = os.getenv(
37+
"MCP_SERVER_URL",
38+
"https://mcp-gps-key-n7pc5ej-ca.ashymeadow-ae27942e.eastus2.azurecontainerapps.io/mcp"
39+
)
40+
KEYCLOAK_REALM_URL = os.getenv(
41+
"KEYCLOAK_REALM_URL",
42+
"https://mcp-gps-key-n7pc5ej-kc.ashymeadow-ae27942e.eastus2.azurecontainerapps.io/realms/mcp"
43+
)
44+
45+
# Test client credentials (from DCR)
46+
CLIENT_ID = os.getenv("TEST_CLIENT_ID", "4f061e11-d30c-4978-bb2e-2164ce26cc51")
47+
CLIENT_SECRET = os.getenv("TEST_CLIENT_SECRET", "UKZLK1eEga9FfZTFsqTHXK70ubq0PAJu")
48+
49+
# Configure language model based on API_HOST
50+
API_HOST = os.getenv("API_HOST", "github")
51+
52+
if API_HOST == "azure":
53+
token_provider = azure.identity.get_bearer_token_provider(
54+
azure.identity.DefaultAzureCredential(), "https://cognitiveservices.azure.com/.default"
55+
)
56+
base_model = ChatOpenAI(
57+
model=os.environ.get("AZURE_OPENAI_CHAT_DEPLOYMENT"),
58+
base_url=os.environ["AZURE_OPENAI_ENDPOINT"] + "/openai/v1/",
59+
api_key=token_provider,
60+
)
61+
elif API_HOST == "github":
62+
base_model = ChatOpenAI(
63+
model=os.getenv("GITHUB_MODEL", "gpt-4o"),
64+
base_url="https://models.inference.ai.azure.com",
65+
api_key=SecretStr(os.environ["GITHUB_TOKEN"]),
66+
)
67+
elif API_HOST == "ollama":
68+
base_model = ChatOpenAI(
69+
model=os.environ.get("OLLAMA_MODEL", "llama3.1"),
70+
base_url=os.environ.get("OLLAMA_ENDPOINT", "http://localhost:11434/v1"),
71+
api_key=SecretStr(os.environ["OLLAMA_API_KEY"]),
72+
)
73+
else:
74+
base_model = ChatOpenAI(model=os.getenv("OPENAI_MODEL", "gpt-4o-mini"))
75+
76+
77+
async def get_keycloak_token() -> str:
78+
"""Get an access token from Keycloak using client_credentials grant."""
79+
token_url = f"{KEYCLOAK_REALM_URL}/protocol/openid-connect/token"
80+
81+
logger.info(f"🔑 Getting access token from Keycloak...")
82+
83+
async with httpx.AsyncClient() as client:
84+
response = await client.post(
85+
token_url,
86+
data={
87+
"grant_type": "client_credentials",
88+
"client_id": CLIENT_ID,
89+
"client_secret": CLIENT_SECRET,
90+
},
91+
headers={"Content-Type": "application/x-www-form-urlencoded"},
92+
)
93+
94+
if response.status_code != 200:
95+
raise Exception(f"Failed to get token: {response.status_code} - {response.text}")
96+
97+
token_data = response.json()
98+
access_token = token_data["access_token"]
99+
expires_in = token_data.get("expires_in", "unknown")
100+
101+
logger.info(f"✅ Got access token (expires in {expires_in}s)")
102+
return access_token
103+
104+
105+
async def run_agent() -> None:
106+
"""
107+
Run the agent to process expense-related queries using authenticated MCP tools.
108+
"""
109+
# First, get OAuth token from Keycloak
110+
access_token = await get_keycloak_token()
111+
112+
logger.info(f"📡 Connecting to MCP server: {MCP_SERVER_URL}")
113+
114+
# Initialize MCP client with Bearer token auth
115+
client = MultiServerMCPClient(
116+
{
117+
"expenses": {
118+
"url": MCP_SERVER_URL,
119+
"transport": "streamable_http",
120+
"headers": {
121+
"Authorization": f"Bearer {access_token}",
122+
},
123+
}
124+
}
125+
)
126+
127+
# Get tools and create agent
128+
logger.info("🔧 Getting available tools...")
129+
tools = await client.get_tools()
130+
logger.info(f"✅ Found {len(tools)} tools: {[t.name for t in tools]}")
131+
132+
agent = create_agent(base_model, tools)
133+
134+
# Prepare query with context
135+
today = datetime.now().strftime("%Y-%m-%d")
136+
user_query = "Add an expense: yesterday I bought a laptop for $1200 using my visa."
137+
138+
logger.info(f"💬 User query: {user_query}")
139+
140+
# Invoke agent
141+
response = await agent.ainvoke(
142+
{"messages": [SystemMessage(content=f"Today's date is {today}."), HumanMessage(content=user_query)]}
143+
)
144+
145+
# Display result
146+
logger.info("=" * 60)
147+
logger.info("📊 Agent Response:")
148+
logger.info("=" * 60)
149+
150+
final_message = response["messages"][-1]
151+
print(final_message.content)
152+
153+
154+
async def main():
155+
print("=" * 60)
156+
print("LangChain Agent with Keycloak-Protected MCP Server")
157+
print("=" * 60)
158+
print(f"\nConfiguration:")
159+
print(f" MCP Server: {MCP_SERVER_URL}")
160+
print(f" Keycloak: {KEYCLOAK_REALM_URL}")
161+
print(f" Client ID: {CLIENT_ID[:20]}...")
162+
print(f" LLM Host: {API_HOST}")
163+
print()
164+
165+
await run_agent()
166+
167+
168+
if __name__ == "__main__":
169+
asyncio.run(main())

azure.yaml

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,30 @@ name: python-mcp-demo
44
metadata:
55
66
services:
7-
# Not using remoteBuild due to private endpoint usage
7+
# MCP Server WITH Keycloak authentication (keycloak_deployed_mcp.py)
88
aca:
99
project: .
1010
language: docker
1111
host: containerapp
1212
docker:
1313
path: ./servers/Dockerfile
1414
context: .
15+
# MCP Server WITHOUT authentication (deployed_mcp.py)
16+
mcpnoauth:
17+
project: .
18+
language: docker
19+
host: containerapp
20+
docker:
21+
path: ./servers/Dockerfile.noauth
22+
context: .
23+
# Keycloak identity provider with MCP realm pre-configured
24+
keycloak:
25+
project: .
26+
language: docker
27+
host: containerapp
28+
docker:
29+
path: ./infra/Dockerfile.keycloak
30+
context: .
1531
hooks:
1632
postprovision:
1733
posix:

infra/Dockerfile.keycloak

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
FROM quay.io/keycloak/keycloak:26.0
2+
3+
# Copy the MCP realm configuration for import on startup
4+
COPY infra/keycloak-realm.json /opt/keycloak/data/import/mcp-realm.json
5+
6+
# Expose port 8080
7+
EXPOSE 8080
8+
9+
ENTRYPOINT ["/opt/keycloak/bin/kc.sh"]
10+
11+
# Start in dev mode with H2 database
12+
# --proxy-headers=xforwarded tells Keycloak it's behind a reverse proxy that sets X-Forwarded-* headers
13+
# --hostname-strict=false allows dynamic hostname resolution from proxy headers
14+
# --import-realm imports the MCP realm on startup
15+
CMD ["start-dev", "--http-port=8080", "--proxy-headers=xforwarded", "--hostname-strict=false", "--import-realm"]

infra/aca-noauth.bicep

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
// MCP Server WITHOUT Keycloak authentication (deployed_mcp.py)
2+
param name string
3+
param location string = resourceGroup().location
4+
param tags object = {}
5+
6+
param identityName string
7+
param containerAppsEnvironmentName string
8+
param containerRegistryName string
9+
param serviceName string = 'mcpnoauth'
10+
param exists bool
11+
param openAiDeploymentName string
12+
param openAiEndpoint string
13+
param cosmosDbAccount string
14+
param cosmosDbDatabase string
15+
param cosmosDbContainer string
16+
17+
resource mcpNoAuthIdentity 'Microsoft.ManagedIdentity/userAssignedIdentities@2023-01-31' = {
18+
name: identityName
19+
location: location
20+
}
21+
22+
module app 'core/host/container-app-upsert.bicep' = {
23+
name: '${serviceName}-container-app-module'
24+
params: {
25+
name: name
26+
location: location
27+
tags: union(tags, { 'azd-service-name': serviceName })
28+
identityName: mcpNoAuthIdentity.name
29+
exists: exists
30+
containerAppsEnvironmentName: containerAppsEnvironmentName
31+
containerRegistryName: containerRegistryName
32+
ingressEnabled: true
33+
env: [
34+
{
35+
name: 'AZURE_OPENAI_CHAT_DEPLOYMENT'
36+
value: openAiDeploymentName
37+
}
38+
{
39+
name: 'AZURE_OPENAI_ENDPOINT'
40+
value: openAiEndpoint
41+
}
42+
{
43+
name: 'RUNNING_IN_PRODUCTION'
44+
value: 'true'
45+
}
46+
{
47+
name: 'AZURE_CLIENT_ID'
48+
value: mcpNoAuthIdentity.properties.clientId
49+
}
50+
{
51+
name: 'AZURE_COSMOSDB_ACCOUNT'
52+
value: cosmosDbAccount
53+
}
54+
{
55+
name: 'AZURE_COSMOSDB_DATABASE'
56+
value: cosmosDbDatabase
57+
}
58+
{
59+
name: 'AZURE_COSMOSDB_CONTAINER'
60+
value: cosmosDbContainer
61+
}
62+
]
63+
targetPort: 8000
64+
}
65+
}
66+
67+
output identityPrincipalId string = mcpNoAuthIdentity.properties.principalId
68+
output name string = app.outputs.name
69+
output hostName string = app.outputs.hostName
70+
output uri string = app.outputs.uri
71+
output imageName string = app.outputs.imageName

infra/aca.bicep

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,11 @@ param cosmosDbAccount string
1313
param cosmosDbDatabase string
1414
param cosmosDbContainer string
1515

16+
// Keycloak authentication parameters
17+
param keycloakRealmUrl string
18+
param mcpServerBaseUrl string
19+
param mcpServerAudience string = 'mcp-server'
20+
1621
resource acaIdentity 'Microsoft.ManagedIdentity/userAssignedIdentities@2023-01-31' = {
1722
name: identityName
1823
location: location
@@ -58,6 +63,19 @@ module app 'core/host/container-app-upsert.bicep' = {
5863
name: 'AZURE_COSMOSDB_CONTAINER'
5964
value: cosmosDbContainer
6065
}
66+
// Keycloak authentication environment variables
67+
{
68+
name: 'KEYCLOAK_REALM_URL'
69+
value: keycloakRealmUrl
70+
}
71+
{
72+
name: 'MCP_SERVER_BASE_URL'
73+
value: mcpServerBaseUrl
74+
}
75+
{
76+
name: 'MCP_SERVER_AUDIENCE'
77+
value: mcpServerAudience
78+
}
6179
]
6280
targetPort: 8000
6381
}

0 commit comments

Comments
 (0)