Claude Marketplaces
SkillsMCP serversMarketplacesDigestTools
Submit

This week in Claude

Every Monday: Claude Code, Agent SDK, MCP, and the Anthropic platform moves worth your time.

Skills by category
Frontend DevelopmentBackend & APIsTesting & QASecurityDevOps & CI/CDGit & Pull RequestsDocumentationCode Review & QualityAI & Agent BuildingSkill Development
MCP servers by category
Sales & MarketingWeb & Browser AutomationDatabasesAI & LLM ToolsCloud & InfrastructureCommunication & MessagingDeveloper ToolsDesign & CreativeDocuments & KnowledgeSearch & Web Crawling
Marketplaces by category
AI Agents & OrchestrationLLM IntegrationDevelopment ToolsFrontend & UIBackend & APIsDatabasesTesting & Code QualityDevOps & CloudSecurity & ComplianceGit & Version Control
Claude Marketplaces

An independent directory of skills, plugins and MCP servers for Claude Code. Not affiliated with Anthropic.

Browse

  • Skills
  • MCP servers
  • Marketplaces
  • Weekly digest
  • Skill index
  • MCP index
  • Marketplace index

Site

  • About
  • Tools
  • Submit
  • Feedback
  • Privacy
  • llms.txt
  • Plugins reference

Maker

  • Snug
  • Agent Caffeine
  • knobs
  • Wax
  • Vectoys
  • mertduzgun.com
Crafted at
MCP servers/Data & Analytics
softeria avatar

Microsoft 365

softeria/ms-365-mcp-server

The Ms 365 MCP Server provides programmatic access to Microsoft 365 and Office services through the Microsoft Graph API, supporting multiple cloud environments including Global and China (21Vianet) deployments. The server exposes tools for managing Outlook email, calendar, contacts, OneDrive files, and other Microsoft 365 services, with features including MSAL-based authentication, read-only mode support, and configurable output formats (JSON or TOON token-optimized format). It solves the problem of integrating Microsoft 365 functionality into AI applications and workflows by translating Graph API capabilities into standardized MCP tools accessible to LLM-based clients.

GitHub stars
713
last updated
Jan 30, 2026
View on GitHubSave

Sponsors

SnugThe clipboard manager you and your agent can use, safelyAgent CaffeineClose your lid, come back to finished workWaxBlock anything with supervision, without erasing your iPhoneOpen StepsSponsoredFree and open-source skills that make AI coding agents easier to understand, verify, and control.

Readme

ms-365-mcp-server

npm version build status license

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:

CloudDescriptionAuth EndpointGraph API Endpoint
Global (default)International Microsoft 365login.microsoftonline.comgraph.microsoft.com
China (21Vianet)Microsoft 365 operated by 21Vianetlogin.chinacloudapi.cnmicrosoftgraph.chinacloudapi.cn
GCC HighMicrosoft 365 US Government GCC Highlogin.microsoftonline.usgraph.microsoft.us

GCC High has no pre-registered client: register an app in your government tenant and set MS365_MCP_CLIENT_ID (and MS365_MCP_TENANT_ID), then use --cloud gcc-high.

