Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,12 @@
.venv
.logfire
.devcontainer
infra

# Exclude most of infra, but allow keycloak files needed for builds
infra/*.bicep
infra/*.ps1
infra/*.sh
infra/core
Copy link

Copilot AI Dec 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The .dockerignore file excludes infra/keycloak-realm.json but this file is needed by the Keycloak Dockerfile (see line 4 of Dockerfile.keycloak). This will cause the Docker build to fail with a "file not found" error.

To fix this, add an exception to allow the keycloak-realm.json file:

# Exclude most of infra, but allow keycloak files needed for builds
infra/*.bicep
infra/*.ps1
infra/*.sh
infra/core
!infra/keycloak-realm.json
Suggested change
infra/core
infra/core
!infra/keycloak-realm.json

Copilot uses AI. Check for mistakes.

# Common Python and development files to exclude
__pycache__
Expand Down
64 changes: 32 additions & 32 deletions .vscode/mcp.json
Original file line number Diff line number Diff line change
@@ -1,33 +1,33 @@
{
"servers": {
"expenses-mcp": {
"type": "stdio",
"command": "uv",
"cwd": "${workspaceFolder}",
"args": [
"run",
"servers/basic_mcp_stdio.py"
]
},
"expenses-mcp-http": {
"type": "http",
"url": "http://localhost:8000/mcp"
},
"expenses-mcp-debug": {
"type": "stdio",
"command": "uv",
"cwd": "${workspaceFolder}",
"args": [
"run",
"--",
"python",
"-m",
"debugpy",
"--listen",
"0.0.0.0:5678",
"servers/basic_mcp_stdio.py"
]
}
},
"inputs": []
}
"servers": {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's the whitespace diff from? Pre-commit? (I did add precommit btw if you want to install it)

"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": []
}
88 changes: 86 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

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

## Run local MCP servers

This project includes two MCP servers in the [`servers/`](servers/) directory:
This project includes MCP servers in the [`servers/`](servers/) directory:

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

Both servers implement an "Expenses Tracker" with a tool to add expenses to a CSV file.
The local servers (`basic_mcp_stdio.py` and `basic_mcp_http.py`) implement an "Expenses Tracker" with a tool to add expenses to a CSV file.

### Use with GitHub Copilot

Expand Down Expand Up @@ -276,3 +278,85 @@ When using VNet configuration, additional Azure resources are provisioned:
- **Virtual Network**: Pay-as-you-go tier. Costs based on data processed. [Pricing](https://azure.microsoft.com/pricing/details/virtual-network/)
- **Azure Private DNS Resolver**: Pricing per month, endpoints, and zones. [Pricing](https://azure.microsoft.com/pricing/details/dns/)
- **Azure Private Endpoints**: Pricing per hour per endpoint. [Pricing](https://azure.microsoft.com/pricing/details/private-link/)

---

## Deploy to Azure with Keycloak authentication

This project supports deploying with OAuth 2.0 authentication using Keycloak as the identity provider, implementing the [MCP OAuth specification](https://modelcontextprotocol.io/specification/2025-03-26/basic/authorization) with Dynamic Client Registration (DCR).

### What gets deployed

| Component | Description |
|-----------|-------------|
| **Keycloak Container App** | Keycloak 26.0 with pre-configured realm |
| **HTTP Route Configuration** | Rule-based routing: `/auth/*` → Keycloak, `/*` → MCP Server |
| **OAuth-protected MCP Server** | FastMCP with JWT validation against Keycloak's JWKS endpoint |

### Deployment steps

1. Set the Keycloak admin password (required):

```bash
azd env set KEYCLOAK_ADMIN_PASSWORD "YourSecurePassword123!"
```

2. Optionally customize the realm name (default: `mcp`):

```bash
azd env set KEYCLOAK_REALM_NAME "mcp"
```

3. Deploy to Azure:

```bash
azd up
```

This will create the Azure Container Apps environment, deploy Keycloak with the pre-configured realm, deploy the MCP server with OAuth validation, and configure HTTP route-based routing.

4. Verify deployment by checking the outputs:

```bash
azd env get-value MCP_SERVER_URL
azd env get-value KEYCLOAK_DIRECT_URL
azd env get-value KEYCLOAK_ADMIN_CONSOLE
```

5. Visit the Keycloak admin console to verify the realm is configured:

```text
https://<your-mcproutes-url>/auth/admin
```

Login with `admin` and your configured password.

### Testing with the agent

1. Generate the local environment file (automatically created after `azd up`):

```bash
./infra/write_env.sh
```

This creates `.env` with `KEYCLOAK_REALM_URL`, `MCP_SERVER_URL`, and Azure OpenAI settings.

2. Run the agent:

```bash
uv run agents/agentframework_http.py
```

The agent automatically detects `KEYCLOAK_REALM_URL` in the environment and authenticates via DCR + client credentials. On success, it will add an expense and print the result.

### Known limitations (demo trade-offs)

| Item | Current | Production Recommendation | Why |
|------|---------|---------------------------|-----|
| Keycloak mode | `start-dev` | `start` with proper config | Dev mode has relaxed security defaults |
| Database | H2 in-memory | PostgreSQL | H2 doesn't persist data across restarts |
| Replicas | 1 (due to H2) | Multiple with shared DB | H2 is in-memory, can't share state |
| Keycloak access | Public (direct URL) | Internal only via routes | Route URL isn't known until after deployment |
| DCR | Open (anonymous) | Require initial access token | Any client can register without auth |

> **Note:** Keycloak must be publicly accessible because its URL is dynamically generated by Azure. Token issuer validation requires a known URL, but the mcproutes URL isn't available until after deployment. Using a custom domain would fix this.
29 changes: 24 additions & 5 deletions agents/agentframework_http.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import asyncio
import logging
import os
from datetime import datetime

from agent_framework import ChatAgent, MCPStreamableHTTPTool
from agent_framework.azure import AzureOpenAIChatClient
Expand All @@ -12,6 +13,11 @@
from rich import print
from rich.logging import RichHandler

try:
from keycloak_auth import get_auth_headers
except ImportError:
from agents.keycloak_auth import get_auth_headers

# Configure logging
logging.basicConfig(level=logging.WARNING, format="%(message)s", datefmt="[%X]", handlers=[RichHandler()])
logger = logging.getLogger("agentframework_mcp_http")
Expand All @@ -23,6 +29,9 @@
RUNNING_IN_PRODUCTION = os.getenv("RUNNING_IN_PRODUCTION", "false").lower() == "true"
MCP_SERVER_URL = os.getenv("MCP_SERVER_URL", "http://localhost:8000/mcp/")

# Optional: Keycloak authentication (set KEYCLOAK_REALM_URL to enable)
KEYCLOAK_REALM_URL = os.getenv("KEYCLOAK_REALM_URL")

# Configure chat client based on API_HOST
API_HOST = os.getenv("API_HOST", "github")

Expand Down Expand Up @@ -51,19 +60,29 @@
)


# --- Main Agent Logic ---


async def http_mcp_example() -> None:
"""
Demonstrate MCP integration with the local Expenses MCP server.
Demonstrate MCP integration with the Expenses MCP server.

Creates an agent that can help users log expenses
using the Expenses MCP server at http://localhost:8000/mcp/.
If KEYCLOAK_REALM_URL is set, authenticates via OAuth (DCR + client credentials).
Otherwise, connects without authentication.
"""
# Get auth headers if Keycloak is configured
headers = await get_auth_headers(KEYCLOAK_REALM_URL, client_name_prefix="agentframework")
if headers:
logger.info(f"🔐 Auth enabled - connecting to {MCP_SERVER_URL} with Bearer token")
else:
logger.info(f"📡 No auth - connecting to {MCP_SERVER_URL}")

async with (
MCPStreamableHTTPTool(name="Expenses MCP Server", url=MCP_SERVER_URL) as mcp_server,
MCPStreamableHTTPTool(name="Expenses MCP Server", url=MCP_SERVER_URL, headers=headers) as mcp_server,
ChatAgent(
chat_client=client,
name="Expenses Agent",
instructions="You help users to log expenses.",
instructions=f"You help users to log expenses. Today's date is {datetime.now().strftime('%Y-%m-%d')}.",
) as agent,
):
user_query = "yesterday I bought a laptop for $1200 using my visa."
Expand Down
120 changes: 120 additions & 0 deletions agents/keycloak_auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
"""
Keycloak authentication helpers for MCP agents.

Provides OAuth2 client credentials flow authentication via Keycloak's
Dynamic Client Registration (DCR) endpoint.

Usage:
from keycloak_auth import get_auth_headers

headers = await get_auth_headers(keycloak_realm_url)
# Returns {"Authorization": "Bearer <token>"} or None if no URL provided
"""

from __future__ import annotations

import logging
from datetime import datetime

import httpx

logger = logging.getLogger(__name__)


async def register_client_via_dcr(keycloak_realm_url: str, client_name_prefix: str = "agent") -> tuple[str, str]:
"""
Register a new client dynamically using Keycloak's DCR endpoint.

Args:
keycloak_realm_url: The Keycloak realm URL (e.g., http://localhost:8080/realms/myrealm)
client_name_prefix: Prefix for the generated client name

Returns:
Tuple of (client_id, client_secret)

Raises:
RuntimeError: If DCR registration fails
"""
dcr_url = f"{keycloak_realm_url}/clients-registrations/openid-connect"
logger.info("📝 Registering client via DCR...")

async with httpx.AsyncClient() as http_client:
response = await http_client.post(
dcr_url,
json={
"client_name": f"{client_name_prefix}-{datetime.now().strftime('%Y%m%d-%H%M%S')}",
"grant_types": ["client_credentials"],
"token_endpoint_auth_method": "client_secret_basic",
},
headers={"Content-Type": "application/json"},
)

if response.status_code not in (200, 201):
raise RuntimeError(
f"DCR registration failed at {dcr_url}: status={response.status_code}, response={response.text}"
)

data = response.json()
logger.info(f"✅ Registered client: {data['client_id'][:20]}...")
return data["client_id"], data["client_secret"]


async def get_keycloak_token(keycloak_realm_url: str, client_id: str, client_secret: str) -> str:
"""
Get an access token from Keycloak using client_credentials grant.

Args:
keycloak_realm_url: The Keycloak realm URL
client_id: The OAuth client ID
client_secret: The OAuth client secret

Returns:
The access token string

Raises:
RuntimeError: If token request fails
"""
token_url = f"{keycloak_realm_url}/protocol/openid-connect/token"
logger.info("🔑 Getting access token from Keycloak...")

async with httpx.AsyncClient() as http_client:
response = await http_client.post(
token_url,
data={
"grant_type": "client_credentials",
"client_id": client_id,
"client_secret": client_secret,
},
headers={"Content-Type": "application/x-www-form-urlencoded"},
)

if response.status_code != 200:
raise RuntimeError(
f"Token request failed at {token_url}: status={response.status_code}, response={response.text}"
)

token_data = response.json()
logger.info(f"✅ Got access token (expires in {token_data.get('expires_in', '?')}s)")
return token_data["access_token"]


async def get_auth_headers(keycloak_realm_url: str | None, client_name_prefix: str = "agent") -> dict[str, str] | None:
"""
Get authorization headers if Keycloak is configured.

This is the main entry point for agents that need OAuth authentication.
It handles the full flow: DCR registration -> token acquisition -> headers.

Args:
keycloak_realm_url: The Keycloak realm URL, or None to skip auth
client_name_prefix: Prefix for the dynamically registered client name

Returns:
{"Authorization": "Bearer <token>"} 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}"}
7 changes: 7 additions & 0 deletions azure.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,13 @@ services:
remoteBuild: true
path: ./servers/Dockerfile
context: .
keycloak:
project: .
language: docker
host: containerapp
docker:
path: ./keycloak/Dockerfile
context: .
agent:
project: .
language: docker
Expand Down
Loading