seligj95

Secure MCP Server on Azure App Service

Community seligj95
Updated

Hardened Model Context Protocol (MCP) server on Azure App Service: Easy Auth + MCP authorization (PRM), managed identity, Key Vault, private endpoints, and API Management.

Secure MCP Server on Azure App Service

A reference implementation of a hardened MCP server on Azure App Service.It takes the "exposed MCP endpoint" problem seriously and closes it with thefull App Service security stack:

  • Easy Auth + MCP authorization (Preview) — authentication enforced at theplatform, before a request reaches your code, and made MCP-spec-compliant byhosting Protected Resource Metadata (PRM) so MCP clients can discover the authserver and complete the OAuth handshake. No OAuth flow to write.
  • System-assigned managed identity — reads Key Vault secrets with no storedcredential.
  • Key Vault + Key Vault references — secrets are injected at runtime, neverin config or source.
  • VNet integration + private endpoints — the App Service and Key Vault haveno public network access. APIM is the only public ingress.
  • API Management gateway — validates the Entra ID JWT, rate-limits, andcarries a content-safety extension point before forwarding over the VNet.
  • Application Insights + anomaly alert — a scheduled-query alert fires whentool-invocation volume spikes.

The MCP server itself uses stateless HTTP transport (MCP 2025-11-25), soit load-balances cleanly and every tool is a pure function of its arguments.

What's in the box

.
├── main.py                       # FastAPI MCP server (stateless HTTP) + secure tools
├── requirements.txt
├── azure.yaml                    # azd service def + Easy Auth preprovision hook
├── scripts/
│   ├── configure-easy-auth.sh    # creates the Entra ID app registration (POSIX)
│   └── configure-easy-auth.ps1   # same, for Windows
├── infra/
│   ├── main.bicep                # wires every module together
│   ├── main.parameters.json
│   ├── abbreviations.json
│   ├── app/
│   │   └── web.bicep             # App Service: MI, VNet integ, private endpoint,
│   │                             #   Easy Auth, Key Vault references
│   └── shared/
│       ├── app-service-plan.bicep    # P1v3 plan
│       ├── network.bicep             # VNet, subnets, NSGs, private DNS zones
│       ├── keyvault.bicep            # Key Vault + private endpoint + demo secrets
│       ├── keyvault-rbac.bicep       # grants the app MI 'Key Vault Secrets User'
│       ├── monitoring.bicep          # Log Analytics + App Insights + anomaly alert
│       └── apim.bicep                # APIM + API + JWT/rate-limit/content-safety policy
├── static/style.css
└── templates/index.html          # status page (shows principal + security posture)

MCP tools

Tool What it demonstrates
whoami The Entra ID principal that Easy Auth validated, parsed from the platform-injected X-MS-CLIENT-PRINCIPAL headers — proof that auth is enforced
get_config_status Whether a Key Vault reference app setting resolved via managed identity (reports status only, never the value)
read_secret_metadata Fetches a Key Vault secret over managed identity and returns metadata only — the safe alternative to credential-leaking tools
safe_lookup Allow-list lookup that rejects path-traversal / injection payloads — safe tool-input handling
audit_event Emits an Application Insights custom event that feeds the anomaly alert

Architecture

Architecture: an MCP client gets a token from Entra ID, then calls API Management over HTTPS. APIM is the only public ingress and runs validate-jwt, rate-limiting, and a content-safety hook before forwarding over the VNet to an App Service that enforces Easy Auth. The App Service uses a system-assigned managed identity, regional VNet integration, a private endpoint, and Key Vault reference app settings to reach a Key Vault that has a private endpoint, RBAC, and no public access. The App Service also emits telemetry to Application Insights + Log Analytics, which runs a scheduled-query alert on tool-call spikes.

Local development

The server runs locally with no Azure dependencies — the Easy Auth and KeyVault paths degrade gracefully to "not configured".

python -m venv .venv
source .venv/bin/activate          # Windows: .venv\Scripts\activate
pip install -r requirements.txt
python main.py

Open http://localhost:8000/. The MCP endpoint is http://localhost:8000/mcp..vscode/mcp.json includes a secure-mcp-app-service-local server entry so VSCode can connect to it.

Try a tool over curl:

curl -s -X POST localhost:8000/mcp -H 'content-type: application/json' \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/call",
       "params":{"name":"safe_lookup","arguments":{"topic":"../../etc/passwd"}}}'
