Skip to main content
Glama
sinanpl

mcp-demo-aad-viz

by sinanpl
README.md
# mcp-demo-aad-viz

A worked example of two MCP capabilities that are usually demonstrated in isolation,
and are considerably more interesting together:

- **Microsoft Entra ID (Azure AD) authorization** — the server is an OAuth 2.1
  resource server. Your **Entra group membership decides which datasets exist for
  you**. Not "listed then refused" — absent.
- **Inline apps / extensions** (`io.modelcontextprotocol/ui`) — charts arrive as an
  **interactive widget** rendered in the conversation, and adjusting it costs
  **zero tokens**.

The two combine into the thing worth seeing: an Altair chart builder whose dataset
dropdown contains exactly the datasets your Entra groups permit, enforced server-side
on every single interaction with the widget.

Built against MCP **2026-07-28** with the Python SDK `mcp` 2.0. Deploys to Azure
Container Apps. MIT licensed.

> **Heads up:** this is a demonstration, not a product. It ships ten public sample
> datasets and a deliberately simple tier model so the authorization story is legible.

![The interactive Altair chart builder widget, showing dataset/axis/mark controls next to a rendered scatter plot of the Palmer penguins dataset](docs/images/dataviz-chart-builder.jpeg)

---

## Try it without Azure

No tenant, no auth, no deployment — enough to see the widget work:

```bash
uv sync && uv run python scripts/fetch_datasets.py
```

```bash
MCP_DATAVIZ_AUTH_ENABLED=false MCP_DATAVIZ_PORT=3001 uv run python -m mcp_dataviz
```

