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
3 changes: 2 additions & 1 deletion .vscode/mcp.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
"servers/basic_mcp_stdio.py"
]
},

Copilot AI Dec 18, 2025

Copy link

Choose a reason for hiding this comment

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

There is a trailing comma after the last server definition (line 30) which is followed by an empty line before the closing brace. While some JSON parsers tolerate trailing commas, this violates the JSON specification and may cause parsing errors in strict JSON parsers. Remove the comma on line 30 or the empty line 31 to ensure valid JSON.

Suggested change
},
}

Copilot uses AI. Check for mistakes.

Copilot AI Dec 18, 2025

Copy link

Choose a reason for hiding this comment

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

There is trailing whitespace on this line after the closing brace. Consider removing it to maintain consistent formatting.

Suggested change

Copilot uses AI. Check for mistakes.
},
"inputs": []
}
}
40 changes: 40 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -450,6 +450,46 @@ This project supports deploying with OAuth 2.0 authentication using Keycloak as

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.

### Use Keycloak OAuth MCP server with GitHub Copilot

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

![Keycloak allow access screen](screenshots/kc-allow-1.jpg)

6. Sign in with a Keycloak user (e.g., `testuser` / `testpass` for the pre-configured demo user):

![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
```

![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)

| Item | Current | Production Recommendation | Why |
Expand Down
120 changes: 0 additions & 120 deletions agents/keycloak_auth.py

This file was deleted.

11 changes: 2 additions & 9 deletions infra/http-routes.bicep
Original file line number Diff line number Diff line change
Expand Up @@ -29,18 +29,14 @@ 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: [
{
match: {
pathSeparatedPrefix: '/auth'
}
action: {
prefixRewrite: '/'
}
action: {}
}
]
targets: [
Expand All @@ -49,17 +45,14 @@ resource httpRouteConfig 'Microsoft.App/managedEnvironments/httpRouteConfigs@202
}
]
}
// Route everything else to MCP server (catch-all)
{
description: 'MCP Expenses Server'
routes: [
{
match: {
prefix: '/'
}
action: {
prefixRewrite: '/'
}
action: {}
}
]
targets: [
Expand Down
10 changes: 5 additions & 5 deletions infra/main.bicep
Original file line number Diff line number Diff line change
Expand Up @@ -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}' : ''
Comment thread
madebygps marked this conversation as resolved.
keycloakMcpServerBaseUrl: useKeycloak ? keycloakMcpServerBaseUrl : ''
keycloakMcpServerAudience: keycloakMcpServerAudience
// Azure/Entra ID OAuth Proxy authentication configuration (only when enabled)
Expand All @@ -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
}
}
Expand Down Expand Up @@ -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

Copilot AI Dec 18, 2025

Copy link

Choose a reason for hiding this comment

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

The output KEYCLOAK_DIRECT_URL is not conditional on useKeycloak, which means it will attempt to access keycloak.outputs.uri even when Keycloak is not deployed. This will cause a deployment error when mcpAuthProvider is not set to "keycloak". Consider making this conditional like the other Keycloak outputs: output KEYCLOAK_DIRECT_URL string = useKeycloak ? keycloak.outputs.uri : ''

Suggested change
output KEYCLOAK_DIRECT_URL string = keycloak.outputs.uri
output KEYCLOAK_DIRECT_URL string = useKeycloak ? keycloak.outputs.uri : ''

Copilot uses AI. Check for mistakes.
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
Expand Down
5 changes: 0 additions & 5 deletions infra/server.bicep
Original file line number Diff line number Diff line change
Expand Up @@ -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 = ''
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions infra/write_env.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions infra/write_env.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
4 changes: 3 additions & 1 deletion keycloak/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Comment thread
madebygps marked this conversation as resolved.
Loading