Skip to content

Conversation

@pamelafox
Copy link
Contributor

This pull request introduces support for deploying the MCP server with Microsoft Entra ID (Azure AD) OAuth authentication, alongside infrastructure and documentation updates. The main changes add pre- and post-provision hooks to automate Azure app registration, update Bicep templates and environment handling for OAuth, and provide comprehensive documentation for using the new authentication option.

Entra OAuth Proxy Integration

  • Added pre-provision (infra/auth_init.sh, infra/auth_init.ps1) and post-provision (infra/auth_update.sh, infra/auth_update.ps1) hooks to automate creation and configuration of Azure/Entra ID app registrations for FastMCP OAuth Proxy. These scripts call new Python scripts to handle Azure resource setup. [1] [2] [3] [4]
  • Implemented infra/fastmcp_auth_init.py (pre-provision): creates or updates the Entra ID app registration, sets up redirect URIs (including for VS Code), exposes an API scope, generates a client secret, and stores credentials in azd environment variables.
  • Implemented infra/fastmcp_auth_update.py (post-provision): updates the app registration with the actual deployed server URL as an OAuth redirect URI after deployment.
  • Updated .vscode/mcp.json to fix minor JSON formatting.

Infrastructure and Bicep Updates

  • Added new parameters to infra/main.bicep for enabling Entra OAuth Proxy (useFastMcpAuth), and for passing the app registration client ID and secret. Also added new CosmosDB containers for OAuth client and user storage. [1] [2] [3]
  • Updated azure.yaml to wire up the new pre- and post-provision hooks for both POSIX and Windows environments.

Documentation

  • Added a new section to README.md with detailed instructions for deploying with Entra OAuth Proxy, including deployment steps, environment variables, local testing, VS Code integration, and troubleshooting. [1] [2]
  • Added AGENTS.md instructions for properly adding new azd environment variables, including Bicep and script updates.

These changes collectively make it much easier to deploy the MCP server with secure Microsoft Entra ID authentication, automate the required Azure setup, and provide clear guidance for developers.

@@ -0,0 +1,219 @@
import asyncio
Copy link
Contributor Author

Choose a reason for hiding this comment

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

These files were based off the same files in azure-search-openai-demo.
I was hoping that I could use Bicep instead, but I think that'd require a FastMCP change, as they accept a client secret, whereas Bicep can also set up MI-as-FIC.

