Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
8 changes: 8 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,11 @@ When adding new azd environment variables, update these files:

6. **infra/main.bicep outputs**: If the value needs to be stored back in azd env after provisioning:
- Add an output (note: `@secure()` parameters cannot be outputs)

## Updating Python dependencies

When updating or adding Python dependencies:

1. Edit `pyproject.toml` with the new or updated version constraints.
2. Run `uv lock` to re-resolve dependencies (use `uv lock -P <package>` to upgrade only a specific package).
3. Run `uv sync` to install the updated lockfile into the virtual environment.
25 changes: 14 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ If you're not using one of the above options, then you'll need to:
This project includes MCP servers in the [`servers/`](servers/) directory:

| File | Description |
|------|-------------|
| ---- | ----------- |
| [servers/basic_mcp_stdio.py](servers/basic_mcp_stdio.py) | MCP server with stdio transport for VS Code integration |
| [servers/basic_mcp_http.py](servers/basic_mcp_http.py) | MCP server with HTTP transport on port 8000 |
| [servers/deployed_mcp.py](servers/deployed_mcp.py) | MCP server for Azure deployment with Cosmos DB and optional Keycloak auth |
Expand Down Expand Up @@ -201,7 +201,7 @@ You can use the [.NET Aspire Dashboard](https://learn.microsoft.com/dotnet/aspir
This project includes example agents in the [`agents/`](agents/) directory that demonstrate how to connect AI agents to MCP servers:

| File | Description |
|------|-------------|
| ---- | ----------- |
| [agents/agentframework_learn.py](agents/agentframework_learn.py) | Microsoft Agent Framework integration with MCP |
| [agents/agentframework_http.py](agents/agentframework_http.py) | Microsoft Agent Framework integration with local Expenses MCP server |
| [agents/langchainv1_http.py](agents/langchainv1_http.py) | LangChain agent with MCP integration |
Expand Down Expand Up @@ -381,10 +381,12 @@ When using VNet configuration, additional Azure resources are provisioned:

This project supports deploying with OAuth 2.0 authentication using Keycloak as the identity provider, implementing the [MCP OAuth specification](https://modelcontextprotocol.io/specification/2025-03-26/basic/authorization) with Dynamic Client Registration (DCR).

[📺 Watch a demo video of Keycloak integration](https://youtu.be/lpH8PI4JgEY)

### What gets deployed

| Component | Description |
|-----------|-------------|
| --------- | ----------- |
| **Keycloak Container App** | Keycloak 26.0 with pre-configured realm |
| **HTTP Route Configuration** | Rule-based routing: `/auth/*` → Keycloak, `/*` → MCP Server |
| **OAuth-protected MCP Server** | FastMCP with JWT validation against Keycloak's JWKS endpoint |
Expand Down Expand Up @@ -433,7 +435,6 @@ This project supports deploying with OAuth 2.0 authentication using Keycloak as

Login with `admin` and your configured password.


### 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.
Expand Down Expand Up @@ -477,7 +478,7 @@ To use the deployed MCP server with GitHub Copilot Chat:
### Known limitations (demo trade-offs)

| Item | Current | Production Recommendation | Why |
|------|---------|---------------------------|-----|
| ---- | ------- | ------------------------- | --- |
| Keycloak mode | `start-dev` | `start` with proper config | Dev mode has relaxed security defaults |
| Database | H2 in-memory | PostgreSQL | H2 doesn't persist data across restarts |
| Replicas | 1 (due to H2) | Multiple with shared DB | H2 is in-memory, can't share state |
Expand All @@ -492,10 +493,12 @@ To use the deployed MCP server with GitHub Copilot Chat:

This project supports deploying with Microsoft Entra ID (Azure AD) authentication using FastMCP's built-in Azure OAuth proxy. This is an alternative to Keycloak that uses Microsoft Entra with your Azure tenant for identity management.

[📺 Watch a demo video of Entra integration](https://youtu.be/nOPXUBOXU2M)

### What gets deployed with Entra OAuth

| Component | Description |
|-----------|-------------|
| --------- | ----------- |
| **Microsoft Entra App Registration** | Created automatically during provisioning with redirect URIs for local development, VS Code, and production |
| **OAuth-protected MCP Server** | FastMCP with AzureProvider for OAuth authentication |
| **CosmosDB OAuth Client Storage** | Persists OAuth client registrations across server restarts |
Expand Down Expand Up @@ -535,10 +538,10 @@ This project supports deploying with Microsoft Entra ID (Azure AD) authenticatio

The following environment variables are automatically set by the deployment hooks:

| Variable | Description |
|----------|-------------|
| `ENTRA_PROXY_AZURE_CLIENT_ID` | The App Registration's client ID |
| `ENTRA_PROXY_AZURE_CLIENT_SECRET` | The App Registration's client secret |
| Variable | Description |
| ----------------------------------- | --------------------------------------- |
| `ENTRA_PROXY_AZURE_CLIENT_ID` | The App Registration's client ID |
| `ENTRA_PROXY_AZURE_CLIENT_SECRET` | The App Registration's client secret |

These are then written to `.env` by the postprovision hook for local development.

Expand All @@ -548,7 +551,7 @@ After deployment, you can test locally with OAuth enabled:

```bash
# Run the MCP server
cd servers && uvicorn auth_mcp:app --host 0.0.0.0 --port 8000
cd servers && uvicorn auth_entra_mcp:app --host 0.0.0.0 --port 8000
```

The server will use the Entra App Registration for OAuth and CosmosDB for client storage.
Expand Down
86 changes: 82 additions & 4 deletions infra/auth_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,26 @@
import random
import subprocess
import uuid
from dataclasses import dataclass

from azure.identity.aio import AzureDeveloperCliCredential
from dotenv_azd import load_azd_env
from kiota_abstractions.api_error import APIError
from kiota_abstractions.base_request_configuration import RequestConfiguration
from msgraph import GraphServiceClient
from msgraph.generated.applications.item.add_password.add_password_post_request_body import (
AddPasswordPostRequestBody,
)
from msgraph.generated.models.api_application import ApiApplication
from msgraph.generated.models.application import Application
from msgraph.generated.models.o_auth2_permission_grant import OAuth2PermissionGrant
from msgraph.generated.models.password_credential import PasswordCredential
from msgraph.generated.models.permission_scope import PermissionScope
from msgraph.generated.models.service_principal import ServicePrincipal
from msgraph.generated.models.web_application import WebApplication
from msgraph.graph_service_client import GraphServiceClient
from msgraph.generated.oauth2_permission_grants.oauth2_permission_grants_request_builder import (
Oauth2PermissionGrantsRequestBuilder,
)


async def get_application(graph_client: GraphServiceClient, app_id: str) -> str | None:
Expand Down Expand Up @@ -136,7 +143,7 @@ def update_app_with_identifier_uri(client_id: str) -> Application:
)


async def create_or_update_fastmcp_app(graph_client: GraphServiceClient) -> None:
async def create_or_update_fastmcp_app(graph_client: GraphServiceClient):
"""Create or update a FastMCP app registration."""
app_id_env_var = "ENTRA_PROXY_AZURE_CLIENT_ID"
app_secret_env_var = "ENTRA_PROXY_AZURE_CLIENT_SECRET"
Expand Down Expand Up @@ -171,6 +178,73 @@ async def create_or_update_fastmcp_app(graph_client: GraphServiceClient) -> None
update_azd_env(app_secret_env_var, client_secret)
print("Client secret created and saved to environment.")

return app_id


@dataclass
class GrantDefinition:
principal_id: str
resource_app_id: str
scopes: list[str]
target_label: str

def scope_string(self) -> str:
return " ".join(self.scopes)


async def grant_application_admin_consent(graph_client: GraphServiceClient, server_app_id: str):
server_principal = await graph_client.service_principals_with_app_id(server_app_id).get()
if server_principal is None or server_principal.id is None:
raise ValueError("Unable to locate service principal for server application")

grant_definitions = [
GrantDefinition(
principal_id=server_principal.id,
resource_app_id="00000003-0000-0000-c000-000000000000",
scopes=["User.Read", "email", "offline_access", "openid", "profile"],
target_label="server application",
)
]

for grant in grant_definitions:
resource_principal = await graph_client.service_principals_with_app_id(grant.resource_app_id).get()
if resource_principal is None or resource_principal.id is None:
raise ValueError(f"Unable to locate service principal for resource {grant.resource_app_id}")

desired_scope = grant.scope_string()
filter_query = f"clientId eq '{grant.principal_id}' and resourceId eq '{resource_principal.id}'"
query_params = Oauth2PermissionGrantsRequestBuilder.Oauth2PermissionGrantsRequestBuilderGetQueryParameters(
filter=filter_query
)
request_config = RequestConfiguration[
Oauth2PermissionGrantsRequestBuilder.Oauth2PermissionGrantsRequestBuilderGetQueryParameters
](query_parameters=query_params)
existing_grants = await graph_client.oauth2_permission_grants.get(request_configuration=request_config)

current_grant = existing_grants.value[0] if existing_grants and existing_grants.value else None

if current_grant:
print(f"Admin consent already granted for {desired_scope} on the {grant.target_label}")
continue

try:
await graph_client.oauth2_permission_grants.post(
OAuth2PermissionGrant(
client_id=grant.principal_id,
consent_type="AllPrincipals",
resource_id=resource_principal.id,
scope=desired_scope,
)
)
print(f"Granted admin consent for {desired_scope} on the {grant.target_label}")
except APIError as error:
status_code = error.response_status_code
if status_code in {401, 403}:
print(f"Failed to grant admin consent: {error.message}")
return
Comment on lines +240 to +244
Copy link

Copilot AI Jan 8, 2026

Choose a reason for hiding this comment

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

When admin consent granting fails with 401 or 403 errors, the function prints an error message and returns early, but the script continues with 'print("✅ Entra app registration setup is complete.")' in the main function. This could mislead users into thinking the setup was successful when admin consent actually failed. Consider having grant_application_admin_consent return a boolean indicating success or failure, and update the main function to reflect the actual outcome.

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

Choose a reason for hiding this comment

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

Raising a ValueError as other functions do, instead

else:
raise


async def main():
# Configuration - customize these as needed
Expand All @@ -182,8 +256,12 @@ async def main():
scopes = ["https://graph.microsoft.com/.default"]
graph_client = GraphServiceClient(credentials=credential, scopes=scopes)

await create_or_update_fastmcp_app(graph_client)
print("Setup complete!")
server_app_id = await create_or_update_fastmcp_app(graph_client)

print("Attempting to grant admin consent for the server application...")
await grant_application_admin_consent(graph_client, server_app_id)

print("✅ Entra app registration setup is complete.")


if __name__ == "__main__":
Expand Down
4 changes: 4 additions & 0 deletions infra/main.bicep
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,9 @@ param keycloakMcpServerAudience string = 'mcp-server'
@description('Flag to restrict ACR public network access (requires VPN for local image push when true)')
param usePrivateAcr bool = false

@description('Entra ID group ID for admin access to expense statistics (only used when mcpAuthProvider is entra_proxy)')
param entraAdminGroupId string = ''

@description('Flag to restrict Log Analytics public query access for increased security')
param usePrivateLogAnalytics bool = false

Expand Down Expand Up @@ -790,6 +793,7 @@ module server 'server.bicep' = {
entraProxyClientSecret: useEntraProxy ? entraProxyClientSecret : ''
entraProxyBaseUrl: useEntraProxy ? entraProxyMcpServerBaseUrl : ''
tenantId: useEntraProxy ? tenant().tenantId : ''
entraAdminGroupId: useEntraProxy ? entraAdminGroupId : ''
mcpAuthProvider: mcpAuthProvider
logfireToken: logfireToken
}
Expand Down
3 changes: 3 additions & 0 deletions infra/main.parameters.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,9 @@
"entraProxyClientSecret": {
"value": "${ENTRA_PROXY_AZURE_CLIENT_SECRET}"
},
"entraAdminGroupId": {
"value": "${ENTRA_ADMIN_GROUP_ID}"
},
"logfireToken": {
"value": "${LOGFIRE_TOKEN}"
}
Expand Down
14 changes: 12 additions & 2 deletions infra/server.bicep
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ param entraProxyClientId string = ''
param entraProxyClientSecret string = ''
param entraProxyBaseUrl string = ''
param tenantId string = ''
param entraAdminGroupId string = ''
@secure()
param logfireToken string = ''
@allowed([
Expand All @@ -39,8 +40,13 @@ param logfireToken string = ''
param mcpAuthProvider string = 'none'

// Base environment variables
// Select MCP entrypoint based on configured auth (Keycloak or FastMCP Azure auth)
var mcpEntry = (!empty(keycloakRealmUrl) || !empty(entraProxyClientId)) ? 'auth' : 'deployed'
// Select MCP entrypoint based on configured auth
// - Keycloak → 'auth_keycloak_mcp'
// - Entra OAuth Proxy → 'auth_entra_mcp'
// - None → 'deployed_mcp'
var mcpEntry = mcpAuthProvider == 'keycloak'
? 'auth_keycloak_mcp'
: (mcpAuthProvider == 'entra_proxy' ? 'auth_entra_mcp' : 'deployed_mcp')
var baseEnv = [
{
name: 'AZURE_OPENAI_CHAT_DEPLOYMENT'
Expand Down Expand Up @@ -139,6 +145,10 @@ var entraProxyEnv = !empty(entraProxyClientId) ? [
name: 'AZURE_TENANT_ID'
value: tenantId
}
{
name: 'ENTRA_ADMIN_GROUP_ID'
value: entraAdminGroupId
}
] : []

// Secrets for sensitive values
Expand Down
1 change: 1 addition & 0 deletions infra/write_env.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ if ($ENTRA_PROXY_AZURE_CLIENT_ID -and $ENTRA_PROXY_AZURE_CLIENT_ID -ne "") {
Add-Content -Path $ENV_FILE_PATH -Value "ENTRA_PROXY_AZURE_CLIENT_ID=$ENTRA_PROXY_AZURE_CLIENT_ID"
Write-Env ENTRA_PROXY_AZURE_CLIENT_SECRET
Write-Env ENTRA_PROXY_MCP_SERVER_BASE_URL
Write-EnvIfSet ENTRA_ADMIN_GROUP_ID
}
Add-Content -Path $ENV_FILE_PATH -Value "MCP_ENTRY=$(azd env get-value MCP_ENTRY)"
Add-Content -Path $ENV_FILE_PATH -Value "MCP_SERVER_URL=$(azd env get-value MCP_SERVER_URL)"
Expand Down
1 change: 1 addition & 0 deletions infra/write_env.sh
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ if [ -n "$ENTRA_PROXY_AZURE_CLIENT_ID" ]; then
echo "ENTRA_PROXY_AZURE_CLIENT_ID=${ENTRA_PROXY_AZURE_CLIENT_ID}" >> "$ENV_FILE_PATH"
write_env ENTRA_PROXY_AZURE_CLIENT_SECRET
write_env ENTRA_PROXY_MCP_SERVER_BASE_URL
write_env_if_set ENTRA_ADMIN_GROUP_ID
fi
echo "MCP_ENTRY=$(azd env get-value MCP_ENTRY)" >> "$ENV_FILE_PATH"
echo "MCP_SERVER_URL=$(azd env get-value MCP_SERVER_URL)" >> "$ENV_FILE_PATH"
Expand Down
8 changes: 4 additions & 4 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ description = "Demonstration of Python FastMCP servers"
readme = "README.md"
requires-python = "==3.13.*"
dependencies = [
"fastmcp>=2.13.3",
"fastmcp>=2.14.2",
"debugpy>=1.8.0",
"langchain-core>=0.3.0",
"mcp>=1.3.0",
Expand All @@ -18,9 +18,9 @@ dependencies = [
"azure-ai-agents>=1.1.0",
"agent-framework>=1.0.0b251016",
"azure-cosmos>=4.9.0",
"azure-monitor-opentelemetry>=1.6.4",
"opentelemetry-instrumentation-starlette>=0.49b0",
"opentelemetry-exporter-otlp-proto-grpc>=1.28.0",
"azure-monitor-opentelemetry>=1.8.3",
"opentelemetry-instrumentation-starlette>=0.60b0",
"opentelemetry-exporter-otlp-proto-grpc>=1.39.0",
"logfire>=4.15.1",
"azure-core-tracing-opentelemetry>=1.0.0b12"
]
Expand Down
2 changes: 1 addition & 1 deletion servers/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ ENV PATH="/code/.venv/bin:$PATH"

EXPOSE 8000

ENV MCP_ENTRY=deployed
ENV MCP_ENTRY=deployed_mcp

# Copy and use entrypoint script for clean env expansion
COPY --chown=app:app servers/entrypoint.sh /usr/local/bin/entrypoint.sh
Expand Down
Loading