Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
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": []
}
193 changes: 193 additions & 0 deletions agents/langchainv1_keycloak.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
"""
LangChain agent that connects to Keycloak-protected MCP server.
This script demonstrates:
Copy link
Contributor

Choose a reason for hiding this comment

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

For agent-framework, you modified the current code, but for langchain, you made a new one?
I think we can put a KeyCloakClient in a separate file that both existing agents could use?

1. Dynamic Client Registration (DCR) with Keycloak
2. Getting an OAuth token using the registered client
3. Connecting to the MCP server with Bearer token authentication
4. Using MCP tools through LangChain
Usage:
python agents/langchainv1_keycloak.py
"""

import asyncio
import logging
import os
from datetime import datetime

import azure.identity
import httpx
from dotenv import load_dotenv
from langchain.agents import create_agent
from langchain_core.messages import HumanMessage, SystemMessage
from langchain_mcp_adapters.client import MultiServerMCPClient
from langchain_openai import ChatOpenAI
from pydantic import SecretStr
from rich.logging import RichHandler

# Configure logging
logging.basicConfig(level=logging.INFO, format="%(message)s", datefmt="[%X]", handlers=[RichHandler()])
logger = logging.getLogger("langchain_keycloak")

# Load environment variables
load_dotenv(override=True)

# MCP Server and Keycloak configuration
MCP_SERVER_URL = os.getenv(
"MCP_SERVER_URL", "https://mcp-gps-key-n7pc5ej-ca.ashymeadow-ae27942e.eastus2.azurecontainerapps.io/mcp"
)
KEYCLOAK_REALM_URL = os.getenv(
"KEYCLOAK_REALM_URL", "https://mcp-gps-key-n7pc5ej-kc.ashymeadow-ae27942e.eastus2.azurecontainerapps.io/realms/mcp"
)

# Configure language model based on API_HOST
API_HOST = os.getenv("API_HOST", "github")

