@alfe.ai/microsoft-mcp 0.1.7 → 0.1.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/server.js CHANGED
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env node
2
+ import { createRequire } from "node:module";
2
3
  import { realpathSync } from "node:fs";
3
4
  import { fileURLToPath, pathToFileURL } from "node:url";
4
5
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
@@ -6,283 +7,486 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
6
7
  import { z } from "zod";
7
8
  import { resolveConfig } from "@alfe.ai/config";
8
9
  import { AgentApiClient } from "@alfe.ai/agent-api-client";
9
- //#region src/server.ts
10
- /**
11
- * @alfe.ai/microsoft-mcp — self-contained Microsoft 365 MCP server
12
- *
13
- * A runtime-agnostic stdio MCP server (works on OpenClaw AND Hermes) that gives
14
- * an agent multi-account Microsoft 365 access. Declared via the manifest's
15
- * top-level `mcp_servers:` block (`command: npx, args: [-y, @alfe.ai/microsoft-mcp@x]`,
16
- * `requires_credentials: microsoft`), like every other OAuth provider
17
- * (Notion / Xero / GitHub / Atlassian / MYOB).
18
- *
19
- * Tools (multi-account by design — every credential-touching tool requires an
20
- * explicit `email` so the LLM picks the target account per call):
21
- * - microsoft_list_accounts — list connected Microsoft 365 accounts
22
- * - microsoft_run_command — run a Microsoft Graph request (requires email)
23
- * - microsoft_disconnect_account — disconnect an account (requires email)
24
- *
25
- * ── Why self-contained (not a proxy around an upstream MCP) ────────────────
26
- * Notion/Xero/etc. WRAP an official upstream MCP server that accepts an
27
- * injected credential. Microsoft has NO suitable upstream MCP that accepts an
28
- * injected DELEGATED access token — the community M365 MCP servers do their
29
- * own device/browser OAuth. So this server exposes its own tools and calls
30
- * `graph.microsoft.com` REST directly.
31
- *
32
- * ── Why direct Graph REST, not the `mgc` CLI ───────────────────────────────
33
- * The Google Workspace sibling shells out to `gws` because `gws` accepts a
34
- * hand-written `authorized_user` refresh-token file and self-refreshes
35
- * non-interactively. `mgc` (Microsoft Graph CLI) does NOT: its auth strategies
36
- * are DeviceCode / InteractiveBrowser (both need a human at a browser —
37
- * impossible on a headless agent VM) and ClientCertificate / Environment /
38
- * ManagedIdentity (all APP-ONLY client-credentials, which need admin-consented
39
- * *application* permissions, not the DELEGATED authorization-code refresh token
40
- * our connect flow produces). mgc persists tokens in a proprietary MSAL binary
41
- * cache (needs a keyring on headless Linux) with no hand-writable credentials
42
- * file and no config-dir override env var — so per-account isolation via config
43
- * dirs (the gws pattern) is impossible.
44
- *
45
- * Token refresh is server-mediated: the connect backend owns token refresh
46
- * (`refreshMicrosoftAccountToken`) so the client secret never leaves the server
47
- * and per-(tenant,user) refresh-token rotation stays server-side.
48
- */
10
+ //#region src/boundary.ts
49
11
  const GRAPH_BASE = "https://graph.microsoft.com/v1.0";
12
+ const MAX_ACCOUNTS = 128;
13
+ const MAX_IDENTIFIER_CHARS = 512;
14
+ const MAX_EMAIL_CHARS = 320;
15
+ const MAX_DISPLAY_NAME_CHARS = 512;
16
+ const MAX_ACCESS_TOKEN_CHARS = 64 * 1024;
17
+ const MAX_COMMAND_CHARS = 256 * 1024;
18
+ const MAX_GRAPH_PATH_CHARS = 16 * 1024;
19
+ const MAX_REQUEST_BODY_BYTES = 1024 * 1024;
20
+ const MAX_RESPONSE_BYTES = 5 * 1024 * 1024;
21
+ const MAX_ERROR_RESPONSE_BYTES = 256 * 1024;
22
+ const MAX_JSON_DEPTH = 64;
23
+ const MAX_JSON_NODES = 5e4;
50
24
  const TOKEN_SKEW_MS = 120 * 1e3;
