msteams-mcp 0.17.0 → 0.18.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.
@@ -9,7 +9,7 @@ import { CHATSVC_API, getMessagingHeaders, getSkypeAuthHeaders, getTeamsHeaders
9
9
  import { ErrorCode, createError } from '../types/errors.js';
10
10
  import { ok, err } from '../types/result.js';
11
11
  import { getUserDisplayName } from '../auth/token-extractor.js';
12
- import { requireMessageAuth, getRegion, getTeamsBaseUrl } from '../utils/auth-guards.js';
12
+ import { requireMessageAuth, getRegion, getTeamsBaseUrl, getTenantId } from '../utils/auth-guards.js';
13
13
  import { stripHtml, extractLinks, buildMessageLink, buildOneOnOneConversationId, extractObjectId, extractActivityTimestamp, parseVirtualConversationMessage, markdownToTeamsHtml } from '../utils/parsers.js';
14
14
  import { DEFAULT_ACTIVITY_LIMIT, SAVED_MESSAGES_ID, FOLLOWED_THREADS_ID, VIRTUAL_CONVERSATION_PREFIX, SELF_CHAT_ID, MRI_ORGID_PREFIX } from '../constants.js';
15
15
  // Reusable date formatter for human-readable timestamps with day of week
@@ -166,18 +166,24 @@ export async function getThreadMessages(conversationId, options = {}) {
166
166
  const timestamp = msg.originalarrivaltime ||
167
167
  msg.composetime ||
168
168
  new Date(parseInt(id, 10)).toISOString();
169
- // Build message link
169
+ // Extract thread root ID for channel messages
170
+ // When rootMessageId differs from id, this message is a reply within a thread
171
+ const rootMessageId = msg.rootMessageId;
172
+ const isThreadReply = !!rootMessageId && rootMessageId !== id;
173
+ // Build message link with tenant context for reliable deep links
170
174
  const messageLink = /^\d+$/.test(id)
171
- ? buildMessageLink(conversationId, id)
175
+ ? buildMessageLink({
176
+ conversationId,
177
+ messageId: id,
178
+ tenantId: getTenantId() ?? undefined,
179
+ parentMessageId: isThreadReply ? rootMessageId : undefined,
180
+ teamsBaseUrl: getTeamsBaseUrl(),
181
+ })
172
182
  : undefined;
173
183
  // Extract links before stripping HTML
174
184
  const links = extractLinks(content);
175
185
  // Format human-readable date with day of week to help LLMs
176
186
  const when = formatHumanReadableDate(timestamp);
177
- // Extract thread root ID for channel messages
178
- // When rootMessageId differs from id, this message is a reply within a thread
179
- const rootMessageId = msg.rootMessageId;
180
- const isThreadReply = !!rootMessageId && rootMessageId !== id;
181
187
  messages.push({
182
188
  id,
183
189
  content: stripHtml(content),
@@ -243,7 +249,7 @@ export async function getSavedMessages(options = {}) {
243
249
  }
244
250
  const messages = [];
245
251
  for (const raw of rawMessages) {
246
- const parsed = parseVirtualConversationMessage(raw, SAVED_MESSAGE_PATTERN);
252
+ const parsed = parseVirtualConversationMessage(raw, SAVED_MESSAGE_PATTERN, { tenantId: getTenantId() ?? undefined, teamsBaseUrl: getTeamsBaseUrl() });
247
253
  if (!parsed)
248
254
  continue;
249
255
  messages.push({
@@ -288,7 +294,7 @@ export async function getFollowedThreads(options = {}) {
288
294
  }
289
295
  const threads = [];
290
296
  for (const raw of rawMessages) {
291
- const parsed = parseVirtualConversationMessage(raw, FOLLOWED_THREAD_PATTERN);
297
+ const parsed = parseVirtualConversationMessage(raw, FOLLOWED_THREAD_PATTERN, { tenantId: getTenantId() ?? undefined, teamsBaseUrl: getTeamsBaseUrl() });
292
298
  if (!parsed)
293
299
  continue;
294
300
  threads.push({
@@ -998,7 +1004,12 @@ export async function getActivityFeed(options = {}) {
998
1004
  // Skip virtual conversations (48:xxx) as they don't produce working deep links
999
1005
  let activityLink;
1000
1006
  if (conversationId && !conversationId.startsWith(VIRTUAL_CONVERSATION_PREFIX) && /^\d+$/.test(id)) {
1001
- activityLink = buildMessageLink(conversationId, id);
1007
+ activityLink = buildMessageLink({
1008
+ conversationId,
1009
+ messageId: id,
1010
+ tenantId: getTenantId() ?? undefined,
1011
+ teamsBaseUrl: getTeamsBaseUrl(),
1012
+ });
1002
1013
  }
1003
1014
  const activityType = detectActivityType(msg);
1004
1015
  // Extract links before stripping HTML
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Files API client for shared file operations.
3
+ *
4
+ * Uses the Substrate AllFiles API to retrieve files and links
5
+ * shared in a Teams conversation.
6
+ */
7
+ import { type Result } from '../types/result.js';
8
+ /** A file shared in a conversation. */
9
+ export interface SharedFile {
10
+ /** Item type: "File" or "Link". */
11
+ itemType: 'File' | 'Link';
12
+ /** File name (for files). */
13
+ fileName?: string;
14
+ /** File extension (for files). */
15
+ fileExtension?: string;
16
+ /** URL to access the file or link. */
17
+ webUrl?: string;
18
+ /** File size in bytes (for files). */
19
+ fileSize?: number;
20
+ /** Display name of the person who shared the item. */
21
+ sharedBy?: string;
22
+ /** Email of the person who shared the item. */
23
+ sharedByEmail?: string;
24
+ /** When the item was shared (ISO timestamp). */
25
+ sharedTime?: string;
26
+ /** Title (for links). */
27
+ title?: string;
28
+ }
29
+ /** Result of getting shared files. */
30
+ export interface GetSharedFilesResult {
31
+ conversationId: string;
32
+ files: SharedFile[];
33
+ returned: number;
34
+ /** Continuation token for pagination (if more results available). */
35
+ skipToken?: string;
36
+ }
37
+ /**
38
+ * Gets files and links shared in a Teams conversation.
39
+ *
40
+ * Uses the Substrate AllFiles API which indexes files shared across
41
+ * Teams conversations. Supports both files (SharePoint/OneDrive) and
42
+ * links shared in chat.
43
+ *
44
+ * @param conversationId - The conversation ID to get files for
45
+ * @param options.pageSize - Number of items per page (default: 25, max: 100)
46
+ * @param options.skipToken - Continuation token for pagination
47
+ */
48
+ export declare function getSharedFiles(conversationId: string, options?: {
49
+ pageSize?: number;
50
+ skipToken?: string;
51
+ }): Promise<Result<GetSharedFilesResult>>;
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Files API client for shared file operations.
3
+ *
4
+ * Uses the Substrate AllFiles API to retrieve files and links
5
+ * shared in a Teams conversation.
6
+ */
7
+ import { httpRequest } from '../utils/http.js';
8
+ import { ok, err } from '../types/result.js';
9
+ import { ErrorCode, createError } from '../types/errors.js';
10
+ import { clearTokenCache } from '../auth/token-extractor.js';
11
+ import { requireSubstrateTokenAsync, getTenantId } from '../utils/auth-guards.js';
12
+ import { extractObjectId } from '../utils/parsers.js';
13
+ import { requireMessageAuth } from '../utils/auth-guards.js';
14
+ import { DEFAULT_FILES_PAGE_SIZE } from '../constants.js';
15
+ import { DEFAULT_SUBSTRATE_BASE_URL } from '../utils/api-config.js';
16
+ // ─────────────────────────────────────────────────────────────────────────────
17
+ // API Functions
18
+ // ─────────────────────────────────────────────────────────────────────────────
19
+ /**
20
+ * Gets files and links shared in a Teams conversation.
21
+ *
22
+ * Uses the Substrate AllFiles API which indexes files shared across
23
+ * Teams conversations. Supports both files (SharePoint/OneDrive) and
24
+ * links shared in chat.
25
+ *
26
+ * @param conversationId - The conversation ID to get files for
27
+ * @param options.pageSize - Number of items per page (default: 25, max: 100)
28
+ * @param options.skipToken - Continuation token for pagination
29
+ */
30
+ export async function getSharedFiles(conversationId, options = {}) {
31
+ // Need both Substrate token (for the API) and message auth (for user MRI)
32
+ const [tokenResult, authResult] = await Promise.all([
33
+ requireSubstrateTokenAsync(),
34
+ Promise.resolve(requireMessageAuth()),
35
+ ]);
36
+ if (!tokenResult.ok)
37
+ return tokenResult;
38
+ if (!authResult.ok)
39
+ return authResult;
40
+ const token = tokenResult.value;
41
+ const auth = authResult.value;
42
+ // Extract user's object ID from their MRI
43
+ const userId = extractObjectId(auth.userMri);
44
+ if (!userId) {
45
+ return err(createError(ErrorCode.AUTH_REQUIRED, 'Could not extract user ID from session. Please try logging in again.', { suggestions: ['Call teams_login to re-authenticate'] }));
46
+ }
47
+ // Get tenant ID
48
+ const tenantId = getTenantId();
49
+ if (!tenantId) {
50
+ return err(createError(ErrorCode.AUTH_REQUIRED, 'Could not determine tenant ID. Please try logging in again.', { suggestions: ['Call teams_login to re-authenticate'] }));
51
+ }
52
+ const pageSize = options.pageSize ?? DEFAULT_FILES_PAGE_SIZE;
53
+ // Build the AllFiles API URL
54
+ const userPrincipal = `OID:${userId}@${tenantId}`;
55
+ const params = new URLSearchParams();
56
+ params.set('ThreadId', conversationId);
57
+ params.set('ItemTypes', 'File');
58
+ params.append('ItemTypes', 'Link');
59
+ params.set('PageSize', String(pageSize));
60
+ if (options.skipToken) {
61
+ params.set('$skiptoken', options.skipToken);
62
+ }
63
+ const url = `${DEFAULT_SUBSTRATE_BASE_URL}/AllFiles/api/users('${encodeURIComponent(userPrincipal)}')/AllShared?${params.toString()}`;
64
+ const response = await httpRequest(url, {
65
+ method: 'GET',
66
+ headers: {
67
+ 'Authorization': `Bearer ${token}`,
68
+ 'Accept': 'application/json',
69
+ 'Content-Type': 'application/json',
70
+ },
71
+ });
72
+ if (!response.ok) {
73
+ if (response.error.code === ErrorCode.AUTH_EXPIRED) {
74
+ clearTokenCache();
75
+ }
76
+ return response;
77
+ }
78
+ const data = response.value.data;
79
+ const rawItems = data.Items;
80
+ const skipToken = data.SkipToken;
81
+ if (!rawItems || rawItems.length === 0) {
82
+ return ok({
83
+ conversationId,
84
+ files: [],
85
+ returned: 0,
86
+ skipToken: undefined,
87
+ });
88
+ }
89
+ const files = [];
90
+ for (const item of rawItems) {
91
+ const itemType = item.ItemType;
92
+ if (itemType === 'File') {
93
+ const fileData = item.FileData;
94
+ files.push({
95
+ itemType: 'File',
96
+ fileName: fileData?.FileName,
97
+ fileExtension: fileData?.FileExtension,
98
+ webUrl: fileData?.WebUrl,
99
+ fileSize: fileData?.FileSize,
100
+ sharedBy: item.SharedByDisplayName,
101
+ sharedByEmail: item.SharedBySmtp,
102
+ sharedTime: item.SharedTime,
103
+ });
104
+ }
105
+ else if (itemType === 'Link') {
106
+ const linkData = item.WeblinkData;
107
+ files.push({
108
+ itemType: 'Link',
109
+ webUrl: linkData?.WebUrl,
110
+ title: linkData?.Title,
111
+ sharedBy: item.SharedByDisplayName,
112
+ sharedByEmail: item.SharedBySmtp,
113
+ sharedTime: item.SharedTime,
114
+ });
115
+ }
116
+ }
117
+ return ok({
118
+ conversationId,
119
+ files,
120
+ returned: files.length,
121
+ skipToken: skipToken || undefined,
122
+ });
123
+ }
@@ -6,3 +6,4 @@ export * from './chatsvc-api.js';
6
6
  export * from './csa-api.js';
7
7
  export * from './calendar-api.js';
8
8
  export * from './transcript-api.js';
9
+ export * from './files-api.js';
package/dist/api/index.js CHANGED
@@ -6,3 +6,4 @@ export * from './chatsvc-api.js';
6
6
  export * from './csa-api.js';
7
7
  export * from './calendar-api.js';
8
8
  export * from './transcript-api.js';
9
+ export * from './files-api.js';
@@ -8,7 +8,7 @@ import { SUBSTRATE_API, getBearerHeaders } from '../utils/api-config.js';
8
8
  import { ErrorCode } from '../types/errors.js';
9
9
  import { ok } from '../types/result.js';
10
10
  import { clearTokenCache } from '../auth/token-extractor.js';
11
- import { requireSubstrateTokenAsync } from '../utils/auth-guards.js';
11
+ import { requireSubstrateTokenAsync, getTenantId, getTeamsBaseUrl } from '../utils/auth-guards.js';
12
12
  import { parseSearchResults, parsePeopleResults, parseChannelResults, filterChannelsByName, } from '../utils/parsers.js';
13
13
  import { getMyTeamsAndChannels } from './csa-api.js';
14
14
  /**
@@ -72,7 +72,11 @@ export async function searchMessages(query, options = {}) {
72
72
  return response;
73
73
  }
74
74
  const data = response.value.data;
75
- const { results, total } = parseSearchResults(data.EntitySets);
75
+ const linkContext = {
76
+ tenantId: getTenantId() ?? undefined,
77
+ teamsBaseUrl: getTeamsBaseUrl(),
78
+ };
79
+ const { results, total } = parseSearchResults(data.EntitySets, linkContext);
76
80
  const maxResults = options.maxResults ?? size;
77
81
  const limitedResults = results.slice(0, maxResults);
78
82
  return ok({
@@ -92,14 +92,15 @@ export async function getTranscriptContent(threadId, meetingDate) {
92
92
  try {
93
93
  transcriptData = JSON.parse(transcriptJsonStr);
94
94
  }
95
- catch {
96
- return err(createError(ErrorCode.UNKNOWN, 'Failed to parse transcript data.'));
95
+ catch (parseError) {
96
+ return err(createError(ErrorCode.UNKNOWN, `Failed to parse transcript data: ${parseError instanceof Error ? parseError.message : 'unknown error'}`));
97
97
  }
98
98
  const rawEntries = transcriptData.entries || [];
99
99
  if (rawEntries.length === 0) {
100
100
  return err(createError(ErrorCode.NOT_FOUND, 'Transcript is empty — no speech was detected during the meeting.'));
101
101
  }
102
102
  // Map to TranscriptEntry format
103
+ // Substrate returns timestamps with microsecond precision (extra 4 trailing zeros) — normalise to milliseconds
103
104
  const entries = rawEntries.map(e => ({
104
105
  startTime: (e.startOffset || '').replace(/0{4}$/, ''),
105
106
  endTime: (e.endOffset || '').replace(/0{4}$/, ''),
@@ -83,6 +83,10 @@ export declare const DEFAULT_MEETING_DAYS_AHEAD = 7;
83
83
  export declare const DEFAULT_MEETING_LIMIT = 50;
84
84
  /** Maximum limit for meeting results. */
85
85
  export declare const MAX_MEETING_LIMIT = 200;
86
+ /** Default page size for shared files. */
87
+ export declare const DEFAULT_FILES_PAGE_SIZE = 25;
88
+ /** Maximum page size for shared files. */
89
+ export declare const MAX_FILES_PAGE_SIZE = 100;
86
90
  /** Threshold for proactive token refresh (10 minutes before expiry). */
87
91
  export declare const TOKEN_REFRESH_THRESHOLD_MS: number;
88
92
  /** MRI type prefix for Teams/AAD users (type 8). */
package/dist/constants.js CHANGED
@@ -111,6 +111,13 @@ export const DEFAULT_MEETING_LIMIT = 50;
111
111
  /** Maximum limit for meeting results. */
112
112
  export const MAX_MEETING_LIMIT = 200;
113
113
  // ─────────────────────────────────────────────────────────────────────────────
114
+ // Files
115
+ // ─────────────────────────────────────────────────────────────────────────────
116
+ /** Default page size for shared files. */
117
+ export const DEFAULT_FILES_PAGE_SIZE = 25;
118
+ /** Maximum page size for shared files. */
119
+ export const MAX_FILES_PAGE_SIZE = 100;
120
+ // ─────────────────────────────────────────────────────────────────────────────
114
121
  // Token Refresh
115
122
  // ─────────────────────────────────────────────────────────────────────────────
116
123
  /** Threshold for proactive token refresh (10 minutes before expiry). */
@@ -0,0 +1,33 @@
1
+ /**
2
+ * File-related tool handlers.
3
+ */
4
+ import { z } from 'zod';
5
+ import type { RegisteredTool } from './index.js';
6
+ export declare const GetSharedFilesInputSchema: z.ZodObject<{
7
+ conversationId: z.ZodString;
8
+ pageSize: z.ZodDefault<z.ZodOptional<z.ZodNumber>>;
9
+ skipToken: z.ZodOptional<z.ZodString>;
10
+ }, "strip", z.ZodTypeAny, {
11
+ conversationId: string;
12
+ pageSize: number;
13
+ skipToken?: string | undefined;
14
+ }, {
15
+ conversationId: string;
16
+ pageSize?: number | undefined;
17
+ skipToken?: string | undefined;
18
+ }>;
19
+ export declare const getSharedFilesTool: RegisteredTool<typeof GetSharedFilesInputSchema>;
20
+ /** All file-related tools. */
21
+ export declare const fileTools: RegisteredTool<z.ZodObject<{
22
+ conversationId: z.ZodString;
23
+ pageSize: z.ZodDefault<z.ZodOptional<z.ZodNumber>>;
24
+ skipToken: z.ZodOptional<z.ZodString>;
25
+ }, "strip", z.ZodTypeAny, {
26
+ conversationId: string;
27
+ pageSize: number;
28
+ skipToken?: string | undefined;
29
+ }, {
30
+ conversationId: string;
31
+ pageSize?: number | undefined;
32
+ skipToken?: string | undefined;
33
+ }>>[];
@@ -0,0 +1,65 @@
1
+ /**
2
+ * File-related tool handlers.
3
+ */
4
+ import { z } from 'zod';
5
+ import { handleApiResult } from './index.js';
6
+ import { getSharedFiles } from '../api/files-api.js';
7
+ import { DEFAULT_FILES_PAGE_SIZE, MAX_FILES_PAGE_SIZE, } from '../constants.js';
8
+ // ─────────────────────────────────────────────────────────────────────────────
9
+ // Schemas
10
+ // ─────────────────────────────────────────────────────────────────────────────
11
+ export const GetSharedFilesInputSchema = z.object({
12
+ conversationId: z.string(),
13
+ pageSize: z.number().min(1).max(MAX_FILES_PAGE_SIZE).optional().default(DEFAULT_FILES_PAGE_SIZE),
14
+ skipToken: z.string().optional(),
15
+ });
16
+ // ─────────────────────────────────────────────────────────────────────────────
17
+ // Tool Definitions
18
+ // ─────────────────────────────────────────────────────────────────────────────
19
+ const getSharedFilesToolDefinition = {
20
+ name: 'teams_get_shared_files',
21
+ description: 'Get files and links shared in a Teams conversation. Returns file names, URLs, extensions, sizes, and who shared them. Works for channels, group chats, 1:1 chats, and meeting chats. Use the conversationId from other tools (teams_get_favorites, teams_search, teams_find_channel, teams_get_chat). Supports pagination via skipToken for conversations with many files.',
22
+ inputSchema: {
23
+ type: 'object',
24
+ properties: {
25
+ conversationId: {
26
+ type: 'string',
27
+ description: 'The conversation ID to get shared files for (e.g., "19:abc@thread.tacv2" for a channel, or a chat conversation ID).',
28
+ },
29
+ pageSize: {
30
+ type: 'number',
31
+ description: `Number of items per page (default: ${DEFAULT_FILES_PAGE_SIZE}, max: ${MAX_FILES_PAGE_SIZE})`,
32
+ },
33
+ skipToken: {
34
+ type: 'string',
35
+ description: 'Continuation token from a previous response to get the next page of results.',
36
+ },
37
+ },
38
+ required: ['conversationId'],
39
+ },
40
+ };
41
+ // ─────────────────────────────────────────────────────────────────────────────
42
+ // Handlers
43
+ // ─────────────────────────────────────────────────────────────────────────────
44
+ async function handleGetSharedFiles(input, _ctx) {
45
+ const result = await getSharedFiles(input.conversationId, {
46
+ pageSize: input.pageSize,
47
+ skipToken: input.skipToken,
48
+ });
49
+ return handleApiResult(result, (value) => ({
50
+ conversationId: value.conversationId,
51
+ returned: value.returned,
52
+ files: value.files,
53
+ ...(value.skipToken ? { skipToken: value.skipToken, hasMore: true } : { hasMore: false }),
54
+ }));
55
+ }
56
+ // ─────────────────────────────────────────────────────────────────────────────
57
+ // Exports
58
+ // ─────────────────────────────────────────────────────────────────────────────
59
+ export const getSharedFilesTool = {
60
+ definition: getSharedFilesToolDefinition,
61
+ schema: GetSharedFilesInputSchema,
62
+ handler: handleGetSharedFiles,
63
+ };
64
+ /** All file-related tools. */
65
+ export const fileTools = [getSharedFilesTool];
@@ -53,4 +53,5 @@ export * from './message-tools.js';
53
53
  export * from './people-tools.js';
54
54
  export * from './auth-tools.js';
55
55
  export * from './meeting-tools.js';
56
+ export * from './file-tools.js';
56
57
  export * from './registry.js';
@@ -31,4 +31,5 @@ export * from './message-tools.js';
31
31
  export * from './people-tools.js';
32
32
  export * from './auth-tools.js';
33
33
  export * from './meeting-tools.js';
34
+ export * from './file-tools.js';
34
35
  export * from './registry.js';
@@ -62,7 +62,7 @@ async function handleGetMeetings(input, _ctx) {
62
62
  // ─────────────────────────────────────────────────────────────────────────────
63
63
  const getTranscriptToolDefinition = {
64
64
  name: 'teams_get_transcript',
65
- description: 'Get the transcript of a Teams meeting. Requires the meeting\'s threadId (from teams_get_meetings). Returns the full transcript with timestamps and speakers, formatted as readable text. The meeting must have had transcription enabled. Optionally pass meetingDate (ISO string, e.g. the startTime from teams_get_meetings) to narrow the search.',
65
+ description: 'Get the transcript of a Teams meeting. Requires the meeting\'s threadId (from teams_get_meetings). Returns formatted transcript text with timestamps and speaker names, ready for reading or summarization. The meeting must have had transcription enabled. Optionally pass meetingDate (ISO string, e.g. the startTime from teams_get_meetings) to narrow the search.',
66
66
  inputSchema: {
67
67
  type: 'object',
68
68
  properties: {
@@ -8,6 +8,7 @@ import { messageTools } from './message-tools.js';
8
8
  import { peopleTools } from './people-tools.js';
9
9
  import { authTools } from './auth-tools.js';
10
10
  import { meetingTools } from './meeting-tools.js';
11
+ import { fileTools } from './file-tools.js';
11
12
  // ─────────────────────────────────────────────────────────────────────────────
12
13
  // Registry
13
14
  // ─────────────────────────────────────────────────────────────────────────────
@@ -19,6 +20,7 @@ const allTools = [
19
20
  ...peopleTools,
20
21
  ...authTools,
21
22
  ...meetingTools,
23
+ ...fileTools,
22
24
  ];
23
25
  /** Lookup map for tools by name. */
24
26
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -61,7 +61,14 @@ export declare function getTeamsBaseUrl(): string;
61
61
  */
62
62
  export declare function getRegionConfig(): RegionConfig | null;
63
63
  /**
64
- * Clears the cached region config.
64
+ * Clears the cached region config and tenant ID.
65
65
  * Call this after login/logout to pick up new session.
66
66
  */
67
67
  export declare function clearRegionCache(): void;
68
+ /**
69
+ * Gets the tenant ID from the user's session (JWT tokens).
70
+ *
71
+ * Required for building reliable Teams deep links.
72
+ * Returns null if no valid session is available.
73
+ */
74
+ export declare function getTenantId(): string | null;
@@ -6,7 +6,7 @@
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, extractSubstrateToken, extractSkypeSpacesToken, extractRegionConfig, } from '../auth/token-extractor.js';
9
+ import { getValidSubstrateToken, extractMessageAuth, extractCsaToken, extractSubstrateToken, extractSkypeSpacesToken, extractRegionConfig, getUserProfile, } from '../auth/token-extractor.js';
10
10
  import { TOKEN_REFRESH_THRESHOLD_MS } from '../constants.js';
11
11
  import { refreshTokensViaBrowser } from '../auth/token-refresh.js';
12
12
  // ─────────────────────────────────────────────────────────────────────────────
@@ -100,8 +100,8 @@ export function requireCalendarAuth() {
100
100
  import { DEFAULT_TEAMS_BASE_URL } from './api-config.js';
101
101
  /** Default region when session config is unavailable. */
102
102
  const DEFAULT_REGION = 'amer';
103
- /** Cached region config to avoid repeated localStorage parsing. */
104
- let cachedRegionConfig = null;
103
+ /** Cached region config (undefined = not yet extracted, null = extraction failed). */
104
+ let cachedRegionConfig = undefined;
105
105
  /**
106
106
  * Gets the user's region from session, with caching.
107
107
  *
@@ -109,7 +109,7 @@ let cachedRegionConfig = null;
109
109
  * Falls back to 'amer' if not available (shouldn't happen with valid session).
110
110
  */
111
111
  export function getRegion() {
112
- if (!cachedRegionConfig) {
112
+ if (cachedRegionConfig === undefined) {
113
113
  cachedRegionConfig = extractRegionConfig();
114
114
  }
115
115
  return cachedRegionConfig?.region ?? DEFAULT_REGION;
@@ -122,7 +122,7 @@ export function getRegion() {
122
122
  * Falls back to default if config not available.
123
123
  */
124
124
  export function getTeamsBaseUrl() {
125
- if (!cachedRegionConfig) {
125
+ if (cachedRegionConfig === undefined) {
126
126
  cachedRegionConfig = extractRegionConfig();
127
127
  }
128
128
  return cachedRegionConfig?.teamsBaseUrl ?? DEFAULT_TEAMS_BASE_URL;
@@ -133,15 +133,36 @@ export function getTeamsBaseUrl() {
133
133
  * Returns null if no valid session - caller should handle auth error.
134
134
  */
135
135
  export function getRegionConfig() {
136
- if (!cachedRegionConfig) {
136
+ if (cachedRegionConfig === undefined) {
137
137
  cachedRegionConfig = extractRegionConfig();
138
138
  }
139
139
  return cachedRegionConfig;
140
140
  }
141
141
  /**
142
- * Clears the cached region config.
142
+ * Clears the cached region config and tenant ID.
143
143
  * Call this after login/logout to pick up new session.
144
144
  */
145
145
  export function clearRegionCache() {
146
- cachedRegionConfig = null;
146
+ cachedRegionConfig = undefined;
147
+ cachedTenantId = undefined;
148
+ }
149
+ // ─────────────────────────────────────────────────────────────────────────────
150
+ // Tenant ID
151
+ // ─────────────────────────────────────────────────────────────────────────────
152
+ /** Cached tenant ID (undefined = not yet extracted, null = extraction failed). */
153
+ let cachedTenantId = undefined;
154
+ /**
155
+ * Gets the tenant ID from the user's session (JWT tokens).
156
+ *
157
+ * Required for building reliable Teams deep links.
158
+ * Returns null if no valid session is available.
159
+ */
160
+ export function getTenantId() {
161
+ if (cachedTenantId !== undefined) {
162
+ return cachedTenantId;
163
+ }
164
+ const profile = getUserProfile();
165
+ const tid = profile?.tenantId ?? null;
166
+ cachedTenantId = tid;
167
+ return tid;
147
168
  }
@@ -47,17 +47,33 @@ export declare function stripHtml(html: string): string;
47
47
  * - Group chats: 19:xxx@thread.v2 (non-meeting)
48
48
  */
49
49
  export declare function getConversationType(conversationId: string): 'channel' | 'meeting' | 'chat';
50
+ /** Options for building a message deep link. */
51
+ export interface MessageLinkOptions {
52
+ /** The conversation/channel/chat ID (e.g., "19:xxx@thread.tacv2"). */
53
+ conversationId: string;
54
+ /** The message timestamp in epoch milliseconds. */
55
+ messageId: string | number;
56
+ /** Tenant ID (GUID) - required for reliable deep links. */
57
+ tenantId?: string;
58
+ /** For channel messages: the team's group ID (GUID). */
59
+ groupId?: string;
60
+ /** For channel messages: the parent/root message ID (epoch ms). If omitted for channels, messageId is used. */
61
+ parentMessageId?: string;
62
+ /** Teams base URL (for GCC/GCC-High support). */
63
+ teamsBaseUrl?: string;
64
+ }
50
65
  /**
51
66
  * Builds a deep link to open a message in Teams.
52
67
  *
53
- * Different conversation types require different URL formats:
54
- * - Channels: /l/message/{channelId}/{msgId}?parentMessageId={parentId} (for thread replies)
55
- * - Chats/Meetings: /l/message/{chatId}/{msgId}?context={"contextType":"chat"}
68
+ * Uses Microsoft's documented deep link formats:
69
+ * - Channels: /l/message/{channelId}/{msgId}?tenantId=...&groupId=...&parentMessageId=...
70
+ * - Chats/Meetings: /l/message/{chatId}/{msgId}?tenantId=...&context={"contextType":"chat"}
56
71
  *
57
- * @param conversationId - The conversation/thread ID (e.g., "19:xxx@thread.tacv2")
58
- * @param messageTimestamp - The message timestamp in epoch milliseconds
59
- * @param parentMessageId - For channel thread replies, the ID of the parent/root message
60
- * @param teamsBaseUrl - Optional Teams base URL (for GCC/GCC-High support)
72
+ * @see https://learn.microsoft.com/en-us/microsoftteams/platform/concepts/build-and-test/deep-link-teams
73
+ */
74
+ export declare function buildMessageLink(opts: MessageLinkOptions): string;
75
+ /**
76
+ * @deprecated Use the options object overload instead.
61
77
  */
62
78
  export declare function buildMessageLink(conversationId: string, messageTimestamp: string | number, parentMessageId?: string, teamsBaseUrl?: string): string;
63
79
  /**
@@ -77,10 +93,17 @@ export declare function extractMessageTimestamp(source: Record<string, unknown>
77
93
  * - Base64-encoded GUID: "93qkaTtFGWpUHjyRafgdhg=="
78
94
  */
79
95
  export declare function parsePersonSuggestion(item: Record<string, unknown>): PersonSearchResult | null;
96
+ /** Context for building reliable message deep links. */
97
+ export interface LinkContext {
98
+ /** Tenant ID (GUID) from session. */
99
+ tenantId?: string;
100
+ /** Teams base URL (for GCC/GCC-High support). */
101
+ teamsBaseUrl?: string;
102
+ }
80
103
  /**
81
104
  * Parses a v2 query result item into a search result.
82
105
  */
83
- export declare function parseV2Result(item: Record<string, unknown>): TeamsSearchResult | null;
106
+ export declare function parseV2Result(item: Record<string, unknown>, linkContext?: LinkContext): TeamsSearchResult | null;
84
107
  /**
85
108
  * Parses user profile from a JWT payload.
86
109
  *
@@ -106,7 +129,7 @@ export declare function calculateTokenStatus(expiryMs: number, nowMs?: number):
106
129
  * @param entitySets - Raw EntitySets array from API response
107
130
  * @returns Parsed results and total count if available
108
131
  */
109
- export declare function parseSearchResults(entitySets: unknown[] | undefined): {
132
+ export declare function parseSearchResults(entitySets: unknown[] | undefined, linkContext?: LinkContext): {
110
133
  results: TeamsSearchResult[];
111
134
  total?: number;
112
135
  };
@@ -265,7 +288,7 @@ export interface VirtualConversationItem {
265
288
  * @param referencePattern - Regex to extract source ID from secondaryReferenceId
266
289
  * @returns Parsed virtual conversation item, or null if message should be skipped
267
290
  */
268
- export declare function parseVirtualConversationMessage(msg: Record<string, unknown>, referencePattern: RegExp): VirtualConversationItem | null;
291
+ export declare function parseVirtualConversationMessage(msg: Record<string, unknown>, referencePattern: RegExp, linkContext?: LinkContext): VirtualConversationItem | null;
269
292
  /** A single entry from a meeting transcript. */
270
293
  export interface TranscriptEntry {
271
294
  /** Start time (e.g., "00:00:22.287"). */
@@ -59,32 +59,44 @@ export function getConversationType(conversationId) {
59
59
  }
60
60
  /** Default Teams base URL for message links. */
61
61
  const DEFAULT_TEAMS_LINK_BASE = 'https://teams.microsoft.com';
62
- /**
63
- * Builds a deep link to open a message in Teams.
64
- *
65
- * Different conversation types require different URL formats:
66
- * - Channels: /l/message/{channelId}/{msgId}?parentMessageId={parentId} (for thread replies)
67
- * - Chats/Meetings: /l/message/{chatId}/{msgId}?context={"contextType":"chat"}
68
- *
69
- * @param conversationId - The conversation/thread ID (e.g., "19:xxx@thread.tacv2")
70
- * @param messageTimestamp - The message timestamp in epoch milliseconds
71
- * @param parentMessageId - For channel thread replies, the ID of the parent/root message
72
- * @param teamsBaseUrl - Optional Teams base URL (for GCC/GCC-High support)
73
- */
74
- export function buildMessageLink(conversationId, messageTimestamp, parentMessageId, teamsBaseUrl = DEFAULT_TEAMS_LINK_BASE) {
75
- const timestamp = typeof messageTimestamp === 'string' ? messageTimestamp : String(messageTimestamp);
76
- const linkUrl = `${teamsBaseUrl}/l/message/${encodeURIComponent(conversationId)}/${timestamp}`;
77
- const convType = getConversationType(conversationId);
78
- // Chats and meetings require the context parameter
79
- if (convType === 'chat' || convType === 'meeting') {
80
- const context = encodeURIComponent('{"contextType":"chat"}');
81
- return `${linkUrl}?context=${context}`;
82
- }
83
- // Channel messages - add parentMessageId for thread replies
84
- if (convType === 'channel' && parentMessageId && parentMessageId !== timestamp) {
85
- return `${linkUrl}?parentMessageId=${parentMessageId}`;
86
- }
87
- return linkUrl;
62
+ export function buildMessageLink(optsOrConversationId, messageTimestamp, parentMessageId, teamsBaseUrl) {
63
+ // Normalise arguments: support both old positional and new options-object signatures
64
+ let opts;
65
+ if (typeof optsOrConversationId === 'string') {
66
+ opts = {
67
+ conversationId: optsOrConversationId,
68
+ messageId: messageTimestamp,
69
+ parentMessageId,
70
+ teamsBaseUrl,
71
+ };
72
+ }
73
+ else {
74
+ opts = optsOrConversationId;
75
+ }
76
+ const base = opts.teamsBaseUrl ?? DEFAULT_TEAMS_LINK_BASE;
77
+ const msgId = typeof opts.messageId === 'string' ? opts.messageId : String(opts.messageId);
78
+ const convType = getConversationType(opts.conversationId);
79
+ // Build the base URL path encode the conversationId for URL safety
80
+ const linkUrl = `${base}/l/message/${encodeURIComponent(opts.conversationId)}/${msgId}`;
81
+ const params = new URLSearchParams();
82
+ if (convType === 'channel') {
83
+ // Channel deep links require tenantId, groupId, and parentMessageId
84
+ if (opts.tenantId)
85
+ params.set('tenantId', opts.tenantId);
86
+ if (opts.groupId)
87
+ params.set('groupId', opts.groupId);
88
+ // parentMessageId is always required — for top-level posts it equals the messageId
89
+ params.set('parentMessageId', opts.parentMessageId ?? msgId);
90
+ params.set('createdTime', msgId);
91
+ }
92
+ else {
93
+ // Chat and meeting deep links require tenantId and context
94
+ if (opts.tenantId)
95
+ params.set('tenantId', opts.tenantId);
96
+ params.set('context', '{"contextType":"chat"}');
97
+ }
98
+ const qs = params.toString();
99
+ return qs ? `${linkUrl}?${qs}` : linkUrl;
88
100
  }
89
101
  /**
90
102
  * Extracts a timestamp-based message ID from various sources.
@@ -170,7 +182,7 @@ export function parsePersonSuggestion(item) {
170
182
  /**
171
183
  * Parses a v2 query result item into a search result.
172
184
  */
173
- export function parseV2Result(item) {
185
+ export function parseV2Result(item, linkContext) {
174
186
  const content = item.HitHighlightedSummary ||
175
187
  item.Summary ||
176
188
  '';
@@ -235,7 +247,13 @@ export function parseV2Result(item) {
235
247
  // Build message link if we have the required data
236
248
  let messageLink;
237
249
  if (conversationId && messageTimestamp) {
238
- messageLink = buildMessageLink(conversationId, messageTimestamp, parentMessageId);
250
+ messageLink = buildMessageLink({
251
+ conversationId,
252
+ messageId: messageTimestamp,
253
+ tenantId: linkContext?.tenantId,
254
+ parentMessageId,
255
+ teamsBaseUrl: linkContext?.teamsBaseUrl,
256
+ });
239
257
  }
240
258
  return {
241
259
  id,
@@ -317,7 +335,7 @@ export function calculateTokenStatus(expiryMs, nowMs = Date.now()) {
317
335
  * @param entitySets - Raw EntitySets array from API response
318
336
  * @returns Parsed results and total count if available
319
337
  */
320
- export function parseSearchResults(entitySets) {
338
+ export function parseSearchResults(entitySets, linkContext) {
321
339
  const results = [];
322
340
  let total;
323
341
  if (!Array.isArray(entitySets)) {
@@ -337,7 +355,7 @@ export function parseSearchResults(entitySets) {
337
355
  const items = rs.Results;
338
356
  if (Array.isArray(items)) {
339
357
  for (const item of items) {
340
- const parsed = parseV2Result(item);
358
+ const parsed = parseV2Result(item, linkContext);
341
359
  if (parsed)
342
360
  results.push(parsed);
343
361
  }
@@ -829,7 +847,7 @@ export function hasMarkdownFormatting(text) {
829
847
  * @param referencePattern - Regex to extract source ID from secondaryReferenceId
830
848
  * @returns Parsed virtual conversation item, or null if message should be skipped
831
849
  */
832
- export function parseVirtualConversationMessage(msg, referencePattern) {
850
+ export function parseVirtualConversationMessage(msg, referencePattern, linkContext) {
833
851
  // Skip non-message types
834
852
  const messageType = msg.messagetype || msg.type;
835
853
  if (!messageType || messageType.startsWith('Control/')) {
@@ -859,7 +877,12 @@ export function parseVirtualConversationMessage(msg, referencePattern) {
859
877
  }
860
878
  // Build message link to original message
861
879
  const messageLink = sourceConversationId && sourceReferenceId
862
- ? buildMessageLink(sourceConversationId, sourceReferenceId)
880
+ ? buildMessageLink({
881
+ conversationId: sourceConversationId,
882
+ messageId: sourceReferenceId,
883
+ tenantId: linkContext?.tenantId,
884
+ teamsBaseUrl: linkContext?.teamsBaseUrl,
885
+ })
863
886
  : undefined;
864
887
  // Extract links before stripping HTML
865
888
  const links = extractLinks(content);
@@ -91,9 +91,11 @@ describe('getConversationType', () => {
91
91
  });
92
92
  });
93
93
  describe('buildMessageLink', () => {
94
- it('builds channel link without context parameter', () => {
94
+ it('builds channel link with parentMessageId and createdTime', () => {
95
+ // Channel links always include parentMessageId (defaults to messageId for top-level posts)
95
96
  const link = buildMessageLink('19:abc@thread.tacv2', '1705760000000');
96
- expect(link).toBe('https://teams.microsoft.com/l/message/19%3Aabc%40thread.tacv2/1705760000000');
97
+ expect(link).toContain('parentMessageId=1705760000000');
98
+ expect(link).toContain('createdTime=1705760000000');
97
99
  expect(link).not.toContain('context');
98
100
  });
99
101
  it('builds chat link with context parameter', () => {
@@ -111,17 +113,47 @@ describe('buildMessageLink', () => {
111
113
  it('builds channel thread reply link with parentMessageId', () => {
112
114
  // Thread reply: message timestamp differs from parent
113
115
  const link = buildMessageLink('19:abc@thread.tacv2', '1705770000000', '1705760000000');
114
- expect(link).toBe('https://teams.microsoft.com/l/message/19%3Aabc%40thread.tacv2/1705770000000?parentMessageId=1705760000000');
116
+ expect(link).toContain('parentMessageId=1705760000000');
117
+ expect(link).toContain('createdTime=1705770000000');
115
118
  });
116
- it('omits parentMessageId for top-level channel posts', () => {
117
- // Top-level post: message timestamp equals parent (or no parent)
118
- const link = buildMessageLink('19:abc@thread.tacv2', '1705760000000', '1705760000000');
119
- expect(link).not.toContain('parentMessageId');
119
+ it('includes parentMessageId for top-level channel posts (defaults to messageId)', () => {
120
+ // Per MS docs, parentMessageId is always required for channel links
121
+ const link = buildMessageLink('19:abc@thread.tacv2', '1705760000000');
122
+ expect(link).toContain('parentMessageId=1705760000000');
120
123
  });
121
124
  it('encodes special characters in conversation ID', () => {
122
125
  const link = buildMessageLink('19:special@thread.tacv2', '123');
123
126
  expect(link).toContain('19%3Aspecial%40thread.tacv2');
124
127
  });
128
+ it('builds channel link with tenantId and groupId via options object', () => {
129
+ const link = buildMessageLink({
130
+ conversationId: '19:abc@thread.tacv2',
131
+ messageId: '1705760000000',
132
+ tenantId: '0d9b645f-597b-41f0-a2a3-ef103fbd91bb',
133
+ groupId: '3606f714-ec2e-41b3-9ad1-6afb331bd35d',
134
+ });
135
+ expect(link).toContain('tenantId=0d9b645f-597b-41f0-a2a3-ef103fbd91bb');
136
+ expect(link).toContain('groupId=3606f714-ec2e-41b3-9ad1-6afb331bd35d');
137
+ expect(link).toContain('parentMessageId=1705760000000');
138
+ expect(link).toContain('createdTime=1705760000000');
139
+ });
140
+ it('builds chat link with tenantId via options object', () => {
141
+ const link = buildMessageLink({
142
+ conversationId: '19:guid1_guid2@unq.gbl.spaces',
143
+ messageId: '1705760000000',
144
+ tenantId: '0d9b645f-597b-41f0-a2a3-ef103fbd91bb',
145
+ });
146
+ expect(link).toContain('tenantId=0d9b645f-597b-41f0-a2a3-ef103fbd91bb');
147
+ expect(link).toContain('context=%7B%22contextType%22%3A%22chat%22%7D');
148
+ });
149
+ it('uses custom teamsBaseUrl for GCC support', () => {
150
+ const link = buildMessageLink({
151
+ conversationId: '19:guid1_guid2@unq.gbl.spaces',
152
+ messageId: '1705760000000',
153
+ teamsBaseUrl: 'https://teams.microsoft.us',
154
+ });
155
+ expect(link.startsWith('https://teams.microsoft.us/l/message/')).toBe(true);
156
+ });
125
157
  });
126
158
  describe('extractMessageTimestamp', () => {
127
159
  it('extracts from MessageId field', () => {
@@ -252,11 +284,13 @@ describe('parseV2Result', () => {
252
284
  // The message's own timestamp (from DateTimeReceived 2026-01-20T15:00:00.000Z)
253
285
  expect(result.messageLink).toContain('/1768921200000');
254
286
  });
255
- it('generates messageLink without parentMessageId for top-level posts', () => {
287
+ it('generates messageLink with parentMessageId for top-level channel posts', () => {
256
288
  const result = parseV2Result(searchResultItem);
257
289
  expect(result).not.toBeNull();
258
- // Top-level post: messageid matches the message timestamp, so no parentMessageId needed
259
- expect(result.messageLink).not.toContain('parentMessageId');
290
+ // Top-level post: parentMessageId equals the message's own timestamp
291
+ // Per MS docs, parentMessageId is always required for channel links
292
+ expect(result.messageLink).toContain('parentMessageId=1768919400000');
293
+ expect(result.messageLink).toContain('createdTime=1768919400000');
260
294
  });
261
295
  it('generates messageLink with context for meeting chats', () => {
262
296
  const result = parseV2Result(searchResultWithHtml);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "msteams-mcp",
3
- "version": "0.17.0",
3
+ "version": "0.18.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",