Skip to main content
Glama
JohnGilligan2

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
```