notify-mcp
README.md
# notify-mcp
Deliver-to-self email + SMS for Example Corp staff, as an Entra-authenticated
remote MCP server. This is the send path for the claude.ai chief-of-staff
rollout: agents can email or text **the signed-in user their own content**, and
structurally nothing else.
## The invariant (why this server exists)
Agents that read untrusted content (inbound email, tickets, chats) must never
hold an unconstrained send capability — a prompt injection could turn it into
an exfiltration channel. This server solves that by construction:
- **Recipients are not tool parameters.** Email goes to the caller's UPN,
taken from the validated Entra token. SMS goes to the caller's mobile on
record. A fully compromised model can change *what* is sent, never *where*.
- **FROM is locked** to `<agent>approver@example.com` — the server cannot send
as a human, so it cannot impersonate anyone.
- The Resend + Inteliquent keys live only in this container. Users never see a
key; they authenticate with their own M365 login.
- Per-user hourly budgets + an audit log line per send.
## Tools
| Tool | What it does |
|---|---|
| `whoami` | Returns the email + masked mobile that sends will deliver to. Test wiring with this first. |
| `send_email_to_me(subject, body_markdown, agent_name?)` | Emails the caller. `agent_name` brands the sender (`alerts` → `approver@example.com`). |
| `send_sms_to_me(message)` | Texts the caller's mobile from the company DID 13105550100. ≤640 chars. |
| `register_my_mobile(mobile_number)` | Starts SMS setup: texts a 6-digit code to the number. |
| `confirm_my_mobile(code)` | Completes setup: verifies the code, saves the number, emails the user a change notice. |
## How the server knows who you are
Claude → Entra sign-in (the user's own login) → every tool call carries a
bearer JWT → `AzureJWTVerifier` validates signature/issuer/audience →
`preferred_username` claim = the recipient. No env vars per user, no
configuration; the identity is cryptographic.
Mobile numbers aren't in the token, so SMS uses **verified self-registration**
(built for tenants that don't populate Entra mobilePhone — ours doesn't):
1. User (typically during the CoS setup interview): "register my mobile
310-555-1212" → server texts a 6-digit code to that number.
2. User types the code → `confirm_my_mobile` → number saved in
`/data/sms_directory.json` keyed to their UPN + confirmation email sent.
3. **Changes are then locked** (`NOTIFY_ALLOW_MOBILE_CHANGE=false`): a
confirmed number can only be changed by the admin editing the registry.
Code-verification proves possession; the change-lock + email notice kill
the injection-rebind attack (hostile content re-pointing texts elsewhere).
Resolution order: registry file (hand entries are plain strings, self-
registered entries are objects) → optional Entra `mobilePhone` via Graph
(`NOTIFY_GRAPH_LOOKUP_ENABLED=true` + `-GrantGraphUserRead`; unnecessary if
self-registration is in use).
## Connector session longevity (the disconnect fix)
Three things keep the connector from showing "disconnected":
1. **`offline_access` is advertised** in the protected-resource metadata
(`NOTIFY_ADVERTISE_OFFLINE_ACCESS=true`), so Claude's sign-in obtains a
refresh token and silently renews the ~60–90 min access tokens.
2. **`stateless_http=True`** — sessions survive container redeploys.
3. **NPM proxy host** must have the playbook's timeout overrides (see
PORTAINER_DEPLOY.md) so idle streams aren't cut at 60s.
Also check no Conditional Access sign-in-frequency policy covers this app.
Acceptance test: connect, wait 2+ hours idle, redeploy the container, then
call `whoami` — no re-auth prompt should appear.
## Setup (playbook order)
1. `scripts/setup_entra_app.ps1 -TenantId <tenant>` (optionally
`-GrantGraphUserRead`) — prints the server env values + the connector
secret (goes in Claude's connector Advanced settings, never the server).
2. Deploy via Portainer Git stack — PORTAINER_DEPLOY.md. Suggested host port
**8093** (probe first; recorded in the playbook registry).
3. NPM proxy host `notify-mcp.example.com` → the container; playbook
timeouts + Anthropic IP allowlist; LE cert before enabling the allowlist.
4. Add as an org custom connector in claude.ai; users connect once with
their own M365 login.
## Acceptance tests
```bash
curl http://<docker-host>:8093/healthz # {"status":"ok",...}
curl -s https://notify-mcp.example.com/.well-known/oauth-protected-resource/mcp | jq
# scopes_supported MUST include the full https://.../mcp/access_as_user scope
# AND "offline_access"
curl -i https://notify-mcp.example.com/mcp # 401 + WWW-Authenticate
```
Then in Claude, as a group member:
1. `whoami` → your own email, mobile "on record (…1234)" or a clear miss.
2. `send_email_to_me` → arrives in YOUR inbox from `notify_ai@`.
3. Ask the model to "send this to <someone else>" → it must refuse / have no
way to comply. This failing is a P1 — do not roll out.
4. `register_my_mobile` with your number → code arrives → `confirm_my_mobile`
→ confirmation email lands → `send_sms_to_me` works. Then try registering a
DIFFERENT number → must be refused (change-lock).
5. The 2-hour idle + redeploy test above.
6. A non-member connects → rejected at sign-in (AADSTS50105).
## Local dev
```bash
pip install -r requirements.txt
MCP_AUTH_ENABLED=false python -m notify_mcp # Inspector only — tools will
# refuse to send without a token
# identity; NEVER expose auth-off
```
This server cannot be deployed
Maintenance
ActivitySlowing
ResponsivenessNo issues