To route Graph traffic through a proxy of your own (one that terminates the client's plain HTTP and originates TLS to Microsoft), set MS365_MCP_GRAPH_BASE_URL to its base URL; see the environment variable list under CLI Options, including what still bypasses it.

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

Custom Teams emojis are available in organization mode through list-custom-emojis and create-custom-emoji (teams and work presets). These use the Microsoft Graph beta API and request the delegated permissions TeamworkCustomEmoji.Read and TeamworkCustomEmoji.Create, respectively. Read-only mode exposes only the list tool. Existing deployments may need consent for these new scopes and reauthentication; adding tool support does not upgrade an already-issued token.

Listing can return contentBytes: null by default. To request base64 PNG/GIF images, pass select: "displayName,contentBytes" or select: ["displayName", "contentBytes"]. Use a small top to keep image responses manageable. To create an emoji, pass body: { displayName, contentBytes } with the exact approved name and base64 PNG/GIF file bytes. See Microsoft's list and create contracts. These tools do not post messages or reactions.

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, --enabled-tools-file, --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'

For longer filters, put one regex per line in a file and pass --enabled-tools-file / ENABLED_TOOLS_FILE. Blank lines and whole-line # comments are dropped; the rest are joined with |, so the file is equivalent to --enabled-tools=line1|line2|…:

# tools.txt
^list-mail-messages$
^get-mail-message$
drive|download-bytes
npx @softeria/ms-365-mcp-server --org-mode --enabled-tools-file ./tools.txt

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, 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.

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 <clientId>/access_as_user for protected-resource metadata and requests it on /authorize; --allowed-scopes does not override OBO, it only narrows the tool surface.

Restricting user profile fields

Deployments can set a comma-separated field filter for selected user-directory paths with --user-fields or MS365_MCP_USER_FIELDS. It narrows the $select sent to Microsoft Graph on /users reads and projects responses on the covered paths listed below. This is a path-specific data filter, not a complete Graph authorization boundary: other Graph routes and resource shapes can expose overlapping profile data.

npx @softeria/ms-365-mcp-server \
  --org-mode \
  --user-fields 'id,displayName,mail,userPrincipalName'

The filter is keyed on known Graph paths rather than tool names. It applies to direct tools, discovery mode's execute-tool, and matching graph-batch subrequests. Batch subrequest URLs are rewritten on covered /users reads, and responses are projected on all covered profile paths. The byte-passthrough tools (download-bytes, download-bytes-to-file, get-download-url) return responses verbatim and refuse targets matching the covered user profile paths while the filter is active.

Two levels of enforcement apply, depending on what Graph returns:

PathEnforcement
/users, /users/{id}$select is narrowed on the request and the response is projected, so excluded fields never leave the tenant
/me/manager, /me/directReports, /users/{id}/manager, /users/{id}/directReports, /groups/{id}/members, /groups/{id}/ownersThe response is projected. These are typed as directoryObject, where Graph requires an OData cast before it will $select a user-only property such as jobTitle, so narrowing the request would risk breaking calls that work today

While the filter is active, $expand is removed from /me, /groups, and /groups/{id} requests because those resources can expand to related user profiles. This also applies to matching batch subrequests. The base /me and group responses are otherwise not projected.

This does not cover every route that can return a user-shaped object. In particular, generic Graph access can still reach alternate routes or representations such as /directoryObjects/{id}, /groups/{id}/transitiveMembers, or /users('{id}'). Those are not currently normalized to the covered paths. If this setting is a security requirement, also restrict generic Graph access (for example graph-batch) and validate the effective tool surface for your deployment; do not rely on --user-fields alone as a tenant-wide data-access control.

Resources below a user, such as /users/{id}/messages or /users/{id}/photo/$value, are mail, calendar and binary resources rather than profile properties, and are unaffected. The signed-in user's own /me profile is also unaffected, as are the Teams and chat member lists, which return conversationMember rather than user profiles.

Known gap: list-relevant-people

list-relevant-people (/me/people) is not covered. It returns person resources, a different type whose properties only partly overlap with user — it carries jobTitle, department and officeLocation, but addresses arrive as scoredEmailAddresses rather than mail. Applying a user-field allowlist to it would reject valid field names and narrow the result to something the tool could not use.

Deployments that need the people surface closed as well should drop the tool from the surface, for example with --enabled-tools or by choosing a preset that excludes it:

npx @softeria/ms-365-mcp-server \
  --org-mode \
  --user-fields 'id,displayName,mail' \
  --enabled-tools '^(?!list-relevant-people$).*'

CLI values take precedence over the environment variable. A value that names no fields fails at startup. When neither is configured, existing behavior is unchanged. Configure this in the LibreChat MCP server environment or command arguments; project-local .env files are intentionally restricted to application credentials and are not used for this setting.

The allowlist is exhaustive for user properties: unlike an ordinary $select, id is only returned when it appears in the list, so include it if downstream tools need it to address a user. Collection annotations such as @odata.nextLink are retained so paging keeps working. If Graph returns none of the allowlisted properties, the response is projected to empty rather than returned untrimmed.

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:

  1. Organization mode: Shared mailbox tools require --org-mode flag (work/school accounts only)
  2. 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
  3. Exchange permissions: The signed-in user must have been granted access to the shared mailbox
  4. 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

Quick Start Example

Test login in Claude Desktop:

Login example

Examples

Image

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.

  1. Start the server with HTTP mode:

    npx @softeria/ms-365-mcp-server --http
    
  2. 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
  3. 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.

Open WebUI MCP Connection

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:

  • MCP client login:
    • Call the login tool (auto-checks existing token)
    • If needed, get URL+code, visit in browser
    • Use verify-login tool to confirm
  • CLI login:
    npx @softeria/ms-365-mcp-server --login
    
    Follow the URL and code prompt in the terminal.

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:

  1. Create Azure AD App Registration:
  • Go to Azure Portal
  • Navigate to Azure Active Directory → App registrations → New registration
  • Set name: "MS365 MCP Server"
  1. 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).
  1. 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)
  1. 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)
  1. 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)
