MCP character catalog
README.md
# MCP character catalog
An educational Python MCP server with four bundled character records and two
read-only tools. The official MCP SDK serves Streamable HTTP at `/mcp`; `/health`
is anonymous and returns `{"status":"ok"}`. There is no model or external catalog.
The hosted app uses **Entra-backed OAuth with Dynamic discovery**. FastMCP's
maintained OAuthProxy supplies registration, browser consent and S256 PKCE;
the official SDK still serves the tools. Broker tokens reference encrypted
upstream Entra tokens, whose signature, tenant, API audience, lifetime and
delegated `Catalog.Read` scope are also verified. API keys and raw Entra tokens
cannot bypass broker authentication.
## Project layout
- `catalog/`: tools, configuration, authentication and safe JSON logging.
- `infra/`, `azure.yaml`, `scripts/deploy.py`: Azure deployment.
- `tests/`, `.github/workflows/checks.yml`: regression tests and CI.
- [Editable workshop diagram](docs/diagrams/workshop.drawio): agenda and architecture.
## Develop and test
Use Python **3.14** (tested; supported range 3.12-3.14). No Docker or Azure account
is needed for local tests.
```sh
python3 -m venv .venv
. .venv/bin/activate
python -m pip install --require-hashes -r requirements.txt -r requirements-dev.txt
pytest -q
ruff check .
ruff format --check .
```
For an offline development server, use the existing API-key mode:
```sh
export MCP_AUTH_MODE=api_key
export MCP_ALLOWED_HOSTS=localhost,127.0.0.1
export MCP_API_KEY="$(python -c 'import secrets; print(secrets.token_urlsafe(32))')"
python -m uvicorn catalog.server:create_app --factory \
--host 127.0.0.1 --port 8000 --no-access-log
```
Clients must send that key in `X-API-Key`; query-string keys are not accepted.
Do not paste credentials into chat or recordings. Visiting `/mcp` in a browser
is not an MCP test; the suite exercises actual SDK clients in modern and legacy
modes, including tool contracts and failure cases.
| Tool | Behavior |
| --- | --- |
| `list_characters(affiliation?)` | Returns `{characters, count}`. Omitted/null means all four; `Rebel Alliance` returns Luke and Leia. Unknown affiliation succeeds with an empty list. |
| `get_character(name)` | Returns `{name, homeworld, affiliation}`; a missing character is an MCP `NOT_FOUND` tool error. |
Matching is exact, case-insensitive and trims whitespace. Blank, non-string and
over-100-character inputs fail; tools must not guess absent facts.
## Deploy or update Azure
**Cloud deployment requires approval and incurs charges.** Prerequisites:
Azure CLI, azd, Bicep, an approved subscription/region and permissions to create
resources, run ACR builds and assign roles. Azure CLI and azd must use the same
subscription. Local Docker is not required: ACR builds the Linux image remotely.
The approved environment already has two single-tenant Entra registrations:
an API exposing delegated `Catalog.Read` with v2 access tokens, and a confidential
upstream client permitted that scope. Their IDs are in private
`.azure/entra-registration.json`. Do not create duplicates or add Graph-wide
permissions. For another approved environment, create equivalent registrations.
Keep deployment settings in the existing ignored, mode-0600 `.env.broker`:
| Setting | Value |
| --- | --- |
| `MCP_AUTH_MODE` | `broker` |
| `MCP_ENTRA_TENANT_ID` | Tenant UUID |
| `MCP_ENTRA_AUDIENCE` | API application/client UUID |
| `MCP_BROKER_CLIENT_ID` | Confidential upstream client UUID |
| `MCP_BROKER_CLIENT_SECRET` | Its credential value |
| `MCP_BROKER_SIGNING_KEY` | Stable generated ASCII secret, at least 43 characters |
| `MCP_BROKER_ENCRYPTION_KEY` | Independent, stable Fernet key |
For a new environment, generate the signing key with `secrets.token_urlsafe(48)`
and the encryption key with `cryptography.fernet.Fernet.generate_key()`, storing
the results securely. **Do not regenerate keys during normal deployment.**
With the virtual environment active and the existing approved azd environment
selected:
```sh
set -a
. ./.env.broker
set +a
azd provision
```
This validates configuration, provisions infrastructure, remotely builds the
image and deploys it with secure temporary parameters. Use `azd provision`, not
`azd deploy`, for updates. Never put secrets in `azd env set`, command arguments,
source control or debug logs.
Broker mode creates a separate app (base app name truncated to 25 characters
plus `-broker`), retaining any original direct-Entra app. Use the effective name
printed by the hook; azd's base app-name output is not the broker app name:
```sh
az containerapp show --resource-group <resource-group> \
--name <broker-app-name> --query properties.configuration.ingress.fqdn -o tsv
```
Register `https://<broker-fqdn>/auth/callback` as an exact **Web** redirect on the
upstream Entra client, preserving other approved callbacks. Set private
`MCP_URL` and `MCP_RESOURCE_URL` to `https://<broker-fqdn>/mcp`, and
`MCP_ALLOWED_HOSTS` to its hostname. Bicep derives resource/host, Azure Tables
endpoint and managed-identity settings at deployment; private-network broker
storage is not accessible from an ordinary local development server.
## Connect Copilot Studio
1. Use an account/environment with Copilot authoring, connection permissions,
generative orchestration and tenant data-policy approval.
2. Open **Tools > Add a tool > New tool > Model Context Protocol**.
3. Enter the **broker** `/mcp` URL from `.env.broker`, not the original direct-Entra
URL. Select **OAuth 2.0 > Dynamic discovery**. No client ID or secret is entered.
4. Create a connection, approve broker consent, then sign in and consent to
`Catalog.Read` in Entra. Tenant policy may require admin approval.
5. Add to the agent and confirm `list_characters` and `get_character`. Test all
characters, the two Rebels, and a nonexistent name; inspect actual tool
arguments, results and error status rather than just the agent's answer.
The default broker callback allowlist covers
`https://global.consent.azure-apim.net/redirect` and its path descendants.
If Copilot supplies a regional callback, add that **exact trusted HTTPS URI**
to `MCP_BROKER_REDIRECT_URIS` and redeploy. Copilot callbacks go in this allowlist,
not on the upstream Entra app. Never allow arbitrary redirects.
UI labels and feature availability vary by tenant; see
[Microsoft's onboarding guide](https://learn.microsoft.com/microsoft-copilot-studio/agent-extend-action-mcp).
## Operations and verification
- Check `/health`, `/.well-known/oauth-protected-resource/mcp` and
`/.well-known/oauth-authorization-server`; missing/invalid credentials at `/mcp`
must return **401**. Hosted ingress is HTTPS-only.
- OAuth state is Fernet-encrypted in private-endpoint Azure Tables. Managed
identity has AcrPull plus Table Data Contributor on the dedicated table;
public storage and shared-key access are disabled. Storage/key-fetch outages
return **503**, not anonymous access. Expiry checks are not physical retention
cleanup; manage retained state explicitly.
- Broker mode is capped at **one replica**: shared storage does not make
in-process refresh locks distributed. Registration is limited to 16 KiB and
60 attempts/minute/process; this is a workshop, not production abuse protection.
Default minimum replicas is zero; use `MCP_MIN_REPLICAS=1` when rehearsing if
approved, accepting idle charges. ACR, networking, private endpoints/storage,
compute and Copilot Studio can incur charges.
- Preserve signing/encryption keys. Rotate the upstream credential before its
90-day expiry and redeploy. Signing-key rotation requires reconnection;
encryption-key rotation needs a state migration or explicitly approved reset.
- Live discovery, DCR, consent-to-Entra redirects and registration persistence
after replica replacement were verified. Tests simulate the delegated exchange.
**Actual Copilot Studio sign-in, refresh and hosted tool calls remain tenant
checks**, not proven by local tests. No group, per-record authorization or
downstream on-behalf-of flow is implemented; connection identity behavior
depends on the Copilot tool/channel configuration.
- Keep ignored `.env`, `.env.entra`, `.env.broker` and `.azure/` private. The first
two retain local/previous direct-mode settings, not the current broker setup.
- Cleanup requires approval: remove the dedicated Copilot connection, then use
`azd down` only for the dedicated workshop environment. Entra registrations,
service principals and consent are outside that resource-group cleanup.
Dependencies are declared in `pyproject.toml` and hash-locked separately for
runtime/development. To intentionally refresh, use `uv pip compile pyproject.toml
--universal --generate-hashes --no-header --no-annotate -o requirements.txt` and
repeat with `--group dev -o requirements-dev.txt`; review and rerun checks.
## License and attribution
Code, tests and documentation use the [Microsoft MIT license](LICENSE). Dependencies
retain their own licenses. The Azure Container Apps icon embedded in the editable
diagram is from Microsoft's Azure Public Service Icons V24, used unchanged under
[Microsoft's architectural/training use terms](https://learn.microsoft.com/azure/architecture/icons/);
it is **not** covered by this project's MIT license. Star Wars names and minimal
facts are an independently written educational fixture, not a copied dataset.
This project is unofficial and has no affiliation with Microsoft, Disney or
Lucasfilm. No character artwork or franchise logos are included.
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues