Skip to main content
Glama
seligj95

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

![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.](architecture.png)

## 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.