# -> rejected_as_suspicious: true

Deploy to Azure

Heads up — this is a security reference architecture, not a 60-second demo.It provisions a VNet, private endpoints, Key Vault, and an API Managementinstance. APIM alone takes ~30–45 minutes to create. Budget for it.

azd auth login
azd up

What happens, in order:

  1. Preprovision hook (scripts/configure-easy-auth.sh / .ps1) creates anEntra ID app registration and stores its client id asAZURE_AUTH_CLIENT_ID in the azd environment. This id wires both Easy Authand the APIM validate-jwt policy.
  2. Bicep provisions:
    • VNet with delegated app-integration, private-endpoint, and APIM subnets +private DNS zones.
    • Log Analytics + Application Insights + the tool-anomaly alert rule.
    • Key Vault (public access disabled) behind a private endpoint, seededwith a demo-secret and a secure-config-value.
    • P1v3 App Service with a system-assigned managed identity, regional VNetintegration, a private endpoint, Easy Auth (authsettingsV2) with MCPauthorization PRM (WEBSITE_AUTH_PRM_DEFAULT_WITH_SCOPES), and aSECURE_CONFIG_VALUE Key Vault reference.
    • A role assignment granting the app MI Key Vault Secrets User.
    • API Management (External VNet mode) with an mcp API, the JWT / rate-limit/ content-safety policy, and the App Service as its backend.
  3. App deploy pushes the Python app via Oryx.

Outputs include APIM_MCP_URL (the public MCP endpoint) and WEB_URI (the AppService URL).

Lock down the App Service (the hardened end state)

A fully-private App Service can only receive a code push from inside the VNet,so the first azd up deploys with App Service public access enabled — that'sthe only way azd deploy (SCM/Kudu/Oryx) can reach it from your machine. Oncethe app is deployed and verified, flip it to APIM-only ingress:

azd env set LOCK_DOWN_WEB_APP true
azd provision

This re-runs Bicep and sets the App Service publicNetworkAccess: Disabled. Fromthen on the only public surface is the APIM gateway, and any later azd deploymust run from a host with VNet/private-DNS access (self-hosted agent, jumpbox, orVPN). This two-phase pattern is the standard way to ship a private-ingress AppService.

Key Vault reference propagation. The SECURE_CONFIG_VALUE referenceresolves once the managed identity's Key Vault Secrets User role assignmentpropagates (usually a few minutes). Until then get_config_status reports thevalue as not yet resolved; a single app restart forces an immediate refresh.

Deploy without auth (functional smoke test only)

To skip the app registration and Easy Auth entirely — handy to confirm theplumbing before layering auth on:

SKIP_EASY_AUTH=true azd up

Leaving Easy Auth off defeats the purpose of the sample; only do this to test.

Verify

Test through APIM — that's the path that exercises the full security stack(and the only path once you've locked the app down).

  1. Get an Entra ID access token for the API:
az account get-access-token \
     --resource "api://$(azd env get-value AZURE_AUTH_CLIENT_ID)" \
     --query accessToken -o tsv
  1. Call the MCP endpoint through the gateway:
APIM_MCP_URL=$(azd env get-value APIM_MCP_URL)
   TOKEN=<token from step 1>

   curl -s -X POST "$APIM_MCP_URL" \
     -H "Authorization: Bearer $TOKEN" \
     -H 'content-type: application/json' \
     -d '{"jsonrpc":"2.0","id":1,"method":"tools/call",
          "params":{"name":"whoami","arguments":{}}}'

The response's principal block shows the authenticated caller — proof thatEasy Auth validated the token end to end.

  1. Confirm the gateway rejects unauthenticated calls:
curl -s -o /dev/null -w '%{http_code}\n' -X POST "$APIM_MCP_URL" \
     -H 'content-type: application/json' \
     -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
   # -> 401
  1. Watch the anomaly alert: drive a burst of audit_event calls and inspectApplication Insights:
customEvents
   | where name == "mcp_tool_audit"
   | summarize calls = count() by bin(timestamp, 5m)

Connect VS Code to the deployed server

Edit .vscode/mcp.json and set the secure-mcp-app-service URL to yourAPIM_MCP_URL. VS Code will prompt for the Entra ID token (fromaz account get-access-token) and send it as a bearer header.

Letting a real MCP client sign in (MCP authorization)

