msteams-mcp 0.2.0 → 0.3.1

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
@@ -66,6 +66,7 @@ Then configure:
66
66
  | `teams_search` | Search messages with operators (`from:`, `sent:`, `in:`, `hasattachment:`, etc.) |
67
67
  | `teams_get_thread` | Get messages from a conversation/thread |
68
68
  | `teams_find_channel` | Find channels by name (your teams + org-wide discovery) |
69
+ | `teams_get_activity` | Get activity feed (mentions, reactions, replies, notifications) |
69
70
 
70
71
  ### Messaging
71
72
 
@@ -94,6 +95,8 @@ Then configure:
94
95
  | `teams_remove_favorite` | Unpin a conversation |
95
96
  | `teams_save_message` | Bookmark a message |
96
97
  | `teams_unsave_message` | Remove bookmark from a message |
98
+ | `teams_get_unread` | Get unread counts (aggregate or per-conversation) |
99
+ | `teams_mark_read` | Mark a conversation as read up to a message |
97
100
 
98
101
  ### Session
99
102
 
@@ -166,6 +169,8 @@ npm run test:mcp -- search "your query"
166
169
  npm run test:mcp -- status
167
170
  npm run test:mcp -- people "john smith"
168
171
  npm run test:mcp -- favorites
172
+ npm run test:mcp -- activity # Get activity feed
173
+ npm run test:mcp -- unread # Check unread counts
169
174
  ```
170
175
 
171
176
  ## Limitations
@@ -14,7 +14,7 @@ export declare const searchResultItem: {
14
14
  HitHighlightedSummary: string;
15
15
  Summary: string;
16
16
  Source: {
17
- ReceivedTime: string;
17
+ DateTimeReceived: string;
18
18
  From: {
19
19
  EmailAddress: {
20
20
  Name: string;
@@ -37,7 +37,7 @@ export declare const searchResultWithHtml: {
37
37
  ReferenceId: string;
38
38
  HitHighlightedSummary: string;
39
39
  Source: {
40
- ReceivedTime: string;
40
+ DateTimeReceived: string;
41
41
  From: string;
42
42
  ClientThreadId: string;
43
43
  };
@@ -69,7 +69,7 @@ export declare const searchEntitySetsResponse: {
69
69
  HitHighlightedSummary: string;
70
70
  Summary: string;
71
71
  Source: {
72
- ReceivedTime: string;
72
+ DateTimeReceived: string;
73
73
  From: {
74
74
  EmailAddress: {
75
75
  Name: string;
@@ -88,7 +88,7 @@ export declare const searchEntitySetsResponse: {
88
88
  ReferenceId: string;
89
89
  HitHighlightedSummary: string;
90
90
  Source: {
91
- ReceivedTime: string;
91
+ DateTimeReceived: string;
92
92
  From: string;
93
93
  ClientThreadId: string;
94
94
  };
@@ -183,14 +183,14 @@ export declare const jwtPayloadSpaceName: {
183
183
  */
184
184
  export declare const sourceWithMessageId: {
185
185
  MessageId: string;
186
- ReceivedTime: string;
186
+ DateTimeReceived: string;
187
187
  ClientConversationId: string;
188
188
  };
189
189
  /**
190
190
  * Message source with ID in ClientConversationId.
191
191
  */
192
192
  export declare const sourceWithConvIdMessageId: {
193
- ReceivedTime: string;
193
+ DateTimeReceived: string;
194
194
  ClientConversationId: string;
195
195
  };
196
196
  /**
@@ -14,7 +14,7 @@ export const searchResultItem = {
14
14
  HitHighlightedSummary: 'Let me check the <c0>budget</c0> report for Q3',
15
15
  Summary: 'Let me check the budget report for Q3',
16
16
  Source: {
17
- ReceivedTime: '2026-01-20T14:30:00.000Z',
17
+ DateTimeReceived: '2026-01-20T14:30:00.000Z',
18
18
  From: {
19
19
  EmailAddress: {
20
20
  Name: 'Smith, John',
@@ -37,7 +37,7 @@ export const searchResultWithHtml = {
37
37
  ReferenceId: 'xyz789.1000.1',
38
38
  HitHighlightedSummary: '<p>Meeting <strong>notes</strong> from &amp; yesterday&apos;s call</p><br/><div>Action items:</div>',
39
39
  Source: {
40
- ReceivedTime: '2026-01-21T09:00:00.000Z',
40
+ DateTimeReceived: '2026-01-21T09:00:00.000Z',
41
41
  From: 'Jane Doe',
42
42
  ClientThreadId: '19:meeting123@thread.v2',
43
43
  },
@@ -163,14 +163,14 @@ export const jwtPayloadSpaceName = {
163
163
  */
164
164
  export const sourceWithMessageId = {
165
165
  MessageId: '1705760000000',
166
- ReceivedTime: '2026-01-20T12:00:00.000Z',
166
+ DateTimeReceived: '2026-01-20T12:00:00.000Z',
167
167
  ClientConversationId: '19:thread@tacv2',
168
168
  };
169
169
  /**
170
170
  * Message source with ID in ClientConversationId.
171
171
  */
172
172
  export const sourceWithConvIdMessageId = {
173
- ReceivedTime: '2026-01-20T12:00:00.000Z',
173
+ DateTimeReceived: '2026-01-20T12:00:00.000Z',
174
174
  ClientConversationId: '19:thread@tacv2;messageid=1705770000000',
175
175
  };
176
176
  /**
@@ -76,18 +76,8 @@ export interface ReplyToThreadResult extends SendMessageResult {
76
76
  /**
77
77
  * Replies to a thread in a Teams channel.
78
78
  *
79
- * Uses the provided messageId directly as the thread root. In Teams channels:
80
- * - If messageId is a top-level post, the reply goes under that post
81
- * - If messageId is already a reply within a thread, the reply goes to the same thread
82
- *
83
- * For channel messages from search results, the messageId is typically the thread root
84
- * (the original message that started the thread).
85
- *
86
- * @param conversationId - The channel conversation ID (from search results)
87
- * @param messageId - The message ID to reply to (typically the thread root from search)
88
- * @param content - The reply content
89
- * @param region - API region (default: 'amer')
90
- * @returns The result including the new message ID
79
+ * Uses the provided messageId as the thread root. For search results,
80
+ * this is typically the original message that started the thread.
91
81
  */
92
82
  export declare function replyToThread(conversationId: string, messageId: string, content: string, region?: string): Promise<Result<ReplyToThreadResult>>;
93
83
  /**
@@ -158,14 +148,88 @@ export interface GetOneOnOneChatResult {
158
148
  /**
159
149
  * Gets the conversation ID for a 1:1 chat with another user.
160
150
  *
161
- * This constructs the predictable conversation ID format used by Teams
162
- * for 1:1 chats. The conversation ID is: `19:{id1}_{id2}@unq.gbl.spaces`
163
- * where id1 and id2 are the two users' object IDs sorted lexicographically.
164
- *
165
- * Note: This doesn't create the conversation - it just returns the ID.
166
- * The conversation is implicitly created when the first message is sent.
167
- *
168
- * @param otherUserIdentifier - The other user's MRI, object ID, or ID with tenant
169
- * @returns The conversation ID, or an error if auth is missing or ID is invalid
151
+ * Constructs the predictable format: `19:{id1}_{id2}@unq.gbl.spaces`
152
+ * where IDs are sorted lexicographically. The conversation is created
153
+ * implicitly when the first message is sent.
170
154
  */
171
155
  export declare function getOneOnOneChatId(otherUserIdentifier: string): Result<GetOneOnOneChatResult>;
156
+ /** Consumption horizon information for a conversation. */
157
+ export interface ConsumptionHorizonInfo {
158
+ /** The conversation/thread ID. */
159
+ conversationId: string;
160
+ /** The version timestamp of the consumption horizon. */
161
+ version?: string;
162
+ /** The last read message ID (timestamp). */
163
+ lastReadMessageId?: string;
164
+ /** The last read timestamp. */
165
+ lastReadTimestamp?: number;
166
+ /** Raw consumption horizons array from API. */
167
+ consumptionHorizons: Array<{
168
+ id: string;
169
+ consumptionHorizon: string;
170
+ }>;
171
+ }
172
+ /** Result of marking a conversation as read. */
173
+ export interface MarkAsReadResult {
174
+ conversationId: string;
175
+ markedUpTo: string;
176
+ }
177
+ /**
178
+ * Gets the consumption horizon (read receipts) for a conversation.
179
+ * The consumption horizon indicates where each user has read up to.
180
+ */
181
+ export declare function getConsumptionHorizon(conversationId: string, region?: string): Promise<Result<ConsumptionHorizonInfo>>;
182
+ /**
183
+ * Marks a conversation as read up to a specific message.
184
+ */
185
+ export declare function markAsRead(conversationId: string, messageId: string, region?: string): Promise<Result<MarkAsReadResult>>;
186
+ /**
187
+ * Gets unread count for a conversation by comparing consumption horizon
188
+ * with recent messages.
189
+ */
190
+ export declare function getUnreadStatus(conversationId: string, region?: string): Promise<Result<{
191
+ conversationId: string;
192
+ unreadCount: number;
193
+ lastReadMessageId?: string;
194
+ latestMessageId?: string;
195
+ }>>;
196
+ /** Type of activity item. */
197
+ export type ActivityType = 'mention' | 'reaction' | 'reply' | 'message' | 'unknown';
198
+ /** An activity item from the notifications feed. */
199
+ export interface ActivityItem {
200
+ /** Activity/message ID. */
201
+ id: string;
202
+ /** Type of activity (mention, reaction, reply, etc.). */
203
+ type: ActivityType;
204
+ /** Activity content (HTML stripped). */
205
+ content: string;
206
+ /** Raw content type from API. */
207
+ contentType: string;
208
+ /** Sender information. */
209
+ sender: {
210
+ mri: string;
211
+ displayName?: string;
212
+ };
213
+ /** When the activity occurred. */
214
+ timestamp: string;
215
+ /** The source conversation ID (where the activity happened). */
216
+ conversationId?: string;
217
+ /** The conversation/thread topic name. */
218
+ topic?: string;
219
+ /** Direct link to the activity in Teams. */
220
+ activityLink?: string;
221
+ }
222
+ /** Result of fetching the activity feed. */
223
+ export interface GetActivityResult {
224
+ /** Activity items (newest first). */
225
+ activities: ActivityItem[];
226
+ /** Sync state for incremental polling. */
227
+ syncState?: string;
228
+ }
229
+ /**
230
+ * Gets the activity feed (notifications) for the current user.
231
+ * Includes mentions, reactions, replies, and other notifications.
232
+ */
233
+ export declare function getActivityFeed(options?: {
234
+ limit?: number;
235
+ }, region?: string): Promise<Result<GetActivityResult>>;
@@ -9,7 +9,8 @@ import { ErrorCode, createError } from '../types/errors.js';
9
9
  import { ok, err } from '../types/result.js';
10
10
  import { getUserDisplayName } from '../auth/token-extractor.js';
11
11
  import { requireMessageAuth } from '../utils/auth-guards.js';
12
- import { stripHtml, buildMessageLink, buildOneOnOneConversationId, extractObjectId } from '../utils/parsers.js';
12
+ import { stripHtml, buildMessageLink, buildOneOnOneConversationId, extractObjectId, extractActivityTimestamp } from '../utils/parsers.js';
13
+ import { DEFAULT_ACTIVITY_LIMIT } from '../constants.js';
13
14
  /**
14
15
  * Sends a message to a Teams conversation.
15
16
  *
@@ -63,24 +64,11 @@ export async function sendNoteToSelf(content) {
63
64
  /**
64
65
  * Replies to a thread in a Teams channel.
65
66
  *
66
- * Uses the provided messageId directly as the thread root. In Teams channels:
67
- * - If messageId is a top-level post, the reply goes under that post
68
- * - If messageId is already a reply within a thread, the reply goes to the same thread
69
- *
70
- * For channel messages from search results, the messageId is typically the thread root
71
- * (the original message that started the thread).
72
- *
73
- * @param conversationId - The channel conversation ID (from search results)
74
- * @param messageId - The message ID to reply to (typically the thread root from search)
75
- * @param content - The reply content
76
- * @param region - API region (default: 'amer')
77
- * @returns The result including the new message ID
67
+ * Uses the provided messageId as the thread root. For search results,
68
+ * this is typically the original message that started the thread.
78
69
  */
79
70
  export async function replyToThread(conversationId, messageId, content, region = 'amer') {
80
- // Use the provided messageId directly as the thread root
81
- // Search results return the thread root ID for channel messages
82
71
  const threadRootMessageId = messageId;
83
- // Send the reply using the provided message ID as the thread root
84
72
  const sendResult = await sendMessage(conversationId, content, {
85
73
  region,
86
74
  replyToMessageId: threadRootMessageId,
@@ -420,15 +408,9 @@ function escapeHtml(text) {
420
408
  /**
421
409
  * Gets the conversation ID for a 1:1 chat with another user.
422
410
  *
423
- * This constructs the predictable conversation ID format used by Teams
424
- * for 1:1 chats. The conversation ID is: `19:{id1}_{id2}@unq.gbl.spaces`
425
- * where id1 and id2 are the two users' object IDs sorted lexicographically.
426
- *
427
- * Note: This doesn't create the conversation - it just returns the ID.
428
- * The conversation is implicitly created when the first message is sent.
429
- *
430
- * @param otherUserIdentifier - The other user's MRI, object ID, or ID with tenant
431
- * @returns The conversation ID, or an error if auth is missing or ID is invalid
411
+ * Constructs the predictable format: `19:{id1}_{id2}@unq.gbl.spaces`
412
+ * where IDs are sorted lexicographically. The conversation is created
413
+ * implicitly when the first message is sent.
432
414
  */
433
415
  export function getOneOnOneChatId(otherUserIdentifier) {
434
416
  const authResult = requireMessageAuth();
@@ -457,3 +439,227 @@ export function getOneOnOneChatId(otherUserIdentifier) {
457
439
  currentUserId,
458
440
  });
459
441
  }
442
+ /**
443
+ * Gets the consumption horizon (read receipts) for a conversation.
444
+ * The consumption horizon indicates where each user has read up to.
445
+ */
446
+ export async function getConsumptionHorizon(conversationId, region = 'amer') {
447
+ const authResult = requireMessageAuth();
448
+ if (!authResult.ok) {
449
+ return authResult;
450
+ }
451
+ const auth = authResult.value;
452
+ const validRegion = validateRegion(region);
453
+ const url = CHATSVC_API.consumptionHorizons(validRegion, conversationId);
454
+ const response = await httpRequest(url, {
455
+ method: 'GET',
456
+ headers: getSkypeAuthHeaders(auth.skypeToken, auth.authToken),
457
+ });
458
+ if (!response.ok) {
459
+ return response;
460
+ }
461
+ const data = response.value.data;
462
+ const horizons = data.consumptionhorizons || [];
463
+ // Find the current user's consumption horizon
464
+ let lastReadMessageId;
465
+ let lastReadTimestamp;
466
+ for (const h of horizons) {
467
+ if (h.id === auth.userMri || h.id.includes(auth.userMri)) {
468
+ // Consumption horizon format: "{timestamp};{timestamp};{messageId}"
469
+ const parts = h.consumptionhorizon.split(';');
470
+ if (parts.length >= 3) {
471
+ lastReadMessageId = parts[2];
472
+ lastReadTimestamp = parseInt(parts[0], 10);
473
+ }
474
+ break;
475
+ }
476
+ }
477
+ return ok({
478
+ conversationId,
479
+ version: data.version,
480
+ lastReadMessageId,
481
+ lastReadTimestamp,
482
+ consumptionHorizons: horizons.map(h => ({
483
+ id: h.id,
484
+ consumptionHorizon: h.consumptionhorizon,
485
+ })),
486
+ });
487
+ }
488
+ /**
489
+ * Marks a conversation as read up to a specific message.
490
+ */
491
+ export async function markAsRead(conversationId, messageId, region = 'amer') {
492
+ const authResult = requireMessageAuth();
493
+ if (!authResult.ok) {
494
+ return authResult;
495
+ }
496
+ const auth = authResult.value;
497
+ const validRegion = validateRegion(region);
498
+ const url = CHATSVC_API.updateConsumptionHorizon(validRegion, conversationId);
499
+ // Format: "{messageId};{messageId};{messageId}" - all three values are the same
500
+ const consumptionHorizon = `${messageId};${messageId};${messageId}`;
501
+ const response = await httpRequest(url, {
502
+ method: 'PUT',
503
+ headers: getSkypeAuthHeaders(auth.skypeToken, auth.authToken),
504
+ body: JSON.stringify({
505
+ consumptionhorizon: consumptionHorizon,
506
+ }),
507
+ });
508
+ if (!response.ok) {
509
+ return response;
510
+ }
511
+ return ok({
512
+ conversationId,
513
+ markedUpTo: messageId,
514
+ });
515
+ }
516
+ /**
517
+ * Gets unread count for a conversation by comparing consumption horizon
518
+ * with recent messages.
519
+ */
520
+ export async function getUnreadStatus(conversationId, region = 'amer') {
521
+ // Get consumption horizon
522
+ const horizonResult = await getConsumptionHorizon(conversationId, region);
523
+ if (!horizonResult.ok) {
524
+ return horizonResult;
525
+ }
526
+ // Get recent messages
527
+ const messagesResult = await getThreadMessages(conversationId, { limit: 50 }, region);
528
+ if (!messagesResult.ok) {
529
+ return messagesResult;
530
+ }
531
+ const lastReadId = horizonResult.value.lastReadMessageId;
532
+ const messages = messagesResult.value.messages;
533
+ // Count messages after the last read position
534
+ let unreadCount = 0;
535
+ let foundLastRead = false;
536
+ let latestMessageId;
537
+ // Messages are sorted oldest-first, so reverse to process newest-first
538
+ const reversedMessages = [...messages].reverse();
539
+ for (const msg of reversedMessages) {
540
+ if (!latestMessageId && !msg.isFromMe) {
541
+ latestMessageId = msg.id;
542
+ }
543
+ if (lastReadId && msg.id === lastReadId) {
544
+ foundLastRead = true;
545
+ break;
546
+ }
547
+ // Count messages not from the current user
548
+ if (!msg.isFromMe) {
549
+ unreadCount++;
550
+ }
551
+ }
552
+ // If last read message wasn't in our window, count is a lower bound
553
+ return ok({
554
+ conversationId,
555
+ unreadCount,
556
+ lastReadMessageId: lastReadId,
557
+ latestMessageId,
558
+ });
559
+ }
560
+ /**
561
+ * Determines the activity type from message content and properties.
562
+ */
563
+ function detectActivityType(msg) {
564
+ const content = msg.content || '';
565
+ const messageType = msg.messagetype || '';
566
+ // Check for @mention
567
+ if (content.includes('itemtype="http://schema.skype.com/Mention"') ||
568
+ content.includes('itemscope itemtype="http://schema.skype.com/Mention"')) {
569
+ return 'mention';
570
+ }
571
+ // Check for reaction-related message types
572
+ if (messageType.toLowerCase().includes('reaction')) {
573
+ return 'reaction';
574
+ }
575
+ // Check for thread/reply indicators
576
+ if (msg.threadtopic || msg.parentMessageId) {
577
+ return 'reply';
578
+ }
579
+ // Standard message
580
+ if (messageType.includes('RichText') || messageType.includes('Text')) {
581
+ return 'message';
582
+ }
583
+ return 'unknown';
584
+ }
585
+ /**
586
+ * Gets the activity feed (notifications) for the current user.
587
+ * Includes mentions, reactions, replies, and other notifications.
588
+ */
589
+ export async function getActivityFeed(options = {}, region = 'amer') {
590
+ const authResult = requireMessageAuth();
591
+ if (!authResult.ok) {
592
+ return authResult;
593
+ }
594
+ const auth = authResult.value;
595
+ const validRegion = validateRegion(region);
596
+ const limit = options.limit ?? DEFAULT_ACTIVITY_LIMIT;
597
+ let url = CHATSVC_API.activityFeed(validRegion);
598
+ url += `?view=msnp24Equivalent&pageSize=${limit}`;
599
+ const response = await httpRequest(url, {
600
+ method: 'GET',
601
+ headers: getSkypeAuthHeaders(auth.skypeToken, auth.authToken),
602
+ });
603
+ if (!response.ok) {
604
+ return response;
605
+ }
606
+ const rawMessages = response.value.data.messages;
607
+ const syncState = response.value.data.syncState;
608
+ if (!Array.isArray(rawMessages)) {
609
+ return ok({
610
+ activities: [],
611
+ syncState,
612
+ });
613
+ }
614
+ const activities = [];
615
+ for (const raw of rawMessages) {
616
+ const msg = raw;
617
+ // Skip control/system messages that aren't relevant
618
+ const messageType = msg.messagetype;
619
+ if (!messageType ||
620
+ messageType.startsWith('Control/') ||
621
+ messageType === 'ThreadActivity/AddMember' ||
622
+ messageType === 'ThreadActivity/DeleteMember') {
623
+ continue;
624
+ }
625
+ const id = msg.id || msg.originalarrivaltime;
626
+ if (!id)
627
+ continue;
628
+ const content = msg.content || '';
629
+ const contentType = msg.messagetype || 'Text';
630
+ const fromMri = msg.from || '';
631
+ const displayName = msg.imdisplayname || msg.displayName;
632
+ // Safely extract timestamp - returns null if no valid timestamp found
633
+ const timestamp = extractActivityTimestamp(msg);
634
+ if (!timestamp)
635
+ continue;
636
+ const conversationId = msg.conversationid || msg.conversationId;
637
+ const topic = msg.threadtopic || msg.topic;
638
+ // Build activity link if we have the conversation context
639
+ let activityLink;
640
+ if (conversationId && /^\d+$/.test(id)) {
641
+ activityLink = buildMessageLink(conversationId, id);
642
+ }
643
+ const activityType = detectActivityType(msg);
644
+ activities.push({
645
+ id,
646
+ type: activityType,
647
+ content: stripHtml(content),
648
+ contentType,
649
+ sender: {
650
+ mri: fromMri,
651
+ displayName,
652
+ },
653
+ timestamp,
654
+ conversationId,
655
+ topic,
656
+ activityLink,
657
+ });
658
+ }
659
+ // Sort by timestamp (newest first for activity feed)
660
+ activities.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
661
+ return ok({
662
+ activities,
663
+ syncState,
664
+ });
665
+ }
@@ -3,4 +3,5 @@
3
3
  */
4
4
  export * from './session-store.js';
5
5
  export * from './token-extractor.js';
6
+ export * from './token-refresh.js';
6
7
  export { encrypt, decrypt, isEncrypted, type EncryptedData } from './crypto.js';
@@ -3,4 +3,5 @@
3
3
  */
4
4
  export * from './session-store.js';
5
5
  export * from './token-extractor.js';
6
+ export * from './token-refresh.js';
6
7
  export { encrypt, decrypt, isEncrypted } from './crypto.js';