ms365-mcp-server 1.1.24 → 2.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.
@@ -0,0 +1,614 @@
1
+ import { ConfidentialClientApplication, PublicClientApplication, CryptoProvider } from '@azure/msal-node';
2
+ import { Client } from '@microsoft/microsoft-graph-client';
3
+ import * as http from 'http';
4
+ import * as fs from 'fs';
5
+ import * as path from 'path';
6
+ import { URL } from 'url';
7
+ import open from 'open';
8
+ import { logger } from './api.js';
9
+ import { getConfigDirWithFallback } from './config-dir.js';
10
+ // OAuth callback port
11
+ const CALLBACK_PORT = 44005;
12
+ // Built-in fallback client ID (Microsoft Graph CLI Tools)
13
+ const BUILTIN_CLIENT_ID = '14d82eec-204b-4c2f-b7e8-296a70dab67e';
14
+ const DEFAULT_TENANT_ID = 'common';
15
+ // Use existing config directory (~/.ms365-mcp/)
16
+ const CONFIG_DIR = getConfigDirWithFallback();
17
+ const CREDENTIALS_FILE = path.join(CONFIG_DIR, 'credentials.json');
18
+ const TOKEN_FILE = path.join(CONFIG_DIR, 'token_ms365-user.json');
19
+ const MSAL_CACHE_FILE = path.join(CONFIG_DIR, 'msal-cache.json');
20
+ // Required scopes for Outlook operations
21
+ const OUTLOOK_SCOPES = [
22
+ 'https://graph.microsoft.com/User.Read',
23
+ 'https://graph.microsoft.com/Mail.Read',
24
+ 'https://graph.microsoft.com/Mail.Send',
25
+ 'https://graph.microsoft.com/Mail.ReadWrite',
26
+ 'https://graph.microsoft.com/MailboxSettings.Read',
27
+ 'https://graph.microsoft.com/Contacts.Read',
28
+ 'offline_access'
29
+ ];
30
+ // Token refresh buffer (10 minutes before expiry)
31
+ const TOKEN_REFRESH_BUFFER_MS = 10 * 60 * 1000;
32
+ /**
33
+ * Check if running in MCP context (non-interactive)
34
+ */
35
+ function isInMcpContext() {
36
+ const args = process.argv.slice(2);
37
+ const isCliCommand = args.some(arg => ['--login', '--logout', '--verify-login', '--reset-auth', '--setup-auth'].includes(arg));
38
+ // If it's a CLI command, we're NOT in MCP context
39
+ if (isCliCommand)
40
+ return false;
41
+ // Check if stdin is not a TTY (piped input = MCP context)
42
+ // or if running via npx in a non-interactive way
43
+ return !process.stdin.isTTY || (process.env.npm_execpath?.includes('npx') ?? false);
44
+ }
45
+ /**
46
+ * Outlook OAuth2 Authentication Manager
47
+ * Implements OAuth redirect flow with local callback server
48
+ */
49
+ export class OutlookAuth {
50
+ constructor() {
51
+ this.msalClient = null;
52
+ this.credentials = null;
53
+ this.callbackServer = null;
54
+ this.cryptoProvider = new CryptoProvider();
55
+ }
56
+ /**
57
+ * Ensure config directory exists
58
+ */
59
+ ensureConfigDir() {
60
+ if (!fs.existsSync(CONFIG_DIR)) {
61
+ fs.mkdirSync(CONFIG_DIR, { recursive: true });
62
+ }
63
+ }
64
+ /**
65
+ * Load credentials from environment, stored file, or use fallback
66
+ */
67
+ async loadCredentials() {
68
+ // Priority 1: Environment variables
69
+ if (process.env.OUTLOOK_CLIENT_ID && process.env.OUTLOOK_TENANT_ID) {
70
+ this.credentials = {
71
+ clientId: process.env.OUTLOOK_CLIENT_ID,
72
+ tenantId: process.env.OUTLOOK_TENANT_ID,
73
+ clientSecret: process.env.OUTLOOK_CLIENT_SECRET,
74
+ redirectUri: process.env.OUTLOOK_REDIRECT_URI || `http://localhost:${CALLBACK_PORT}/oauth2callback`,
75
+ authType: process.env.OUTLOOK_CLIENT_SECRET ? 'redirect' : 'device'
76
+ };
77
+ logger.log('Loaded credentials from environment variables');
78
+ return this.credentials;
79
+ }
80
+ // Priority 2: Stored credentials file (~/.ms365-mcp/credentials.json)
81
+ try {
82
+ if (fs.existsSync(CREDENTIALS_FILE)) {
83
+ const data = fs.readFileSync(CREDENTIALS_FILE, 'utf8');
84
+ this.credentials = JSON.parse(data);
85
+ logger.log('Loaded credentials from stored file');
86
+ return this.credentials;
87
+ }
88
+ }
89
+ catch (error) {
90
+ logger.error('Error loading credentials file:', error);
91
+ }
92
+ // Priority 3: Built-in fallback (no setup required)
93
+ this.credentials = {
94
+ clientId: BUILTIN_CLIENT_ID,
95
+ tenantId: DEFAULT_TENANT_ID,
96
+ redirectUri: `http://localhost:${CALLBACK_PORT}/oauth2callback`,
97
+ authType: 'redirect'
98
+ };
99
+ logger.log('Using built-in fallback credentials');
100
+ return this.credentials;
101
+ }
102
+ /**
103
+ * Initialize MSAL client with persistent cache
104
+ */
105
+ async initializeMsalClient() {
106
+ if (this.msalClient) {
107
+ return this.msalClient;
108
+ }
109
+ if (!this.credentials) {
110
+ await this.loadCredentials();
111
+ }
112
+ const isConfidential = !!this.credentials.clientSecret;
113
+ // Create persistent token cache plugin using existing ~/.ms365-mcp/ location
114
+ const cachePlugin = {
115
+ beforeCacheAccess: async (cacheContext) => {
116
+ try {
117
+ if (fs.existsSync(MSAL_CACHE_FILE)) {
118
+ const cacheData = fs.readFileSync(MSAL_CACHE_FILE, 'utf8');
119
+ cacheContext.tokenCache.deserialize(cacheData);
120
+ }
121
+ }
122
+ catch (error) {
123
+ logger.error('Error loading MSAL cache:', error);
124
+ }
125
+ },
126
+ afterCacheAccess: async (cacheContext) => {
127
+ try {
128
+ if (cacheContext.cacheHasChanged) {
129
+ this.ensureConfigDir();
130
+ const cacheData = cacheContext.tokenCache.serialize();
131
+ fs.writeFileSync(MSAL_CACHE_FILE, cacheData);
132
+ }
133
+ }
134
+ catch (error) {
135
+ logger.error('Error saving MSAL cache:', error);
136
+ }
137
+ }
138
+ };
139
+ const config = {
140
+ auth: {
141
+ clientId: this.credentials.clientId,
142
+ authority: `https://login.microsoftonline.com/${this.credentials.tenantId}`,
143
+ ...(isConfidential && { clientSecret: this.credentials.clientSecret })
144
+ },
145
+ cache: {
146
+ cachePlugin
147
+ },
148
+ system: {
149
+ loggerOptions: {
150
+ loggerCallback: (level, message, containsPii) => {
151
+ if (!containsPii) {
152
+ logger.log(`MSAL: ${message}`);
153
+ }
154
+ },
155
+ piiLoggingEnabled: false,
156
+ logLevel: 3 // Error level
157
+ }
158
+ }
159
+ };
160
+ if (isConfidential) {
161
+ this.msalClient = new ConfidentialClientApplication(config);
162
+ }
163
+ else {
164
+ this.msalClient = new PublicClientApplication(config);
165
+ }
166
+ logger.log(`Initialized ${isConfidential ? 'Confidential' : 'Public'} MSAL client`);
167
+ return this.msalClient;
168
+ }
169
+ /**
170
+ * Start local callback server for OAuth redirect
171
+ */
172
+ startCallbackServer(expectedState) {
173
+ return new Promise((resolve, reject) => {
174
+ const server = http.createServer(async (req, res) => {
175
+ if (!req.url?.startsWith('/oauth2callback')) {
176
+ res.writeHead(200, { 'Content-Type': 'text/html' });
177
+ res.end('<html><body><h1>Outlook MCP OAuth2</h1><p>Waiting for authentication...</p></body></html>');
178
+ return;
179
+ }
180
+ try {
181
+ const url = new URL(req.url, `http://localhost:${CALLBACK_PORT}`);
182
+ const code = url.searchParams.get('code');
183
+ const state = url.searchParams.get('state');
184
+ const error = url.searchParams.get('error');
185
+ const errorDescription = url.searchParams.get('error_description');
186
+ // Handle errors from OAuth provider
187
+ if (error) {
188
+ res.writeHead(400, { 'Content-Type': 'text/html' });
189
+ res.end(`<html><body><h1>Authentication Error</h1><p>${error}: ${errorDescription || 'Unknown error'}</p></body></html>`);
190
+ this.closeCallbackServer();
191
+ reject(new Error(`OAuth error: ${error} - ${errorDescription}`));
192
+ return;
193
+ }
194
+ // Validate state parameter (CSRF protection)
195
+ if (state !== expectedState) {
196
+ res.writeHead(400, { 'Content-Type': 'text/html' });
197
+ res.end('<html><body><h1>Authentication Error</h1><p>Invalid state parameter. Possible CSRF attack.</p></body></html>');
198
+ this.closeCallbackServer();
199
+ reject(new Error('Invalid state parameter - possible CSRF attack'));
200
+ return;
201
+ }
202
+ if (!code) {
203
+ res.writeHead(400, { 'Content-Type': 'text/html' });
204
+ res.end('<html><body><h1>Authentication Error</h1><p>No authorization code received.</p></body></html>');
205
+ this.closeCallbackServer();
206
+ reject(new Error('No authorization code received'));
207
+ return;
208
+ }
209
+ // Exchange code for tokens
210
+ const msalClient = await this.initializeMsalClient();
211
+ const tokenResponse = await msalClient.acquireTokenByCode({
212
+ code,
213
+ scopes: OUTLOOK_SCOPES,
214
+ redirectUri: this.credentials.redirectUri
215
+ });
216
+ if (!tokenResponse) {
217
+ res.writeHead(500, { 'Content-Type': 'text/html' });
218
+ res.end('<html><body><h1>Authentication Error</h1><p>Failed to exchange code for tokens.</p></body></html>');
219
+ this.closeCallbackServer();
220
+ reject(new Error('Failed to exchange authorization code for tokens'));
221
+ return;
222
+ }
223
+ // Success!
224
+ res.writeHead(200, { 'Content-Type': 'text/html' });
225
+ res.end(`
226
+ <html>
227
+ <body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);">
228
+ <div style="background: white; padding: 40px; border-radius: 10px; text-align: center; box-shadow: 0 4px 6px rgba(0,0,0,0.1);">
229
+ <h1 style="color: #22c55e; margin-bottom: 10px;">āœ“ Authentication Successful!</h1>
230
+ <p style="color: #666;">You can close this window and return to the terminal.</p>
231
+ </div>
232
+ </body>
233
+ </html>
234
+ `);
235
+ this.closeCallbackServer();
236
+ resolve(tokenResponse);
237
+ }
238
+ catch (error) {
239
+ res.writeHead(500, { 'Content-Type': 'text/html' });
240
+ res.end(`<html><body><h1>Authentication Error</h1><p>${error}</p></body></html>`);
241
+ this.closeCallbackServer();
242
+ reject(error);
243
+ }
244
+ });
245
+ server.on('error', (err) => {
246
+ reject(new Error(`Failed to start callback server: ${err.message}`));
247
+ });
248
+ server.listen(CALLBACK_PORT, () => {
249
+ logger.log(`OAuth callback server listening on port ${CALLBACK_PORT}`);
250
+ });
251
+ this.callbackServer = server;
252
+ // Timeout after 5 minutes
253
+ setTimeout(() => {
254
+ if (this.callbackServer) {
255
+ this.closeCallbackServer();
256
+ reject(new Error('Authentication timed out after 5 minutes'));
257
+ }
258
+ }, 5 * 60 * 1000);
259
+ });
260
+ }
261
+ /**
262
+ * Close callback server
263
+ */
264
+ closeCallbackServer() {
265
+ if (this.callbackServer) {
266
+ this.callbackServer.close();
267
+ this.callbackServer = null;
268
+ logger.log('Closed OAuth callback server');
269
+ }
270
+ }
271
+ /**
272
+ * Generate authorization URL with PKCE and state
273
+ */
274
+ async generateAuthUrl() {
275
+ const msalClient = await this.initializeMsalClient();
276
+ // Generate state for CSRF protection
277
+ const state = this.cryptoProvider.createNewGuid();
278
+ const authUrl = await msalClient.getAuthCodeUrl({
279
+ scopes: OUTLOOK_SCOPES,
280
+ redirectUri: this.credentials.redirectUri,
281
+ state,
282
+ prompt: 'select_account'
283
+ });
284
+ return { authUrl, state };
285
+ }
286
+ /**
287
+ * Main authentication entry point
288
+ * Handles both CLI and MCP contexts
289
+ */
290
+ async authenticate() {
291
+ await this.loadCredentials();
292
+ // Check if we already have valid tokens
293
+ const existingToken = await this.getValidToken();
294
+ if (existingToken) {
295
+ logger.log('Using existing valid token');
296
+ return existingToken;
297
+ }
298
+ const { authUrl, state } = await this.generateAuthUrl();
299
+ if (isInMcpContext()) {
300
+ // In MCP context - throw error with auth URL for client to handle
301
+ const error = new Error('Authentication required');
302
+ error.authUrl = authUrl;
303
+ error.code = 'AUTH_REQUIRED';
304
+ throw error;
305
+ }
306
+ // CLI context - open browser and wait for callback
307
+ console.log('\nšŸ” Microsoft 365 Authentication');
308
+ console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
309
+ console.log('Opening browser for authentication...');
310
+ console.log(`\nIf browser doesn't open, visit:\n${authUrl}\n`);
311
+ console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
312
+ // Start callback server first, then open browser
313
+ const tokenPromise = this.startCallbackServer(state);
314
+ try {
315
+ await open(authUrl);
316
+ }
317
+ catch (error) {
318
+ console.log('Could not open browser automatically. Please visit the URL above.');
319
+ }
320
+ const tokenResponse = await tokenPromise;
321
+ // Save tokens
322
+ await this.saveTokens(tokenResponse);
323
+ console.log('\nāœ… Authentication successful!\n');
324
+ return tokenResponse;
325
+ }
326
+ /**
327
+ * Save tokens to existing storage location (~/.ms365-mcp/token_ms365-user.json)
328
+ */
329
+ async saveTokens(tokenResponse) {
330
+ const tokens = {
331
+ accessToken: tokenResponse.accessToken,
332
+ refreshToken: undefined, // MSAL handles refresh tokens via cache
333
+ expiresOn: tokenResponse.expiresOn?.getTime() || Date.now() + 3600000,
334
+ account: tokenResponse.account ? {
335
+ username: tokenResponse.account.username,
336
+ homeAccountId: tokenResponse.account.homeAccountId,
337
+ environment: tokenResponse.account.environment,
338
+ tenantId: tokenResponse.account.tenantId,
339
+ localAccountId: tokenResponse.account.localAccountId
340
+ } : null,
341
+ authType: 'redirect'
342
+ };
343
+ try {
344
+ this.ensureConfigDir();
345
+ fs.writeFileSync(TOKEN_FILE, JSON.stringify(tokens, null, 2));
346
+ logger.log(`Tokens saved to ${TOKEN_FILE}`);
347
+ }
348
+ catch (error) {
349
+ logger.error('Error saving tokens:', error);
350
+ throw error;
351
+ }
352
+ }
353
+ /**
354
+ * Load tokens from existing storage location (~/.ms365-mcp/token_ms365-user.json)
355
+ */
356
+ async loadStoredTokens() {
357
+ try {
358
+ if (!fs.existsSync(TOKEN_FILE)) {
359
+ return null;
360
+ }
361
+ const data = fs.readFileSync(TOKEN_FILE, 'utf8');
362
+ return JSON.parse(data);
363
+ }
364
+ catch (error) {
365
+ logger.error('Error loading tokens:', error);
366
+ return null;
367
+ }
368
+ }
369
+ /**
370
+ * Clear tokens from storage
371
+ */
372
+ async clearTokens() {
373
+ try {
374
+ if (fs.existsSync(TOKEN_FILE)) {
375
+ fs.unlinkSync(TOKEN_FILE);
376
+ }
377
+ if (fs.existsSync(MSAL_CACHE_FILE)) {
378
+ fs.unlinkSync(MSAL_CACHE_FILE);
379
+ }
380
+ }
381
+ catch (error) {
382
+ logger.error('Error clearing tokens:', error);
383
+ }
384
+ }
385
+ /**
386
+ * Get valid token (checks expiry with buffer)
387
+ */
388
+ async getValidToken() {
389
+ const storedTokens = await this.loadStoredTokens();
390
+ if (!storedTokens) {
391
+ return null;
392
+ }
393
+ // Check if token expires within buffer time
394
+ const now = Date.now();
395
+ if (storedTokens.expiresOn > now + TOKEN_REFRESH_BUFFER_MS) {
396
+ // Token is still valid
397
+ return {
398
+ accessToken: storedTokens.accessToken,
399
+ expiresOn: new Date(storedTokens.expiresOn),
400
+ account: storedTokens.account,
401
+ scopes: OUTLOOK_SCOPES,
402
+ authority: `https://login.microsoftonline.com/${this.credentials?.tenantId || DEFAULT_TENANT_ID}`,
403
+ uniqueId: storedTokens.account?.localAccountId || '',
404
+ tenantId: storedTokens.account?.tenantId || '',
405
+ idToken: '',
406
+ idTokenClaims: {},
407
+ fromCache: true,
408
+ tokenType: 'Bearer',
409
+ correlationId: ''
410
+ };
411
+ }
412
+ // Token expired or expiring soon - try to refresh
413
+ logger.log('Token expired or expiring soon, attempting refresh...');
414
+ return await this.refreshToken();
415
+ }
416
+ /**
417
+ * Refresh token using MSAL silent acquisition
418
+ */
419
+ async refreshToken() {
420
+ try {
421
+ const storedTokens = await this.loadStoredTokens();
422
+ if (!storedTokens?.account) {
423
+ logger.log('No account info for token refresh');
424
+ return null;
425
+ }
426
+ await this.loadCredentials();
427
+ const msalClient = await this.initializeMsalClient();
428
+ // Try silent token acquisition
429
+ const tokenResponse = await msalClient.acquireTokenSilent({
430
+ scopes: OUTLOOK_SCOPES,
431
+ account: storedTokens.account
432
+ });
433
+ if (tokenResponse) {
434
+ await this.saveTokens(tokenResponse);
435
+ logger.log('Token refreshed successfully');
436
+ return tokenResponse;
437
+ }
438
+ return null;
439
+ }
440
+ catch (error) {
441
+ logger.error('Token refresh failed:', error.message);
442
+ // If refresh fails, clear tokens so user can re-authenticate
443
+ if (error.errorCode === 'invalid_grant' ||
444
+ error.errorCode === 'interaction_required' ||
445
+ error.errorCode === 'consent_required') {
446
+ await this.clearTokens();
447
+ }
448
+ return null;
449
+ }
450
+ }
451
+ /**
452
+ * Get authenticated Microsoft Graph client
453
+ */
454
+ async getGraphClient() {
455
+ const token = await this.getValidToken();
456
+ if (!token) {
457
+ // Try to authenticate
458
+ const newToken = await this.authenticate();
459
+ return Client.init({
460
+ authProvider: (done) => {
461
+ done(null, newToken.accessToken);
462
+ }
463
+ });
464
+ }
465
+ return Client.init({
466
+ authProvider: (done) => {
467
+ done(null, token.accessToken);
468
+ }
469
+ });
470
+ }
471
+ /**
472
+ * Check if user is authenticated
473
+ */
474
+ async isAuthenticated() {
475
+ const token = await this.getValidToken();
476
+ return token !== null;
477
+ }
478
+ /**
479
+ * Get authentication status
480
+ */
481
+ async getAuthenticationStatus() {
482
+ const storedTokens = await this.loadStoredTokens();
483
+ await this.loadCredentials();
484
+ if (!storedTokens) {
485
+ return {
486
+ authenticated: false,
487
+ clientId: this.credentials?.clientId,
488
+ tenantId: this.credentials?.tenantId
489
+ };
490
+ }
491
+ const now = Date.now();
492
+ const isValid = storedTokens.expiresOn > now;
493
+ const expiresIn = Math.max(0, Math.floor((storedTokens.expiresOn - now) / 1000 / 60));
494
+ return {
495
+ authenticated: isValid,
496
+ username: storedTokens.account?.username,
497
+ expiresAt: new Date(storedTokens.expiresOn).toLocaleString(),
498
+ expiresIn: expiresIn,
499
+ clientId: this.credentials?.clientId,
500
+ tenantId: this.credentials?.tenantId
501
+ };
502
+ }
503
+ /**
504
+ * Logout - clear all stored credentials and tokens
505
+ */
506
+ async logout() {
507
+ this.closeCallbackServer();
508
+ await this.clearTokens();
509
+ // Also clear credentials file
510
+ try {
511
+ if (fs.existsSync(CREDENTIALS_FILE)) {
512
+ fs.unlinkSync(CREDENTIALS_FILE);
513
+ }
514
+ }
515
+ catch (error) {
516
+ logger.error('Error clearing credentials:', error);
517
+ }
518
+ this.msalClient = null;
519
+ this.credentials = null;
520
+ logger.log('Logged out successfully');
521
+ }
522
+ /**
523
+ * Reset auth - clear everything
524
+ */
525
+ async resetAuth() {
526
+ await this.logout();
527
+ console.log('āœ… All authentication data cleared');
528
+ }
529
+ /**
530
+ * Get current user info
531
+ */
532
+ async getCurrentUser() {
533
+ const storedTokens = await this.loadStoredTokens();
534
+ if (storedTokens?.account?.username) {
535
+ return storedTokens.account.username;
536
+ }
537
+ return null;
538
+ }
539
+ /**
540
+ * Save credentials to storage
541
+ */
542
+ async saveCredentials(credentials) {
543
+ try {
544
+ this.ensureConfigDir();
545
+ fs.writeFileSync(CREDENTIALS_FILE, JSON.stringify(credentials, null, 2));
546
+ logger.log(`Credentials saved to ${CREDENTIALS_FILE}`);
547
+ }
548
+ catch (error) {
549
+ logger.error('Error saving credentials:', error);
550
+ throw error;
551
+ }
552
+ }
553
+ /**
554
+ * Interactive credential setup
555
+ */
556
+ async setupCredentials() {
557
+ const readline = await import('readline');
558
+ const rl = readline.createInterface({
559
+ input: process.stdin,
560
+ output: process.stdout
561
+ });
562
+ const question = (prompt) => {
563
+ return new Promise((resolve) => {
564
+ rl.question(prompt, resolve);
565
+ });
566
+ };
567
+ try {
568
+ console.log('\nšŸ”§ Outlook MCP Server Credential Setup\n');
569
+ console.log('Choose setup method:');
570
+ console.log('1. Use built-in credentials (Recommended - no Azure setup needed)');
571
+ console.log('2. Use custom Azure App credentials\n');
572
+ const choice = await question('Enter choice (1 or 2): ');
573
+ if (choice === '1') {
574
+ const credentials = {
575
+ clientId: BUILTIN_CLIENT_ID,
576
+ tenantId: DEFAULT_TENANT_ID,
577
+ redirectUri: `http://localhost:${CALLBACK_PORT}/oauth2callback`,
578
+ authType: 'redirect'
579
+ };
580
+ await this.saveCredentials(credentials);
581
+ console.log('\nāœ… Configured with built-in credentials!');
582
+ console.log('Run: ms365-mcp-server --login to authenticate\n');
583
+ }
584
+ else if (choice === '2') {
585
+ console.log('\nCustom Azure App Setup:');
586
+ console.log('1. Go to https://portal.azure.com');
587
+ console.log('2. Navigate to Azure Active Directory > App registrations');
588
+ console.log('3. Click "New registration"');
589
+ console.log(`4. Set redirect URI to: http://localhost:${CALLBACK_PORT}/oauth2callback`);
590
+ console.log('5. Grant required API permissions for Microsoft Graph\n');
591
+ const clientId = await question('Enter Client ID: ');
592
+ const tenantId = await question('Enter Tenant ID (or "common"): ');
593
+ const clientSecret = await question('Enter Client Secret (optional, press Enter to skip): ');
594
+ const credentials = {
595
+ clientId: clientId.trim(),
596
+ tenantId: tenantId.trim() || DEFAULT_TENANT_ID,
597
+ clientSecret: clientSecret.trim() || undefined,
598
+ redirectUri: `http://localhost:${CALLBACK_PORT}/oauth2callback`,
599
+ authType: 'redirect'
600
+ };
601
+ await this.saveCredentials(credentials);
602
+ console.log('\nāœ… Credentials saved!');
603
+ console.log('Run: ms365-mcp-server --login to authenticate\n');
604
+ }
605
+ else {
606
+ console.log('Invalid choice. Setup cancelled.');
607
+ }
608
+ }
609
+ finally {
610
+ rl.close();
611
+ }
612
+ }
613
+ }
614
+ export const outlookAuth = new OutlookAuth();