Skip to content

Commit 1f69dee

Browse files
authored
Merge pull request Azure-Samples#12 from madebygps/keycloak-integration
Keycloak integration
2 parents 9cca618 + 2c25546 commit 1f69dee

File tree

17 files changed

+851
-105
lines changed

17 files changed

+851
-105
lines changed

.dockerignore

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,12 @@
22
.venv
33
.logfire
44
.devcontainer
5-
infra
5+
6+
# Exclude most of infra, but allow keycloak files needed for builds
7+
infra/*.bicep
8+
infra/*.ps1
9+
infra/*.sh
10+
infra/core
611

712
# Common Python and development files to exclude
813
__pycache__

.vscode/mcp.json

Lines changed: 32 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,33 @@
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+
},
32+
"inputs": []
33+
}

README.md

Lines changed: 86 additions & 2 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

@@ -71,14 +72,15 @@ If you're not using one of the above options, then you'll need to:
7172

7273
## Run local MCP servers
7374

74-
This project includes two MCP servers in the [`servers/`](servers/) directory:
75+
This project includes MCP servers in the [`servers/`](servers/) directory:
7576

7677
| File | Description |
7778
|------|-------------|
7879
| [servers/basic_mcp_stdio.py](servers/basic_mcp_stdio.py) | MCP server with stdio transport for VS Code integration |
7980
| [servers/basic_mcp_http.py](servers/basic_mcp_http.py) | MCP server with HTTP transport on port 8000 |
81+
| [servers/deployed_mcp.py](servers/deployed_mcp.py) | MCP server for Azure deployment with Cosmos DB and optional Keycloak auth |
8082

81-
Both servers implement an "Expenses Tracker" with a tool to add expenses to a CSV file.
83+
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.
8284

8385
### Use with GitHub Copilot
8486

@@ -276,3 +278,85 @@ When using VNet configuration, additional Azure resources are provisioned:
276278
- **Virtual Network**: Pay-as-you-go tier. Costs based on data processed. [Pricing](https://azure.microsoft.com/pricing/details/virtual-network/)
277279
- **Azure Private DNS Resolver**: Pricing per month, endpoints, and zones. [Pricing](https://azure.microsoft.com/pricing/details/dns/)
278280
- **Azure Private Endpoints**: Pricing per hour per endpoint. [Pricing](https://azure.microsoft.com/pricing/details/private-link/)
281+
282+
---
283+
284+
## Deploy to Azure with Keycloak authentication
285+
286+
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).
287+
288+
### What gets deployed
289+
290+
| Component | Description |
291+
|-----------|-------------|
292+
| **Keycloak Container App** | Keycloak 26.0 with pre-configured realm |
293+
| **HTTP Route Configuration** | Rule-based routing: `/auth/*` → Keycloak, `/*` → MCP Server |
294+
| **OAuth-protected MCP Server** | FastMCP with JWT validation against Keycloak's JWKS endpoint |
295+
296+
### Deployment steps
297+
298+
1. Set the Keycloak admin password (required):
299+
300+
```bash
301+
azd env set KEYCLOAK_ADMIN_PASSWORD "YourSecurePassword123!"
302+
```
303+
304+
2. Optionally customize the realm name (default: `mcp`):
305+
306+
```bash
307+
azd env set KEYCLOAK_REALM_NAME "mcp"
308+
```
309+
310+
3. Deploy to Azure:
311+
312+
```bash
313+
azd up
314+
```
315+
316+
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.
317+
318+
4. Verify deployment by checking the outputs:
319+
320+
```bash
321+
azd env get-value MCP_SERVER_URL
322+
azd env get-value KEYCLOAK_DIRECT_URL
323+
azd env get-value KEYCLOAK_ADMIN_CONSOLE
324+
```
325+
326+
5. Visit the Keycloak admin console to verify the realm is configured:
327+
328+
```text
329+
https://<your-mcproutes-url>/auth/admin
330+
```
331+
332+
Login with `admin` and your configured password.
333+
334+
### Testing with the agent
335+
336+
1. Generate the local environment file (automatically created after `azd up`):
337+
338+
```bash
339+
./infra/write_env.sh
340+
```
341+
342+
This creates `.env` with `KEYCLOAK_REALM_URL`, `MCP_SERVER_URL`, and Azure OpenAI settings.
343+
344+
2. Run the agent:
345+
346+
```bash
347+
uv run agents/agentframework_http.py
348+
```
349+
350+
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.
351+
352+
### Known limitations (demo trade-offs)
353+
354+
| Item | Current | Production Recommendation | Why |
355+
|------|---------|---------------------------|-----|
356+
| Keycloak mode | `start-dev` | `start` with proper config | Dev mode has relaxed security defaults |
357+
| Database | H2 in-memory | PostgreSQL | H2 doesn't persist data across restarts |
358+
| Replicas | 1 (due to H2) | Multiple with shared DB | H2 is in-memory, can't share state |
359+
| Keycloak access | Public (direct URL) | Internal only via routes | Route URL isn't known until after deployment |
360+
| DCR | Open (anonymous) | Require initial access token | Any client can register without auth |
361+
362+
> **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: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import asyncio
44
import logging
55
import os
6+
from datetime import datetime
67

78
from agent_framework import ChatAgent, MCPStreamableHTTPTool
89
from agent_framework.azure import AzureOpenAIChatClient
@@ -12,6 +13,11 @@
1213
from rich import print
1314
from rich.logging import RichHandler
1415

16+
try:
17+
from keycloak_auth import get_auth_headers
18+
except ImportError:
19+
from agents.keycloak_auth import get_auth_headers
20+
1521
# Configure logging
1622
logging.basicConfig(level=logging.WARNING, format="%(message)s", datefmt="[%X]", handlers=[RichHandler()])
1723
logger = logging.getLogger("agentframework_mcp_http")
@@ -23,6 +29,9 @@
2329
RUNNING_IN_PRODUCTION = os.getenv("RUNNING_IN_PRODUCTION", "false").lower() == "true"
2430
MCP_SERVER_URL = os.getenv("MCP_SERVER_URL", "http://localhost:8000/mcp/")
2531

32+
# Optional: Keycloak authentication (set KEYCLOAK_REALM_URL to enable)
33+
KEYCLOAK_REALM_URL = os.getenv("KEYCLOAK_REALM_URL")
34+
2635
# Configure chat client based on API_HOST
2736
API_HOST = os.getenv("API_HOST", "github")
2837

@@ -51,19 +60,29 @@
5160
)
5261

5362

63+
# --- Main Agent Logic ---
64+
65+
5466
async def http_mcp_example() -> None:
5567
"""
56-
Demonstrate MCP integration with the local Expenses MCP server.
68+
Demonstrate MCP integration with the Expenses MCP server.
5769
58-
Creates an agent that can help users log expenses
59-
using the Expenses MCP server at http://localhost:8000/mcp/.
70+
If KEYCLOAK_REALM_URL is set, authenticates via OAuth (DCR + client credentials).
71+
Otherwise, connects without authentication.
6072
"""
73+
# Get auth headers if Keycloak is configured
74+
headers = await get_auth_headers(KEYCLOAK_REALM_URL, client_name_prefix="agentframework")
75+
if headers:
76+
logger.info(f"🔐 Auth enabled - connecting to {MCP_SERVER_URL} with Bearer token")
77+
else:
78+
logger.info(f"📡 No auth - connecting to {MCP_SERVER_URL}")
79+
6180
async with (
62-
MCPStreamableHTTPTool(name="Expenses MCP Server", url=MCP_SERVER_URL) as mcp_server,
81+
MCPStreamableHTTPTool(name="Expenses MCP Server", url=MCP_SERVER_URL, headers=headers) as mcp_server,
6382
ChatAgent(
6483
chat_client=client,
6584
name="Expenses Agent",
66-
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')}.",
6786
) as agent,
6887
):
6988
user_query = "yesterday I bought a laptop for $1200 using my visa."

agents/keycloak_auth.py

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
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}: status={response.status_code}, response={response.text}"
55+
)
56+
57+
data = response.json()
58+
logger.info(f"✅ Registered client: {data['client_id'][:20]}...")
59+
return data["client_id"], data["client_secret"]
60+
61+
62+
async def get_keycloak_token(keycloak_realm_url: str, client_id: str, client_secret: str) -> str:
63+
"""
64+
Get an access token from Keycloak using client_credentials grant.
65+
66+
Args:
67+
keycloak_realm_url: The Keycloak realm URL
68+
client_id: The OAuth client ID
69+
client_secret: The OAuth client secret
70+
71+
Returns:
72+
The access token string
73+
74+
Raises:
75+
RuntimeError: If token request fails
76+
"""
77+
token_url = f"{keycloak_realm_url}/protocol/openid-connect/token"
78+
logger.info("🔑 Getting access token from Keycloak...")
79+
80+
async with httpx.AsyncClient() as http_client:
81+
response = await http_client.post(
82+
token_url,
83+
data={
84+
"grant_type": "client_credentials",
85+
"client_id": client_id,
86+
"client_secret": client_secret,
87+
},
88+
headers={"Content-Type": "application/x-www-form-urlencoded"},
89+
)
90+
91+
if response.status_code != 200:
92+
raise RuntimeError(
93+
f"Token request failed at {token_url}: status={response.status_code}, response={response.text}"
94+
)
95+
96+
token_data = response.json()
97+
logger.info(f"✅ Got access token (expires in {token_data.get('expires_in', '?')}s)")
98+
return token_data["access_token"]
99+
100+
101+
async def get_auth_headers(keycloak_realm_url: str | None, client_name_prefix: str = "agent") -> dict[str, str] | None:
102+
"""
103+
Get authorization headers if Keycloak is configured.
104+
105+
This is the main entry point for agents that need OAuth authentication.
106+
It handles the full flow: DCR registration -> token acquisition -> headers.
107+
108+
Args:
109+
keycloak_realm_url: The Keycloak realm URL, or None to skip auth
110+
client_name_prefix: Prefix for the dynamically registered client name
111+
112+
Returns:
113+
{"Authorization": "Bearer <token>"} if keycloak_realm_url is set, None otherwise
114+
"""
115+
if not keycloak_realm_url:
116+
return None
117+
118+
client_id, client_secret = await register_client_via_dcr(keycloak_realm_url, client_name_prefix)
119+
access_token = await get_keycloak_token(keycloak_realm_url, client_id, client_secret)
120+
return {"Authorization": f"Bearer {access_token}"}

azure.yaml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,13 @@ services:
1313
remoteBuild: true
1414
path: ./servers/Dockerfile
1515
context: .
16+
keycloak:
17+
project: .
18+
language: docker
19+
host: containerapp
20+
docker:
21+
path: ./keycloak/Dockerfile
22+
context: .
1623
agent:
1724
project: .
1825
language: docker

0 commit comments

Comments
 (0)