@alfe.ai/microsoft-mcp 0.1.0

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 ADDED
@@ -0,0 +1,16 @@
1
+ # @alfe.ai/microsoft-mcp
2
+
3
+ Microsoft 365 MCP server — multi-account Microsoft Graph access using Alfe OAuth credentials with server-mediated token refresh
4
+
5
+ Part of [**Alfe**](https://alfe.ai) — the operating system for AI agents: build, deploy, and run agents with persistent memory, identity, integrations, and channels. See the [documentation](https://docs.alfe.ai) to get started.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ npm install @alfe.ai/microsoft-mcp
11
+ ```
12
+
13
+ ## Links
14
+
15
+ - 🌐 Website: <https://alfe.ai>
16
+ - 📚 Docs: <https://docs.alfe.ai>
@@ -0,0 +1,54 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+
3
+ //#region src/server.d.ts
4
+
5
+ interface MicrosoftAccountInfo {
6
+ connectionId: string;
7
+ accountIdentifier: string;
8
+ email: string;
9
+ displayName?: string;
10
+ connectedAt?: string;
11
+ microsoftTenantId?: string;
12
+ accessToken: string;
13
+ accessTokenExpiresAt: string;
14
+ }
15
+ declare function tokenIsExpired(account: MicrosoftAccountInfo): boolean;
16
+ interface GraphRequestSpec {
17
+ method: string;
18
+ path: string;
19
+ body?: unknown;
20
+ }
21
+ /**
22
+ * Parse the `command` string into a Graph request. Accepts a compact
23
+ * "METHOD /path" line (optionally followed by a JSON body), so the LLM can
24
+ * express any Graph operation the way it would a curl call:
25
+ * - "GET /me/messages?$top=5"
26
+ * - "GET /me"
27
+ * - "POST /me/sendMail {\"message\":{...}}"
28
+ * A bare "/path" (or "me/messages") is treated as a GET.
29
+ */
30
+ declare function parseCommand(command: string): GraphRequestSpec;
31
+ /**
32
+ * Resolve a relative Graph path to an absolute graph.microsoft.com URL and
33
+ * assert the host cannot have been escaped. Defense-in-depth alongside
34
+ * parseCommand's absolute/protocol-relative rejection — the bearer token must
35
+ * never be sent anywhere but graph.microsoft.com.
36
+ */
37
+ declare function resolveGraphUrl(path: string): string;
38
+ declare function createServer(): McpServer;
39
+ /**
40
+ * True when this module is the process entrypoint — i.e. `process.argv[1]`
41
+ * resolves to the same real file as `import.meta.url`. Used to start the stdio
42
+ * server only when spawned via `npx -y @alfe.ai/microsoft-mcp` / the `bin`, and
43
+ * to stay quiet (importable) under a test runner.
44
+ *
45
+ * CRITICAL: both sides MUST be realpath-resolved before comparing. npm/pnpm/npx
46
+ * install a package `bin` as a SYMLINK in a `.bin` dir, so when spawned via
47
+ * `npx`, `process.argv[1]` is that symlink (…/.bin/microsoft-mcp-proxy) while
48
+ * `import.meta.url` is the realpath target (…/dist/server.js). A plain `===` /
49
+ * `endsWith` check fails on that mismatch → main() never runs → the server
50
+ * starts and exits without connecting its transport (green-but-dead).
51
+ */
52
+ declare function isProcessEntrypoint(argvPath: string | undefined, metaUrl: string): boolean;
53
+ //#endregion
54
+ export { MicrosoftAccountInfo, createServer, isProcessEntrypoint, parseCommand, resolveGraphUrl, tokenIsExpired };
package/dist/server.js ADDED
@@ -0,0 +1,299 @@
1
+ #!/usr/bin/env node
2
+ import { realpathSync } from "node:fs";
3
+ import { fileURLToPath, pathToFileURL } from "node:url";
4
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
5
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
6
+ import { z } from "zod";
7
+ import { resolveConfig } from "@alfe.ai/config";
8
+ 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
+ */
49
+ const GRAPH_BASE = "https://graph.microsoft.com/v1.0";
50
+ 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
+ */
126
+ function parseCommand(command) {
127
+ const trimmed = command.trim();
128
+ if (!trimmed) throw new Error("Command cannot be empty");
129
+ const methodMatch = /^(GET|POST|PATCH|PUT|DELETE)\s+(.*)$/is.exec(trimmed);
130
+ let method = "GET";
131
+ let rest = trimmed;
132
+ if (methodMatch) {
133
+ method = methodMatch[1].toUpperCase();
134
+ rest = methodMatch[2].trim();
135
+ }
136
+ let path = rest;
137
+ 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();
142
+ try {
143
+ body = JSON.parse(rawBody);
144
+ } catch {
145
+ throw new Error(`Invalid JSON body in command: ${rawBody.slice(0, 120)}`);
146
+ }
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}`;
152
+ return {
153
+ method,
154
+ path,
155
+ body
156
+ };
157
+ }
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
+ function resolveGraphUrl(path) {
165
+ const url = `${GRAPH_BASE}${path.startsWith("/") ? path : `/${path}`}`;
166
+ let hostname;
167
+ try {
168
+ hostname = new URL(url).hostname;
169
+ } catch {
170
+ throw new Error(`Invalid Graph path: ${path.slice(0, 120)}`);
171
+ }
172
+ if (hostname.toLowerCase() !== "graph.microsoft.com") throw new Error(`Refusing to send a Microsoft token to non-Graph host: ${hostname}`);
173
+ return url;
174
+ }
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) } : {}
184
+ });
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);
190
+ }
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;
197
+ return {
198
+ status: response.status,
199
+ ok: response.ok,
200
+ data
201
+ };
202
+ }
203
+ function jsonResult(data) {
204
+ return { content: [{
205
+ type: "text",
206
+ text: JSON.stringify(data)
207
+ }] };
208
+ }
209
+ function createServer() {
210
+ const server = new McpServer({
211
+ name: "microsoft-mcp",
212
+ version: "0.0.1"
213
+ });
214
+ const registerTool = server.registerTool.bind(server);
215
+ 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.",
217
+ inputSchema: {}
218
+ }, async () => {
219
+ const accounts = await refreshAccountCache();
220
+ return jsonResult({
221
+ accounts: accounts.map((a) => ({
222
+ email: a.email,
223
+ displayName: a.displayName,
224
+ connectedAt: a.connectedAt
225
+ })),
226
+ count: accounts.length
227
+ });
228
+ });
229
+ 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.",
231
+ 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.")
234
+ }
235
+ }, async ({ command, email }) => {
236
+ const account = await resolveAccount(email);
237
+ const spec = parseCommand(command);
238
+ const result = await graphRequest(account, spec);
239
+ 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
+ });
246
+ });
247
+ 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") }
250
+ }, async ({ email }) => {
251
+ const account = await resolveAccount(email);
252
+ const result = await getClient().disconnectMicrosoftAccount(account.accountIdentifier);
253
+ await refreshAccountCache();
254
+ return jsonResult({
255
+ message: `${account.email} has been disconnected`,
256
+ remainingAccounts: result.accounts
257
+ });
258
+ });
259
+ return server;
260
+ }
261
+ async function main() {
262
+ const server = createServer();
263
+ 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)}`);
268
+ }
269
+ const transport = new StdioServerTransport();
270
+ await server.connect(transport);
271
+ log("Microsoft 365 MCP server running (multi-account, direct Graph REST)");
272
+ }
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
+ function isProcessEntrypoint(argvPath, metaUrl) {
287
+ try {
288
+ if (!argvPath) return false;
289
+ return pathToFileURL(realpathSync(argvPath)).href === pathToFileURL(realpathSync(fileURLToPath(metaUrl))).href;
290
+ } catch {
291
+ return false;
292
+ }
293
+ }
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);
297
+ });
298
+ //#endregion
299
+ export { createServer, isProcessEntrypoint, parseCommand, resolveGraphUrl, tokenIsExpired };
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "@alfe.ai/microsoft-mcp",
3
+ "version": "0.1.0",
4
+ "description": "Microsoft 365 MCP server — multi-account Microsoft Graph access using Alfe OAuth credentials with server-mediated token refresh",
5
+ "type": "module",
6
+ "main": "./dist/server.js",
7
+ "bin": {
8
+ "microsoft-mcp-proxy": "./dist/server.js"
9
+ },
10
+ "exports": {
11
+ ".": {
12
+ "types": "./dist/server.d.ts",
13
+ "import": "./dist/server.js"
14
+ }
15
+ },
16
+ "files": [
17
+ "dist"
18
+ ],
19
+ "dependencies": {
20
+ "@modelcontextprotocol/sdk": ">=1.24.0",
21
+ "zod": "^4.0.5",
22
+ "@alfe.ai/config": "0.3.0",
23
+ "@alfe.ai/agent-api-client": "0.9.0"
24
+ },
25
+ "license": "UNLICENSED",
26
+ "homepage": "https://alfe.ai",
27
+ "author": "Alfe (https://alfe.ai)",
28
+ "keywords": [
29
+ "alfe",
30
+ "ai-agents",
31
+ "agent",
32
+ "llm",
33
+ "mcp",
34
+ "model-context-protocol"
35
+ ],
36
+ "scripts": {
37
+ "build": "tsdown",
38
+ "dev": "tsdown --watch",
39
+ "typecheck": "tsc --noEmit",
40
+ "lint": "eslint .",
41
+ "test": "vitest run"
42
+ }
43
+ }