4. App Mode (application permissions, no signed-in user)

Warning: application permissions apply to the whole tenant. Mail.Read granted to the app reads every mailbox, Files.ReadWrite.All writes every OneDrive and site, and whoever can call this server acts with them. Grant the fewest permissions you can. To limit which mailboxes and calendars it reaches, grant the mail and calendar permissions with RBAC for Applications in Exchange Online instead of in Microsoft Entra: the two add up, so an Entra Mail.Read grant left in place still reaches every mailbox. SharePoint sites can be narrowed with Sites.Selected.

For unattended agents that act as the app registration itself (client credentials):

MS365_MCP_CLIENT_ID=<your app id> \
MS365_MCP_TENANT_ID=<your tenant id or domain> \
MS365_MCP_CLIENT_SECRET=<secret> \
npx @softeria/ms-365-mcp-server --app-mode --read-only
  • Use your own app registration with application permissions and admin consent, and set exactly one credential:

    • MS365_MCP_CLIENT_SECRET
    • MS365_MCP_CLIENT_CERTIFICATE_PATH: a PEM file with the certificate and its unencrypted private key
    • MS365_MCP_FEDERATED_TOKEN_FILE: a token from an identity provider the app trusts through workload identity federation, read again on every token request. On AKS set it to $AZURE_FEDERATED_TOKEN_FILE, which Kubernetes keeps fresh. A GitHub Actions OIDC token is valid for 5 minutes, so writing it to a file once only covers the first Graph token (about an hour) unless something rewrites the file. If Entra answers AADSTS700213, set the federated credential's subject to the one quoted in the error (GitHub's subject can include the owner and repo ids).

    MS365_MCP_TENANT_ID must be a real tenant, not common.

  • On an Azure host (App Service, Container Apps, VM, ...) set MS365_MCP_MANAGED_IDENTITY=system, or the client id of a user-assigned identity, instead. No app registration, tenant id or secret is needed, and Key Vault (MS365_MCP_KEYVAULT_URL) is not read. Graph application permissions for a managed identity can't be granted in the portal; assign them with PowerShell or the Graph API. On Azure Container Instances, give the container one kind of identity: with both a system- and a user-assigned identity, the system-assigned token request fails there.

  • Tokens are renewed automatically. Nothing is cached on disk.

  • Only tools that work with application permissions are offered: the /users/{user-id} mail and calendar tools (pass a UPN or object id as user-id), users and groups, OneDrive/SharePoint drives and lists, Planner, Teams reads, presence, places and subscriptions. /me tools, Excel, OneNote and sending Teams chat or channel messages are not available, because Graph does not support them with application permissions.

  • --list-permissions --app-mode lists the application permissions the offered tools use. --allowed-scopes, --enabled-tools, --preset and --read-only narrow the surface as usual.

  • --verify-login --app-mode gets a token and shows the app id, tenant and granted roles (Exchange RBAC grants do not show up there).

  • The mail, calendar and outlook presets are built from /me tools and leave little in app mode; the /users/{user-id} mail and calendar tools are in work.

  • stdio only. It refuses --http, --obo, --extra-scopes, MS365_MCP_OAUTH_TOKEN and the login/account options.

  • Webinar tools also need an application access policy assigned to the organizer.

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

See PRESETS.md for the full list of tools each preset unlocks (regenerated on npm run generate).

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, and joins every other preset (bar teams-write) when HTTP mode runs with --enable-attachment-urls. 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).

Hybrid discovery with direct tools

Keep the full enabled catalogue discoverable while exposing frequent operations directly:

