msteams-mcp 0.2.0 → 0.3.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.
@@ -76,6 +76,30 @@ export declare const DeleteMessageInputSchema: z.ZodObject<{
76
76
  conversationId: string;
77
77
  messageId: string;
78
78
  }>;
79
+ export declare const GetUnreadInputSchema: z.ZodObject<{
80
+ conversationId: z.ZodOptional<z.ZodString>;
81
+ }, "strip", z.ZodTypeAny, {
82
+ conversationId?: string | undefined;
83
+ }, {
84
+ conversationId?: string | undefined;
85
+ }>;
86
+ export declare const MarkAsReadInputSchema: z.ZodObject<{
87
+ conversationId: z.ZodString;
88
+ messageId: z.ZodString;
89
+ }, "strip", z.ZodTypeAny, {
90
+ conversationId: string;
91
+ messageId: string;
92
+ }, {
93
+ conversationId: string;
94
+ messageId: string;
95
+ }>;
96
+ export declare const GetActivityInputSchema: z.ZodObject<{
97
+ limit: z.ZodOptional<z.ZodNumber>;
98
+ }, "strip", z.ZodTypeAny, {
99
+ limit?: number | undefined;
100
+ }, {
101
+ limit?: number | undefined;
102
+ }>;
79
103
  export declare const sendMessageTool: RegisteredTool<typeof SendMessageInputSchema>;
80
104
  export declare const replyToThreadTool: RegisteredTool<typeof ReplyToThreadInputSchema>;
81
105
  export declare const getFavoritesTool: RegisteredTool<z.ZodObject<Record<string, never>>>;
@@ -86,6 +110,9 @@ export declare const unsaveMessageTool: RegisteredTool<typeof SaveMessageInputSc
86
110
  export declare const getChatTool: RegisteredTool<typeof GetChatInputSchema>;
87
111
  export declare const editMessageTool: RegisteredTool<typeof EditMessageInputSchema>;
88
112
  export declare const deleteMessageTool: RegisteredTool<typeof DeleteMessageInputSchema>;
113
+ export declare const getUnreadTool: RegisteredTool<typeof GetUnreadInputSchema>;
114
+ export declare const markAsReadTool: RegisteredTool<typeof MarkAsReadInputSchema>;
115
+ export declare const getActivityTool: RegisteredTool<typeof GetActivityInputSchema>;
89
116
  /** All message-related tools. */
90
117
  export declare const messageTools: (RegisteredTool<z.ZodObject<{
91
118
  content: z.ZodString;
@@ -136,4 +163,16 @@ export declare const messageTools: (RegisteredTool<z.ZodObject<{
136
163
  userId: string;
137
164
  }, {
138
165
  userId: string;
166
+ }>> | RegisteredTool<z.ZodObject<{
167
+ conversationId: z.ZodOptional<z.ZodString>;
168
+ }, "strip", z.ZodTypeAny, {
169
+ conversationId?: string | undefined;
170
+ }, {
171
+ conversationId?: string | undefined;
172
+ }>> | RegisteredTool<z.ZodObject<{
173
+ limit: z.ZodOptional<z.ZodNumber>;
174
+ }, "strip", z.ZodTypeAny, {
175
+ limit?: number | undefined;
176
+ }, {
177
+ limit?: number | undefined;
139
178
  }>>)[];
@@ -2,9 +2,10 @@
2
2
  * Messaging-related tool handlers.
3
3
  */
4
4
  import { z } from 'zod';
5
- import { sendMessage, sendNoteToSelf, replyToThread, saveMessage, unsaveMessage, getOneOnOneChatId, editMessage, deleteMessage, } from '../api/chatsvc-api.js';
5
+ import { sendMessage, sendNoteToSelf, replyToThread, saveMessage, unsaveMessage, getOneOnOneChatId, editMessage, deleteMessage, getUnreadStatus, markAsRead, getActivityFeed, } from '../api/chatsvc-api.js';
6
6
  import { getFavorites, addFavorite, removeFavorite } from '../api/csa-api.js';
7
- import { SELF_CHAT_ID } from '../constants.js';
7
+ import { SELF_CHAT_ID, MAX_UNREAD_AGGREGATE_CHECK } from '../constants.js';
8
+ import { ErrorCode } from '../types/errors.js';
8
9
  // ─────────────────────────────────────────────────────────────────────────────
9
10
  // Schemas
10
11
  // ─────────────────────────────────────────────────────────────────────────────
@@ -37,6 +38,16 @@ export const DeleteMessageInputSchema = z.object({
37
38
  conversationId: z.string().min(1, 'Conversation ID cannot be empty'),
38
39
  messageId: z.string().min(1, 'Message ID cannot be empty'),
39
40
  });
41
+ export const GetUnreadInputSchema = z.object({
42
+ conversationId: z.string().optional(),
43
+ });
44
+ export const MarkAsReadInputSchema = z.object({
45
+ conversationId: z.string().min(1, 'Conversation ID cannot be empty'),
46
+ messageId: z.string().min(1, 'Message ID cannot be empty'),
47
+ });
48
+ export const GetActivityInputSchema = z.object({
49
+ limit: z.number().min(1).max(200).optional(),
50
+ });
40
51
  // ─────────────────────────────────────────────────────────────────────────────
41
52
  // Tool Definitions
42
53
  // ─────────────────────────────────────────────────────────────────────────────
@@ -210,6 +221,50 @@ const deleteMessageToolDefinition = {
210
221
  required: ['conversationId', 'messageId'],
211
222
  },
212
223
  };
224
+ const getUnreadToolDefinition = {
225
+ name: 'teams_get_unread',
226
+ description: 'Get unread message status. Without parameters, returns aggregate unread counts across all favourite/pinned conversations. With a conversationId, returns unread status for that specific conversation.',
227
+ inputSchema: {
228
+ type: 'object',
229
+ properties: {
230
+ conversationId: {
231
+ type: 'string',
232
+ description: 'Optional. A specific conversation ID to check. If omitted, checks all favourites.',
233
+ },
234
+ },
235
+ },
236
+ };
237
+ const markAsReadToolDefinition = {
238
+ name: 'teams_mark_read',
239
+ description: 'Mark a conversation as read up to a specific message. This updates your read position so messages up to (and including) the specified message are marked as read.',
240
+ inputSchema: {
241
+ type: 'object',
242
+ properties: {
243
+ conversationId: {
244
+ type: 'string',
245
+ description: 'The conversation ID to mark as read',
246
+ },
247
+ messageId: {
248
+ type: 'string',
249
+ description: 'The message ID to mark as read up to (all messages up to this point will be marked read)',
250
+ },
251
+ },
252
+ required: ['conversationId', 'messageId'],
253
+ },
254
+ };
255
+ const getActivityToolDefinition = {
256
+ name: 'teams_get_activity',
257
+ description: 'Get the user\'s activity feed - mentions, reactions, replies, and other notifications. Returns recent activity items with sender, content, and source conversation context.',
258
+ inputSchema: {
259
+ type: 'object',
260
+ properties: {
261
+ limit: {
262
+ type: 'number',
263
+ description: 'Maximum number of activity items to return (default: 50, max: 200)',
264
+ },
265
+ },
266
+ },
267
+ };
213
268
  // ─────────────────────────────────────────────────────────────────────────────
214
269
  // Handlers
215
270
  // ─────────────────────────────────────────────────────────────────────────────
@@ -365,6 +420,108 @@ async function handleDeleteMessage(input, _ctx) {
365
420
  },
366
421
  };
367
422
  }
