Exchange Server MCP
A Model Context Protocol server that gives an LLM read-only administration and troubleshooting access to an on-premises Microsoft Exchange Server via remote PowerShell (PSRP/WinRM).
It exposes a curated set of narrow, named diagnostic tools — one per whitelisted cmdlet — so the model can answer questions like "is mail flow healthy on this server?", "when does this certificate expire?" or "why was this message delayed?" without ever being able to run arbitrary commands or change anything.
Scope: server administration & diagnostics. It does not read users' mail — no message bodies, no EWS. Diagnostic data (message tracking incl. subject, calendar/rule config, statistics) is in scope, because that is what troubleshooting needs.
Safety model
This project is built around a few non-negotiable rules:
- No generic "run PowerShell" tool. Every capability is one named tool that maps to exactly one cmdlet. The model cannot execute arbitrary script.
- A cmdlet whitelist is authoritative. A cmdlet runs only if it is listed
in
config/cmdlet_whitelist.yaml, and only with the parameters declared there. Everything else is rejected before it reaches the server. - Read-only by default. Mutating cmdlets are blocked unless explicitly
flagged and
ALLOW_WRITE_CMDLETS=true. The project ships read-only. - Everything is audited. Every call (ok / blocked / error) is appended to a JSON-lines audit log through a single chokepoint.
- Least privilege. Designed for a View-Only Organization Management service account.
How it works
MCP tool (generated from the whitelist)
└─ Runner.execute(cmdlet, params)
├─ Whitelist.validate() # reject unknown cmdlet / param / write
├─ ExchangeSession.run_cmdlet() # pypsrp PSRP → Microsoft.Exchange
└─ AuditLog.record() # JSON-lines audit (ok / blocked / error)
Runner.execute is the single point through which all cmdlet execution flows.
Tools are generated from the whitelist at startup, so the whitelist is the
one source of truth for what the server can do.
The current whitelist exposes 99 read-only tools across: servers & health, certificates, databases & DAG, transport & mail flow, message tracking, client-access & virtual directories, mobile devices, recipients & groups, permissions, mailbox/calendar/rule configuration, retention, RBAC, migration and organization configuration.
Requirements
- Docker (the supported runtime — see Why Docker below).
- An on-prem Exchange Server reachable over WinRM/HTTP(S) (the
/PowerShellIIS endpoint). - A service account with View-Only Organization Management (or narrower).
- The Exchange endpoint typically accepts Kerberos only, so the host running the container must be able to reach the realm's KDC.
Why Docker
The Exchange /PowerShell endpoint advertises WWW-Authenticate: Kerberos and
nothing else. Authenticating with explicit credentials from a non
domain-joined machine is reliable from Linux via pyspnego's GSSAPI/Kerberos
backend, but fights the Windows SSPI stack (which insists on resolving an SPN and
fails for non-joined hosts). The Docker image bundles the Kerberos backend and
generates /etc/krb5.conf from your environment at startup, so it runs anywhere
Docker does.
Configuration
The server is configured entirely through environment variables — nothing is
baked into the image. A .env file is just the most convenient way to provide
them; anything that sets the same variables works (docker run -e VAR=value …,
or a client that injects them from its own settings UI / OS keychain — see
Configuring from a client).
In the Docker deployment there is no
.envinside the container —--env-file .envreads the file on the host and passes the values in as environment variables. The.envfile is never committed or imaged (.gitignore+.dockerignore).
For local use, copy the template and fill in the three required values:
cp .env.example .env
# minimal .env — everything else has sensible defaults
EXCHANGE_SERVER=mail.example.com # FQDN, not IP
[email protected] # UPN form: user@REALM
EXCHANGE_PASSWORD=your-password
See .env.example for the full annotated list.
Configuring from an MCP client
Because configuration is just environment variables, a client with a per-server
settings UI — such as Claude Desktop's connector panel, which stores secrets in
the OS keychain — can supply them instead of a file. Forward each variable into
the container with -e VAR (no value means "inherit from whatever launched
docker"):
docker run -i --rm --add-host mail.example.com:10.0.0.10 \
-e EXCHANGE_SERVER -e EXCHANGE_USERNAME -e EXCHANGE_PASSWORD \
-v /abs/path/logs:/app/logs exchange-server-mcp
This keeps secrets out of any file on disk. There is also a packaged Claude
Desktop extension (.mcpb) with a user_config form — see
Claude Desktop extension below.
Build & run
docker build -t exchange-server-mcp .
Smoke-test the connection (the host must resolve the Exchange FQDN — either via
DNS or an --add-host mapping to its IP):
docker run --rm --add-host mail.example.com:10.0.0.10 \
--env-file .env exchange-server-mcp python scripts/conn_test.py
Connecting an MCP client
The server speaks MCP over stdio; the client launches the container per
session. Note -i, and bind-mount a host folder for the audit log so it
lands as a normal file on disk (audit.log appears directly in that folder).
Replace /abs/path/logs with a real path — on Windows use e.g.
"C:/Users/you/exchange-logs:/app/logs":
docker run -i --rm \
--add-host mail.example.com:10.0.0.10 \
--env-file /abs/path/.env \
-v /abs/path/logs:/app/logs \
exchange-server-mcp
Claude Code:
claude mcp add exchange -- docker run -i --rm \
--add-host mail.example.com:10.0.0.10 \
--env-file /abs/path/.env -v /abs/path/logs:/app/logs exchange-server-mcp
Codex (OpenAI) — codex mcp add exchange -- docker run …, or add to
~/.codex/config.toml:
[mcp_servers.exchange]
command = "docker"
args = [
"run", "-i", "--rm",
"--add-host", "mail.example.com:10.0.0.10",
"--env-file", "/abs/path/.env",
"-v", "/abs/path/logs:/app/logs",
"exchange-server-mcp",
]
Claude Desktop (claude_desktop_config.json) — or use the one-click
extension below:
{
"mcpServers": {
"exchange": {
"command": "docker",
"args": ["run", "-i", "--rm",
"--add-host", "mail.example.com:10.0.0.10",
"--env-file", "/abs/path/.env",
"-v", "/abs/path/logs:/app/logs",
"exchange-server-mcp"]
}
}
}
Any other client that launches a stdio command works the same way (Cursor,
Windsurf, VS Code, …): point it at the docker run … exchange-server-mcp command.
ChatGPT is the exception: it only supports remote (HTTPS) MCP connectors
in Developer Mode, not local stdio. This server is stdio/Docker, so it cannot be
added directly. To use it with ChatGPT you would have to expose it over HTTPS —
e.g. with an mcp-remote bridge or a
tunnel (ngrok / Cloudflare Tunnel) — and register that URL as a custom connector.
That is an advanced, self-hosted setup and not a first-class path here; a native
HTTP transport is possible future work.
Claude Desktop extension (.mcpb)
For Claude Desktop you can install this as a one-click extension instead of
editing config: Claude shows a settings form (server, account, password) and
stores the password in the OS keychain. The manifest lives in
extension/manifest.json.
The extension launches docker run … exchange-server-mcp, so first build the
image locally (until a public image is published):
docker build -t exchange-server-mcp .
Then install the bundled exchange-server-mcp.mcpb
(in the repo root): open it with Claude Desktop, or Settings → Extensions →
Install. Fill in the form and save. The fields map to the same environment
variables described above — the password is stored in the OS keychain and passed
through with -e, so it never appears on disk or in the process arguments.
Maintainers: if you change
extension/manifest.json, re-pack and re-commit the bundle (needs Node.js):npx @anthropic-ai/mcpb validate extension/manifest.json npx @anthropic-ai/mcpb pack extension exchange-server-mcp.mcpb
The form asks for a DNS server (usually a domain controller) and the Exchange server IP. The DNS server lets the container resolve the Exchange server, the KDC and Kerberos records — important when the KDC is a separate domain controller (it won't resolve otherwise). The IP additionally pins the Exchange name via
--add-host. For the CLI paths above, the equivalents are--dns <dns-ip>and--add-host <fqdn>:<ip>.
Adding a cmdlet
Tools are generated from the whitelist, so adding a capability is a single edit
to config/cmdlet_whitelist.yaml:
Get-MailboxDatabase:
write: false
description: "Mailbox database configuration. Status adds mount/size fields."
allowed_params: [Identity, Server, Status]
param_types: { Identity: str, Server: str, Status: switch }
On the next start a tool get_mailbox_database(identity=None, server=None, status=False) is registered automatically — snake_case args are mapped back to
the cmdlet's PascalCase parameters, and switch params become booleans.
Keep new cmdlets within the read-only, no-message-body scope.
Development
python -m venv .venv && . .venv/bin/activate
pip install -e ".[dev]"
python -m pytest -q # unit tests (mocked — no live server needed)
ruff check . && ruff format .
The test suite covers whitelist validation, the Runner (auditing + output cap),
serialization (switch encoding, CLIXML collection extraction, certificate
parsing) and tool generation — all without a server.
scripts/smoke_whitelist.py is the manual end-to-end check against a real box.
Project layout
src/exchange_mcp/
server.py entry point — wires everything together
config.py settings from .env
connection.py pypsrp PSRP session + result serialization
whitelist.py cmdlet + parameter validation (safety core)
runner.py validate → audit → execute chokepoint + output cap
audit.py JSON-lines audit log
tools/diagnostics.py generates one MCP tool per whitelist entry
config/cmdlet_whitelist.yaml the authoritative cmdlet list
docker/entrypoint.sh generates /etc/krb5.conf from env
tests/ pytest suite
Security notes
.envholds credentials and is never committed or baked into the image.- The container runs as a non-root user (uid 10001). Bind-mount a host
folder for the audit log (
-v /abs/path/logs:/app/logs); Docker Desktop makes bind mounts writable by the container user, soaudit.logis written straight to that folder on the host. - The audit log records cmdlet, parameters and outcome for every call — treat it as sensitive (it can contain recipient addresses and message subjects) and mount it to durable storage.
- Run the container with a least-privilege service account; the whitelist is the backstop, not the only control.
- The server reconnects once automatically if the WinRM session drops (idle timeout, app-pool recycle); all whitelisted cmdlets are read-only, so the retry is safe.
Troubleshooting
Most setup problems are Kerberos. Run the bundled diagnostic inside the
container — it checks config, DNS, KDC reachability, a TGT (kinit), the SPN
ticket (kvno) and the actual pyspnego/gssapi path the server uses, with a
KRB5 trace per step:
docker run --rm --dns <dns-ip> --add-host <fqdn>:<ip> \
-e EXCHANGE_SERVER=<fqdn> -e EXCHANGE_USERNAME=<USER@REALM> \
-e EXCHANGE_PASSWORD='...' -e EXCHANGE_REALM=<REALM> -e EXCHANGE_KDC=<dc-fqdn> \
exchange-server-mcp python scripts/troubleshoot_kerberos.py
Common causes of Matching credential not found / SpnegoError:
- Username case. Use the account's exact case as stored in AD.
kinittolerates a mismatch but pyspnego/gssapi does not — the script warns if the Kerberos salt shows a different case than you entered. - Realm ≠ NetBIOS. The part after
@must be the DNS realm ([email protected]), not the NetBIOS name (user@CORP). - KDC not resolvable. The KDC is usually a separate domain controller; set
a DNS server (
--dns) so the container can resolve it.
License
Built and maintained by FrankysWeb — a German blog on Microsoft Exchange, Windows Server and related infrastructure.