@alfe.ai/microsoft-mcp 0.1.7 → 0.1.9
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/README.md +24 -1
- package/dist/server.cjs +538 -0
- package/dist/server.d.cts +1966 -0
- package/dist/server.d.ts +1944 -32
- package/dist/server.js +457 -225
- package/package.json +6 -4
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/
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
|
139
|
-
if (
|
|
140
|
-
path = rest.slice(0,
|
|
141
|
-
const rawBody = rest.slice(
|
|
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(
|
|
59
|
+
throw new Error("Invalid JSON body in command");
|
|
146
60
|
}
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
if (
|
|
150
|
-
|
|
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
|
|
166
|
-
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
|
|
173
|
-
|
|
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
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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
|
-
|
|
199
|
-
|
|
200
|
-
|
|
407
|
+
content: [{
|
|
408
|
+
type: "text",
|
|
409
|
+
text: JSON.stringify(data)
|
|
410
|
+
}],
|
|
411
|
+
...isError ? { isError: true } : {}
|
|
201
412
|
};
|
|
202
413
|
}
|
|
203
|
-
function
|
|
204
|
-
|
|
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:
|
|
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.
|
|
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
|
|
425
|
+
const accounts = await runtime.listAccounts();
|
|
220
426
|
return jsonResult({
|
|
221
|
-
accounts: accounts.map((
|
|
222
|
-
email:
|
|
223
|
-
displayName:
|
|
224
|
-
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
|
|
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().
|
|
233
|
-
email: z.string().
|
|
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
|
|
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
|
|
241
|
-
request:
|
|
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
|
|
249
|
-
inputSchema: { email: z.string().
|
|
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
|
|
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.
|
|
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
|
|
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 =
|
|
474
|
+
const { server, runtime } = createRuntimeServer();
|
|
263
475
|
try {
|
|
264
|
-
const accounts = await
|
|
265
|
-
log(`Cached ${String(accounts.length)} Microsoft account(s)
|
|
266
|
-
} catch (
|
|
267
|
-
log(`Failed to pre-cache Microsoft accounts (will fetch on demand): ${
|
|
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
|
-
|
|
295
|
-
|
|
296
|
-
|
|
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 };
|