msteams-mcp 0.30.0 → 0.30.2

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.
@@ -0,0 +1,476 @@
1
+ #!/usr/bin/env npx tsx
2
+ /**
3
+ * MCP Protocol Test Harness
4
+ *
5
+ * Tests the MCP server by connecting a client through the actual MCP protocol,
6
+ * rather than calling underlying functions directly. This ensures the full
7
+ * protocol layer works correctly.
8
+ *
9
+ * Usage:
10
+ * npm run test:mcp # List tools and check status
11
+ * npm run test:mcp -- search "query" # Search for messages (shortcut)
12
+ * npm run test:mcp -- teams_search --query "q" # Generic tool call
13
+ * npm run test:mcp -- --json # Output as JSON
14
+ *
15
+ * Any unrecognised command is treated as a tool name. Use --key value for parameters.
16
+ */
17
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
18
+ import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
19
+ import { createServer } from '../server.js';
20
+ // Shortcuts map command names to tool names and parameter mappings
21
+ const SHORTCUTS = {
22
+ search: { tool: 'teams_search', primaryArg: 'query' },
23
+ status: { tool: 'teams_status' },
24
+ send: { tool: 'teams_send_message', primaryArg: 'content' },
25
+ me: { tool: 'teams_get_me' },
26
+ people: { tool: 'teams_search_people', primaryArg: 'query' },
27
+ favorites: { tool: 'teams_get_favorites' },
28
+ save: { tool: 'teams_save_message' },
29
+ unsave: { tool: 'teams_unsave_message' },
30
+ thread: { tool: 'teams_get_thread' },
31
+ login: { tool: 'teams_login' },
32
+ contacts: { tool: 'teams_get_frequent_contacts' },
33
+ channel: { tool: 'teams_find_channel', primaryArg: 'query' },
34
+ chat: { tool: 'teams_get_chat', primaryArg: 'userId' },
35
+ groupchat: { tool: 'teams_create_group_chat' },
36
+ unread: { tool: 'teams_get_unread' },
37
+ markread: { tool: 'teams_mark_read' },
38
+ activity: { tool: 'teams_get_activity' },
39
+ message: { tool: 'teams_get_message' },
40
+ };
41
+ // Map CLI flags to tool parameter names
42
+ const FLAG_MAPPINGS = {
43
+ '--to': 'conversationId',
44
+ '--message': 'messageId',
45
+ '--reply': 'replyToMessageId',
46
+ '--replyTo': 'replyToMessageId',
47
+ '--from': 'from',
48
+ '--size': 'size',
49
+ '--limit': 'limit',
50
+ '--query': 'query',
51
+ '--content': 'content',
52
+ '--force': 'forceNew',
53
+ '--user': 'userId',
54
+ '--userId': 'userId',
55
+ '--userIds': 'userIds',
56
+ '--topic': 'topic',
57
+ '--markRead': 'markRead',
58
+ };
59
+ function parseArgs() {
60
+ const args = process.argv.slice(2);
61
+ const result = {
62
+ command: 'list',
63
+ toolName: null,
64
+ primaryArg: null,
65
+ args: {},
66
+ json: false,
67
+ };
68
+ let i = 0;
69
+ // First pass: find the command (first non-flag argument)
70
+ while (i < args.length) {
71
+ const arg = args[i];
72
+ if (arg === '--json') {
73
+ result.json = true;
74
+ i++;
75
+ continue;
76
+ }
77
+ if (arg.startsWith('--')) {
78
+ // Skip flag and its value
79
+ i += 2;
80
+ continue;
81
+ }
82
+ // Found the command
83
+ result.command = arg;
84
+ i++;
85
+ break;
86
+ }
87
+ // Check if it's a shortcut
88
+ const shortcut = SHORTCUTS[result.command];
89
+ if (shortcut) {
90
+ result.toolName = shortcut.tool;
91
+ // Look for primary argument (next non-flag arg)
92
+ while (i < args.length) {
93
+ const arg = args[i];
94
+ if (!arg.startsWith('--')) {
95
+ if (shortcut.primaryArg) {
96
+ result.args[shortcut.primaryArg] = arg;
97
+ }
98
+ result.primaryArg = arg;
99
+ i++;
100
+ break;
101
+ }
102
+ // Skip --flag and its value
103
+ i += 2;
104
+ }
105
+ }
106
+ else if (result.command !== 'list') {
107
+ // Treat as a tool name (add teams_ prefix if not present)
108
+ result.toolName = result.command.startsWith('teams_')
109
+ ? result.command
110
+ : `teams_${result.command}`;
111
+ }
112
+ // Second pass: collect all --key value pairs
113
+ for (let j = 0; j < args.length; j++) {
114
+ const arg = args[j];
115
+ if (arg === '--json') {
116
+ result.json = true;
117
+ continue;
118
+ }
119
+ if (arg.startsWith('--') && args[j + 1] !== undefined) {
120
+ const key = FLAG_MAPPINGS[arg] || arg.slice(2); // Use mapping or strip --
121
+ let value = args[j + 1];
122
+ // Only parse booleans and specific numeric fields
123
+ // Don't coerce messageId, conversationId etc. - they're strings
124
+ const numericFields = new Set(['from', 'size', 'limit']);
125
+ if (value === 'true')
126
+ value = true;
127
+ else if (value === 'false')
128
+ value = false;
129
+ else if (numericFields.has(key) && /^\d+$/.test(value)) {
130
+ value = parseInt(value, 10);
131
+ }
132
+ else if (typeof value === 'string' && (value.startsWith('[') || value.startsWith('{'))) {
133
+ // Try to parse JSON for array or object values
134
+ try {
135
+ value = JSON.parse(value);
136
+ }
137
+ catch {
138
+ // Keep as string if not valid JSON
139
+ }
140
+ }
141
+ result.args[key] = value;
142
+ j++; // Skip the value
143
+ }
144
+ }
145
+ return result;
146
+ }
147
+ function log(message) {
148
+ console.log(message);
149
+ }
150
+ function logSection(title) {
151
+ console.log('\n' + '─'.repeat(50));
152
+ console.log(` ${title}`);
153
+ console.log('─'.repeat(50));
154
+ }
155
+ async function createTestClient() {
156
+ // Create the MCP server
157
+ const server = await createServer();
158
+ // Create linked in-memory transports
159
+ const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
160
+ // Connect the server to its transport
161
+ await server.connect(serverTransport);
162
+ // Create and connect the client
163
+ const client = new Client({ name: 'mcp-test-harness', version: '1.0.0' }, { capabilities: {} });
164
+ await client.connect(clientTransport);
165
+ const cleanup = async () => {
166
+ await client.close();
167
+ await server.close();
168
+ };
169
+ return { client, cleanup };
170
+ }
171
+ async function listTools(client, json) {
172
+ if (!json) {
173
+ logSection('Available Tools');
174
+ }
175
+ const result = await client.listTools();
176
+ if (json) {
177
+ console.log(JSON.stringify(result, null, 2));
178
+ return;
179
+ }
180
+ log(`Found ${result.tools.length} tools:\n`);
181
+ for (const tool of result.tools) {
182
+ log(`📦 ${tool.name}`);
183
+ log(` ${tool.description}`);
184
+ const schema = tool.inputSchema;
185
+ if (schema.properties) {
186
+ const props = Object.entries(schema.properties);
187
+ const required = new Set(schema.required ?? []);
188
+ for (const [name, prop] of props) {
189
+ const propObj = prop;
190
+ const reqMark = required.has(name) ? ' (required)' : '';
191
+ log(` - ${name}: ${propObj.type ?? 'any'}${reqMark}`);
192
+ if (propObj.description) {
193
+ log(` ${propObj.description}`);
194
+ }
195
+ }
196
+ }
197
+ log('');
198
+ }
199
+ // Show available shortcuts
200
+ log('Shortcuts:');
201
+ for (const [shortcut, config] of Object.entries(SHORTCUTS)) {
202
+ const primaryNote = config.primaryArg ? ` <${config.primaryArg}>` : '';
203
+ log(` ${shortcut}${primaryNote} → ${config.tool}`);
204
+ }
205
+ log('');
206
+ log('Any unrecognised command is treated as a tool name.');
207
+ log('Use --key value for parameters, e.g.: teams_find_channel --query "name"');
208
+ }
209
+ async function callTool(client, toolName, args, json) {
210
+ // Verify the tool exists
211
+ const tools = await client.listTools();
212
+ const tool = tools.tools.find(t => t.name === toolName);
213
+ if (!tool) {
214
+ console.error(`❌ Unknown tool: ${toolName}`);
215
+ console.error('');
216
+ console.error('Available tools:');
217
+ for (const t of tools.tools) {
218
+ console.error(` - ${t.name}`);
219
+ }
220
+ process.exit(1);
221
+ }
222
+ if (!json) {
223
+ logSection(`Calling: ${toolName}`);
224
+ if (Object.keys(args).length > 0) {
225
+ log(`Arguments: ${JSON.stringify(args)}\n`);
226
+ }
227
+ }
228
+ // Login tool needs a longer timeout for MFA flows
229
+ const timeout = toolName === 'teams_login' ? 5 * 60 * 1000 : undefined; // 5 minutes for login
230
+ const result = await client.callTool({ name: toolName, arguments: args }, undefined, { timeout });
231
+ if (json) {
232
+ console.log(JSON.stringify(result, null, 2));
233
+ return;
234
+ }
235
+ // Pretty-print the result
236
+ const content = result.content;
237
+ const textContent = content.find(c => c.type === 'text');
238
+ if (textContent?.text) {
239
+ try {
240
+ const response = JSON.parse(textContent.text);
241
+ prettyPrintResponse(response, toolName);
242
+ }
243
+ catch {
244
+ // Not JSON, just print as-is
245
+ log(textContent.text);
246
+ }
247
+ }
248
+ else {
249
+ log('(No text content in response)');
250
+ }
251
+ }
252
+ /**
253
+ * Pretty-prints a tool response based on common patterns
254
+ */
255
+ function prettyPrintResponse(response, _toolName) {
256
+ // Check for error
257
+ if (response.success === false) {
258
+ log(`❌ Failed: ${response.error || 'Unknown error'}`);
259
+ if (response.code)
260
+ log(` Code: ${response.code}`);
261
+ if (response.suggestion)
262
+ log(` Suggestion: ${response.suggestion}`);
263
+ return;
264
+ }
265
+ log('✅ Success\n');
266
+ // Handle common response shapes
267
+ if (response.results && Array.isArray(response.results)) {
268
+ printResultsList(response.results, response);
269
+ }
270
+ else if (response.favorites && Array.isArray(response.favorites)) {
271
+ printFavoritesList(response.favorites);
272
+ }
273
+ else if (response.messages && Array.isArray(response.messages)) {
274
+ printMessagesList(response.messages);
275
+ }
276
+ else if (response.activities && Array.isArray(response.activities)) {
277
+ printActivityList(response.activities);
278
+ }
279
+ else if (response.contacts && Array.isArray(response.contacts)) {
280
+ printContactsList(response.contacts);
281
+ }
282
+ else if (response.profile) {
283
+ printProfile(response.profile);
284
+ }
285
+ else {
286
+ // Generic output for other responses
287
+ printGenericResponse(response);
288
+ }
289
+ // Print pagination if present
290
+ if (response.pagination) {
291
+ const p = response.pagination;
292
+ log(`\nPagination: from=${p.from}, size=${p.size}, returned=${p.returned}`);
293
+ if (p.total !== undefined)
294
+ log(`Total available: ${p.total}`);
295
+ if (p.hasMore)
296
+ log(`More results available (use --from ${p.nextFrom})`);
297
+ }
298
+ }
299
+ function printResultsList(results, response) {
300
+ log(`Found ${response.resultCount ?? results.length} results:\n`);
301
+ for (let i = 0; i < results.length; i++) {
302
+ const r = results[i];
303
+ const num = (response.pagination?.from ?? 0) + i + 1;
304
+ // Try to extract meaningful content
305
+ const content = String(r.content ?? r.displayName ?? r.name ?? '').substring(0, 100).replace(/\n/g, ' ');
306
+ log(`${num}. ${content}${content.length >= 100 ? '...' : ''}`);
307
+ // Print common fields
308
+ const sender = extractSenderName(r.sender);
309
+ if (sender)
310
+ log(` From: ${sender}`);
311
+ if (r.email)
312
+ log(` Email: ${r.email}`);
313
+ if (r.teamName)
314
+ log(` Team: ${r.teamName}`);
315
+ if (r.channelName && r.channelName !== r.teamName)
316
+ log(` Channel: ${r.channelName}`);
317
+ if (r.timestamp)
318
+ log(` Time: ${r.timestamp}`);
319
+ if (r.conversationId)
320
+ log(` ConversationId: ${r.conversationId}`);
321
+ if (r.jobTitle)
322
+ log(` Title: ${r.jobTitle}`);
323
+ if (r.department)
324
+ log(` Dept: ${r.department}`);
325
+ if (r.mri)
326
+ log(` MRI: ${r.mri}`);
327
+ log('');
328
+ }
329
+ }
330
+ function printFavoritesList(favorites) {
331
+ log(`Found ${favorites.length} favourites:\n`);
332
+ for (const f of favorites) {
333
+ const fav = f;
334
+ const typeLabel = fav.conversationType ? ` [${fav.conversationType}]` : '';
335
+ const nameLabel = fav.displayName || '(unnamed)';
336
+ log(`⭐ ${nameLabel}${typeLabel}`);
337
+ log(` ID: ${fav.conversationId}`);
338
+ }
339
+ }
340
+ function printMessagesList(messages) {
341
+ log(`Got ${messages.length} messages:\n`);
342
+ for (const msg of messages) {
343
+ const m = msg;
344
+ const preview = String(m.content ?? '').substring(0, 100).replace(/\n/g, ' ');
345
+ const sender = m.sender?.displayName ||
346
+ m.sender?.mri || 'Unknown';
347
+ const time = m.timestamp ? new Date(m.timestamp).toLocaleString() : '';
348
+ const fromMe = m.isFromMe ? ' (you)' : '';
349
+ log(`📝 ${sender}${fromMe} - ${time}`);
350
+ log(` ${preview}${String(m.content ?? '').length > 100 ? '...' : ''}`);
351
+ log('');
352
+ }
353
+ }
354
+ function printContactsList(contacts) {
355
+ log(`Found ${contacts.length} contacts:\n`);
356
+ for (const c of contacts) {
357
+ const contact = c;
358
+ log(`👤 ${contact.displayName}`);
359
+ if (contact.email)
360
+ log(` Email: ${contact.email}`);
361
+ if (contact.jobTitle)
362
+ log(` Title: ${contact.jobTitle}`);
363
+ if (contact.department)
364
+ log(` Dept: ${contact.department}`);
365
+ log('');
366
+ }
367
+ }
368
+ function printActivityList(activities) {
369
+ log(`Found ${activities.length} activity items:\n`);
370
+ const typeIcons = {
371
+ mention: '📣',
372
+ reaction: '👍',
373
+ reply: '💬',
374
+ message: '📝',
375
+ unknown: '❓',
376
+ };
377
+ for (const a of activities) {
378
+ const activity = a;
379
+ const type = activity.type || 'unknown';
380
+ const icon = typeIcons[type] || '❓';
381
+ const sender = activity.sender?.displayName || 'Unknown';
382
+ const time = activity.timestamp ? new Date(activity.timestamp).toLocaleString() : '';
383
+ const topic = activity.topic ? ` in "${activity.topic}"` : '';
384
+ const preview = String(activity.content ?? '').substring(0, 80).replace(/\n/g, ' ');
385
+ log(`${icon} [${type}] ${sender}${topic} - ${time}`);
386
+ log(` ${preview}${String(activity.content ?? '').length > 80 ? '...' : ''}`);
387
+ if (activity.conversationId)
388
+ log(` ConversationId: ${activity.conversationId}`);
389
+ log('');
390
+ }
391
+ }
392
+ function printProfile(profile) {
393
+ log('👤 Profile:\n');
394
+ for (const [key, value] of Object.entries(profile)) {
395
+ if (value !== null && value !== undefined) {
396
+ log(` ${key}: ${value}`);
397
+ }
398
+ }
399
+ }
400
+ function printGenericResponse(response) {
401
+ // Filter out success flag and print remaining fields
402
+ for (const [key, value] of Object.entries(response)) {
403
+ if (key === 'success')
404
+ continue;
405
+ if (typeof value === 'object' && value !== null) {
406
+ log(`${key}: ${JSON.stringify(value, null, 2)}`);
407
+ }
408
+ else {
409
+ log(`${key}: ${value}`);
410
+ }
411
+ }
412
+ }
413
+ function extractSenderName(sender) {
414
+ if (!sender)
415
+ return null;
416
+ if (typeof sender === 'string')
417
+ return sender;
418
+ if (typeof sender === 'object') {
419
+ const s = sender;
420
+ // Handle { EmailAddress: { Name: string } } structure
421
+ if (s.EmailAddress && typeof s.EmailAddress === 'object') {
422
+ const email = s.EmailAddress;
423
+ if (email.Name)
424
+ return String(email.Name);
425
+ if (email.Address)
426
+ return String(email.Address);
427
+ }
428
+ // Handle { name: string } or { displayName: string } structure
429
+ if (s.displayName)
430
+ return String(s.displayName);
431
+ if (s.name)
432
+ return String(s.name);
433
+ if (s.Name)
434
+ return String(s.Name);
435
+ }
436
+ return null;
437
+ }
438
+ async function main() {
439
+ const parsed = parseArgs();
440
+ if (!parsed.json) {
441
+ console.log('\n🧪 MCP Protocol Test Harness');
442
+ console.log('============================');
443
+ }
444
+ let cleanup = null;
445
+ try {
446
+ const { client, cleanup: cleanupFn } = await createTestClient();
447
+ cleanup = cleanupFn;
448
+ if (!parsed.json) {
449
+ log('\n✅ Connected to MCP server via in-memory transport');
450
+ }
451
+ if (parsed.command === 'list' || !parsed.toolName) {
452
+ await listTools(client, parsed.json);
453
+ }
454
+ else {
455
+ await callTool(client, parsed.toolName, parsed.args, parsed.json);
456
+ }
457
+ if (!parsed.json) {
458
+ logSection('Complete');
459
+ log('MCP protocol test finished successfully.');
460
+ }
461
+ }
462
+ catch (error) {
463
+ console.error('\n❌ Error:', error instanceof Error ? error.message : String(error));
464
+ if (error instanceof Error && error.stack) {
465
+ console.error('\nStack trace:');
466
+ console.error(error.stack);
467
+ }
468
+ process.exit(1);
469
+ }
470
+ finally {
471
+ if (cleanup) {
472
+ await cleanup();
473
+ }
474
+ }
475
+ }
476
+ main();
@@ -5,12 +5,12 @@ import { createRequire } from 'module';
5
5
  import { z } from 'zod';
6
6
  const require = createRequire(import.meta.url);
7
7
  const pkg = require('../../package.json');
8
- import { hasSessionState, isSessionLikelyExpired, clearSessionState, clearTokenCache, } from '../auth/session-store.js';
8
+ import { hasSessionState, isSessionLikelyExpired, clearAllLocalData, } from '../auth/session-store.js';
9
9
  import { getSubstrateTokenStatus, getMessageAuthStatus, extractMessageAuth, extractCsaToken, extractSubstrateToken, extractSkypeSpacesToken, } from '../auth/token-extractor.js';
10
10
  import { refreshTokensViaHttp } from '../auth/token-refresh-http.js';
11
11
  import { createBrowserContext, closeBrowser } from '../browser/context.js';
12
12
  import * as log from '../utils/logger.js';
13
- import { resetAssignmentsAvailability } from '../utils/auth-guards.js';
13
+ import { resetAssignmentsAvailability, clearRegionCache } from '../utils/auth-guards.js';
14
14
  import { ensureAuthenticated } from '../browser/auth.js';
15
15
  // ─────────────────────────────────────────────────────────────────────────────
16
16
  // Schemas
@@ -29,7 +29,7 @@ const loginToolDefinition = {
29
29
  properties: {
30
30
  forceNew: {
31
31
  type: 'boolean',
32
- description: 'Force a new login even if a session exists (default: false)',
32
+ description: 'Delete all previous login data (browser profile, cookies, saved session, every previously signed-in account) and sign in from scratch in a visible browser. Use to switch accounts or recover a broken session (default: false)',
33
33
  },
34
34
  },
35
35
  },
