office365-mcp
Click on "Install Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@office365-mcpSummarize my unread emails and flag anything urgent"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
office365-mcp
A multi-user remote MCP server for Microsoft 365 — Outlook mail, Teams and SharePoint/OneDrive over Microsoft Graph. Every user connects with their own Microsoft account through a normal browser sign-in; the server keeps that authorization, encrypted, and acts as that user on every subsequent tool call, so a connection made once keeps working without the client ever holding a Microsoft token. Shared and service mailboxes (support@, billing@, info@) are a first-class identity rather than a parameter bolted onto a few tools.
One TypeScript codebase, three deployment targets:
Platform | Entry | Build / deploy |
AWS Lambda (Function URL) |
|
|
Validate the configuration without touching AWS first:
DRY_RUN=1 npm run deploy:lambda| Azure Functions (v4 Node) | src/entries/azure.ts | npm run build:azure && func azure functionapp publish … |
| Plain Node (dev / self-host) | src/entries/node.ts | npm run dev |
How this differs from the other Microsoft 365 MCP servers
The open-source field is large and several projects are good. This one is built around a different deployment and identity model, not around more Graph coverage.
The server brokers refresh tokens; the client never sees a Microsoft token. Most existing servers keep one token for one user on one machine —
~/.outlook-mcp-tokens.json,~/.microsoft_mcp_token_cache.json,~/.office-mcp-tokens.json— in plaintext. The one mature remote server (Softeria'sms-365-mcp-server, in HTTP mode) states outright that token refresh is the client's responsibility, so a session dies when the Graph access token expires after roughly an hour. Here, the per-user Entra refresh token is sealed with AES-256-GCM and stored server-side, and the server silently renews access tokens on the user's behalf.It is stateless and serverless-shaped. No SSE session affinity, no long-lived process, all session and credential state in DynamoDB. The HTTP-capable alternatives assume an always-on container (Express, Azure Container Apps, an App Service backend behind a local stdio shim).
Shared mailboxes are modelled, including the app-only path. Where the caller holds Exchange rights the server uses their own delegated token against
/users/{mailbox}; where nobody signs in to the mailbox at all it can use scoped application credentials. No other open-source server offers that second, governed path, and the Exchange-side scoping is shipped here as a script (deploy/entra/scope-app-only.ps1) rather than left as an exercise.The user chooses which mailboxes the assistant may use. Entra has no per-mailbox consent for delegated
Mail.*.Shared: granting those scopes yields a token that can open every mailbox Exchange lets that person open, and Microsoft offers no way to narrow it. So sign-in is followed by an approval page served by this server, and what the user ticks there is enforced server-side on every request. See Mailbox approval.There is a governance story. Per-user tool allowlists that are default-deny for new tools, per-user rate limits, an admin mailbox ceiling on top of the user's own approval, a deployment-level lock on irreversible deletion, and a structured audit record for every call naming the user, the tool, the arguments and the mailbox actually touched.
The tool surface is deliberately small. 27 task-shaped tools, not 300 endpoint-shaped ones. Breadth is not where this competes: Softeria covers Excel ranges and OneNote pages, and Microsoft's own Work IQ servers have semantic search and Defender-grade tracing. What neither offers is a server you host yourself, in your own region, without a Microsoft 365 Copilot licence.
Two first-party options exist and are worth knowing about. Microsoft's MCP Server for Enterprise is free but read-only and scoped to Entra directory data — a complement, not a competitor. Agent 365 / Work IQ does cover mail, calendar, Teams and SharePoint, but is preview, Microsoft-hosted only, and requires a Microsoft 365 Copilot licence.
Quick start
1. Register the Entra application. This is the step that goes wrong most often. Follow deploy/entra/SETUP.md — in particular, register the platform as Web, not SPA (a SPA redirect URI silently caps refresh tokens at 24 hours, and that expiry is inherited by every token derived from them, which would destroy the connect-once premise).
2. Generate the two keys. They are different keys with different jobs, and neither can substitute for the other.
npm install
npm run gen:oauth-key # RS256 keypair — signs the tokens Claude presents to US
npm run gen:enc-key # AES-256-GCM key — seals the tokens WE present to MicrosoftBack up the output of gen:enc-key in a secrets manager, separately from the deployment. Losing it makes every stored connection undecryptable and forces every user to sign in again at once.
3. Deploy.
npm run build:lambda
npm run deploy:lambda # wraps `sam deploy` against deploy/aws/template.yamlThe stack prints an EntraRedirectUri output. Register that exact URI on the app registration — it is the one step that cannot be automated.
4. Connect a client. Add https://<your-deployment>/mcp as a custom connector. The client discovers the OAuth endpoints from /.well-known/oauth-protected-resource, registers itself, and sends the user through the Microsoft sign-in. Microsoft's consent screen is then followed by this server's own mailbox approval page, where the user ticks which shared mailboxes the assistant may use; the client receives its token only after that. Then call o365_whoami — it reports the connection state, the granted permissions, the mailboxes the user approved, and which tools the caller currently holds.
For local development, put the variables from Configuration in a .env file — at minimum the Entra registration, the two keys, and MCP_USERS_FILE + MCP_GRAPH_FILE + MCP_OAUTH_FILE for file-backed stores, which is what lets npm run dev run the real browser sign-in and mailbox approval flow without AWS. .env.example is the annotated version. Then:
npm run dev # http://localhost:3000/mcpAdd http://localhost:3000/oauth/callback as a second redirect URI on the app registration.
Architecture
Claude / MCP client
│ 1. POST /mcp (Bearer: our RS256 JWT)
▼
┌──────────────────────────────────────────────────────────┐
│ office365-mcp (Lambda Function URL / Azure Fn / Node) │
│ │
│ Hono ── /mcp ── JSON-RPC 2.0 ── tool registry │
│ │ │
│ ├─ OAuth 2.1 authorization server (for the MCP client) │
│ │ /.well-known/* /oauth/register /authorize │
│ │ /callback /consent /token /jwks.json │
│ │ │
│ └─ Graph token broker ── actor resolution ── client │
└───────┬──────────────────────────┬────────────────────────┘
│ │
│ 2. browser sign-in │ 5. Bearer: Graph access token
▼ ▼
Microsoft Entra ID Microsoft Graph
login.microsoftonline graph.microsoft.com/v1.0
│
│ 3. refresh token ──► AES-256-GCM ──► DynamoDB (MCP_GRAPH_TABLE)
│ (row-bound AAD)
▼
4. the browser lands back here, on /oauth/consent — the user ticks
which mailboxes the assistant may use, and only then is the
authorization code handed to the MCP clientTransport. Streamable HTTP, stateless mode. JSON-RPC 2.0 to POST /mcp, one message per request — JSON-RPC batching is refused with -32600, because it was removed from MCP in revision 2025-06-18 and because an array of calls would ride on a single rate-limit charge. An unauthenticated request gets a 401 with the RFC 9728 WWW-Authenticate: Bearer realm="mcp", resource_metadata=… challenge, which is what makes a client start the connector OAuth flow. The protected-resource document is served at both /.well-known/oauth-protected-resource and /.well-known/oauth-protected-resource/mcp, and reports resource as {origin}/mcp — the endpoint the user actually typed, which is what a client compares against.
The identity model
There are two separate OAuth relationships, and keeping them distinct is the whole design:
MCP client ↔ this server. We are the authorization server. The client registers dynamically (RFC 7591), runs an authorization-code + PKCE flow against
/oauth/authorizeand/oauth/token, and receives an RS256 JWT that we signed. Entra does not support dynamic client registration and never sees this exchange.This server ↔ Entra. We are a confidential client with one static Web redirect URI. During the user's sign-in we run our own, independent PKCE chain toward Entra, redeem the code with our client secret or certificate, and receive an id_token, a Graph access token and — because we request
offline_access— a refresh token.
That refresh token is the product. It is sealed with AES-256-GCM under additional authenticated data bound to the row ({tid}:{oid}:refresh), so a blob lifted out of one user's record cannot be replayed into another's, and written to a table that exists only for credentials. Every later tool call goes: JWT → (tid, oid) → cached access token, or a refresh redemption → Graph. The user's key is the immutable pair (tid, oid) from the id_token, never email, preferred_username or upn, all of which are mutable and admin-controllable.
Between the two, /oauth/callback pauses. Once the Microsoft authorization is stored it does not hand the MCP client its authorization code; it redirects the browser to /oauth/consent with a single-use ticket, and the user chooses which mailboxes this assistant may address. Only when they submit that page is the code minted and the client redirected home. See Mailbox approval.
The tenant allowlist (O365_ALLOWED_TENANTS) is re-checked on every request, not only at sign-in, so removing a tenant takes effect immediately rather than at the next login.
Delegated vs app-only
Every Graph call resolves an actor first, deterministically from configuration — a model can never talk the server into escalating:
Actor | Address | When | Sees |
|
| No | Exactly what the signed-in user sees |
|
| A different mailbox, which the user approved at connect time, the admin policy permits, and the caller has Exchange rights on | What Exchange has granted that user on that mailbox |
|
| The mailbox is in | Whatever Exchange RBAC scopes the application to |
Every actor other than delegated-self passes policyAllowsMailbox first — the user's own approval, then the administrator's ceiling — before Graph is asked anything. Delegated is the default and the normal path: past those two gates the tenant's existing permissions remain the real gate, and a denied call produces an honest 403 that the server translates into "ask an administrator for Full Access on that mailbox". App-only exists only for mailboxes nobody signs into, is off by default, and is described in full below. /me never means a shared mailbox — there is no /me path into one — and an app-only token may never use /me at all, because there is no signed-in user.
Teams is delegated-only by design, permanently. See Limitations.
Tools exposed to the model
27 tools. W marks a tool that changes tenant state; those are disabled by default for a newly provisioned user and additionally require allowWrites on their policy. Every Outlook tool takes an optional mailbox argument (a UPN or SMTP address) selecting the mailbox to act on; omit it for your own. All ids returned are opaque Microsoft Graph ids — pass them back verbatim, and never construct one.
Outlook — reading
Tool | What it does |
| Full-text search a mailbox using Outlook search syntax ( |
| List messages in a folder with structural filters and sorting — unread only, a sender, a date range, an order. The counterpart to |
| One message in full, body as plain text, optionally with internet message headers and attachment metadata. Long bodies are truncated with the original length reported. |
| List mail folders, top level or the whole tree, with message and unread counts. Use it to resolve a folder id before moving messages. |
| Attachment names, types, sizes and inline flags for one message. Metadata only — never file contents. |
| Download an attachment and return a short-lived presigned URL. Never returns bytes inline. |
Outlook — writing
Tool | What it does |
W | Send a message immediately, composed inline or from a draft. Graph accepts for delivery and returns no id, so this reports "accepted", not "delivered". |
W | Reply, reply-all or forward an existing message in one step. Your text goes above the quoted original. |
W | Create an unsent draft, from scratch or as a reply/forward that already quotes the original. Returns the draft id. |
W | Edit an unsent draft's subject, body or recipients. Only works on drafts. |
W | Move or copy a message to another folder. Moving changes the message id — the new one is returned and the old one stops working. |
W | Delete messages: |
W | Mark read/unread, flag, categorise and set importance on up to 20 messages at once. |
W | Create, rename, move or delete a mail folder. |
W | Attach a file to a draft. Under 3 MB inline, up to 150 MB via a chunked upload session. |
Teams
Tool | What it does |
| Your teams, or the channels in one team. The way to resolve a team or channel name to the ids the other Teams tools need. |
| Your chats — one-to-one, group and meeting — most recently active first. One-to-one chats have no name of their own, so one is built from the participants. |
| Read messages from a channel or a chat. Chats support a date range; channels do not, because Graph's channel API accepts no date filter. |
W | Post as yourself to a channel, into a channel thread, or to a chat — including to people by email, which finds or creates the one-to-one chat. Takes no |
| Keyword search across every chat and channel you can see. The only way to search Teams; the listing APIs have no search at all. Takes no |
SharePoint and OneDrive
Tool | What it does |
| Search files across SharePoint and OneDrive, or within one site or library. Supports KQL terms ( |
| Find sites, or list a site's document libraries and their drive ids. The starting point for SharePoint work. |
| List a folder in OneDrive or a document library, by drive + item id, by path, or the root of your own OneDrive. |
| Details of one file or folder — including from a pasted sharing URL, which it resolves. Optionally reports who has access. |
| Download a file as a presigned URL, optionally converted to PDF on the way out. Never inline. |
W | Share by link or by inviting people. Reports the access actually granted, because the tenant's sharing policy can silently downgrade what you asked for. |
Core
Tool | What it does |
| Who you are signed in as, whether the Microsoft connection is live and when it last refreshed, which permissions were granted, which tools you hold, which shared mailboxes you approved and can actually use right now, and for each of them whether a call would run as you or as the service account. Call this first when something fails. |
Configuration
Everything is environment-only. The authoritative list is the Env type in src/config.ts.
Entra app registration
Var | Required | Description |
| yes | Tenant GUID or verified domain. Every call targets this concrete tenant — |
| yes | Application (client) ID. The registration must use platform type Web. |
| one of | Client secret. Simplest, but Entra caps its lifetime at 24 months. |
| one of | PKCS#8 PEM private key (literal or base64) for certificate client authentication. Preferred in production. |
| with cert | Hex SHA-1 thumbprint as shown in the portal. Entra matches the assertion to the certificate by thumbprint, so both halves are required. |
| Comma-separated tenant ids accepted when running multi-tenant. Checked at sign-in and again on every request, so removing a tenant here locks out its existing connections immediately rather than at their next login. Empty with |
Keys
Var | Required | Description |
| yes | Base64 PKCS#8 PEM of the RS256 key signing our MCP access tokens. |
| yes | Base64 SPKI PEM of the matching public key. Published at |
| Key id in the JWKS and token headers. Change it alongside a keypair rotation. Default | |
| yes |
|
| Comma-separated retired keys in the same form, accepted for decryption only. This is what makes rotation a rolling operation rather than a flag day. |
Storage
Var | Required | Description |
| yes¹ | DynamoDB table for sealed refresh tokens (no TTL) and cached access tokens (TTL). Deliberately separate from the OAuth table so credentials get their own IAM boundary and backup policy. |
| JSON-file fallback for self-hosting and development. Ignored when | |
| yes¹ | DynamoDB table for our own OAuth state — registered clients, login states, auth codes, refresh tokens. TTL on |
| JSON-file fallback for the same state, so | |
| DynamoDB table for users and policies, with | |
| JSON user store for self-hosting and development. Ignored when | |
| Legacy single-admin bearer that bypasses the user store. Useful for smoke tests. It has no Graph connection of its own, so Graph tools return a reconnect error unless it maps to a user who has signed in. |
¹ or the corresponding _FILE variant (MCP_GRAPH_FILE / MCP_OAUTH_FILE) for local development.
Graph behaviour
Var | Default | Description |
|
|
|
| derived | Space-separated wholesale override. Validated on load: |
|
| Change only for sovereign clouds, where several capabilities used here do not exist. |
|
| Sends |
|
|
|
|
| Per-request timeout, kept well under the client's 300-second tool timeout. |
|
| In-flight cap per (application, mailbox). Exchange allows exactly four; the value is clamped there because raising it only converts throughput into 429s. |
|
| Microsoft deprioritises undecorated traffic. Keep the documented shape and put your own company name in the middle field. |
| auto | SharePoint geography ( |
Gates and output
Var | Default | Description |
|
| Master switch for app-only mode. While false the code path is unreachable regardless of any allowlist. |
| empty | Comma-separated mailbox addresses reachable with application credentials. A wildcard is rejected outright. |
| empty | Comma-separated site ids or URLs reachable with application credentials, enforced on every site- or drive-addressed call made with an app-only actor. An empty list means app-only never reaches SharePoint at all, and an app-only call that names no site is refused rather than allowed. Matching is case-insensitive and either exact or a prefix that ends on a |
|
| Deployment-level lock on |
|
|
|
| S3 bucket for downloads. Required by every download tool — there is no base64 fallback by design. | |
|
| Lifetime of presigned URLs. Anyone holding one can fetch the file without authenticating, so keep it short. |
|
| Overrides the region for the artifact bucket. |
| JSONL sink for audit and security records, in addition to stderr. For self-hosted deployments; on Lambda, stderr already reaches CloudWatch. | |
| unset |
|
|
| Listen port for the plain Node entry point. Unused on Lambda and Azure Functions. |
Shared mailbox access
This is the feature the design is built around, and the capability itself depends on the tenant rather than on this server.
Two things must both be true before Graph will do it at all. The connection needs the delegated .Shared Graph scopes (Mail.Read.Shared, Mail.ReadWrite.Shared, Mail.Send.Shared — all present in the work scope profile), and Exchange Online must have granted the signed-in user rights on the target mailbox. The scope only unlocks the capability; Exchange is the actual gate. Without the Exchange grant, Graph returns 403 no matter what was consented.
An administrator grants one or more of these in the Exchange admin center (Recipients → Mailboxes → the shared mailbox → Delegation):
Right | What it enables | Effect |
Full Access | Reading, listing, moving, deleting, drafting in the mailbox | Required for every read and write tool against that mailbox. Also required if a send should land a copy in the shared mailbox's Sent Items. |
Send As | Sending with the shared mailbox as the sender | The recipient sees only the shared mailbox. |
Send on Behalf | Sending on the mailbox's behalf | The recipient sees "user on behalf of shared mailbox". Users can grant this themselves in Outlook; only an admin can grant Send As. |
Permissions can take up to an hour to take effect after they are granted. A 403 immediately after a grant is usually just that, and the server says so in the error.
On top of those two, this server adds its own two: the user must have approved the mailbox when they connected, and the administrator's allowedMailboxes ceiling must permit it. Both are checked before Graph is asked anything — see Mailbox approval.
Then simply pass the address: o365_mail_list({ mailbox: "support@contoso.com", unreadOnly: true }). The server resolves the actor, calls /users/support@contoso.com/…, and never /me — there is no /me path into a shared mailbox. The address must also have been approved by the user at connect time; see Mailbox approval below.
Two constraints worth knowing up front. There is no Graph API that enumerates which mailboxes a user has rights on — which is why the approval page verifies a candidate address rather than listing them for you, and why the caller still names the mailbox on a tool call. And the signed-in user generally needs their own licensed mailbox, though the shared mailbox itself does not need a licence.
Narrowing it further. policy.allowedMailboxes is the administrator's ceiling, on top of what the user themselves approved; effective access is the intersection of the two. It defaults to "*", which defers the remaining decision to Exchange where it belongs. Set it to an explicit list to constrain a user below their Exchange rights, or to null to reject a mailbox argument outright:
npm run user -- add alice --mailboxes=support@contoso.com,billing@contoso.comMailbox approval
Addresses are entered by hand. Microsoft exposes no API that lists the mailboxes a person can open, and inferring it from who they correspond with produced a list that was mostly wrong — so the page does not guess. What it does do is verify: every address typed in is checked against Exchange before it can be approved, and shown as available or unavailable with the reason.
The signed-in user's own mailbox is a normal entry on that list and can be removed. An assistant built around a shared support mailbox has no business reading the operator's personal inbox, so that has to be expressible. Withholding it makes every Outlook tool refuse a call that does not name another approved mailbox; Teams and SharePoint are unaffected, because neither passes through the mailbox gate.
Microsoft cannot restrict this grant, so this server does. Entra offers no per-mailbox consent for delegated Mail.*.Shared. The moment a user grants those scopes, the resulting token can open every mailbox Exchange lets that person open, and there is no Microsoft-side way to narrow it — SharePoint gained delegated Sites.Selected in 2024, Exchange has no equivalent and no roadmap entry for one. The approval page below is therefore not a nicety: it is the only thing that restricts the grant, and it is enforced server-side, on every request, before any Graph call (policyAllowsMailbox in src/users.ts, reached from resolveActor).
The flow. /oauth/authorize → Entra sign-in → /oauth/callback stores the sealed refresh token and then, instead of handing the MCP client its authorization code, redirects to /oauth/consent with a single-use ticket (15 minutes). The page shows the user's own mailbox — always included, never removable — plus candidate shared mailboxes, each already probed so an address that cannot be opened is shown greyed out with the reason rather than accepted and failing later. They tick what this assistant may use, and only then is the authorization code minted and the client redirected home. Connecting again re-runs the page with the previous choice pre-ticked, which is also how a user removes a mailbox later.
What the probe cannot see. A 403 on the Inbox has three distinct causes, and only one of them is "no access at all". A user who holds only Send As, or who has been given access to a single folder rather than the whole mailbox, fails the Inbox probe even though that narrower access would work for the operation they want. The page says so where the mailbox appears; the honest summary is that the probe under-reports rather than over-reports. It never produces a false positive — an address that probes 200 is one the server can genuinely open.
Two gates, both server-side. grantedMailboxes is what the user approved; allowedMailboxes is the administrator's ceiling. A mailbox is reachable only when it appears in both, and o365_whoami reports that intersection as usableSharedMailboxes so the model only ever sees mailboxes it may actually use. The user's own mailbox is always permitted and appears in neither list.
An API-key service account never sees the page — there is no browser and no human to ask — so the consent gate does not apply to it. That is deliberate: the administrator who created the key is the consenting party, and allowedMailboxes governs alone. The distinction is drawn on whether the identity has an Entra oid, i.e. whether it ever went through a browser sign-in.
App-only mode
App-only exists for one situation: a mailbox nobody signs into, that nobody has been delegated, which an agent should still triage. It uses the application's own credential rather than a user's, so there is no signed-in user and /me is invalid.
It is off by default and should stay off unless you need it, because an admin-consented application Mail.ReadWrite grants access to every mailbox in the organisation. Turning it on requires three independent things: O365_APP_ONLY_ENABLED=true, the mailbox listed in O365_APP_ONLY_MAILBOXES, and allowAppOnly on the calling user's policy. A mailbox that is allowlisted but whose caller lacks allowAppOnly simply falls back to delegated and lets Exchange answer. Escalating to app-only does not skip the two mailbox gates: resolveActor applies them before it considers the app-only branch at all, so a signed-in user still has to have approved the address on the approval page. The typical app-only caller is an API-key service account, which is never asked and for which allowedMailboxes is the whole control.
O365_APP_ONLY_MAILBOXES is only half the control, and it is the weaker half. It constrains what this code will ask for. It does nothing to the credential itself: anyone who obtains it reaches every mailbox in the tenant. The real control is Exchange RBAC for Applications, applied tenant-side. deploy/entra/scope-app-only.ps1 scripts it: register the service principal in Exchange, create a management scope over a mail-enabled security group, assign the Application Mail.* role restricted to that scope, and verify with Test-ServicePrincipalAuthorization.
The trap that defeats the whole thing: RBAC grants are additive with Entra grants. If the unscoped application permission remains consented on the app registration, the union of the two is what applies and the scoping accomplishes nothing. The Entra application permission must be removed. Also budget for the permission cache: changes take 30 minutes to 2 hours to take effect (Test-ServicePrincipalAuthorization bypasses the cache, which is why the script ends with it).
Teams has no app-only path here at all — see below.
Per-user tool permissions
When a user store is configured (MCP_USERS_TABLE, or MCP_USERS_FILE for development), every user record carries a policy:
Field | Meaning |
|
|
| Per-tool |
| Required on top, for every tool marked as mutating. |
| What the user approved on the mailbox approval page at connect time. Absent means they were never asked, and only their own mailbox is reachable. Written by |
| The administrator's ceiling on top of that: |
| Per-user gate for the app-only actor. Default |
| Per-user call ceiling. Default 60. |
| Kills the identity without deleting it. |
Tools a user cannot call are also hidden from tools/list, so the model never sees them. On every OAuth login the map is reconciled against the live registry: newly shipped tools are added as false, so a new — possibly destructive — tool is never silently granted; removed tools are pruned. The store is written only when something changed.
A newly provisioned user gets every read-only tool enabled and every mutating tool disabled.
Admin CLIs
npm run user -- list
npm run user -- add alice --writes --mailboxes=support@contoso.com --app-only
npm run user -- tools alice # the effective 27-tool map
npm run user -- tools alice --enable=o365_mail_send
npm run user -- rotate alice # new API key, old one dead
npm run user -- disable alicenpm run connection -- list # who is connected, scopes, last refresh — never token material
npm run connection -- test alice@contoso.com # one live Graph call, proves the stored credential still redeems
npm run connection -- revoke alice@contoso.com # server-side kill switch: delete the row, purge cached tokens
npm run connection -- rewrap # re-seal every stored secret under the current encryption keyconnection revoke stops this server using the credential. The authoritative tenant-side kill switch is Revoke Sessions on the user object in Entra — note that a password change alone does not invalidate a confidential-client refresh token (see the revocation matrix in SECURITY.md).
Limitations and known issues
On AWS, the
WWW-Authenticatechallenge header is renamed. A Lambda Function URL rewrites it tox-amzn-Remapped-WWW-Authenticate, and nothing inside the function can prevent that. It does not break discovery: the MCP specification requires clients to fall back to fetching/.well-known/oauth-protected-resource/mcp(then the root variant) directly, and the reference SDK does so unconditionally on a 401 — which is why this server serves both documents and reports aresourcevalue byte-identical to its MCP endpoint URL. If you meet a client that genuinely needs the header, put CloudFront in front with a Lambda@Edge origin-response function that copies the remapped name back; a viewer-response function will not work, because CloudFront does not invoke those when the origin returns 400 or higher.
Stated plainly, because most of these will be experienced as bugs otherwise.
Refresh tokens die in ways that look random. The 90-day window is sliding inactivity, not a fixed expiry: a user who connects weekly effectively never expires, while one who goes quiet for 91 days comes back dead (AADSTS70008 / 700082). Independently, Conditional Access sign-in frequency forces re-authentication on its own cadence and no server-side code can prevent it. An admin resetting a password through the Entra or Microsoft 365 admin center revokes tokens immediately; a user changing their own password does not. Every one of these surfaces as a single clear tool error carrying the URL to reconnect.
A client secret expiry is a cliff, not a slope. Entra caps secret lifetime at 24 months, and when it expires every user of the deployment fails at once with AADSTS7000222 — not gradually, not one at a time. Certificate credentials avoid the failure mode; rotate either well before the date.
Losing the encryption key is unrecoverable. No key, no stored connections, and every user must sign in again simultaneously. Back it up separately and rotate it through O365_TOKEN_ENC_KEYS_PREVIOUS + connection rewrap, never by replacement.
Teams sending is delegated-only, permanently. Every Graph send endpoint offers Teamwork.Migrate.All as its sole application permission and Microsoft restricts it to migration scenarios. There is no compliant way to post Teams messages from a service account in this architecture; the alternatives are a Bot Framework bot or a Teams app package with resource-specific consent installed per team, neither of which fits a standalone remote MCP server. Unattended Teams posting is not on the table. (Teams metering is not a concern: the model A / model B billing regime ended on 25 August 2025, despite what most existing documentation still says.)
Teams channel scopes need tenant-admin consent. ChannelMessage.Read.All in particular cannot be self-consented. A self-hoster without admin rights running O365_SCOPE_PROFILE=personal gets working mail, files and chat, and channel tools that fail with an explanation rather than a mystery 403. personal also omits User.ReadBasic.All, so an @-mention of someone outside the conversation cannot be resolved: the message is still sent, the name stays in the body as plain text, and the tool returns a warning saying the person was not notified.
The mailbox approval page under-reports, never over-reports. It decides whether you can use a mailbox by trying to open its Inbox, and a 403 there has three causes. If you hold only Send As, or access to one folder rather than the whole mailbox, the address shows as unavailable and cannot be ticked even though that narrower access would have worked for what you wanted. The reverse mistake cannot happen — an address you can tick is one the server can genuinely open.
Search has hard ceilings that look like data loss. Outlook $search returns at most 1,000 results and cannot be combined with filters or custom sorting. Teams search reports a page count rather than a total, so it can never be presented as a match count. SharePoint deep paging stops beyond result 1,000, and an app-only search excludes private OneDrive content by default — enabling it provisions a new index that can take days to a week, during which results are quietly incomplete with no error at all.
Tenant sharing policy silently rewrites what o365_files_share produces. Organisation-level and per-site settings can downgrade an anonymous link to organisation-only, force an expiry, or make links view-only. Worse, createLink is idempotent per (application, link type), so a request for a fresh seven-day link can return a years-old never-expiring one with different scope. The tool always reads back and reports the actual grant, which is the only defence.
Message ids change when messages move, and Teams ids are not globally unique. O365_IMMUTABLE_IDS defaults to on for exactly this reason, but it is effectively a one-way door: ids handed out under one format do not work under the other, and flipping it on a live stack produces ErrorInvalidIdMalformed. Separately, a Teams message id is unique only within its chat or channel, so message ids are always returned with their conversation coordinates.
Throttling is the most likely day-to-day failure. Outlook permits four concurrent requests per (application, mailbox) and 10,000 per ten minutes; Teams permits roughly one request per second per channel, per chat, and per user; SharePoint charges five resource units per permissions call and throttles search far tighter than the rest of Graph. Batching does not help — Graph forwards at most four sub-requests from a batch to Outlook concurrently. The server bounds its own fan-out and honours Retry-After exactly, but an eager agent will still meet a 429 eventually.
Attachments over 3 MB do not work in a shared mailbox. Microsoft documents that a delegated caller gets a 403 attaching large files to a message in a shared or delegated mailbox. Under 3 MB is fine. The tool says so rather than surfacing a bare 403.
Sovereign clouds quietly lack features. Permanent delete, chat delta and the Teams export APIs are unavailable in US Government L4/L5 and China 21Vianet, and cross-geo site access can fail for reasons unrelated to app permissions. Multi-geo tenants need one search request per region, or content elsewhere is silently missing.
Refresh-token rotation races are benign but real. Entra issues a new refresh token on every redemption and does not revoke the old one, so two concurrent invocations for the same user each receive a valid successor. A conditional write means one wins and the loser discards its copy; a lost race never fails a tool call. The access-token cache is what keeps it rare.
Roadmap
These are deliberate v1 scope cuts, not oversights:
Calendar. The second thing anyone expects after mail.
Calendars.ReadWriteis user-consentable and the actor model already built for shared mailboxes applies unchanged to shared calendars. Sequenced next.File upload to OneDrive and SharePoint. v1 covers search, list, get, download and share.
Contacts / people lookup to resolve a name to an email address. Every send tool currently assumes the caller already has one.
Inbox rules (
messageRules, needsMailboxSettings.ReadWrite) for server-side triage automation.
Also under consideration: a pluggable audit sink (Firehose → S3 → Athena) beyond stderr, workload identity federation as a third client-credential type on AWS so no long-lived secret exists at all, and server-minted opaque page handles instead of raw @odata.nextLink strings.
Directory administration is deliberately out of scope — Microsoft's free MCP Server for Enterprise already covers read-only Entra queries.
Contributing
See CONTRIBUTING.md. Security issues: SECURITY.md — please do not open a public issue.
Licence
MIT. See LICENSE.
This server cannot be installed
Maintenance
Resources
Unclaimed servers have limited discoverability.
Looking for Admin?
If you are the server author, to access and configure the admin panel.
Related MCP Connectors
Copilot connector permission audits with owner signoff receipts.
*Updated June 17th 2025** Manage your Microsoft 365 services effortlessly. Create and manage distr…
Governed email for AI agents (Mailbuttons / mbag.ai): sandbox inboxes, policy gate, audit log.
Latest Blog Posts
- Who's Calling? MCP Hosts Are an Identity Blind Spot (And the Spec Knows It)By Om-Shree-0709 on .mcpAgent IdentityOAuth 2.1
- Your AI Chatbot Just Exposed Your CEO's Salary to an InternBy Om-Shree-0709 on .Agent IdentityMCP SecurityOAuth Delegation
- Why MCP Servers Need Execution Sandboxing (And Why Your Current Stack Isn't Enough)By Om-Shree-0709 on .Agentic AiPrompt InjectionWebAssembly
MCP directory API
We provide all the information about MCP servers via our MCP API.
curl -X GET 'https://glama.ai/api/mcp/v1/servers/LotzerDigital/aws-office365mcp'
If you have feedback or need assistance with the MCP directory API, please join our Discord server