The bearer-token approach above is fine for testing, but a spec-compliant MCPclient (VS Code, Claude) signs the user in itself by discovering the server'sProtected Resource Metadata. Two things make that work:

  • PRM is published via the WEBSITE_AUTH_PRM_DEFAULT_WITH_SCOPES app setting(wired automatically when Easy Auth is on). App Service answers the client'smetadata probe with the scopes to request.
  • The client must be allowed and preauthorized. Microsoft Entra ID has noDynamic Client Registration, so the client ships a known client id. Set itbefore azd up so it's added to the Easy Auth allowed-applications policy:
  azd env set AZURE_MCP_CLIENT_APP_ID <mcp-client-app-id>

Also preauthorize that client id on the server's app registration (or havean admin consent), so clients like GitHub Copilot — which won't surface aninteractive consent prompt — can connect without a consent error. For dev/testyou can self-consent by visiting <APIM_MCP_URL host>/.auth/login/aad in abrowser once.

MCP server authorization is currently a Preview App Service feature andgates access to the server, not to individual tools. Never forward theclient's token to a downstream resource — use the managed identity (or anon-behalf-of token) for that hop, as the sample does for Key Vault.

Notes on the security choices

  • APIM is the public surface. After the lockdown step(LOCK_DOWN_WEB_APP=true), the App Service private endpoint pluspublicNetworkAccess: Disabled mean the only way in is the APIM gateway, whichenforces the JWT. This is defense in depth: APIM validates the token andEasy Auth validates it again at the app.
  • Least privilege to Key Vault. The managed identity gets `Key Vault SecretsUser` (read secret values) — not list, set, or delete.
  • Content safety is a documented hook. The APIM policy includes the place toattach Azure AI Content Safety (or the APIM AI Gateway llm-content-safetypolicy). It's left as an extension point so azd up stays self-contained.

Optional: notes for restricted enterprise subscriptions

Most people deploying this on a normal Azure subscription can ignore this section —azd up just works. These two notes only apply if your subscription/tenant isgoverned by enterprise Azure Policy or security baselines (the kind of restrictionsyou'd find in a large corporate tenant). They're documented here only so thetemplate degrades gracefully in those environments:

  • APIM management NSG rule priority. Some enterprise baselines (e.g. Microsoft'sinternal "NRMS") asynchronously inject Deny-Internet inbound rules aroundpriorities 105–109. Because the ApiManagement control-plane IPs are public, adeny in that band can shadow the AllowApimManagement (3443) rule and causeAPI/policy imports to fail with `ManagementApiRequestFailed: Failed to connect tomanagement endpoint …:3443. network.bicep` therefore places that allow rule atpriority 102 (below the typical deny band) so it's robust either way. If yourenvironment additionally denies the APIM control plane at a layer above thesubnet NSG, importing the API definition may still be blocked — that's asubscription-governance issue, not a problem with this template, and the AppService MCP server plus every other pillar are unaffected.
  • Easy Auth app registration. In a restricted corporate tenant, `az ad appcreate` can require a Service Tree ID. The preprovision hooks acceptAZURE_SERVICE_MANAGEMENT_REFERENCE (env var or azd env set) and pass it as--service-management-reference. Set it before azd up, or deploy withSKIP_EASY_AUTH=true to bring everything else up first. On a normal subscriptionneither of these is needed.

License

MIT.

MCP Server · Populars

MCP Server · New

    healthchainai

    healthchain

    Python SDK for healthcare AI — typed, validated FHIR tools for agents, real-time EHR connectivity, production deployment ✨ 🏥

    Community healthchainai
    deverman

    FocusRelay — Fast Swift OmniFocus MCP Server and CLI for macOS

    Fast native Swift OmniFocus MCP server and CLI for macOS. Let AI assistants safely read, update, complete, and organize tasks and projects through documented Omni Automation APIs.

    Community deverman
    Glade-tool

    GladeKit MCP

    Connect any MCP-compatible AI client (Claude Code, Cursor, Windsurf) to Unity or Godot. 235+ granular tools, an editor aware system prompt, game design document project context, script semantic search, and skill calibration.

    Community Glade-tool
    Consiliency

    Code-Index-MCP

    Code indexing MCP server to provide context to coding agents.

    Community Consiliency
    semihbugrasezer

    seerxo

    AI-powered Etsy product listing generator for Claude Desktop Generate perfect SEO titles, descriptions, and tags in seconds

    Community semihbugrasezer