if API_HOST == "azure":
token_provider = azure.identity.get_bearer_token_provider(
azure.identity.DefaultAzureCredential(), "https://cognitiveservices.azure.com/.default"
)
base_model = ChatOpenAI(
model=os.environ.get("AZURE_OPENAI_CHAT_DEPLOYMENT"),
base_url=os.environ["AZURE_OPENAI_ENDPOINT"] + "/openai/v1/",
api_key=token_provider,
)
elif API_HOST == "github":
base_model = ChatOpenAI(
model=os.getenv("GITHUB_MODEL", "gpt-4o"),
base_url="https://models.inference.ai.azure.com",
api_key=SecretStr(os.environ["GITHUB_TOKEN"]),
)
elif API_HOST == "ollama":
base_model = ChatOpenAI(
model=os.environ.get("OLLAMA_MODEL", "llama3.1"),
base_url=os.environ.get("OLLAMA_ENDPOINT", "http://localhost:11434/v1"),
api_key=SecretStr(os.environ["OLLAMA_API_KEY"]),
)
else:
base_model = ChatOpenAI(model=os.getenv("OPENAI_MODEL", "gpt-4o-mini"))


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 client:
response = await client.post(
dcr_url,
json={
"client_name": f"langchain-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 Exception(f"DCR failed: {response.status_code} - {response.text}")

data = response.json()
client_id = data["client_id"]
client_secret = data["client_secret"]

logger.info(f"✅ Registered client: {client_id[:20]}...")
return client_id, 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 client:
response = await 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 Exception(f"Failed to get token: {response.status_code} - {response.text}")

token_data = response.json()
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.

Generic error messages reduce debuggability. The error messages on lines 92 and 120 should be more specific. Consider including additional context such as the URL being called and any relevant response headers to help diagnose authentication issues.

Copilot uses AI. Check for mistakes.
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

addressed in latest commit

access_token = token_data["access_token"]
expires_in = token_data.get("expires_in", "unknown")

logger.info(f"✅ Got access token (expires in {expires_in}s)")
return access_token


async def run_agent() -> None:
"""
Run the agent to process expense-related queries using authenticated MCP tools.
"""
# Register client via DCR and get token
client_id, client_secret = await register_client_via_dcr()
access_token = await get_keycloak_token(client_id, client_secret)

logger.info(f"📡 Connecting to MCP server: {MCP_SERVER_URL}")

# Initialize MCP client with Bearer token auth
client = MultiServerMCPClient(
{
"expenses": {
"url": MCP_SERVER_URL,
"transport": "streamable_http",
"headers": {
"Authorization": f"Bearer {access_token}",
},
}
}
)

# Get tools and create agent
logger.info("🔧 Getting available tools...")
tools = await client.get_tools()
logger.info(f"✅ Found {len(tools)} tools: {[t.name for t in tools]}")

agent = create_agent(base_model, tools)

# Prepare query with context
today = datetime.now().strftime("%Y-%m-%d")
user_query = "Add an expense: yesterday I bought a laptop for $1200 using my visa."

logger.info(f"💬 User query: {user_query}")

# Invoke agent
response = await agent.ainvoke(
{"messages": [SystemMessage(content=f"Today's date is {today}."), HumanMessage(content=user_query)]}
)

# Display result
logger.info("=" * 60)
logger.info("📊 Agent Response:")
logger.info("=" * 60)

final_message = response["messages"][-1]
print(final_message.content)


async def main():
print("=" * 60)
print("LangChain Agent with Keycloak-Protected MCP Server")
print("=" * 60)
print("\nConfiguration:")
print(f" MCP Server: {MCP_SERVER_URL}")
print(f" Keycloak: {KEYCLOAK_REALM_URL}")
print(f" LLM Host: {API_HOST}")
print(" Auth: Dynamic Client Registration (DCR)")
print()

await run_agent()


if __name__ == "__main__":
asyncio.run(main())
18 changes: 17 additions & 1 deletion azure.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,30 @@ name: python-mcp-demo
metadata:
template: [email protected]
services:
# Not using remoteBuild due to private endpoint usage
# MCP Server WITH Keycloak authentication (keycloak_deployed_mcp.py)
aca:
project: .
language: docker
host: containerapp
docker:
path: ./servers/Dockerfile
context: .
# MCP Server WITHOUT authentication (deployed_mcp.py)
mcpnoauth:
project: .
language: docker
host: containerapp
docker:
path: ./servers/Dockerfile.noauth
context: .
# Keycloak identity provider with MCP realm pre-configured
keycloak:
project: .
language: docker
host: containerapp
docker:
path: ./infra/Dockerfile.keycloak
context: .
hooks:
postprovision:
posix:
Expand Down
24 changes: 24 additions & 0 deletions infra/Dockerfile.keycloak
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
FROM quay.io/keycloak/keycloak:26.0 AS builder

# Copy the MCP realm configuration for import
COPY infra/keycloak-realm.json /opt/keycloak/data/import/mcp-realm.json

# Build Keycloak to pre-compile themes (fixes cache hash mismatch across replicas)
RUN /opt/keycloak/bin/kc.sh build

# Production image with pre-built themes
FROM quay.io/keycloak/keycloak:26.0

# Copy built Keycloak with consistent theme cache hashes
COPY --from=builder /opt/keycloak/ /opt/keycloak/

# Expose port 8080
EXPOSE 8080

ENTRYPOINT ["/opt/keycloak/bin/kc.sh"]

# Start in dev mode with H2 database (still uses pre-built themes)
# --proxy-headers=xforwarded tells Keycloak it's behind a reverse proxy that sets X-Forwarded-* headers
# --hostname-strict=false allows dynamic hostname resolution from proxy headers
# --import-realm imports the MCP realm on startup
CMD ["start-dev", "--http-port=8080", "--proxy-headers=xforwarded", "--hostname-strict=false", "--import-realm"]
Loading