@@ -76,36 +76,32 @@ async function handleLogin(input) {
76
76
  // A fresh login may have changed what the account can access (e.g. new consent)
77
77
  resetAssignmentsAvailability();
78
78
  if (input.forceNew) {
79
- clearSessionState();
80
- clearTokenCache();
79
+ // Start from nothing: no profile, cookies, MSAL cache or session from any
80
+ // earlier account can survive to be mixed into the new login.
81
+ clearAllLocalData();
82
+ clearRegionCache();
83
+ return loginInVisibleBrowser();
81
84
  }
82
- else {
83
- // Fast path: if tokens are still valid, skip browser entirely
84
- const tokenStatus = getSubstrateTokenStatus();
85
- if (tokenStatus.hasToken && tokenStatus.minutesRemaining >= TOKEN_VALID_THRESHOLD_MINUTES) {
86
- await fillCoreCredentialGaps();
87
- return {
88
- success: true,
89
- data: {
90
- message: `Already authenticated. Token valid for ${tokenStatus.minutesRemaining} more minutes.`,
91
- tokenStatus: {
92
- expiresAt: tokenStatus.expiresAt,
93
- minutesRemaining: tokenStatus.minutesRemaining,
94
- },
85
+ // Fast path: if tokens are still valid, skip browser entirely
86
+ const tokenStatus = getSubstrateTokenStatus();
87
+ if (tokenStatus.hasToken && tokenStatus.minutesRemaining >= TOKEN_VALID_THRESHOLD_MINUTES) {
88
+ await fillCoreCredentialGaps();
89
+ return {
90
+ success: true,
91
+ data: {
92
+ message: `Already authenticated. Token valid for ${tokenStatus.minutesRemaining} more minutes.`,
93
+ tokenStatus: {
94
+ expiresAt: tokenStatus.expiresAt,
95
+ minutesRemaining: tokenStatus.minutesRemaining,
95
96
  },
96
- };
97
- }
97
+ },
98
+ };
98
99
  }
99
100
  // Headless-first strategy:
100
101
  // The persistent browser profile retains Microsoft's long-lived session cookies,
101
- // so headless SSO can succeed even without a session-state file. Always try
102
- // headless first — even for forceNew. Most recovery scenarios complete silently.
102
+ // so headless SSO can succeed even without a session-state file.
103
103
  const headless = await createBrowserContext({ headless: true });
104
104
  try {
105
- if (input.forceNew) {
106
- // Clear persistent profile cookies to force fresh authentication
107
- await headless.context.clearCookies();
108
- }
109
105
  await ensureAuthenticated(headless.page, headless.context, (msg) => log.info('login:headless', msg), false, // No overlay in headless
110
106
  true // Headless mode - throw immediately if user interaction required
111
107
  );
@@ -122,7 +118,10 @@ async function handleLogin(input) {
122
118
  log.warn('login:headless', `Headless SSO failed, falling back to visible browser: ${error instanceof Error ? error.message : String(error)}`);
123
119
  await closeBrowser(headless, false).catch(() => { });
124
120
  }
125
- // Open visible browser for user interaction
121
+ return loginInVisibleBrowser();
122
+ }
123
+ /** Opens a visible browser for the user to sign in, then saves the session. */
124
+ async function loginInVisibleBrowser() {
126
125
  const visible = await createBrowserContext({ headless: false });
127
126
  try {
128
127
  await ensureAuthenticated(visible.page, visible.context, (msg) => log.info('login', msg));
@@ -2,8 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
2
  vi.mock('../auth/session-store.js', () => ({
3
3
  hasSessionState: vi.fn(() => true),
4
4
  isSessionLikelyExpired: vi.fn(() => false),
5
- clearSessionState: vi.fn(),
6
- clearTokenCache: vi.fn(),
5
+ clearAllLocalData: vi.fn(),
7
6
  }));
8
7
  vi.mock('../auth/token-extractor.js', () => ({
9
8
  getSubstrateTokenStatus: vi.fn(),
@@ -16,8 +15,9 @@ vi.mock('../auth/token-extractor.js', () => ({
16
15
  vi.mock('../auth/token-refresh-http.js', () => ({ refreshTokensViaHttp: vi.fn() }));
17
16
  vi.mock('../browser/context.js', () => ({ createBrowserContext: vi.fn(), closeBrowser: vi.fn() }));
18
17
  vi.mock('../browser/auth.js', () => ({ ensureAuthenticated: vi.fn() }));
19
- vi.mock('../utils/auth-guards.js', () => ({ resetAssignmentsAvailability: vi.fn() }));
18
+ vi.mock('../utils/auth-guards.js', () => ({ resetAssignmentsAvailability: vi.fn(), clearRegionCache: vi.fn() }));
20
19
  import { loginTool } from './auth-tools.js';
20
+ import { clearAllLocalData } from '../auth/session-store.js';
21
21
  import { getSubstrateTokenStatus, extractMessageAuth, extractCsaToken, extractSubstrateToken, extractSkypeSpacesToken, } from '../auth/token-extractor.js';
22
22
  import { refreshTokensViaHttp } from '../auth/token-refresh-http.js';
23
23
  import { createBrowserContext, closeBrowser } from '../browser/context.js';
@@ -49,12 +49,26 @@ describe('teams_login core credential top-up', () => {
49
49
  });
50
50
  it('tops up after a forced browser login leaves the CSA token behind', async () => {
51
51
  sessionMissingCsaToken();
52
- vi.mocked(createBrowserContext).mockResolvedValue({ page: {}, context: { clearCookies: vi.fn() } });
52
+ vi.mocked(createBrowserContext).mockResolvedValue({ page: {}, context: {} });
53
53
  vi.mocked(closeBrowser).mockResolvedValue(undefined);
54
54
  const result = await loginTool.handler({ forceNew: true });
55
55
  expect(result.success).toBe(true);
56
56
  expect(refreshTokensViaHttp).toHaveBeenCalledOnce();
57
57
  });
58
+ it('forceNew wipes all previous login data and signs in from scratch in a visible browser', async () => {
59
+ sessionMissingCsaToken();
60
+ vi.mocked(extractCsaToken).mockReturnValue('csa');
61
+ vi.mocked(createBrowserContext).mockResolvedValue({ page: {}, context: {} });
62
+ vi.mocked(closeBrowser).mockResolvedValue(undefined);
63
+ await loginTool.handler({ forceNew: true });
64
+ expect(clearAllLocalData).toHaveBeenCalledOnce();
65
+ expect(vi.mocked(clearAllLocalData).mock.invocationCallOrder[0])
66
+ .toBeLessThan(vi.mocked(createBrowserContext).mock.invocationCallOrder[0]);
67
+ // Valid-looking tokens from the old session must not short-circuit the login,
68
+ // and a wiped profile has nothing for headless SSO to use.
69
+ expect(createBrowserContext).toHaveBeenCalledOnce();
70
+ expect(createBrowserContext).toHaveBeenCalledWith({ headless: false });
71
+ });
58
72
  it('still reports success when the top-up fails', async () => {
59
73
  sessionMissingCsaToken();
60
74
  vi.mocked(refreshTokensViaHttp).mockResolvedValue({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "msteams-mcp",
3
- "version": "0.30.0",
3
+ "version": "0.30.2",
4
4
  "description": "MCP server for Microsoft Teams - search messages, send replies, manage favourites",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -1,8 +0,0 @@
1
- /**
2
- * Tests for browser channel selection.
3
- *
4
- * The server launches into its own profile dir, so the user's default browser
5
- * is irrelevant - what matters is that every platform has an ordered list of
6
- * channels to try and ends with a fallback that needs no system browser.
7
- */
8
- export {};