@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 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 MS365_MCP_CLIENT_SECRET=your-secret -e MS365_MCP_TENANT_ID=your-tenant-id -- npx -y @masonator/m365-mcp
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
- | `MS365_MCP_CLIENT_SECRET` | Yes | Azure AD client secret |
49
- | `MS365_MCP_TENANT_ID` | Yes | Azure AD tenant ID |
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) — the server uses a dynamic port
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, MS365_MCP_CLIENT_SECRET, MS365_MCP_TENANT_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('throws when only CLIENT_SECRET is missing', () => {
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
- expect(() => loadAuthConfig()).toThrow('MS365_MCP_CLIENT_SECRET');
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('throws when only TENANT_ID is missing', () => {
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: 'outlook.timezone="Europe/London"',
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: 'outlook.timezone="Europe/London"',
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.1.0' }, { capabilities: { tools: {} } });
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,
@@ -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, '&amp;')
@@ -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: 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 body = new URLSearchParams({
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: { 'Content-Type': 'application/x-www-form-urlencoded' },
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('/callback')) {
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 port = await findAvailablePort();
257
- const redirectUri = `http://localhost:${port}/callback`;
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 body = new URLSearchParams({
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: { 'Content-Type': 'application/x-www-form-urlencoded' },
340
+ headers: refreshHeaders,
290
341
  body: body.toString(),
291
342
  });
292
343
  if (!response.ok) {
@@ -6,6 +6,6 @@ export interface TokenData {
6
6
  }
7
7
  export interface AuthConfig {
8
8
  clientId: string;
9
- clientSecret: string;
9
+ clientSecret?: string;
10
10
  tenantId: string;
11
11
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@masonator/m365-mcp",
3
3
  "scope": "@masonator",
4
- "version": "0.1.0",
4
+ "version": "0.2.0",
5
5
  "description": "MCP server implementation for Microsoft 365 via Microsoft Graph API",
6
6
  "type": "module",
7
7
  "main": "./dist/index.js",