ms-365-mcp-server
Microsoft 365 MCP Server
A Model Context Protocol (MCP) server for interacting with Microsoft 365 and Microsoft Office services through the Graph
API.
Supported Clouds
This server supports multiple Microsoft cloud environments:
| Cloud | Description | Auth Endpoint | Graph API Endpoint |
|---|
| Global (default) | International Microsoft 365 | login.microsoftonline.com | graph.microsoft.com |
| China (21Vianet) | Microsoft 365 operated by 21Vianet | login.chinacloudapi.cn | microsoftgraph.chinacloudapi.cn |
Prerequisites
- Node.js >= 20 (recommended)
- Node.js 14+ may work with dependency warnings
Features
- Authentication via Microsoft Authentication Library (MSAL)
- Comprehensive Microsoft 365 service integration
- Read-only mode support for safe operations
- Tool filtering for granular access control
- Tool presets and dynamic discovery to shrink the tool surface and token usage
Output Format: JSON vs TOON
The server supports two output formats that can be configured globally:
JSON Format (Default)
Standard JSON output with pretty-printing:
{
"value": [
{
"id": "1",
"displayName": "Alice Johnson",
"mail": "alice@example.com",
"jobTitle": "Software Engineer"
}
]
}
(experimental) TOON Format
Token-Oriented Object Notation for efficient LLM token usage:
value[1]{id,displayName,mail,jobTitle}:
"1",Alice Johnson,alice@example.com,Software Engineer
Benefits:
- 30-60% fewer tokens vs JSON
- Best for uniform array data (lists of emails, calendar events, files, etc.)
- Ideal for cost-sensitive applications at scale
Usage:
(experimental) Enable TOON format globally:
Via CLI flag:
npx @softeria/ms-365-mcp-server --toon
Via Claude Desktop configuration:
{
"mcpServers": {
"ms365": {
"command": "npx",
"args": ["-y", "@softeria/ms-365-mcp-server", "--toon"]
}
}
}
Via environment variable:
MS365_MCP_OUTPUT_FORMAT=toon npx @softeria/ms-365-mcp-server
Supported Services & Tools
The server provides 300+ tools covering most of the Microsoft Graph API surface. Each tool maps 1-to-1 to a Graph API endpoint and is defined declaratively in src/endpoints.json.
Personal Account Tools (Available by default)
Email (Outlook), Calendar, OneDrive Files, Excel, OneNote, To Do Tasks, Planner, Contacts, User Profile, Search
Organization Account Tools (Requires --org-mode flag)
Teams & Chats, Online Meetings, Transcripts & Recordings, Attendance Reports, SharePoint Sites & Lists, Shared Mailboxes & Calendars, User Management, Presence, Virtual Events
Required Graph API Permissions
Permissions are requested dynamically based on which tools are enabled. Use --list-permissions to see the exact permissions for your configuration:
# Personal mode (default)
npx @softeria/ms-365-mcp-server --list-permissions
# Organization mode (includes Teams, SharePoint, etc.)
npx @softeria/ms-365-mcp-server --org-mode --list-permissions
# Filtered by preset
npx @softeria/ms-365-mcp-server --preset mail --list-permissions
This is useful for enterprise environments where Graph API permissions must be pre-approved and admin-consented before deploying a new version.
The --list-permissions JSON includes:
toolPermissions: permissions implied by the tool surface before --allowed-scopes filtering
effectivePermissions: permissions implied by the tools that remain enabled after --allowed-scopes
permissions: legacy alias for effectivePermissions, kept for compatibility with existing scripts
allowedScopes: the configured scope allowlist, when provided
disabledTools: tools hidden because their required Graph scopes are not covered by allowedScopes
missingAllowedScopesForTools: unique missing scopes across disabled tools
extraAllowedScopesNotUsedByTools: allowed scopes that are not used by the current tool surface
Allowed Scopes
By default, MSAL requests the scopes implied by the enabled tools, and the tool surface is controlled by --enabled-tools, --preset, --org-mode, and --read-only.
Enterprise and headless deployments can add a scope boundary with --allowed-scopes or MS365_MCP_ALLOWED_SCOPES. When configured, the server first computes the normal tool surface, then hides Graph tools whose required scopes are not covered by the allowlist. OAuth metadata and login flows request only the effective permissions for the tools that remain enabled.
npx @softeria/ms-365-mcp-server \
--org-mode \
--enabled-tools '^(list-mail-messages|get-mail-message|list-drives|get-drive-item|download-bytes)$' \
--allowed-scopes 'User.Read Mail.Read Files.Read'
CLI value takes precedence over MS365_MCP_ALLOWED_SCOPES; if neither is set, the default tool-derived scope behavior is unchanged. Supplying an empty value fails at startup so deployments do not accidentally fall back to a wider tool surface.
Scope coverage is hierarchy-aware: for example, Mail.ReadWrite covers tools that require Mail.Read, and Files.ReadWrite.All covers tools that require Files.Read.
SharePoint supports two enterprise permission models:
- Broad tenant scopes such as
Sites.Read.All, Sites.ReadWrite.All, and Sites.Manage.All.
- Microsoft Graph
Sites.Selected, where SharePoint site access is granted to the app on specific site collections and Graph evaluates the signed-in user's own permissions at request time.
The default org-mode behavior continues to request the broad SharePoint scopes used by existing deployments. Enterprises that want selected-site SharePoint access can set an allowlist containing Sites.Selected instead of broad Sites.*.All scopes. Direct site/list/item tools that target an explicit SharePoint site can run with Sites.Selected; tenant-wide SharePoint discovery and search tools still require broad SharePoint scopes.
npx @softeria/ms-365-mcp-server \
--org-mode \
--read-only \
--enabled-tools 'sharepoint|site|drive|planner' \
--allowed-scopes 'User.Read Files.Read Notes.Read Tasks.Read Sites.Selected'
In HTTP mode, OAuth discovery advertises the effective filtered permissions so clients request the same consent surface. On-Behalf-Of mode (--obo) still advertises api://<clientId>/access_as_user for protected-resource metadata; --allowed-scopes does not override OBO.
Requesting extra scopes
--allowed-scopes only ever narrows the token request. To request a Graph scope that no bundled tool needs — for example to drive an endpoint via graph-batch — use --extra-scopes (or MS365_MCP_EXTRA_SCOPES). These scopes are appended verbatim to the token request, on top of the tool-derived scopes.
npx @softeria/ms-365-mcp-server \
--org-mode \
--extra-scopes 'CopilotPackages.ReadWrite.All'
This is for use with your own Azure app registration (MS365_MCP_CLIENT_ID / MS365_MCP_CLIENT_SECRET): the default Softeria app only declares a lean, fixed permission set, so request additional scopes against an app you control (your tenant admin consents to them there). CLI value takes precedence over the env var; an empty value fails at startup.
Organization/Work Mode
To access work/school features (Teams, SharePoint, etc.), enable organization mode using any of these flags:
{
"mcpServers": {
"ms365": {
"command": "npx",
"args": ["-y", "@softeria/ms-365-mcp-server", "--org-mode"]
}
}
}
Organization mode must be enabled from the start to access work account features. Without this flag, only personal
account features (email, calendar, OneDrive, etc.) are available.
Shared Mailbox Access
To access shared mailboxes, you need:
- Organization mode: Shared mailbox tools require
--org-mode flag (work/school accounts only)
- Delegated permissions:
Mail.Read.Shared to read, Mail.ReadWrite.Shared to create, update or move
messages, Mail.Send.Shared to send, reply or forward, and Calendars.Read.Shared for the shared calendar
tools
- Exchange permissions: The signed-in user must have been granted access to the shared mailbox
- Usage: Use the shared mailbox's email address as the
user-id parameter in the shared mailbox tools
Finding shared mailboxes: Use the list-users tool to discover available users and shared mailboxes in your
organization.
Example: list-shared-mailbox-messages with user-id set to shared-mailbox@company.com
Examples
Integration
Claude Desktop
To add this MCP server to Claude Desktop, edit the config file under Settings > Developer.
Personal Account (MSA)
{
"mcpServers": {
"ms365": {
"command": "npx",
"args": ["-y", "@softeria/ms-365-mcp-server"]
}
}
}
Work/School Account (Global)
{
"mcpServers": {
"ms365": {
"command": "npx",
"args": ["-y", "@softeria/ms-365-mcp-server", "--org-mode"]
}
}
}
Work/School Account (China 21Vianet)
{
"mcpServers": {
"ms365-china": {
"command": "npx",
"args": ["-y", "@softeria/ms-365-mcp-server", "--org-mode", "--cloud", "china"]
}
}
}
Claude Code CLI
Personal Account (MSA)
claude mcp add ms365 -- npx -y @softeria/ms-365-mcp-server
Work/School Account (Global)
# macOS/Linux
claude mcp add ms365 -- npx -y @softeria/ms-365-mcp-server --org-mode
# Windows (use cmd /c wrapper)
claude mcp add ms365 -s user -- cmd /c "npx -y @softeria/ms-365-mcp-server --org-mode"
Work/School Account (China 21Vianet)
# macOS/Linux
claude mcp add ms365-china -- npx -y @softeria/ms-365-mcp-server --org-mode --cloud china
# Windows (use cmd /c wrapper)
claude mcp add ms365-china -s user -- cmd /c "npx -y @softeria/ms-365-mcp-server --org-mode --cloud china"
For other interfaces that support MCPs, please refer to their respective documentation for the correct
integration method.
Open WebUI
Open WebUI supports MCP servers via HTTP transport with OAuth 2.1.
-
Start the server with HTTP mode:
npx @softeria/ms-365-mcp-server --http
-
In Open WebUI, go to Admin Settings → Tools (/admin/settings/tools) → Add Connection:
- Type: MCP Streamable HTTP
- URL: Your MCP server URL with
/mcp path
- Auth: OAuth 2.1
-
Click Register Client.
Note: Dynamic client registration is enabled by default in HTTP mode. Use --no-dynamic-registration (or set MS365_MCP_DISABLE_DCR=true) to disable it. If using a custom Azure Entra app, the platform type for your redirect URI depends on whether the app has a client secret: with a secret use "Web", without one use "Mobile and desktop applications" (never "Single-page application").
Quick test setup using the default Azure app (ID ms-365 and localhost:8080 are pre-configured):
docker run -d -p 8080:8080 \
-e WEBUI_AUTH=false \
-e OPENAI_API_KEY \
ghcr.io/open-webui/open-webui:main
npx @softeria/ms-365-mcp-server --http
Then add connection with URL http://localhost:3000/mcp and ID ms-365.
Running in Docker behind a reverse proxy? Set --public-url https://your-domain.com so the OAuth authorize URL handed to the user's browser is reachable from outside the container network. See docs/deployment.md for the full guide.
Local Development
For local development or testing:
# From the project directory
claude mcp add ms -- npx tsx src/index.ts --org-mode
Or configure Claude Desktop manually:
{
"mcpServers": {
"ms365": {
"command": "node",
"args": ["/absolute/path/to/ms-365-mcp-server/dist/index.js", "--org-mode"]
}
}
}
Note: Run npm run build after code changes to update the dist/ folder.
Authentication
⚠️ You must authenticate before using tools.
The server supports three authentication methods:
1. Device Code Flow (Default)
For interactive authentication via device code:
Tokens are cached securely in your OS credential store (fallback to file).
2. OAuth Authorization Code Flow (HTTP mode only)
When running with --http, the server requires OAuth authentication:
npx @softeria/ms-365-mcp-server --http 3000
This mode:
- Advertises OAuth capabilities to MCP clients
- Provides OAuth endpoints at
/auth/* (authorize, token, metadata)
- Requires
Authorization: Bearer <token> for all MCP requests
- Validates tokens with Microsoft Graph API
- Disables login/logout tools by default (use
--enable-auth-tools to enable them)
MCP clients will automatically handle the OAuth flow when they see the advertised capabilities.
Setting up Azure AD for OAuth Testing
To use OAuth mode with custom Azure credentials (recommended for production), you'll need to set up an Azure AD app
registration:
- Create Azure AD App Registration:
- Go to Azure Portal
- Navigate to Azure Active Directory → App registrations → New registration
- Set name: "MS365 MCP Server"
- Configure Redirect URIs:
- Configure the OAuth callback URI: Go to your app registration and on the left side, go to Authentication.
- Under Platform configurations:
- Click Add a platform (if you don’t already see one for "Mobile and desktop applications" / "Public client").
- Choose Mobile and desktop applications or Public client/native (mobile & desktop) (label depends on portal version).
- Testing with MCP Inspector (
npm run inspector):
- Go to your app registration and on the left side, go to Authentication.
- Under Platform configurations:
- Click Add a platform (if you don’t already see one for "Web").
- Choose Web.
- Configure the following redirect URIs
http://localhost:6274/oauth/callback
http://localhost:6274/oauth/callback/debug
http://localhost:3000/callback (optional, for server callback)
- Get Credentials:
- Copy the Application (client) ID from Overview page
- Go to Certificates & secrets → New client secret → Copy the secret value (optional for public apps)
- Configure Environment Variables:
Create a
.env file in your project root:
MS365_MCP_CLIENT_ID=your-azure-ad-app-client-id-here
MS365_MCP_CLIENT_SECRET=your-secret-here # Optional for public apps
MS365_MCP_TENANT_ID=common
With these configured, the server will use your custom Azure app instead of the built-in one.
Note: .env is read from the directory the server is started in, and the MCP client decides what
that is. Only MS365_MCP_CLIENT_ID, MS365_MCP_CLIENT_SECRET, MS365_MCP_TENANT_ID and
MS365_MCP_CLOUD_TYPE are read from it. Every other variable listed above must be set in your shell
or MCP client config; anything else found in a .env is ignored with a warning on stderr.
3. Bring Your Own Token (BYOT)
If you are running ms-365-mcp-server as part of a larger system that manages Microsoft OAuth tokens externally, you can
provide an access token directly to this MCP server:
MS365_MCP_OAUTH_TOKEN=your_oauth_token npx @softeria/ms-365-mcp-server
This method:
- Bypasses the interactive authentication flows
- Use your pre-existing OAuth token for Microsoft Graph API requests
- Does not handle token refresh (token lifecycle management is your responsibility)
Note: HTTP mode requires authentication. For unauthenticated testing, use stdio mode with device code flow.
Authentication Tools: In HTTP mode, login/logout tools are disabled by default since OAuth handles authentication.
Use --enable-auth-tools if you need them available.
Multi-Account Support
Use a single server instance to serve multiple Microsoft accounts. When more than one account is logged in, an account parameter is automatically injected into every tool, allowing you to specify which account to use per tool call.
Login multiple accounts (one-time per account):
# Login first account (device code flow)
npx @softeria/ms-365-mcp-server --login
# Follow the device code prompt, sign in as personal@outlook.com
# Login second account
npx @softeria/ms-365-mcp-server --login
# Follow the device code prompt, sign in as work@company.com
List configured accounts:
npx @softeria/ms-365-mcp-server --list-accounts
Use in tool calls: Pass "account": "work@company.com" in any tool request:
{ "tool": "list-mail-messages", "arguments": { "account": "work@company.com" } }
Behavior:
- With a single account configured, it auto-selects (no
account parameter needed).
- With multiple accounts and no
account parameter, the server uses the selected default or returns a helpful error listing available accounts.
- 100% backward compatible: existing single-account setups work unchanged.
- The
account parameter accepts email address (e.g. user@outlook.com) or MSAL homeAccountId.
Strict Account Pinning
Headless stdio deployments can pin the local MSAL cache to one expected Microsoft account:
# Username matching is case-insensitive
MS365_MCP_EXPECTED_USERNAME=work@company.com npx @softeria/ms-365-mcp-server --login
# Or pin the exact MSAL homeAccountId shown by --list-accounts
npx @softeria/ms-365-mcp-server --expected-home-account-id <homeAccountId> --login
Use --list-accounts to discover homeAccountId values. The MCP list-accounts tool intentionally hides account IDs, so use the CLI for exact ID pinning.
Pinning is opt-in and local-MSAL only:
- CLI values (
--expected-username, --expected-home-account-id) take precedence over MS365_MCP_EXPECTED_USERNAME and MS365_MCP_EXPECTED_HOME_ACCOUNT_ID.
- Supplying an empty pin value fails at startup instead of being ignored.
- Username pins are compared case-insensitively;
homeAccountId pins are exact.
- If both pins are set, they must resolve to the same cached account.
- Local stdio startup fails fast when the expected account is not in the token cache. Bootstrap by setting the pin, running
--login, then starting the headless server.
- Device-code and browser logins reject a missing or mismatched account before persisting the selected account or token cache.
- Pinning collapses the effective MCP mode to single-account: the server does not advertise an
account parameter and MCP instructions do not suggest account switching.
--http, --obo, and MS365_MCP_OAUTH_TOKEN use request-provided tokens for Graph calls, so account pins are warning-only in those modes. If HTTP auth tools are enabled, the pin still applies to those local MSAL helper flows.
--logout clears all cached accounts, including the pinned account. For surgical cleanup, prefer --remove-account <id>.
For MCP multiplexers (Legate, Governor): Multi-account mode replaces the N-process pattern. Instead of spawning one server per account, a single instance handles all accounts via the account parameter, reducing tool duplication from N×110 to 110.
Tool Presets
To reduce initial connection overhead and token usage, use preset tool categories instead of loading the full tool set:
npx @softeria/ms-365-mcp-server --preset mail
npx @softeria/ms-365-mcp-server --list-presets # See all available presets
Available presets: mail, calendar, files, personal, work, excel, contacts, tasks, onenote, search, users, outlook, onedrive, teams, teams-write, all
Each endpoint in endpoints.json declares which presets it belongs to via a presets array, so every preset is an exact tool-name allow-list that never over-matches across apps (e.g. mail does not include shared-mailbox tools; those are in work). The universal binary reader download-bytes is included in every preset except teams-write, so whatever an app returns (a file, an attachment, a photo, a recording) can always be fetched; get-download-url (a pre-authenticated URL for drive/SharePoint files) rides with the drive-backed presets. So a preset that can find a file can always read its bytes.
The outlook, onedrive and teams presets are app-scoped: they expose exactly one Microsoft app. Use these for "expose exactly one app" deployments:
# Outlook only (mail + calendar + contacts; no shared mailboxes, no files)
npx @softeria/ms-365-mcp-server --preset outlook
# Teams only (requires --org-mode)
npx @softeria/ms-365-mcp-server --org-mode --preset teams
The teams-write preset is the send-only counterpart to --read-only: send in chats, send/reply in channels, list chats/teams/channels by name, and activity notifications - no message reading and no byte downloaders. The requested token is minimal by construction (Chat.ReadBasic, the *.Send scopes, and basic team/channel listing - nothing that can read message content):
npx @softeria/ms-365-mcp-server --org-mode --preset teams-write
Dynamic Tool Discovery
Instead of loading every tool upfront, use dynamic discovery so the LLM finds and loads tools only when it needs them:
npx @softeria/ms-365-mcp-server --discovery
Keeps the initial context small and cuts token usage, especially useful for long sessions or cost-sensitive setups (e.g. Open WebUI running against a paid API).
CLI Options
The following options can be used when running ms-365-mcp-server directly from the command line:
--login Login using device code flow
--logout Log out and clear saved credentials
--verify-login Verify login without starting the server
--list-permissions List required Graph API permissions and exit (respects --org-mode, --preset, --enabled-tools, --allowed-scopes)
--org-mode Enable organization/work mode from start (includes Teams, SharePoint, etc.)
--work-mode Alias for --org-mode
--force-work-scopes Backwards compatibility alias for --org-mode (deprecated)
--cloud <type> Microsoft cloud environment: global (default) or china (21Vianet)
--allowed-scopes <scopes> Limit exposed tools to Graph scopes covered by this allowlist
--extra-scopes <scopes> Append additional Graph scopes to the token request (for use with your own app registration + graph-batch)
--expected-username <username> Require local MSAL auth to use this account username
--expected-home-account-id <id> Require local MSAL auth to use this exact homeAccountId
Server Options
When running as an MCP server, the following options can be used:
-v Enable verbose logging
--read-only Start server in read-only mode, disabling write operations
--http [port] Use Streamable HTTP transport instead of stdio (optionally specify port, default: 3000)
Starts Express.js server with MCP endpoint at /mcp
--enable-auth-tools Enable login/logout tools when using HTTP mode (disabled by default in HTTP mode)
--enable-attachment-urls Let get-download-url mint a server-served URL for byte resources Graph
exposes no pre-authenticated URL for (see "Server-Minted Attachment URLs")
--attachment-port <port> Serve /attachment on its own listener on this port instead of on the
MCP app, so a fetcher that can read attachments cannot also reach /mcp
(requires --enable-attachment-urls; see "Splitting the attachment listener")
--attachment-host <host> Interface the --attachment-port listener binds. Defaults to whatever
--http bound, which with a wildcard --http leaves BOTH ports on every
interface and so isolates nothing — set this to make the split real
(requires --attachment-port; see "Splitting the attachment listener")
--no-dynamic-registration Disable OAuth Dynamic Client Registration (enabled by default in HTTP mode)
--enabled-tools <pattern> Filter tools using regex pattern (e.g., "excel|contact" to enable Excel and Contact tools)
--preset <names> Use preset tool categories (comma-separated). See "Tool Presets" section above
--list-presets List all available presets and exit
--toon (experimental) Enable TOON output format for 30-60% token reduction
--discovery Dynamic tool discovery: loads tools on demand to reduce initial token usage (see "Dynamic Tool Discovery" above)
--public-url <url> Public base URL for OAuth when behind a reverse proxy (see Open WebUI section and docs/deployment.md)
Environment variables:
READ_ONLY=true|1: Alternative to --read-only flag
ENABLED_TOOLS: Filter tools using a regex pattern (alternative to --enabled-tools flag)
MS365_MCP_ORG_MODE=true|1: Enable organization/work mode (alternative to --org-mode flag)
MS365_MCP_FORCE_WORK_SCOPES=true|1: Backwards compatibility for MS365_MCP_ORG_MODE
MS365_MCP_OUTPUT_FORMAT=toon: Enable TOON output format (alternative to --toon flag)
MS365_MCP_MAX_TOP=<n>: Hard cap for Graph $top / top on list requests (positive integer). When the model passes a larger value, the server clamps it to n so responses stay smaller. Example: MS365_MCP_MAX_TOP=15
MS365_MCP_MAX_PAGES=<n>: Maximum number of pages followed when a tool is called with fetchAllPages: true (positive integer, default 100). Bounds memory and latency for large result sets.
MS365_MCP_MAX_ITEMS=<n>: Maximum number of items accumulated when fetchAllPages: true (positive integer, default 10000). Pagination stops and the response is truncated once this many items are collected.
MS365_MCP_ALLOW_PAGINATION=0|false|no: Disable multi-page following entirely. When set, the fetchAllPages parameter is not advertised on tools, and any request that still passes it returns only the first page (default: pagination enabled).
- : Return email bodies as HTML instead of plain text (default: text)
Server-Minted Attachment URLs
get-download-url returns Microsoft's own pre-authenticated @microsoft.graph.downloadUrl
for OneDrive and SharePoint items. Graph publishes no such URL for mail and calendar
attachments, meeting recordings, or any other /$value byte endpoint — for those, the
only way to read the bytes has been download-bytes, which returns base64 into the
agent's context. A 73 KB, 3-page PDF costs about 24,500 tokens that way, and the model
cannot parse them anyway.
--enable-attachment-urls (HTTP mode, off by default) closes that gap. When Graph has no
URL of its own, get-download-url mints one this server serves:
GET /attachment?t=<ticket>&dgk=<key-id>&dgx=<expiry>&dgs=<signature>
The ticket is 32 bytes of CSPRNG output, single-use, memory-only, and expires after
MS365_MCP_ATTACHMENT_URL_TTL_S seconds. Redeeming it streams the Graph bytes with this
server's own token; the fetcher sends no Authorization header and holds no Microsoft
credential.
This grants no authority the calling agent did not already have. Every target that can
be minted is one download-bytes would fetch for the same caller on the same account. The
ticket only moves those bytes out of the context window and into a direct transfer.
Configuration
MS365_MCP_ATTACHMENT_URL_BASE=http://m365-mcp:3000 # required
MS365_MCP_ATTACHMENT_URL_KEY=... # required (or _KEY_FILE=/path)
MS365_MCP_ATTACHMENT_URL_KEY_ID=1 # optional, default 1
MS365_MCP_ATTACHMENT_URL_TTL_S=120 # optional, default 120, max 300
MS365_MCP_ATTACHMENT_URL_BASE is deliberately not MS365_MCP_PUBLIC_URL: that one is
browser-facing, for OAuth redirects, while this is fetched server-to-server and is
commonly a container address. A missing or malformed setting fails at startup rather than
per-request — a signing feature that comes up without a key would mint URLs nothing can
verify, silently.
Splitting the attachment listener
By default /attachment is served by the same Express app, on the same port, as /mcp.
That is fine when callers are authenticated by a bearer token, and it is a problem when
they are not. Under --trust-proxy-auth the MCP endpoint reads no Authorization header
at all — reachability is the authentication — so one shared port means the sidecar you
allowed through in order to fetch a PDF can also call every tool on the server.
--attachment-port <port> (or MS365_MCP_ATTACHMENT_PORT) moves the route onto a listener
of its own, and --attachment-host <host> (or MS365_MCP_ATTACHMENT_HOST) says which
interface that listener binds:
ms-365-mcp-server --http 10.89.0.2:3000 --trust-proxy-auth \
--enable-attachment-urls \
--attachment-port 3001 --attachment-host 10.89.1.2
MS365_MCP_ATTACHMENT_URL_BASE=http://m365-mcp:3001 # note: the attachment port
GET /attachment on 3001 works; on 3000 it is 404 — the MCP app never mounts it.
/mcp on 3001 is 404, as is everything else: the second app has the attachment
route and nothing more. No OAuth router, no body parsers, no CORS, no health check.
- The 60 req/min limiter that guards the route follows it onto the new listener.
trust proxy is off on the attachment listener (and MS365_MCP_TRUST_PROXY_HOPS is
not read for it), unlike the MCP listener, which trusts one hop. This port is meant to be
dialled directly on a container network; honouring X-Forwarded-For on the server's one
uncredentialed surface would let a caller choose its own rate-limit bucket.
The flag requires --enable-attachment-urls and refuses to start without it — on its own
it would open a port with nothing on it while the operator believed the surfaces were
separated. In stdio mode it warns and is ignored, like the flag it depends on.
--attachment-host likewise requires --attachment-port: alone it would name an interface
for a listener that does not exist.
Two ports are not two surfaces unless they bind two interfaces
This is the part that decides whether any of the above is worth anything. Read it
before you deploy the split.
--attachment-port on its own separates the two surfaces inside the process. It does not
separate them on the network. Without --attachment-host the attachment listener inherits
whatever host --http bound — and --http 3000, the common form, names no host at all, so
Node binds the wildcard and both ports answer on every interface:
ms-365-mcp-server --http 3000 --trust-proxy-auth \
--enable-attachment-urls --attachment-port 3001 # NOT isolated
Container networks grant a peer every port on a container, not one port. Put a
document-conversion sidecar on a shared bridge so it can fetch /attachment on 3001, and
that same sidecar can dial :3000/mcp — which under --trust-proxy-auth reads no
Authorization header at all and hands back the full tool catalogue. Nothing fails, nothing
is logged as an error, and the config looks exactly like the isolated one.
To make it real, give the two listeners different addresses, and put only the attachment
address on the network the fetcher is on:
# docker compose — the MCP port on the agent's own bridge, the attachment port on the
# bridge shared with the converter. The converter can reach 3001 and cannot route to 3000.
services:
m365-mcp:
networks: { agent-net: { ipv4_address: 10.89.0.2 }, convert-net: { ipv4_address: 10.89.1.2 } }
command: >
--http 10.89.0.2:3000 --trust-proxy-auth
--enable-attachment-urls
--attachment-port 3001 --attachment-host 10.89.1.2
docglean:
networks: [convert-net]
The MCP port is then unreachable from convert-net by binding — there is no socket
listening on that interface — rather than by a firewall rule that has to keep matching.
The server warns at startup if you run --trust-proxy-auth with --attachment-port while
both listeners still answer on a common interface (either sharing an address, or either one
on the wildcard). Both bound addresses are logged, read back from the socket rather than
from the flags, so Server listening on … and Attachment listener on … can be compared
directly.
--attachment-host takes a bare IPv4 address, IPv6 address (bracketed [::1] or bare
::1) or hostname. It is refused rather than coerced — --attachment-host 10.0.0.5:3001
is an error naming --attachment-port, not a bind to something else. Note that
MS365_MCP_ATTACHMENT_URL_BASE still must not be an IPv6 literal (the URL signature covers
the host and the two implementations normalise IPv6 differently); if you bind the listener
to an IPv6 address, name it in the base by hostname.
Point MS365_MCP_ATTACHMENT_URL_BASE at the attachment port. The server cannot check this
for you: the base is usually a container name on a network this process cannot resolve, so
a wrong port here shows up as a fetch failure in the sidecar, not an error here. Both the
base and the bound port are logged at startup, one line apart, for exactly that comparison.
The signature, and who checks what
dgk/dgx/dgs are not checked by this server on redemption, and that is deliberate.
They exist for the fetcher: a document-conversion sidecar that refuses to dial a private
address unless the URL carries a valid HMAC from an origin it has been configured to trust.
What authorises redemption here is the ticket. Verifying the signature on the way back in
would prove only that we minted the URL — which the ticket already proves — while coupling
redemption to the sidecar's clock and to the key surviving a restart.
The wire format is docglean-mcp's
signing.py (canonical_string), and src/lib/url-signing.ts is a port of it. The
canonical string is \n-joined: v1, lowercased scheme, lowercased host, the port always
explicit, the path, the remaining query with dgk/dgx/dgs removed and the rest sorted
and re-encoded, and the expiry. The test vectors in
test/attachment-url-signing.test.ts were verified against the Python implementation byte
for byte — three places where the obvious JavaScript disagrees with Python (!*'()
escaping, + decoding as a space, and code-point vs UTF-16 sort order) are why that check
exists rather than being assumed.
The ticket travels in the query, not the path, because the verifying sidecar keeps a
fetched URL's path in its error messages and strips the query.
Not available in OAuth/OBO mode
Identity there arrives per request on the caller's Authorization header, and a ticket is
redeemed later by a fetcher that sends none. Minting refuses with an explanation rather
than producing a URL that always fails.
Token Storage
Authentication tokens are stored in an encrypted file (AES-256-GCM). Only the 32-byte encryption key goes to the OS credential store via keytar.
The cache itself is too big for some credential stores to hold - a Windows Credential Manager blob caps out at 2560 bytes and a real token cache is several times that, so on Windows the write could never succeed. A key is 32 bytes regardless of how many accounts are signed in, so this works the same way on every platform.
Default paths are in the per-user config directory:
| Platform | Location |
|---|
| Windows | %APPDATA%\ms-365-mcp-server\ |
| macOS | ~/Library/Application Support/ms-365-mcp-server/ |
| Linux | $XDG_CONFIG_HOME/ms-365-mcp-server/ (or ~/.config/ms-365-mcp-server/) |
Earlier versions defaulted to a path inside the installed package, which under npx resolves to a content-hashed cache directory that npm cache clean or a version bump throws away. A cache still sitting in the package directory is moved to the new location on first run.
That covers global and local installs, and npx when the hash has not changed. It cannot reach a cache left behind in a previous npx hash directory, so upgrading an npx install one last time means signing in again. Adopting a cache from another directory would mean trusting a directory this package cannot prove it wrote, which is not worth one saved sign-in.
Override the paths if you need to:
export MS365_MCP_TOKEN_CACHE_PATH="$HOME/.config/ms365-mcp/.token-cache.json"
export MS365_MCP_SELECTED_ACCOUNT_PATH="$HOME/.config/ms365-mcp/.selected-account.json"
Parent directories are created automatically. Files are written with 0600 permissions.
Without a credential store (headless Linux, most containers) the key is written to .cache-key next to the cache file, with 0600 permissions. That stops the tokens showing up in a stray cat, a backup or an accidental commit. It does not protect against anyone who can already read the directory - the key is right there. Use MS365_MCP_AUTH_CACHE_COMMAND below if you need the cache in a real secret store.
Skipping the credential store on purpose:
export MS365_MCP_USE_KEYTAR=0 # also accepts false, no or off
The key then goes to .cache-key on every platform, exactly as it does where no credential store exists, and nothing in the server calls keytar. Useful when the credential store prompts on each start - macOS re-asks whenever the calling binary changes, which under npx is every version bump - or when the native module misbehaves on your platform rather than simply failing to load. Any other value leaves the credential store in use, and an unrecognised one is warned about rather than passed over silently.
Switching it off strands a cache that was encrypted under a key already in the credential store, since nothing can reach that key any more. The server says so and replaces that cache on the next sign-in, which signs out every account it held, not just the one you sign back in as. Unset the variable first if that cache is worth keeping.
Only a cache that nothing on the machine can open is replaced. One that fails to decrypt while a usable key is sitting right there - a truncated file, a downgrade to an older build, a cache from somewhere else - is damage rather than a stranded cache, and is left alone exactly as it is by default.
Two things it deliberately does not do. It never deletes what this server already put in the credential store, on logout or otherwise, because reaching the store is the thing you just asked it to stop doing - clear the ms-365-mcp-server entries by hand if you want them gone. And a .cache-key that exists but cannot be read (wrong owner on a bind-mounted config directory, say) is treated as recoverable rather than missing: the server refuses both to overwrite a cache and to mint a replacement key, and says so, rather than deleting a key that would work again once the permissions are fixed. Fix the permissions, or delete .cache-key yourself to start over - which does mean signing in again.
If the cache cannot be decrypted - key lost, keychain locked, file modified - you are asked to sign in again rather than the server failing to start. The cache file is left exactly as it was: not deleted, and not overwritten by that new sign-in either. A keychain that is merely locked usually reads fine on the next start, and the cache is still there when it does.
The cost is that the new session is not saved while this lasts, so each start asks you to sign in again. If the key is genuinely gone and the cache will never open, delete .token-cache.json to start over - the log says so, and names the path.
Hosted/sandboxed environments (e.g. Anthropic Cowork): Set MS365_MCP_TOKEN_CACHE_PATH and MS365_MCP_SELECTED_ACCOUNT_PATH to a persistent mount so tokens survive between sessions.
External auth-cache command
Headless local-MSAL deployments can replace the built-in keytar/file storage with a provider-neutral external command:
export MS365_MCP_AUTH_CACHE_COMMAND="/path/to/ms365-auth-cache-store"
export MS365_MCP_AUTH_CACHE_COMMAND_TIMEOUT_MS=10000
When MS365_MCP_AUTH_CACHE_COMMAND is set for a local auth flow, the server uses only that command for the MSAL token cache and selected-account metadata. It does not fall back to keytar or local files. If the command path is missing, not executable on POSIX, exits non-zero, times out, or returns malformed data, auth-cache operations fail closed with a sanitized error message.
The value must be a real executable wrapper path. It is not a shell command string, and there is no companion args environment variable. Put any interpreter, region, profile, or provider-specific settings inside the wrapper. Windows users should point the variable at a wrapper executable or script that can be launched directly by Node without shell parsing.
The server invokes the wrapper with:
$MS365_MCP_AUTH_CACHE_COMMAND load token-cache
$MS365_MCP_AUTH_CACHE_COMMAND save token-cache
$MS365_MCP_AUTH_CACHE_COMMAND delete token-cache
$MS365_MCP_AUTH_CACHE_COMMAND load selected-account
$MS365_MCP_AUTH_CACHE_COMMAND save selected-account
$MS365_MCP_AUTH_CACHE_COMMAND delete selected-account
Protocol v1:
load <key> reads no stdin. Exit 0 with {"found":true,"value":"<stored envelope string>"} when present. A miss is exit 0 with {"found":false} or empty stdout.
save <key> receives {"value":"<stamped envelope string>"} on stdin and must exit 0 only after the value is durably committed. There are no fire-and-forget or coalesced saves in v1.
delete <key> reads no stdin and exits 0 whether the key existed or not.
<key> is token-cache or selected-account.
- Any non-zero exit is a storage error. Do not use exit code
2 for cache misses.
- Stderr is captured and truncated in sanitized errors. Stdin and stdout payloads are never logged by the server.
- Token-cache payloads can be large; wrappers should handle at least 256 KB values.
Normal stateless HTTP Graph requests do not use local auth-cache storage. In HTTP mode, command storage is skipped at startup and per request unless local auth tools are explicitly enabled or a local account command such as --login, --verify-login, --list-accounts, --select-account, or --logout is used.
Azure Key Vault Integration
For production deployments, you can store secrets in Azure Key Vault instead of environment variables. This is particularly useful for Azure Container Apps with managed identity.
Secret Name Mapping
| Key Vault Secret Name | Environment Variable | Required |
|---|
| ms365-mcp-client-id | MS365_MCP_CLIENT_ID | Yes |
| ms365-mcp-tenant-id | MS365_MCP_TENANT_ID | No (defaults to 'common') |
| ms365-mcp-client-secret | MS365_MCP_CLIENT_SECRET | No |
Authentication
The Key Vault integration uses DefaultAzureCredential from the Azure Identity SDK, which automatically tries multiple authentication methods in order:
- Environment variables (AZURE_CLIENT_ID, AZURE_CLIENT_SECRET, AZURE_TENANT_ID)
- Managed Identity (recommended for Azure Container Apps)
- Azure CLI credentials (for local development)
- Visual Studio Code credentials
- Azure PowerShell credentials
Optional Dependencies
The Azure Key Vault packages (@azure/identity and @azure/keyvault-secrets) are optional dependencies. They are only loaded when MS365_MCP_KEYVAULT_URL is configured. If you don't use Key Vault, these packages are not required.
Message Signoff
Outgoing messages can be wrapped in a configurable signoff (e.g. a 🤖 prefix) so recipients can tell agent-sent messages from ones you typed yourself. Off by default — enable it with --message-signoff-prefix / --message-signoff-suffix (env: MS365_MCP_MESSAGE_SIGNOFF_PREFIX / MS365_MCP_MESSAGE_SIGNOFF_SUFFIX); --no-message-signoff or an empty env value turns it back off.
Once configured, it applies to all Teams messages (sends, replies and edits, including via graph-batch), to direct mail sends (send-mail, reply/forward, their shared-mailbox variants, and group thread replies), and to mail drafts as their content is written — send-draft-message sends a draft as-is, so a draft you wrote yourself goes out untouched. A message that already carries the marker is not signed twice, and a send whose body cannot take the signoff is refused rather than sent unsigned.
Markers may contain markup (e.g. a coloured <span>) as long as it renders visible text. Note that the signoff is a guardrail against an agent misusing the tools it was given, not a hard security boundary — an agent with shell access on the same machine could simply restart the server without it.
Production Deployment
See docs/deployment.md for a full guide to hosting the server for organization-wide access, including Docker, Azure Container Apps, Azure App Service, Azure AD app registration, reverse proxy setup, client configuration, and exposed endpoints.
Contributing
We welcome contributions! Before submitting a pull request, please ensure your changes meet our quality standards.
Run the verification script to check all code quality requirements:
npm run verify
For Developers
After cloning the repository, you may need to generate the client code from the Microsoft Graph OpenAPI specification:
npm run generate
Related Projects
- ms-365-admin-mcp-server by @okapi-ca: companion server for admin/daemon scenarios using application permissions (client credentials flow), covering security alerts, audit logs, service health, and usage reports.
Support
If you're having problems or need help:
License
MIT © 2026 Softeria