Secure MCP Server on Azure App Service
by seligj95
README.md
# 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 the
full App Service security stack:
* **Easy Auth + MCP authorization (Preview)** — authentication enforced at the
platform, before a request reaches your code, and made MCP-spec-compliant by
hosting Protected Resource Metadata (PRM) so MCP clients can discover the auth
server and complete the OAuth handshake. No OAuth flow to write.
* **System-assigned managed identity** — reads Key Vault secrets with no stored
credential.
* **Key Vault + Key Vault references** — secrets are injected at runtime, never
in config or source.
* **VNet integration + private endpoints** — the App Service and Key Vault have
**no public network access**. APIM is the only public ingress.
* **API Management gateway** — validates the Entra ID JWT, rate-limits, and
carries a content-safety extension point before forwarding over the VNet.
* **Application Insights + anomaly alert** — a scheduled-query alert fires when
tool-invocation volume spikes.
The MCP server itself uses **stateless HTTP transport** (MCP `2025-11-25`), so
it load-balances cleanly and every tool is a pure function of its arguments.
## What's in the box
```javascript
.
├── 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

## Local development
The server runs locally with **no Azure dependencies** — the Easy Auth and Key
Vault paths degrade gracefully to "not configured".
```bash
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 VS
Code can connect to it.
Try a tool over curl:
```bash
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 Management**
> instance. APIM alone takes **\~30–45 minutes** to create. Budget for it.
```bash
azd auth login
azd up
```
What happens, in order:
1. **Preprovision hook** (`scripts/configure-easy-auth.sh` / `.ps1`) creates an
Entra ID **app registration** and stores its client id as
`AZURE_AUTH_CLIENT_ID` in the azd environment. This id wires both Easy Auth
and 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, seeded
with a `demo-secret` and a `secure-config-value`.
- P1v3 App Service with a system-assigned managed identity, regional VNet
integration, a private endpoint, Easy Auth (`authsettingsV2`) with MCP
authorization PRM (`WEBSITE_AUTH_PRM_DEFAULT_WITH_SCOPES`), and a
`SECURE_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 App
Service 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's
the only way `azd deploy` (SCM/Kudu/Oryx) can reach it from your machine. Once
the app is deployed and verified, flip it to APIM-only ingress:
```bash
azd env set LOCK_DOWN_WEB_APP true
azd provision
```
This re-runs Bicep and sets the App Service `publicNetworkAccess: Disabled`. From
then on the only public surface is the APIM gateway, and any later `azd deploy`
must run from a host with VNet/private-DNS access (self-hosted agent, jumpbox, or
VPN). This two-phase pattern is the standard way to ship a private-ingress App
Service.
> **Key Vault reference propagation.** The `SECURE_CONFIG_VALUE` reference
> resolves once the managed identity's `Key Vault Secrets User` role assignment
> propagates (usually a few minutes). Until then `get_config_status` reports the
> value 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 the
plumbing before layering auth on:
```bash
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:
```bash
az account get-access-token \
--resource "api://$(azd env get-value AZURE_AUTH_CLIENT_ID)" \
--query accessToken -o tsv
```
2. Call the MCP endpoint through the gateway:
```bash
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 that
Easy Auth validated the token end to end.
3. Confirm the gateway rejects unauthenticated calls:
```bash
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
```
4. Watch the anomaly alert: drive a burst of `audit_event` calls and inspect
Application Insights:
```kusto
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 your
`APIM_MCP_URL`. VS Code will prompt for the Entra ID token (from
`az 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 MCP
client (VS Code, Claude) signs the user in itself by discovering the server's
**Protected 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's
metadata probe with the scopes to request.
* **The client must be allowed and preauthorized.** Microsoft Entra ID has no
Dynamic Client Registration, so the client ships a known client id. Set it
before `azd up` so it's added to the Easy Auth allowed-applications policy:
```bash
azd env set AZURE_MCP_CLIENT_APP_ID <mcp-client-app-id>
```
Also **preauthorize** that client id on the server's app registration (or have
an admin consent), so clients like GitHub Copilot — which won't surface an
interactive consent prompt — can connect without a consent error. For dev/test
you can self-consent by visiting `<APIM_MCP_URL host>/.auth/login/aad` in a
browser once.
> MCP server authorization is currently a **Preview** App Service feature and
> gates access to the *server*, not to individual tools. Never forward the
> client's token to a downstream resource — use the managed identity (or an
> on-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 plus
`publicNetworkAccess: Disabled` mean the only way in is the APIM gateway, which
enforces the JWT. This is defense in depth: APIM validates the token *and*
Easy Auth validates it again at the app.
* **Least privilege to Key Vault.** The managed identity gets \`Key Vault Secrets
User\` (read secret values) — not list, set, or delete.
* **Content safety is a documented hook.** The APIM policy includes the place to
attach Azure AI Content Safety (or the APIM AI Gateway `llm-content-safety`
policy). 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 is
governed by enterprise Azure Policy or security baselines (the kind of restrictions
you'd find in a large corporate tenant). They're documented here only so the
template degrades gracefully in those environments:
* **APIM management NSG rule priority.** Some enterprise baselines (e.g. Microsoft's
internal "NRMS") asynchronously inject `Deny`-Internet inbound rules around
priorities 105–109. Because the `ApiManagement` control-plane IPs are public, a
deny in that band can shadow the `AllowApimManagement` (3443) rule and cause
API/policy imports to fail with \`ManagementApiRequestFailed: Failed to connect to
management endpoint …:3443`.` network.bicep\` therefore places that allow rule at
**priority 102** (below the typical deny band) so it's robust either way. If your
environment additionally denies the APIM control plane at a layer *above* the
subnet NSG, importing the API definition may still be blocked — that's a
subscription-governance issue, not a problem with this template, and the App
Service MCP server plus every other pillar are unaffected.
* **Easy Auth app registration.** In a restricted corporate tenant, \`az ad app
create\` can require a Service Tree ID. The preprovision hooks accept
`AZURE_SERVICE_MANAGEMENT_REFERENCE` (env var or `azd env set`) and pass it as
`--service-management-reference`. Set it before `azd up`, or deploy with
`SKIP_EASY_AUTH=true` to bring everything else up first. On a normal subscription
neither of these is needed.
## License
MIT.
This server cannot be deployed
Maintenance
ActivityInactive
ResponsivenessNo issues