ms365-mcp-server 1.0.4 → 1.0.5

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/dist/index.js CHANGED
@@ -55,7 +55,7 @@ function parseArgs() {
55
55
  }
56
56
  const server = new Server({
57
57
  name: "ms365-mcp-server",
58
- version: "1.0.4"
58
+ version: "1.0.5"
59
59
  }, {
60
60
  capabilities: {
61
61
  resources: {
@@ -770,11 +770,22 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
770
770
  const isAuthenticated = await enhancedMS365Auth.isAuthenticated();
771
771
  const currentUser = await enhancedMS365Auth.getCurrentUser();
772
772
  const storageInfo = enhancedMS365Auth.getStorageInfo();
773
+ const tokenInfo = await enhancedMS365Auth.getTokenExpirationInfo();
774
+ let statusText = `šŸ“Š Microsoft 365 Authentication Status\n\nšŸ” Authentication: ${isAuthenticated ? 'āœ… Valid' : 'āŒ Not authenticated'}\nšŸ‘¤ Current User: ${currentUser || 'None'}\nšŸ’¾ Storage method: ${storageInfo.method}\nšŸ“ Storage location: ${storageInfo.location}`;
775
+ if (isAuthenticated) {
776
+ statusText += `\nā° Token expires in: ${tokenInfo.expiresInMinutes} minutes`;
777
+ if (tokenInfo.needsRefresh) {
778
+ statusText += `\nāš ļø Token will be refreshed automatically on next operation`;
779
+ }
780
+ }
781
+ else {
782
+ statusText += `\nšŸ’” Use the "authenticate_with_device_code" tool to sign in`;
783
+ }
773
784
  return {
774
785
  content: [
775
786
  {
776
787
  type: "text",
777
- text: `šŸ“Š Microsoft 365 Authentication Status\n\nšŸ” Authentication: ${isAuthenticated ? 'āœ… Valid' : 'āŒ Not authenticated'}\nšŸ‘¤ Current User: ${currentUser || 'None'}\nšŸ’¾ Storage method: ${storageInfo.method}\nšŸ“ Storage location: ${storageInfo.location}`
788
+ text: statusText
778
789
  }
779
790
  ]
780
791
  };
@@ -881,11 +892,33 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
881
892
  ms365Ops.setGraphClient(graphClient);
882
893
  }
883
894
  const searchResults = await ms365Ops.searchEmails(args);
895
+ // Enhanced feedback for search results
896
+ let responseText = `šŸ” Email Search Results (${searchResults.messages.length} found)`;
897
+ if (searchResults.messages.length === 0) {
898
+ responseText = `šŸ” No emails found matching your criteria.\n\nšŸ’” Search Tips:\n`;
899
+ if (args?.from) {
900
+ responseText += `• Try partial names: "${args.from.split(' ')[0]}" or "${args.from.split(' ').pop()}"\n`;
901
+ responseText += `• Check spelling of sender name\n`;
902
+ }
903
+ if (args?.subject) {
904
+ responseText += `• Try broader subject terms\n`;
905
+ }
906
+ if (args?.after || args?.before) {
907
+ responseText += `• Try expanding date range\n`;
908
+ }
909
+ responseText += `• Remove some search criteria to get broader results`;
910
+ }
911
+ else {
912
+ responseText += `\n\n${searchResults.messages.map((email, index) => `${index + 1}. šŸ“§ ${email.subject}\n šŸ‘¤ From: ${email.from.name} <${email.from.address}>\n šŸ“… ${new Date(email.receivedDateTime).toLocaleDateString()}\n ${email.isRead ? 'šŸ“– Read' : 'šŸ“© Unread'}\n šŸ†” ID: ${email.id}\n`).join('\n')}`;
913
+ if (searchResults.hasMore) {
914
+ responseText += `\nšŸ’” There are more results available. Use maxResults parameter to get more emails.`;
915
+ }
916
+ }
884
917
  return {
885
918
  content: [
886
919
  {
887
920
  type: "text",
888
- text: `šŸ” Email Search Results (${searchResults.messages.length} found)\n\n${searchResults.messages.map((email, index) => `${index + 1}. šŸ“§ ${email.subject}\n šŸ‘¤ From: ${email.from.name} <${email.from.address}>\n šŸ“… ${new Date(email.receivedDateTime).toLocaleDateString()}\n šŸ†” ID: ${email.id}\n`).join('\n')}`
921
+ text: responseText
889
922
  }
890
923
  ]
891
924
  };
@@ -347,12 +347,52 @@ export class EnhancedMS365Auth {
347
347
  return client;
348
348
  }
349
349
  /**
350
- * Refresh access token
350
+ * Get token expiration information for proactive refresh
351
+ */
352
+ async getTokenExpirationInfo() {
353
+ try {
354
+ const storedToken = await this.loadStoredToken();
355
+ if (!storedToken) {
356
+ return { expiresInMinutes: 0, needsRefresh: true };
357
+ }
358
+ const now = Date.now();
359
+ const expiresInMs = storedToken.expiresOn - now;
360
+ const expiresInMinutes = Math.floor(expiresInMs / (1000 * 60));
361
+ return {
362
+ expiresInMinutes: Math.max(0, expiresInMinutes),
363
+ needsRefresh: expiresInMinutes < 5 // Refresh if expiring within 5 minutes
364
+ };
365
+ }
366
+ catch (error) {
367
+ logger.error('Error getting token expiration info:', error);
368
+ return { expiresInMinutes: 0, needsRefresh: true };
369
+ }
370
+ }
371
+ /**
372
+ * Refresh token if needed (proactive refresh)
373
+ */
374
+ async refreshTokenIfNeeded() {
375
+ try {
376
+ const tokenInfo = await this.getTokenExpirationInfo();
377
+ if (tokenInfo.needsRefresh) {
378
+ logger.log('Proactively refreshing token to prevent interruption...');
379
+ await this.refreshToken();
380
+ return true;
381
+ }
382
+ return false;
383
+ }
384
+ catch (error) {
385
+ logger.error('Proactive token refresh failed:', error);
386
+ return false;
387
+ }
388
+ }
389
+ /**
390
+ * Enhanced refresh token with better error handling
351
391
  */
352
392
  async refreshToken() {
353
393
  const storedToken = await this.loadStoredToken();
354
394
  if (!storedToken?.account) {
355
- throw new Error('No account information available. Please re-authenticate.');
395
+ throw new Error('No account information available. Please re-authenticate using: authenticate_with_device_code');
356
396
  }
357
397
  if (!await this.loadCredentials()) {
358
398
  throw new Error('MS365 credentials not configured');
@@ -364,14 +404,23 @@ export class EnhancedMS365Auth {
364
404
  account: storedToken.account
365
405
  });
366
406
  if (!tokenResponse) {
367
- throw new Error('Failed to refresh token');
407
+ throw new Error('Failed to refresh token - please re-authenticate using: authenticate_with_device_code');
368
408
  }
369
409
  await this.saveToken(tokenResponse, storedToken.authType);
370
410
  logger.log('MS365 token refreshed successfully');
371
411
  }
372
412
  catch (error) {
373
- logger.error('Token refresh failed:', error);
374
- throw error;
413
+ // Enhanced error handling with user-friendly messages
414
+ if (error.errorCode === 'invalid_grant' || error.errorCode === 'interaction_required') {
415
+ throw new Error('Authentication has expired. Please re-authenticate using the "authenticate_with_device_code" tool.');
416
+ }
417
+ else if (error.errorCode === 'consent_required') {
418
+ throw new Error('Additional consent required. Please re-authenticate using the "authenticate_with_device_code" tool.');
419
+ }
420
+ else {
421
+ logger.error('Token refresh failed:', error);
422
+ throw new Error(`Token refresh failed: ${error.message}. Please re-authenticate using the "authenticate_with_device_code" tool.`);
423
+ }
375
424
  }
376
425
  }
377
426
  /**
@@ -1,4 +1,3 @@
1
- import { ms365Auth } from './ms365-auth.js';
2
1
  import { logger } from './api.js';
3
2
  /**
4
3
  * Microsoft 365 operations manager class
@@ -6,6 +5,38 @@ import { logger } from './api.js';
6
5
  export class MS365Operations {
7
6
  constructor() {
8
7
  this.graphClient = null;
8
+ this.searchCache = new Map();
9
+ this.CACHE_DURATION = 60 * 1000; // 1 minute cache
10
+ this.MAX_RETRIES = 3;
11
+ this.BASE_DELAY = 1000; // 1 second
12
+ }
13
+ /**
14
+ * Execute API call with retry logic and exponential backoff
15
+ */
16
+ async executeWithRetry(operation, context = 'API call') {
17
+ let lastError;
18
+ for (let attempt = 1; attempt <= this.MAX_RETRIES; attempt++) {
19
+ try {
20
+ return await operation();
21
+ }
22
+ catch (error) {
23
+ lastError = error;
24
+ // Don't retry on authentication errors or client errors (4xx)
25
+ if (error.code === 'InvalidAuthenticationToken' ||
26
+ (error.status >= 400 && error.status < 500)) {
27
+ throw error;
28
+ }
29
+ if (attempt === this.MAX_RETRIES) {
30
+ logger.error(`${context} failed after ${this.MAX_RETRIES} attempts:`, error);
31
+ throw error;
32
+ }
33
+ // Exponential backoff with jitter
34
+ const delay = this.BASE_DELAY * Math.pow(2, attempt - 1) + Math.random() * 1000;
35
+ logger.log(`${context} failed (attempt ${attempt}), retrying in ${delay}ms...`);
36
+ await new Promise(resolve => setTimeout(resolve, delay));
37
+ }
38
+ }
39
+ throw lastError;
9
40
  }
10
41
  /**
11
42
  * Set the Microsoft Graph client externally
@@ -14,14 +45,52 @@ export class MS365Operations {
14
45
  this.graphClient = client;
15
46
  }
16
47
  /**
17
- * Get authenticated Microsoft Graph client
48
+ * Get authenticated Microsoft Graph client with proactive token refresh
18
49
  */
19
50
  async getGraphClient() {
20
51
  if (!this.graphClient) {
21
- this.graphClient = await ms365Auth.getGraphClient();
52
+ // Import the enhanced auth module dynamically to avoid circular imports
53
+ const { enhancedMS365Auth } = await import('./ms365-auth-enhanced.js');
54
+ // Proactive token check - refresh if expiring within 5 minutes
55
+ const tokenInfo = await enhancedMS365Auth.getTokenExpirationInfo();
56
+ if (tokenInfo.expiresInMinutes < 5) {
57
+ logger.log(`Token expires in ${tokenInfo.expiresInMinutes} minutes, refreshing proactively...`);
58
+ await enhancedMS365Auth.refreshTokenIfNeeded();
59
+ }
60
+ this.graphClient = await enhancedMS365Auth.getGraphClient();
22
61
  }
23
62
  return this.graphClient;
24
63
  }
64
+ /**
65
+ * Clear expired cache entries
66
+ */
67
+ clearExpiredCache() {
68
+ const now = Date.now();
69
+ for (const [key, value] of this.searchCache.entries()) {
70
+ if (now - value.timestamp > this.CACHE_DURATION) {
71
+ this.searchCache.delete(key);
72
+ }
73
+ }
74
+ }
75
+ /**
76
+ * Get cached search results if available and not expired
77
+ */
78
+ getCachedResults(cacheKey) {
79
+ this.clearExpiredCache();
80
+ const cached = this.searchCache.get(cacheKey);
81
+ if (cached && Date.now() - cached.timestamp < this.CACHE_DURATION) {
82
+ logger.log(`Cache hit for search: ${cacheKey}`);
83
+ return cached.results;
84
+ }
85
+ return null;
86
+ }
87
+ /**
88
+ * Cache search results
89
+ */
90
+ setCachedResults(cacheKey, results) {
91
+ this.searchCache.set(cacheKey, { results, timestamp: Date.now() });
92
+ logger.log(`Cached results for search: ${cacheKey}`);
93
+ }
25
94
  /**
26
95
  * Build filter query for Microsoft Graph API
27
96
  */
@@ -235,54 +304,57 @@ export class MS365Operations {
235
304
  * Search emails with criteria
236
305
  */
237
306
  async searchEmails(criteria = {}) {
238
- try {
307
+ return await this.executeWithAuth(async () => {
239
308
  const graphClient = await this.getGraphClient();
309
+ // Create cache key from criteria
310
+ const cacheKey = JSON.stringify(criteria);
311
+ const cachedResults = this.getCachedResults(cacheKey);
312
+ if (cachedResults) {
313
+ return cachedResults;
314
+ }
240
315
  // For name-based searches, use enhanced approach for better partial matching
241
316
  if (criteria.from && !criteria.from.includes('@')) {
242
317
  logger.log(`Using enhanced name matching for: "${criteria.from}"`);
243
318
  // Get more emails and filter manually for better name matching
244
319
  const maxResults = criteria.maxResults || 100;
245
320
  try {
246
- // Get broader set of emails for manual filtering
321
+ // Optimized field selection for name search - only get essential fields first
322
+ const essentialFields = 'id,subject,from,receivedDateTime,isRead,hasAttachments,importance';
247
323
  const apiCall = graphClient.api('/me/messages')
248
- .select('id,subject,from,toRecipients,ccRecipients,receivedDateTime,sentDateTime,bodyPreview,isRead,hasAttachments,importance,conversationId,parentFolderId,webLink')
324
+ .select(essentialFields)
249
325
  .orderby('receivedDateTime desc')
250
- .top(Math.min(maxResults * 2, 200)); // Get more emails to filter from
326
+ .top(Math.min(maxResults * 3, 300)); // Get more emails for better filtering
251
327
  const result = await apiCall.get();
252
- let messages = result.value?.map((email) => ({
253
- id: email.id,
254
- subject: email.subject || '',
255
- from: {
256
- name: email.from?.emailAddress?.name || '',
257
- address: email.from?.emailAddress?.address || ''
258
- },
259
- toRecipients: email.toRecipients?.map((recipient) => ({
260
- name: recipient.emailAddress?.name || '',
261
- address: recipient.emailAddress?.address || ''
262
- })) || [],
263
- ccRecipients: email.ccRecipients?.map((recipient) => ({
264
- name: recipient.emailAddress?.name || '',
265
- address: recipient.emailAddress?.address || ''
266
- })) || [],
267
- receivedDateTime: email.receivedDateTime,
268
- sentDateTime: email.sentDateTime,
269
- bodyPreview: email.bodyPreview || '',
270
- isRead: email.isRead || false,
271
- hasAttachments: email.hasAttachments || false,
272
- importance: email.importance || 'normal',
273
- conversationId: email.conversationId || '',
274
- parentFolderId: email.parentFolderId || '',
275
- webLink: email.webLink || ''
276
- })) || [];
277
- // Apply manual filtering with enhanced name matching
278
- messages = this.applyManualFiltering(messages, criteria);
328
+ // First pass: filter by name using minimal data
329
+ const nameMatches = result.value?.filter((email) => {
330
+ const fromName = email.from?.emailAddress?.name?.toLowerCase() || '';
331
+ const fromAddress = email.from?.emailAddress?.address?.toLowerCase() || '';
332
+ const searchTerm = criteria.from.toLowerCase().trim();
333
+ // Quick name matching (subset of the full logic for performance)
334
+ return fromName.includes(searchTerm) ||
335
+ fromAddress.includes(searchTerm) ||
336
+ fromName.split(/\s+/).some((part) => part.startsWith(searchTerm)) ||
337
+ searchTerm.split(/\s+/).every((part) => fromName.includes(part));
338
+ }) || [];
339
+ if (nameMatches.length === 0) {
340
+ const emptyResult = { messages: [], hasMore: false };
341
+ this.setCachedResults(cacheKey, emptyResult);
342
+ return emptyResult;
343
+ }
344
+ // Second pass: get full details for matched emails
345
+ const messageIds = nameMatches.slice(0, maxResults).map((email) => email.id);
346
+ const fullMessages = await this.getEmailsByIds(messageIds);
347
+ // Apply remaining criteria filtering
348
+ let messages = this.applyManualFiltering(fullMessages, criteria);
279
349
  // Apply maxResults limit
280
350
  const limitedMessages = messages.slice(0, maxResults);
281
351
  logger.log(`Enhanced name search found ${limitedMessages.length} emails matching "${criteria.from}"`);
282
- return {
352
+ const result_final = {
283
353
  messages: limitedMessages,
284
- hasMore: messages.length > maxResults || !!result['@odata.nextLink']
354
+ hasMore: messages.length > maxResults || nameMatches.length > maxResults
285
355
  };
356
+ this.setCachedResults(cacheKey, result_final);
357
+ return result_final;
286
358
  }
287
359
  catch (error) {
288
360
  logger.error('Error in enhanced name search, falling back to standard search:', error);
@@ -375,10 +447,12 @@ export class MS365Operations {
375
447
  if (useSearchAPI && (criteria.folder || filterQuery)) {
376
448
  messages = this.applyManualFiltering(messages, criteria);
377
449
  }
378
- return {
450
+ const standardResult = {
379
451
  messages,
380
452
  hasMore: !!result['@odata.nextLink']
381
453
  };
454
+ this.setCachedResults(cacheKey, standardResult);
455
+ return standardResult;
382
456
  }
383
457
  catch (searchError) {
384
458
  // If search API fails due to syntax error, fall back to filter-only approach
@@ -431,21 +505,19 @@ export class MS365Operations {
431
505
  // Apply manual filtering for any criteria that couldn't be handled by the filter
432
506
  // This includes subject, to, cc, and query filters that need manual processing
433
507
  messages = this.applyManualFiltering(messages, criteria);
434
- return {
508
+ const fallbackResult = {
435
509
  messages,
436
510
  hasMore: !!result['@odata.nextLink']
437
511
  };
512
+ this.setCachedResults(cacheKey, fallbackResult);
513
+ return fallbackResult;
438
514
  }
439
515
  else {
440
516
  // Re-throw other errors
441
517
  throw searchError;
442
518
  }
443
519
  }
444
- }
445
- catch (error) {
446
- logger.error('Error searching emails:', error);
447
- throw error;
448
- }
520
+ }, 'searchEmails');
449
521
  }
450
522
  /**
451
523
  * Apply manual filtering to search results (used when $filter can't be used with $search)
@@ -791,5 +863,90 @@ export class MS365Operations {
791
863
  throw error;
792
864
  }
793
865
  }
866
+ /**
867
+ * Get multiple emails by their IDs efficiently
868
+ */
869
+ async getEmailsByIds(messageIds) {
870
+ try {
871
+ const graphClient = await this.getGraphClient();
872
+ // Batch request for better performance when getting multiple emails
873
+ const emails = [];
874
+ // Process in batches of 20 to stay within Graph API limits
875
+ const batchSize = 20;
876
+ for (let i = 0; i < messageIds.length; i += batchSize) {
877
+ const batch = messageIds.slice(i, i + batchSize);
878
+ // Get full details for this batch
879
+ const promises = batch.map(id => graphClient
880
+ .api(`/me/messages/${id}`)
881
+ .select('id,subject,from,toRecipients,ccRecipients,receivedDateTime,sentDateTime,bodyPreview,isRead,hasAttachments,importance,conversationId,parentFolderId,webLink')
882
+ .get());
883
+ const results = await Promise.all(promises);
884
+ const batchEmails = results.map((email) => ({
885
+ id: email.id,
886
+ subject: email.subject || '',
887
+ from: {
888
+ name: email.from?.emailAddress?.name || '',
889
+ address: email.from?.emailAddress?.address || ''
890
+ },
891
+ toRecipients: email.toRecipients?.map((recipient) => ({
892
+ name: recipient.emailAddress?.name || '',
893
+ address: recipient.emailAddress?.address || ''
894
+ })) || [],
895
+ ccRecipients: email.ccRecipients?.map((recipient) => ({
896
+ name: recipient.emailAddress?.name || '',
897
+ address: recipient.emailAddress?.address || ''
898
+ })) || [],
899
+ receivedDateTime: email.receivedDateTime,
900
+ sentDateTime: email.sentDateTime,
901
+ bodyPreview: email.bodyPreview || '',
902
+ isRead: email.isRead || false,
903
+ hasAttachments: email.hasAttachments || false,
904
+ importance: email.importance || 'normal',
905
+ conversationId: email.conversationId || '',
906
+ parentFolderId: email.parentFolderId || '',
907
+ webLink: email.webLink || ''
908
+ }));
909
+ emails.push(...batchEmails);
910
+ }
911
+ return emails;
912
+ }
913
+ catch (error) {
914
+ logger.error('Error getting emails by IDs:', error);
915
+ throw error;
916
+ }
917
+ }
918
+ /**
919
+ * Clear cached graph client (used when authentication fails)
920
+ */
921
+ clearGraphClient() {
922
+ this.graphClient = null;
923
+ logger.log('Cleared cached graph client due to authentication failure');
924
+ }
925
+ /**
926
+ * Execute operation with authentication retry
927
+ */
928
+ async executeWithAuth(operation, operationName) {
929
+ try {
930
+ return await this.executeWithRetry(operation, operationName);
931
+ }
932
+ catch (error) {
933
+ // Check if it's an authentication error
934
+ if (error.code === 'InvalidAuthenticationToken' ||
935
+ error.code === 'Unauthorized' ||
936
+ error.status === 401) {
937
+ logger.log(`Authentication failed for ${operationName}, clearing cache and retrying...`);
938
+ this.clearGraphClient();
939
+ // Retry once with fresh authentication
940
+ try {
941
+ return await this.executeWithRetry(operation, `${operationName} (retry after auth failure)`);
942
+ }
943
+ catch (retryError) {
944
+ logger.error(`${operationName} failed even after authentication retry:`, retryError);
945
+ throw new Error(`Authentication failed. Please re-authenticate using the "authenticate_with_device_code" tool. Details: ${retryError.message}`);
946
+ }
947
+ }
948
+ throw error;
949
+ }
950
+ }
794
951
  }
795
952
  export const ms365Operations = new MS365Operations();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ms365-mcp-server",
3
- "version": "1.0.4",
3
+ "version": "1.0.5",
4
4
  "description": "Microsoft 365 MCP Server for managing Microsoft 365 email through natural language interactions with full OAuth2 authentication support",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",