multi-mail-mcp
by jcpinto54
README.md
# multi-mail-mcp
One local MCP server in front of any number of **Gmail** and **Exchange Online /
Outlook.com** accounts, with **access control per email account**: read-only for
some mailboxes, drafts-only or scoped sending for others, all behind one server.
- Any MCP client, over stdio. The server runs on your machine; only it talks to
Google and Microsoft.
- Every account has its own policy: capabilities (`read`, `draft`, `organize`,
`send`, `delete`), a recipient allowlist, a recipient limit, and whether each
send must be confirmed by you.
- Each account's OAuth token is requested with the **minimum scopes** its policy
needs, so the provider itself refuses what the policy forbids wherever scope
granularity allows. The server enforces the rest before any provider call.
- Email content is treated as untrusted input, and every tool call is written to
a local audit log.
The full design is in [SPEC.md](SPEC.md).
## Why this exists
This started as research: [docs/](docs/) surveys the multi-account email MCP
servers that existed in September 2026. Its headline finding was that **none
enforced per-account permissions inside one instance**. Several handled many
accounts and several had good permission controls, but in every one the
permission profile was a property of the **process** while the account was a
property of the **call**:
| | Account granularity | Permission granularity |
|---|---|---|
| `ms-365-mcp-server` | per tool call (`account` param) | per process (`--read-only`, `--preset`, `--allowed-scopes`) |
| `google_workspace_mcp` | per connection (OAuth 2.1) | per process (`--read-only`, `--tool-tier`, `--permissions`) |
| Agent Email | per process | per process (`EMAIL_AGENT_MCP_SCOPE_PROFILE`) |
The second finding shaped the design more: **the tool list is not the security
boundary, the OAuth scope is.** Hiding tools shapes what the model attempts; only
the token decides what the provider allows. So this server treats the account
as the unit of policy and derives each account's token scopes from its policy.
| Path | Holds |
|---|---|
| [docs/landscape.md](docs/landscape.md) | Every server and product evaluated, with evidence and a verdict |
| [docs/permissions.md](docs/permissions.md) | How access control works today: the three layers, and which one is real |
| [docs/aggregation.md](docs/aggregation.md) | Getting to one entry point: gateways vs. client-native rules |
| [docs/the-gap.md](docs/the-gap.md) | What was missing, and what a new server would have to do about it |
## Status
v1 is built to [SPEC.md](SPEC.md) and passes its unit, contract and end-to-end
suites against fake providers. It is in use with real Gmail and Outlook.com
accounts, but the **live test suite has not been run yet** (it checks the
provider behaviour the fakes can only assume), so treat 0.1 as pre-release.
- [x] Survey existing multi-account email MCP servers
- [x] Establish how permission control is done today
- [x] Establish the options for a single entry point
- [x] Decide to build: [SPEC.md](SPEC.md) (TypeScript, Node.js 22+, stdio, test-first)
- [x] Config, per-account policy engine, scope derivation, audit log
- [x] OAuth login (PKCE and loopback), token storage (keychain or file), token manager
- [x] Gmail and Microsoft Graph adapters, tested against scope-enforcing fakes
- [x] Tool layer and MCP server with call-time enforcement and elicitation-based confirmation
- [x] CLI (`serve`, `login`, `logout`, `status`, `permissions`, `validate`, `init`) and composition root
- [x] Control panel (`multi-mail-mcp ui`): account status, sign-in and revoke, per-account permission panel, editing accounts with review (comment-preserving writes), OAuth app setup with keychain-stored secrets, client registration snippets, activity log, settings
- [x] End-to-end suite: the real server and CLI as child processes against the fakes
- [x] Adversarial security review; confirmed findings fixed with regression tests
(draft recipient smuggling via encoded display names, Spam/Junk as a delete path,
routing-style local parts, `send_draft` edit races, request timeouts, IPv6
loopback callback squatting)
- [ ] Live suite run against real test mailboxes (written, opt-in, not yet run)
- [x] Published package (`npm install -g multi-mail-mcp`)
## Install
Requires Node.js 22 or later.
```sh
npm install -g multi-mail-mcp
```
From source:
```sh
git clone https://github.com/jcpinto54/multi-mail-mcp.git
cd multi-mail-mcp
npm install
npm run build # compiles to dist/; the command is dist/cli/index.js
npm link # optional: puts `multi-mail-mcp` on your PATH
```
`npm run verify` runs the typecheck, the test suite and the build.
## 1. Register your OAuth apps (once)
You use your own OAuth clients: one Google Cloud **Desktop app** client and one
Microsoft Entra **public client** app registration. The exact steps are in
[SPEC.md §6.1](SPEC.md#61-app-registrations-done-once-by-the-user), and
`multi-mail-mcp init` writes a config template that walks through them in its
comments. Two things matter most:
- Google: set the consent screen to **In production** (it can stay unverified).
In *Testing*, Google expires refresh tokens after 7 days.
- Microsoft: redirect URI `http://localhost` under *Mobile and desktop
applications*, *Allow public client flows* on, no client secret.
## 2. Configure
```sh
multi-mail-mcp init # writes ~/.config/multi-mail-mcp/config.yaml
multi-mail-mcp validate # prints every error, if any
```
The config lives at `--config <path>`, else `$MULTI_MAIL_MCP_CONFIG`, else
`~/.config/multi-mail-mcp/config.yaml`. Changes take effect when the server
restarts. Unknown keys are errors, so a typo cannot silently weaken a policy.
```yaml
version: 1
providers:
google:
clientId: 1234-abc.apps.googleusercontent.com
clientSecret: env:MULTI_MAIL_MCP_GOOGLE_CLIENT_SECRET # Desktop-app client
microsoft:
clientId: 00000000-0000-0000-0000-000000000000 # public client, no secret
accounts:
- id: personal
provider: google
address: me@gmail.com # verified at login
capabilities: [read] # read-only: the token cannot write or send
- id: work
provider: microsoft
address: me@company.com
microsoft: { tenant: organizations }
capabilities: [read, draft, organize, send]
send:
allowedRecipients: ["*@company.com", "partner@example.org"]
requireConfirmation: true # ask you before every send (MCP elicitation)
maxRecipients: 10
tokenStore: { kind: keychain } # keychain (default on macOS) | file
audit: { path: ~/.local/state/multi-mail-mcp/audit.log }
security: { overPrivilegedTokens: refuse }
```
Instead of `env:NAME`, a secret can live in the OS keychain:
`clientSecret: keychain:multi-mail-mcp-secrets/google-client-secret`. The
control panel's OAuth apps page stores it there for you, and then no MCP client
needs to pass the secret to the server.
Every rule is in [SPEC.md §4](SPEC.md#4-configuration). `providers.<p>.endpoints`
also exists, but **only for the test suite**: it points the server at fake
providers, and the server warns on stderr whenever it is set.
## 3. Sign accounts in and check them
The easiest way is the control panel:
```sh
multi-mail-mcp ui # opens a local page; --no-browser prints its address
```
It lists every account with its sign-in state, signs accounts in (or revokes and
signs in again), and shows for each one what the AI may do and whether Google or
Microsoft enforces that too, or only the server. You can add, edit and remove
accounts there: every change is validated like `validate`, and before saving you
see the exact lines that change and what the change does to the account's
sign-in. Your comments and formatting in the file are kept. It also sets up the
OAuth apps (storing the Google client secret in the keychain), shows the exact
command to register the server in Claude Code and other clients, and lists
recent tool calls from the audit log. It runs on 127.0.0.1 only, needs
the key in the link it prints, and stops when you close the page. Your MCP client
never starts it, so the AI cannot change its own permissions. Details:
[SPEC.md §6.7](SPEC.md#67-control-panel).
The same from the command line:
```sh
multi-mail-mcp login personal # opens the browser; --no-browser prints the URL
multi-mail-mcp login work
multi-mail-mcp status # auth state, granted vs required scopes, expiry
multi-mail-mcp permissions # what each account may do, and who enforces it
multi-mail-mcp logout work --revoke
```
- `login` verifies you signed in as the configured address (signing into the
wrong account in the browser is the easiest mistake to make) and that the
granted scopes cover the policy, before anything is stored.
- `status` reports each account as `ok`, `missing`, `reauth_required`,
`under_privileged` (the policy was widened: log in again) or `over_privileged`
(the token grants more than the policy: it is refused until you run
`logout <id> --revoke` and `login <id>`). It never prints tokens; `--json` is
machine-readable.
- `permissions [id] [--json]` is offline: per account, the capabilities, the
scopes login requests, the tools it can call, and the enforcement matrix below.
## 4. Register the server with an MCP client
The control panel's **Connect a client** page prints the exact command for this
install, with absolute paths (GUI clients often don't share your shell's PATH).
**Claude Code:**
```sh
claude mcp add --scope user multi-mail -- multi-mail-mcp serve
```
`--scope user` makes it available in every project. Add `-e
MULTI_MAIL_MCP_GOOGLE_CLIENT_SECRET=...` if your config reads the secret from the
environment (not needed with a `keychain:` secret), and `--config
/abs/path/config.yaml` after `serve` to use a config outside the default
location.
**Other MCP clients** (Claude Desktop, Cursor, and anything else that launches
stdio servers) take the same command in their JSON config:
```json
{
"mcpServers": {
"multi-mail": {
"command": "/abs/path/to/node",
"args": ["/abs/path/to/multi-mail-mcp/dist/cli/index.js", "serve"],
"env": { "MULTI_MAIL_MCP_GOOGLE_CLIENT_SECRET": "..." }
}
}
}
```
Send confirmation uses MCP **elicitation**. With a client that does not support
it, sends from an account with `requireConfirmation: true` are denied
(`CONFIRMATION_UNAVAILABLE`), never silently allowed.
## What each account can do, and who stops it
Each tool is advertised only if some account may call it, and its `account`
parameter is an enum of exactly those accounts; every call is re-checked when it
arrives regardless. The OAuth scope is the first boundary, the server the second:
**Gmail**
| Capabilities | Scope requested | The provider refuses | Only the server refuses |
|---|---|---|---|
| read | `gmail.readonly` | send, modify, delete | — |
| draft | `gmail.compose` | reading non-draft mail, modify | send (compose can send) |
| send | `gmail.send` | read, modify | recipients outside the allowlist; unconfirmed sends |
| organize / delete | `gmail.modify` | permanent delete | send, drafts, Trash unless `delete` |
**Microsoft Graph**
| Capabilities | Scope requested | The provider refuses | Only the server refuses |
|---|---|---|---|
| read | `Mail.Read` | send, modify, delete | — |
| draft / organize / delete | `Mail.ReadWrite` | send | editing non-drafts, permanent delete, Trash unless `delete` |
| send | `Mail.Send` | read, modify | recipients outside the allowlist; unconfirmed sends |
The headline guarantee: **an account with only `read` holds a token the provider
will not let write or send**, on both providers. Unions of capabilities get the
minimal union of scopes; `multi-mail-mcp permissions` prints the exact matrix
for each of your accounts. Details, including send policy, confirmation and
reply semantics: [SPEC.md §5](SPEC.md#5-access-control) and
[§7.4](SPEC.md#74-reply-and-forward-semantics).
## How it is verified
| Layer | What | Where |
|---|---|---|
| Unit | Every config rule, scope derivation and coverage, recipient matching (hostile inputs included), capability checks, reply recipients, untrusted-content wrapping, audit redaction, PKCE and state, token lifecycle | `src/**/*.test.ts` |
| Provider contract | The Gmail and Graph adapters against in-process fakes of the Gmail API, Microsoft Graph and both OAuth servers, which enforce scopes per endpoint (403 on insufficient scope) and record every request, including 401/403/404/429/5xx paths | `test/fakes/`, `src/providers/**` |
| End to end | The real `multi-mail-mcp serve` and `login` as child processes, driven by the MCP SDK's stdio client, against the fakes: tool enums per policy; read-only accounts denied with zero provider requests, audited, and their stored tokens refused by the fake API's send endpoint; recipient allowlist incl. reply-all and `send_draft` re-reading recipients; confirmation accepted, declined and unavailable; several accounts per provider concurrently and `account: "all"`; over-privileged tokens refused until repaired; login with wrong identity or wrong state rejected; Graph draft tools refusing non-drafts; stdout carrying only JSON-RPC | `test/e2e/` |
| Live | Against real, already signed-in test mailboxes: identity, read tools, a read-only token refused by the real send endpoint, Graph reply recipient fidelity | `test/live/` (opt-in) |
```sh
npm test # unit, contract and e2e; no build needed
MULTI_MAIL_MCP_LIVE_CONFIG=/path/to/live.yaml npm run test:live # live; skipped without the variable
```
An honest caveat: **the fakes are built from the providers' published API
documentation**, so they are only as faithful as that documentation. Everything
the e2e suite proves about Google and Microsoft behaviour (which scope a given
endpoint demands, what Graph's `/reply` does with explicit recipients) is proven
against the fakes' reading of the docs. The live suite is what confirms the real
behaviour, and it has not been run yet. The environment variables it needs are
documented at the top of [test/live/live.test.ts](test/live/live.test.ts).
## License
MIT
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues