Hosting — Streamable HTTP¶
The stdio transport signs one user in and serves one client. The HTTP transport serves many callers, each presenting their own token. This covers the second.
Settings are in configuration.md; this is about running it.
# from a clone
GRAPH_MCP_PORT=8094 uv run --directory /path/to/ms-graph-mcp ms-graph-mcp-http
# or from the package
GRAPH_MCP_PORT=8094 uvx --from ms-graph-mcp ms-graph-mcp-http
curl -s localhost:8094/health
Two routes: /mcp for the protocol, and /health. /health is unauthenticated and does not touch Graph, so a healthy response means the process is serving — not that Entra is reachable.
Per-request headers¶
| Header | Purpose |
|---|---|
Authorization: Bearer <token> | Required. Either a Microsoft Graph access token (validated as a real Entra JWT), or the configured shared secret for a machine caller. |
X-Write-Scope: true | Expose and permit the write tools for this request. |
X-Toolsets: mail,calendar | Narrow the advertised tool surface for this request. Can only narrow — the startup value is a ceiling. |
X-Entra-App-Token: <token> | Optional app-only token for directory and group lookups that delegated permissions cannot cover tenant-wide. |
X-Internal-Scope: true | Expose the internal deterministic tier. Honoured only for the shared-secret machine principal — never for a user token. |
X-OBO-Token: <token> | Internal tier only: an explicitly supplied downstream token. |
The internal tier gates on the caller being a machine principal, which only the shared-secret bypass sets. A real Entra client-credentials token does not qualify. That distinction came out of a security audit and is asserted by tests in two places.
Set GRAPH_MCP_RESOURCE_URL when you deploy behind a proxy¶
It does two things, and the second one will bite you if you skip it.
It turns on OAuth discovery: the server publishes RFC 9728 metadata at /.well-known/oauth-protected-resource/mcp and answers an unauthenticated request with a 401 carrying WWW-Authenticate: Bearer resource_metadata="…". A spec-compliant MCP client follows that pointer to find your tenant's authorization server on its own, rather than needing it configured by hand. Left empty, discovery is simply off — the server cannot know its own public URL from behind a proxy, and publishing a guess would send clients somewhere wrong.
It also registers your hostname with the transport's DNS-rebinding protection. The MCP SDK validates the Host header and, by default, trusts only localhost. Since this process binds 0.0.0.0, a deployment behind an ingress receives requests with a real hostname — and without this setting every one of them is refused with 421 Misdirected Request before reaching any handler. Localhost stays valid regardless, so local runs and MCP Inspector are unaffected.
Use GRAPH_MCP_ALLOWED_HOSTS (comma-separated) only for additional names that URL does not cover — a split-horizon DNS name, a service-mesh address, a second domain. Ports are wildcarded automatically; the protection that matters is on the name, which is what a DNS-rebinding attack has to control.
Getting
421 Misdirected Requeston every request? That is this, and it is the most likely thing to go wrong on a first hosted deployment. SetGRAPH_MCP_RESOURCE_URLto the URL clients actually connect to.
Dynamic client registration is not available. Entra ID does not implement RFC 7591, so a client cannot register itself from the discovery metadata alone. Clients need a pre-registered app id — either yours, or their own with your API added as a permission. This is an Entra limitation, not something this server can work around.
Docker¶
The image serves the HTTP transport only. stdio speaks JSON-RPC over the process's own stdin/stdout, so a client has to spawn it directly — wrapping that in docker run gains nothing and breaks the interactive sign-in.
docker run --rm -p 8094:8094 \
-e GRAPH_MCP_CLIENT_ID=<application-client-id> \
-e GRAPH_MCP_TENANT_ID=<directory-tenant-id> \
-e GRAPH_MCP_RESOURCE_URL=https://graph-mcp.example.com/mcp \
ghcr.io/nitin27may/ms-graph-mcp:latest
Published to GHCR on each release for linux/amd64 and linux/arm64, with build provenance attestations. Tags follow the release: latest tracks the newest stable, plus MAJOR.MINOR.PATCH and MAJOR.MINOR. Pre-releases are tagged with their full version (0.3.0-rc1) and never move latest.
Runs as a non-root user (uid 10001) that cannot write to its own virtualenv, and carries a HEALTHCHECK against /health.
Build it yourself with docker build -t ms-graph-mcp ..
Resource limits¶
Sensible for a single replica. The process is I/O-bound on Graph, not CPU-bound:
Embedding it instead¶
build_app() returns a Starlette app you can mount into a larger service — see configuration.md.
One trap: streamable_http_app() owns the app's lifespan, because it runs the session manager there. If you need your own lifespan, chain onto application.router.lifespan_context rather than replacing it — replacing it means the transport never starts.
Before you expose it¶
SECURITY.md is the checklist. The short version:
GRAPH_MCP_JWT_VERIFYstays on. It defaults on for a reason.- Set
GRAPH_MCP_RESOURCE_URL, or nothing reaches a handler. - Set
GRAPH_MCP_READ_ONLY=trueunless writes are genuinely needed — it removes the write tier from the deployment rather than trusting callers to omit a header. - Set
GRAPH_MCP_SEND_EMAIL_ALLOWED_DOMAINSif writes are enabled. - Leave
GRAPH_MCP_SHARED_SECRETempty unless you have a machine caller. It is what unlocks the internal tier.