npx @softeria/ms-365-mcp-server --org-mode --discovery \
  --eager-tools list-mail-messages,get-calendar-view,download-bytes

--eager-tools accepts comma-separated exact Graph or utility tool names, not a regular expression or preset. MS365_MCP_EAGER_TOOLS is the environment fallback; the CLI wins. An empty or whitespace-only environment value is treated as unset, including without discovery; an explicitly empty CLI option is still invalid. The option requires --discovery. Empty entries and unknown names fail startup, including in HTTP mode. Duplicate names are registered once.

Direct tools use the same schemas, annotations and execution handlers as normal mode. They also remain searchable and executable through discovery. Existing --preset, --enabled-tools, --allowed-scopes, --read-only, organization-mode and transport restrictions apply first: an eager selection cannot enable a filtered-out tool or add permissions. Recognized but unavailable tools are omitted with a diagnostic logged once per server instance. HTTP still excludes server-local file tools.

Select a small set: exposing many generated Graph schemas can consume substantial model context. This option does not shrink individual schemas. Without --eager-tools, discovery and normal registration behave as before.

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, --enabled-tools-file, --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), china (21Vianet) or gcc-high
--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. Bound to a loopback host
                  (e.g. --http 127.0.0.1:3000 or --http [::1]:3000) with no --public-url, it
                  rejects requests whose Host or Origin is not localhost (not applied to the
                  --attachment-port listener)
--http-local-file-tools Register download-bytes-to-file over HTTP. Anyone who can reach the port
                  can write files as the server's user (without a valid token, only an empty
                  file that is removed again), so enable it only on a single-user machine.
                  With MS365_MCP_UPLOAD_DIRS set it also registers upload-attachment-from-file,
                  which reads files from those directories.
                  Refused unless --http binds a loopback host with no --public-url and no
                  --trust-proxy-auth
--app-mode        Use application permissions (client credentials) with no signed-in user. stdio only
                  (see "App Mode" under Authentication)
--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").
                  Also adds get-download-url to any --preset that lacks it (except teams-write)
--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)
--enabled-tools-file <path> Filter tools using a file with one regex per line (same as --enabled-tools;
                  blank lines and whole-line # comments ignored). Overrides --enabled-tools;
                  overridden by --preset. Equivalent env var: ENABLED_TOOLS_FILE
--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
--blob-output     File download tools answer an MCP EmbeddedResource with the file instead of
                  base64 JSON in a text block (see MS365_MCP_BLOB_OUTPUT below)
--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)
  • ENABLED_TOOLS_FILE: Path to a file with one regex per line (alternative to --enabled-tools-file). Blank lines and whole-line # comments are dropped, then lines are joined with | (same as ENABLED_TOOLS=line1|line2|…). Layering (later wins): --enabled-tools → --enabled-tools-file → --preset → ENABLED_TOOLS → ENABLED_TOOLS_FILE
  • 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_APP_MODE=true|1: Enable app mode (alternative to --app-mode flag)
  • MS365_MCP_CLIENT_CERTIFICATE_PATH=<path>: PEM file with a certificate and its unencrypted private key, used instead of MS365_MCP_CLIENT_SECRET in app mode
  • MS365_MCP_FEDERATED_TOKEN_FILE=<path>: Federated token (workload identity federation), used instead of a secret or certificate in app mode
  • MS365_MCP_MANAGED_IDENTITY=system|<client id>: Use the host's managed identity in app mode, instead of an app registration
  • 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).
  • MS365_MCP_BLOB_OUTPUT=true|1: File download tools (download-bytes, get-mail-message-mime, get-shared-mailbox-message-mime, get-mail-attachment-content, get-shared-mailbox-attachment-content) answer a short JSON text block (name, contentType, size) and an MCP EmbeddedResource with BlobResourceContents (uri, mimeType, blob) instead of base64 JSON in text. The uri's last segment is the optional fileName argument. Default: off. Alternative to the --blob-output flag.
  • MS365_MCP_BLOB_MAX_BYTES=<n>: With blob output on, the largest file a download tool reads (positive integer, default 5242880). The server stops reading past it and answers an error naming the limit.
  • MS365_MCP_BODY_FORMAT=html: Return email bodies as HTML instead of plain text (default: text)
  • MS365_MCP_OBO_ALLOWED_TENANTS=<ids> / MS365_MCP_OBO_ALLOWED_USERS=<ids>: With --obo, only callers whose token tid (tenant id) / oid (user object id) is listed may use the server; others get 403 before any On-Behalf-Of exchange. Comma or space separated; unset = everyone. The claims are read without verifying the signature, which Entra verifies during the exchange, so a token that lies about them still gets no Graph token. A token Entra rejects at the exchange (bad signature, expired, wrong audience, consent needed) gets 401 with a WWW-Authenticate challenge so the client signs in again
  • MS365_MCP_READ_MAIL_TEXT_MAX_CHARS=<n>: Largest result read-mail-text returns, in characters (default 80000). A larger result is refused whole, naming the largest messages, rather than cut (see Reading Mail as Clean Text below)
  • 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 list
  • MS365_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_HTTP_LOCAL_FILE_TOOLS=true|1: Register download-bytes-to-file, and upload-attachment-from-file when MS365_MCP_UPLOAD_DIRS is set, over HTTP (alternative to --http-local-file-tools; same restrictions)
  • MS365_MCP_UPLOAD_DIRS=<dirs>: Directories upload-attachment-from-file may read from, as absolute paths separated like PATH. Unset (the default) leaves the tool unregistered. See "Attaching a local file in stdio mode"
  • MS365_MCP_CLOUD_TYPE=global|china|gcc-high: Microsoft cloud environment (alternative to --cloud flag)
  • MS365_MCP_GRAPH_BASE_URL=<url>: Send Graph API requests to this base URL instead of the cloud's graph.microsoft.com (e.g. http://127.0.0.1:10255/tenant-a/graph for an egress proxy that originates TLS itself). Absolute http(s) URL without query or fragment; a path prefix is kept, so requests go to <url>/v1.0/.... The login authority and the On-Behalf-Of token resource are unchanged. Two things bypass it: get-download-url returns Graph's pre-authenticated SharePoint URL, and the 302 from /content points straight at SharePoint, so whoever follows either connects to SharePoint directly; if you control egress, allow those SharePoint hosts or disable those tools. Not read from .env
  • LOG_LEVEL: Set logging level (default: 'info')
  • SILENT=true|1: Disable console output
  • MS365_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 login
  • MS365_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)