Every caller is then treated as holding all three dataset tiers. Point any MCP Apps
host at `http://localhost:3001/mcp` — see [Local development](#local-development) for
a browser-based host that shows you the whole `ui/` protocol as it happens.

## Try it with Azure

```bash
# 1. Directory objects (app registration, scopes, app roles, 3 groups)
./scripts/entra-setup.sh
```

```bash
# 2. Put yourself in a group to pick a persona
source entra.env
az ad group member add --group "$MCP_DATAVIZ_GROUP_ANALYSTS_ID" \
                       --member-id "$(az ad signed-in-user show --query id -o tsv)"
```

```bash
# 3. Deploy (builds the image in Azure; no local Docker needed)
./scripts/deploy.sh --tag v1
```

The script prints your MCP endpoint. Add it to your client **exactly as printed** —
the `/mcp` path is part of the OAuth resource identifier → **[docs/CONNECT.md](docs/CONNECT.md)**.

> Pass a **unique** `--tag` on every deploy. With a repeated tag the Bicep template is
> byte-identical to the live one, no new revision is created, and the deploy reports
> success while shipping nothing.

---

## What it demonstrates

| MCP feature | Where | What you see |
|---|---|---|
| **Authorization** (OAuth 2.1 RS) | [`auth.py`](src/mcp_dataviz/auth.py) | Group membership changes the size of the catalogue |
| **MCP Apps** (`io.modelcontextprotocol/ui`) | [`chart_builder.html`](src/mcp_dataviz/widgets/chart_builder.html) | Dropdowns re-render the chart in place |
| **App-only tools** (`visibility: ["app"]`) | `render_chart` | Widget re-renders cost **zero tokens** |
| **`input_required`** | `plot_dataset` | Hosts without widgets get a form instead |
| **Scope step-up** (`403 insufficient_scope`) | `export_chart` | First export triggers re-consent |
| **Resources + templates** | `data://catalog` | Permission-filtered |
| **Completions** | dataset arguments | Autocomplete never names a dataset you can't open |
| **Prompts** | `explore_dataset` | Guided first pass |

Two things the spec **deprecated** in this revision, and which this server therefore
avoids: `sampling` and the `logging` capability (SEP-2577). `suggest_chart` picks a
mark from column types instead of asking a model.

---

## The authorization model

Two independent axes. Getting these confused is the usual mistake.

```
WHO YOU ARE                                 WHAT YOU'RE DOING
Entra group ──► app role ──► dataset tier   OAuth scope ──► operation
                (roles claim)                              (scp claim)

analysts   → Open                      ( 4)  Datasets.Read   → everything
engineers  → Open + Operations         ( 7)  Datasets.Export → export_chart
scientists → Open + Confidential       ( 7)     ↑ withheld at first, so the
             ...a *different* 7            first export triggers a step-up
(no group) → nothing                   ( 0)
```

| Tier | Role | Datasets |
|---|---|---|
| open | `Datasets.Open` | iris, penguins, cars, barley |
| operations | `Datasets.Operations` | seattle-weather, us-employment, gapminder |
| confidential | `Datasets.Confidential` | diamonds, movies, titanic |

Engineers and scientists hold the same *number* of datasets but not the same ones, so
two colleagues asking the same question get different answers.

```bash
./scripts/assign-persona.sh engineer colleague@example.com --now
```

`--now` also assigns the roles directly to the user: a group change can take Entra
several minutes to reach a new token, a direct assignment about twenty seconds.

**Roles are a hard denial.** You cannot request your way into a group. Datasets
outside your tier are absent from `tools/list` results, `resources/list`,
completions and the widget's dropdown — not listed-then-refused.

**Scopes are a soft denial.** Missing `Datasets.Export` returns `403` with a
`WWW-Authenticate: Bearer error="insufficient_scope"` challenge, and the client
re-authorizes asking for it.

Two Entra-specific traps this repo works around, both of which produce baffling
errors if you set it up by hand: Entra has **no dynamic client registration** and no
RFC 8414 metadata endpoint, and the MCP URL **must** be registered as an Application
ID URI or RFC 8707 `resource=` fails with `AADSTS9010010`.

Full detail: **[docs/AUTHZ.md](docs/AUTHZ.md)** · **[docs/CONNECT.md](docs/CONNECT.md)**.

---

## Why the widget is interesting

A Vega-Lite spec with inline data is 30–300 KB. Naively returning it from a tool puts
that in the model's context on every chart.

Instead:

1. `plot_dataset` returns a **~900-byte handle** — encoding, row counts, warnings. No spec.
2. The host renders the `ui://` app and pushes it that handle.
3. The **widget** calls `render_chart` (an app-only tool) for the actual spec.

Because step 3 originates in the app rather than the model, **the spec never enters
the conversation**. Changing a dropdown is one small server round-trip and zero tokens.

The authorization story holds here too: `render_chart` and `app_catalogue` re-derive
the caller's tiers on every call, so the widget cannot reach a dataset the token does
not permit — even though the model is no longer in the loop.

Design notes: **[docs/DESIGN.md](docs/DESIGN.md)**.

---

## Local development

Two harnesses, for two different questions.

**"Is my widget's HTML/JS right?"** — a miniature host that speaks the real `ui/`
postMessage protocol and logs every message, with no MCP client involved:

```bash
uv run python scripts/preview_widget.py     # http://127.0.0.1:8765
```

It injects the same restrictive CSP a real host applies, so CSP failures reproduce
here instead of only in production. `--strip-structured-content` emulates a host
defect described in [docs/HOST-COMPATIBILITY.md](docs/HOST-COMPATIBILITY.md).

**"Is my MCP surface right?"** — the reference host from the MCP Apps repo, driving
the actual server over HTTP:

```bash
git clone https://github.com/modelcontextprotocol/ext-apps.git
cd ext-apps && npm install && cd examples/basic-host
SERVERS='["http://localhost:3001/mcp"]' npm start   # http://localhost:8080
```

This is the harness worth reaching for first when a widget renders blank: it reports
protocol violations that shipping hosts swallow silently. Running the server with
`MCP_DATAVIZ_AUTH_ENABLED=false` also relaxes the SDK's `Origin` check and adds CORS
headers, which a browser-based host needs and which are **off** whenever auth is on.

Note that the widget HTML is read **once at server construction**, so editing it
requires a server restart.

---

## Host compatibility

MCP Apps support varies between hosts in ways that produce identical-looking
symptoms — usually a blank or collapsed widget with no error anywhere.
**[docs/HOST-COMPATIBILITY.md](docs/HOST-COMPATIBILITY.md)** documents what was
actually observed, how each cause was isolated, and which are fixable server-side
(one of three) versus not.

---

## Datasets

Four open, three operations, three confidential — all public sample datasets from the
Vega datasets collection. Vendored into the image at build time, so the running
container needs no network access to any data origin. The tier labels are illustrative,
chosen to make the access model concrete.

| open | operations | confidential (illustrative reason) |
|---|---|---|
| `iris` | `seattle-weather` | `diamonds` — unit pricing |
| `penguins` | `us-employment` | `movies` — commercial revenue |
| `cars` | `gapminder` | `titanic` — person-level records |
| `barley` | | |

---

## Layout

```
src/mcp_dataviz/
  server.py       tools, resources, prompts, completions
  auth.py         Entra token verification, roles→tiers, scope challenge
  catalog.py      the 10 datasets and the tier gate
  charts.py       Altair → Vega-Lite, with aggregation pushed into pandas
  config.py       environment settings (nothing hardcoded)
  widgets/        the MCP App
infra/            Bicep: ACR, Container Apps, Log Analytics
scripts/          entra-setup.sh, deploy.sh, assign-persona.sh, preview_widget.py
tests/            168 tests, incl. HTTP-level auth and step-up
docs/             AUTHZ, CONNECT, DESIGN, HOST-COMPATIBILITY
```

The Python package keeps the name `mcp_dataviz` (and the `MCP_DATAVIZ_` environment
prefix) even though the repository is `mcp-demo-aad-viz`; renaming it would churn every
Azure resource name and environment variable for no benefit.

## Tests

```bash
uv run pytest          # 168 tests, no Azure needed
```

```bash
uv run ruff check src tests scripts
```

`tests/test_http.py` runs a real uvicorn server and asserts the `401` challenge, the
PRM document, the `403 insufficient_scope` step-up, and the `input_required` round trip.

## Cost

Container Apps scales to zero (`minReplicas: 0`), so an idle demo costs roughly
nothing; ACR Basic and Log Analytics are the only standing charges (a few € / month).

```bash
az group delete --name rg-mcp-dataviz --yes && ./scripts/entra-teardown.sh
```

## Licence

[MIT](LICENSE).