Architecture
April 17, 2026 · View on GitHub
This document describes the internal design of ms-365-admin-mcp-server.
High-level flow
+-----------------+
| MCP client | (Claude Desktop, Claude Code, custom agent)
+--------+--------+
|
stdio or HTTP | MCP protocol (JSON-RPC over streams)
v
+--------+--------+
| This server |
| |
| CLI parse |
| Secrets load |
| Auth (MSAL) |
| Tool register |
+--------+--------+
|
HTTPS Graph |
v
+--------+--------+
| Microsoft |
| Graph API |
+-----------------+
Components
Entry point — src/index.ts
- Parses CLI args (
parseArgs). - Handles meta-commands first (
--list-tools,--list-permissions,--list-presets) and exits. - Loads secrets from env or Key Vault (
getSecrets). - Creates the
AuthManager(MSAL confidential client). - If
--verify-login, tests credentials and exits. - Otherwise creates
AdminGraphServer, initializes, and starts.
CLI — src/cli.ts
Thin wrapper over commander. Adds:
- Preset expansion (
--preset identity,securitybecomes a regex viagetCombinedPresetPattern) - Read-only default (only
--allow-writesenables mutations) - Regex length cap (500 chars, ReDoS prevention)
MS365_ADMIN_MCP_CLOUD_TYPEpropagation via env
Secrets — src/secrets.ts
Two providers:
EnvironmentSecretsProvider— readsMS365_ADMIN_MCP_*env vars. Default.KeyVaultSecretsProvider— fetches from Azure Key Vault ifMS365_ADMIN_MCP_KEYVAULT_URLis set. UsesDefaultAzureCredential(local CLI login, managed identity, workload identity, etc.).
Secrets are cached in-process after first retrieval. clearSecretsCache() exists for tests.
Auth — src/auth.ts
Wraps @azure/msal-node's ConfidentialClientApplication. Responsibilities:
- Validate inputs early (
clientId,clientSecret, specific tenant — rejectscommon). - Acquire tokens via
acquireTokenByClientCredentialwith scopehttps://graph.microsoft.com/.default. - Cache the token until 60 seconds before expiry.
testLogin()hits/v1.0/organizationto validate end-to-end.
Also exports buildPermissionsFromEndpoints(enabledToolsPattern, readOnly) — collects the unique set of appPermissions from endpoints.json based on filters, used by --list-permissions.
Graph client — src/graph-client.ts
Thin fetch wrapper that:
- Adds the bearer token from
AuthManager - Applies the cloud-specific base URL (
getCloudEndpoints) - Normalizes errors into MCP-friendly
CallToolResultshapes
Tool registration — src/graph-tools.ts
The core of the server. For each endpoint definition:
- Build a Zod schema from the generated client's parameter metadata (path, query, header, body, plus OData-friendly descriptions for
$filter,$select,$top). - Compose a tool description, optionally augmented with
llmTipand aRISK LEVELwarning for non-GET operations (graph-tools.ts:290-303). - Register via
server.tool(alias, description, paramSchema, hints, handler). - The handler (
executeGraphTool) stitches params into the path, query string, and body, then callsgraphClient.graphRequest.
Skipping logic:
readOnlymode skips any non-GET.enabledToolsRegex(from--preset/--enabled-tools/ENABLED_TOOLS) filters by tool name.
Security hardening inside the handler (grep for SEC- comments):
SEC-A— path-param skip-encoding validated against traversalSEC-D— query-string encoding viaencodeURIComponentexcludeResponse: trueopt-in to drop the body for operations where the LLM only needs success/failure
Presets — src/tool-categories.ts
A static record mapping preset names to { pattern: RegExp, description }. getCombinedPresetPattern(names) unions their regex. Matching happens at tool-registration time against the toolName string.
Stdio server — src/server.ts
AdminGraphServer wires up:
McpServerfrom the MCP SDKGraphClientinstance- Tool registration via
registerGraphTools - Transport selection based on
--transport
For stdio it connects StdioServerTransport. For HTTP it delegates to src/http-server.ts.
HTTP server — src/http-server.ts
Express app with:
- Security headers
- 100 KB body limit
- 100 req/min rate limit on
/mcp - JWT validation middleware on
/mcp(seetoken-validator.ts) GET /healthunauthenticated- Stateless MCP transport (
StreamableHTTPServerTransport) — one fresh transport per request
Token validation — src/token-validator.ts
Per-tenant jwks-rsa client with caching (10 min). Verifies:
- RS256 signature
iss= tenant v2 endpointtidmatches configured tenantappid/azpinallowedClientIdsaudmatches expected (if provided)- 30 s clock tolerance
Failures are logged at warn with a short reason (no token echoing).
Cloud config — src/cloud-config.ts
Maps CloudType (global / china) to { authority, graphApi } endpoints. Validates the input and defaults to global.
Logger — src/logger.ts
Winston logger. Default level depends on -v flag. Redacts sensitive fields.
Data sources
src/endpoints.json
Single source of truth for tool definitions. Each entry:
{
"toolName": "list-users",
"pathPattern": "/users",
"method": "GET",
"appPermissions": ["User.Read.All"],
"llmTip": "Use $filter='userType eq ''Guest''' to list guests",
"riskLevel": "medium", // only for non-GET
"skipEncoding": ["userId"], // optional: path params that should not be URL-encoded
"contentType": "application/json",
"acceptType": "application/json",
"returnDownloadUrl": false,
}
endpoints.json is hand-curated (~515 entries). It is the thing a contributor edits.
src/generated/
Auto-generated by bin/generate-graph-client.mjs from the Microsoft Graph OpenAPI spec. Contains the typed zod client. Never hand-edit.
Code generation pipeline
npm run generate runs bin/generate-graph-client.mjs:
- Download the latest Graph OpenAPI spec (
openapi/openapi.yaml). - Trim it to only the paths listed in
src/endpoints.json(openapi-trimmed.yaml). - Generate a zod-typed client via
openapi-zod-clientintosrc/generated/.
This means tool parameter validation is derived automatically from Graph's own schema — you get accurate types for body schemas, path params, query params, enums, etc., without writing them by hand.
The runtime tool registration in graph-tools.ts then combines:
- Generated schema (from OpenAPI) → parameter validation
endpoints.jsonmetadata → risk level, llmTip, skipEncoding, content typetool-categories.tsregexes → preset filtering
Risk classification
Every non-GET tool carries a riskLevel. See RISK_MODEL.md for the rubric. Risk levels are surfaced to the LLM through the tool description (graph-tools.ts:295-302) and serve as machine-readable guardrails for policy enforcement (e.g., an operator wrapper can require explicit human confirmation for critical).
Write protection
Two layers:
- Registration-time. If the server is started without
--allow-writes, non-GET tools are skipped entirely during registration (graph-tools.ts:232-235). They are not exposed to the MCP client. - Permission-time.
--list-permissionsonly emits write scopes when--allow-writesis set, so the operator granting admin consent naturally follows least privilege.
Extensibility points
- New tools — add entries to
endpoints.json, regenerate, done (see CONTRIBUTING.md). - New presets — add regex to
tool-categories.ts. - New secret backend — implement
SecretsProviderinsecrets.ts. - New transport — follow the
http-server.tspattern and wire it inserver.ts. - New cloud — add endpoints to
cloud-config.tsand extendparseCloudType.
Testing
- Vitest (
test/). Unit tests focus on auth validation, tool parameter parsing, preset regex combinations, token validation, risk-level serialization. - MCP Inspector for interactive E2E testing (
npm run inspector). --verify-loginas a smoke test against a real tenant.
What's intentionally absent
- No caching of Graph responses. Freshness matters for admin data.
- No automatic retry on 429. Callers / proxies should handle throttling.
- No tool result summarization. The raw Graph response is returned; the LLM decides what to do with it.
- No audit log of tool invocations by this server. Graph itself logs every mutation (directory audits, Intune audit, Cloud PC audit); use those for traceability.
- No per-user authorization. With application permissions, all calls run as the app. User-scoped authorization belongs in a delegated-permissions server (see Softeria/ms-365-mcp-server).
Dependencies
Runtime:
@azure/msal-node— MSAL for client credentials@modelcontextprotocol/sdk— MCP protocolcommander— CLI parsingdotenv— local.envloadingexpress,express-rate-limit— HTTP transportjs-yaml— OpenAPI parsingjsonwebtoken,jwks-rsa— JWT validation (HTTP mode)winston— loggingzod— schema validation (via generated client)
Optional:
@azure/identity,@azure/keyvault-secrets— Key Vault secret provider
Dev:
tsup— buildvitest— testseslint,prettier— linting and formattingtsx— dev runner