Skip to content

Commit 8627170

Browse files
committed
addresses feedback from Pamela
1 parent 74f2e6d commit 8627170

18 files changed

+416
-691
lines changed

README.md

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ A demonstration project showcasing Model Context Protocol (MCP) implementations
1515
- [Run local Agents <-> MCP](#run-local-agents---mcp)
1616
- [Deploy to Azure](#deploy-to-azure)
1717
- [Deploy to Azure with private networking](#deploy-to-azure-with-private-networking)
18+
- [Deploy to Azure with Keycloak authentication](#deploy-to-azure-with-keycloak-authentication)
1819

1920
## Getting started
2021

@@ -276,3 +277,122 @@ When using VNet configuration, additional Azure resources are provisioned:
276277
- **Virtual Network**: Pay-as-you-go tier. Costs based on data processed. [Pricing](https://azure.microsoft.com/pricing/details/virtual-network/)
277278
- **Azure Private DNS Resolver**: Pricing per month, endpoints, and zones. [Pricing](https://azure.microsoft.com/pricing/details/dns/)
278279
- **Azure Private Endpoints**: Pricing per hour per endpoint. [Pricing](https://azure.microsoft.com/pricing/details/private-link/)
280+
281+
---
282+
283+
## Deploy to Azure with Keycloak authentication
284+
285+
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).
286+
287+
### Architecture
288+
289+
```text
290+
┌─────────────┐ 1. DCR + Token ┌─────────────┐
291+
│ LangChain │ ──────────────────────► │ Keycloak │
292+
│ Agent │ ◄────────────────────── │ (direct) │
293+
│ │ └─────────────┘
294+
│ │ 2. MCP + Bearer
295+
│ │ ──────────────────────► ┌─────────────┐
296+
│ │ ◄────────────────────── │ mcproutes │ → MCP Server
297+
└─────────────┘ └─────────────┘
298+
```
299+
300+
### What gets deployed
301+
302+
| Component | Description |
303+
|-----------|-------------|
304+
| **Keycloak Container App** | Keycloak 26.0 with pre-configured realm |
305+
| **HTTP Route Configuration** | Rule-based routing: `/auth/*` → Keycloak, `/*` → MCP Server |
306+
| **OAuth-protected MCP Server** | FastMCP with JWT validation against Keycloak's JWKS endpoint |
307+
308+
### Deployment steps
309+
310+
1. Set the Keycloak admin password (required):
311+
312+
```bash
313+
azd env set KEYCLOAK_ADMIN_PASSWORD "YourSecurePassword123!"
314+
```
315+
316+
2. Optionally customize the realm name (default: `mcp`):
317+
318+
```bash
319+
azd env set KEYCLOAK_REALM_NAME "mcp"
320+
```
321+
322+
3. Deploy to Azure:
323+
324+
```bash
325+
azd up
326+
```
327+
328+
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.
329+
330+
4. Verify deployment by checking the outputs:
331+
332+
```bash
333+
azd env get-value MCP_SERVER_URL
334+
azd env get-value KEYCLOAK_DIRECT_URL
335+
azd env get-value KEYCLOAK_ADMIN_CONSOLE
336+
```
337+
338+
5. Visit the Keycloak admin console to verify the realm is configured:
339+
340+
```text
341+
https://<your-mcproutes-url>/auth/admin
342+
```
343+
344+
Login with `admin` and your configured password.
345+
346+
### Testing with the LangChain agent
347+
348+
1. Generate the local environment file:
349+
350+
```bash
351+
./infra/write_env.sh
352+
```
353+
354+
This creates `.env` with `KEYCLOAK_REALM_URL`, `MCP_SERVER_URL`, and Azure OpenAI settings.
355+
356+
2. Run the agent:
357+
358+
```bash
359+
uv run agents/langchainv1_keycloak.py
360+
```
361+
362+
Expected output:
363+
364+
```text
365+
============================================================
366+
LangChain Agent with Keycloak-Protected MCP Server
367+
============================================================
368+
369+
Configuration:
370+
MCP Server: https://mcproutes.<env>.azurecontainerapps.io/mcp
371+
Keycloak: https://mcp-<name>-kc.<env>.azurecontainerapps.io/realms/mcp
372+
LLM Host: azure
373+
Auth: Dynamic Client Registration (DCR)
374+
375+
[11:40:48] INFO 📝 Registering client via DCR...
376+
INFO ✅ Registered client: caef6f47-0243-474d-b...
377+
INFO 🔑 Getting access token from Keycloak...
378+
INFO ✅ Got access token (expires in 300s)
379+
INFO 📡 Connecting to MCP server...
380+
INFO 🔧 Getting available tools...
381+
INFO ✅ Found 1 tools: ['add_expense']
382+
INFO 💬 User query: Add an expense: yesterday I bought a laptop...
383+
...
384+
INFO 📊 Agent Response:
385+
The expense of $1200 for the laptop purchase has been successfully recorded.
386+
```
387+
388+
### Known limitations (demo trade-offs)
389+
390+
| Item | Current | Production Recommendation | Why |
391+
|------|---------|---------------------------|-----|
392+
| Keycloak mode | `start-dev` | `start` with proper config | Dev mode has relaxed security defaults |
393+
| Database | H2 in-memory | PostgreSQL | H2 doesn't persist data across restarts |
394+
| Replicas | 1 (due to H2) | Multiple with shared DB | H2 is in-memory, can't share state |
395+
| Keycloak access | Public (direct URL) | Internal only via routes | Route URL isn't known until after deployment |
396+
| DCR | Open (anonymous) | Require initial access token | Any client can register without auth |
397+
398+
> **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.

agents/agentframework_http.py

Lines changed: 8 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
import os
66
from datetime import datetime
77

8-
import httpx
98
from agent_framework import ChatAgent, MCPStreamableHTTPTool
109
from agent_framework.azure import AzureOpenAIChatClient
1110
from agent_framework.openai import OpenAIChatClient
@@ -14,6 +13,11 @@
1413
from rich import print
1514
from rich.logging import RichHandler
1615

16+
try:
17+
from keycloak_auth import get_auth_headers
18+
except ImportError:
19+
from agents.keycloak_auth import get_auth_headers
20+
1721
# Configure logging
1822
logging.basicConfig(level=logging.WARNING, format="%(message)s", datefmt="[%X]", handlers=[RichHandler()])
1923
logger = logging.getLogger("agentframework_mcp_http")
@@ -56,65 +60,6 @@
5660
)
5761

5862

59-
# --- Keycloak Authentication Helpers (only used if KEYCLOAK_REALM_URL is set) ---
60-
61-
62-
async def register_client_via_dcr() -> tuple[str, str]:
63-
"""Register a new client dynamically using Keycloak's DCR endpoint."""
64-
dcr_url = f"{KEYCLOAK_REALM_URL}/clients-registrations/openid-connect"
65-
logger.info("📝 Registering client via DCR...")
66-
67-
async with httpx.AsyncClient() as http_client:
68-
response = await http_client.post(
69-
dcr_url,
70-
json={
71-
"client_name": f"agent-{datetime.now().strftime('%Y%m%d-%H%M%S')}",
72-
"grant_types": ["client_credentials"],
73-
"token_endpoint_auth_method": "client_secret_basic",
74-
},
75-
headers={"Content-Type": "application/json"},
76-
)
77-
if response.status_code not in (200, 201):
78-
raise RuntimeError(f"DCR registration failed: {response.status_code} - {response.text}")
79-
80-
data = response.json()
81-
logger.info(f"✅ Registered client: {data['client_id'][:20]}...")
82-
return data["client_id"], data["client_secret"]
83-
84-
85-
async def get_keycloak_token(client_id: str, client_secret: str) -> str:
86-
"""Get an access token from Keycloak using client_credentials grant."""
87-
token_url = f"{KEYCLOAK_REALM_URL}/protocol/openid-connect/token"
88-
logger.info("🔑 Getting access token from Keycloak...")
89-
90-
async with httpx.AsyncClient() as http_client:
91-
response = await http_client.post(
92-
token_url,
93-
data={
94-
"grant_type": "client_credentials",
95-
"client_id": client_id,
96-
"client_secret": client_secret,
97-
},
98-
headers={"Content-Type": "application/x-www-form-urlencoded"},
99-
)
100-
if response.status_code != 200:
101-
raise RuntimeError(f"Token request failed: {response.status_code} - {response.text}")
102-
103-
token_data = response.json()
104-
logger.info(f"✅ Got access token (expires in {token_data.get('expires_in', '?')}s)")
105-
return token_data["access_token"]
106-
107-
108-
async def get_auth_headers() -> dict[str, str] | None:
109-
"""Get authorization headers if Keycloak is configured, otherwise return None."""
110-
if not KEYCLOAK_REALM_URL:
111-
return None
112-
113-
client_id, client_secret = await register_client_via_dcr()
114-
access_token = await get_keycloak_token(client_id, client_secret)
115-
return {"Authorization": f"Bearer {access_token}"}
116-
117-
11863
# --- Main Agent Logic ---
11964

12065

@@ -126,7 +71,7 @@ async def http_mcp_example() -> None:
12671
Otherwise, connects without authentication.
12772
"""
12873
# Get auth headers if Keycloak is configured
129-
headers = await get_auth_headers()
74+
headers = await get_auth_headers(KEYCLOAK_REALM_URL, client_name_prefix="agentframework")
13075
if headers:
13176
logger.info(f"🔐 Auth enabled - connecting to {MCP_SERVER_URL} with Bearer token")
13277
else:
@@ -137,12 +82,11 @@ async def http_mcp_example() -> None:
13782
ChatAgent(
13883
chat_client=client,
13984
name="Expenses Agent",
140-
instructions="You help users to log expenses.",
85+
instructions=f"You help users to log expenses. Today's date is {datetime.now().strftime('%Y-%m-%d')}.",
14186
) as agent,
14287
):
143-
today = datetime.now().strftime("%Y-%m-%d")
14488
user_query = "yesterday I bought a laptop for $1200 using my visa."
145-
result = await agent.run(f"Today's date is {today}. {user_query}", tools=mcp_server)
89+
result = await agent.run(user_query, tools=mcp_server)
14690
print(result)
14791

14892
# Keep the worker alive in production

agents/keycloak_auth.py

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
"""
2+
Keycloak authentication helpers for MCP agents.
3+
4+
Provides OAuth2 client credentials flow authentication via Keycloak's
5+
Dynamic Client Registration (DCR) endpoint.
6+
7+
Usage:
8+
from keycloak_auth import get_auth_headers
9+
10+
headers = await get_auth_headers(keycloak_realm_url)
11+
# Returns {"Authorization": "Bearer <token>"} or None if no URL provided
12+
"""
13+
14+
from __future__ import annotations
15+
16+
import logging
17+
from datetime import datetime
18+
19+
import httpx
20+
21+
logger = logging.getLogger(__name__)
22+
23+
24+
async def register_client_via_dcr(keycloak_realm_url: str, client_name_prefix: str = "agent") -> tuple[str, str]:
25+
"""
26+
Register a new client dynamically using Keycloak's DCR endpoint.
27+
28+
Args:
29+
keycloak_realm_url: The Keycloak realm URL (e.g., http://localhost:8080/realms/myrealm)
30+
client_name_prefix: Prefix for the generated client name
31+
32+
Returns:
33+
Tuple of (client_id, client_secret)
34+
35+
Raises:
36+
RuntimeError: If DCR registration fails
37+
"""
38+
dcr_url = f"{keycloak_realm_url}/clients-registrations/openid-connect"
39+
logger.info("📝 Registering client via DCR...")
40+
41+
async with httpx.AsyncClient() as http_client:
42+
response = await http_client.post(
43+
dcr_url,
44+
json={
45+
"client_name": f"{client_name_prefix}-{datetime.now().strftime('%Y%m%d-%H%M%S')}",
46+
"grant_types": ["client_credentials"],
47+
"token_endpoint_auth_method": "client_secret_basic",
48+
},
49+
headers={"Content-Type": "application/json"},
50+
)
51+
52+
if response.status_code not in (200, 201):
53+
raise RuntimeError(
54+
f"DCR registration failed at {dcr_url}: "
55+
f"status={response.status_code}, "
56+
f"response={response.text}"
57+
)
58+
59+
data = response.json()
60+
logger.info(f"✅ Registered client: {data['client_id'][:20]}...")
61+
return data["client_id"], data["client_secret"]
62+
63+
64+
async def get_keycloak_token(keycloak_realm_url: str, client_id: str, client_secret: str) -> str:
65+
"""
66+
Get an access token from Keycloak using client_credentials grant.
67+
68+
Args:
69+
keycloak_realm_url: The Keycloak realm URL
70+
client_id: The OAuth client ID
71+
client_secret: The OAuth client secret
72+
73+
Returns:
74+
The access token string
75+
76+
Raises:
77+
RuntimeError: If token request fails
78+
"""
79+
token_url = f"{keycloak_realm_url}/protocol/openid-connect/token"
80+
logger.info("🔑 Getting access token from Keycloak...")
81+
82+
async with httpx.AsyncClient() as http_client:
83+
response = await http_client.post(
84+
token_url,
85+
data={
86+
"grant_type": "client_credentials",
87+
"client_id": client_id,
88+
"client_secret": client_secret,
89+
},
90+
headers={"Content-Type": "application/x-www-form-urlencoded"},
91+
)
92+
93+
if response.status_code != 200:
94+
raise RuntimeError(
95+
f"Token request failed at {token_url}: "
96+
f"status={response.status_code}, "
97+
f"response={response.text}"
98+
)
99+
100+
token_data = response.json()
101+
logger.info(f"✅ Got access token (expires in {token_data.get('expires_in', '?')}s)")
102+
return token_data["access_token"]
103+
104+
105+
async def get_auth_headers(
106+
keycloak_realm_url: str | None, client_name_prefix: str = "agent"
107+
) -> dict[str, str] | None:
108+
"""
109+
Get authorization headers if Keycloak is configured.
110+
111+
This is the main entry point for agents that need OAuth authentication.
112+
It handles the full flow: DCR registration -> token acquisition -> headers.
113+
114+
Args:
115+
keycloak_realm_url: The Keycloak realm URL, or None to skip auth
116+
client_name_prefix: Prefix for the dynamically registered client name
117+
118+
Returns:
119+
{"Authorization": "Bearer <token>"} if keycloak_realm_url is set, None otherwise
120+
"""
121+
if not keycloak_realm_url:
122+
return None
123+
124+
client_id, client_secret = await register_client_via_dcr(keycloak_realm_url, client_name_prefix)
125+
access_token = await get_keycloak_token(keycloak_realm_url, client_id, client_secret)
126+
return {"Authorization": f"Bearer {access_token}"}

0 commit comments

Comments
 (0)