423
+ async function handleGetUnread(input, _ctx) {
424
+ // If a specific conversation is provided, just check that one
425
+ if (input.conversationId) {
426
+ const result = await getUnreadStatus(input.conversationId);
427
+ if (!result.ok) {
428
+ return { success: false, error: result.error };
429
+ }
430
+ return {
431
+ success: true,
432
+ data: {
433
+ conversationId: result.value.conversationId,
434
+ unreadCount: result.value.unreadCount,
435
+ lastReadMessageId: result.value.lastReadMessageId,
436
+ latestMessageId: result.value.latestMessageId,
437
+ },
438
+ };
439
+ }
440
+ // Aggregate mode: check all favourites
441
+ const favResult = await getFavorites();
442
+ if (!favResult.ok) {
443
+ return { success: false, error: favResult.error };
444
+ }
445
+ const favorites = favResult.value.favorites;
446
+ const conversations = [];
447
+ let totalUnread = 0;
448
+ let checkedCount = 0;
449
+ let errorCount = 0;
450
+ // Check unread status for each favourite (limit to prevent timeout)
451
+ const maxToCheck = MAX_UNREAD_AGGREGATE_CHECK;
452
+ for (const fav of favorites.slice(0, maxToCheck)) {
453
+ const unreadResult = await getUnreadStatus(fav.conversationId);
454
+ checkedCount++;
455
+ if (unreadResult.ok) {
456
+ if (unreadResult.value.unreadCount > 0) {
457
+ conversations.push({
458
+ conversationId: fav.conversationId,
459
+ displayName: fav.displayName,
460
+ conversationType: fav.conversationType,
461
+ unreadCount: unreadResult.value.unreadCount,
462
+ });
463
+ totalUnread += unreadResult.value.unreadCount;
464
+ }
465
+ }
466
+ else {
467
+ errorCount++;
468
+ }
469
+ }
470
+ // If all checks failed, return an error rather than misleading success
471
+ if (checkedCount > 0 && errorCount === checkedCount) {
472
+ return {
473
+ success: false,
474
+ error: {
475
+ code: ErrorCode.API_ERROR,
476
+ message: `Failed to check unread status for all ${checkedCount} favourites`,
477
+ retryable: true,
478
+ suggestions: ['Check authentication status with teams_status', 'Try teams_login to refresh session'],
479
+ },
480
+ };
481
+ }
482
+ return {
483
+ success: true,
484
+ data: {
485
+ totalUnread,
486
+ conversationsWithUnread: conversations.length,
487
+ conversations,
488
+ checked: checkedCount,
489
+ totalFavorites: favorites.length,
490
+ errors: errorCount > 0 ? errorCount : undefined,
491
+ note: favorites.length > maxToCheck
492
+ ? `Checked first ${maxToCheck} of ${favorites.length} favourites`
493
+ : undefined,
494
+ },
495
+ };
496
+ }
497
+ async function handleMarkAsRead(input, _ctx) {
498
+ const result = await markAsRead(input.conversationId, input.messageId);
499
+ if (!result.ok) {
500
+ return { success: false, error: result.error };
501
+ }
502
+ return {
503
+ success: true,
504
+ data: {
505
+ message: 'Conversation marked as read',
506
+ conversationId: result.value.conversationId,
507
+ markedUpTo: result.value.markedUpTo,
508
+ },
509
+ };
510
+ }
511
+ async function handleGetActivity(input, _ctx) {
512
+ const result = await getActivityFeed({ limit: input.limit });
513
+ if (!result.ok) {
514
+ return { success: false, error: result.error };
515
+ }
516
+ return {
517
+ success: true,
518
+ data: {
519
+ count: result.value.activities.length,
520
+ activities: result.value.activities,
521
+ syncState: result.value.syncState,
522
+ },
523
+ };
524
+ }
368
525
  // ─────────────────────────────────────────────────────────────────────────────
369
526
  // Exports
370
527
  // ─────────────────────────────────────────────────────────────────────────────
@@ -418,6 +575,21 @@ export const deleteMessageTool = {
418
575
  schema: DeleteMessageInputSchema,
419
576
  handler: handleDeleteMessage,
420
577
  };
578
+ export const getUnreadTool = {
579
+ definition: getUnreadToolDefinition,
580
+ schema: GetUnreadInputSchema,
581
+ handler: handleGetUnread,
582
+ };
583
+ export const markAsReadTool = {
584
+ definition: markAsReadToolDefinition,
585
+ schema: MarkAsReadInputSchema,
586
+ handler: handleMarkAsRead,
587
+ };
588
+ export const getActivityTool = {
589
+ definition: getActivityToolDefinition,
590
+ schema: GetActivityInputSchema,
591
+ handler: handleGetActivity,
592
+ };
421
593
  /** All message-related tools. */
