mcp-server-microsoft365
# mcp-server-microsoft365
TypeScript MCP server for Microsoft 365 / Entra ID via Microsoft Graph. It runs
app-only (client-credentials) and is intentionally boring good: typed,
documented, read-first, least-privilege, credential-sane, and idempotent where
Graph allows it. Its focus is identity provisioning — create a user, assign a
license, manage group membership — plus the supporting reads you need to do that
safely.
> **Disclaimer:** This is an independent, unofficial project by Borgels. Borgels
> is not affiliated with, endorsed by, or supported by Microsoft. "Microsoft
> 365", "Microsoft Graph", and "Entra ID" are referenced only to describe what
> this server talks to. You need your own Entra ID app registration, and use of
> Microsoft Graph is subject to Microsoft's own terms.
## Scope
Read tools (enabled by default):
- `get_user`, `list_users`
- `list_subscribed_skus` (license lookup and seat availability)
- `list_groups`, `get_group`, `list_group_members`
- `get_user_license_details`
Write tools (disabled by default):
- `create_user`
- `update_user` (PATCH attributes on an existing user, incl. `accountEnabled` to enable/disable — e.g. activate a pre-created hire or disable a leaver)
- `set_manager`
- `assign_license`, `remove_license`
- `add_group_member`, `remove_group_member`
- `set_usage_location`
- `create_temporary_access_pass` (passwordless first sign-in / MFA setup; regenerated until alphanumeric), `delete_temporary_access_pass`
- `activate_pim_role` (just-in-time PIM self-activation of an *eligible* directory role, e.g. Authentication Administrator for TAP — delegated mode only)
## Authentication modes
The server authenticates to Graph in one of three ways, chosen by which options
are set (never as tool arguments):
- **App-only (client credentials)** — `MS_TENANT_ID` + `MS_CLIENT_ID` + `MS_CLIENT_SECRET`. Acts as the app; requires the application permissions below.
- **Delegated (on-behalf-of a user)** — a `refreshToken` option / `MS_REFRESH_TOKEN` (plus the app's `MS_CLIENT_ID`/`MS_CLIENT_SECRET`/`MS_TENANT_ID`). The server mints short-lived **delegated** access tokens via the `refresh_token` grant, so calls act as the consenting user and are **bounded by that user's own roles**. Entra rotates the refresh token on use; the latest is exposed on the client (`latestRefreshToken`) so the host can persist it. `activate_pim_role` only works in this mode.
- **Static token** — a pre-fetched `MS_ACCESS_TOKEN` / `accessToken` (short-circuits the grants).
## Least-privilege application permissions
Grant the app registration only what it needs, then admin-consent:
| Task | Application permission |
| --- | --- |
| Read users and groups | `User.Read.All` |
| Create users | `User.Create` (or `User.ReadWrite.All`) |
| Update users / set manager / enable-disable | `User.ReadWrite.All` |
| Read license/SKU state | `LicenseAssignment.Read.All` |
| Assign / remove licenses | `LicenseAssignment.ReadWrite.All` |
| Manage group membership | `GroupMember.ReadWrite.All` |
| Create / delete Temporary Access Pass | `UserAuthenticationMethod.ReadWrite.All` |
| Read Intune devices (`list_user_devices`, `list_managed_devices`) | `DeviceManagementManagedDevices.Read.All` |
| Read Intune configuration / compliance policies | `DeviceManagementConfiguration.Read.All` |
| JIT PIM role activation (`activate_pim_role`, delegated) | delegated `RoleManagement.ReadWrite.Directory` (bounded by the user's eligible roles) |
`User.Create` is narrower than `User.ReadWrite.All`; use it when you only need to
provision new users. `update_user` and `set_manager` require `User.ReadWrite.All`.
Reading SKUs (`/subscribedSkus`) is covered by `LicenseAssignment.Read.All`.
**Temporary Access Pass** additionally requires the app to hold the **Authentication
Administrator** (or Privileged Authentication Administrator) directory role —
`UserAuthenticationMethod.ReadWrite.All` alone is not sufficient to manage another
user's authentication methods. Assign the role to the app's service principal in
**Entra ID → Roles and administrators**, and ensure the tenant's TAP policy is
enabled (it also caps `lifetimeInMinutes`).
## App registration and admin consent
1. In the Entra admin center, register an application (single tenant).
2. Under **Certificates & secrets**, create a client secret and copy its value.
3. Under **API permissions**, add the Microsoft Graph *Application* permissions
above, then click **Grant admin consent**.
4. Copy the **Directory (tenant) ID** and **Application (client) ID**.
## Setup
Install dependencies and build the CLI:
```sh
npm install
npm run build
```
Provide credentials through the environment. The server never accepts them as
tool arguments.
```sh
export MS_TENANT_ID="contoso.onmicrosoft.com" # or the tenant GUID
export MS_CLIENT_ID="your-application-client-id"
export MS_CLIENT_SECRET="your-client-secret"
```
Optional settings:
```sh
export MS_GRAPH_BASE_URL="https://graph.microsoft.com/v1.0"
export MS_TIMEOUT_MS=30000
# export MS_AUTHORITY_HOST="https://login.microsoftonline.com"
```
If another process manages tokens, provide a bearer token directly:
```sh
export MS_ACCESS_TOKEN="your-access-token"
```
## Claude or Cursor config
Use the stdio server for local MCP clients:
```json
{
"mcpServers": {
"microsoft365": {
"command": "node",
"args": ["/absolute/path/to/mcp-server-microsoft365/dist/transports/stdio.js"],
"env": {
"MS_TENANT_ID": "contoso.onmicrosoft.com",
"MS_CLIENT_ID": "your-application-client-id",
"MS_CLIENT_SECRET": "your-client-secret"
}
}
}
}
```
During development:
```json
{
"mcpServers": {
"microsoft365": {
"command": "npm",
"args": ["run", "dev", "--prefix", "/absolute/path/to/mcp-server-microsoft365"],
"env": {
"MS_TENANT_ID": "contoso.onmicrosoft.com",
"MS_CLIENT_ID": "your-application-client-id",
"MS_CLIENT_SECRET": "your-client-secret"
}
}
}
}
```
## Intune (read)
`list_user_devices`, `list_managed_devices`, `list_device_configurations` and
`list_compliance_policies` read Intune through Graph (`/users/{id}/managedDevices`,
`/deviceManagement/...`). They need the application permissions
`DeviceManagementManagedDevices.Read.All` and `DeviceManagementConfiguration.Read.All`.
Pass `includeAssignments: true` to expand group assignments on policies.
## Write gating
All write and destructive tools (`create_user`, `assign_license`, `add_group_member`,
`create_temporary_access_pass`, ...) are **only registered when `MS_ENABLE_WRITES=true`**.
The default is read-only, so a container started without the flag exposes the seven
read tools and nothing else; callers see write tools as unknown rather than forbidden.
Gating is by tool annotation (`readOnlyHint`), so a new write tool is off by default.
## Streamable HTTP transport
```sh
npm run dev:http
```
The HTTP transport binds to `127.0.0.1` by default and serves `POST /mcp`. Set
`MCP_HTTP_TOKEN` to require bearer auth, and `MCP_ALLOWED_ORIGINS` for
browser-based local clients. `MCP_MAX_BODY_BYTES` defaults to `10485760`
(10 MiB).
## Provisioning workflow
1. `list_subscribed_skus` to see which licenses exist and how many seats are
free.
2. `create_user` with a `displayName` and `userPrincipalName`. A strong
temporary password is generated when you do not supply one, and returned in
the result with `forceChangePasswordNextSignIn` set. Store it securely and
share it out-of-band. `create_user` fails clearly if the UPN already exists.
3. `assign_license` with a friendly name (`E3`), a `skuPartNumber`
(`ENTERPRISEPACK`), or a SKU GUID. Pass `usageLocation` on the same call for a
brand-new user.
4. `add_group_member` to place the user in the right groups. It is idempotent:
an already-a-member response is treated as success.
### The usageLocation gotcha
Microsoft Graph rejects a license assignment for a user with no `usageLocation`
(the classic "License assignment failed because of an invalid usage location"
error). This server sets `usageLocation` **first**:
- If you pass `usageLocation` to `assign_license`, it is `PATCH`ed onto the user
before the license is assigned.
- If you do not, the server reads the user's current `usageLocation` and returns
a clear, actionable error when it is missing — rather than letting Graph fail
cryptically.
`usageLocation` is an ISO 3166-1 alpha-2 country code, e.g. `DK`.
### License resolution
`assign_license` / `remove_license` accept a friendly name, a `skuPartNumber`,
or a SKU GUID. The server fetches `/subscribedSkus` (no `$filter`; it matches
client-side) and:
- matches a GUID directly against `skuId`;
- otherwise matches `skuPartNumber` case-insensitively;
- otherwise maps a friendly name (e.g. `E3` → `ENTERPRISEPACK` / `SPE_E3`,
`E5` → `ENTERPRISEPREMIUM` / `SPE_E5`) to the first candidate the tenant owns;
- checks availability (`prepaidUnits.enabled - consumedUnits`) and refuses to
assign when no seats are free.
## Rate limiting
On HTTP 429, the raised `GraphHttpError` carries the `Retry-After` value and the
Graph `request-id`. The Borgels connector layer uses these to retry.
## Borgels gateway
The `./gateway` subpath exports the Borgels gateway contract:
```ts
import { createMicrosoft365Gateway, microsoft365GatewayTools } from 'mcp-server-microsoft365/gateway';
const gateway = createMicrosoft365Gateway({ tenantId, clientId, clientSecret });
const result = await gateway.callTool('list_subscribed_skus');
```
`microsoft365GatewayTools` lists the same tool surface as the MCP server, with
`riskLevel` and `enabledByDefault` so the gateway can gate writes. Read tools are
`enabledByDefault: true`; every write tool is `enabledByDefault: false`.
## Live smoke test
The normal test suite uses mocked `fetch`. With real credentials in the
environment you can run a read-only live smoke test that counts subscribed SKUs:
```sh
npm run smoke:live
```
## Security and audit
- Credentials are read from environment variables only.
- `MS_GRAPH_BASE_URL` must be `https://`; loopback `http://` is allowed for local
mocks.
- Formatted errors redact Authorization headers, client secrets, access tokens,
and token-like material.
- Write tools are disabled by default and surfaced with write/destructive
annotations.
- The Streamable HTTP transport binds to `127.0.0.1` by default.
Security reports: security@borgels.com.
TDQS
Scored across 18 tools
Each tool targets a specific resource and action, such as get_user vs list_users, assign_license vs remove_license, and create_temporary_access_pass vs delete_temporary_access_pass. There is no meaningful overlap that would cause an agent to select the wrong tool.
All tool names follow a consistent verb_noun snake_case pattern (e.g., get_user, list_groups, assign_license, create_temporary_access_pass). No mixing of conventions or vague verbs.
With 18 tools, the server is slightly above the typical 3-15 range but justifiably so, as it covers user/group management, licensing, temporary access passes, and PIM roles. The count feels appropriate for the broad Microsoft 365 domain without being bloated.
The tool surface covers core user lifecycle (create/get/update/list), license management, group membership, and authentication flows. The main gap is lack of user deletion, but this is workable via update_user disable, and group creation/deletion appears out of scope, making these minor gaps.