"keycloakMcpServerAudience": {
"value": "${KEYCLOAK_MCP_SERVER_AUDIENCE=mcp-server}"
},
"useFastMcpAuth": {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

I do wonder if we should instead have one variable, MCP_AUTH_PROVIDER, default to None, optionally keycloak or entra.



@mcp.tool
async def get_user_expenses(ctx: Context):
Copy link
Contributor Author

Choose a reason for hiding this comment

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

I changed to a tool as Copilot really does not want to use resources!

except Exception as e:
logger.error(f"Error reading expenses: {str(e)}")
return f"Error: Unable to retrieve expense data - {str(e)}"

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Removed prompt as well, doesnt seem necessary for this demonstration.

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This pull request adds comprehensive support for Microsoft Entra ID (Azure AD) OAuth authentication to the MCP server via FastMCP's AzureAuthProvider, alongside automating Azure app registration through pre- and post-provision hooks. The implementation introduces a new authenticated server variant (auth_mcp.py), a custom Cosmos DB storage adapter for OAuth client persistence, and extensive infrastructure updates to support both Entra and existing Keycloak authentication options.

Key Changes:

  • Automated Azure/Entra ID app registration with pre-provision hooks that create app registrations and post-provision hooks that update redirect URIs with deployed URLs
  • New auth_mcp.py server implementation supporting both Entra OAuth and Keycloak authentication with user-scoped data storage
  • CosmosDB-backed OAuth client storage via custom CosmosDBStore implementing the py-key-value AsyncKeyValue protocol
  • Infrastructure updates enabling dynamic MCP entry point selection based on authentication configuration

Reviewed changes

Copilot reviewed 19 out of 21 changed files in this pull request and generated 13 comments.

Show a summary per file
File Description
uv.lock Updated dependencies for FastMCP 2.13.3, MCP 1.22.0, msgraph-sdk, dotenv-azd, and related OAuth packages
servers/deployed_mcp.py Removed Keycloak authentication logic; simplified to basic MCP server without auth
servers/auth_mcp.py New authenticated MCP server supporting both Entra OAuth and Keycloak with user-scoped expense tracking
servers/cosmosdb_store.py New Cosmos DB implementation of AsyncKeyValue protocol for OAuth client storage
servers/Dockerfile Updated CMD to use environment variable for dynamic entry point selection (deployed vs auth)
pyproject.toml Added msgraph-sdk and dotenv-azd dependencies; bumped fastmcp version requirement
infra/main.bicep Added useFastMcpAuth parameter, entraProxyClientId/Secret parameters, and new Cosmos containers
infra/server.bicep Added Entra OAuth environment variables and secrets; dynamic entry point selection logic
infra/main.parameters.json Added parameter mappings for USE_FASTMCP_AUTH and Entra client credentials
infra/fastmcp_auth_init.py Pre-provision script to create/update Entra app registration with OAuth configuration
infra/fastmcp_auth_update.py Post-provision script to update app registration with deployed server URL
infra/auth_init.sh/ps1 Shell hooks to invoke fastmcp_auth_init.py during pre-provision
infra/auth_update.sh/ps1 Shell hooks to invoke fastmcp_auth_update.py during post-provision
infra/write_env.sh/ps1 Updated to write new Entra auth environment variables to .env file
azure.yaml Wired up preprovision and postprovision hooks for both POSIX and Windows
README.md Added comprehensive Entra OAuth deployment documentation and troubleshooting guidance
AGENTS.md New developer instructions for adding azd environment variables
.vscode/mcp.json Fixed JSON formatting (trailing comma)

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

1. **infra/main.parameters.json**: Add the new parameter mapping from azd env variable to Bicep parameter
- Use format `${ENV_VAR_NAME}` for required values
- Use format `${ENV_VAR_NAME=default}` for optional values with defaults
- Example: `"useEntraProxy": { "value": "${USE_ENTRA_PROXY=false}" }`
Copy link

Copilot AI Dec 9, 2025

Choose a reason for hiding this comment

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

The variable name in the AGENTS.md example doesn't match the actual parameter name used in the PR. The example shows useEntraProxy but the actual code uses useFastMcpAuth (see infra/main.parameters.json line 53-54 and infra/main.bicep line 72).

Update the example to use the actual parameter name:

"useFastMcpAuth": { "value": "${USE_FASTMCP_AUTH=false}" }
Suggested change
- Example: `"useEntraProxy": { "value": "${USE_ENTRA_PROXY=false}" }`
- Example: `"useFastMcpAuth": { "value": "${USE_FASTMCP_AUTH=false}" }`

Copilot uses AI. Check for mistakes.
{
name: 'AZURE_TENANT_ID'
value: tenantId
}
Copy link

Copilot AI Dec 9, 2025

Choose a reason for hiding this comment

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

The MCP_SERVER_URL environment variable is missing from the container configuration. The auth_mcp.py file reads this variable at line 73 when RUNNING_IN_PRODUCTION is true, but it's not set in the Bicep template.

Add this to the appropriate env array in server.bicep:

var entraProxyEnv = !empty(entraProxyClientId) ? [
  {
    name: 'MCP_SERVER_URL'
    value: entraProxyBaseUrl
  }
  // ... existing entries
] : []

Or add it to baseEnv if it should always be available.

Suggested change
}
}
{
name: 'MCP_SERVER_URL'
value: entraProxyBaseUrl
}

Copilot uses AI. Check for mistakes.
ENTRYPOINT ["uvicorn", "deployed_mcp:app", "--host", "0.0.0.0", "--port", "8000"]
ENV MCP_ENTRY=deployed

CMD ["uvicorn", "${MCP_ENTRY}_mcp:app", "--host", "0.0.0.0", "--port", "8000"]
Copy link

Copilot AI Dec 9, 2025

Choose a reason for hiding this comment

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

The Dockerfile CMD uses shell variable substitution ${MCP_ENTRY}_mcp:app, but this won't work as expected with the CMD exec form (JSON array). Shell variable expansion only works with the shell form of CMD.

The current command will literally pass the string "${MCP_ENTRY}_mcp:app" to uvicorn without expanding the variable.

Consider using the shell form instead:

CMD uvicorn ${MCP_ENTRY}_mcp:app --host 0.0.0.0 --port 8000