422
594
  export const messageTools = [
423
595
  sendMessageTool,
@@ -430,4 +602,7 @@ export const messageTools = [
430
602
  getChatTool,
431
603
  editMessageTool,
432
604
  deleteMessageTool,
605
+ getUnreadTool,
606
+ markAsReadTool,
607
+ getActivityTool,
433
608
  ];
@@ -7,8 +7,8 @@ export declare const SearchPeopleInputSchema: z.ZodObject<{
7
7
  query: z.ZodString;
8
8
  limit: z.ZodDefault<z.ZodOptional<z.ZodNumber>>;
9
9
  }, "strip", z.ZodTypeAny, {
10
- query: string;
11
10
  limit: number;
11
+ query: string;
12
12
  }, {
13
13
  query: string;
14
14
  limit?: number | undefined;
@@ -32,8 +32,8 @@ export declare const peopleTools: (RegisteredTool<z.ZodObject<Record<string, nev
32
32
  query: z.ZodString;
33
33
  limit: z.ZodDefault<z.ZodOptional<z.ZodNumber>>;
34
34
  }, "strip", z.ZodTypeAny, {
35
- query: string;
36
35
  limit: number;
36
+ query: string;
37
37
  }, {
38
38
  query: string;
39
39
  limit?: number | undefined;
@@ -22,19 +22,22 @@ export declare const SearchInputSchema: z.ZodObject<{
22
22
  export declare const GetThreadInputSchema: z.ZodObject<{
23
23
  conversationId: z.ZodString;
24
24
  limit: z.ZodDefault<z.ZodOptional<z.ZodNumber>>;
25
+ markRead: z.ZodDefault<z.ZodOptional<z.ZodBoolean>>;
25
26
  }, "strip", z.ZodTypeAny, {
26
27
  conversationId: string;
27
28
  limit: number;
29
+ markRead: boolean;
28
30
  }, {
29
31
  conversationId: string;
30
32
  limit?: number | undefined;
33
+ markRead?: boolean | undefined;
31
34
  }>;
32
35
  export declare const FindChannelInputSchema: z.ZodObject<{
33
36
  query: z.ZodString;
34
37
  limit: z.ZodDefault<z.ZodOptional<z.ZodNumber>>;
35
38
  }, "strip", z.ZodTypeAny, {
36
- query: string;
37
39
  limit: number;
40
+ query: string;
38
41
  }, {
39
42
  query: string;
40
43
  limit?: number | undefined;
@@ -61,18 +64,21 @@ export declare const searchTools: (RegisteredTool<z.ZodObject<{
61
64
  }>> | RegisteredTool<z.ZodObject<{
62
65
  conversationId: z.ZodString;
63
66
  limit: z.ZodDefault<z.ZodOptional<z.ZodNumber>>;
67
+ markRead: z.ZodDefault<z.ZodOptional<z.ZodBoolean>>;
64
68
  }, "strip", z.ZodTypeAny, {
65
69
  conversationId: string;
66
70
  limit: number;
71
+ markRead: boolean;
67
72
  }, {
68
73
  conversationId: string;
69
74
  limit?: number | undefined;
75
+ markRead?: boolean | undefined;
70
76
  }>> | RegisteredTool<z.ZodObject<{
71
77
  query: z.ZodString;
72
78
  limit: z.ZodDefault<z.ZodOptional<z.ZodNumber>>;
73
79
  }, "strip", z.ZodTypeAny, {
74
- query: string;
75
80
  limit: number;
81
+ query: string;
76
82
  }, {
77
83
  query: string;
78
84
  limit?: number | undefined;
@@ -3,7 +3,7 @@
3
3
  */
4
4
  import { z } from 'zod';
5
5
  import { searchMessages, searchChannels } from '../api/substrate-api.js';
6
- import { getThreadMessages } from '../api/chatsvc-api.js';
6
+ import { getThreadMessages, getConsumptionHorizon, markAsRead } from '../api/chatsvc-api.js';
7
7
  import { DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE, DEFAULT_THREAD_LIMIT, MAX_THREAD_LIMIT, DEFAULT_CHANNEL_LIMIT, MAX_CHANNEL_LIMIT, } from '../constants.js';
8
8
  // ─────────────────────────────────────────────────────────────────────────────
9
9
  // Schemas
@@ -17,6 +17,7 @@ export const SearchInputSchema = z.object({
17
17
  export const GetThreadInputSchema = z.object({
18
18
  conversationId: z.string().min(1, 'Conversation ID cannot be empty'),
19
19
  limit: z.number().min(1).max(MAX_THREAD_LIMIT).optional().default(DEFAULT_THREAD_LIMIT),
20
+ markRead: z.boolean().optional().default(false),
20
21
  });
21
22
  export const FindChannelInputSchema = z.object({
22
23
  query: z.string().min(1, 'Query cannot be empty'),
@@ -53,7 +54,7 @@ const searchToolDefinition = {
53
54
  };
54
55
  const getThreadToolDefinition = {
55
56
  name: 'teams_get_thread',
56
- description: 'Get messages from a Teams conversation/thread. Use this to see replies to a message, check thread context, or read recent messages in a chat. Requires a conversationId (available from search results).',
57
+ description: 'Get messages from a Teams conversation/thread. Use this to see replies to a message, check thread context, or read recent messages in a chat. Requires a conversationId (available from search results). Returns unread count and can optionally mark the conversation as read.',
57
58
  inputSchema: {
58
59
  type: 'object',
59
60
  properties: {
@@ -65,6 +66,10 @@ const getThreadToolDefinition = {
65
66
  type: 'number',
66
67
  description: 'Maximum number of messages to return (default: 50, max: 200)',
67
68
  },
69
+ markRead: {
70
+ type: 'boolean',
71
+ description: 'If true, marks the conversation as read up to the latest message after fetching (default: false)',
72
+ },
68
73
  },
69
74
  required: ['conversationId'],
70
75
  },
@@ -123,11 +128,53 @@ async function handleGetThread(input, _ctx) {
123
128
  if (!result.ok) {
124
129
  return { success: false, error: result.error };
125
130
  }
131
+ // Get unread status
132
+ let unreadCount;
133
+ let lastReadMessageId;
134
+ const horizonResult = await getConsumptionHorizon(input.conversationId);
135
+ if (horizonResult.ok) {
136
+ lastReadMessageId = horizonResult.value.lastReadMessageId;
137
+ // Count messages after last read
138
+ if (lastReadMessageId) {
139
+ let foundLastRead = false;
140
+ unreadCount = 0;
141
+ for (const msg of result.value.messages) {
142
+ if (msg.id === lastReadMessageId) {
143
+ foundLastRead = true;
144
+ continue;
145
+ }
146
+ if (foundLastRead && !msg.isFromMe) {
147
+ unreadCount++;
148
+ }
149
+ }
150
+ // If last read message wasn't found, it's older than our window - all messages are unread
151
+ if (!foundLastRead) {
152
+ unreadCount = result.value.messages.filter(m => !m.isFromMe).length;
153
+ }
154
+ }
155
+ else {
156
+ // No consumption horizon means all messages are unread (new conversation)
157
+ unreadCount = result.value.messages.filter(m => !m.isFromMe).length;
158
+ }
159
+ }
160
+ // Mark as read if requested
161
+ let markedAsRead = false;
162
+ if (input.markRead && result.value.messages.length > 0) {
163
+ // Find the latest message
164
+ const latestMessage = result.value.messages[result.value.messages.length - 1];
165
+ if (latestMessage) {
166
+ const markResult = await markAsRead(input.conversationId, latestMessage.id);
167
+ markedAsRead = markResult.ok;
168
+ }
169
+ }
126
170
  return {
127
171
  success: true,
128
172
  data: {
129
173
  conversationId: result.value.conversationId,
130
174
  messageCount: result.value.messages.length,
175
+ unreadCount,
176
+ lastReadMessageId,
177
+ markedAsRead: input.markRead ? markedAsRead : undefined,
131
178
  messages: result.value.messages,
132
179
  },
133
180
  };
@@ -46,6 +46,12 @@ export declare const CHATSVC_API: {
46
46
  readonly editMessage: (region: Region, conversationId: string, messageId: string) => string;
47
47
  /** Delete a specific message URL (soft delete). */
48
48
  readonly deleteMessage: (region: Region, conversationId: string, messageId: string) => string;
49
+ /** Get consumption horizons (read receipts) for a thread. */
50
+ readonly consumptionHorizons: (region: Region, threadId: string) => string;
51
+ /** Update consumption horizon (mark as read) for a conversation. */
52
+ readonly updateConsumptionHorizon: (region: Region, conversationId: string) => string;
53
+ /** Activity feed (notifications) messages. */
54
+ readonly activityFeed: (region: Region) => string;
49
55
  };
50
56
  /** CSA (Chat Service Aggregator) API endpoints. */
51
57
  export declare const CSA_API: {
@@ -3,6 +3,7 @@
3
3
  *
4
4
  * Centralises all API URLs and common request headers.
5
5
  */
6
+ import { NOTIFICATIONS_ID } from '../constants.js';
6
7
  /** Valid API regions. */
7
8
  export const VALID_REGIONS = ['amer', 'emea', 'apac'];
8
9
  /**
@@ -64,6 +65,12 @@ export const CHATSVC_API = {
64
65
  editMessage: (region, conversationId, messageId) => `https://teams.microsoft.com/api/chatsvc/${region}/v1/users/ME/conversations/${encodeURIComponent(conversationId)}/messages/${messageId}`,
65
66
  /** Delete a specific message URL (soft delete). */
66
67
  deleteMessage: (region, conversationId, messageId) => `https://teams.microsoft.com/api/chatsvc/${region}/v1/users/ME/conversations/${encodeURIComponent(conversationId)}/messages/${messageId}?behavior=softDelete`,
68
+ /** Get consumption horizons (read receipts) for a thread. */
69
+ consumptionHorizons: (region, threadId) => `https://teams.microsoft.com/api/chatsvc/${region}/v1/threads/${encodeURIComponent(threadId)}/consumptionhorizons`,
70
+ /** Update consumption horizon (mark as read) for a conversation. */
71
+ updateConsumptionHorizon: (region, conversationId) => `https://teams.microsoft.com/api/chatsvc/${region}/v1/users/ME/conversations/${encodeURIComponent(conversationId)}/properties?name=consumptionhorizon`,
72
+ /** Activity feed (notifications) messages. */
73
+ activityFeed: (region) => `https://teams.microsoft.com/api/chatsvc/${region}/v1/users/ME/conversations/${encodeURIComponent(NOTIFICATIONS_ID)}/messages`,
67
74
  };
68
75
  /** CSA (Chat Service Aggregator) API endpoints. */
69
76
  export const CSA_API = {
@@ -17,6 +17,13 @@ export interface CsaAuthInfo {
17
17
  * Use for search and people APIs.
18
18
  */
19
19
  export declare function requireSubstrateToken(): Result<string, McpError>;
20
+ /**
21
+ * Requires a valid Substrate token with proactive refresh.
22
+ *
23
+ * This async version attempts to refresh tokens if they're approaching
24
+ * expiry (within 10 minutes). Use this in tool handlers for better UX.
25
+ */
26
+ export declare function requireSubstrateTokenAsync(): Promise<Result<string, McpError>>;
20
27
  /**
21
28
  * Requires valid message authentication.
22
29
  * Use for chatsvc messaging APIs.
@@ -6,7 +6,9 @@
6
6
  */
7
7
  import { ErrorCode, createError } from '../types/errors.js';
8
8
  import { err, ok } from '../types/result.js';
9
- import { getValidSubstrateToken, extractMessageAuth, extractCsaToken, } from '../auth/token-extractor.js';
9
+ import { getValidSubstrateToken, extractMessageAuth, extractCsaToken, extractSubstrateToken, } from '../auth/token-extractor.js';
10
+ import { TOKEN_REFRESH_THRESHOLD_MS } from '../constants.js';
11
+ import { refreshTokensViaBrowser } from '../auth/token-refresh.js';
10
12
  // ─────────────────────────────────────────────────────────────────────────────
11
13
  // Error Messages
12
14
  // ─────────────────────────────────────────────────────────────────────────────
@@ -29,6 +31,45 @@ export function requireSubstrateToken() {
29
31
  }
30
32
  return ok(token);
31
33
  }
34
+ /**
35
+ * Checks if the Substrate token is approaching expiry and needs refresh.
36
+ *
37
+ * @returns true if token will expire within the refresh threshold
38
+ */
39
+ function shouldRefreshSubstrateToken() {
40
+ const substrate = extractSubstrateToken();
41
+ if (!substrate)
42
+ return false;
43
+ const timeRemaining = substrate.expiry.getTime() - Date.now();
44
+ return timeRemaining > 0 && timeRemaining < TOKEN_REFRESH_THRESHOLD_MS;
45
+ }
46
+ /**
47
+ * Requires a valid Substrate token with proactive refresh.
48
+ *
49
+ * This async version attempts to refresh tokens if they're approaching
50
+ * expiry (within 10 minutes). Use this in tool handlers for better UX.
51
+ */
52
+ export async function requireSubstrateTokenAsync() {
53
+ // Check if we need to refresh proactively
54
+ if (shouldRefreshSubstrateToken()) {
55
+ const refreshResult = await refreshTokensViaBrowser();
56
+ if (refreshResult.ok) {
57
+ // Refresh succeeded, get the new token
58
+ const token = getValidSubstrateToken();
59
+ if (token) {
60
+ return ok(token);
61
+ }
62
+ }
63
+ // Refresh failed but token might still be valid, continue
64
+ }
65
+ // Try to get existing token
66
+ const token = getValidSubstrateToken();
67
+ if (!token) {
68
+ // Token expired and refresh not available/failed
69
+ return err(createError(ErrorCode.AUTH_EXPIRED, 'Token expired and automatic refresh failed. Please run teams_login to re-authenticate.', { suggestions: ['Call teams_login to re-authenticate'] }));
70
+ }
71
+ return ok(token);
72
+ }
32
73
  /**
33
74
  * Requires valid message authentication.
34
75
  * Use for chatsvc messaging APIs.
@@ -185,3 +185,18 @@ export declare function extractObjectId(identifier: string): string | null;
185
185
  * @returns The constructed conversation ID, or null if either ID is invalid
186
186
  */
187
187
  export declare function buildOneOnOneConversationId(userId1: string, userId2: string): string | null;
188
+ /**
189
+ * Safely extracts a timestamp from an activity feed message.
190
+ *
191
+ * Tries multiple sources in order of preference:
192
+ * 1. originalarrivaltime - Primary timestamp field
193
+ * 2. composetime - When message was composed
194
+ * 3. id as numeric timestamp - Fallback if ID is a Unix timestamp
195
+ *
196
+ * Returns null if no valid timestamp can be determined, preventing
197
+ * RangeError from Date operations on invalid values.
198
+ *
199
+ * @param msg - Raw message object from activity feed API
200
+ * @returns ISO timestamp string, or null if no valid timestamp found
201
+ */
202
+ export declare function extractActivityTimestamp(msg: Record<string, unknown>): string | null;
@@ -572,3 +572,34 @@ export function buildOneOnOneConversationId(userId1, userId2) {
572
572
  const sorted = [id1, id2].sort();
573
573
  return `19:${sorted[0]}_${sorted[1]}@unq.gbl.spaces`;
574
574
  }
575
+ /**
576
+ * Safely extracts a timestamp from an activity feed message.
577
+ *
578
+ * Tries multiple sources in order of preference:
579
+ * 1. originalarrivaltime - Primary timestamp field
580
+ * 2. composetime - When message was composed
581
+ * 3. id as numeric timestamp - Fallback if ID is a Unix timestamp
582
+ *
583
+ * Returns null if no valid timestamp can be determined, preventing
584
+ * RangeError from Date operations on invalid values.
585
+ *
586
+ * @param msg - Raw message object from activity feed API
587
+ * @returns ISO timestamp string, or null if no valid timestamp found
588
+ */
589
+ export function extractActivityTimestamp(msg) {
590
+ const arrivalTime = msg.originalarrivaltime;
591
+ const composeTime = msg.composetime;
592
+ if (arrivalTime)
593
+ return arrivalTime;
594
+ if (composeTime)
595
+ return composeTime;
596
+ // Try parsing the message ID as a numeric timestamp
597
+ const id = msg.id;
598
+ if (id) {
599
+ const numericId = parseInt(id, 10);
600
+ if (!isNaN(numericId) && numericId > 0) {
601
+ return new Date(numericId).toISOString();
602
+ }
603
+ }
604
+ return null;
605
+ }
@@ -5,7 +5,7 @@
5
5
  * produce expected outputs regardless of internal logic.
6
6
  */
7
7
  import { describe, it, expect } from 'vitest';
8
- import { stripHtml, buildMessageLink, extractMessageTimestamp, parsePersonSuggestion, parseV2Result, parseJwtProfile, calculateTokenStatus, parseSearchResults, parsePeopleResults, extractObjectId, buildOneOnOneConversationId, decodeBase64Guid, } from './parsers.js';
8
+ import { stripHtml, buildMessageLink, extractMessageTimestamp, parsePersonSuggestion, parseV2Result, parseJwtProfile, calculateTokenStatus, parseSearchResults, parsePeopleResults, extractObjectId, buildOneOnOneConversationId, decodeBase64Guid, extractActivityTimestamp, } from './parsers.js';
9
9
  import { searchResultItem, searchResultWithHtml, searchResultMinimal, searchResultTooShort, searchEntitySetsResponse, personSuggestion, personMinimal, personWithBase64Id, peopleGroupsResponse, jwtPayloadFull, jwtPayloadMinimal, jwtPayloadCommaName, jwtPayloadSpaceName, sourceWithMessageId, sourceWithConvIdMessageId, } from '../__fixtures__/api-responses.js';
10
10
  describe('stripHtml', () => {
11
11
  it('removes HTML tags', () => {
@@ -358,3 +358,57 @@ describe('buildOneOnOneConversationId', () => {
358
358
  expect(buildOneOnOneConversationId('', '')).toBeNull();
359
359
  });
360
360
  });
361
+ describe('extractActivityTimestamp', () => {
362
+ it('prefers originalarrivaltime when present', () => {
363
+ const msg = {
364
+ originalarrivaltime: '2024-01-15T10:30:00.000Z',
365
+ composetime: '2024-01-15T10:29:00.000Z',
366
+ id: '1705315800000',
367
+ };
368
+ expect(extractActivityTimestamp(msg)).toBe('2024-01-15T10:30:00.000Z');
369
+ });
370
+ it('falls back to composetime when originalarrivaltime is missing', () => {
371
+ const msg = {
372
+ composetime: '2024-01-15T10:29:00.000Z',
373
+ id: '1705315800000',
374
+ };
375
+ expect(extractActivityTimestamp(msg)).toBe('2024-01-15T10:29:00.000Z');
376
+ });
377
+ it('parses numeric id as timestamp when no time fields present', () => {
378
+ const msg = {
379
+ id: '1705315800000', // 2024-01-15T10:30:00.000Z
380
+ };
381
+ const result = extractActivityTimestamp(msg);
382
+ expect(result).toBe(new Date(1705315800000).toISOString());
383
+ });
384
+ it('returns null for non-numeric id when no time fields present', () => {
385
+ const msg = {
386
+ id: 'abc-not-a-number',
387
+ };
388
+ expect(extractActivityTimestamp(msg)).toBeNull();
389
+ });
390
+ it('returns null for empty message object', () => {
391
+ expect(extractActivityTimestamp({})).toBeNull();
392
+ });
393
+ it('returns null when id is undefined', () => {
394
+ const msg = {
395
+ originalarrivaltime: undefined,
396
+ composetime: undefined,
397
+ };
398
+ expect(extractActivityTimestamp(msg)).toBeNull();
399
+ });
400
+ it('handles zero id correctly (returns null)', () => {
401
+ const msg = {
402
+ id: '0',
403
+ };
404
+ // Zero is not a valid timestamp
405
+ expect(extractActivityTimestamp(msg)).toBeNull();
406
+ });
407
+ it('handles negative id correctly (returns null)', () => {
408
+ const msg = {
409
+ id: '-1705315800000',
410
+ };
411
+ // Negative timestamps are invalid
412
+ expect(extractActivityTimestamp(msg)).toBeNull();
413
+ });
414
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "msteams-mcp",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "MCP server for Microsoft Teams - search messages, send replies, manage favourites",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",