diff --git a/.vscode/mcp.json b/.vscode/mcp.json index fe05888..a2fac6c 100644 --- a/.vscode/mcp.json +++ b/.vscode/mcp.json @@ -27,7 +27,8 @@ "0.0.0.0:5678", "servers/basic_mcp_stdio.py" ] - }, + } + }, "inputs": [] -} +} \ No newline at end of file diff --git a/README.md b/README.md index 96d747a..8a75809 100644 --- a/README.md +++ b/README.md @@ -432,23 +432,46 @@ This project supports deploying with OAuth 2.0 authentication using Keycloak as Login with `admin` and your configured password. -### Testing with the agent -1. Generate the local environment file (automatically created after `azd up`): +### Use Keycloak OAuth MCP server with GitHub Copilot - ```bash - ./infra/write_env.sh - ``` +The Keycloak deployment supports Dynamic Client Registration (DCR), which allows VS Code to automatically register as an OAuth client. VS Code redirect URIs are pre-configured in the Keycloak realm. + +To use the deployed MCP server with GitHub Copilot Chat: + +1. To avoid conflicts, stop the MCP servers from `mcp.json` and disable the expense MCP servers in GitHub Copilot Chat tools. +2. Select "MCP: Add Server" from the VS Code Command Palette +3. Select "HTTP" as the server type +4. Enter the URL of the MCP server from `azd env get-value MCP_SERVER_URL` +5. You should see a Keycloak authentication screen open in your browser. Select "Allow access": - This creates `.env` with `KEYCLOAK_REALM_URL`, `MCP_SERVER_URL`, and Azure OpenAI settings. + ![Keycloak allow access screen](screenshots/kc-allow-1.jpg) -2. Run the agent: +6. Sign in with a Keycloak user (e.g., `testuser` / `testpass` for the pre-configured demo user): - ```bash - uv run agents/agentframework_http.py + ![Keycloak sign-in screen](screenshots/kc-signin-2.jpg) + +7. After authentication, the browser will redirect back to VS Code: + + ![VS Code redirect after Keycloak sign-in](screenshots/kc-redirect-3.jpg) + +8. Enable the MCP server in GitHub Copilot Chat tools: + + ![Select MCP tools in GitHub Copilot](screenshots/kc-select-tools-4.jpg) + +9. Test it with an expense tracking query: + + ```text + Log expense for 75 dollars of office supplies on my visa last Friday ``` - 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. + ![Example GitHub Copilot Chat with Keycloak auth](screenshots/kc-chat-5.jpg) + +10. Verify the expense was added by checking the Cosmos DB `user-expenses` container in the Azure Portal or by asking GitHub Copilot Chat: + + ```text + Show me my expenses from last week + ``` ### Known limitations (demo trade-offs) diff --git a/agents/keycloak_auth.py b/agents/keycloak_auth.py deleted file mode 100644 index b98d1f7..0000000 --- a/agents/keycloak_auth.py +++ /dev/null @@ -1,120 +0,0 @@ -""" -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 "} 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 "} 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}"} diff --git a/infra/http-routes.bicep b/infra/http-routes.bicep index 0f8ced4..b139028 100644 --- a/infra/http-routes.bicep +++ b/infra/http-routes.bicep @@ -29,8 +29,6 @@ resource httpRouteConfig 'Microsoft.App/managedEnvironments/httpRouteConfigs@202 parent: containerEnv properties: { rules: [ - // Route /auth/* to Keycloak (strip /auth prefix since Keycloak serves at root) - // Using pathSeparatedPrefix ensures /auth doesn't match /authentication { description: 'Keycloak Authentication Server' routes: [ @@ -38,9 +36,7 @@ resource httpRouteConfig 'Microsoft.App/managedEnvironments/httpRouteConfigs@202 match: { pathSeparatedPrefix: '/auth' } - action: { - prefixRewrite: '/' - } + action: {} } ] targets: [ @@ -49,7 +45,6 @@ resource httpRouteConfig 'Microsoft.App/managedEnvironments/httpRouteConfigs@202 } ] } - // Route everything else to MCP server (catch-all) { description: 'MCP Expenses Server' routes: [ @@ -57,9 +52,7 @@ resource httpRouteConfig 'Microsoft.App/managedEnvironments/httpRouteConfigs@202 match: { prefix: '/' } - action: { - prefixRewrite: '/' - } + action: {} } ] targets: [ diff --git a/infra/main.bicep b/infra/main.bicep index 5fbe45b..70e2d6a 100644 --- a/infra/main.bicep +++ b/infra/main.bicep @@ -782,8 +782,7 @@ module server 'server.bicep' = { openTelemetryPlatform: openTelemetryPlatform exists: serverExists // Keycloak authentication configuration (only when enabled) - keycloakRealmUrl: useKeycloak ? '${keycloak!.outputs.uri}/realms/${keycloakRealmName}' : '' - keycloakTokenIssuer: useKeycloak ? '${keycloakMcpServerBaseUrl}/realms/${keycloakRealmName}' : '' + keycloakRealmUrl: useKeycloak ? '${keycloak!.outputs.uri}/auth/realms/${keycloakRealmName}' : '' keycloakMcpServerBaseUrl: useKeycloak ? keycloakMcpServerBaseUrl : '' keycloakMcpServerAudience: keycloakMcpServerAudience // Azure/Entra ID OAuth Proxy authentication configuration (only when enabled) @@ -810,7 +809,7 @@ module agent 'agent.bicep' = { openAiDeploymentName: openAiDeploymentName openAiEndpoint: openAi.outputs.endpoint mcpServerUrl: useKeycloak ? 'https://mcproutes.${containerApps.outputs.defaultDomain}/mcp' : '${server.outputs.uri}/mcp' - keycloakRealmUrl: useKeycloak ? '${keycloak.outputs.uri}/realms/${keycloakRealmName}' : '' + keycloakRealmUrl: useKeycloak ? '${keycloak.outputs.uri}/auth/realms/${keycloakRealmName}' : '' exists: agentExists } } @@ -946,9 +945,10 @@ output KEYCLOAK_MCP_SERVER_BASE_URL string = useKeycloak ? keycloakMcpServerBase // Keycloak and MCP Server routing outputs (only populated when mcpAuthProvider is keycloak) output KEYCLOAK_REALM_URL string = useKeycloak ? '${httpRoutes!.outputs.routeConfigUrl}/auth/realms/${keycloakRealmName}' : '' -output KEYCLOAK_ADMIN_CONSOLE string = useKeycloak ? '${httpRoutes!.outputs.routeConfigUrl}/auth/admin' : '' +output KEYCLOAK_ADMIN_CONSOLE string = useKeycloak ? '${httpRoutes!.outputs.routeConfigUrl}/auth/admin/master/console' : '' output KEYCLOAK_DIRECT_URL string = keycloak.outputs.uri -output KEYCLOAK_TOKEN_ISSUER string = useKeycloak ? '${keycloakMcpServerBaseUrl}/realms/${keycloakRealmName}' : '' +output KEYCLOAK_TOKEN_ISSUER string = useKeycloak ? '${keycloakMcpServerBaseUrl}/auth/realms/${keycloakRealmName}' : '' +output KEYCLOAK_AGENT_REALM_URL string = useKeycloak ? '${keycloak!.outputs.uri}/auth/realms/${keycloakRealmName}' : '' // Auth provider for env scripts output MCP_AUTH_PROVIDER string = mcpAuthProvider diff --git a/infra/server.bicep b/infra/server.bicep index d78d590..bc8084a 100644 --- a/infra/server.bicep +++ b/infra/server.bicep @@ -22,7 +22,6 @@ param applicationInsightsConnectionString string = '' ]) param openTelemetryPlatform string = 'appinsights' param keycloakRealmUrl string = '' -param keycloakTokenIssuer string = '' param keycloakMcpServerAudience string = 'mcp-server' param keycloakMcpServerBaseUrl string = '' param entraProxyClientId string = '' @@ -112,10 +111,6 @@ var keycloakEnv = !empty(keycloakRealmUrl) ? [ name: 'KEYCLOAK_REALM_URL' value: keycloakRealmUrl } - { - name: 'KEYCLOAK_TOKEN_ISSUER' - value: !empty(keycloakTokenIssuer) ? keycloakTokenIssuer : keycloakRealmUrl - } { name: 'KEYCLOAK_MCP_SERVER_AUDIENCE' value: keycloakMcpServerAudience diff --git a/infra/write_env.ps1 b/infra/write_env.ps1 index 0455edd..da228d1 100644 --- a/infra/write_env.ps1 +++ b/infra/write_env.ps1 @@ -49,6 +49,7 @@ $KEYCLOAK_REALM_URL = Get-AzdValue KEYCLOAK_REALM_URL if ($KEYCLOAK_REALM_URL -and $KEYCLOAK_REALM_URL -ne "") { Add-Content -Path $ENV_FILE_PATH -Value "KEYCLOAK_REALM_URL=$KEYCLOAK_REALM_URL" Write-EnvIfSet KEYCLOAK_TOKEN_ISSUER + Write-EnvIfSet KEYCLOAK_AGENT_REALM_URL } # Entra proxy env vars (only if ENTRA_PROXY_AZURE_CLIENT_ID is set) diff --git a/infra/write_env.sh b/infra/write_env.sh index fa7627c..618e02d 100755 --- a/infra/write_env.sh +++ b/infra/write_env.sh @@ -55,6 +55,7 @@ KEYCLOAK_REALM_URL=$(get_azd_value KEYCLOAK_REALM_URL) if [ -n "$KEYCLOAK_REALM_URL" ]; then echo "KEYCLOAK_REALM_URL=${KEYCLOAK_REALM_URL}" >> "$ENV_FILE_PATH" write_env_if_set KEYCLOAK_TOKEN_ISSUER + write_env_if_set KEYCLOAK_AGENT_REALM_URL fi # Entra proxy env vars (only if ENTRA_PROXY_AZURE_CLIENT_ID is set) diff --git a/keycloak/Dockerfile b/keycloak/Dockerfile index 9c1c3b2..8184dd0 100644 --- a/keycloak/Dockerfile +++ b/keycloak/Dockerfile @@ -22,5 +22,7 @@ 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 +# --http-relative-path=/auth sets the base path so Keycloak serves all content under /auth/* # --import-realm imports the MCP realm on startup -CMD ["start-dev", "--http-port=8080", "--proxy-headers=xforwarded", "--hostname-strict=false", "--import-realm"] +# --import-strategy=overwrite-existing ensures realm.json changes are applied even if the realm exists +CMD ["start-dev", "--http-port=8080", "--proxy-headers=xforwarded", "--hostname-strict=false", "--http-relative-path=/auth", "--import-realm", "--import-strategy=overwrite-existing"] \ No newline at end of file diff --git a/keycloak/realm.json b/keycloak/realm.json index 16cb5fb..6591516 100644 --- a/keycloak/realm.json +++ b/keycloak/realm.json @@ -1,11 +1,7 @@ { "realm": "mcp", "enabled": true, - "registrationAllowed": false, - "loginWithEmailAllowed": true, - "duplicateEmailsAllowed": false, "resetPasswordAllowed": true, - "editUsernameAllowed": false, "bruteForceProtected": true, "accessTokenLifespan": 300, "ssoSessionIdleTimeout": 1800, @@ -14,10 +10,14 @@ "accessCodeLifespan": 60, "accessCodeLifespanUserAction": 300, "accessCodeLifespanLogin": 1800, + "defaultDefaultClientScopes": [ + "openid", + "mcp:access" + ], "clientScopes": [ { - "name": "mcp-server", - "description": "MCP Server audience scope for DCR clients", + "name": "openid", + "description": "OpenID Connect scope with MCP server audience mapper", "protocol": "openid-connect", "attributes": { "include.in.token.scope": "true", @@ -25,28 +25,75 @@ }, "protocolMappers": [ { - "name": "mcp-server-audience", + "name": "sub", "protocol": "openid-connect", - "protocolMapper": "oidc-audience-mapper", + "protocolMapper": "oidc-sub-mapper", "consentRequired": false, "config": { - "included.custom.audience": "mcp-server", "id.token.claim": "true", "access.token.claim": "true", - "introspection.token.claim": "true" + "userinfo.token.claim": "true" + } + } + ] + }, + { + "name": "mcp:access", + "description": "MCP tools scope for accessing MCP server tools", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true", + "consent.screen.text": "Access MCP tools" + }, + "protocolMappers": [ + { + "name": "mcp-server-audience-tools", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-mapper", + "consentRequired": false, + "config": { + "included.custom.audience": "mcp-server", + "id.token.claim": "false", + "access.token.claim": "true" } } ] } ], - "defaultDefaultClientScopes": [ - "mcp-server", - "profile", - "email", - "roles", - "web-origins", - "acr", - "basic" + "clients": [ + { + "clientId": "vscode-mcp-client", + "name": "VS Code MCP Client", + "enabled": true, + "publicClient": true, + "standardFlowEnabled": true, + "redirectUris": [ + "http://127.0.0.1/*", + "http://localhost/*", + "https://vscode.dev/redirect", + "https://insiders.vscode.dev/redirect" + ], + "webOrigins": [ + "+" + ] + } + ], + "users": [ + { + "username": "testuser", + "enabled": true, + "email": "testuser@example.com", + "firstName": "Test", + "lastName": "User", + "credentials": [ + { + "type": "password", + "value": "testpass", + "temporary": false + } + ] + } ], "components": { "org.keycloak.services.clientregistration.policy.ClientRegistrationPolicy": [ @@ -55,29 +102,31 @@ "providerId": "trusted-hosts", "subType": "anonymous", "config": { - "host-sending-registration-request-must-match": ["false"], - "client-uris-must-match": ["true"], - "trusted-hosts": ["localhost", "127.0.0.1", "*.azurecontainerapps.io"] + "host-sending-registration-request-must-match": [ + "false" + ], + "client-uris-must-match": [ + "true" + ], + "trusted-hosts": [ + "localhost", + "127.0.0.1", + "172.17.0.1", + "host.docker.internal", + "vscode.dev", + "insiders.vscode.dev", + "code.visualstudio.com" + ] } }, - { - "name": "Consent Required", - "providerId": "consent-required", - "subType": "anonymous", - "config": {} - }, - { - "name": "Full Scope Disabled", - "providerId": "scope", - "subType": "anonymous", - "config": {} - }, { "name": "Max Clients Limit", "providerId": "max-clients", "subType": "anonymous", "config": { - "max-clients": ["200"] + "max-clients": [ + "200" + ] } }, { @@ -89,47 +138,14 @@ "oidc-usermodel-attribute-mapper", "oidc-full-name-mapper", "oidc-address-mapper", - "saml-user-attribute-idp-mapper", "oidc-usermodel-property-mapper", - "saml-role-idp-mapper", - "saml-user-property-idp-mapper", - "oidc-sha256-pairwise-sub-mapper" - ] - } - }, - { - "name": "Allowed Client Scopes", - "providerId": "allowed-client-templates", - "subType": "anonymous", - "config": { - "allow-default-scopes": ["true"] - } - }, - { - "name": "Allowed Protocol Mapper Types", - "providerId": "allowed-protocol-mappers", - "subType": "authenticated", - "config": { - "allowed-protocol-mapper-types": [ - "saml-user-property-idp-mapper", "oidc-sha256-pairwise-sub-mapper", - "oidc-usermodel-attribute-mapper", - "saml-role-idp-mapper", - "oidc-full-name-mapper", - "oidc-usermodel-property-mapper", - "saml-user-attribute-idp-mapper", - "oidc-address-mapper" + "oidc-sub-mapper", + "oidc-usersessionmodel-note-mapper", + "oidc-audience-mapper" ] } - }, - { - "name": "Allowed Client Scopes", - "providerId": "allowed-client-templates", - "subType": "authenticated", - "config": { - "allow-default-scopes": ["true"] - } } ] } -} +} \ No newline at end of file diff --git a/screenshots/kc-allow-1.jpg b/screenshots/kc-allow-1.jpg new file mode 100644 index 0000000..d9d642f Binary files /dev/null and b/screenshots/kc-allow-1.jpg differ diff --git a/screenshots/kc-chat-5.jpg b/screenshots/kc-chat-5.jpg new file mode 100644 index 0000000..84c27ae Binary files /dev/null and b/screenshots/kc-chat-5.jpg differ diff --git a/screenshots/kc-redirect-3.jpg b/screenshots/kc-redirect-3.jpg new file mode 100644 index 0000000..7467794 Binary files /dev/null and b/screenshots/kc-redirect-3.jpg differ diff --git a/screenshots/kc-select-tools-4.jpg b/screenshots/kc-select-tools-4.jpg new file mode 100644 index 0000000..596dccf Binary files /dev/null and b/screenshots/kc-select-tools-4.jpg differ diff --git a/screenshots/kc-signin-2.jpg b/screenshots/kc-signin-2.jpg new file mode 100644 index 0000000..048157a Binary files /dev/null and b/screenshots/kc-signin-2.jpg differ diff --git a/servers/auth_mcp.py b/servers/auth_mcp.py index fed2fe8..fc58ae3 100644 --- a/servers/auth_mcp.py +++ b/servers/auth_mcp.py @@ -15,14 +15,12 @@ from cosmosdb_store import CosmosDBStore from dotenv import load_dotenv from fastmcp import Context, FastMCP -from fastmcp.server.auth import RemoteAuthProvider from fastmcp.server.auth.providers.azure import AzureProvider -from fastmcp.server.auth.providers.jwt import JWTVerifier from fastmcp.server.dependencies import get_access_token from fastmcp.server.middleware import Middleware, MiddlewareContext from key_value.aio.stores.memory import MemoryStore +from keycloak_provider import KeycloakAuthProvider from opentelemetry.instrumentation.starlette import StarletteInstrumentor -from pydantic import AnyHttpUrl from rich.console import Console from rich.logging import RichHandler from starlette.responses import JSONResponse @@ -101,25 +99,27 @@ "Using Entra OAuth Proxy for server %s and %s storage", entra_base_url, type(oauth_client_store).__name__ ) elif mcp_auth_provider == "keycloak": - # Keycloak authentication using RemoteAuthProvider with JWT verification + # Keycloak authentication using KeycloakAuthProvider with DCR support KEYCLOAK_REALM_URL = os.environ["KEYCLOAK_REALM_URL"] - KEYCLOAK_TOKEN_ISSUER = os.getenv("KEYCLOAK_TOKEN_ISSUER", KEYCLOAK_REALM_URL) - token_verifier = JWTVerifier( - jwks_uri=f"{KEYCLOAK_REALM_URL}/protocol/openid-connect/certs", - issuer=KEYCLOAK_TOKEN_ISSUER, - audience=os.getenv("KEYCLOAK_MCP_SERVER_AUDIENCE", "mcp-server"), - ) - # Prefer specific base URL env for Keycloak when provided if RUNNING_IN_PRODUCTION: keycloak_base_url = os.environ["KEYCLOAK_MCP_SERVER_BASE_URL"] else: - keycloak_base_url = "http://localhost:8000/mcp" - auth = RemoteAuthProvider( - token_verifier=token_verifier, - authorization_servers=[AnyHttpUrl(KEYCLOAK_REALM_URL)], + keycloak_base_url = "http://localhost:8000" + + keycloak_audience = os.getenv("KEYCLOAK_MCP_SERVER_AUDIENCE") or "mcp-server" + + auth = KeycloakAuthProvider( + realm_url=KEYCLOAK_REALM_URL, base_url=keycloak_base_url, + required_scopes=["openid", "mcp:access"], + audience=keycloak_audience, + ) + logger.info( + "Using Keycloak DCR auth for server %s and realm %s (audience=%s)", + keycloak_base_url, + KEYCLOAK_REALM_URL, + keycloak_audience, ) - logger.info("Using Keycloak auth for server %s and realm %s", keycloak_base_url, KEYCLOAK_REALM_URL) else: logger.error("No authentication configured for MCP server, exiting.") raise SystemExit(1) diff --git a/servers/keycloak_provider.py b/servers/keycloak_provider.py new file mode 100644 index 0000000..ce33b65 --- /dev/null +++ b/servers/keycloak_provider.py @@ -0,0 +1,225 @@ +"""Keycloak authentication provider for FastMCP. + +This module provides KeycloakAuthProvider - a complete authentication solution that integrates +with Keycloak's OAuth 2.1 and OpenID Connect services, supporting Dynamic Client Registration (DCR) +for seamless MCP client authentication. + +Based on proposed FastMCP PR: https://github.com/jlowin/fastmcp/pull/1937 +""" + +from __future__ import annotations + +import httpx +from fastmcp.server.auth import RemoteAuthProvider +from fastmcp.server.auth.providers.jwt import JWTVerifier +from fastmcp.utilities.logging import get_logger +from pydantic import AnyHttpUrl +from starlette.responses import JSONResponse +from starlette.routing import Route + +logger = get_logger(__name__) + + +class KeycloakAuthProvider(RemoteAuthProvider): + """Keycloak authentication provider with Dynamic Client Registration (DCR) support. + + This provider integrates FastMCP with Keycloak using a **minimal proxy architecture** that + solves a specific MCP compatibility issue. The proxy only intercepts DCR responses to fix + a single field - all other OAuth operations go directly to Keycloak. + + ## Why a Minimal Proxy is Needed + + Keycloak has a known limitation with Dynamic Client Registration: it ignores the client's + requested `token_endpoint_auth_method` parameter and always returns `client_secret_basic`, + even when clients explicitly request `client_secret_post` (which MCP requires per RFC 9110). + + This minimal proxy works around this by: + 1. Advertising itself as the authorization server to MCP clients + 2. Forwarding Keycloak's OAuth metadata with a custom registration endpoint + 3. Intercepting DCR responses from Keycloak and fixing only the `token_endpoint_auth_method` field + + **What the minimal proxy does NOT intercept:** + - Authorization flows (users authenticate directly with Keycloak) + - Token issuance (tokens come directly from Keycloak) + - Token validation (JWT signatures verified against Keycloak's keys) + + Example: + ```python + from fastmcp import FastMCP + from keycloak_provider import KeycloakAuthProvider + + keycloak_auth = KeycloakAuthProvider( + realm_url="http://localhost:8080/realms/fastmcp", + base_url="http://localhost:8000", + required_scopes=["openid", "profile"], + ) + + mcp = FastMCP("My App", auth=keycloak_auth) + ``` + """ + + def __init__( + self, + *, + realm_url: AnyHttpUrl | str, + base_url: AnyHttpUrl | str, + required_scopes: list[str] | None = None, + audience: str | list[str] | None = None, + token_verifier: JWTVerifier | None = None, + ): + """Initialize Keycloak metadata provider. + + Args: + realm_url: Your Keycloak realm URL (e.g., "https://keycloak.example.com/realms/myrealm") + base_url: Public URL of this FastMCP server + required_scopes: Optional list of scopes to require for all requests + audience: Optional audience(s) for JWT validation. If not specified and no custom + verifier is provided, audience validation is disabled. For production use, + it's recommended to set this to your resource server identifier or base_url. + token_verifier: Optional token verifier. If None, creates JWT verifier for Keycloak + """ + self.base_url = AnyHttpUrl(str(base_url).rstrip("/")) + self.realm_url = str(realm_url).rstrip("/") + + # Create default JWT verifier if none provided + if token_verifier is None: + # Keycloak uses specific URL patterns (not the standard .well-known paths) + token_verifier = JWTVerifier( + jwks_uri=f"{self.realm_url}/protocol/openid-connect/certs", + issuer=self.realm_url, + algorithm="RS256", + required_scopes=required_scopes, + audience=audience, + ) + + # Initialize RemoteAuthProvider with FastMCP as the authorization server + # We advertise ourselves as the auth server because we provide the + # authorization server metadata endpoint that forwards from Keycloak + # with our /register DCR proxy endpoint. + super().__init__( + token_verifier=token_verifier, + authorization_servers=[self.base_url], + base_url=self.base_url, + ) + + def get_routes( + self, + mcp_path: str | None = None, + ) -> list[Route]: + """Get OAuth routes including Keycloak metadata forwarding and minimal DCR proxy. + + Adds two routes to the parent class's protected resource metadata: + 1. `/.well-known/oauth-authorization-server` - Forwards Keycloak's OAuth metadata + with the registration endpoint rewritten to point to our minimal DCR proxy + 2. `/register` - Minimal DCR proxy that forwards requests to Keycloak and fixes + only the `token_endpoint_auth_method` field in responses + + Args: + mcp_path: The path where the MCP endpoint is mounted (e.g., "/mcp") + """ + # Get the standard protected resource routes from RemoteAuthProvider + routes = super().get_routes(mcp_path) + + async def oauth_authorization_server_metadata(request): + """Forward Keycloak's OAuth metadata with registration endpoint pointing to our minimal DCR proxy.""" + try: + async with httpx.AsyncClient() as client: + response = await client.get(f"{self.realm_url}/.well-known/oauth-authorization-server") + response.raise_for_status() + metadata = response.json() + + # Override registration_endpoint to use our minimal DCR proxy + base_url = str(self.base_url).rstrip("/") + metadata["registration_endpoint"] = f"{base_url}/register" + + return JSONResponse(metadata) + except Exception as e: + logger.error(f"Failed to fetch Keycloak metadata: {e}") + return JSONResponse( + { + "error": "server_error", + "error_description": f"Failed to fetch Keycloak metadata: {e}", + }, + status_code=500, + ) + + # Add Keycloak authorization server metadata forwarding + routes.append( + Route( + "/.well-known/oauth-authorization-server", + endpoint=oauth_authorization_server_metadata, + methods=["GET"], + ) + ) + + async def register_client_fix_auth_method(request): + """Minimal DCR proxy that fixes token_endpoint_auth_method in Keycloak's + client registration response. + + Forwards registration requests to Keycloak's DCR endpoint and modifies: + 1. token_endpoint_auth_method: "client_secret_basic" -> "client_secret_post" for MCP compatibility + + All other fields are passed through unchanged. + """ + try: + body = await request.body() + + # Forward to Keycloak's DCR endpoint + async with httpx.AsyncClient(timeout=10.0) as client: + forward_headers = { + key: value + for key, value in request.headers.items() + if key.lower() not in {"host", "content-length", "transfer-encoding", "content-type"} + } + forward_headers["Content-Type"] = "application/json" + + # Keycloak's standard DCR endpoint pattern + registration_endpoint = f"{self.realm_url}/clients-registrations/openid-connect" + logger.info(f"DCR proxy forwarding to: {registration_endpoint}") + response = await client.post( + registration_endpoint, + content=body, + headers=forward_headers, + ) + + if response.status_code != 201: + logger.error(f"DCR failed with status {response.status_code}: {response.text}") + return JSONResponse( + response.json() + if response.headers.get("content-type", "").startswith("application/json") + else {"error": "registration_failed", "status": response.status_code}, + status_code=response.status_code, + ) + + # Fix token_endpoint_auth_method for MCP compatibility + client_info = response.json() + original_auth_method = client_info.get("token_endpoint_auth_method") + logger.debug(f"Received token_endpoint_auth_method from Keycloak: {original_auth_method}") + + if original_auth_method == "client_secret_basic": + logger.debug("Fixing token_endpoint_auth_method: client_secret_basic -> client_secret_post") + client_info["token_endpoint_auth_method"] = "client_secret_post" + + auth_method = client_info.get("token_endpoint_auth_method") + logger.debug(f"Returning token_endpoint_auth_method to client: {auth_method}") + return JSONResponse(client_info, status_code=201) + except Exception: + logger.exception("DCR proxy error during client registration") + return JSONResponse( + { + "error": "server_error", + "error_description": "Client registration failed.", + }, + status_code=500, + ) + + # Add minimal DCR proxy + routes.append( + Route( + "/register", + endpoint=register_client_fix_auth_method, + methods=["POST"], + ) + ) + + return routes diff --git a/spanish/README.md b/spanish/README.md index f17e8bf..8c8cf13 100644 --- a/spanish/README.md +++ b/spanish/README.md @@ -406,6 +406,46 @@ Este proyecto soporta desplegar con autenticación OAuth 2.0 usando Keycloak com El agente detecta `KEYCLOAK_REALM_URL` en el entorno y se autentica vía DCR + client credentials. Al éxito, agregará un gasto e imprimirá el resultado. +### Usar servidor MCP OAuth Keycloak con GitHub Copilot + +El despliegue de Keycloak soporta Dynamic Client Registration (DCR), lo que permite que VS Code se registre automáticamente como cliente OAuth. Las URIs de redirección de VS Code están preconfiguradas en el realm de Keycloak. + +Para usar el servidor MCP desplegado con GitHub Copilot Chat: + +1. Para evitar conflictos, detén los servidores MCP de `mcp.json` y deshabilitá los servidores MCP de gastos en las herramientas de GitHub Copilot Chat. +2. Seleccioná "MCP: Add Server" desde la Paleta de Comandos de VS Code +3. Seleccioná "HTTP" como tipo de servidor +4. Ingresá la URL del servidor MCP desde `azd env get-value MCP_SERVER_URL` +5. Deberías ver una pantalla de autenticación de Keycloak abrirse en tu navegador. Seleccioná "Allow access": + + ![Pantalla de permitir acceso de Keycloak](../screenshots/kc-allow-1.jpg) + +6. Iniciá sesión con un usuario de Keycloak (ej. `testuser` / `testpass` para el usuario demo preconfigurado): + + ![Pantalla de inicio de sesión de Keycloak](../screenshots/kc-signin-2.jpg) + +7. Después de la autenticación, el navegador redirigirá de vuelta a VS Code: + + ![Redirección de VS Code después del inicio de sesión de Keycloak](../screenshots/kc-redirect-3.jpg) + +8. Habilitá el servidor MCP en las herramientas de GitHub Copilot Chat: + + ![Seleccionar herramientas MCP en GitHub Copilot](../screenshots/kc-select-tools-4.jpg) + +9. Probálo con una consulta de seguimiento de gastos: + + ```text + Registrá un gasto de 75 dólares de útiles de oficina en mi visa el viernes pasado + ``` + + ![Ejemplo de GitHub Copilot Chat con autenticación Keycloak](../screenshots/kc-chat-5.jpg) + +10. Verificá que el gasto se agregó revisando el contenedor `user-expenses` de Cosmos DB en el Portal de Azure o preguntando a GitHub Copilot Chat: + + ```text + Dime mis gastos de la semana pasada + ``` + ### Limitaciones conocidas (trade-offs de la demo) | Ítem | Actual | Recomendación para producción | Por qué |