# Exchange Server Mcp

> Read-only MCP server for on-prem Exchange Server administration & troubleshooting (Kerberos remote PowerShell, Docker)

- **Type:** MCP server
- **Install:** `agentstack add mcp-frankysweb-exchange-server-mcp`
- **Verified:** Yes — security-reviewed for prompt injection and unsafe behavior
- **Seller:** [FrankysWeb](https://agentstack.voostack.com/s/frankysweb)
- **Installs:** 0
- **Category:** [Cloud & Infrastructure](https://agentstack.voostack.com/c/cloud-infrastructure)
- **Latest version:** 0.1.0
- **License:** MIT
- **Upstream author:** [FrankysWeb](https://github.com/FrankysWeb)
- **Source:** https://github.com/FrankysWeb/exchange-server-mcp

## Install

```sh
agentstack add mcp-frankysweb-exchange-server-mcp
```

Requires the [AgentStack CLI](https://agentstack.voostack.com/docs/cli). Works with Claude Code, Cursor, and any MCP-compatible agent.

## About

# Exchange Server MCP

A [Model Context Protocol](https://modelcontextprotocol.io) 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:

1. **No generic "run PowerShell" tool.** Every capability is one named tool that
   maps to exactly one cmdlet. The model cannot execute arbitrary script.
2. **A cmdlet whitelist is authoritative.** A cmdlet runs only if it is listed
   in [`config/cmdlet_whitelist.yaml`](config/cmdlet_whitelist.yaml), and only
   with the parameters declared there. Everything else is rejected before it
   reaches the server.
3. **Read-only by default.** Mutating cmdlets are blocked unless explicitly
   flagged *and* `ALLOW_WRITE_CMDLETS=true`. The project ships read-only.
4. **Everything is audited.** Every call (ok / blocked / error) is appended to a
   JSON-lines audit log through a single chokepoint.
5. **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 `/PowerShell`
  IIS 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](#configuring-from-an-mcp-client)).

> In the Docker deployment there is **no `.env` inside the container** —
> `--env-file .env` reads the file on the *host* and passes the values in as
> environment variables. The `.env` file is never committed or imaged
> (`.gitignore` + `.dockerignore`).

For local use, copy the template and fill in the three required values:

```bash
cp .env.example .env
```

```ini
# minimal .env — everything else has sensible defaults
EXCHANGE_SERVER=mail.example.com                # FQDN, not IP
EXCHANGE_USERNAME=svc-exchange-mcp@EXAMPLE.COM   # UPN form: user@REALM
EXCHANGE_PASSWORD=your-password
```

| Variable | Purpose | Default |
|---|---|---|
| `EXCHANGE_SERVER` | Exchange FQDN (the `/PowerShell` host). Use the FQDN, not the IP. | — |
| `EXCHANGE_USERNAME` | Service account, UPN form `user@REALM`. | — |
| `EXCHANGE_PASSWORD` | Service account password. | — |
| `EXCHANGE_AUTH` | Auth mechanism (`kerberos`). | `kerberos` |
| `EXCHANGE_SPN_SERVICE` | Kerberos SPN service class (IIS vdir → `HTTP`). | `HTTP` |
| `EXCHANGE_PORT` / `EXCHANGE_SCHEME` | `80`/`http` or `443`/`https`. | `80` / `http` |
| `EXCHANGE_REALM` / `EXCHANGE_KDC` | Kerberos realm / KDC. Derived from the username/server if unset. | derived |
| `EXCHANGE_VERIFY_SSL` | Verify the TLS cert (only for `https`). | `false` |
| `ALLOW_WRITE_CMDLETS` | Hard guard for write cmdlets. | `false` |
| `MAX_RESULT_OBJECTS` | Cap objects returned per call. | `200` |

See [`.env.example`](.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`"):

```bash
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](#claude-desktop-extension-mcpb) below.

## Build & run

```bash
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):

```bash
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"`:

```bash
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:**

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

```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](#claude-desktop-extension-mcpb) below:

```json
{
  "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`](https://www.npmjs.com/package/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`](extension/manifest.json).

The extension launches `docker run … exchange-server-mcp`, so first build the
image locally (until a public image is published):

```bash
docker build -t exchange-server-mcp .
```

Then install the bundled **[`exchange-server-mcp.mcpb`](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):
> ```bash
> 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 ` and `--add-host :`.

## Adding a cmdlet

Tools are generated from the whitelist, so adding a capability is a single edit
to [`config/cmdlet_whitelist.yaml`](config/cmdlet_whitelist.yaml):

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

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

- `.env` holds 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, so `audit.log` is 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:

```bash
docker run --rm --dns  --add-host : \
  -e EXCHANGE_SERVER= -e EXCHANGE_USERNAME= \
  -e EXCHANGE_PASSWORD='...' -e EXCHANGE_REALM= -e EXCHANGE_KDC= \
  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. `kinit`
  tolerates 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
  (`user@CORP.EXAMPLE.COM`), 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

[MIT](LICENSE) © [FrankysWeb](https://www.frankysweb.de)

---

Built and maintained by **[FrankysWeb](https://www.frankysweb.de)** — a German
blog on Microsoft Exchange, Windows Server and related infrastructure.

## Source & license

This open-source MCP server is cataloged on AgentStack and links to its original source — we do not rehost the code.

- **Author:** [FrankysWeb](https://github.com/FrankysWeb)
- **Source:** [FrankysWeb/exchange-server-mcp](https://github.com/FrankysWeb/exchange-server-mcp)
- **License:** MIT

Install and usage instructions live in the source repository linked above.

## Pricing

- **Free** — Free

## Security capabilities

Automated source analysis of v0.1.0 — what this tool can access:

- **Network access:** no
- **Filesystem access:** no
- **Shell / process execution:** no
- **Environment & secrets:** yes
- **Dynamic code execution:** no

*"Yes" means the capability is present in the source — more access means more to trust, not that it is unsafe.*


## Versions

- **0.1.0** — security scan: passed — Imported from the upstream source.

## Links

- Listing page: https://agentstack.voostack.com/l/mcp-frankysweb-exchange-server-mcp
- Seller: https://agentstack.voostack.com/s/frankysweb
- Browse the marketplace: https://agentstack.voostack.com/browse

---
Listed on AgentStack — the marketplace for AI agent skills and MCP servers. Every listing is security-reviewed. Creators keep 70%.