51
- let client = null;
52
- let cachedAccounts = [];
53
- function log(msg) {
54
- process.stderr.write(`[microsoft-mcp] ${msg}\n`);
55
- }
56
- function getClient() {
57
- if (!client) {
58
- const config = resolveConfig();
59
- client = new AgentApiClient({
60
- apiKey: config.apiKey,
61
- apiUrl: config.apiUrl
62
- });
63
- }
64
- return client;
65
- }
66
- async function refreshAccountCache() {
67
- cachedAccounts = (await getClient().getMicrosoftAccounts()).accounts.map((a) => ({
68
- connectionId: a.connectionId,
69
- accountIdentifier: a.accountIdentifier,
70
- email: a.email,
71
- displayName: a.displayName ?? void 0,
72
- connectedAt: a.connectedAt,
73
- microsoftTenantId: a.microsoftTenantId || void 0,
74
- accessToken: a.accessToken,
75
- accessTokenExpiresAt: a.accessTokenExpiresAt
76
- }));
77
- return cachedAccounts;
78
- }
79
- function findAccount(email) {
80
- const account = cachedAccounts.find((a) => a.email === email || a.accountIdentifier === email);
81
- if (!account) throw new Error(`Microsoft account "${email}" not found. Available: ${cachedAccounts.map((a) => a.email).join(", ")}`);
82
- return account;
83
- }
84
- /**
85
- * Resolve an account by email (or accountIdentifier), refreshing the cache once
86
- * if it's not already there. This server is long-lived, so an account connected
87
- * AFTER the last cache fill would otherwise be invisible until a
88
- * microsoft_list_accounts call — this closes that stale window. Throws
89
- * findAccount's descriptive error if the account still isn't found after a refresh.
90
- */
91
- async function resolveAccount(email) {
92
- const hit = cachedAccounts.find((a) => a.email === email || a.accountIdentifier === email);
93
- if (hit) return hit;
94
- await refreshAccountCache();
95
- return findAccount(email);
96
- }
97
- function tokenIsExpired(account) {
98
- if (!account.accessToken) return true;
99
- if (!account.accessTokenExpiresAt) return false;
100
- const expiresAt = Date.parse(account.accessTokenExpiresAt);
101
- if (Number.isNaN(expiresAt)) return false;
102
- return expiresAt - Date.now() <= TOKEN_SKEW_MS;
103
- }
104
- /**
105
- * Returns a currently-valid access token for the account, refreshing it via the
106
- * connect service (which owns the client secret + refresh-token rotation) when
107
- * the cached token is missing or near expiry. Setting `force` bypasses the
108
- * expiry check — used to recover from a 401 on a token we believed was valid.
109
- */
110
- async function ensureAccessToken(account, force = false) {
111
- if (!force && !tokenIsExpired(account)) return account.accessToken;
112
- const refreshed = await getClient().refreshMicrosoftAccountToken(account.accountIdentifier);
113
- account.accessToken = refreshed.accessToken;
114
- account.accessTokenExpiresAt = refreshed.accessTokenExpiresAt;
115
- return account.accessToken;
116
- }
117
- /**
118
- * Parse the `command` string into a Graph request. Accepts a compact
119
- * "METHOD /path" line (optionally followed by a JSON body), so the LLM can
120
- * express any Graph operation the way it would a curl call:
121
- * - "GET /me/messages?$top=5"
122
- * - "GET /me"
123
- * - "POST /me/sendMail {\"message\":{...}}"
124
- * A bare "/path" (or "me/messages") is treated as a GET.
125
- */
25
+ const SUPPORTED_METHODS = new Set([
26
+ "GET",
27
+ "POST",
28
+ "PATCH",
29
+ "PUT",
30
+ "DELETE"
31
+ ]);
32
+ const UNSAFE_KEYS = new Set([
33
+ "__proto__",
34
+ "prototype",
35
+ "constructor"
36
+ ]);
126
37
  function parseCommand(command) {
127
- const trimmed = command.trim();
38
+ const trimmed = requireBoundedString(command, "command", MAX_COMMAND_CHARS).trim();
128
39
  if (!trimmed) throw new Error("Command cannot be empty");
129
- const methodMatch = /^(GET|POST|PATCH|PUT|DELETE)\s+(.*)$/is.exec(trimmed);
130
40
  let method = "GET";
131
41
  let rest = trimmed;
132
- if (methodMatch) {
133
- method = methodMatch[1].toUpperCase();
134
- rest = methodMatch[2].trim();
42
+ const firstToken = /^(\S+)\s+([\s\S]+)$/u.exec(trimmed);
43
+ if (firstToken && /^[A-Za-z]+$/u.test(firstToken[1])) {
44
+ const candidate = firstToken[1].toUpperCase();
45
+ if (!SUPPORTED_METHODS.has(candidate)) throw new Error(`Unsupported Graph method: ${candidate.slice(0, 32)}`);
46
+ method = candidate;
47
+ rest = firstToken[2].trim();
135
48
  }
136
49
  let path = rest;
137
50
  let body;
138
- const bodyStart = rest.search(/[{[]/);
139
- if (bodyStart > 0) {
140
- path = rest.slice(0, bodyStart).trim();
141
- const rawBody = rest.slice(bodyStart).trim();
51
+ const bodyDelimiter = /\s+(\{|\[)/u.exec(rest);
52
+ if (bodyDelimiter) {
53
+ path = rest.slice(0, bodyDelimiter.index).trim();
54
+ const rawBody = rest.slice(bodyDelimiter.index).trim();
55
+ if (Buffer.byteLength(rawBody, "utf8") > 1048576) throw new Error(`Graph request body exceeds ${String(MAX_REQUEST_BODY_BYTES)} bytes`);
142
56
  try {
143
57
  body = JSON.parse(rawBody);
144
58
  } catch {
145
- throw new Error(`Invalid JSON body in command: ${rawBody.slice(0, 120)}`);
59
+ throw new Error("Invalid JSON body in command");
146
60
  }
147
- }
148
- path = path.trim();
149
- if (!path) throw new Error("Command must include a Graph path (e.g. 'GET /me/messages')");
150
- if (/^[a-z][a-z0-9+.-]*:/i.test(path) || path.startsWith("//")) throw new Error("Graph path must be a relative path (e.g. '/me/messages'), not an absolute or protocol-relative URL");
151
- if (!path.startsWith("/")) path = `/${path}`;
61
+ assertJsonBudget(body, "Graph request body");
62
+ } else if (/\s/u.test(rest)) throw new Error("Graph paths cannot contain unescaped whitespace");
63
+ if (method === "GET" && body !== void 0) throw new Error("GET Graph requests cannot include a body");
64
+ path = validateGraphPath(path);
152
65
  return {
153
66
  method,
154
67
  path,
155
68
  body
156
69
  };
157
70
  }
158
- /**
159
- * Resolve a relative Graph path to an absolute graph.microsoft.com URL and
160
- * assert the host cannot have been escaped. Defense-in-depth alongside
161
- * parseCommand's absolute/protocol-relative rejection — the bearer token must
162
- * never be sent anywhere but graph.microsoft.com.
163
- */
164
71
  function resolveGraphUrl(path) {
165
- const url = `${GRAPH_BASE}${path.startsWith("/") ? path : `/${path}`}`;
166
- let hostname;
72
+ const checkedPath = validateGraphPath(path);
73
+ const url = new URL(checkedPath.slice(1), `${GRAPH_BASE}/`);
74
+ const pathname = url.pathname;
75
+ if (url.protocol !== "https:" || url.hostname.toLowerCase() !== "graph.microsoft.com" || url.username || url.password || url.hash || pathname !== "/v1.0" && !pathname.startsWith("/v1.0/")) throw new Error("Refusing to send a Microsoft token outside the Graph v1.0 authority");
76
+ return url.toString();
77
+ }
78
+ function tokenIsExpired(account, now = Date.now()) {
79
+ if (!account.accessToken) return true;
80
+ if (!account.accessTokenExpiresAt) return false;
81
+ const expiresAt = Date.parse(account.accessTokenExpiresAt);
82
+ if (Number.isNaN(expiresAt)) return true;
83
+ return expiresAt - now <= TOKEN_SKEW_MS;
84
+ }
85
+ function normalizeMicrosoftAccounts(value) {
86
+ const root = requireRecord(value, "Microsoft accounts response");
87
+ if (!Array.isArray(root.accounts) || root.accounts.length > MAX_ACCOUNTS) throw new Error(`Microsoft accounts response must contain at most ${String(MAX_ACCOUNTS)} accounts`);
88
+ const selectorOwners = /* @__PURE__ */ new Map();
89
+ const connectionIds = /* @__PURE__ */ new Set();
90
+ return root.accounts.map((raw, index) => {
91
+ const record = requireRecord(raw, `Microsoft account ${String(index)}`);
92
+ const connectionId = requireIdentifier(record.connectionId, "Microsoft account connectionId", MAX_IDENTIFIER_CHARS);
93
+ if (connectionIds.has(connectionId)) throw new Error(`Duplicate Microsoft connection: ${connectionId}`);
94
+ connectionIds.add(connectionId);
95
+ const accountIdentifier = requireIdentifier(record.accountIdentifier, "Microsoft account accountIdentifier", MAX_IDENTIFIER_CHARS);
96
+ const email = requireIdentifier(typeof record.email === "string" && record.email.length > 0 ? record.email : accountIdentifier, "Microsoft account email", MAX_EMAIL_CHARS);
97
+ const account = {
98
+ connectionId,
99
+ accountIdentifier,
100
+ email,
101
+ displayName: optionalBoundedString(record.displayName, "Microsoft account displayName", MAX_DISPLAY_NAME_CHARS),
102
+ connectedAt: optionalBoundedString(record.connectedAt, "Microsoft account connectedAt", 128),
103
+ microsoftTenantId: optionalBoundedString(record.microsoftTenantId, "Microsoft account microsoftTenantId", MAX_IDENTIFIER_CHARS),
104
+ accessToken: validateAccessToken(record.accessToken ?? "", true),
105
+ accessTokenExpiresAt: validateExpiry(record.accessTokenExpiresAt ?? "")
106
+ };
107
+ for (const selector of [email, accountIdentifier]) {
108
+ const normalized = normalizeAccountSelector(selector);
109
+ const owner = selectorOwners.get(normalized);
110
+ if (owner !== void 0 && owner !== connectionId) throw new Error(`Microsoft account selector is ambiguous: ${selector.slice(0, 128)}`);
111
+ selectorOwners.set(normalized, connectionId);
112
+ }
113
+ return account;
114
+ });
115
+ }
116
+ function normalizeAccountSelector(value) {
117
+ const normalized = requireBoundedString(value, "Microsoft account selector", MAX_IDENTIFIER_CHARS).trim().toLowerCase();
118
+ if (!normalized) throw new Error("Microsoft account selector cannot be empty");
119
+ return normalized;
120
+ }
121
+ function validateRefreshResult(value) {
122
+ const record = requireRecord(value, "Microsoft token refresh response");
123
+ return {
124
+ accessToken: validateAccessToken(record.accessToken, false),
125
+ accessTokenExpiresAt: validateExpiry(record.accessTokenExpiresAt ?? "")
126
+ };
127
+ }
128
+ function validateRequestTimeout(value) {
129
+ if (!Number.isInteger(value) || value < 1e3 || value > 12e4) throw new Error("Microsoft Graph request timeout must be between 1000 and 120000 milliseconds");
130
+ return value;
131
+ }
132
+ async function readResponseText(response, maxBytes) {
133
+ const declaredLength = response.headers.get("content-length");
134
+ if (declaredLength !== null) {
135
+ const length = Number(declaredLength);
136
+ if (!Number.isSafeInteger(length) || length < 0 || length > maxBytes) {
137
+ await response.body?.cancel().catch(() => void 0);
138
+ throw new Error(`Microsoft Graph response exceeds ${String(maxBytes)} bytes`);
139
+ }
140
+ }
141
+ if (!response.body) return "";
142
+ const reader = response.body.getReader();
143
+ const chunks = [];
144
+ let total = 0;
167
145
  try {
168
- hostname = new URL(url).hostname;
169
- } catch {
170
- throw new Error(`Invalid Graph path: ${path.slice(0, 120)}`);
146
+ for (;;) {
147
+ const chunk = await reader.read();
148
+ if (chunk.done) break;
149
+ if (chunk.value === void 0) throw new Error("Microsoft Graph returned an invalid response chunk");
150
+ total += chunk.value.byteLength;
151
+ if (total > maxBytes) {
152
+ await reader.cancel("response exceeds limit");
153
+ throw new Error(`Microsoft Graph response exceeds ${String(maxBytes)} bytes`);
154
+ }
155
+ chunks.push(chunk.value);
156
+ }
157
+ } finally {
158
+ reader.releaseLock();
171
159
  }
172
- if (hostname.toLowerCase() !== "graph.microsoft.com") throw new Error(`Refusing to send a Microsoft token to non-Graph host: ${hostname}`);
173
- return url;
160
+ return Buffer.concat(chunks.map((chunk) => Buffer.from(chunk)), total).toString("utf8");
161
+ }
162
+ function assertJsonBudget(value, label) {
163
+ const stack = [{
164
+ value,
165
+ depth: 0
166
+ }];
167
+ let nodes = 0;
168
+ while (stack.length > 0) {
169
+ const current = stack.pop();
170
+ if (!current) break;
171
+ nodes += 1;
172
+ if (nodes > MAX_JSON_NODES || current.depth > MAX_JSON_DEPTH) throw new Error(`${label} is too deeply or broadly nested`);
173
+ if (current.value === null || typeof current.value !== "object") continue;
174
+ if (Array.isArray(current.value)) {
175
+ for (const child of current.value) stack.push({
176
+ value: child,
177
+ depth: current.depth + 1
178
+ });
179
+ continue;
180
+ }
181
+ for (const [key, child] of Object.entries(current.value)) {
182
+ if (UNSAFE_KEYS.has(key)) throw new Error(`${label} contains an unsafe object key`);
183
+ stack.push({
184
+ value: child,
185
+ depth: current.depth + 1
186
+ });
187
+ }
188
+ }
189
+ }
190
+ function safeErrorMessage(error, secrets = []) {
191
+ let output = (error instanceof Error ? error.message : String(error)).slice(0, 4096).replace(/\b(Bearer|Basic)\s+[A-Za-z0-9._~+/=-]+/giu, "$1 [REDACTED]").replace(/\b(api[_-]?key|access[_-]?token|refresh[_-]?token|secret|password)\s*[=:]\s*[^\s,;]+/giu, "$1=[REDACTED]").replace(/https?:\/\/[^\s/@:]+:[^\s/@]+@/giu, "https://[REDACTED]@").replace(/\balfe_[A-Za-z0-9_-]{8,}/gu, "[REDACTED]");
192
+ for (const secret of secrets) if (secret.length >= 4) output = output.split(secret).join("[REDACTED]");
193
+ return flattenControls(output);
194
+ }
195
+ function validateGraphPath(value) {
196
+ let path = requireBoundedString(value, "Graph path", MAX_GRAPH_PATH_CHARS).trim();
197
+ if (!path) throw new Error("Command must include a Graph path (e.g. 'GET /me/messages')");
198
+ if (hasControlCharacters(path) || /\s/u.test(path)) throw new Error("Graph paths cannot contain unescaped whitespace or control characters");
199
+ if (/^[a-z][a-z0-9+.-]*:/iu.test(path) || path.startsWith("//")) throw new Error("Graph path must be a relative path, not an absolute or protocol-relative URL");
200
+ if (!path.startsWith("/")) path = `/${path}`;
201
+ return path;
202
+ }
203
+ function validateAccessToken(value, allowEmpty) {
204
+ if (allowEmpty && value === "") return "";
205
+ const token = requireBoundedString(value, "Microsoft access token", MAX_ACCESS_TOKEN_CHARS);
206
+ if (token !== token.trim() || /\s/u.test(token)) throw new Error("Microsoft access token is invalid");
207
+ return token;
208
+ }
209
+ function validateExpiry(value) {
210
+ if (value === "") return "";
211
+ const expiry = requireBoundedString(value, "Microsoft access-token expiry", 128);
212
+ if (Number.isNaN(Date.parse(expiry))) throw new Error("Microsoft access-token expiry is invalid");
213
+ return expiry;
174
214
  }
175
- async function graphRequest(account, spec) {
176
- const url = resolveGraphUrl(spec.path);
177
- const doFetch = async (token) => fetch(url, {
178
- method: spec.method,
179
- headers: {
180
- Authorization: `Bearer ${token}`,
181
- ...spec.body !== void 0 ? { "Content-Type": "application/json" } : {}
182
- },
183
- ...spec.body !== void 0 ? { body: JSON.stringify(spec.body) } : {}
215
+ function requireRecord(value, label) {
216
+ if (value === null || typeof value !== "object" || Array.isArray(value)) throw new Error(`${label} must be an object`);
217
+ return value;
218
+ }
219
+ function requireBoundedString(value, label, maxChars) {
220
+ if (typeof value !== "string" || value.length < 1 || value.length > maxChars || hasControlCharacters(value)) throw new Error(`${label} must contain 1 to ${String(maxChars)} non-control characters`);
221
+ return value;
222
+ }
223
+ function requireIdentifier(value, label, maxChars) {
224
+ const checked = requireBoundedString(value, label, maxChars);
225
+ if (checked !== checked.trim()) throw new Error(`${label} cannot have leading or trailing whitespace`);
226
+ return checked;
227
+ }
228
+ function optionalBoundedString(value, label, maxChars) {
229
+ if (value === void 0 || value === null || value === "") return void 0;
230
+ return requireBoundedString(value, label, maxChars);
231
+ }
232
+ function hasControlCharacters(value) {
233
+ return Array.from(value).some((character) => {
234
+ const codePoint = character.codePointAt(0) ?? 0;
235
+ return codePoint < 32 || codePoint === 127;
184
236
  });
185
- let token = await ensureAccessToken(account);
186
- let response = await doFetch(token);
187
- if (response.status === 401) {
188
- token = await ensureAccessToken(account, true);
189
- response = await doFetch(token);
237
+ }
238
+ function flattenControls(value) {
239
+ let output = "";
240
+ let previousWasControl = false;
241
+ for (const character of value) {
242
+ const codePoint = character.codePointAt(0) ?? 0;
243
+ const isControl = codePoint < 32 || codePoint === 127;
244
+ if (isControl) {
245
+ if (!previousWasControl) output += " ";
246
+ } else output += character;
247
+ previousWasControl = isControl;
248
+ }
249
+ return output;
250
+ }
251
+ const SERVER_VERSION = validatePackageVersion(createRequire(import.meta.url)("../package.json").version);
252
+ const DEFAULT_REQUEST_TIMEOUT_MS = 2e4;
253
+ var MicrosoftRuntime = class {
254
+ client;
255
+ accounts = [];
256
+ cacheRefreshPromise;
257
+ tokenRefreshes = /* @__PURE__ */ new Map();
258
+ tokenVersions = /* @__PURE__ */ new Map();
259
+ fetchImpl;
260
+ now;
261
+ requestTimeoutMs;
262
+ constructor(options) {
263
+ this.client = options.client;
264
+ this.fetchImpl = options.fetchImpl ?? fetch;
265
+ this.now = options.now ?? Date.now;
266
+ this.requestTimeoutMs = validateRequestTimeout(options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS);
267
+ }
268
+ async listAccounts() {
269
+ return this.refreshAccountCache();
270
+ }
271
+ async runCommand(email, command) {
272
+ const account = await this.resolveAccount(email);
273
+ const spec = parseCommand(command);
274
+ return {
275
+ account: account.email,
276
+ request: `${spec.method} ${spec.path}`,
277
+ result: await this.graphRequest(account, spec)
278
+ };
279
+ }
280
+ async disconnectAccount(email) {
281
+ const account = await this.resolveAccount(email);
282
+ await this.getClient().disconnectMicrosoftAccount(account.accountIdentifier);
283
+ return {
284
+ account,
285
+ remainingAccounts: await this.refreshAccountCache(true)
286
+ };
287
+ }
288
+ getClient() {
289
+ if (!this.client) {
290
+ const config = resolveConfig();
291
+ this.client = new AgentApiClient({
292
+ apiKey: config.apiKey,
293
+ apiUrl: config.apiUrl
294
+ });
295
+ }
296
+ return this.client;
297
+ }
298
+ async refreshAccountCache(force = false) {
299
+ if (this.cacheRefreshPromise) {
300
+ if (!force) return this.cacheRefreshPromise;
301
+ await this.cacheRefreshPromise;
302
+ }
303
+ const versionsAtStart = new Map(this.tokenVersions);
304
+ const operation = (async () => {
305
+ const next = normalizeMicrosoftAccounts(await this.getClient().getMicrosoftAccounts());
306
+ for (const account of next) {
307
+ const versionBefore = versionsAtStart.get(account.connectionId) ?? 0;
308
+ if ((this.tokenVersions.get(account.connectionId) ?? 0) !== versionBefore) {
309
+ const current = this.accounts.find((item) => item.connectionId === account.connectionId);
310
+ if (current) {
311
+ account.accessToken = current.accessToken;
312
+ account.accessTokenExpiresAt = current.accessTokenExpiresAt;
313
+ }
314
+ }
315
+ }
316
+ this.accounts = next;
317
+ return next;
318
+ })();
319
+ this.cacheRefreshPromise = operation;
320
+ try {
321
+ return await operation;
322
+ } finally {
323
+ if (this.cacheRefreshPromise === operation) this.cacheRefreshPromise = void 0;
324
+ }
325
+ }
326
+ async resolveAccount(selector) {
327
+ const normalized = normalizeAccountSelector(selector);
328
+ const find = () => this.accounts.find((account) => normalizeAccountSelector(account.email) === normalized || normalizeAccountSelector(account.accountIdentifier) === normalized);
329
+ const hit = find();
330
+ if (hit) return hit;
331
+ await this.refreshAccountCache();
332
+ const refreshed = find();
333
+ if (refreshed) return refreshed;
334
+ throw new Error(`Microsoft account "${selector.slice(0, 128)}" not found. Available: ${this.accounts.map((account) => account.email).join(", ")}`);
335
+ }
336
+ ensureAccessToken(account, force = false) {
337
+ if (!force && !tokenIsExpired(account, this.now())) return Promise.resolve(account.accessToken);
338
+ const existing = this.tokenRefreshes.get(account.connectionId);
339
+ if (existing) return existing;
340
+ const refresh = (async () => {
341
+ const next = validateRefreshResult(await this.getClient().refreshMicrosoftAccountToken(account.accountIdentifier));
342
+ this.updateAccountToken(account.connectionId, next.accessToken, next.accessTokenExpiresAt);
343
+ account.accessToken = next.accessToken;
344
+ account.accessTokenExpiresAt = next.accessTokenExpiresAt;
345
+ return next.accessToken;
346
+ })();
347
+ this.tokenRefreshes.set(account.connectionId, refresh);
348
+ const cleanup = () => {
349
+ if (this.tokenRefreshes.get(account.connectionId) === refresh) this.tokenRefreshes.delete(account.connectionId);
350
+ };
351
+ refresh.then(cleanup, cleanup);
352
+ return refresh;
190
353
  }
191
- const text = await response.text();
192
- let data = text;
193
- if (text) try {
194
- data = JSON.parse(text);
195
- } catch {}
196
- else data = null;
354
+ updateAccountToken(connectionId, accessToken, expiresAt) {
355
+ const current = this.accounts.find((account) => account.connectionId === connectionId);
356
+ if (current) {
357
+ current.accessToken = accessToken;
358
+ current.accessTokenExpiresAt = expiresAt;
359
+ }
360
+ this.tokenVersions.set(connectionId, (this.tokenVersions.get(connectionId) ?? 0) + 1);
361
+ }
362
+ async graphRequest(account, spec) {
363
+ const url = resolveGraphUrl(spec.path);
364
+ const encodedBody = encodeRequestBody(spec.body);
365
+ const doFetch = async (accessToken) => {
366
+ try {
367
+ return await this.fetchImpl(url, {
368
+ method: spec.method,
369
+ headers: {
370
+ Authorization: `Bearer ${accessToken}`,
371
+ ...encodedBody === void 0 ? {} : { "Content-Type": "application/json" }
372
+ },
373
+ body: encodedBody,
374
+ redirect: "error",
375
+ signal: AbortSignal.timeout(this.requestTimeoutMs)
376
+ });
377
+ } catch (error) {
378
+ throw new Error(`Microsoft Graph ${spec.method} ${new URL(url).pathname} request failed`, { cause: error });
379
+ }
380
+ };
381
+ let accessToken = await this.ensureAccessToken(account);
382
+ let response = await doFetch(accessToken);
383
+ if (response.status === 401) {
384
+ await response.body?.cancel().catch(() => void 0);
385
+ accessToken = await this.ensureAccessToken(account, true);
386
+ response = await doFetch(accessToken);
387
+ }
388
+ const text = await readResponseText(response, response.ok ? MAX_RESPONSE_BYTES : MAX_ERROR_RESPONSE_BYTES);
389
+ let data = null;
390
+ if (text) try {
391
+ data = JSON.parse(text);
392
+ assertJsonBudget(data, "Microsoft Graph response");
393
+ } catch (error) {
394
+ if (!response.ok && error instanceof SyntaxError) data = { message: "Microsoft Graph returned a non-JSON error response" };
395
+ else if (error instanceof SyntaxError) throw new Error("Microsoft Graph returned invalid JSON", { cause: error });
396
+ else throw error;
397
+ }
398
+ return {
399
+ status: response.status,
400
+ ok: response.ok,
401
+ data
402
+ };
403
+ }
404
+ };
405
+ function jsonResult(data, isError = false) {
197
406
  return {
198
- status: response.status,
199
- ok: response.ok,
200
- data
407
+ content: [{
408
+ type: "text",
409
+ text: JSON.stringify(data)
410
+ }],
411
+ ...isError ? { isError: true } : {}
201
412
  };
202
413
  }
203
- function jsonResult(data) {
204
- return { content: [{
205
- type: "text",
206
- text: JSON.stringify(data)
207
- }] };
208
- }
209
- function createServer() {
414
+ function createRuntimeServer(options = {}) {
415
+ const runtime = new MicrosoftRuntime(options);
210
416
  const server = new McpServer({
211
417
  name: "microsoft-mcp",
212
- version: "0.0.1"
418
+ version: SERVER_VERSION
213
419
  });
214
420
  const registerTool = server.registerTool.bind(server);
215
421
  registerTool("microsoft_list_accounts", {
216
- description: "List all connected Microsoft 365 accounts. Shows email, display name, and when each account was connected. Use this to resolve which account to target (e.g., 'Kevin's mail' → kevin@contoso.com) before calling microsoft_run_command.",
422
+ description: "List all connected Microsoft 365 accounts. Use this before a credential-touching tool to select the account explicitly.",
217
423
  inputSchema: {}
218
424
  }, async () => {
219
- const accounts = await refreshAccountCache();
425
+ const accounts = await runtime.listAccounts();
220
426
  return jsonResult({
221
- accounts: accounts.map((a) => ({
222
- email: a.email,
223
- displayName: a.displayName,
224
- connectedAt: a.connectedAt
427
+ accounts: accounts.map((account) => ({
428
+ email: account.email,
429
+ displayName: account.displayName,
430
+ connectedAt: account.connectedAt
225
431
  })),
226
432
  count: accounts.length
227
433
  });
228
434
  });
229
435
  registerTool("microsoft_run_command", {
230
- description: "Call the Microsoft Graph API for a specific Microsoft 365 account. The `command` is a Graph REST request line — an optional HTTP method, a Graph path (relative to https://graph.microsoft.com/v1.0), and an optional JSON body. Email is required — call microsoft_list_accounts first if you don't know which account to use. Examples: microsoft_run_command({ command: 'GET /me/messages?$top=5&$select=subject,from,receivedDateTime', email: 'kevin@contoso.com' }); microsoft_run_command({ command: 'GET /me/calendarView?startDateTime=2026-07-08T00:00:00Z&endDateTime=2026-07-09T00:00:00Z', email: 'kevin@contoso.com' }); microsoft_run_command({ command: 'POST /me/sendMail {\"message\":{\"subject\":\"Hi\",\"body\":{\"contentType\":\"Text\",\"content\":\"Hello\"},\"toRecipients\":[{\"emailAddress\":{\"address\":\"a@b.com\"}}]}}', email: 'kevin@contoso.com' }). A bare path with no method is treated as GET.",
436
+ description: "Call Microsoft Graph v1.0 for one explicit Microsoft 365 account. Command format: '[METHOD] /path [json-body]'; method defaults to GET.",
231
437
  inputSchema: {
232
- command: z.string().describe("The Graph request: '[METHOD] /path [json-body]'. Method defaults to GET. Path is relative to https://graph.microsoft.com/v1.0 (e.g. '/me/messages')."),
233
- email: z.string().describe("Email of the Microsoft 365 account to use. Required — there is no implicit default.")
438
+ command: z.string().trim().min(1).max(MAX_COMMAND_CHARS),
439
+ email: z.string().trim().min(1).max(512)
234
440
  }
235
441
  }, async ({ command, email }) => {
236
- const account = await resolveAccount(email);
237
- const spec = parseCommand(command);
238
- const result = await graphRequest(account, spec);
442
+ const call = await runtime.runCommand(email, command);
239
443
  return jsonResult({
240
- account: account.email,
241
- request: `${spec.method} ${spec.path}`,
242
- status: result.status,
243
- ok: result.ok,
244
- data: result.data
245
- });
444
+ account: call.account,
445
+ request: call.request,
446
+ status: call.result.status,
447
+ ok: call.result.ok,
448
+ data: call.result.data
449
+ }, !call.result.ok);
246
450
  });
247
451
  registerTool("microsoft_disconnect_account", {
248
- description: "Disconnect a specific Microsoft 365 account from this agent. Revokes the connection and removes the account.",
249
- inputSchema: { email: z.string().describe("Email of the Microsoft 365 account to disconnect") }
452
+ description: "Disconnect one explicit Microsoft 365 account from this agent.",
453
+ inputSchema: { email: z.string().trim().min(1).max(512) }
250
454
  }, async ({ email }) => {
251
- const account = await resolveAccount(email);
252
- const result = await getClient().disconnectMicrosoftAccount(account.accountIdentifier);
253
- await refreshAccountCache();
455
+ const result = await runtime.disconnectAccount(email);
254
456
  return jsonResult({
255
- message: `${account.email} has been disconnected`,
256
- remainingAccounts: result.accounts
457
+ message: `${result.account.email} has been disconnected`,
458
+ remainingAccounts: result.remainingAccounts.map((account) => ({
459
+ email: account.email,
460
+ displayName: account.displayName,
461
+ connectedAt: account.connectedAt
462
+ }))
257
463
  });
258
464
  });
259
- return server;
465
+ return {
466
+ server,
467
+ runtime
468
+ };
469
+ }
470
+ function createServer(options = {}) {
471
+ return createRuntimeServer(options).server;
260
472
  }
261
473
  async function main() {
262
- const server = createServer();
474
+ const { server, runtime } = createRuntimeServer();
263
475
  try {
264
- const accounts = await refreshAccountCache();
265
- log(`Cached ${String(accounts.length)} Microsoft account(s): ${accounts.map((a) => a.email).join(", ")}`);
266
- } catch (err) {
267
- log(`Failed to pre-cache Microsoft accounts (will fetch on demand): ${err instanceof Error ? err.message : String(err)}`);
476
+ const accounts = await runtime.listAccounts();
477
+ log(`Cached ${String(accounts.length)} Microsoft account(s)`);
478
+ } catch (error) {
479
+ log(`Failed to pre-cache Microsoft accounts (will fetch on demand): ${safeErrorMessage(error)}`);
480
+ }
481
+ try {
482
+ await server.connect(new StdioServerTransport());
483
+ log("Microsoft 365 MCP server running (multi-account, direct Graph REST)");
484
+ return server;
485
+ } catch (error) {
486
+ await server.close().catch(() => void 0);
487
+ throw error;
268
488
  }
269
- const transport = new StdioServerTransport();
270
- await server.connect(transport);
271
- log("Microsoft 365 MCP server running (multi-account, direct Graph REST)");
272
489
  }
273
- /**
274
- * True when this module is the process entrypoint — i.e. `process.argv[1]`
275
- * resolves to the same real file as `import.meta.url`. Used to start the stdio
276
- * server only when spawned via `npx -y @alfe.ai/microsoft-mcp` / the `bin`, and
277
- * to stay quiet (importable) under a test runner.
278
- *
279
- * CRITICAL: both sides MUST be realpath-resolved before comparing. npm/pnpm/npx
280
- * install a package `bin` as a SYMLINK in a `.bin` dir, so when spawned via
281
- * `npx`, `process.argv[1]` is that symlink (…/.bin/microsoft-mcp-proxy) while
282
- * `import.meta.url` is the realpath target (…/dist/server.js). A plain `===` /
283
- * `endsWith` check fails on that mismatch → main() never runs → the server
284
- * starts and exits without connecting its transport (green-but-dead).
285
- */
286
490
  function isProcessEntrypoint(argvPath, metaUrl) {
287
491
  try {
288
492
  if (!argvPath) return false;
@@ -291,9 +495,37 @@ function isProcessEntrypoint(argvPath, metaUrl) {
291
495
  return false;
292
496
  }
293
497
  }
294
- if (isProcessEntrypoint(process.argv[1], import.meta.url)) main().catch((err) => {
295
- log(`Fatal: ${err instanceof Error ? err.message : String(err)}`);
296
- process.exit(1);
498
+ function encodeRequestBody(body) {
499
+ if (body === void 0) return void 0;
500
+ let encoded;
501
+ try {
502
+ encoded = JSON.stringify(body);
503
+ } catch (error) {
504
+ throw new Error("Microsoft Graph request body is not valid JSON", { cause: error });
505
+ }
506
+ if (Buffer.byteLength(encoded, "utf8") > 1048576) throw new Error(`Graph request body exceeds ${String(MAX_REQUEST_BODY_BYTES)} bytes`);
507
+ return encoded;
508
+ }
509
+ function validatePackageVersion(value) {
510
+ if (typeof value !== "string" || !/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/u.test(value)) throw new Error("microsoft-mcp package version is invalid");
511
+ return value;
512
+ }
513
+ function log(message) {
514
+ process.stderr.write(`[microsoft-mcp] ${message}\n`);
515
+ }
516
+ if (isProcessEntrypoint(process.argv[1], import.meta.url)) main().then((server) => {
517
+ let shutdownPromise;
518
+ const shutdown = () => {
519
+ if (shutdownPromise) return;
520
+ shutdownPromise = server.close().catch((error) => {
521
+ log(`Failed to close cleanly: ${safeErrorMessage(error)}`);
522
+ }).then(() => process.exit(0));
523
+ };
524
+ process.once("SIGTERM", shutdown);
525
+ process.once("SIGINT", shutdown);
526
+ }).catch((error) => {
527
+ log(`Fatal: ${safeErrorMessage(error)}`);
528
+ process.exitCode = 1;
297
529
  });
298
530
  //#endregion
299
- export { createServer, isProcessEntrypoint, parseCommand, resolveGraphUrl, tokenIsExpired };
531
+ export { SERVER_VERSION, createServer, isProcessEntrypoint, parseCommand, resolveGraphUrl, safeErrorMessage, tokenIsExpired };