Or use ENTRYPOINT with shell form:

ENTRYPOINT sh -c "uvicorn ${MCP_ENTRY}_mcp:app --host 0.0.0.0 --port 8000"
Suggested change
CMD ["uvicorn", "${MCP_ENTRY}_mcp:app", "--host", "0.0.0.0", "--port", "8000"]
CMD uvicorn ${MCP_ENTRY}_mcp:app --host 0.0.0.0 --port 8000

Copilot uses AI. Check for mistakes.
Copy link
Collaborator

Choose a reason for hiding this comment

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

I also encountered this error when deploying, what copilot suggests works


```bash
# Run the MCP server
cd servers && uvicorn deployed_mcp:app --host 0.0.0.0 --port 8000
Copy link

Copilot AI Dec 9, 2025

Choose a reason for hiding this comment

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

The documentation refers to running uvicorn deployed_mcp:app for local testing with OAuth, but when OAuth is enabled, the code actually uses auth_mcp.py (not deployed_mcp.py).

This should be:

cd servers && uvicorn auth_mcp:app --host 0.0.0.0 --port 8000

The deployed infrastructure correctly selects the entry point based on auth configuration, but the documentation should reflect this.

Suggested change
cd servers && uvicorn deployed_mcp:app --host 0.0.0.0 --port 8000
cd servers && uvicorn auth_mcp:app --host 0.0.0.0 --port 8000

Copilot uses AI. Check for mistakes.
|----------|-------------|
| `FASTMCP_AUTH_AZURE_CLIENT_ID` | The App Registration's client ID |
| `FASTMCP_AUTH_AZURE_CLIENT_SECRET` | The App Registration's client secret |
| `FASTMCP_AUTH_AZURE_TENANT_ID` | Your Azure tenant ID |
Copy link

Copilot AI Dec 9, 2025

Choose a reason for hiding this comment

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

The environment variable name is inconsistent. The table lists FASTMCP_AUTH_AZURE_TENANT_ID, but the actual code uses AZURE_TENANT_ID (without the FASTMCP_AUTH prefix).

Looking at the code in auth_mcp.py line 90 and server.bicep line 109-110, the variable is named AZURE_TENANT_ID.

Update the documentation to use AZURE_TENANT_ID instead of FASTMCP_AUTH_AZURE_TENANT_ID.

Suggested change
| `FASTMCP_AUTH_AZURE_TENANT_ID` | Your Azure tenant ID |
| `AZURE_TENANT_ID` | Your Azure tenant ID |

Copilot uses AI. Check for mistakes.
logfire.configure(service_name="expenses-mcp", send_to_logfire=True)

# Configure Cosmos DB client
azure_credential = ChainedTokenCredential(ManagedIdentityCredential(), DefaultAzureCredential())
Copy link

Copilot AI Dec 9, 2025

Choose a reason for hiding this comment

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

The ChainedTokenCredential tries ManagedIdentityCredential() first without specifying a client ID. In production, the code sets AZURE_CLIENT_ID in the environment (see server.bicep line 44-46), but the ManagedIdentityCredential here doesn't use it.

For consistency with the deployed_mcp.py approach (lines 52-56), consider using the client ID when available:

if RUNNING_IN_PRODUCTION and os.getenv("AZURE_CLIENT_ID"):
    azure_credential = ManagedIdentityCredential(client_id=os.environ["AZURE_CLIENT_ID"])
else:
    azure_credential = ChainedTokenCredential(ManagedIdentityCredential(), DefaultAzureCredential())

This ensures the managed identity is used correctly in production.

Suggested change
azure_credential = ChainedTokenCredential(ManagedIdentityCredential(), DefaultAzureCredential())
if RUNNING_IN_PRODUCTION and os.getenv("AZURE_CLIENT_ID"):
azure_credential = ManagedIdentityCredential(client_id=os.environ["AZURE_CLIENT_ID"])
else:
azure_credential = ChainedTokenCredential(ManagedIdentityCredential(), DefaultAzureCredential())

Copilot uses AI. Check for mistakes.
Comment on lines +94 to +112
// Azure/Entra ID OAuth Proxy environment variables (only added when configured)
var entraProxyEnv = !empty(entraProxyClientId) ? [
{
name: 'FASTMCP_AUTH_AZURE_CLIENT_ID'
value: entraProxyClientId
}
{
name: 'FASTMCP_AUTH_AZURE_CLIENT_SECRET'
secretRef: 'entra-proxy-client-secret'
}
{
name: 'FASTMCP_AUTH_AZURE_BASE_URL'
value: entraProxyBaseUrl
}
{
name: 'AZURE_TENANT_ID'
value: tenantId
}
] : []
Copy link

Copilot AI Dec 9, 2025

Choose a reason for hiding this comment

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

The environment variables USE_FASTMCP_AUTH and USE_KEYCLOAK are missing from the Bicep configuration. The auth_mcp.py file checks these variables (lines 77 and 98) to determine which authentication mode to use, but they are not set in the container environment.

Add these environment variables to the appropriate env arrays in server.bicep:

var entraProxyEnv = !empty(entraProxyClientId) ? [
  {
    name: 'USE_FASTMCP_AUTH'
    value: 'true'
  }
  // ... existing entries
] : []

var keycloakEnv = !empty(keycloakRealmUrl) ? [
  {
    name: 'USE_KEYCLOAK'
    value: 'true'
  }
  // ... existing entries
] : []

Copilot uses AI. Check for mistakes.
Comment on lines +431 to +432
- `http://localhost:5173/oauth/callback` (VS Code extension localhost)
- `http://localhost:5174/oauth/callback` (VS Code extension localhost alt port)
Copy link