Reading Mail as Clean Text

read-mail-text returns messages as text with the bulk junk removed. Graph's plain-text body is still mostly not the message: links are wrapped in Safe Links or tracking redirects several hundred characters long, marketing mail pads its preheader with invisible characters, images become [https://…png] lines, and replies carry the whole quoted thread. On a real mailbox, 20 inbox messages came to about 280,000 characters through list-mail-messages and 70,000 through read-mail-text.

The rule is: remove obvious junk, never cut or rewrite the message; anything ambiguous is kept. It removes runs of invisible padding characters, image placeholders (a bracketed address of an image file), and quoted reply history from its header down (a dated On … wrote:, also >-quoted, an Outlook From:/Sent: block, -----Original Message-----). Forwards keep the forwarded content: a subject starting FW: / Fwd: or a localised prefix (VS:, WG:, TR:, RV:, I:, ENC:, …, also after tags such as [EXTERNAL]), or history directly under a Forwarded message / Begin forwarded message: marker. Links are kept: Safe Links, Proofpoint URL Defense and Google redirects are unwrapped to the real address, tracking parameters (utm_* and similar) are dropped, and only an address still longer than 300 characters (a tracking redirect) is shortened to ⟦link: domain⟧. Line endings become \n, and the marker brackets are reserved: a body's own ⟦ ⟧ appear as [ ], so text cannot pose as a marker. Everything else — signatures, footers, whitespace, repeated links — is left as the message has it. get-mail-message returns the raw body.

  • messageIds (up to 20, one $batch), or folderId with optional top (max 20), filter or search.
  • Each message is cut to maxChars characters, default 3,000 (marked truncated): a page of 20 then fits the result limit, and a typical message is whole. Ticket systems that repeat their history in every update put the newest entry first, so the cut keeps it. Pass a larger maxChars (up to 300,000; a raw body beyond that is cleaned only that far) for the whole of a long message; get-mail-message has the raw body.
  • Returns [{ id, from, to, cc, subject, received, hasAttachments, text, chars, truncated }]; an unknown id comes back as { id, error }.
  • A result over MS365_MCP_READ_MAIL_TEXT_MAX_CHARS (default 80,000) is refused whole with the largest messages listed, so the agent narrows the query instead of silently getting part of it.

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. With it on, get-download-url is registered under every --preset (except the send-only teams-write), so a --preset mail agent that lists an attachment can mint a URL for it. A minted URL only works if the preset's scopes cover the resource: a contacts, tasks or excel preset has no Mail scopes, so a URL minted for a mail attachment fails when fetched.

