@littlebearapps/outlook-assistant 3.5.0 → 3.5.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/.env.example CHANGED
@@ -23,3 +23,8 @@ USE_TEST_MODE=false
23
23
 
24
24
  # Optional: Enable immutable IDs (IDs persist through folder moves)
25
25
  # OUTLOOK_IMMUTABLE_IDS=true
26
+
27
+ # Optional: Default authentication method (device-code or browser)
28
+ # device-code: No auth server needed, works remotely/headless
29
+ # browser: Traditional OAuth redirect via localhost:3333
30
+ # OUTLOOK_AUTH_METHOD=device-code
package/README.md CHANGED
@@ -149,9 +149,11 @@ npx @littlebearapps/outlook-assistant
149
149
  You need a Microsoft Azure app registration to authenticate. See the **[Azure Setup Guide](docs/guides/azure-setup.md)** for a detailed walkthrough (including first-time Azure account creation), or if you've done this before:
150
150
 
151
151
  1. Create a new app registration at [portal.azure.com](https://portal.azure.com/)
152
- 2. Set redirect URI to `http://localhost:3333/auth/callback`
153
- 3. Add Microsoft Graph delegated permissions (Mail, Calendar, Contacts)
154
- 4. Create a client secret and copy the **Value** (not the Secret ID)
152
+ 2. Add Microsoft Graph delegated permissions (Mail, Calendar, Contacts)
153
+ 3. Create a client secret and copy the **Value** (not the Secret ID)
154
+ 4. Under Authentication > **Add a platform** > **Mobile and desktop applications** — check `nativeclient` URI
155
+ 5. Enable **"Allow public client flows"** in Authentication > Advanced settings
156
+ 6. _(Optional)_ Set redirect URI to `http://localhost:3333/auth/callback` — only needed for browser auth flow
155
157
 
156
158
  ### 3. Configure Your MCP Client
157
159
 
@@ -340,7 +342,21 @@ If installed from source, use `node` instead of `npx`:
340
342
 
341
343
  ## Authentication Flow
342
344
 
343
- ### Step 1: Start the Auth Server
345
+ ### Device Code Flow (Default — Recommended)
346
+
347
+ No auth server needed. Works everywhere, including remote/headless environments.
348
+
349
+ 1. Ask your AI assistant to authenticate (calls `auth` tool with `action=authenticate`)
350
+ 2. Visit the URL shown (`microsoft.com/devicelogin`) on **any** browser, **any** device
351
+ 3. Enter the code, sign in with your Microsoft account, and grant permissions
352
+ 4. Tell your AI assistant to complete authentication (calls `auth` with `action=device-code-complete`)
353
+ 5. Tokens are saved to `~/.outlook-assistant-tokens.json` and **refresh automatically**
354
+
355
+ > **Prerequisite**: Enable "Allow public client flows" in Azure Portal > your app > Authentication > Advanced settings.
356
+
357
+ ### Browser Redirect Flow (Alternative)
358
+
359
+ For localhost development or if you prefer the traditional OAuth flow:
344
360
 
345
361
  ```bash
346
362
  npm run auth-server
@@ -348,14 +364,11 @@ npm run auth-server
348
364
 
349
365
  This starts a local server on port 3333 to handle the OAuth callback.
350
366
 
351
- > **Note**: The auth server reads `OUTLOOK_CLIENT_ID` and `OUTLOOK_CLIENT_SECRET` from environment variables (or `MS_CLIENT_ID`/`MS_CLIENT_SECRET`). When running the auth server separately, ensure your `.env` file is in the project root or export the variables in your shell. Your MCP client's `"env"` config only applies to the MCP server process, not a separately-started auth server.
352
-
353
- ### Step 2: Authenticate
354
-
355
- 1. In your AI assistant, use the `auth` tool with `action=authenticate`
367
+ 1. In your AI assistant, use the `auth` tool with `action=authenticate, method=browser`
356
368
  2. Open the provided URL in your browser
357
- 3. Sign in with your Microsoft account and grant permissions
358
- 4. Tokens are saved to `~/.outlook-assistant-tokens.json` and refresh automatically
369
+ 3. Sign in and grant permissions — tokens are saved automatically
370
+
371
+ > **Note**: The auth server reads `OUTLOOK_CLIENT_ID` and `OUTLOOK_CLIENT_SECRET` from environment variables. Your MCP client's `"env"` config only applies to the MCP server process, not a separately-started auth server.
359
372
 
360
373
  ## Directory Structure
361
374
 
@@ -409,7 +422,11 @@ You're using the Secret **ID** instead of the Secret **Value**. Go to Azure Port
409
422
 
410
423
  ### Authentication URL doesn't work
411
424
 
412
- Start the auth server first: `npm run auth-server`
425
+ If using browser flow: start the auth server first with `npm run auth-server`. If using device code flow: visit `microsoft.com/devicelogin` instead.
426
+
427
+ ### Device code "invalid_client"
428
+
429
+ Enable "Allow public client flows" in Azure Portal > App registrations > Authentication > Advanced settings.
413
430
 
414
431
  ### Empty API responses
415
432
 
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Device Code Flow for Microsoft OAuth2
3
+ *
4
+ * Enables authentication without browser redirect — ideal for
5
+ * headless/remote environments (SSH, VPS, containers).
6
+ *
7
+ * The user gets a short code, visits https://microsoft.com/devicelogin
8
+ * on any device, and enters it. No auth server or port forwarding needed.
9
+ */
10
+ const https = require('https');
11
+ const querystring = require('querystring');
12
+ const config = require('../config');
13
+
14
+ /**
15
+ * POST helper for OAuth2 endpoints
16
+ * @param {string} url - Full URL to POST to
17
+ * @param {string} postData - URL-encoded form data
18
+ * @returns {Promise<{statusCode: number, body: object}>}
19
+ */
20
+ function postRequest(url, postData) {
21
+ return new Promise((resolve, reject) => {
22
+ const req = https.request(
23
+ url,
24
+ {
25
+ method: 'POST',
26
+ headers: {
27
+ 'Content-Type': 'application/x-www-form-urlencoded',
28
+ 'Content-Length': Buffer.byteLength(postData),
29
+ },
30
+ },
31
+ (res) => {
32
+ let data = '';
33
+ res.on('data', (chunk) => (data += chunk));
34
+ res.on('end', () => {
35
+ try {
36
+ resolve({ statusCode: res.statusCode, body: JSON.parse(data) });
37
+ } catch (_e) {
38
+ reject(new Error(`Failed to parse response: ${data}`));
39
+ }
40
+ });
41
+ }
42
+ );
43
+ req.on('error', reject);
44
+ req.write(postData);
45
+ req.end();
46
+ });
47
+ }
48
+
49
+ /**
50
+ * Initiates the device code flow by requesting a device code from Azure.
51
+ * @param {string} clientId - Azure app client ID
52
+ * @param {string[]} scopes - OAuth2 scopes to request
53
+ * @returns {Promise<{userCode: string, verificationUri: string, deviceCode: string, expiresIn: number, interval: number, message: string}>}
54
+ */
55
+ async function initiateDeviceCodeFlow(clientId, scopes) {
56
+ const postData = querystring.stringify({
57
+ client_id: clientId,
58
+ scope: scopes.join(' '),
59
+ });
60
+
61
+ const endpoint = config.AUTH_CONFIG.deviceCodeEndpoint;
62
+ const { statusCode, body } = await postRequest(endpoint, postData);
63
+
64
+ if (statusCode < 200 || statusCode >= 300) {
65
+ throw new Error(
66
+ body.error_description ||
67
+ `Device code request failed with status ${statusCode}`
68
+ );
69
+ }
70
+
71
+ return {
72
+ userCode: body.user_code,
73
+ verificationUri: body.verification_uri,
74
+ deviceCode: body.device_code,
75
+ expiresIn: body.expires_in,
76
+ interval: body.interval || 5,
77
+ message: body.message,
78
+ };
79
+ }
80
+
81
+ /**
82
+ * Polls the token endpoint until the user completes authentication.
83
+ * @param {string} clientId - Azure app client ID
84
+ * @param {string} deviceCode - Device code from initiateDeviceCodeFlow
85
+ * @param {number} interval - Polling interval in seconds
86
+ * @param {number} expiresIn - Seconds until the device code expires
87
+ * @returns {Promise<{access_token: string, refresh_token: string, expires_in: number, scope: string, token_type: string}>}
88
+ */
89
+ async function pollForToken(clientId, deviceCode, interval, expiresIn) {
90
+ const endpoint = config.AUTH_CONFIG.tokenEndpoint;
91
+ const deadline = Date.now() + expiresIn * 1000;
92
+ let pollInterval = interval;
93
+
94
+ while (Date.now() < deadline) {
95
+ await new Promise((resolve) => setTimeout(resolve, pollInterval * 1000));
96
+
97
+ const postData = querystring.stringify({
98
+ client_id: clientId,
99
+ grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
100
+ device_code: deviceCode,
101
+ });
102
+
103
+ const { statusCode, body } = await postRequest(endpoint, postData);
104
+
105
+ if (statusCode >= 200 && statusCode < 300) {
106
+ return body;
107
+ }
108
+
109
+ switch (body.error) {
110
+ case 'authorization_pending':
111
+ // User hasn't completed auth yet — keep polling
112
+ break;
113
+ case 'slow_down':
114
+ // Server asked us to slow down — increase interval by 5s
115
+ pollInterval += 5;
116
+ break;
117
+ case 'authorization_declined':
118
+ throw new Error('Authentication was declined by the user.');
119
+ case 'expired_token':
120
+ throw new Error(
121
+ 'Device code expired. Please restart the authentication process.'
122
+ );
123
+ default:
124
+ throw new Error(
125
+ body.error_description ||
126
+ `Token polling failed: ${body.error || `status ${statusCode}`}`
127
+ );
128
+ }
129
+ }
130
+
131
+ throw new Error(
132
+ 'Device code expired. Please restart the authentication process.'
133
+ );
134
+ }
135
+
136
+ module.exports = {
137
+ initiateDeviceCodeFlow,
138
+ pollForToken,
139
+ };
package/auth/index.js CHANGED
@@ -2,22 +2,32 @@
2
2
  * Authentication module for Outlook Assistant server
3
3
  */
4
4
  const tokenManager = require('./token-manager');
5
- const { authTools } = require('./tools');
5
+ const TokenStorage = require('./token-storage');
6
+ const config = require('../config');
7
+ const { authTools, setToolCount } = require('./tools');
8
+
9
+ // Singleton TokenStorage instance with auto-refresh support
10
+ const tokenStorage = new TokenStorage({
11
+ clientId: config.AUTH_CONFIG.clientId,
12
+ clientSecret: config.AUTH_CONFIG.clientSecret,
13
+ tokenStorePath: config.AUTH_CONFIG.tokenStorePath,
14
+ scopes: config.AUTH_CONFIG.scopes,
15
+ tokenEndpoint: config.AUTH_CONFIG.tokenEndpoint,
16
+ });
6
17
 
7
18
  /**
8
- * Ensures the user is authenticated and returns an access token
19
+ * Ensures the user is authenticated and returns an access token.
20
+ * Automatically refreshes expired tokens via tokenStorage.
9
21
  * @param {boolean} forceNew - Whether to force a new authentication
10
22
  * @returns {Promise<string>} - Access token
11
23
  * @throws {Error} - If authentication fails
12
24
  */
13
25
  async function ensureAuthenticated(forceNew = false) {
14
26
  if (forceNew) {
15
- // Force re-authentication
16
27
  throw new Error('Authentication required');
17
28
  }
18
29
 
19
- // Check for existing token
20
- const accessToken = tokenManager.getAccessToken();
30
+ const accessToken = await tokenStorage.getValidAccessToken();
21
31
  if (!accessToken) {
22
32
  throw new Error('Authentication required');
23
33
  }
@@ -26,7 +36,9 @@ async function ensureAuthenticated(forceNew = false) {
26
36
  }
27
37
 
28
38
  module.exports = {
29
- tokenManager,
39
+ tokenManager, // deprecated: use tokenStorage
40
+ tokenStorage,
30
41
  authTools,
42
+ setToolCount,
31
43
  ensureAuthenticated,
32
44
  };
package/auth/tools.js CHANGED
@@ -3,6 +3,13 @@
3
3
  */
4
4
  const config = require('../config');
5
5
  const tokenManager = require('./token-manager');
6
+ const { initiateDeviceCodeFlow, pollForToken } = require('./device-code');
7
+
8
+ // Dynamic tool count — set by index.js after TOOLS array is built
9
+ let _toolCount = 0;
10
+ function setToolCount(count) {
11
+ _toolCount = count;
12
+ }
6
13
 
7
14
  /**
8
15
  * About tool handler
@@ -24,7 +31,7 @@ async function handleAbout() {
24
31
  `## Diagnostics\n`,
25
32
  `| Setting | Value |`,
26
33
  `|---------|-------|`,
27
- `| Tools | 20 across 9 modules |`,
34
+ `| Tools | ${_toolCount} across 9 modules |`,
28
35
  `| Modules | auth, email, calendar, folder, rules, contacts, categories, settings, advanced |`,
29
36
  `| Timezone | ${config.DEFAULT_TIMEZONE} |`,
30
37
  `| Test Mode | ${testMode} |`,
@@ -46,18 +53,14 @@ async function handleAbout() {
46
53
  }
47
54
 
48
55
  /**
49
- * Authentication tool handler
56
+ * Authentication tool handler — supports browser redirect and device code flow.
50
57
  * @param {object} args - Tool arguments
51
58
  * @returns {object} - MCP response
52
59
  */
53
60
  async function handleAuthenticate(args) {
54
- const _force = args && args.force === true;
55
-
56
61
  // For test mode, create a test token
57
62
  if (config.USE_TEST_MODE) {
58
- // Create a test token with a 1-hour expiry
59
63
  tokenManager.createTestTokens();
60
-
61
64
  return {
62
65
  content: [
63
66
  {
@@ -68,43 +71,205 @@ async function handleAuthenticate(args) {
68
71
  };
69
72
  }
70
73
 
71
- // For real authentication, generate an auth URL and instruct the user to visit it
74
+ const method = args?.method || config.AUTH_CONFIG.defaultAuthMethod;
75
+
76
+ if (method === 'device-code') {
77
+ return handleDeviceCodeAuth();
78
+ }
79
+
80
+ // Browser redirect flow (existing behaviour)
72
81
  const authUrl = `${config.AUTH_CONFIG.authServerUrl}/auth?client_id=${config.AUTH_CONFIG.clientId}`;
82
+ return {
83
+ content: [
84
+ {
85
+ type: 'text',
86
+ text: `Authentication required. Please visit the following URL to authenticate with Microsoft: ${authUrl}\n\nAfter authentication, you will be redirected back to this application.\n\nNote: The auth server must be running on port 3333. If working remotely, consider using method=device-code instead.`,
87
+ },
88
+ ],
89
+ };
90
+ }
91
+
92
+ // Module-level state for pending device code flow
93
+ let pendingDeviceCode = null;
94
+
95
+ /**
96
+ * Device code flow step 1 — request a code for the user to enter.
97
+ * Returns the code + URL immediately. Call device-code-complete to finish.
98
+ * @returns {object} - MCP response
99
+ */
100
+ async function handleDeviceCodeAuth() {
101
+ const clientId = config.AUTH_CONFIG.clientId;
102
+ if (!clientId) {
103
+ return {
104
+ content: [
105
+ {
106
+ type: 'text',
107
+ text: 'Error: OUTLOOK_CLIENT_ID is not configured.',
108
+ },
109
+ ],
110
+ };
111
+ }
112
+
113
+ console.error('[AUTH] Starting device code flow...');
114
+ const response = await initiateDeviceCodeFlow(
115
+ clientId,
116
+ config.AUTH_CONFIG.scopes
117
+ );
118
+
119
+ // Store for the completion step
120
+ pendingDeviceCode = {
121
+ deviceCode: response.deviceCode,
122
+ interval: response.interval,
123
+ expiresIn: response.expiresIn,
124
+ expiresAt: Date.now() + response.expiresIn * 1000,
125
+ };
126
+
127
+ console.error(
128
+ `[AUTH] Device code: ${response.userCode}, expires in ${response.expiresIn}s`
129
+ );
73
130
 
74
131
  return {
75
132
  content: [
76
133
  {
77
134
  type: 'text',
78
- text: `Authentication required. Please visit the following URL to authenticate with Microsoft: ${authUrl}\n\nAfter authentication, you will be redirected back to this application.`,
135
+ text: [
136
+ `## Device Code Authentication\n`,
137
+ `Visit: **${response.verificationUri}**`,
138
+ `Enter code: **${response.userCode}**\n`,
139
+ `The code expires in ${Math.floor(response.expiresIn / 60)} minutes.\n`,
140
+ `After entering the code and signing in, call this tool again with \`action=device-code-complete\` to finish authentication.`,
141
+ ].join('\n'),
79
142
  },
80
143
  ],
81
144
  };
82
145
  }
83
146
 
84
147
  /**
85
- * Check authentication status tool handler
148
+ * Device code flow step 2 — poll until the user completes authentication.
149
+ * @returns {object} - MCP response
150
+ */
151
+ async function handleDeviceCodeComplete() {
152
+ if (!pendingDeviceCode) {
153
+ return {
154
+ content: [
155
+ {
156
+ type: 'text',
157
+ text: 'No pending device code flow. Call authenticate with method=device-code first.',
158
+ },
159
+ ],
160
+ };
161
+ }
162
+
163
+ if (Date.now() > pendingDeviceCode.expiresAt) {
164
+ pendingDeviceCode = null;
165
+ return {
166
+ content: [
167
+ {
168
+ type: 'text',
169
+ text: 'Device code has expired. Please start a new authentication with action=authenticate.',
170
+ },
171
+ ],
172
+ };
173
+ }
174
+
175
+ const clientId = config.AUTH_CONFIG.clientId;
176
+
177
+ try {
178
+ console.error('[AUTH] Polling for device code completion...');
179
+ const tokenResponse = await pollForToken(
180
+ clientId,
181
+ pendingDeviceCode.deviceCode,
182
+ pendingDeviceCode.interval,
183
+ Math.ceil((pendingDeviceCode.expiresAt - Date.now()) / 1000)
184
+ );
185
+
186
+ pendingDeviceCode = null;
187
+
188
+ // Save tokens using TokenStorage
189
+ const TokenStorage = require('./token-storage');
190
+ const tokenStorage = new TokenStorage({
191
+ clientId: config.AUTH_CONFIG.clientId,
192
+ clientSecret: config.AUTH_CONFIG.clientSecret,
193
+ tokenStorePath: config.AUTH_CONFIG.tokenStorePath,
194
+ scopes: config.AUTH_CONFIG.scopes,
195
+ tokenEndpoint: config.AUTH_CONFIG.tokenEndpoint,
196
+ });
197
+
198
+ tokenStorage.tokens = {
199
+ access_token: tokenResponse.access_token,
200
+ refresh_token: tokenResponse.refresh_token,
201
+ expires_in: tokenResponse.expires_in,
202
+ expires_at: Date.now() + tokenResponse.expires_in * 1000,
203
+ scope: tokenResponse.scope,
204
+ token_type: tokenResponse.token_type,
205
+ };
206
+ await tokenStorage._saveTokensToFile();
207
+
208
+ console.error('[AUTH] Device code flow completed successfully.');
209
+
210
+ return {
211
+ content: [
212
+ {
213
+ type: 'text',
214
+ text: 'Authentication successful! Tokens saved. You can now use Outlook tools.',
215
+ },
216
+ ],
217
+ };
218
+ } catch (error) {
219
+ pendingDeviceCode = null;
220
+ return {
221
+ content: [
222
+ {
223
+ type: 'text',
224
+ text: `Authentication failed: ${error.message}`,
225
+ },
226
+ ],
227
+ };
228
+ }
229
+ }
230
+
231
+ /**
232
+ * Check authentication status — attempts token refresh if expired.
86
233
  * @returns {object} - MCP response
87
234
  */
88
235
  async function handleCheckAuthStatus() {
89
236
  console.error('[CHECK-AUTH-STATUS] Starting authentication status check');
90
237
 
91
- const tokens = tokenManager.loadTokenCache();
238
+ // Use TokenStorage for accurate status (includes refresh attempt)
239
+ const TokenStorage = require('./token-storage');
240
+ const tokenStorage = new TokenStorage({
241
+ clientId: config.AUTH_CONFIG.clientId,
242
+ clientSecret: config.AUTH_CONFIG.clientSecret,
243
+ tokenStorePath: config.AUTH_CONFIG.tokenStorePath,
244
+ scopes: config.AUTH_CONFIG.scopes,
245
+ tokenEndpoint: config.AUTH_CONFIG.tokenEndpoint,
246
+ });
92
247
 
93
- console.error(`[CHECK-AUTH-STATUS] Tokens loaded: ${tokens ? 'YES' : 'NO'}`);
248
+ const accessToken = await tokenStorage.getValidAccessToken();
94
249
 
95
- if (!tokens || !tokens.access_token) {
96
- console.error('[CHECK-AUTH-STATUS] No valid access token found');
250
+ if (!accessToken) {
251
+ console.error('[CHECK-AUTH-STATUS] No valid access token');
97
252
  return {
98
253
  content: [{ type: 'text', text: 'Not authenticated' }],
99
254
  };
100
255
  }
101
256
 
102
- console.error('[CHECK-AUTH-STATUS] Access token present');
103
- console.error(`[CHECK-AUTH-STATUS] Token expires at: ${tokens.expires_at}`);
104
- console.error(`[CHECK-AUTH-STATUS] Current time: ${Date.now()}`);
257
+ const expiresAt = tokenStorage.getExpiryTime();
258
+ const expiresIn = expiresAt
259
+ ? Math.round((expiresAt - Date.now()) / 60000)
260
+ : 'unknown';
261
+
262
+ console.error(
263
+ `[CHECK-AUTH-STATUS] Authenticated, token expires in ~${expiresIn} min`
264
+ );
105
265
 
106
266
  return {
107
- content: [{ type: 'text', text: 'Authenticated and ready' }],
267
+ content: [
268
+ {
269
+ type: 'text',
270
+ text: `Authenticated and ready (token expires in ~${expiresIn} minutes)`,
271
+ },
272
+ ],
108
273
  };
109
274
  }
110
275
 
@@ -113,7 +278,7 @@ const authTools = [
113
278
  {
114
279
  name: 'auth',
115
280
  description:
116
- 'Manage authentication with Microsoft Graph API. action=status (default) checks auth state, action=authenticate starts OAuth flow, action=about shows server info.',
281
+ 'Manage authentication with Microsoft Graph API. action=status (default) checks auth state and refreshes tokens if needed, action=authenticate starts OAuth flow (device-code by default — no auth server needed), action=device-code-complete finishes device code auth after user enters code, action=about shows server info.',
117
282
  annotations: {
118
283
  title: 'Authentication',
119
284
  readOnlyHint: false,
@@ -125,9 +290,15 @@ const authTools = [
125
290
  properties: {
126
291
  action: {
127
292
  type: 'string',
128
- enum: ['status', 'authenticate', 'about'],
293
+ enum: ['status', 'authenticate', 'device-code-complete', 'about'],
129
294
  description: 'Action to perform (default: status)',
130
295
  },
296
+ method: {
297
+ type: 'string',
298
+ enum: ['device-code', 'browser'],
299
+ description:
300
+ 'Auth method for action=authenticate. device-code (default): no auth server needed, works remotely. browser: traditional OAuth redirect via port 3333.',
301
+ },
131
302
  force: {
132
303
  type: 'boolean',
133
304
  description:
@@ -141,6 +312,8 @@ const authTools = [
141
312
  switch (action) {
142
313
  case 'authenticate':
143
314
  return handleAuthenticate(args);
315
+ case 'device-code-complete':
316
+ return handleDeviceCodeComplete();
144
317
  case 'about':
145
318
  return handleAbout();
146
319
  case 'status':
@@ -153,7 +326,10 @@ const authTools = [
153
326
 
154
327
  module.exports = {
155
328
  authTools,
329
+ setToolCount,
156
330
  handleAbout,
157
331
  handleAuthenticate,
332
+ handleDeviceCodeAuth,
333
+ handleDeviceCodeComplete,
158
334
  handleCheckAuthStatus,
159
335
  };
@@ -305,17 +305,26 @@ async function handleUpdateCategory(args) {
305
305
  updateData
306
306
  );
307
307
 
308
- const colorName = COLOR_NAMES[response.color] || response.color;
308
+ // Prefer input values over response (PATCH may return partial data)
309
+ const updatedName = displayName || response.displayName;
310
+ const updatedColor = color || response.color;
311
+ const colorName = COLOR_NAMES[updatedColor] || updatedColor;
312
+
313
+ const updatedCategory = {
314
+ ...response,
315
+ displayName: updatedName,
316
+ color: updatedColor,
317
+ };
309
318
 
310
319
  return {
311
320
  content: [
312
321
  {
313
322
  type: 'text',
314
- text: `Category updated!\n\n**Name**: ${response.displayName}\n**Color**: ${colorName} (${response.color})\n**ID**: ${response.id}`,
323
+ text: `Category updated!\n\n**Name**: ${updatedName}\n**Color**: ${colorName} (${updatedColor})\n**ID**: ${response.id || id}`,
315
324
  },
316
325
  ],
317
326
  _meta: {
318
- category: formatCategory(response),
327
+ category: formatCategory(updatedCategory),
319
328
  },
320
329
  };
321
330
  } catch (error) {
@@ -428,7 +437,9 @@ async function handleApplyCategory(args) {
428
437
  };
429
438
  }
430
439
 
431
- if (!categories || !Array.isArray(categories) || categories.length === 0) {
440
+ const applyAction = action || 'set'; // 'set', 'add', 'remove'
441
+
442
+ if (!categories || !Array.isArray(categories)) {
432
443
  return {
433
444
  content: [
434
445
  {
@@ -439,7 +450,17 @@ async function handleApplyCategory(args) {
439
450
  };
440
451
  }
441
452
 
442
- const applyAction = action || 'set'; // 'set', 'add', 'remove'
453
+ // Empty array is only valid for action=set (clears all categories)
454
+ if (categories.length === 0 && applyAction !== 'set') {
455
+ return {
456
+ content: [
457
+ {
458
+ type: 'text',
459
+ text: 'Categories array cannot be empty for add/remove. Use action=set with an empty array to clear all categories.',
460
+ },
461
+ ],
462
+ };
463
+ }
443
464
 
444
465
  try {
445
466
  const accessToken = await ensureAuthenticated();
package/config.js CHANGED
@@ -54,6 +54,10 @@ module.exports = {
54
54
  ],
55
55
  tokenStorePath: path.join(homeDir, '.outlook-assistant-tokens.json'),
56
56
  authServerUrl: 'http://localhost:3333',
57
+ deviceCodeEndpoint:
58
+ 'https://login.microsoftonline.com/common/oauth2/v2.0/devicecode',
59
+ tokenEndpoint: 'https://login.microsoftonline.com/common/oauth2/v2.0/token',
60
+ defaultAuthMethod: process.env.OUTLOOK_AUTH_METHOD || 'device-code',
57
61
  },
58
62
 
59
63
  // Microsoft Graph API
@@ -17,6 +17,8 @@ const {
17
17
  formatEmailsAsCSV,
18
18
  VERBOSITY,
19
19
  } = require('../utils/response-formatter');
20
+ // Note: buildFromFilter/buildToFilter from search.js use OData $filter which causes
21
+ // InefficientFilter on personal accounts with $orderby. Client-side filtering used instead.
20
22
 
21
23
  /**
22
24
  * Format a date for filenames
@@ -65,10 +67,12 @@ async function handleListConversations(args) {
65
67
  'id',
66
68
  'subject',
67
69
  'from',
70
+ 'toRecipients',
68
71
  'receivedDateTime',
69
72
  'conversationId',
70
73
  'conversationIndex',
71
74
  'isRead',
75
+ 'hasAttachments',
72
76
  'bodyPreview',
73
77
  ].join(',');
74
78
 
@@ -79,6 +83,34 @@ async function handleListConversations(args) {
79
83
  $top: 200, // Get more to group
80
84
  };
81
85
 
86
+ // Apply simple $filter conditions that Graph API supports on personal accounts
87
+ // Complex filters (contains on subject, endswith on email) cause InefficientFilter
88
+ // errors, so those are handled client-side after fetching.
89
+ const serverFilterConditions = [];
90
+ if (args.hasAttachments === true) {
91
+ serverFilterConditions.push('hasAttachments eq true');
92
+ }
93
+ if (args.receivedAfter) {
94
+ try {
95
+ const afterDate = new Date(args.receivedAfter).toISOString();
96
+ serverFilterConditions.push(`receivedDateTime ge ${afterDate}`);
97
+ } catch (_e) {
98
+ /* ignore invalid date */
99
+ }
100
+ }
101
+ if (args.receivedBefore) {
102
+ try {
103
+ const beforeDate = new Date(args.receivedBefore).toISOString();
104
+ serverFilterConditions.push(`receivedDateTime le ${beforeDate}`);
105
+ } catch (_e) {
106
+ /* ignore invalid date */
107
+ }
108
+ }
109
+
110
+ if (serverFilterConditions.length > 0) {
111
+ queryParams.$filter = serverFilterConditions.join(' and ');
112
+ }
113
+
82
114
  const response = await callGraphAPI(
83
115
  accessToken,
84
116
  'GET',
@@ -86,7 +118,33 @@ async function handleListConversations(args) {
86
118
  null,
87
119
  queryParams
88
120
  );
89
- const messages = response.value || [];
121
+ let messages = response.value || [];
122
+
123
+ // Client-side filtering for conditions that cause InefficientFilter on personal accounts
124
+ if (args.subject) {
125
+ const subjectLower = args.subject.toLowerCase();
126
+ messages = messages.filter((m) =>
127
+ (m.subject || '').toLowerCase().includes(subjectLower)
128
+ );
129
+ }
130
+ if (args.from) {
131
+ const fromLower = args.from.toLowerCase();
132
+ messages = messages.filter((m) => {
133
+ const addr = (m.from?.emailAddress?.address || '').toLowerCase();
134
+ const name = (m.from?.emailAddress?.name || '').toLowerCase();
135
+ return addr.includes(fromLower) || name.includes(fromLower);
136
+ });
137
+ }
138
+ if (args.to) {
139
+ const toLower = args.to.toLowerCase();
140
+ messages = messages.filter((m) =>
141
+ (m.toRecipients || []).some((r) => {
142
+ const addr = (r.emailAddress?.address || '').toLowerCase();
143
+ const name = (r.emailAddress?.name || '').toLowerCase();
144
+ return addr.includes(toLower) || name.includes(toLower);
145
+ })
146
+ );
147
+ }
90
148
 
91
149
  // Group by conversationId
92
150
  const conversations = new Map();
@@ -146,13 +146,46 @@ async function handleGetMailTips(args) {
146
146
  };
147
147
  }
148
148
 
149
+ // Normalise recipients to a clean array of email strings
150
+ let recipientList;
151
+ if (Array.isArray(recipients)) {
152
+ recipientList = recipients.map((e) => String(e).trim());
153
+ } else if (typeof recipients === 'string') {
154
+ // Handle JSON array strings like '["a@b.com","c@d.com"]' from MCP clients
155
+ const trimmed = recipients.trim();
156
+ if (trimmed.startsWith('[')) {
157
+ try {
158
+ recipientList = JSON.parse(trimmed).map((e) => String(e).trim());
159
+ } catch (_e) {
160
+ // Fall through to comma-split
161
+ }
162
+ }
163
+ if (!recipientList) {
164
+ recipientList = trimmed.split(',').map((e) => e.trim());
165
+ }
166
+ } else {
167
+ recipientList = [String(recipients).trim()];
168
+ }
169
+
170
+ // Filter out empty strings
171
+ recipientList = recipientList.filter((e) => e.length > 0);
172
+
173
+ if (recipientList.length === 0) {
174
+ return {
175
+ content: [
176
+ {
177
+ type: 'text',
178
+ text: 'At least one valid recipient email address is required.',
179
+ },
180
+ ],
181
+ };
182
+ }
183
+
149
184
  try {
150
185
  const accessToken = await ensureAuthenticated();
151
186
 
152
187
  const requestBody = {
153
- EmailAddresses: Array.isArray(recipients)
154
- ? recipients
155
- : recipients.split(',').map((e) => e.trim()),
188
+ EmailAddresses: recipientList,
156
189
  MailTipsOptions: tipTypes || MAIL_TIP_TYPES.join(','),
157
190
  };
158
191
 
package/email/search.js CHANGED
@@ -152,33 +152,47 @@ async function progressiveSearch(
152
152
  }
153
153
  }
154
154
 
155
- // 1. Try combined search (most specific)
156
- try {
157
- const params = buildSearchParams(
158
- searchTerms,
159
- filterTerms,
160
- Math.min(50, maxCount),
161
- selectFields
162
- );
163
- console.error('Attempting combined search with params:', params);
164
- searchAttempts.push('combined-search');
155
+ // Check if we have any actual search terms (not just boolean filters)
156
+ const hasSearchTerms =
157
+ searchTerms.query ||
158
+ searchTerms.from ||
159
+ searchTerms.to ||
160
+ searchTerms.subject;
161
+
162
+ // 1. Try combined search (most specific) — skip if only boolean filters
163
+ if (
164
+ !hasSearchTerms &&
165
+ (filterTerms.hasAttachments === true || filterTerms.unreadOnly === true)
166
+ ) {
167
+ // Skip directly to boolean-only filter (step 3) — combined search is redundant
168
+ console.error('Only boolean filters provided, skipping combined search');
169
+ } else
170
+ try {
171
+ const params = buildSearchParams(
172
+ searchTerms,
173
+ filterTerms,
174
+ Math.min(50, maxCount),
175
+ selectFields
176
+ );
177
+ console.error('Attempting combined search with params:', params);
178
+ searchAttempts.push('combined-search');
165
179
 
166
- const response = await callGraphAPIPaginated(
167
- accessToken,
168
- 'GET',
169
- endpoint,
170
- params,
171
- maxCount
172
- );
173
- if (response.value && response.value.length > 0) {
174
- console.error(
175
- `Combined search successful: found ${response.value.length} results`
180
+ const response = await callGraphAPIPaginated(
181
+ accessToken,
182
+ 'GET',
183
+ endpoint,
184
+ params,
185
+ maxCount
176
186
  );
177
- return response;
187
+ if (response.value && response.value.length > 0) {
188
+ console.error(
189
+ `Combined search successful: found ${response.value.length} results`
190
+ );
191
+ return response;
192
+ }
193
+ } catch (error) {
194
+ console.error(`Combined search failed: ${error.message}`);
178
195
  }
179
- } catch (error) {
180
- console.error(`Combined search failed: ${error.message}`);
181
- }
182
196
 
183
197
  // 2. Try each search term individually, starting with most specific
184
198
  const searchPriority = ['from', 'to', 'subject', 'query'];
@@ -200,23 +214,16 @@ async function progressiveSearch(
200
214
  // $search for free-text query only.
201
215
  // NOTE: $filter and $orderby cannot be used together on mailbox - Graph API limitation
202
216
  if (term === 'from') {
203
- if (searchTerms[term].includes('@')) {
204
- simplifiedParams.$filter = `from/emailAddress/address eq '${searchTerms[term]}'`;
205
- } else {
206
- simplifiedParams.$filter = `contains(from/emailAddress/name, '${searchTerms[term]}')`;
207
- }
217
+ simplifiedParams.$filter = buildFromFilter(searchTerms[term]);
208
218
  } else if (term === 'to') {
209
- if (searchTerms[term].includes('@')) {
210
- simplifiedParams.$filter = `toRecipients/any(r: r/emailAddress/address eq '${searchTerms[term]}')`;
211
- } else {
212
- simplifiedParams.$filter = `toRecipients/any(r: contains(r/emailAddress/name, '${searchTerms[term]}'))`;
213
- }
219
+ simplifiedParams.$filter = buildToFilter(searchTerms[term]);
214
220
  } else if (term === 'subject') {
215
221
  // Use $filter with contains() — $search silently fails on personal MS accounts
216
222
  simplifiedParams.$filter = `contains(subject, '${searchTerms[term].replace(/'/g, "''")}')`;
217
223
  } else if (term === 'query') {
218
- simplifiedParams.$orderby = 'receivedDateTime desc';
219
- simplifiedParams.$search = `"${searchTerms[term]}"`;
224
+ // On personal accounts, $search fails with 503. Use $filter with
225
+ // contains(subject) as a best-effort fallback for free-text queries.
226
+ simplifiedParams.$filter = `contains(subject, '${searchTerms[term].replace(/'/g, "''")}')`;
220
227
  }
221
228
 
222
229
  // Add boolean filters if applicable
@@ -241,21 +248,29 @@ async function progressiveSearch(
241
248
  }
242
249
  }
243
250
 
244
- // 3. Try with only boolean filters
245
- if (filterTerms.hasAttachments === true || filterTerms.unreadOnly === true) {
251
+ // 3. Try with only boolean filters (also date range filters)
252
+ const hasBooleanFilters =
253
+ filterTerms.hasAttachments === true || filterTerms.unreadOnly === true;
254
+ const hasDateFilters =
255
+ filterTerms.receivedAfter || filterTerms.receivedBefore;
256
+ if (hasBooleanFilters || hasDateFilters) {
246
257
  try {
247
- console.error('Attempting search with only boolean filters');
258
+ console.error('Attempting search with only boolean/date filters');
248
259
  searchAttempts.push('boolean-filters-only');
249
260
 
250
261
  const filterOnlyParams = {
251
262
  $top: Math.min(50, maxCount),
252
263
  $select: selectFields,
253
- $orderby: 'receivedDateTime desc',
254
264
  };
255
265
 
256
- // Add the boolean filters
266
+ // Add the boolean + date filters
257
267
  addBooleanFilters(filterOnlyParams, filterTerms);
258
268
 
269
+ // Only add $orderby if no $filter (they can conflict on personal accounts)
270
+ if (!filterOnlyParams.$filter) {
271
+ filterOnlyParams.$orderby = 'receivedDateTime desc';
272
+ }
273
+
259
274
  const response = await callGraphAPIPaginated(
260
275
  accessToken,
261
276
  'GET',
@@ -269,6 +284,29 @@ async function progressiveSearch(
269
284
  return response;
270
285
  } catch (error) {
271
286
  console.error(`Boolean filter search failed: ${error.message}`);
287
+ // Retry without $orderby if it was the issue
288
+ if (error.message && error.message.includes('InefficientFilter')) {
289
+ try {
290
+ console.error('Retrying boolean filters without $orderby');
291
+ const retryParams = {
292
+ $top: Math.min(50, maxCount),
293
+ $select: selectFields,
294
+ };
295
+ addBooleanFilters(retryParams, filterTerms);
296
+ const response = await callGraphAPIPaginated(
297
+ accessToken,
298
+ 'GET',
299
+ endpoint,
300
+ retryParams,
301
+ maxCount
302
+ );
303
+ return response;
304
+ } catch (retryError) {
305
+ console.error(
306
+ `Boolean filter retry also failed: ${retryError.message}`
307
+ );
308
+ }
309
+ }
272
310
  }
273
311
  }
274
312
 
@@ -304,6 +342,54 @@ async function progressiveSearch(
304
342
  return response;
305
343
  }
306
344
 
345
+ /**
346
+ * Detect if a value is a domain-only filter (e.g. "@souliv.com.au" or "souliv.com.au")
347
+ * vs a full email address (e.g. "user@souliv.com.au") vs a display name (e.g. "Billie")
348
+ * @param {string} val - The from/to filter value
349
+ * @returns {'domain'|'email'|'name'} - The type of filter
350
+ */
351
+ function classifyEmailFilter(val) {
352
+ if (val.startsWith('@')) return 'domain';
353
+ // Has dots but no @ — likely a domain like "souliv.com.au"
354
+ if (!val.includes('@') && val.includes('.')) return 'domain';
355
+ if (val.includes('@')) return 'email';
356
+ return 'name';
357
+ }
358
+
359
+ /**
360
+ * Build a $filter condition for a from field value
361
+ * @param {string} val - The from filter value
362
+ * @returns {string} - OData $filter condition
363
+ */
364
+ function buildFromFilter(val) {
365
+ const type = classifyEmailFilter(val);
366
+ if (type === 'domain') {
367
+ // Use contains() — endswith() not supported on personal accounts
368
+ const domain = val.startsWith('@') ? val : `@${val}`;
369
+ return `contains(from/emailAddress/address, '${domain.substring(1)}')`;
370
+ } else if (type === 'email') {
371
+ return `from/emailAddress/address eq '${val}'`;
372
+ }
373
+ return `contains(from/emailAddress/name, '${val}')`;
374
+ }
375
+
376
+ /**
377
+ * Build a $filter condition for a to field value
378
+ * @param {string} val - The to filter value
379
+ * @returns {string} - OData $filter condition
380
+ */
381
+ function buildToFilter(val) {
382
+ const type = classifyEmailFilter(val);
383
+ if (type === 'domain') {
384
+ const domain = val.startsWith('@') ? val : `@${val}`;
385
+ // Use contains() — endswith() not supported on personal accounts
386
+ return `toRecipients/any(r: contains(r/emailAddress/address, '${domain.substring(1)}'))`;
387
+ } else if (type === 'email') {
388
+ return `toRecipients/any(r: r/emailAddress/address eq '${val}')`;
389
+ }
390
+ return `toRecipients/any(r: contains(r/emailAddress/name, '${val}'))`;
391
+ }
392
+
307
393
  /**
308
394
  * Build search parameters from search terms and filter terms
309
395
  * Uses $filter for email addresses (more reliable than $search)
@@ -342,28 +428,12 @@ function buildSearchParams(searchTerms, filterTerms, count, selectFields) {
342
428
  // NOTE: $filter on email addresses is incompatible with $orderby - Graph API limitation
343
429
  if (searchTerms.from) {
344
430
  usesEmailFilter = true;
345
- if (searchTerms.from.includes('@')) {
346
- filterConditions.push(
347
- `from/emailAddress/address eq '${searchTerms.from}'`
348
- );
349
- } else {
350
- filterConditions.push(
351
- `contains(from/emailAddress/name, '${searchTerms.from}')`
352
- );
353
- }
431
+ filterConditions.push(buildFromFilter(searchTerms.from));
354
432
  }
355
433
 
356
434
  if (searchTerms.to) {
357
435
  usesEmailFilter = true;
358
- if (searchTerms.to.includes('@')) {
359
- filterConditions.push(
360
- `toRecipients/any(r: r/emailAddress/address eq '${searchTerms.to}')`
361
- );
362
- } else {
363
- filterConditions.push(
364
- `toRecipients/any(r: contains(r/emailAddress/name, '${searchTerms.to}'))`
365
- );
366
- }
436
+ filterConditions.push(buildToFilter(searchTerms.to));
367
437
  }
368
438
 
369
439
  // Add boolean filters (these ARE compatible with $orderby)
@@ -625,4 +695,7 @@ async function handleSearchByMessageId(args) {
625
695
  module.exports = {
626
696
  handleSearchEmails,
627
697
  handleSearchByMessageId,
698
+ buildFromFilter,
699
+ buildToFilter,
700
+ classifyEmailFilter,
628
701
  };
package/email/send.js CHANGED
@@ -101,49 +101,7 @@ async function handleSendEmail(args) {
101
101
  const allowlistError = checkRecipientAllowlist(allRecipients);
102
102
  if (allowlistError) return allowlistError;
103
103
 
104
- // Pre-send mail tips check
105
- if (checkRecipients) {
106
- const allAddresses = allRecipients.map((r) => r.emailAddress.address);
107
- const tipsResult = await handleGetMailTips({
108
- recipients: allAddresses,
109
- });
110
-
111
- // If there are warnings, prepend them to the response
112
- if (tipsResult._meta?.warningCount > 0 && dryRun) {
113
- // In dry-run mode, include mail tips in the preview
114
- const tipsText = tipsResult.content[0]?.text || '';
115
- const preview = formatDryRunPreview({
116
- message: {
117
- subject,
118
- body: {
119
- contentType:
120
- /<(html|div|p|h[1-6]|br|table|ul|ol|li|span|a\s|img|strong|em|b|i)\b/i.test(
121
- body
122
- )
123
- ? 'html'
124
- : 'text',
125
- content: body,
126
- },
127
- toRecipients,
128
- ccRecipients: ccRecipients.length > 0 ? ccRecipients : undefined,
129
- bccRecipients: bccRecipients.length > 0 ? bccRecipients : undefined,
130
- importance,
131
- },
132
- saveToSentItems,
133
- });
134
- return {
135
- content: [
136
- {
137
- type: 'text',
138
- text: tipsText + '\n\n---\n\n' + preview.content[0].text,
139
- },
140
- ],
141
- _meta: { mailTips: tipsResult._meta },
142
- };
143
- }
144
- }
145
-
146
- // Prepare email object
104
+ // Prepare email object (needed by both dryRun and actual send)
147
105
  const emailObject = {
148
106
  message: {
149
107
  subject,
@@ -164,6 +122,37 @@ async function handleSendEmail(args) {
164
122
  saveToSentItems,
165
123
  };
166
124
 
125
+ // Pre-send mail tips check
126
+ if (checkRecipients) {
127
+ const allAddresses = allRecipients.map((r) => r.emailAddress.address);
128
+ const tipsResult = await handleGetMailTips({
129
+ recipients: allAddresses,
130
+ });
131
+
132
+ const tipsText = tipsResult.content[0]?.text || '';
133
+
134
+ // In dry-run mode, always include mail tips in the preview
135
+ if (dryRun) {
136
+ const preview = formatDryRunPreview(emailObject);
137
+ return {
138
+ content: [
139
+ {
140
+ type: 'text',
141
+ text: tipsText + '\n\n---\n\n' + preview.content[0].text,
142
+ },
143
+ ],
144
+ _meta: { mailTips: tipsResult._meta },
145
+ };
146
+ }
147
+
148
+ // In send mode, warn if there are issues but proceed
149
+ if (tipsResult._meta?.warningCount > 0) {
150
+ // Store tips to prepend to send response
151
+ emailObject._mailTipsText = tipsText;
152
+ emailObject._mailTipsMeta = tipsResult._meta;
153
+ }
154
+ }
155
+
167
156
  // Dry-run mode: return preview without sending
168
157
  if (dryRun) {
169
158
  return formatDryRunPreview(emailObject);
package/index.js CHANGED
@@ -12,7 +12,7 @@ const {
12
12
  const config = require('./config');
13
13
 
14
14
  // Import module tools
15
- const { authTools } = require('./auth');
15
+ const { authTools, setToolCount } = require('./auth');
16
16
  const { calendarTools } = require('./calendar');
17
17
  const { emailTools } = require('./email');
18
18
  const { folderTools } = require('./folder');
@@ -39,6 +39,9 @@ const TOOLS = [
39
39
  ...advancedTools,
40
40
  ];
41
41
 
42
+ // Set dynamic tool count for auth about handler
43
+ setToolCount(TOOLS.length);
44
+
42
45
  // Create server with tools capabilities
43
46
  const server = new Server(
44
47
  { name: config.SERVER_NAME, version: config.SERVER_VERSION },
@@ -0,0 +1,90 @@
1
+ # Installing Outlook Assistant
2
+
3
+ ## Quick Install (npx — no global install needed)
4
+
5
+ Add to your MCP client configuration:
6
+
7
+ ```json
8
+ {
9
+ "mcpServers": {
10
+ "outlook": {
11
+ "command": "npx",
12
+ "args": ["-y", "@littlebearapps/outlook-assistant"],
13
+ "env": {
14
+ "OUTLOOK_CLIENT_ID": "<user-must-provide>",
15
+ "OUTLOOK_CLIENT_SECRET": "<user-must-provide>"
16
+ }
17
+ }
18
+ }
19
+ }
20
+ ```
21
+
22
+ ## Prerequisites
23
+
24
+ 1. **Node.js 18+** must be installed
25
+ 2. **Azure app registration** is required for authentication (free tier works)
26
+
27
+ ## Getting the Client ID and Secret
28
+
29
+ Users must create an Azure app registration to get credentials:
30
+
31
+ 1. Go to https://portal.azure.com/ and sign in
32
+ 2. Search for "App registrations" → click "New registration"
33
+ 3. Name: "Outlook Assistant" (or any name)
34
+ 4. Supported account types: "Accounts in any organizational directory and personal Microsoft accounts"
35
+ 5. Redirect URI: platform "Web", URI `http://localhost:3333/auth/callback`
36
+ 6. Click "Register"
37
+ 7. Copy the **Application (client) ID** → this is `OUTLOOK_CLIENT_ID`
38
+
39
+ ### Create a client secret:
40
+ 1. Go to "Certificates & secrets" → "New client secret"
41
+ 2. Add a description, select expiration, click "Add"
42
+ 3. **Copy the Value immediately** (not the Secret ID) → this is `OUTLOOK_CLIENT_SECRET`
43
+
44
+ ### Add API permissions:
45
+ 1. Go to "API permissions" → "Add a permission" → "Microsoft Graph" → "Delegated permissions"
46
+ 2. Add: `offline_access`, `User.Read`, `Mail.Read`, `Mail.ReadWrite`, `Mail.Send`, `Calendars.Read`, `Calendars.ReadWrite`, `Contacts.Read`, `Contacts.ReadWrite`, `People.Read`, `MailboxSettings.ReadWrite`
47
+ 3. Click "Add permissions"
48
+
49
+ ## First-Time Authentication
50
+
51
+ After configuring the MCP server:
52
+
53
+ 1. Start the auth server: `npx @littlebearapps/outlook-assistant-auth` (or run `npm run auth-server` from source)
54
+ 2. Use the `auth` tool with `action=authenticate` to get an OAuth URL
55
+ 3. Open the URL in a browser, sign in with your Microsoft account
56
+ 4. Grant permissions — tokens are saved to `~/.outlook-assistant-tokens.json` and refresh automatically
57
+
58
+ **Note**: The auth server needs `OUTLOOK_CLIENT_ID` and `OUTLOOK_CLIENT_SECRET` as environment variables. If running it separately from the MCP server, export them in your shell or create a `.env` file.
59
+
60
+ ## Configuration Files by Client
61
+
62
+ ### Claude Desktop
63
+ File: `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) or `%APPDATA%\Claude\claude_desktop_config.json` (Windows)
64
+
65
+ ### Claude Code
66
+ ```bash
67
+ claude mcp add outlook -- npx @littlebearapps/outlook-assistant
68
+ ```
69
+
70
+ ### Cursor
71
+ File: `.cursor/mcp.json` in your project root
72
+
73
+ ### Windsurf
74
+ File: `~/.codeium/windsurf/mcp_config.json`
75
+
76
+ ## Verify Installation
77
+
78
+ After authentication, test with:
79
+ - `auth` tool with `action=status` — should show "authenticated"
80
+ - `search-emails` with no parameters — should list recent inbox emails
81
+
82
+ ## Troubleshooting
83
+
84
+ | Problem | Solution |
85
+ |---------|----------|
86
+ | "Invalid client secret" (AADSTS7000215) | Use the secret **Value**, not the Secret ID |
87
+ | Auth URL doesn't work | Start the auth server first |
88
+ | "EADDRINUSE :3333" | Run `npx kill-port 3333` then restart auth server |
89
+ | Empty API responses | Run `auth` tool with `action=status` to check token |
90
+ | Search returns no results (personal account) | Use `from`, `subject`, `to` filters instead of `query` |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@littlebearapps/outlook-assistant",
3
- "version": "3.5.0",
3
+ "version": "3.5.2",
4
4
  "mcpName": "io.github.littlebearapps/outlook-assistant",
5
5
  "description": "Outlook Assistant — MCP server with 21 tools for email, calendar, contacts, and settings via Microsoft Graph API",
6
6
  "main": "index.js",
@@ -70,7 +70,8 @@
70
70
  ".env.example",
71
71
  "README.md",
72
72
  "LICENSE",
73
- "llms.txt"
73
+ "llms.txt",
74
+ "llms-install.md"
74
75
  ],
75
76
  "dependencies": {
76
77
  "@modelcontextprotocol/sdk": "^1.27.1",
@@ -325,9 +325,67 @@ async function callGraphAPIRaw(accessToken, emailId) {
325
325
  });
326
326
  }
327
327
 
328
+ /**
329
+ * Calls Graph API with automatic auth and 401 retry.
330
+ * Gets token via ensureAuthenticated(), and if a 401 occurs,
331
+ * refreshes the token and retries once.
332
+ * @param {string} method - HTTP method
333
+ * @param {string} path - API endpoint path
334
+ * @param {object} data - Request body
335
+ * @param {object} queryParams - Query parameters
336
+ * @param {object} extraHeaders - Additional headers
337
+ * @returns {Promise<object>} - API response
338
+ */
339
+ async function callGraphAPIWithAuth(
340
+ method,
341
+ path,
342
+ data = null,
343
+ queryParams = {},
344
+ extraHeaders = {}
345
+ ) {
346
+ // Lazy require to avoid circular dependency
347
+ const { ensureAuthenticated, tokenStorage } = require('../auth');
348
+
349
+ const accessToken = await ensureAuthenticated();
350
+ try {
351
+ return await callGraphAPI(
352
+ accessToken,
353
+ method,
354
+ path,
355
+ data,
356
+ queryParams,
357
+ extraHeaders
358
+ );
359
+ } catch (error) {
360
+ if (error.message === 'UNAUTHORIZED' && tokenStorage) {
361
+ console.error('[GRAPH-API] 401 received, attempting token refresh...');
362
+ try {
363
+ const newToken = await tokenStorage.refreshAccessToken();
364
+ if (newToken) {
365
+ return await callGraphAPI(
366
+ newToken,
367
+ method,
368
+ path,
369
+ data,
370
+ queryParams,
371
+ extraHeaders
372
+ );
373
+ }
374
+ } catch (refreshError) {
375
+ console.error(
376
+ '[GRAPH-API] Token refresh failed:',
377
+ refreshError.message
378
+ );
379
+ }
380
+ }
381
+ throw error;
382
+ }
383
+ }
384
+
328
385
  module.exports = {
329
386
  callGraphAPI,
330
387
  callGraphAPIPaginated,
331
388
  callGraphAPIBatch,
332
389
  callGraphAPIRaw,
390
+ callGraphAPIWithAuth,
333
391
  };