msteams-mcp 0.29.1 → 0.29.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.
package/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
  [![npm version](https://img.shields.io/npm/v/msteams-mcp.svg)](https://www.npmjs.com/package/msteams-mcp)
5
5
  [![npm downloads](https://img.shields.io/npm/dm/msteams-mcp.svg)](https://www.npmjs.com/package/msteams-mcp)
6
6
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
7
- [![Node.js](https://img.shields.io/badge/node-%3E%3D18-brightgreen.svg)](https://nodejs.org/)
7
+ [![Node.js](https://img.shields.io/badge/node-%3E%3D20-brightgreen.svg)](https://nodejs.org/)
8
8
 
9
9
  Give AI access to Microsoft Teams easily with your standard Teams login.
10
10
 
@@ -27,7 +27,7 @@ Search messages and email, catch up on unread chats, read threads, send replies,
27
27
 
28
28
  ### Prerequisites
29
29
 
30
- - Node.js 18+
30
+ - Node.js 20+
31
31
  - A Microsoft account with Teams access
32
32
  - Google Chrome, Microsoft Edge, or Chromium browser installed
33
33
 
@@ -13,7 +13,7 @@ import * as fs from 'fs';
13
13
  import * as os from 'os';
14
14
  import * as path from 'path';
15
15
  import { encrypt, decrypt } from './crypto.js';
16
- import { SESSION_EXPIRY_HOURS } from '../constants.js';
16
+ import { SESSION_EXPIRY_HOURS, TEAMS_HOSTS } from '../constants.js';
17
17
  import * as log from '../utils/logger.js';
18
18
  /**
19
19
  * User-specific config directory for teams-mcp-server.
@@ -131,12 +131,7 @@ export function clearTokenCache() {
131
131
  * Known Teams origins (commercial and government clouds).
132
132
  * Used to find the correct origin in session state.
133
133
  */
134
- const TEAMS_ORIGINS = [
135
- 'https://teams.cloud.microsoft', // New Teams URL (often holds SubstrateSearch tokens)
136
- 'https://teams.microsoft.com', // Commercial legacy
137
- 'https://teams.microsoft.us', // GCC-High
138
- 'https://dod.teams.microsoft.us', // DoD
139
- ];
134
+ const TEAMS_ORIGINS = TEAMS_HOSTS.map(host => `https://${host}`);
140
135
  /**
141
136
  * Returns true if localStorage has a SubstrateSearch MSAL token entry.
142
137
  */
@@ -177,6 +172,6 @@ export function getTeamsOrigin(state) {
177
172
  if (origin)
178
173
  return origin;
179
174
  }
180
- // Fallback: find any origin containing 'teams.microsoft'
181
- return state.origins.find(o => o.origin.includes('teams.microsoft') || o.origin.includes('teams.cloud')) ?? null;
175
+ // Fallback: any origin on a known Teams host
176
+ return state.origins.find(o => TEAMS_HOSTS.some(host => o.origin.includes(host))) ?? null;
182
177
  }
@@ -3,6 +3,19 @@
3
3
  * Manages login detection and manual authentication flows.
4
4
  */
5
5
  import type { Page, BrowserContext } from 'playwright';
6
+ interface AuthStatus {
7
+ isAuthenticated: boolean;
8
+ isOnLoginPage: boolean;
9
+ currentUrl: string;
10
+ }
11
+ /**
12
+ * Gets the current authentication status.
13
+ */
14
+ export declare function getAuthStatus(page: Page): Promise<AuthStatus>;
15
+ /**
16
+ * Checks if a URL is a Teams domain.
17
+ */
18
+ export declare function isTeamsUrl(url: string): boolean;
6
19
  /**
7
20
  * Performs a full authentication flow:
8
21
  * 1. Navigate to Teams
@@ -16,3 +29,4 @@ import type { Page, BrowserContext } from 'playwright';
16
29
  * @param headless - If true, throw immediately if user interaction is required (default: false)
17
30
  */
18
31
  export declare function ensureAuthenticated(page: Page, context: BrowserContext, onProgress?: (message: string) => void, showOverlay?: boolean, headless?: boolean): Promise<void>;
32
+ export {};
@@ -3,7 +3,7 @@
3
3
  * Manages login detection and manual authentication flows.
4
4
  */
5
5
  import { saveSessionState } from './context.js';
6
- import { OVERLAY_STEP_PAUSE_MS, OVERLAY_COMPLETE_PAUSE_MS, } from '../constants.js';
6
+ import { OVERLAY_STEP_PAUSE_MS, OVERLAY_COMPLETE_PAUSE_MS, TEAMS_HOSTS, } from '../constants.js';
7
7
  import { extractSubstrateToken } from '../auth/token-extractor.js';
8
8
  import * as logger from '../utils/logger.js';
9
9
  /**
@@ -145,41 +145,16 @@ const LOGIN_URL_PATTERNS = [
145
145
  'login.live.com',
146
146
  'login.microsoft.com',
147
147
  ];
148
- // Selectors that indicate successful authentication
149
- const AUTH_SUCCESS_SELECTORS = [
150
- '[data-tid="app-bar"]',
151
- '[data-tid="search-box"]',
152
- 'input[placeholder*="Search"]',
153
- '[data-tid="chat-list"]',
154
- '[data-tid="team-list"]',
155
- ];
156
148
  /**
157
149
  * Checks if the current page URL indicates a login flow.
158
150
  */
159
151
  function isLoginUrl(url) {
160
152
  return LOGIN_URL_PATTERNS.some(pattern => url.includes(pattern));
161
153
  }
162
- /**
163
- * Checks if the page shows authenticated Teams content.
164
- */
165
- async function hasAuthenticatedContent(page) {
166
- for (const selector of AUTH_SUCCESS_SELECTORS) {
167
- try {
168
- const count = await page.locator(selector).count();
169
- if (count > 0) {
170
- return true;
171
- }
172
- }
173
- catch {
174
- // Selector not found, continue checking others
175
- }
176
- }
177
- return false;
178
- }
179
154
  /**
180
155
  * Gets the current authentication status.
181
156
  */
182
- async function getAuthStatus(page) {
157
+ export async function getAuthStatus(page) {
183
158
  const currentUrl = page.url();
184
159
  const onLoginPage = isLoginUrl(currentUrl);
185
160
  // If on login page, definitely not authenticated
@@ -190,11 +165,15 @@ async function getAuthStatus(page) {
190
165
  currentUrl,
191
166
  };
192
167
  }
193
- // If on Teams domain, check for authenticated content
194
- if (currentUrl.includes('teams.microsoft.com')) {
195
- const hasContent = await hasAuthenticatedContent(page);
168
+ // On a Teams domain, a live MSAL token is the proof of sign-in: it is what
169
+ // every API call needs and what the saved session is for. Teams' own DOM
170
+ // markers are not a usable signal — new Teams renders none of the `data-tid`
171
+ // elements earlier versions exposed, so waiting for them left a completed
172
+ // login undetected and the browser window open until the manual-login
173
+ // timeout.
174
+ if (isTeamsUrl(currentUrl)) {
196
175
  return {
197
- isAuthenticated: hasContent,
176
+ isAuthenticated: await checkBrowserTokenExpiry(page) > 0,
198
177
  isOnLoginPage: false,
199
178
  currentUrl,
200
179
  };
@@ -208,18 +187,11 @@ async function getAuthStatus(page) {
208
187
  }
209
188
  /** Timeout for detecting login redirect (ms). */
210
189
  const LOGIN_REDIRECT_TIMEOUT_MS = 5000;
211
- /** URL patterns that indicate we're on a Teams page (not redirected elsewhere). */
212
- const TEAMS_URL_PATTERNS = [
213
- 'teams.microsoft.com',
214
- 'teams.microsoft.us', // GCC-High
215
- 'dod.teams.microsoft.us', // DoD
216
- 'teams.cloud.microsoft', // New Teams URL
217
- ];
218
190
  /**
219
191
  * Checks if a URL is a Teams domain.
220
192
  */
221
- function isTeamsUrl(url) {
222
- return TEAMS_URL_PATTERNS.some(pattern => url.includes(pattern));
193
+ export function isTeamsUrl(url) {
194
+ return TEAMS_HOSTS.some(host => url.includes(host));
223
195
  }
224
196
  /**
225
197
  * Navigates to Teams and checks authentication status.
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,45 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ vi.mock('./context.js', () => ({ saveSessionState: vi.fn() }));
3
+ vi.mock('../auth/token-extractor.js', () => ({ extractSubstrateToken: vi.fn() }));
4
+ import { getAuthStatus, isTeamsUrl } from './auth.js';
5
+ /** A page on `url` with no Teams DOM markers and `tokenMinutes` left on MSAL. */
6
+ function fakePage(url, tokenMinutes) {
7
+ return {
8
+ url: () => url,
9
+ locator: () => ({ count: async () => 0 }),
10
+ evaluate: async () => tokenMinutes,
11
+ };
12
+ }
13
+ describe('isTeamsUrl', () => {
14
+ it('covers every host Microsoft redirects Teams users to', () => {
15
+ // New Teams settles on the bare host, with no path.
16
+ for (const url of [
17
+ 'https://teams.microsoft.com/',
18
+ 'https://teams.cloud.microsoft/',
19
+ 'https://teams.chat.microsoft.com/',
20
+ 'https://teams.microsoft.us/',
21
+ 'https://dod.teams.microsoft.us/',
22
+ ]) {
23
+ expect(isTeamsUrl(url), url).toBe(true);
24
+ }
25
+ expect(isTeamsUrl('https://outlook.office.com/')).toBe(false);
26
+ });
27
+ it('matches on the host, whatever path the app has routed to', () => {
28
+ expect(isTeamsUrl('https://teams.microsoft.com/v2/#/conversations/19:abc@thread.tacv2')).toBe(true);
29
+ });
30
+ });
31
+ describe('getAuthStatus', () => {
32
+ it('detects a completed login from the MSAL token alone', async () => {
33
+ const status = await getAuthStatus(fakePage('https://teams.cloud.microsoft/', 55));
34
+ expect(status.isAuthenticated).toBe(true);
35
+ expect(status.isOnLoginPage).toBe(false);
36
+ });
37
+ it('stays unauthenticated on a Teams host with no token', async () => {
38
+ const status = await getAuthStatus(fakePage('https://teams.cloud.microsoft/', -1));
39
+ expect(status.isAuthenticated).toBe(false);
40
+ });
41
+ it('reports the login page', async () => {
42
+ const status = await getAuthStatus(fakePage('https://login.microsoftonline.com/common/oauth2', 99));
43
+ expect(status).toMatchObject({ isAuthenticated: false, isOnLoginPage: true });
44
+ });
45
+ });
package/dist/cli.js CHANGED
File without changes
@@ -101,3 +101,12 @@ export declare const STANDARD_EMOJIS: readonly StandardEmoji[];
101
101
  export declare const ASSIGNMENTS_APP_ID = "8f348934-64be-4bb2-bc16-c54c96789f43";
102
102
  /** Microsoft Graph token audiences: MSAL caches either the URL or the app ID. */
103
103
  export declare const GRAPH_AUDIENCES: readonly string[];
104
+ /**
105
+ * Teams web hosts, in the order a session's origins should be preferred.
106
+ *
107
+ * Microsoft keeps adding hosts (`teams.cloud.microsoft`, then
108
+ * `teams.chat.microsoft.com`) and redirects users to them without warning, so
109
+ * every host check reads this one list: a missing host makes login detection
110
+ * fail on a page that is in fact signed in.
111
+ */
112
+ export declare const TEAMS_HOSTS: readonly string[];
package/dist/constants.js CHANGED
@@ -192,3 +192,18 @@ export const STANDARD_EMOJIS = [
192
192
  export const ASSIGNMENTS_APP_ID = '8f348934-64be-4bb2-bc16-c54c96789f43';
193
193
  /** Microsoft Graph token audiences: MSAL caches either the URL or the app ID. */
194
194
  export const GRAPH_AUDIENCES = ['https://graph.microsoft.com', '00000003-0000-0000-c000-000000000000'];
195
+ /**
196
+ * Teams web hosts, in the order a session's origins should be preferred.
197
+ *
198
+ * Microsoft keeps adding hosts (`teams.cloud.microsoft`, then
199
+ * `teams.chat.microsoft.com`) and redirects users to them without warning, so
200
+ * every host check reads this one list: a missing host makes login detection
201
+ * fail on a page that is in fact signed in.
202
+ */
203
+ export const TEAMS_HOSTS = [
204
+ 'teams.cloud.microsoft', // New Teams (often holds the SubstrateSearch token)
205
+ 'teams.chat.microsoft.com', // New Teams chat host
206
+ 'teams.microsoft.com', // Commercial legacy
207
+ 'teams.microsoft.us', // GCC-High
208
+ 'dod.teams.microsoft.us', // DoD
209
+ ];
package/dist/index.js CHANGED
File without changes
@@ -6,7 +6,8 @@ import { z } from 'zod';
6
6
  const require = createRequire(import.meta.url);
7
7
  const pkg = require('../../package.json');
8
8
  import { hasSessionState, isSessionLikelyExpired, clearSessionState, clearTokenCache, } from '../auth/session-store.js';
9
- import { getSubstrateTokenStatus, getMessageAuthStatus, extractMessageAuth, extractCsaToken, } from '../auth/token-extractor.js';
9
+ import { getSubstrateTokenStatus, getMessageAuthStatus, extractMessageAuth, extractCsaToken, extractSubstrateToken, extractSkypeSpacesToken, } from '../auth/token-extractor.js';
10
+ import { refreshTokensViaHttp } from '../auth/token-refresh-http.js';
10
11
  import { createBrowserContext, closeBrowser } from '../browser/context.js';
11
12
  import * as log from '../utils/logger.js';
12
13
  import { resetAssignmentsAvailability } from '../utils/auth-guards.js';
@@ -46,6 +47,31 @@ const statusToolDefinition = {
46
47
  // ─────────────────────────────────────────────────────────────────────────────
47
48
  /** Minimum minutes remaining on token to consider it valid (skip browser). */
48
49
  const TOKEN_VALID_THRESHOLD_MINUTES = 10;
50
+ /** Every core credential a session needs: search, calendar, favourites, messaging. */
51
+ function coreCredentialsComplete() {
52
+ return extractSubstrateToken() !== null
53
+ && extractSkypeSpacesToken() !== null
54
+ && extractCsaToken() !== null
55
+ && extractMessageAuth() !== null;
56
+ }
57
+ /**
58
+ * Mints any core credential the browser did not leave in the saved session.
59
+ *
60
+ * Teams JS acquires some resource tokens lazily — the CSA (chatsvcagg) token
61
+ * behind favourites is routinely absent from a session saved straight after
62
+ * SSO, so login would report success while teams_status reported favourites
63
+ * unavailable until some later call happened to trigger a refresh. One HTTP
64
+ * exchange mints every core resource. Failure is non-fatal: the session is
65
+ * still usable and the next tool call retries through auto-login.
66
+ */
67
+ async function fillCoreCredentialGaps() {
68
+ if (coreCredentialsComplete())
69
+ return;
70
+ const result = await refreshTokensViaHttp();
71
+ if (!result.ok) {
72
+ log.warn('login', `Could not top up core credentials: ${result.error.message}`);
73
+ }
74
+ }
49
75
  async function handleLogin(input) {
50
76
  // A fresh login may have changed what the account can access (e.g. new consent)
51
77
  resetAssignmentsAvailability();
@@ -57,6 +83,7 @@ async function handleLogin(input) {
57
83
  // Fast path: if tokens are still valid, skip browser entirely
58
84
  const tokenStatus = getSubstrateTokenStatus();
59
85
  if (tokenStatus.hasToken && tokenStatus.minutesRemaining >= TOKEN_VALID_THRESHOLD_MINUTES) {
86
+ await fillCoreCredentialGaps();
60
87
  return {
61
88
  success: true,
62
89
  data: {
@@ -83,6 +110,7 @@ async function handleLogin(input) {
83
110
  true // Headless mode - throw immediately if user interaction required
84
111
  );
85
112
  await closeBrowser(headless, true);
113
+ await fillCoreCredentialGaps();
86
114
  return {
87
115
  success: true,
88
116
  data: {
@@ -103,6 +131,7 @@ async function handleLogin(input) {
103
131
  // Close browser after login - we only need the saved session/tokens
104
132
  await closeBrowser(visible, true);
105
133
  }
134
+ await fillCoreCredentialGaps();
106
135
  return {
107
136
  success: true,
108
137
  data: {
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,67 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+ vi.mock('../auth/session-store.js', () => ({
3
+ hasSessionState: vi.fn(() => true),
4
+ isSessionLikelyExpired: vi.fn(() => false),
5
+ clearSessionState: vi.fn(),
6
+ clearTokenCache: vi.fn(),
7
+ }));
8
+ vi.mock('../auth/token-extractor.js', () => ({
9
+ getSubstrateTokenStatus: vi.fn(),
10
+ getMessageAuthStatus: vi.fn(),
11
+ extractMessageAuth: vi.fn(),
12
+ extractCsaToken: vi.fn(),
13
+ extractSubstrateToken: vi.fn(),
14
+ extractSkypeSpacesToken: vi.fn(),
15
+ }));
16
+ vi.mock('../auth/token-refresh-http.js', () => ({ refreshTokensViaHttp: vi.fn() }));
17
+ vi.mock('../browser/context.js', () => ({ createBrowserContext: vi.fn(), closeBrowser: vi.fn() }));
18
+ vi.mock('../browser/auth.js', () => ({ ensureAuthenticated: vi.fn() }));
19
+ vi.mock('../utils/auth-guards.js', () => ({ resetAssignmentsAvailability: vi.fn() }));
20
+ import { loginTool } from './auth-tools.js';
21
+ import { getSubstrateTokenStatus, extractMessageAuth, extractCsaToken, extractSubstrateToken, extractSkypeSpacesToken, } from '../auth/token-extractor.js';
22
+ import { refreshTokensViaHttp } from '../auth/token-refresh-http.js';
23
+ import { createBrowserContext, closeBrowser } from '../browser/context.js';
24
+ /** A session holding every core credential except the CSA (favourites) token. */
25
+ function sessionMissingCsaToken() {
26
+ vi.mocked(getSubstrateTokenStatus).mockReturnValue({ hasToken: true, minutesRemaining: 60 });
27
+ vi.mocked(extractSubstrateToken).mockReturnValue({ token: 'substrate', expiry: new Date(Date.now() + 3.6e6) });
28
+ vi.mocked(extractSkypeSpacesToken).mockReturnValue('spaces');
29
+ vi.mocked(extractMessageAuth).mockReturnValue({ skypeToken: 's', authToken: 'a', userMri: '8:orgid:u' });
30
+ vi.mocked(extractCsaToken).mockReturnValue(null);
31
+ }
32
+ describe('teams_login core credential top-up', () => {
33
+ beforeEach(() => {
34
+ vi.clearAllMocks();
35
+ vi.mocked(refreshTokensViaHttp).mockResolvedValue({ ok: true, value: undefined });
36
+ });
37
+ it('mints the missing CSA token when the session is otherwise valid', async () => {
38
+ sessionMissingCsaToken();
39
+ const result = await loginTool.handler({ forceNew: false });
40
+ expect(result.success).toBe(true);
41
+ expect(refreshTokensViaHttp).toHaveBeenCalledOnce();
42
+ expect(createBrowserContext).not.toHaveBeenCalled();
43
+ });
44
+ it('skips the refresh when every core credential is present', async () => {
45
+ sessionMissingCsaToken();
46
+ vi.mocked(extractCsaToken).mockReturnValue('csa');
47
+ await loginTool.handler({ forceNew: false });
48
+ expect(refreshTokensViaHttp).not.toHaveBeenCalled();
49
+ });
50
+ it('tops up after a forced browser login leaves the CSA token behind', async () => {
51
+ sessionMissingCsaToken();
52
+ vi.mocked(createBrowserContext).mockResolvedValue({ page: {}, context: { clearCookies: vi.fn() } });
53
+ vi.mocked(closeBrowser).mockResolvedValue(undefined);
54
+ const result = await loginTool.handler({ forceNew: true });
55
+ expect(result.success).toBe(true);
56
+ expect(refreshTokensViaHttp).toHaveBeenCalledOnce();
57
+ });
58
+ it('still reports success when the top-up fails', async () => {
59
+ sessionMissingCsaToken();
60
+ vi.mocked(refreshTokensViaHttp).mockResolvedValue({
61
+ ok: false,
62
+ error: { code: 'NETWORK_ERROR', message: 'offline' },
63
+ });
64
+ const result = await loginTool.handler({ forceNew: false });
65
+ expect(result.success).toBe(true);
66
+ });
67
+ });
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Server interface types.
3
+ *
4
+ * Extracted to a shared module to avoid circular dependencies between
5
+ * server.ts and tools/index.ts.
6
+ */
7
+ import type { BrowserManager } from '../browser/context.js';
8
+ /**
9
+ * Interface for the TeamsServer class.
10
+ *
11
+ * This interface is used by tool handlers to interact with the server
12
+ * without creating circular dependencies.
13
+ */
14
+ export interface TeamsServer {
15
+ /** Ensures a browser is running and authenticated. */
16
+ ensureBrowser(headless?: boolean): Promise<BrowserManager>;
17
+ /** Resets browser state (clears manager and initialisation flag). */
18
+ resetBrowserState(): void;
19
+ /** Gets the current browser manager, if any. */
20
+ getBrowserManager(): BrowserManager | null;
21
+ /** Sets the browser manager. */
22
+ setBrowserManager(manager: BrowserManager): void;
23
+ /** Marks the server as initialised. */
24
+ markInitialised(): void;
25
+ /** Checks if the server is initialised. */
26
+ isInitialisedState(): boolean;
27
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Server interface types.
3
+ *
4
+ * Extracted to a shared module to avoid circular dependencies between
5
+ * server.ts and tools/index.ts.
6
+ */
7
+ export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "msteams-mcp",
3
- "version": "0.29.1",
3
+ "version": "0.29.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",
@@ -21,7 +21,7 @@
21
21
  "url": "https://github.com/m0nkmaster/msteams-mcp/issues"
22
22
  },
23
23
  "engines": {
24
- "node": ">=18"
24
+ "node": ">=20"
25
25
  },
26
26
  "scripts": {
27
27
  "build": "tsc",
@@ -45,6 +45,7 @@
45
45
  "license": "MIT",
46
46
  "dependencies": {
47
47
  "@modelcontextprotocol/sdk": "^1.0.0",
48
+ "marked": "^18.0.14",
48
49
  "playwright": "^1.40.0",
49
50
  "zod": "~3.23.0"
50
51
  },