Skip to content
Merged
Show file tree
Hide file tree
Changes from 12 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": []
}
85 changes: 80 additions & 5 deletions agents/agentframework_http.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@
import asyncio
import logging
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
Expand All @@ -23,6 +25,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,23 +56,93 @@
)


# --- 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)")
Copy link
Contributor

Choose a reason for hiding this comment

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

I'm surprised there isnt a Keycloak Python SDK- wouldnt the best practice be to cache the token and only refresh when near expiration? Wondering if we should make our own KeyCloakClient that does that.

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 ---


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()
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.",
) 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(user_query, tools=mcp_server)
result = await agent.run(f"Today's date is {today}. {user_query}", tools=mcp_server)
print(result)

# Keep the worker alive in production
Expand Down
Loading