@masonator/m365-mcp 0.1.0 → 0.2.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 +9 -9
- package/dist/__tests__/auth.test.js +14 -6
- package/dist/__tests__/graph.test.js +3 -2
- package/dist/index.js +3 -2
- package/dist/lib/auth.d.ts +10 -2
- package/dist/lib/auth.js +71 -20
- package/dist/types/tokens.d.ts +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -13,7 +13,7 @@ MCP server for Microsoft 365 via the Microsoft Graph API. Read-only access to yo
|
|
|
13
13
|
### Claude Code
|
|
14
14
|
|
|
15
15
|
```bash
|
|
16
|
-
claude mcp add m365-mcp -e MS365_MCP_CLIENT_ID=your-client-id -e
|
|
16
|
+
claude mcp add m365-mcp -e MS365_MCP_CLIENT_ID=your-client-id -e MS365_MCP_TENANT_ID=your-tenant-id -- npx -y @masonator/m365-mcp
|
|
17
17
|
```
|
|
18
18
|
|
|
19
19
|
### Claude Desktop
|
|
@@ -28,7 +28,6 @@ Add to your Claude Desktop config (`claude_desktop_config.json`):
|
|
|
28
28
|
"args": ["-y", "@masonator/m365-mcp"],
|
|
29
29
|
"env": {
|
|
30
30
|
"MS365_MCP_CLIENT_ID": "your-azure-ad-client-id",
|
|
31
|
-
"MS365_MCP_CLIENT_SECRET": "your-azure-ad-client-secret",
|
|
32
31
|
"MS365_MCP_TENANT_ID": "your-azure-ad-tenant-id"
|
|
33
32
|
}
|
|
34
33
|
}
|
|
@@ -42,19 +41,20 @@ On first use, the server opens your browser to sign in with Microsoft. After gra
|
|
|
42
41
|
|
|
43
42
|
## Environment Variables
|
|
44
43
|
|
|
45
|
-
| Variable | Required | Description
|
|
46
|
-
| ------------------------- | -------- |
|
|
47
|
-
| `MS365_MCP_CLIENT_ID` | Yes | Azure AD application (client) ID
|
|
48
|
-
| `
|
|
49
|
-
| `
|
|
50
|
-
| `MS365_MCP_TIMEZONE` | No | Timezone for calendar (default: system timezone)
|
|
44
|
+
| Variable | Required | Description |
|
|
45
|
+
| ------------------------- | -------- | ------------------------------------------------------------------------------ |
|
|
46
|
+
| `MS365_MCP_CLIENT_ID` | Yes | Azure AD application (client) ID |
|
|
47
|
+
| `MS365_MCP_TENANT_ID` | Yes | Azure AD tenant ID |
|
|
48
|
+
| `MS365_MCP_CLIENT_SECRET` | No | Azure AD client secret (confidential clients only) |
|
|
49
|
+
| `MS365_MCP_TIMEZONE` | No | Timezone for calendar (default: system timezone) |
|
|
50
|
+
| `MS365_MCP_REDIRECT_URL` | No | OAuth redirect URI (default: dynamic port, `http://localhost:{port}/callback`) |
|
|
51
51
|
|
|
52
52
|
## Azure AD Setup
|
|
53
53
|
|
|
54
54
|
Register an application in Azure AD with these settings:
|
|
55
55
|
|
|
56
56
|
1. **App registration** > New registration
|
|
57
|
-
2. **Redirect URI**: `http://localhost` (Web platform) —
|
|
57
|
+
2. **Redirect URI**: `http://localhost` (Web platform) — or set a fixed URI via `MS365_MCP_REDIRECT_URL`
|
|
58
58
|
3. **Certificates & secrets** > New client secret
|
|
59
59
|
4. **API permissions** > Add the following **delegated** permissions:
|
|
60
60
|
- `User.Read`
|
|
@@ -188,27 +188,35 @@ describe('loadAuthConfig', () => {
|
|
|
188
188
|
tenantId: 'test-tenant-id',
|
|
189
189
|
});
|
|
190
190
|
});
|
|
191
|
-
it('throws when all env vars are missing', () => {
|
|
191
|
+
it('throws when all required env vars are missing', () => {
|
|
192
192
|
delete process.env['MS365_MCP_CLIENT_ID'];
|
|
193
193
|
delete process.env['MS365_MCP_CLIENT_SECRET'];
|
|
194
194
|
delete process.env['MS365_MCP_TENANT_ID'];
|
|
195
|
-
expect(() => loadAuthConfig()).toThrow('Missing required environment variables: MS365_MCP_CLIENT_ID,
|
|
195
|
+
expect(() => loadAuthConfig()).toThrow('Missing required environment variables: MS365_MCP_CLIENT_ID, MS365_MCP_TENANT_ID');
|
|
196
196
|
});
|
|
197
197
|
it('throws when only CLIENT_ID is missing', () => {
|
|
198
198
|
delete process.env['MS365_MCP_CLIENT_ID'];
|
|
199
|
-
process.env['MS365_MCP_CLIENT_SECRET'] = 'secret';
|
|
200
199
|
process.env['MS365_MCP_TENANT_ID'] = 'tenant';
|
|
201
200
|
expect(() => loadAuthConfig()).toThrow('MS365_MCP_CLIENT_ID');
|
|
202
201
|
});
|
|
203
|
-
it('
|
|
202
|
+
it('does not require CLIENT_SECRET (public client support)', () => {
|
|
204
203
|
process.env['MS365_MCP_CLIENT_ID'] = 'client';
|
|
205
204
|
delete process.env['MS365_MCP_CLIENT_SECRET'];
|
|
206
205
|
process.env['MS365_MCP_TENANT_ID'] = 'tenant';
|
|
207
|
-
|
|
206
|
+
const config = loadAuthConfig();
|
|
207
|
+
expect(config.clientId).toBe('client');
|
|
208
|
+
expect(config.clientSecret).toBeUndefined();
|
|
209
|
+
expect(config.tenantId).toBe('tenant');
|
|
208
210
|
});
|
|
209
|
-
it('
|
|
211
|
+
it('includes CLIENT_SECRET when provided', () => {
|
|
210
212
|
process.env['MS365_MCP_CLIENT_ID'] = 'client';
|
|
211
213
|
process.env['MS365_MCP_CLIENT_SECRET'] = 'secret';
|
|
214
|
+
process.env['MS365_MCP_TENANT_ID'] = 'tenant';
|
|
215
|
+
const config = loadAuthConfig();
|
|
216
|
+
expect(config.clientSecret).toBe('secret');
|
|
217
|
+
});
|
|
218
|
+
it('throws when only TENANT_ID is missing', () => {
|
|
219
|
+
process.env['MS365_MCP_CLIENT_ID'] = 'client';
|
|
212
220
|
delete process.env['MS365_MCP_TENANT_ID'];
|
|
213
221
|
expect(() => loadAuthConfig()).toThrow('MS365_MCP_TENANT_ID');
|
|
214
222
|
});
|
|
@@ -10,6 +10,7 @@ function mockFetch(response) {
|
|
|
10
10
|
globalThis.fetch = mock;
|
|
11
11
|
return mock;
|
|
12
12
|
}
|
|
13
|
+
const systemTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
|
|
13
14
|
describe('graphFetch', () => {
|
|
14
15
|
it('returns ok with data on successful response', async () => {
|
|
15
16
|
const mock = mockFetch({
|
|
@@ -22,7 +23,7 @@ describe('graphFetch', () => {
|
|
|
22
23
|
headers: expect.objectContaining({
|
|
23
24
|
Authorization: 'Bearer test-token',
|
|
24
25
|
'Content-Type': 'application/json',
|
|
25
|
-
Prefer:
|
|
26
|
+
Prefer: `outlook.timezone="${systemTimezone}"`,
|
|
26
27
|
}),
|
|
27
28
|
}));
|
|
28
29
|
});
|
|
@@ -99,7 +100,7 @@ describe('graphFetch', () => {
|
|
|
99
100
|
await graphFetch('/me', 'test-token');
|
|
100
101
|
expect(mock).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({
|
|
101
102
|
headers: expect.objectContaining({
|
|
102
|
-
Prefer:
|
|
103
|
+
Prefer: `outlook.timezone="${systemTimezone}"`,
|
|
103
104
|
}),
|
|
104
105
|
}));
|
|
105
106
|
});
|
package/dist/index.js
CHANGED
|
@@ -18,11 +18,12 @@ catch (error) {
|
|
|
18
18
|
process.stderr.write(`Configuration error: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
19
19
|
process.stderr.write('\nRequired environment variables:\n');
|
|
20
20
|
process.stderr.write(' MS365_MCP_CLIENT_ID - Azure AD application (client) ID\n');
|
|
21
|
-
process.stderr.write(' MS365_MCP_CLIENT_SECRET - Azure AD client secret\n');
|
|
22
21
|
process.stderr.write(' MS365_MCP_TENANT_ID - Azure AD tenant ID\n');
|
|
22
|
+
process.stderr.write('\nOptional:\n');
|
|
23
|
+
process.stderr.write(' MS365_MCP_CLIENT_SECRET - Azure AD client secret (confidential clients only)\n');
|
|
23
24
|
process.exit(1);
|
|
24
25
|
}
|
|
25
|
-
const server = new Server({ name: 'm365-mcp', version: '0.
|
|
26
|
+
const server = new Server({ name: 'm365-mcp', version: '0.2.0' }, { capabilities: { tools: {} } });
|
|
26
27
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
27
28
|
tools: [
|
|
28
29
|
authStatusToolDefinition,
|
package/dist/lib/auth.d.ts
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
import { type Server } from 'node:http';
|
|
2
2
|
import type { TokenData, AuthConfig } from '../types/tokens.js';
|
|
3
|
+
/**
|
|
4
|
+
* Generates a PKCE code verifier (43-128 char base64url random string).
|
|
5
|
+
*/
|
|
6
|
+
export declare function generateCodeVerifier(): string;
|
|
7
|
+
/**
|
|
8
|
+
* Derives a PKCE code challenge from a verifier using SHA-256.
|
|
9
|
+
*/
|
|
10
|
+
export declare function generateCodeChallenge(verifier: string): string;
|
|
3
11
|
export declare const SCOPES: string[];
|
|
4
12
|
/**
|
|
5
13
|
* Returns the config directory for m365-mcp.
|
|
@@ -46,13 +54,13 @@ export declare function openBrowser(url: string): void;
|
|
|
46
54
|
* Exchanges an authorization code for tokens via Azure AD token endpoint.
|
|
47
55
|
* @internal Exported for testing only.
|
|
48
56
|
*/
|
|
49
|
-
export declare function exchangeCodeForTokens(config: AuthConfig, code: string, redirectUri: string): Promise<TokenData>;
|
|
57
|
+
export declare function exchangeCodeForTokens(config: AuthConfig, code: string, redirectUri: string, codeVerifier?: string): Promise<TokenData>;
|
|
50
58
|
/**
|
|
51
59
|
* Starts an HTTP server on the given port and waits for the OAuth callback.
|
|
52
60
|
* Returns the authorization code from the callback query string.
|
|
53
61
|
* @internal Exported for testing only.
|
|
54
62
|
*/
|
|
55
|
-
export declare function waitForAuthCallback(port: number, expectedState: string, timeoutMs?: number): {
|
|
63
|
+
export declare function waitForAuthCallback(port: number, expectedState: string, timeoutMs?: number, callbackPath?: string): {
|
|
56
64
|
promise: Promise<string>;
|
|
57
65
|
server: Server;
|
|
58
66
|
};
|
package/dist/lib/auth.js
CHANGED
|
@@ -4,11 +4,24 @@ import { homedir } from 'node:os';
|
|
|
4
4
|
import { createServer } from 'node:net';
|
|
5
5
|
import { createServer as createHttpServer } from 'node:http';
|
|
6
6
|
import { URL } from 'node:url';
|
|
7
|
-
import { randomBytes } from 'node:crypto';
|
|
7
|
+
import { randomBytes, createHash } from 'node:crypto';
|
|
8
8
|
import { execFile } from 'node:child_process';
|
|
9
9
|
const TOKEN_FILENAME = 'tokens.json';
|
|
10
10
|
const EXPIRY_BUFFER_MS = 120000; // 2 minutes
|
|
11
11
|
const AUTH_TIMEOUT_MS = 300000; // 5 minutes
|
|
12
|
+
const DEFAULT_CALLBACK_PATH = '/callback';
|
|
13
|
+
/**
|
|
14
|
+
* Generates a PKCE code verifier (43-128 char base64url random string).
|
|
15
|
+
*/
|
|
16
|
+
export function generateCodeVerifier() {
|
|
17
|
+
return randomBytes(32).toString('base64url');
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Derives a PKCE code challenge from a verifier using SHA-256.
|
|
21
|
+
*/
|
|
22
|
+
export function generateCodeChallenge(verifier) {
|
|
23
|
+
return createHash('sha256').update(verifier).digest('base64url');
|
|
24
|
+
}
|
|
12
25
|
function escapeHtml(text) {
|
|
13
26
|
return text
|
|
14
27
|
.replace(/&/g, '&')
|
|
@@ -92,13 +105,11 @@ export function isTokenExpired(tokens) {
|
|
|
92
105
|
*/
|
|
93
106
|
export function loadAuthConfig() {
|
|
94
107
|
const clientId = process.env['MS365_MCP_CLIENT_ID'];
|
|
95
|
-
const clientSecret = process.env['MS365_MCP_CLIENT_SECRET'];
|
|
108
|
+
const clientSecret = process.env['MS365_MCP_CLIENT_SECRET'] || undefined;
|
|
96
109
|
const tenantId = process.env['MS365_MCP_TENANT_ID'];
|
|
97
110
|
const missing = [];
|
|
98
111
|
if (!clientId)
|
|
99
112
|
missing.push('MS365_MCP_CLIENT_ID');
|
|
100
|
-
if (!clientSecret)
|
|
101
|
-
missing.push('MS365_MCP_CLIENT_SECRET');
|
|
102
113
|
if (!tenantId)
|
|
103
114
|
missing.push('MS365_MCP_TENANT_ID');
|
|
104
115
|
if (missing.length > 0) {
|
|
@@ -106,7 +117,7 @@ export function loadAuthConfig() {
|
|
|
106
117
|
}
|
|
107
118
|
return {
|
|
108
119
|
clientId: clientId,
|
|
109
|
-
clientSecret
|
|
120
|
+
clientSecret,
|
|
110
121
|
tenantId: tenantId,
|
|
111
122
|
};
|
|
112
123
|
}
|
|
@@ -159,18 +170,31 @@ export function openBrowser(url) {
|
|
|
159
170
|
* Exchanges an authorization code for tokens via Azure AD token endpoint.
|
|
160
171
|
* @internal Exported for testing only.
|
|
161
172
|
*/
|
|
162
|
-
export async function exchangeCodeForTokens(config, code, redirectUri) {
|
|
173
|
+
export async function exchangeCodeForTokens(config, code, redirectUri, codeVerifier) {
|
|
163
174
|
const tokenUrl = `https://login.microsoftonline.com/${config.tenantId}/oauth2/v2.0/token`;
|
|
164
|
-
const
|
|
175
|
+
const params = {
|
|
165
176
|
grant_type: 'authorization_code',
|
|
166
177
|
client_id: config.clientId,
|
|
167
|
-
client_secret: config.clientSecret,
|
|
168
178
|
code,
|
|
169
179
|
redirect_uri: redirectUri,
|
|
170
|
-
}
|
|
180
|
+
};
|
|
181
|
+
if (config.clientSecret) {
|
|
182
|
+
params['client_secret'] = config.clientSecret;
|
|
183
|
+
}
|
|
184
|
+
if (codeVerifier) {
|
|
185
|
+
params['code_verifier'] = codeVerifier;
|
|
186
|
+
}
|
|
187
|
+
const body = new URLSearchParams(params);
|
|
188
|
+
const headers = {
|
|
189
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
190
|
+
};
|
|
191
|
+
if (redirectUri) {
|
|
192
|
+
const origin = new URL(redirectUri).origin;
|
|
193
|
+
headers['Origin'] = origin;
|
|
194
|
+
}
|
|
171
195
|
const response = await fetch(tokenUrl, {
|
|
172
196
|
method: 'POST',
|
|
173
|
-
headers
|
|
197
|
+
headers,
|
|
174
198
|
body: body.toString(),
|
|
175
199
|
});
|
|
176
200
|
if (!response.ok) {
|
|
@@ -190,7 +214,7 @@ export async function exchangeCodeForTokens(config, code, redirectUri) {
|
|
|
190
214
|
* Returns the authorization code from the callback query string.
|
|
191
215
|
* @internal Exported for testing only.
|
|
192
216
|
*/
|
|
193
|
-
export function waitForAuthCallback(port, expectedState, timeoutMs = AUTH_TIMEOUT_MS) {
|
|
217
|
+
export function waitForAuthCallback(port, expectedState, timeoutMs = AUTH_TIMEOUT_MS, callbackPath = DEFAULT_CALLBACK_PATH) {
|
|
194
218
|
let httpServer;
|
|
195
219
|
const promise = new Promise((resolve, reject) => {
|
|
196
220
|
const closeServer = () => {
|
|
@@ -202,7 +226,7 @@ export function waitForAuthCallback(port, expectedState, timeoutMs = AUTH_TIMEOU
|
|
|
202
226
|
reject(new Error('Authentication timed out after 5 minutes. Please try again.'));
|
|
203
227
|
}, timeoutMs);
|
|
204
228
|
httpServer = createHttpServer((req, res) => {
|
|
205
|
-
if (!req.url?.startsWith(
|
|
229
|
+
if (!req.url?.startsWith(callbackPath)) {
|
|
206
230
|
res.writeHead(404);
|
|
207
231
|
res.end('Not found');
|
|
208
232
|
return;
|
|
@@ -253,20 +277,37 @@ export function waitForAuthCallback(port, expectedState, timeoutMs = AUTH_TIMEOU
|
|
|
253
277
|
* exchanges the authorization code for tokens, saves them, and returns the TokenData.
|
|
254
278
|
*/
|
|
255
279
|
export async function startAuthFlow(config) {
|
|
256
|
-
const
|
|
257
|
-
|
|
280
|
+
const redirectUrl = process.env['MS365_MCP_REDIRECT_URL'];
|
|
281
|
+
let port;
|
|
282
|
+
let callbackPath;
|
|
283
|
+
let redirectUri;
|
|
284
|
+
if (redirectUrl) {
|
|
285
|
+
const parsed = new URL(redirectUrl);
|
|
286
|
+
port = parseInt(parsed.port, 10) || 80;
|
|
287
|
+
callbackPath = parsed.pathname;
|
|
288
|
+
redirectUri = redirectUrl;
|
|
289
|
+
}
|
|
290
|
+
else {
|
|
291
|
+
port = await findAvailablePort();
|
|
292
|
+
callbackPath = DEFAULT_CALLBACK_PATH;
|
|
293
|
+
redirectUri = `http://localhost:${port}${callbackPath}`;
|
|
294
|
+
}
|
|
258
295
|
const state = randomBytes(16).toString('hex');
|
|
296
|
+
const codeVerifier = generateCodeVerifier();
|
|
297
|
+
const codeChallenge = generateCodeChallenge(codeVerifier);
|
|
259
298
|
const authUrl = new URL(`https://login.microsoftonline.com/${config.tenantId}/oauth2/v2.0/authorize`);
|
|
260
299
|
authUrl.searchParams.set('client_id', config.clientId);
|
|
261
300
|
authUrl.searchParams.set('response_type', 'code');
|
|
262
301
|
authUrl.searchParams.set('redirect_uri', redirectUri);
|
|
263
302
|
authUrl.searchParams.set('scope', SCOPES.join(' '));
|
|
264
303
|
authUrl.searchParams.set('state', state);
|
|
304
|
+
authUrl.searchParams.set('code_challenge', codeChallenge);
|
|
305
|
+
authUrl.searchParams.set('code_challenge_method', 'S256');
|
|
265
306
|
process.stderr.write('Opening browser for Microsoft 365 sign-in...\n');
|
|
266
307
|
openBrowser(authUrl.toString());
|
|
267
|
-
const { promise } = waitForAuthCallback(port, state);
|
|
308
|
+
const { promise } = waitForAuthCallback(port, state, AUTH_TIMEOUT_MS, callbackPath);
|
|
268
309
|
const code = await promise;
|
|
269
|
-
const tokenData = await exchangeCodeForTokens(config, code, redirectUri);
|
|
310
|
+
const tokenData = await exchangeCodeForTokens(config, code, redirectUri, codeVerifier);
|
|
270
311
|
saveTokens(tokenData);
|
|
271
312
|
return tokenData;
|
|
272
313
|
}
|
|
@@ -277,16 +318,26 @@ export async function startAuthFlow(config) {
|
|
|
277
318
|
*/
|
|
278
319
|
export async function refreshAccessToken(config, refreshToken) {
|
|
279
320
|
const tokenUrl = `https://login.microsoftonline.com/${config.tenantId}/oauth2/v2.0/token`;
|
|
280
|
-
const
|
|
321
|
+
const params = {
|
|
281
322
|
grant_type: 'refresh_token',
|
|
282
323
|
client_id: config.clientId,
|
|
283
|
-
client_secret: config.clientSecret,
|
|
284
324
|
refresh_token: refreshToken,
|
|
285
|
-
}
|
|
325
|
+
};
|
|
326
|
+
if (config.clientSecret) {
|
|
327
|
+
params['client_secret'] = config.clientSecret;
|
|
328
|
+
}
|
|
329
|
+
const body = new URLSearchParams(params);
|
|
286
330
|
try {
|
|
331
|
+
const refreshHeaders = {
|
|
332
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
333
|
+
};
|
|
334
|
+
const redirectUrl = process.env['MS365_MCP_REDIRECT_URL'];
|
|
335
|
+
if (redirectUrl) {
|
|
336
|
+
refreshHeaders['Origin'] = new URL(redirectUrl).origin;
|
|
337
|
+
}
|
|
287
338
|
const response = await fetch(tokenUrl, {
|
|
288
339
|
method: 'POST',
|
|
289
|
-
headers:
|
|
340
|
+
headers: refreshHeaders,
|
|
290
341
|
body: body.toString(),
|
|
291
342
|
});
|
|
292
343
|
if (!response.ok) {
|
package/dist/types/tokens.d.ts
CHANGED
package/package.json
CHANGED