The minted URL looks like:

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 as the identity that minted it; 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.

Uploading attachments the same way

The same ticket store works in the other direction. get-upload-url mints a URL for attaching a file to a draft message or an event, so the bytes go straight from the caller to Graph instead of passing as base64 contentBytes through the agent context:

PUT /attachment?t=<ticket>&dgk=<key-id>&dgx=<expiry>&dgs=<signature>
Content-Length: <file size>

<raw file bytes>

target is the item's attachments collection (/me/messages/{id}/attachments for a draft, /me/events/{id}/attachments for an event); name and contentType are recorded on the attachment. Files under 3 MB are attached inline; larger ones, up to Graph's 150 MB, stream through an upload session in 3.75 MiB chunks, so the server never holds more than one chunk. Content-Length is required. The ticket is single-use and burnt on first presentation whatever the outcome, and the upload is attached as the identity that minted it, exactly as downloads are fetched. An upload ticket presented to GET (or a download ticket to PUT) is refused with the same 404 as any other bad ticket.

Attaching a local file in stdio mode

In stdio mode the server and the files are on the same machine, so no URL is needed. upload-attachment-from-file takes the same target and an absolute filePath, reads the file from disk and attaches it, with the same 3 MB and 150 MB limits. It is the upload counterpart of download-bytes-to-file.

The tool is off until you list the directories it may read from:

MS365_MCP_UPLOAD_DIRS=/Users/me/Documents/invitations:/Users/me/Scans

Entries are absolute paths, separated like PATH (: on macOS and Linux, ; on Windows). A file outside them is refused, and symlinks are resolved first, so a link inside an allowed directory cannot point the read somewhere else. The variable is not read from .env.

Over HTTP the tool is registered only with --http-local-file-tools, like download-bytes-to-file.

Configuration

MS365_MCP_ATTACHMENT_URL_BASE=http://m365-mcp:3000   # required
MS365_MCP_ATTACHMENT_URL_KEY=...                     # optional (or _KEY_FILE=/path), see below
MS365_MCP_ATTACHMENT_URL_KEY_ID=1                    # optional, default 1; needs a key
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.

The signing key is optional. Without one, minted URLs are just /attachment?t=<ticket> and any HTTP client can fetch them; the ticket alone authorises redemption. Set a key only if the fetcher verifies the dgk/dgx/dgs signature (see below), such as a docglean-mcp sidecar, which will refuse an unsigned URL. Setting _KEY_ID without a key, or a _KEY_FILE that is empty or unreadable, fails at startup.

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]

View the full README on GitHub

Details

Repository
github.com/softeria/ms-365-mcp-server
Category
Data & Analytics

Related Data & Analytics MCP servers

View all
1elastic avatarElasticsearchelastic/mcp-server-elasticsearchProvides MCP access to Elasticsearch data, enabling index discovery, mappings, searches, and shard information.662stars
224mlight avatara-share-mcp24mlight/a-share-mcp-is-just-i-needProvides access to A股 stock data, including historical prices, fundamentals, macro data, and analytical reports through an MCP interface.644stars
3cohnen avatarGoogle Adscohnen/mcp-google-adsAn MCP tool that connects Google Ads with Claude AI/Cursor and others, allowing you to analyze your advertising data through natural language conversations. Thi609stars
4saidsurucu avatarBorsasaidsurucu/borsa-mcpProvides access to BIST, US stocks, TEFAS funds, crypto, forex, and macro data for analysis and screening via MCP clients.579stars
5marmotdata avatarMarmot Data Catalogmarmotdata/marmotOpen-source data catalog. Search assets, explore lineage, and find ownership.575stars
6guangxiangdebizi avatarFinanceMCPguangxiangdebizi/financemcpProvides real-time financial data, indicators, and market analyses via MCP protocols across global markets.574stars