Copilot AI Dec 9, 2025

Choose a reason for hiding this comment

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

The redirect URIs listed in the documentation don't match what's actually configured in the code. The code in fastmcp_auth_init.py sets up:

  • https://vscode.dev/redirect
  • http://127.0.0.1:{33418-33427} (10 ports)

But the documentation mentions:

  • http://localhost:5173/oauth/callback
  • http://localhost:5174/oauth/callback
  • https://vscode.dev/redirect

Update the documentation to accurately reflect the redirect URIs that are actually configured, or update the code to match the documentation.

Suggested change
- `http://localhost:5173/oauth/callback` (VS Code extension localhost)
- `http://localhost:5174/oauth/callback` (VS Code extension localhost alt port)
- `http://127.0.0.1:{33418-33427}` (VS Code extension local authentication, 10 ports)

Copilot uses AI. Check for mistakes.

def update_azd_env(name: str, val: str) -> None:
"""Update an Azure Developer CLI environment variable."""
subprocess.run(f'azd env set {name} "{val}"', shell=True)
Copy link

Copilot AI Dec 9, 2025

Choose a reason for hiding this comment

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

Using shell=True with subprocess and string formatting creates a command injection security vulnerability. If name or val contain shell metacharacters, they could execute arbitrary commands.

Use subprocess with a list of arguments instead:

subprocess.run(['azd', 'env', 'set', name, val], check=True)

This is safer and avoids shell injection risks.

Suggested change
subprocess.run(f'azd env set {name} "{val}"', shell=True)
subprocess.run(['azd', 'env', 'set', name, val], check=True)

Copilot uses AI. Check for mistakes.
for app in apps.value:
if app.app_id == app_id:
return app.id
except Exception:
Copy link

Copilot AI Dec 9, 2025

Choose a reason for hiding this comment

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

'except' clause does nothing but pass and there is no explanatory comment.

Copilot uses AI. Check for mistakes.
exit 0
fi

echo "Setting up Azure/Entra ID app registration for FastMCP OAuth Proxy..."
Copy link
Collaborator

Choose a reason for hiding this comment

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

I hit a KeyError: 'AZURE_TENANT_ID' during azd up here. I think it is because the variable isn't exported to pre-provision hooks.

I manually added:

AZURE_TENANT_ID=$(azd env get-value AZURE_TENANT_ID 2>/dev/null)
if [ $? -ne 0 ]; then AZURE_TENANT_ID=""; fi
export AZURE_TENANT_ID

if [ -z "$AZURE_TENANT_ID" ]; then
    # Fallback: Extract tenant ID from azd auth token
    token_json=$(azd auth token --output json 2>/dev/null)
    if [ -n "$token_json" ]; then
        AZURE_TENANT_ID=$(python -c "import sys, json, base64; ... print(json.loads(base64.b64decode(payload).decode('utf-8'))['tid'])" <<< "$token_json")
        export AZURE_TENANT_ID
    fi
fi

and it worked.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants