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.
- package/dist/api/chatsvc-activity.js +2 -1
- package/dist/api/chatsvc-messaging.d.ts +1 -3
- package/dist/api/chatsvc-messaging.js +6 -12
- package/dist/api/chatsvc-readstatus.js +1 -8
- package/dist/api/graph-api.d.ts +55 -0
- package/dist/api/graph-api.js +148 -0
- package/dist/browser/chrome-cookie-import.d.ts +27 -0
- package/dist/browser/chrome-cookie-import.js +294 -0
- package/dist/browser/chrome-cookie-import.test.d.ts +1 -0
- package/dist/browser/chrome-cookie-import.test.js +122 -0
- package/dist/browser/context.js +9 -0
- package/dist/test/dump-tokens.d.ts +7 -0
- package/dist/test/dump-tokens.js +90 -0
- package/dist/test/test-transcript.d.ts +6 -0
- package/dist/test/test-transcript.js +47 -0
- package/dist/tools/auth-tools.js +8 -2
- package/dist/tools/graph-tools.d.ts +42 -0
- package/dist/tools/graph-tools.js +99 -0
- package/dist/tools/message-tools.js +6 -6
- package/package.json +1 -1
|
@@ -72,7 +72,8 @@ export async function getActivityFeed(options = {}) {
|
|
|
72
72
|
let syncState;
|
|
73
73
|
if (metadata?.syncState) {
|
|
74
74
|
try {
|
|
75
|
-
|
|
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 -
|
|
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 -
|
|
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
|
-
//
|
|
288
|
-
|
|
289
|
-
const htmlContent =
|
|
290
|
-
|
|
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
|
-
|
|
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
|
+
});
|
package/dist/browser/context.js
CHANGED
|
@@ -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,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,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
|
+
});
|
package/dist/tools/auth-tools.js
CHANGED
|
@@ -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.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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: '
|
|
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
|
|
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.
|
|
261
|
+
description: 'Optional. A specific conversation ID to check. If omitted, checks all favourites.',
|
|
262
262
|
},
|
|
263
263
|
},
|
|
264
264
|
},
|