-
Notifications
You must be signed in to change notification settings - Fork 36
Keycloak integration #12
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 12 commits
a0b7d12
d6663a3
d234dc4
7448490
0df8485
49d490d
5a52481
23be967
9dc7be8
1504037
637805e
74f2e6d
8627170
bf6ec60
ccb5afa
51b3969
2c25546
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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": { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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": [] | ||
madebygps marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
|
|
@@ -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") | ||
|
|
||
|
|
@@ -51,23 +56,93 @@ | |
| ) | ||
|
|
||
|
|
||
| # --- Keycloak Authentication Helpers (only used if KEYCLOAK_REALM_URL is set) --- | ||
|
|
||
|
|
||
| async def register_client_via_dcr() -> tuple[str, str]: | ||
madebygps marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| """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)") | ||
|
||
| 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) | ||
madebygps marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| print(result) | ||
|
|
||
| # Keep the worker alive in production | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The
.dockerignorefile excludesinfra/keycloak-realm.jsonbut this file is needed by the Keycloak Dockerfile (see line 4 ofDockerfile.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: