Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,22 @@ ALLOWED_ORIGINS=["http://localhost:8000","http://localhost:6274"]
# Defaults are 120 requests per 1 minute
# RATE_LIMIT_MAX_REQUESTS=120
# RATE_LIMIT_WINDOW_MINUTES=1

# Optional: keep OAuth state (client registrations, users' tokens) in Redis so
# sign-ins survive container recreation, scale-to-zero and multiple replicas.
# Unset = encrypted file store inside the container (lost on recreation).
# rediss:// = TLS (required by Azure Redis); the path is the DB index.
# REDIS_URL contains a credential — keep it in a secret, not in source control.
# Enabling it (or setting JWT_SIGNING_KEY) signs every user out once.
# Docs: https://gofastmcp.com/servers/auth/oauth-proxy
# REDIS_URL=redis://localhost:6379/1

# Fixed token-signing secret (32+ characters), so rotating CLIENT_SECRET doesn't
# sign everyone out. Same value on every replica. Default: derived from
# CLIENT_SECRET. Left empty, it counts as unset.
# JWT_SIGNING_KEY=

# Fernet key for the Redis store. Requires REDIS_URL. Default: derived like
# FastMCP's own store key — set it explicitly in production. Generate:
# python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
# STORAGE_ENCRYPTION_KEY=
2 changes: 1 addition & 1 deletion .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ MCP server for Microsoft Planner built with FastMCP and Microsoft Graph SDK. Aut
- `src/` — application package
- `src/server.py` — FastMCP app, middleware, route mounting
- `src/config.py` — `Settings` via `pydantic_settings.BaseSettings`
- `src/auth_provider.py` — Azure OAuth provider
- `src/auth_provider.py` — Azure OAuth provider; optional encrypted Redis store for OAuth state (`REDIS_URL`)
- `src/deps.py` — shared dependency helpers (e.g. `get_optional_context()`)
- `src/graph_client_manager.py` — singleton `GraphClientManager` with per-user OBO clients
- `src/types.py` — shared structural types (e.g. `CollectionRequestBuilder` Protocol)
Expand Down
18 changes: 18 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,24 @@ jobs:
build-and-test:
runs-on: ubuntu-24.04

# Real Redis for tests/test_redis_storage_integration.py, which skips
# without REDIS_TEST_URL. A mock cannot show OAuth state surviving a
# restart or Redis applying FastMCP's TTLs.
# Docs: https://docs.github.com/en/actions/use-cases-and-examples/using-containerized-services/creating-redis-service-containers
services:
redis:
image: redis:7-alpine
ports:
- 6379:6379
options: >-
--health-cmd "redis-cli ping"
--health-interval 5s
--health-timeout 3s
--health-retries 10

env:
REDIS_TEST_URL: redis://localhost:6379/15

steps:
- uses: actions/checkout@v4

Expand Down
42 changes: 38 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,12 +152,33 @@ ALLOWED_ORIGINS=["http://localhost:8000","http://localhost:6274"]
# Defaults are 120 requests per 1 minute
# RATE_LIMIT_MAX_REQUESTS=120
# RATE_LIMIT_WINDOW_MINUTES=1

# Optional: keep sign-ins in Redis so they survive restarts (see below)
# REDIS_URL=rediss://:<access-key>@<name>.redis.cache.windows.net:6380/0
# JWT_SIGNING_KEY=<long random string>
# STORAGE_ENCRYPTION_KEY=<Fernet key>
```

`REQUIRE_AUTHORIZATION_CONSENT` controls whether FastMCP prompts users to explicitly approve each new MCP client. Keep this `true` (default) in production to prevent confused-deputy attacks; set it to `false` only during local development with throwaway clients.

`RATE_LIMIT_MAX_REQUESTS` and `RATE_LIMIT_WINDOW_MINUTES` are optional and should stay commented out unless you need to override the defaults for your environment.

### Keeping sign-ins across restarts (Redis)

By default the server keeps OAuth state — client registrations and users' Entra tokens — in an encrypted file store inside the container. That state is lost when the container is recreated or an Azure Container App scales to zero, and each replica has its own copy. Clients then fail to refresh with `401 invalid_client` and users must reconnect.

Set `REDIS_URL` when you run more than one replica, scale to zero, or recreate containers:

| Variable | Default | Purpose |
|---|---|---|
| `REDIS_URL` | unset (file store) | `redis://` or `rediss://` (TLS — required by Azure Redis). The path is the DB index (e.g. `/1`). Percent-encode the password if it contains `/` (Azure access keys can): `python -c "import urllib.parse; print(urllib.parse.quote('<key>', safe=''))"`. A non-clustered Redis is required (Redis Cluster supports only DB 0 and is not supported). **Contains a credential: keep it in a secret.** |
| `JWT_SIGNING_KEY` | derived from `CLIENT_SECRET` | Fixed secret (at least 32 characters) for signing the server's tokens. Set it so rotating the Entra client secret no longer signs every user out. Use the same value on every replica. |
| `STORAGE_ENCRYPTION_KEY` | derived like FastMCP's default | Fernet key encrypting the Redis store (generate: `python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"`). Requires `REDIS_URL` — the server refuses to start without it. **Set it explicitly in production**: the derived default follows FastMCP's internal key scheme, so it is only as stable as that scheme. |

Everything written to Redis is Fernet-encrypted. Tokens and sign-in transactions expire with FastMCP's lifetimes (refresh tokens 30 days, transactions minutes), but **client registrations never expire** — one is added per MCP client registration, and entries written under an old key stay behind as unreadable garbage. Give the store its own DB index (or its own Redis) rather than sharing a DB with a cache, and use an eviction policy that does not drop it silently (`noeviction`, or `volatile-*` with enough memory). An invalid `REDIS_URL`, `JWT_SIGNING_KEY` or `STORAGE_ENCRYPTION_KEY` fails at startup; an empty value (`JWT_SIGNING_KEY=`) counts as unset. If Redis is unreachable at runtime, OAuth requests and tool calls fail with `500` within about 4 seconds and recover on their own once Redis is back.

> **Enabling these settings signs everyone out once.** Turning on `REDIS_URL`, setting `JWT_SIGNING_KEY`, or changing either key later invalidates existing sign-ins: connected clients get a `401` on their next call or refresh and must reconnect once. Existing file-store state is not migrated. Plan the switch.

## Running the Server

### Local
Expand Down Expand Up @@ -187,6 +208,7 @@ docker run --rm -i \

This starts the MCP server on port 8000.
To override rate limits, also pass `-e RATE_LIMIT_MAX_REQUESTS=<value>` and `-e RATE_LIMIT_WINDOW_MINUTES=<minutes>` (defaults: `120` and `1`).
To keep sign-ins across container recreation, also pass `-e REDIS_URL=...` (see [Keeping sign-ins across restarts](#keeping-sign-ins-across-restarts-redis)).

### Running with Docker compose

Expand Down Expand Up @@ -469,7 +491,7 @@ This section covers building, testing, and contributing to the project.
src/
├── server.py # FastMCP app, middleware, route mounting
├── config.py # Settings via pydantic-settings
├── auth_provider.py # Azure OAuth provider (OBO flow)
├── auth_provider.py # Azure OAuth provider (OBO flow) + optional Redis state store
├── deps.py # Shared dependency helpers
├── graph_client_manager.py # Singleton GraphClientManager with per-user OBO clients
├── telemetry.py # OpenTelemetry setup
Expand All @@ -481,14 +503,19 @@ src/
├── groups.py # list_my_groups
├── plans.py # plan tools + list_plan_categories
├── tasks.py # task tools + list_task_fields
└── buckets.py # bucket tools
├── buckets.py # bucket tools
└── users.py # list_users
tests/
├── conftest.py
├── test_auth_provider.py
├── test_buckets_tool.py
├── test_config.py
├── test_groups_tool.py
├── test_planner_service.py
├── test_plans_tool.py
└── test_tasks_tool.py
├── test_redis_storage_integration.py # needs a real Redis (REDIS_TEST_URL)
├── test_tasks_tool.py
└── test_users_tool.py
```

### Installing Dev Dependencies
Expand All @@ -509,13 +536,20 @@ With verbose output:
uv run pytest -v
```

`tests/test_redis_storage_integration.py` runs against a real Redis and is skipped unless `REDIS_TEST_URL` is set (CI provides a `redis:7-alpine` service). Point it at a scratch DB — the tests write and delete their own keys:

```bash
docker run -d --rm --name redis-test -p 6379:6379 redis:7-alpine
REDIS_TEST_URL=redis://localhost:6379/15 uv run pytest tests/test_redis_storage_integration.py -v
```

### Architecture

The server is built with [FastMCP](https://gofastmcp.com) and uses these key patterns:

**Server composition** — Tools are split into five domain routers (`me`, `groups`, `plans`, `tasks`, `buckets`), each a standalone `FastMCP` instance [mounted](https://gofastmcp.com/servers/composition) on the main app. This keeps each domain's tools, imports, and tests isolated.

**Authentication** — The server uses FastMCP's [`OAuthProxy`](https://gofastmcp.com/servers/auth/oauth-proxy) pattern via a custom `AzureProvider`. Azure Entra ID does not support Dynamic Client Registration (DCR), so the provider acts as a DCR-compliant proxy facing MCP clients while using the pre-registered app credentials with Azure. When a tool call arrives, the server exchanges the MCP session token for a Microsoft Graph token via the [On-Behalf-Of flow](https://learn.microsoft.com/en-us/entra/identity-platform/v2-oauth2-on-behalf-of-flow), scoped to `Tasks.ReadWrite` and `User.Read`.
**Authentication** — The server uses FastMCP's [`OAuthProxy`](https://gofastmcp.com/servers/auth/oauth-proxy) pattern via a custom `AzureProvider`. Azure Entra ID does not support Dynamic Client Registration (DCR), so the provider acts as a DCR-compliant proxy facing MCP clients while using the pre-registered app credentials with Azure. When a tool call arrives, the server exchanges the MCP session token for a Microsoft Graph token via the [On-Behalf-Of flow](https://learn.microsoft.com/en-us/entra/identity-platform/v2-oauth2-on-behalf-of-flow), scoped to `Tasks.ReadWrite`, `User.Read` and `User.ReadBasic.All`. The proxy's state (client registrations, users' Entra tokens) lives in FastMCP's encrypted file store by default, or in an encrypted Redis store when `REDIS_URL` is set — see [Keeping sign-ins across restarts](#keeping-sign-ins-across-restarts-redis).

**Middleware** — Five [built-in middleware](https://gofastmcp.com/servers/middleware#built-in-middleware) layers are stacked on the server (outermost first):

Expand Down
9 changes: 7 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
[project]
name = "microsoft-planner-mcp"
version = "0.3.3"
version = "0.4.0"
description = "MCP server for Microsoft Planner via Microsoft Graph"
readme = "README.md"
license = "MIT"
requires-python = ">=3.12"
dependencies = [
"fastmcp[apps]>=3.2.3",
# <4: src/auth_provider.py mirrors FastMCP's store-key derivation (salts,
# KDF); a major release could change it and sign every Redis user out.
"fastmcp[apps]>=3.2.3,<4",
"msgraph-sdk>=1.55.0",
"odata-v4-query>=0.4.1",
"py-key-value-aio[redis]>=0.4.4,<0.5.0",
"pydantic-settings>=2.13.1",
]

Expand Down Expand Up @@ -40,4 +43,6 @@ filterwarnings = [
"ignore::DeprecationWarning:kiota_abstractions",
# msgraph-sdk generated request-configuration subclasses fire warn() in class bodies (SDK bug)
"ignore::DeprecationWarning:msgraph",
# py-key-value-aio's RedisStore calls redis-py's deprecated setex() (library bug)
"ignore:Call to deprecated setex:DeprecationWarning:key_value",
]
74 changes: 74 additions & 0 deletions src/auth_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,81 @@
# The resulting session token is validated by FastMCP on every tool call, so
# no tool executes without a verified Entra ID identity.
# Docs: https://gofastmcp.com/servers/auth/authentication
from cryptography.fernet import Fernet
from fastmcp.server.auth.jwt_issuer import derive_jwt_key
from fastmcp.server.auth.providers.azure import AzureProvider
from key_value.aio.stores.redis import RedisStore
from key_value.aio.wrappers.encryption import FernetEncryptionWrapper
from redis.asyncio import Redis
from redis.asyncio.retry import Retry
from redis.backoff import ExponentialWithJitterBackoff

from .config import settings


_JWT_SALT = "fastmcp-jwt-signing-key"

# Derived once and handed to FastMCP as bytes, which it uses verbatim
# (oauth_proxy/proxy.py). Passing the string would make FastMCP re-run the
# same 1M-iteration PBKDF2 at import — slower cold starts on scale-to-zero.
_jwt_signing_key: bytes | None = (
derive_jwt_key(
low_entropy_material=settings.JWT_SIGNING_KEY.get_secret_value(), salt=_JWT_SALT
)
if settings.JWT_SIGNING_KEY is not None
else None
)


def _redis_client_storage() -> FernetEncryptionWrapper | None:
"""Encrypted Redis store for OAuth proxy state, or None for FastMCP's default.

The default is a file store inside the container: container recreation,
scale-to-zero or a second replica loses every client registration and
token, and refreshes fail with 401 invalid_client. The store holds users'
Entra refresh tokens, so it is always Fernet-encrypted.
Docs: https://gofastmcp.com/servers/auth/oauth-proxy
"""
if settings.REDIS_URL is None:
return None

if settings.STORAGE_ENCRYPTION_KEY is not None:
key = settings.STORAGE_ENCRYPTION_KEY.get_secret_value().encode()
else:
# Mirror FastMCP's default-store key derivation (oauth_proxy/proxy.py):
# storage key from the JWT signing key, itself from JWT_SIGNING_KEY or
# CLIENT_SECRET. Without this, no key scheme would match FastMCP's.
jwt_key = _jwt_signing_key or derive_jwt_key(
high_entropy_material=settings.CLIENT_SECRET, salt=_JWT_SALT
)
key = derive_jwt_key(
high_entropy_material=jwt_key.decode(), salt="fastmcp-storage-encryption-key"
)

return FernetEncryptionWrapper(
# RedisStore(url=...) rebuilds the client from host/port/db/password and
# drops TLS, so a rediss:// URL (Azure Redis) would connect in plaintext
# and fail. redis-py's from_url honours the scheme.
# Explicit outage budget: every tool call reads token state from Redis,
# so a hung Redis must fail fast (500, the client retries) — measured
# ~4 s per request with this budget. One retry still covers a stale
# pooled connection (e.g. Azure dropping idle sockets).
key_value=RedisStore(
client=Redis.from_url(
str(settings.REDIS_URL),
decode_responses=True,
socket_connect_timeout=2,
socket_timeout=2,
retry=Retry(ExponentialWithJitterBackoff(base=0.1, cap=0.5), retries=1),
)
),
fernet=Fernet(key),
# A rotated key turns old entries into cache misses (users reconnect)
# instead of crashing the token endpoint — FastMCP's default behaviour.
raise_on_decryption_error=False,
)


auth = AzureProvider(
client_id=settings.CLIENT_ID,
client_secret=settings.CLIENT_SECRET,
Expand Down Expand Up @@ -40,4 +111,7 @@
# to disable it. Docs:
# https://gofastmcp.com/servers/auth/oauth-proxy#param-require-authorization-consent
require_authorization_consent=settings.REQUIRE_AUTHORIZATION_CONSENT,
# Both None keeps FastMCP's defaults (file store, key from CLIENT_SECRET).
client_storage=_redis_client_storage(),
jwt_signing_key=_jwt_signing_key,
)
Loading
Loading