msteams-mcp 0.23.0 → 0.23.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.
@@ -72,7 +72,8 @@ export async function getActivityFeed(options = {}) {
72
72
  let syncState;
73
73
  if (metadata?.syncState) {
74
74
  try {
75
- syncState = new URL(metadata.syncState).searchParams.get('syncState') ?? undefined;
75
+ const metaUrl = new URL(metadata.syncState);
76
+ syncState = metaUrl.searchParams.get('syncState') ?? undefined;
76
77
  }
77
78
  catch {
78
79
  syncState = metadata.syncState;
@@ -125,14 +125,12 @@ export declare function getThreadMessages(conversationId: string, options?: {
125
125
  /**
126
126
  * Edits an existing message.
127
127
  *
128
- * Uses the same content pipeline as {@link sendMessage} (markdown, @[mentions], links).
129
- *
130
128
  * Note: You can only edit your own messages. The API will reject
131
129
  * attempts to edit messages from other users.
132
130
  *
133
131
  * @param conversationId - The conversation containing the message
134
132
  * @param messageId - The ID of the message to edit
135
- * @param newContent - New content (markdown; supports mentions like sendMessage)
133
+ * @param newContent - The new content for the message
136
134
  */
137
135
  export declare function editMessage(conversationId: string, messageId: string, newContent: string): Promise<Result<EditMessageResult>>;
138
136
  /**
@@ -268,14 +268,12 @@ export async function getThreadMessages(conversationId, options = {}) {
268
268
  /**
269
269
  * Edits an existing message.
270
270
  *
271
- * Uses the same content pipeline as {@link sendMessage} (markdown, @[mentions], links).
272
- *
273
271
  * Note: You can only edit your own messages. The API will reject
274
272
  * attempts to edit messages from other users.
275
273
  *
276
274
  * @param conversationId - The conversation containing the message
277
275
  * @param messageId - The ID of the message to edit
278
- * @param newContent - New content (markdown; supports mentions like sendMessage)
276
+ * @param newContent - The new content for the message
279
277
  */
280
278
  export async function editMessage(conversationId, messageId, newContent) {
281
279
  const authResult = requireMessageAuthWithConfig();
@@ -284,10 +282,11 @@ export async function editMessage(conversationId, messageId, newContent) {
284
282
  }
285
283
  const { auth, region, baseUrl } = authResult.value;
286
284
  const displayName = getUserDisplayName() || 'User';
287
- // Same pipeline as sendMessage: markdown, @[mentions](mri), links, then mentions property.
288
- const parsed = parseContentWithMentionsAndLinks(newContent);
289
- const htmlContent = parsed.html;
290
- const mentionsToSend = parsed.mentions;
285
+ // Always convert through markdown→HTML pipeline (never pass raw HTML through,
286
+ // as Teams requires proper block-level wrapping like <p> tags)
287
+ const htmlContent = markdownToTeamsHtml(newContent);
288
+ // Build the edit request body
289
+ // The API requires the message structure with updated content
291
290
  const body = {
292
291
  id: messageId,
293
292
  type: 'Message',
@@ -297,11 +296,6 @@ export async function editMessage(conversationId, messageId, newContent) {
297
296
  contenttype: 'text',
298
297
  imdisplayname: displayName,
299
298
  };
300
- if (mentionsToSend.length > 0) {
301
- body.properties = {
302
- mentions: buildMentionsProperty(mentionsToSend),
303
- };
304
- }
305
299
  const url = CHATSVC_API.editMessage(region, conversationId, messageId, baseUrl);
306
300
  const response = await httpRequest(url, {
307
301
  method: 'PUT',
@@ -158,8 +158,6 @@ export async function getUnreadConversations() {
158
158
  if (!lastMsg?.id)
159
159
  continue;
160
160
  const lastMsgTime = parseInt(lastMsg.id, 10);
161
- if (isNaN(lastMsgTime))
162
- continue;
163
161
  const fromMe = lastMsg.from?.includes(auth.userMri);
164
162
  const isChannel = tp.threadType === 'channel' || c.id.includes('@thread.tacv2');
165
163
  const displayName = tp.topic || lastMsg.imdisplayname;
@@ -179,17 +177,12 @@ export async function getUnreadConversations() {
179
177
  continue;
180
178
  }
181
179
  const readUpTo = parseInt(horizon.split(';')[0], 10);
182
- if (isNaN(readUpTo))
183
- continue; // malformed horizon — skip rather than misclassify
184
180
  if (lastMsgTime <= readUpTo)
185
181
  continue; // Already read
186
182
  // For channels, preserve original behavior (skip if last msg is ours).
187
183
  // For chats, only skip if read horizon is within 2s of our reply —
188
184
  // a larger gap means unread messages exist before our reply.
189
- // 2-second (2000 ms) window: if the read horizon is this close to our last message,
190
- // assume we've read everything and there's no unread gap before our reply.
191
- const SELF_REPLY_READ_WINDOW_MS = 2000;
192
- if (fromMe && (isChannel || (lastMsgTime - readUpTo) < SELF_REPLY_READ_WINDOW_MS))
185
+ if (fromMe && (isChannel || (lastMsgTime - readUpTo) < 2000))
193
186
  continue;
194
187
  const entry = {
195
188
  conversationId: c.id,
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Microsoft Graph API - Spike for sending messages.
3
+ *
4
+ * This is a spike/experiment to test whether we can use the Graph API
5
+ * with tokens extracted from the Teams browser session (no Azure App
6
+ * registration required).
7
+ *
8
+ * Graph API send message endpoint:
9
+ * POST https://graph.microsoft.com/v1.0/chats/{chatId}/messages
10
+ *
11
+ * Note: Graph API uses a different chat ID format than chatsvc. The
12
+ * conversationId from Teams (e.g., "19:xxx@thread.v2") should work
13
+ * directly as the Graph chatId.
14
+ */
15
+ import { type Result } from '../types/result.js';
16
+ /** Result of sending a message via Graph API. */
17
+ export interface GraphSendMessageResult {
18
+ /** The Graph-assigned message ID. */
19
+ id: string;
20
+ /** When the message was created (ISO 8601). */
21
+ createdDateTime?: string;
22
+ /** The web URL for the message (if returned). */
23
+ webUrl?: string;
24
+ /** Raw Graph API response for debugging in spike. */
25
+ _raw?: unknown;
26
+ }
27
+ /**
28
+ * Sends a message to a Teams chat via the Microsoft Graph API.
29
+ *
30
+ * This is a spike to test Graph API access using tokens from the Teams
31
+ * browser session. The Graph API endpoint is:
32
+ * POST /v1.0/chats/{chatId}/messages
33
+ *
34
+ * For channel messages, the endpoint would be:
35
+ * POST /v1.0/teams/{teamId}/channels/{channelId}/messages
36
+ *
37
+ * @param chatId - The Teams conversation ID (e.g., "19:xxx@thread.v2")
38
+ * @param content - The message content (plain text or HTML)
39
+ * @param contentType - "text" for plain text, "html" for HTML (default: "text")
40
+ */
41
+ export declare function graphSendMessage(chatId: string, content: string, contentType?: 'text' | 'html'): Promise<Result<GraphSendMessageResult>>;
42
+ /**
43
+ * Sends a message to a Teams channel via the Microsoft Graph API.
44
+ *
45
+ * POST /v1.0/teams/{teamId}/channels/{channelId}/messages
46
+ *
47
+ * Note: For channels, we need the teamId (group ID) and channelId separately.
48
+ * This is different from the chatsvc API which uses a single conversationId.
49
+ *
50
+ * @param teamId - The team's group ID (GUID)
51
+ * @param channelId - The channel ID (e.g., "19:xxx@thread.tacv2")
52
+ * @param content - The message content
53
+ * @param contentType - "text" for plain text, "html" for HTML (default: "text")
54
+ */
55
+ export declare function graphSendChannelMessage(teamId: string, channelId: string, content: string, contentType?: 'text' | 'html'): Promise<Result<GraphSendMessageResult>>;
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Microsoft Graph API - Spike for sending messages.
3
+ *
4
+ * This is a spike/experiment to test whether we can use the Graph API
5
+ * with tokens extracted from the Teams browser session (no Azure App
6
+ * registration required).
7
+ *
8
+ * Graph API send message endpoint:
9
+ * POST https://graph.microsoft.com/v1.0/chats/{chatId}/messages
10
+ *
11
+ * Note: Graph API uses a different chat ID format than chatsvc. The
12
+ * conversationId from Teams (e.g., "19:xxx@thread.v2") should work
13
+ * directly as the Graph chatId.
14
+ */
15
+ import { httpRequest } from '../utils/http.js';
16
+ import { ErrorCode, createError } from '../types/errors.js';
17
+ import { ok, err } from '../types/result.js';
18
+ import { requireGraphAuth } from '../utils/auth-guards.js';
19
+ // ─────────────────────────────────────────────────────────────────────────────
20
+ // Constants
21
+ // ─────────────────────────────────────────────────────────────────────────────
22
+ const GRAPH_BASE_URL = 'https://graph.microsoft.com/v1.0';
23
+ // ─────────────────────────────────────────────────────────────────────────────
24
+ // Send Message via Graph API
25
+ // ─────────────────────────────────────────────────────────────────────────────
26
+ /**
27
+ * Sends a message to a Teams chat via the Microsoft Graph API.
28
+ *
29
+ * This is a spike to test Graph API access using tokens from the Teams
30
+ * browser session. The Graph API endpoint is:
31
+ * POST /v1.0/chats/{chatId}/messages
32
+ *
33
+ * For channel messages, the endpoint would be:
34
+ * POST /v1.0/teams/{teamId}/channels/{channelId}/messages
35
+ *
36
+ * @param chatId - The Teams conversation ID (e.g., "19:xxx@thread.v2")
37
+ * @param content - The message content (plain text or HTML)
38
+ * @param contentType - "text" for plain text, "html" for HTML (default: "text")
39
+ */
40
+ export async function graphSendMessage(chatId, content, contentType = 'text') {
41
+ const authResult = requireGraphAuth();
42
+ if (!authResult.ok) {
43
+ return authResult;
44
+ }
45
+ const { graphToken } = authResult.value;
46
+ const url = `${GRAPH_BASE_URL}/chats/${encodeURIComponent(chatId)}/messages`;
47
+ const body = {
48
+ body: {
49
+ contentType,
50
+ content,
51
+ },
52
+ };
53
+ const response = await httpRequest(url, {
54
+ method: 'POST',
55
+ headers: {
56
+ 'Content-Type': 'application/json',
57
+ 'Authorization': `Bearer ${graphToken}`,
58
+ },
59
+ body: JSON.stringify(body),
60
+ // Don't retry auth errors - they indicate the token doesn't have the right scopes
61
+ maxRetries: 1,
62
+ });
63
+ if (!response.ok) {
64
+ // Enhance error message with Graph-specific context
65
+ const errorMessage = response.error.message;
66
+ // Check for common Graph API permission errors
67
+ if (errorMessage.includes('Authorization_RequestDenied') ||
68
+ errorMessage.includes('Forbidden') ||
69
+ errorMessage.includes('403')) {
70
+ return err(createError(ErrorCode.AUTH_REQUIRED, `Graph API permission denied. The token from the Teams session may not have Chat.ReadWrite scope. Error: ${errorMessage}`, {
71
+ retryable: false,
72
+ suggestions: [
73
+ 'The Teams SPA client ID may not have delegated Chat.ReadWrite permission',
74
+ 'Check the token scopes by decoding the JWT at jwt.ms',
75
+ ],
76
+ }));
77
+ }
78
+ return response;
79
+ }
80
+ const data = response.value.data;
81
+ // Check if the response is an error response
82
+ if ('error' in data && data.error) {
83
+ return err(createError(ErrorCode.API_ERROR, `Graph API error: ${data.error.code}: ${data.error.message}`, { retryable: false }));
84
+ }
85
+ const msgData = data;
86
+ if (!msgData.id) {
87
+ return err(createError(ErrorCode.UNKNOWN, 'Graph API returned success but no message ID', { retryable: false }));
88
+ }
89
+ return ok({
90
+ id: msgData.id,
91
+ createdDateTime: msgData.createdDateTime,
92
+ webUrl: msgData.webUrl,
93
+ _raw: msgData,
94
+ });
95
+ }
96
+ /**
97
+ * Sends a message to a Teams channel via the Microsoft Graph API.
98
+ *
99
+ * POST /v1.0/teams/{teamId}/channels/{channelId}/messages
100
+ *
101
+ * Note: For channels, we need the teamId (group ID) and channelId separately.
102
+ * This is different from the chatsvc API which uses a single conversationId.
103
+ *
104
+ * @param teamId - The team's group ID (GUID)
105
+ * @param channelId - The channel ID (e.g., "19:xxx@thread.tacv2")
106
+ * @param content - The message content
107
+ * @param contentType - "text" for plain text, "html" for HTML (default: "text")
108
+ */
109
+ export async function graphSendChannelMessage(teamId, channelId, content, contentType = 'text') {
110
+ const authResult = requireGraphAuth();
111
+ if (!authResult.ok) {
112
+ return authResult;
113
+ }
114
+ const { graphToken } = authResult.value;
115
+ const url = `${GRAPH_BASE_URL}/teams/${encodeURIComponent(teamId)}/channels/${encodeURIComponent(channelId)}/messages`;
116
+ const body = {
117
+ body: {
118
+ contentType,
119
+ content,
120
+ },
121
+ };
122
+ const response = await httpRequest(url, {
123
+ method: 'POST',
124
+ headers: {
125
+ 'Content-Type': 'application/json',
126
+ 'Authorization': `Bearer ${graphToken}`,
127
+ },
128
+ body: JSON.stringify(body),
129
+ maxRetries: 1,
130
+ });
131
+ if (!response.ok) {
132
+ return response;
133
+ }
134
+ const data = response.value.data;
135
+ if ('error' in data && data.error) {
136
+ return err(createError(ErrorCode.API_ERROR, `Graph API error: ${data.error.code}: ${data.error.message}`, { retryable: false }));
137
+ }
138
+ const msgData = data;
139
+ if (!msgData.id) {
140
+ return err(createError(ErrorCode.UNKNOWN, 'Graph API returned success but no message ID', { retryable: false }));
141
+ }
142
+ return ok({
143
+ id: msgData.id,
144
+ createdDateTime: msgData.createdDateTime,
145
+ webUrl: msgData.webUrl,
146
+ _raw: msgData,
147
+ });
148
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Import Microsoft SSO cookies from the user's Chrome profile into a Playwright context.
3
+ *
4
+ * When the Teams MCP server needs to open a visible browser for login,
5
+ * the Playwright profile is isolated from the user's real Chrome — so Microsoft
6
+ * can't recognise the user via SSO. This module copies the relevant Microsoft
7
+ * cookies from the user's actual Chrome work profile, enabling silent SSO in
8
+ * the Playwright browser and eliminating the need to re-type credentials.
9
+ *
10
+ * macOS only (Chrome cookies are encrypted with a Keychain-backed key).
11
+ * Fails gracefully on other platforms or when Chrome isn't available.
12
+ *
13
+ * Configuration:
14
+ * TEAMS_MCP_CHROME_PROFILE env var — Chrome profile directory name
15
+ * (e.g. "Profile 1"). If unset, auto-detects from Chrome's Local State.
16
+ */
17
+ import type { BrowserContext } from 'playwright';
18
+ /**
19
+ * Imports Microsoft SSO cookies from the user's Chrome profile into a Playwright context.
20
+ *
21
+ * This enables SSO in the Playwright browser so the user doesn't have to type
22
+ * credentials when Microsoft redirects to the login page.
23
+ *
24
+ * Fails silently — cookie import is best-effort. If it doesn't work,
25
+ * the user just has to log in manually as before.
26
+ */
27
+ export declare function importMicrosoftCookies(context: BrowserContext): Promise<void>;
@@ -0,0 +1,294 @@
1
+ /**
2
+ * Import Microsoft SSO cookies from the user's Chrome profile into a Playwright context.
3
+ *
4
+ * When the Teams MCP server needs to open a visible browser for login,
5
+ * the Playwright profile is isolated from the user's real Chrome — so Microsoft
6
+ * can't recognise the user via SSO. This module copies the relevant Microsoft
7
+ * cookies from the user's actual Chrome work profile, enabling silent SSO in
8
+ * the Playwright browser and eliminating the need to re-type credentials.
9
+ *
10
+ * macOS only (Chrome cookies are encrypted with a Keychain-backed key).
11
+ * Fails gracefully on other platforms or when Chrome isn't available.
12
+ *
13
+ * Configuration:
14
+ * TEAMS_MCP_CHROME_PROFILE env var — Chrome profile directory name
15
+ * (e.g. "Profile 1"). If unset, auto-detects from Chrome's Local State.
16
+ */
17
+ import * as crypto from 'crypto';
18
+ import * as fs from 'fs';
19
+ import * as os from 'os';
20
+ import * as path from 'path';
21
+ import { execSync } from 'child_process';
22
+ import * as log from '../utils/logger.js';
23
+ // Microsoft domains whose cookies enable SSO
24
+ const MICROSOFT_DOMAINS = [
25
+ '%microsoftonline%',
26
+ '%login.live.com%',
27
+ '%login.microsoft.com%',
28
+ '%microsoft.com%',
29
+ '%office.com%',
30
+ '%office365.com%',
31
+ ];
32
+ const CHROME_DATA_DIR = path.join(os.homedir(), 'Library', 'Application Support', 'Google', 'Chrome');
33
+ // ─────────────────────────────────────────────────────────────────────────────
34
+ // Chrome Profile Detection
35
+ // ─────────────────────────────────────────────────────────────────────────────
36
+ /**
37
+ * Lists Chrome profiles from the Local State file.
38
+ */
39
+ function listChromeProfiles() {
40
+ const localStatePath = path.join(CHROME_DATA_DIR, 'Local State');
41
+ if (!fs.existsSync(localStatePath))
42
+ return [];
43
+ try {
44
+ const localState = JSON.parse(fs.readFileSync(localStatePath, 'utf8'));
45
+ const infoCache = localState?.profile?.info_cache;
46
+ if (!infoCache || typeof infoCache !== 'object')
47
+ return [];
48
+ return Object.entries(infoCache).map(([dirName, info]) => {
49
+ const i = info;
50
+ return {
51
+ dirName,
52
+ name: String(i.name ?? ''),
53
+ gaiaName: String(i.gaia_name ?? ''),
54
+ };
55
+ });
56
+ }
57
+ catch {
58
+ return [];
59
+ }
60
+ }
61
+ /**
62
+ * Selects the Chrome profile to import cookies from.
63
+ *
64
+ * Priority:
65
+ * 1. TEAMS_MCP_CHROME_PROFILE env var (exact dir name like "Profile 1")
66
+ * 2. Auto-detect: first profile whose name looks like a work/corporate account
67
+ * 3. null if no suitable profile found
68
+ */
69
+ function selectChromeProfile() {
70
+ const profiles = listChromeProfiles();
71
+ if (profiles.length === 0)
72
+ return null;
73
+ // Priority 1: explicit env var
74
+ const envProfile = process.env.TEAMS_MCP_CHROME_PROFILE;
75
+ if (envProfile) {
76
+ const match = profiles.find(p => p.dirName === envProfile);
77
+ if (match)
78
+ return match;
79
+ log.warn('cookie-import', `TEAMS_MCP_CHROME_PROFILE="${envProfile}" not found. Available: ${profiles.map(p => `${p.dirName} (${p.name})`).join(', ')}`);
80
+ return null;
81
+ }
82
+ // Priority 2: auto-detect work profile (contains a domain-like name)
83
+ const workProfile = profiles.find(p => /\.[a-z]{2,}$/i.test(p.name) || // name contains a domain (e.g. "corp.example.com")
84
+ p.name.toLowerCase().includes('work') ||
85
+ p.name.toLowerCase().includes('corp'));
86
+ if (workProfile)
87
+ return workProfile;
88
+ // Skip auto-import if we can't identify a work profile
89
+ return null;
90
+ }
91
+ // ─────────────────────────────────────────────────────────────────────────────
92
+ // Cookie Decryption (macOS)
93
+ // ─────────────────────────────────────────────────────────────────────────────
94
+ /**
95
+ * Cached AES key derived from the Chrome Safe Storage Keychain password.
96
+ * Cached for the lifetime of the MCP server process so the Keychain
97
+ * prompt only appears once (on first cookie import after server start).
98
+ *
99
+ * Tip: click "Always Allow" on the macOS Keychain dialog to permanently
100
+ * authorize this process — then no prompt appears at all.
101
+ */
102
+ let cachedKey = null;
103
+ /**
104
+ * Gets (or retrieves from cache) the AES key for Chrome cookie decryption.
105
+ * Accesses the macOS Keychain only on first call, caches for process lifetime.
106
+ */
107
+ function getDecryptionKey() {
108
+ if (cachedKey)
109
+ return cachedKey;
110
+ let password;
111
+ try {
112
+ password = execSync('security find-generic-password -s "Chrome Safe Storage" -w', { encoding: 'utf8', timeout: 5000 }).trim();
113
+ }
114
+ catch {
115
+ return null;
116
+ }
117
+ cachedKey = crypto.pbkdf2Sync(password, 'saltysalt', 1003, 16, 'sha1');
118
+ return cachedKey;
119
+ }
120
+ /**
121
+ * Decrypts a Chrome cookie value.
122
+ * Chrome macOS cookies are prefixed with 'v10' followed by AES-128-CBC ciphertext.
123
+ */
124
+ function decryptCookieValue(hexValue, key) {
125
+ try {
126
+ const encrypted = Buffer.from(hexValue, 'hex');
127
+ // v10 prefix check (0x76 0x31 0x30)
128
+ if (encrypted.length < 4 || encrypted[0] !== 0x76 || encrypted[1] !== 0x31 || encrypted[2] !== 0x30) {
129
+ // Not encrypted or unknown format — try as plain text
130
+ return encrypted.toString('utf8');
131
+ }
132
+ const ciphertext = encrypted.subarray(3);
133
+ const iv = Buffer.alloc(16, 0x20); // 16 bytes of space (0x20)
134
+ const decipher = crypto.createDecipheriv('aes-128-cbc', key, iv);
135
+ decipher.setAutoPadding(true);
136
+ const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
137
+ return decrypted.toString('utf8');
138
+ }
139
+ catch {
140
+ return null;
141
+ }
142
+ }
143
+ // ─────────────────────────────────────────────────────────────────────────────
144
+ // Cookie Reading (via sqlite3 CLI)
145
+ // ─────────────────────────────────────────────────────────────────────────────
146
+ /**
147
+ * Reads Microsoft-related cookies from a Chrome profile's Cookies database.
148
+ * Copies the DB to a temp file to avoid locking conflicts with running Chrome.
149
+ */
150
+ function readChromeCookies(profileDir) {
151
+ const cookiesDb = path.join(CHROME_DATA_DIR, profileDir, 'Cookies');
152
+ if (!fs.existsSync(cookiesDb))
153
+ return [];
154
+ // Copy to temp to avoid lock conflicts with running Chrome
155
+ const tmpDb = path.join(os.tmpdir(), `teams-mcp-cookies-${Date.now()}.db`);
156
+ try {
157
+ fs.copyFileSync(cookiesDb, tmpDb);
158
+ // Also copy WAL/SHM if they exist (needed for consistent reads)
159
+ for (const ext of ['-wal', '-shm']) {
160
+ const src = cookiesDb + ext;
161
+ if (fs.existsSync(src)) {
162
+ fs.copyFileSync(src, tmpDb + ext);
163
+ }
164
+ }
165
+ const whereClause = MICROSOFT_DOMAINS.map(d => `host_key LIKE '${d}'`).join(' OR ');
166
+ const sql = `SELECT host_key, name, hex(encrypted_value) as ev, path, expires_utc, is_secure, is_httponly, samesite FROM cookies WHERE (${whereClause}) AND expires_utc > 0`;
167
+ const output = execSync(`sqlite3 -separator '|||' "${tmpDb}" "${sql}"`, { encoding: 'utf8', timeout: 10000, maxBuffer: 1024 * 1024 });
168
+ return output
169
+ .trim()
170
+ .split('\n')
171
+ .filter(line => line.includes('|||'))
172
+ .map(line => {
173
+ const [host_key, name, encrypted_value_hex, cookiePath, expires_utc, is_secure, is_httponly, samesite] = line.split('|||');
174
+ return {
175
+ host_key,
176
+ name,
177
+ encrypted_value_hex,
178
+ path: cookiePath,
179
+ expires_utc: parseInt(expires_utc, 10),
180
+ is_secure: parseInt(is_secure, 10),
181
+ is_httponly: parseInt(is_httponly, 10),
182
+ samesite: parseInt(samesite, 10),
183
+ };
184
+ });
185
+ }
186
+ catch (err) {
187
+ log.warn('cookie-import', `Failed to read Chrome cookies: ${err instanceof Error ? err.message : String(err)}`);
188
+ return [];
189
+ }
190
+ finally {
191
+ // Clean up temp files
192
+ for (const f of [tmpDb, tmpDb + '-wal', tmpDb + '-shm']) {
193
+ try {
194
+ fs.unlinkSync(f);
195
+ }
196
+ catch { /* ignore */ }
197
+ }
198
+ }
199
+ }
200
+ // ─────────────────────────────────────────────────────────────────────────────
201
+ // Cookie Conversion
202
+ // ─────────────────────────────────────────────────────────────────────────────
203
+ /**
204
+ * Converts Chrome epoch (microseconds since 1601-01-01) to Unix epoch (seconds since 1970-01-01).
205
+ */
206
+ function chromeEpochToUnix(chromeTimestamp) {
207
+ // Chrome epoch starts 11644473600 seconds before Unix epoch
208
+ return Math.floor(chromeTimestamp / 1_000_000) - 11644473600;
209
+ }
210
+ /**
211
+ * Maps Chrome's samesite integer to Playwright's string value.
212
+ */
213
+ function chromeSameSiteToPlaywright(samesite) {
214
+ switch (samesite) {
215
+ case 2: return 'Strict';
216
+ case 1: return 'Lax';
217
+ default: return 'None';
218
+ }
219
+ }
220
+ // ─────────────────────────────────────────────────────────────────────────────
221
+ // Public API
222
+ // ─────────────────────────────────────────────────────────────────────────────
223
+ /**
224
+ * Imports Microsoft SSO cookies from the user's Chrome profile into a Playwright context.
225
+ *
226
+ * This enables SSO in the Playwright browser so the user doesn't have to type
227
+ * credentials when Microsoft redirects to the login page.
228
+ *
229
+ * Fails silently — cookie import is best-effort. If it doesn't work,
230
+ * the user just has to log in manually as before.
231
+ */
232
+ export async function importMicrosoftCookies(context) {
233
+ // Only supported on macOS
234
+ if (process.platform !== 'darwin') {
235
+ log.debug('cookie-import', 'Skipping cookie import (not macOS)');
236
+ return;
237
+ }
238
+ // Check if Chrome is installed
239
+ if (!fs.existsSync(CHROME_DATA_DIR)) {
240
+ log.debug('cookie-import', 'Skipping cookie import (Chrome not found)');
241
+ return;
242
+ }
243
+ const profile = selectChromeProfile();
244
+ if (!profile) {
245
+ log.debug('cookie-import', 'No Chrome work profile found. Set TEAMS_MCP_CHROME_PROFILE env var.');
246
+ return;
247
+ }
248
+ log.info('cookie-import', `Importing Microsoft cookies from Chrome profile: ${profile.dirName} (${profile.name})`);
249
+ // Get decryption key (cached after first access — only one Keychain prompt per server lifetime)
250
+ const key = getDecryptionKey();
251
+ if (!key) {
252
+ log.warn('cookie-import', 'Could not get Chrome Safe Storage password from Keychain. ' +
253
+ 'To fix, run: security set-generic-password-partition-list -S "apple-tool:,apple:" ' +
254
+ '-a "Chrome" -s "Chrome Safe Storage" ~/Library/Keychains/login.keychain-db');
255
+ return;
256
+ }
257
+ // Read and decrypt cookies
258
+ const rawCookies = readChromeCookies(profile.dirName);
259
+ if (rawCookies.length === 0) {
260
+ log.info('cookie-import', 'No Microsoft cookies found in Chrome profile');
261
+ return;
262
+ }
263
+ const playwrightCookies = [];
264
+ for (const raw of rawCookies) {
265
+ const value = decryptCookieValue(raw.encrypted_value_hex, key);
266
+ if (!value)
267
+ continue;
268
+ const expires = chromeEpochToUnix(raw.expires_utc);
269
+ // Skip expired cookies
270
+ if (expires <= Math.floor(Date.now() / 1000))
271
+ continue;
272
+ playwrightCookies.push({
273
+ name: raw.name,
274
+ value,
275
+ domain: raw.host_key,
276
+ path: raw.path,
277
+ expires,
278
+ httpOnly: raw.is_httponly === 1,
279
+ secure: raw.is_secure === 1,
280
+ sameSite: chromeSameSiteToPlaywright(raw.samesite),
281
+ });
282
+ }
283
+ if (playwrightCookies.length === 0) {
284
+ log.info('cookie-import', 'No valid Microsoft cookies to import');
285
+ return;
286
+ }
287
+ try {
288
+ await context.addCookies(playwrightCookies);
289
+ log.info('cookie-import', `Imported ${playwrightCookies.length} Microsoft cookies from Chrome "${profile.name}" profile`);
290
+ }
291
+ catch (err) {
292
+ log.warn('cookie-import', `Failed to inject cookies: ${err instanceof Error ? err.message : String(err)}`);
293
+ }
294
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,122 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import * as crypto from 'crypto';
3
+ /**
4
+ * Tests for the cookie import logic.
5
+ *
6
+ * The public importMicrosoftCookies() function depends on filesystem, Keychain,
7
+ * and sqlite3 — so we test the pure cryptographic and conversion logic directly.
8
+ */
9
+ describe('chrome-cookie-import logic', () => {
10
+ describe('cookie decryption (v10 AES-128-CBC)', () => {
11
+ // Replicate Chrome macOS encryption: PBKDF2-SHA1, salt='saltysalt', 1003 iters
12
+ const password = 'test-password';
13
+ const key = crypto.pbkdf2Sync(password, 'saltysalt', 1003, 16, 'sha1');
14
+ const iv = Buffer.alloc(16, 0x20); // 16 spaces
15
+ function encrypt(plaintext) {
16
+ const cipher = crypto.createCipheriv('aes-128-cbc', key, iv);
17
+ const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
18
+ return Buffer.concat([Buffer.from('v10'), encrypted]).toString('hex');
19
+ }
20
+ function decrypt(hexValue) {
21
+ const buf = Buffer.from(hexValue, 'hex');
22
+ if (buf.length < 4 || buf[0] !== 0x76 || buf[1] !== 0x31 || buf[2] !== 0x30) {
23
+ return buf.toString('utf8');
24
+ }
25
+ try {
26
+ const decipher = crypto.createDecipheriv('aes-128-cbc', key, iv);
27
+ decipher.setAutoPadding(true);
28
+ return Buffer.concat([decipher.update(buf.subarray(3)), decipher.final()]).toString('utf8');
29
+ }
30
+ catch {
31
+ return null;
32
+ }
33
+ }
34
+ it('round-trips a short cookie value', () => {
35
+ const value = 'ESTSAUTHPERSISTENT_VALUE_HERE';
36
+ expect(decrypt(encrypt(value))).toBe(value);
37
+ });
38
+ it('round-trips an empty string', () => {
39
+ expect(decrypt(encrypt(''))).toBe('');
40
+ });
41
+ it('round-trips a long value (>1 AES block)', () => {
42
+ const value = 'A'.repeat(200);
43
+ expect(decrypt(encrypt(value))).toBe(value);
44
+ });
45
+ it('returns raw string for non-v10 prefix', () => {
46
+ const plain = Buffer.from('hello');
47
+ expect(decrypt(plain.toString('hex'))).toBe('hello');
48
+ });
49
+ it('returns null for corrupted ciphertext', () => {
50
+ // Valid v10 prefix but garbage data
51
+ const garbage = Buffer.concat([Buffer.from('v10'), Buffer.from('not-valid-ciphertext-at-all!!')]);
52
+ expect(decrypt(garbage.toString('hex'))).toBeNull();
53
+ });
54
+ });
55
+ describe('Chrome epoch conversion', () => {
56
+ function chromeEpochToUnix(chromeTimestamp) {
57
+ return Math.floor(chromeTimestamp / 1_000_000) - 11644473600;
58
+ }
59
+ it('converts a known Chrome timestamp to Unix', () => {
60
+ // 2025-01-01T00:00:00Z in Unix = 1735689600
61
+ // In Chrome epoch = (1735689600 + 11644473600) * 1_000_000 = 13380163200000000
62
+ const chromeTs = 13380163200000000;
63
+ expect(chromeEpochToUnix(chromeTs)).toBe(1735689600);
64
+ });
65
+ it('returns 0 for the Unix epoch in Chrome time', () => {
66
+ const chromeTs = 11644473600 * 1_000_000;
67
+ expect(chromeEpochToUnix(chromeTs)).toBe(0);
68
+ });
69
+ });
70
+ describe('samesite mapping', () => {
71
+ function chromeSameSiteToPlaywright(samesite) {
72
+ switch (samesite) {
73
+ case 2: return 'Strict';
74
+ case 1: return 'Lax';
75
+ default: return 'None';
76
+ }
77
+ }
78
+ it('maps Chrome samesite values', () => {
79
+ expect(chromeSameSiteToPlaywright(-1)).toBe('None');
80
+ expect(chromeSameSiteToPlaywright(0)).toBe('None');
81
+ expect(chromeSameSiteToPlaywright(1)).toBe('Lax');
82
+ expect(chromeSameSiteToPlaywright(2)).toBe('Strict');
83
+ });
84
+ });
85
+ describe('profile detection heuristics', () => {
86
+ const profiles = [
87
+ { dirName: 'Default', name: 'Person 1', gaiaName: 'Test' },
88
+ { dirName: 'Profile 1', name: 'corp.example.com', gaiaName: 'Jane Smith' },
89
+ { dirName: 'Profile 2', name: 'Jane', gaiaName: 'Jane Smith' },
90
+ { dirName: 'Profile 4', name: 'Test', gaiaName: '' },
91
+ ];
92
+ function selectWorkProfile(profiles) {
93
+ return profiles.find(p => /\.[a-z]{2,}$/i.test(p.name) ||
94
+ p.name.toLowerCase().includes('work') ||
95
+ p.name.toLowerCase().includes('corp')) ?? null;
96
+ }
97
+ it('selects profile with domain-like name', () => {
98
+ expect(selectWorkProfile(profiles)?.dirName).toBe('Profile 1');
99
+ });
100
+ it('selects profile with "work" in name', () => {
101
+ const custom = [
102
+ { dirName: 'Default', name: 'Personal' },
103
+ { dirName: 'Profile 1', name: 'Work Account' },
104
+ ];
105
+ expect(selectWorkProfile(custom)?.dirName).toBe('Profile 1');
106
+ });
107
+ it('selects profile with "corp" in name', () => {
108
+ const custom = [
109
+ { dirName: 'Default', name: 'Me' },
110
+ { dirName: 'Profile 1', name: 'CorpNet' },
111
+ ];
112
+ expect(selectWorkProfile(custom)?.dirName).toBe('Profile 1');
113
+ });
114
+ it('returns null when no work profile found', () => {
115
+ const custom = [
116
+ { dirName: 'Default', name: 'Person 1' },
117
+ { dirName: 'Profile 2', name: 'Gaming' },
118
+ ];
119
+ expect(selectWorkProfile(custom)).toBeNull();
120
+ });
121
+ });
122
+ });
@@ -18,6 +18,7 @@ import * as path from 'path';
18
18
  import { ensureUserDataDir, CONFIG_DIR, writeSessionState, } from '../auth/session-store.js';
19
19
  import { clearRegionCache } from '../utils/auth-guards.js';
20
20
  import * as log from '../utils/logger.js';
21
+ import { importMicrosoftCookies } from './chrome-cookie-import.js';
21
22
  const DEFAULT_OPTIONS = {
22
23
  headless: true,
23
24
  viewport: { width: 1280, height: 800 },
@@ -142,6 +143,14 @@ export async function createBrowserContext(options = {}) {
142
143
  viewport: opts.viewport,
143
144
  acceptDownloads: false,
144
145
  });
146
+ // Import Microsoft SSO cookies from the user's real Chrome profile.
147
+ // Only for visible (interactive) logins — enables SSO so user doesn't have
148
+ // to type credentials. Headless refresh uses the persistent profile's saved
149
+ // cookies, avoiding Keychain access on every hourly auto-refresh cycle.
150
+ // (forceNew login imports cookies explicitly via auth-tools.ts)
151
+ if (!opts.headless) {
152
+ await importMicrosoftCookies(context);
153
+ }
145
154
  // Persistent contexts start with one page; use it or create one
146
155
  const page = context.pages()[0] ?? await context.newPage();
147
156
  return {
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * Quick diagnostic: dump all MSAL token targets from the session.
4
+ * Run: npm run build && node dist/test/dump-tokens.js
5
+ * Or: npx tsx src/test/dump-tokens.ts
6
+ */
7
+ export {};
@@ -0,0 +1,90 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * Quick diagnostic: dump all MSAL token targets from the session.
4
+ * Run: npm run build && node dist/test/dump-tokens.js
5
+ * Or: npx tsx src/test/dump-tokens.ts
6
+ */
7
+ import { readSessionState, getTeamsOrigin } from '../auth/session-store.js';
8
+ function decodeJwtPayload(token) {
9
+ try {
10
+ const parts = token.split('.');
11
+ if (parts.length < 2)
12
+ return null;
13
+ return JSON.parse(Buffer.from(parts[1], 'base64').toString());
14
+ }
15
+ catch {
16
+ return null;
17
+ }
18
+ }
19
+ const sessionState = readSessionState();
20
+ if (!sessionState) {
21
+ console.log('No session state found. Run teams_login first.');
22
+ process.exit(1);
23
+ }
24
+ const teamsOrigin = getTeamsOrigin(sessionState);
25
+ const localStorage = teamsOrigin?.localStorage ?? [];
26
+ console.log(`Found ${localStorage.length} localStorage entries\n`);
27
+ // Collect all tokens with their targets
28
+ const tokens = [];
29
+ for (const item of localStorage) {
30
+ try {
31
+ const entry = JSON.parse(item.value);
32
+ const target = entry.target;
33
+ const secret = entry.secret;
34
+ if (!target || !secret || !secret.startsWith('ey'))
35
+ continue;
36
+ const payload = decodeJwtPayload(secret);
37
+ if (!payload?.exp || typeof payload.exp !== 'number')
38
+ continue;
39
+ const expiry = new Date(payload.exp * 1000);
40
+ const minutesLeft = Math.round((expiry.getTime() - Date.now()) / 1000 / 60);
41
+ // Extract scopes from the token's scp claim
42
+ const scp = payload.scp;
43
+ const scopes = scp ? scp.split(' ') : [];
44
+ tokens.push({
45
+ target,
46
+ scopes,
47
+ expiry: expiry.toISOString(),
48
+ minutesLeft,
49
+ hasGraph: target.includes('graph.microsoft.com'),
50
+ });
51
+ }
52
+ catch {
53
+ continue;
54
+ }
55
+ }
56
+ console.log('=== ALL MSAL TOKENS ===\n');
57
+ for (const t of tokens) {
58
+ const status = t.minutesLeft > 0 ? `✅ ${t.minutesLeft}m left` : '❌ EXPIRED';
59
+ console.log(`Target: ${t.target}`);
60
+ console.log(`Status: ${status}`);
61
+ if (t.scopes.length > 0) {
62
+ console.log(`Scopes: ${t.scopes.join(', ')}`);
63
+ }
64
+ console.log();
65
+ }
66
+ // Highlight Graph tokens
67
+ const graphTokens = tokens.filter(t => t.hasGraph);
68
+ console.log('=== GRAPH TOKENS ===\n');
69
+ if (graphTokens.length === 0) {
70
+ console.log('⚠️ No graph.microsoft.com tokens found in session.');
71
+ console.log(' The Teams web app may not store Graph tokens in localStorage.');
72
+ console.log(' Transcript API will need to use the middle tier instead.');
73
+ }
74
+ else {
75
+ for (const t of graphTokens) {
76
+ console.log(`Target: ${t.target}`);
77
+ console.log(`Scopes: ${t.scopes.join(', ')}`);
78
+ console.log(`Expiry: ${t.expiry} (${t.minutesLeft}m left)`);
79
+ const hasTranscriptScope = t.scopes.some(s => s.toLowerCase().includes('transcript') ||
80
+ s.toLowerCase().includes('onlinemeeting'));
81
+ if (hasTranscriptScope) {
82
+ console.log('✅ Has transcript-related scopes!');
83
+ }
84
+ else {
85
+ console.log('⚠️ No transcript-specific scopes found in this token.');
86
+ console.log(' May still work if the first-party Teams app has implicit access.');
87
+ }
88
+ console.log();
89
+ }
90
+ }
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * Test the transcript API end-to-end.
4
+ * Run: npx tsx src/test/test-transcript.ts
5
+ */
6
+ export {};
@@ -0,0 +1,47 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * Test the transcript API end-to-end.
4
+ * Run: npx tsx src/test/test-transcript.ts
5
+ */
6
+ import { getCalendarView } from '../api/calendar-api.js';
7
+ import { getTranscriptContent } from '../api/transcript-api.js';
8
+ async function main() {
9
+ console.log('=== Get recent meetings with threadId ===');
10
+ const now = new Date();
11
+ const pastWeek = new Date(now);
12
+ pastWeek.setDate(pastWeek.getDate() - 14);
13
+ const meetingsResult = await getCalendarView({
14
+ startDate: pastWeek.toISOString(),
15
+ endDate: now.toISOString(),
16
+ limit: 50,
17
+ });
18
+ if (!meetingsResult.ok) {
19
+ console.log(`❌ ${meetingsResult.error.message}`);
20
+ process.exit(1);
21
+ }
22
+ const meetings = meetingsResult.value.meetings.filter(m => m.threadId);
23
+ console.log(`Found ${meetings.length} meetings with threadId\n`);
24
+ // Try each meeting until we find one with a transcript
25
+ for (const meeting of meetings.slice(0, 10)) {
26
+ console.log(`--- "${meeting.subject}" (${meeting.startTime}) ---`);
27
+ console.log(`Thread: ${meeting.threadId}`);
28
+ const result = await getTranscriptContent(meeting.threadId, meeting.startTime);
29
+ if (result.ok) {
30
+ console.log(`\n✅ SUCCESS!`);
31
+ console.log(`Title: ${result.value.meetingTitle}`);
32
+ console.log(`Speakers: ${result.value.speakers.join(', ')}`);
33
+ console.log(`Entries: ${result.value.entryCount}`);
34
+ console.log(`Recording: ${result.value.recordingStartTime} → ${result.value.recordingEndTime}`);
35
+ console.log(`\nFirst 800 chars:\n${result.value.formattedText.substring(0, 800)}`);
36
+ process.exit(0);
37
+ }
38
+ else {
39
+ console.log(` ❌ ${result.error.code}: ${result.error.message}\n`);
40
+ }
41
+ }
42
+ console.log('No transcripts found for any recent meeting.');
43
+ }
44
+ main().catch(e => {
45
+ console.error('Fatal error:', e);
46
+ process.exit(1);
47
+ });
@@ -76,14 +76,20 @@ async function handleLogin(input, ctx) {
76
76
  // Headless-first strategy:
77
77
  // The persistent browser profile retains Microsoft's long-lived session cookies,
78
78
  // so headless SSO can succeed even without a session-state file. Always try
79
- // headless first — even for forceNew. Most recovery scenarios complete silently.
79
+ // headless first — even for forceNew. Chrome cookie import enables headless SSO
80
+ // to succeed without a session file, so most recovery scenarios complete silently.
80
81
  {
81
82
  const headlessManager = await createBrowserContext({ headless: true });
82
83
  ctx.server.setBrowserManager(headlessManager);
83
84
  try {
84
85
  if (input.forceNew) {
85
- // Clear persistent profile cookies to force fresh authentication
86
+ // Clear persistent profile cookies, then import Chrome's Microsoft SSO
87
+ // cookies so headless SSO can succeed without a session file.
88
+ // (createBrowserContext skips cookie import in headless mode to avoid
89
+ // Keychain access on routine refresh — this is the intentional exception.)
86
90
  await headlessManager.context.clearCookies();
91
+ const { importMicrosoftCookies } = await import('../browser/chrome-cookie-import.js');
92
+ await importMicrosoftCookies(headlessManager.context);
87
93
  }
88
94
  await ensureAuthenticated(headlessManager.page, headlessManager.context, (msg) => log.info('login:headless', msg), false, // No overlay in headless
89
95
  true // Headless mode - throw immediately if user interaction required
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Graph API spike tool handlers.
3
+ *
4
+ * Experimental tools to test Microsoft Graph API access using tokens
5
+ * extracted from the Teams browser session.
6
+ */
7
+ import { z } from 'zod';
8
+ import type { RegisteredTool } from './index.js';
9
+ export declare const GraphSendMessageInputSchema: z.ZodObject<{
10
+ chatId: z.ZodString;
11
+ content: z.ZodString;
12
+ contentType: z.ZodDefault<z.ZodOptional<z.ZodEnum<["text", "html"]>>>;
13
+ }, "strip", z.ZodTypeAny, {
14
+ content: string;
15
+ contentType: "text" | "html";
16
+ chatId: string;
17
+ }, {
18
+ content: string;
19
+ chatId: string;
20
+ contentType?: "text" | "html" | undefined;
21
+ }>;
22
+ export declare const GraphTokenStatusInputSchema: z.ZodObject<{}, "strip", z.ZodTypeAny, {}, {}>;
23
+ export declare const graphSendMessageTool: RegisteredTool<typeof GraphSendMessageInputSchema>;
24
+ export declare const graphTokenStatusTool: RegisteredTool<z.ZodObject<Record<string, never>>>;
25
+ /** All Graph API spike tools. */
26
+ export declare const graphTools: (RegisteredTool<z.ZodObject<Record<string, never>, z.UnknownKeysParam, z.ZodTypeAny, {
27
+ [x: string]: never;
28
+ }, {
29
+ [x: string]: never;
30
+ }>> | RegisteredTool<z.ZodObject<{
31
+ chatId: z.ZodString;
32
+ content: z.ZodString;
33
+ contentType: z.ZodDefault<z.ZodOptional<z.ZodEnum<["text", "html"]>>>;
34
+ }, "strip", z.ZodTypeAny, {
35
+ content: string;
36
+ contentType: "text" | "html";
37
+ chatId: string;
38
+ }, {
39
+ content: string;
40
+ chatId: string;
41
+ contentType?: "text" | "html" | undefined;
42
+ }>>)[];
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Graph API spike tool handlers.
3
+ *
4
+ * Experimental tools to test Microsoft Graph API access using tokens
5
+ * extracted from the Teams browser session.
6
+ */
7
+ import { z } from 'zod';
8
+ import { graphSendMessage } from '../api/graph-api.js';
9
+ import { getGraphTokenStatus } from '../auth/token-extractor.js';
10
+ // ─────────────────────────────────────────────────────────────────────────────
11
+ // Schemas
12
+ // ─────────────────────────────────────────────────────────────────────────────
13
+ export const GraphSendMessageInputSchema = z.object({
14
+ chatId: z.string().min(1, 'Chat ID cannot be empty'),
15
+ content: z.string().min(1, 'Message content cannot be empty'),
16
+ contentType: z.enum(['text', 'html']).optional().default('text'),
17
+ });
18
+ export const GraphTokenStatusInputSchema = z.object({});
19
+ // ─────────────────────────────────────────────────────────────────────────────
20
+ // Tool Definitions
21
+ // ─────────────────────────────────────────────────────────────────────────────
22
+ const graphSendMessageToolDefinition = {
23
+ name: 'teams_graph_send_message',
24
+ description: '[SPIKE] Send a message via Microsoft Graph API instead of chatsvc. This is an experimental tool to test Graph API access. Use the chatId from teams_get_thread or teams_search (the conversationId). Content can be plain text or HTML.',
25
+ inputSchema: {
26
+ type: 'object',
27
+ properties: {
28
+ chatId: {
29
+ type: 'string',
30
+ description: 'The Teams chat/conversation ID (e.g., "19:xxx@thread.v2"). Use the conversationId from other tools.',
31
+ },
32
+ content: {
33
+ type: 'string',
34
+ description: 'The message content. Plain text by default, or HTML if contentType is "html".',
35
+ },
36
+ contentType: {
37
+ type: 'string',
38
+ enum: ['text', 'html'],
39
+ description: 'Content type: "text" (default) or "html".',
40
+ },
41
+ },
42
+ required: ['chatId', 'content'],
43
+ },
44
+ };
45
+ const graphTokenStatusToolDefinition = {
46
+ name: 'teams_graph_token_status',
47
+ description: '[SPIKE] Check the status of the Microsoft Graph API token. Shows whether a Graph token is available and when it expires.',
48
+ inputSchema: {
49
+ type: 'object',
50
+ properties: {},
51
+ },
52
+ };
53
+ // ─────────────────────────────────────────────────────────────────────────────
54
+ // Handlers
55
+ // ─────────────────────────────────────────────────────────────────────────────
56
+ async function handleGraphSendMessage(input, _ctx) {
57
+ const result = await graphSendMessage(input.chatId, input.content, input.contentType);
58
+ if (!result.ok) {
59
+ return { success: false, error: result.error };
60
+ }
61
+ return {
62
+ success: true,
63
+ data: {
64
+ id: result.value.id,
65
+ createdDateTime: result.value.createdDateTime,
66
+ webUrl: result.value.webUrl,
67
+ note: '[SPIKE] Message sent via Microsoft Graph API. This is experimental.',
68
+ _raw: result.value._raw,
69
+ },
70
+ };
71
+ }
72
+ async function handleGraphTokenStatus(_input, _ctx) {
73
+ const status = getGraphTokenStatus();
74
+ return {
75
+ success: true,
76
+ data: {
77
+ graphToken: status,
78
+ note: '[SPIKE] Graph token is acquired during token refresh. If no token is available, try teams_login first, then check again.',
79
+ },
80
+ };
81
+ }
82
+ // ─────────────────────────────────────────────────────────────────────────────
83
+ // Exports
84
+ // ─────────────────────────────────────────────────────────────────────────────
85
+ export const graphSendMessageTool = {
86
+ definition: graphSendMessageToolDefinition,
87
+ schema: GraphSendMessageInputSchema,
88
+ handler: handleGraphSendMessage,
89
+ };
90
+ export const graphTokenStatusTool = {
91
+ definition: graphTokenStatusToolDefinition,
92
+ schema: z.object({}),
93
+ handler: handleGraphTokenStatus,
94
+ };
95
+ /** All Graph API spike tools. */
96
+ export const graphTools = [
97
+ graphSendMessageTool,
98
+ graphTokenStatusTool,
99
+ ];
@@ -76,13 +76,13 @@ export const GetMessageInputSchema = z.object({
76
76
  // ─────────────────────────────────────────────────────────────────────────────
77
77
  const sendMessageToolDefinition = {
78
78
  name: 'teams_send_message',
79
- description: 'Send a message to a Teams conversation. Use markdown for formatting (not HTML): **bold**, *italic*, ~~strikethrough~~, `code`, ```code blocks```, lists, and newlines. Supports @mentions: people with @[Name](mri) (MRI from teams_search_people) and channel tags with @[TagName](tag:tagId) (IDs from teams_get_tags). Defaults to self-notes (48:notes). For channel thread replies, provide replyToMessageId.',
79
+ description: 'Send a message to a Teams conversation. Use markdown for formatting (not HTML): **bold**, *italic*, ~~strikethrough~~, `code`, ```code blocks```, lists, and newlines. Supports @mentions using @[Name](mri) syntax inline. Example: "Hey @[John Smith](8:orgid:abc...), check this". Get MRI from teams_search_people. Defaults to self-notes (48:notes). For channel thread replies, provide replyToMessageId.',
80
80
  inputSchema: {
81
81
  type: 'object',
82
82
  properties: {
83
83
  content: {
84
84
  type: 'string',
85
- description: 'The message content in markdown (not HTML). Supports: **bold**, *italic*, ~~strikethrough~~, `inline code`, ```code blocks```, bullet lists (- item), numbered lists (1. item), and newlines. Do NOT send raw HTML tags. For people @mentions use @[DisplayName](mri) (MRI from teams_search_people). For channel tag @mentions use @[DisplayName](tag:tagId) (tag IDs from teams_get_tags). Markdown links [text](url) are supported.',
85
+ description: 'The message content in markdown (not HTML). Supports: **bold**, *italic*, ~~strikethrough~~, `inline code`, ```code blocks```, bullet lists (- item), numbered lists (1. item), and newlines. Do NOT send raw HTML tags. For @mentions, use @[DisplayName](mri) syntax. Example: "Hey @[John Smith](8:orgid:abc...), can you review this?"',
86
86
  },
87
87
  conversationId: {
88
88
  type: 'string',
@@ -212,7 +212,7 @@ const createGroupChatToolDefinition = {
212
212
  };
213
213
  const editMessageToolDefinition = {
214
214
  name: 'teams_edit_message',
215
- description: 'Edit one of your own messages (same markdown and @mention rules as teams_send_message: people @[Name](mri), channel tags @[TagName](tag:tagId) from teams_get_tags). You can only edit messages you sent.',
215
+ description: 'Edit one of your own messages. You can only edit messages you sent. The API will reject attempts to edit other users\' messages.',
216
216
  inputSchema: {
217
217
  type: 'object',
218
218
  properties: {
@@ -226,7 +226,7 @@ const editMessageToolDefinition = {
226
226
  },
227
227
  content: {
228
228
  type: 'string',
229
- description: 'New content in markdown (not raw HTML): **bold**, *italic*, lists, code, @[Person](mri), @[Tag](tag:id), [text](url) — same as teams_send_message.',
229
+ description: 'The new content for the message. Can include basic HTML formatting.',
230
230
  },
231
231
  },
232
232
  required: ['conversationId', 'messageId', 'content'],
@@ -252,13 +252,13 @@ const deleteMessageToolDefinition = {
252
252
  };
253
253
  const getUnreadToolDefinition = {
254
254
  name: 'teams_get_unread',
255
- description: 'Get unread status. Without conversationId: one bulk API call over your recent conversations (up to 200), returns separate lists of unread chats and channels (conversationId, displayName, lastMessageFrom) plus counts. With conversationId: unread count for that chat/channel using read horizon vs recent messages.',
255
+ 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.',
256
256
  inputSchema: {
257
257
  type: 'object',
258
258
  properties: {
259
259
  conversationId: {
260
260
  type: 'string',
261
- description: 'Optional. If set, returns unreadCount (and related fields) for this conversation only. If omitted, returns bulk unreadChats/unreadChannels across recent conversations.',
261
+ description: 'Optional. A specific conversation ID to check. If omitted, checks all favourites.',
262
262
  },
263
263
  },
264
264
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "msteams-mcp",
3
- "version": "0.23.0",
3
+ "version": "0.23.1",
4
4
  "description": "MCP server for Microsoft Teams - search messages, send replies, manage favourites",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",