Skip to main content
Glama

Entra Planner MCP

A small, stateless remote MCP server that lets Claude (claude.ai, Desktop, mobile, Cowork, and Claude Code through organisation connectors) read and update Microsoft Planner tasks in group plans, acting as the signed-in user.

  • Microsoft Entra ID is the only authorization server. The server runs no OAuth endpoints of its own; it publishes RFC 9728 protected-resource metadata that points clients at your tenant.

  • Stateless, no secrets. Each call validates the Entra access token and does an On-Behalf-Of (OBO) exchange for a Graph token. The OBO client credential is a managed identity federated credential, so there is no client secret anywhere.

  • Narrow by design. Eight Planner tools, no delete tool, no generic Graph passthrough. Write tools are off unless you enable them.

MCP client (Claude) --401 + resource_metadata--> this server (/.well-known/oauth-protected-resource)
        |                                              |
        +-- user signs in to Entra (PKCE) --> token for the API app (aud = API app ID, scp = Planner.Use)
                                                       |
this server: validate token -> On-Behalf-Of (client assertion = managed identity federated credential)
                                                       |
                                            Microsoft Graph Planner API as the user

Tools

Tool

What it does

Kind

planner_list_plans

Plans in the groups you belong to (GET /me/planner/plans)

read

planner_get_plan

Buckets and tasks, filtered by bucket, assignee, due before/after, open/completed

read

planner_get_task

Task plus description, checklist and links, with ETags

read

planner_my_tasks

Tasks assigned to you (GET /me/planner/tasks)

read

planner_create_task

Create a task (bucket, due date, priority, notes, checklist, assignees)

write

planner_update_task

Update title, due date, bucket, progress, priority, notes, checklist (add/check/uncheck)

write

planner_complete_task

Set progress to 100%

write

planner_assign_task

Add or remove assignees (user object IDs or me)

write

Writes use Planner ETags (If-Match). Pass the etag from planner_get_task for true optimistic concurrency; otherwise the server reads the current ETag first. A conflict returns "changed by someone else: re-read it and retry". Premium (Project) plans are not in Graph and are out of scope.

Related MCP server: Planner MCP Server

Configuration (environment only)

Variable

Required

Meaning

ENTRA_TENANT_ID

yes

Tenant GUID. Tokens from any other tenant are rejected

ENTRA_API_CLIENT_ID

yes

API app registration (client) ID; the expected aud

PUBLIC_BASE_URL

yes

Canonical public URL of the MCP endpoint, e.g. https://<app>.<env>.<region>.azurecontainerapps.io/mcp (no trailing slash)

ENTRA_REQUIRED_SCOPE

Delegated scope name, default Planner.Use

ENTRA_ALLOWED_AUDIENCES

Extra accepted audiences (comma list); default the API client ID only

ENTRA_ALLOWED_CLIENT_IDS

If set, only tokens issued to these client apps (azp) are accepted. Recommended

ALLOWED_TOOLS

Comma list of tools to expose; default the four read-only tools

OBO_CREDENTIAL

managed_identity (default) or local_dev_secret (refused when ENV=production)

MANAGED_IDENTITY_CLIENT_ID

Client ID of a user-assigned identity; omit for system-assigned

LOCAL_DEV_CLIENT_SECRET

Only with OBO_CREDENTIAL=local_dev_secret

ENABLE_USER_LOOKUP

true lets assignees be given by email (needs Graph User.ReadBasic.All); default IDs only

RATE_LIMIT_PER_MIN

Per-user tool calls per minute, default 60

LOG_LEVEL

Default WARNING. DEBUG is refused in production. Tokens and task content are never logged

ALLOWED_ORIGINS

Accepted browser Origin values, default https://claude.ai,https://claude.com (requests without Origin are accepted)

ENV

production (default) or development

Endpoints: POST /mcp (streamable HTTP, stateless, JSON responses), GET /.well-known/oauth-protected-resource and /.well-known/oauth-protected-resource/mcp, GET /health/live, GET /health/ready (ready = configuration valid and the tenant's signing keys fetched). Requests whose Host isn't the PUBLIC_BASE_URL host get 421.

Deploying to your tenant

You need an Entra administrator (to create app registrations and grant admin consent) and an Azure subscription. Replace every <...> placeholder.

1. Hosting first, so you know the URL

The MCP URL must be registered on the API app, so create the hosting first. Azure Container Apps (consumption, scale to zero) fits within the monthly free grant for a handful of users:

RG=<resource-group>; LOC=<region>; ENVNAME=<env-name>; APP=<app-name>
az group create -n $RG -l $LOC
az containerapp env create -n $ENVNAME -g $RG -l $LOC --logs-destination none
FQDN="$APP.$(az containerapp env show -n $ENVNAME -g $RG --query properties.defaultDomain -o tsv)"
echo "MCP URL: https://$FQDN/mcp"

2. API app registration (the server)

  1. Register an app (single tenant), e.g. "Planner MCP API". No client secret.

  2. Manifest: set api.requestedAccessTokenVersion to 2 before adding the URL below. (Needed when the URL is on a domain your tenant hasn't verified, such as *.azurecontainerapps.io.)

  3. Expose an API: Application ID URIs api://<api-app-id> and the exact MCP URL (https://<fqdn>/mcp). Claude sends the MCP URL as the OAuth resource; without it Entra fails with AADSTS9010010.

  4. Add a delegated scope Planner.Use, "Admins only" consent.

  5. API permissions, Microsoft Graph delegated: Tasks.ReadWrite, User.Read, offline_access (and User.ReadBasic.All only if you set ENABLE_USER_LOOKUP=true). Grant admin consent.

  6. Enterprise application: Assignment required = Yes; assign the users who may use the connector.

3. Client app registration (what Claude signs in with)

  1. Register a second app, e.g. "Planner MCP for Claude".

  2. Platform Mobile and desktop applications (public client, PKCE, no secret) with redirect URIs https://claude.ai/api/mcp/auth_callback, http://localhost/callback, http://127.0.0.1/callback (Entra ignores the port for loopback addresses).

  3. API permissions: your API's Planner.Use, plus Microsoft Graph openid and offline_access; grant admin consent (with user consent disabled, users can't consent themselves).

  4. Enterprise application: Assignment required = Yes; assign the same users.

  5. Optional hardening: set ENTRA_ALLOWED_CLIENT_IDS=<client-app-id> on the server.

If your Claude organisation can't use a public client, add a client secret to this client app (not the API app) and enter it in Claude's connector settings only.

4. Deploy the container

Always deploy by digest, after verifying the signature:

IMAGE=ghcr.io/<owner>/entra-planner-mcp
DIGEST=$(docker buildx imagetools inspect $IMAGE:latest --format '{{json .Manifest}}' | jq -r .digest)
cosign verify $IMAGE@$DIGEST \
  --certificate-oidc-issuer https://token.actions.githubusercontent.com \
  --certificate-identity https://github.com/<owner>/entra-planner-mcp/.github/workflows/release.yml@refs/heads/main

az containerapp create -n $APP -g $RG --environment $ENVNAME \
  --image $IMAGE@$DIGEST --target-port 8000 --ingress external \
  --cpu 0.25 --memory 0.5Gi --min-replicas 0 --max-replicas 2 \
  --system-assigned \
  --env-vars ENTRA_TENANT_ID=<tenant-id> ENTRA_API_CLIENT_ID=<api-app-id> \
             ENTRA_ALLOWED_CLIENT_IDS=<client-app-id> PUBLIC_BASE_URL=https://$FQDN/mcp \
             ALLOWED_TOOLS=planner_list_plans,planner_get_plan,planner_get_task,planner_my_tasks
az containerapp ingress update -n $APP -g $RG --allow-insecure false

Add HTTP liveness/readiness probes on /health/live and /health/ready (port 8000). The managed identity needs no Azure role assignments.

Optionally restrict ingress to your MCP client's egress range (for Claude, see Anthropic's published IP addresses): az containerapp ingress access-restriction set -n $APP -g $RG --rule-name mcp-client --ip-address <cidr> --action Allow.

5. Federated credential: let the managed identity act as the API app

PRINCIPAL=$(az containerapp show -n $APP -g $RG --query identity.principalId -o tsv)
az ad app federated-credential create --id <api-app-id> --parameters "{
  \"name\": \"container-app-mi\",
  \"issuer\": \"https://login.microsoftonline.com/<tenant-id>/v2.0\",
  \"subject\": \"$PRINCIPAL\",
  \"audiences\": [\"api://AzureADTokenExchange\"]
}"

6. Check it

curl -si -X POST https://$FQDN/mcp | grep -i www-authenticate   # 401, Bearer resource_metadata=...
curl -s https://$FQDN/.well-known/oauth-protected-resource/mcp  # resource = your MCP URL

(If you restricted ingress by IP, run these from an allowed address.)

7. Add the connector in Claude

Organization settings > Connectors > Add > Custom: the MCP URL, then the advanced/OAuth settings: client ID = the client app's ID, client secret blank (public client). Set per-tool permissions (for example reads "Always allow", writes "Ask").

Development

uv sync --locked
uv run ruff check src tests && uv run ruff format --check src tests
uv run mypy
uv run pytest

Tests use a fake JWKS and a fake Graph; nothing touches the network. For a local run against a real tenant, use a separate dev app registration with a secret and ENV=development OBO_CREDENTIAL=local_dev_secret (see .env.example); production refuses that mode.

Supply chain

  • Base image: Chainguard Python (distroless runtime: no shell, no package manager), non-root (UID 65532), read-only root filesystem friendly. Dependencies locked with hashes in uv.lock.

  • CI on every PR and push: ruff, mypy, pytest, gitleaks (full history), an identifier deny-list (supplied as a repository secret, never committed), Trivy filesystem scan.

  • Release on main, weekly, and after Dependabot merges: build, Trivy image scan (fails on any HIGH/CRITICAL with a fix), smoke test, CycloneDX SBOM, push to GHCR, cosign keyless signature + SBOM attestation, SLSA build provenance. Actions pinned by commit SHA with least-privilege permissions.

See SECURITY.md to report a vulnerability. Licence: MIT.

Maintenance

ActivityMaintained
ResponsivenessNo issues

Related MCP Connectors

Related MCP Servers