The full upstream README, mirrored here for reference. Install config, tool schemas, adoption signals, and an original overview live on the Ms 365 MCP Server listing page.
Microsoft 365 MCP Server
A Model Context Protocol (MCP) server for interacting with Microsoft 365 and Microsoft Office services through the Graph API.
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 |
The server supports two output formats that can be configured globally:
Standard JSON output with pretty-printing:
Token-Oriented Object Notation for efficient LLM token usage:
Benefits:
Usage: (experimental) Enable TOON format globally:
Via CLI flag:
Via Claude Desktop configuration:
Via environment variable:
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.
Email (Outlook), Calendar, OneDrive Files, Excel, OneNote, To Do Tasks, Planner, Contacts, User Profile, Search
Teams & Chats, Online Meetings, Transcripts & Recordings, Attendance Reports, SharePoint Sites & Lists, Shared Mailboxes & Calendars, User Management, Presence, Virtual Events
Permissions are requested dynamically based on which tools are enabled. Use --list-permissions to see the exact permissions for your configuration:
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 filteringeffectivePermissions: permissions implied by the tools that remain enabled after --allowed-scopespermissions: legacy alias for effectivePermissions, kept for compatibility with existing scriptsallowedScopes: the configured scope allowlist, when provideddisabledTools: tools hidden because their required Graph scopes are not covered by allowedScopesmissingAllowedScopesForTools: unique missing scopes across disabled toolsextraAllowedScopesNotUsedByTools: allowed scopes that are not used by the current tool surfaceBy 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.
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:
Sites.Read.All, Sites.ReadWrite.All, and Sites.Manage.All.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, and the /drives/{drive-id}/... item tools (list, get, upload, folder, move/rename, copy, versions) for drives of a granted site, can run with Sites.Selected; tenant-wide SharePoint discovery and search tools still require broad SharePoint scopes.
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.
--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.
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.
To access work/school features (Teams, SharePoint, etc.), enable organization mode using any of these flags:
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.
To access shared mailboxes, you need:
--org-mode flag (work/school accounts only)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
toolsuser-id parameter in the shared mailbox toolsFinding 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
Test login in Claude Desktop:
To add this MCP server to Claude Desktop, edit the config file under Settings > Developer.
For other interfaces that support MCPs, please refer to their respective documentation for the correct integration method.
Open WebUI supports MCP servers via HTTP transport with OAuth 2.1.
Start the server with HTTP mode:
In Open WebUI, go to Admin Settings → Tools (/admin/settings/tools) → Add Connection:
/mcp pathClick Register Client.
Note: Dynamic client registration is enabled by default in HTTP mode. Use
--no-dynamic-registration(or setMS365_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):
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.comso 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.
For local development or testing:
Or configure Claude Desktop manually:
Note: Run
npm run buildafter code changes to update thedist/folder.
⚠️ You must authenticate before using tools.
The server supports three authentication methods:
For interactive authentication via device code:
login tool (auto-checks existing token)verify-login tool to confirmTokens are cached securely in your OS credential store (fallback to file).
When running with --http, the server requires OAuth authentication:
This mode:
/auth/* (authorize, token, metadata)Authorization: Bearer <token> for all MCP requests--enable-auth-tools to enable them)MCP clients will automatically handle the OAuth flow when they see the advertised capabilities.
To use OAuth mode with custom Azure credentials (recommended for production), you'll need to set up an Azure AD app registration:
npm run inspector):http://localhost:6274/oauth/callbackhttp://localhost:6274/oauth/callback/debughttp://localhost:3000/callback (optional, for server callback).env file in your project root:
With these configured, the server will use your custom Azure app instead of the built-in one.
Note:
.envis read from the directory the server is started in, and the MCP client decides what that is. OnlyMS365_MCP_CLIENT_ID,MS365_MCP_CLIENT_SECRET,MS365_MCP_TENANT_IDandMS365_MCP_CLOUD_TYPEare read from it. Every other variable listed above must be set in your shell or MCP client config; anything else found in a.envis ignored with a warning on stderr.
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:
This method:
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-toolsif you need them available.
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):
List configured accounts:
Use in tool calls: Pass "account": "work@company.com" in any tool request:
Behavior:
account parameter needed).account parameter, the server uses the selected default or returns a helpful error listing available accounts.account parameter accepts email address (e.g. user@outlook.com) or MSAL homeAccountId.Headless stdio deployments can pin the local MSAL cache to one expected Microsoft account:
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:
--expected-username, --expected-home-account-id) take precedence over MS365_MCP_EXPECTED_USERNAME and MS365_MCP_EXPECTED_HOME_ACCOUNT_ID.homeAccountId pins are exact.--login, then starting the headless server.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
accountparameter, reducing tool duplication from N×110 to 110.
To reduce initial connection overhead and token usage, use preset tool categories instead of loading the full tool set:
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:
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):
Instead of loading every tool upfront, use dynamic discovery so the LLM finds and loads tools only when it needs them:
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).
The following options can be used when running ms-365-mcp-server directly from the command line:
When running as an MCP server, the following options can be used:
Environment variables:
READ_ONLY=true|1: Alternative to --read-only flagENABLED_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_MODEMS365_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=15MS365_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).MS365_MCP_BODY_FORMAT=html: Return email bodies as HTML instead of plain text (default: text)MS365_MCP_MESSAGE_SIGNOFF_PREFIX=<text>: Signoff prepended to outgoing messages so recipients can tell they were agent-sent, e.g. 🤖. Default: none. CLI equivalent: --message-signoff-prefix <text> (see Message Signoff below)MS365_MCP_MESSAGE_SIGNOFF_SUFFIX=<text>: Signoff appended to outgoing messages. Default: none. CLI equivalent: --message-signoff-suffix <text>. --no-message-signoff disables both (see Message Signoff below)MS365_MCP_RATE_LIMIT_DISABLED=true|1: Disable per-IP rate limiting in HTTP mode (default: enabled — 30 req/min on /authorize, /token, /register; 120 req/min on /mcp)MS365_MCP_TRUST_PROXY_HOPS=<n>: Number of trusted reverse-proxy hops in HTTP mode (default 1). Accurate per-IP rate limiting depends on this matching your deployment — set to the number of proxies in front of the server, 0 to use the raw socket peer IP, or a comma-separated subnet listMS365_MCP_ATTACHMENT_PORT=<port>: Serve the attachment route on its own listener on this port (alternative to --attachment-port; requires --enable-attachment-urls)MS365_MCP_ATTACHMENT_HOST=<host>: Interface the MS365_MCP_ATTACHMENT_PORT listener binds (alternative to --attachment-host; requires --attachment-port). Defaults to the host --http bound — which for a wildcard --http means both ports answer everywhere and the port split isolates nothing. See "Splitting the attachment listener"MS365_MCP_CLOUD_TYPE=global|china: Microsoft cloud environment (alternative to --cloud flag)LOG_LEVEL: Set logging level (default: 'info')SILENT=true|1: Disable console outputMS365_MCP_REDACT_PII=false|0: Disable scrubbing of JWTs, Bearer headers, OAuth token fields, and email addresses from log messages (default: enabled). The server handles live Graph bearer tokens, so redaction is on unless you opt out for fully verbose local debugging.MS365_MCP_CLIENT_ID: Custom Azure app client ID (defaults to built-in app)MS365_MCP_TENANT_ID: Custom tenant ID (defaults to 'common' for multi-tenant). Personal Microsoft accounts should set this to consumers - as of June 2026, refresh tokens issued via the default 'common' authority are rejected at the first refresh, so sessions die roughly an hour after loginMS365_MCP_OAUTH_TOKEN: Pre-existing OAuth token for Microsoft Graph API (BYOT method)MS365_MCP_KEYVAULT_URL: Azure Key Vault URL for secrets management (see Azure Key Vault section)MS365_MCP_TOKEN_CACHE_PATH: Custom file path for MSAL token cache (see Token Storage below)MS365_MCP_SELECTED_ACCOUNT_PATH: Custom file path for selected account metadata (see Token Storage below)MS365_MCP_AUTH_CACHE_COMMAND: External executable wrapper for provider-neutral auth-cache storage (see Token Storage below)MS365_MCP_AUTH_CACHE_COMMAND_TIMEOUT_MS: Per-invocation timeout for MS365_MCP_AUTH_CACHE_COMMAND (default: 10000)MS365_MCP_EXPECTED_USERNAME: Require local MSAL auth to use this Microsoft account username (case-insensitive; CLI flag takes precedence)MS365_MCP_EXPECTED_HOME_ACCOUNT_ID: Require local MSAL auth to use this exact MSAL homeAccountId (CLI flag takes precedence)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:
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.
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.
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:
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.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.
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:
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:
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.
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.
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.
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:
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:
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_PATHandMS365_MCP_SELECTED_ACCOUNT_PATHto a persistent mount so tokens survive between sessions.
Headless local-MSAL deployments can replace the built-in keytar/file storage with a provider-neutral external command:
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:
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.2 for cache misses.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.
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.
Create a Key Vault (if you don't have one):
Add secrets to Key Vault:
Grant access to Key Vault:
For Azure Container Apps with managed identity:
For local development with Azure CLI:
Configure the server:
| 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 |
The Key Vault integration uses DefaultAzureCredential from the Azure Identity SDK, which automatically tries multiple authentication methods in order:
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.
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.
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.
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:
After cloning the repository, you may need to generate the client code from the Microsoft Graph OpenAPI specification:
If you're having problems or need help:
MIT © 2026 Softeria