@littlebearapps/outlook-assistant 3.5.0 → 3.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.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
@@ -152,6 +152,7 @@ You need a Microsoft Azure app registration to authenticate. See the **[Azure Se
152
152
  2. Set redirect URI to `http://localhost:3333/auth/callback`
153
153
  3. Add Microsoft Graph delegated permissions (Mail, Calendar, Contacts)
154
154
  4. Create a client secret and copy the **Value** (not the Secret ID)
155
+ 5. Enable **"Allow public client flows"** in Authentication > Advanced settings (for device code flow)
155
156
 
156
157
  ### 3. Configure Your MCP Client
157
158
 
@@ -340,7 +341,21 @@ If installed from source, use `node` instead of `npx`:
340
341
 
341
342
  ## Authentication Flow
342
343
 
343
- ### Step 1: Start the Auth Server
344
+ ### Device Code Flow (Default — Recommended)
345
+
346
+ No auth server needed. Works everywhere, including remote/headless environments.
347
+
348
+ 1. Ask your AI assistant to authenticate (calls `auth` tool with `action=authenticate`)
349
+ 2. Visit the URL shown (`microsoft.com/devicelogin`) on **any** browser, **any** device
350
+ 3. Enter the code, sign in with your Microsoft account, and grant permissions
351
+ 4. Tell your AI assistant to complete authentication (calls `auth` with `action=device-code-complete`)
352
+ 5. Tokens are saved to `~/.outlook-assistant-tokens.json` and **refresh automatically**
353
+
354
+ > **Prerequisite**: Enable "Allow public client flows" in Azure Portal > your app > Authentication > Advanced settings.
355
+
356
+ ### Browser Redirect Flow (Alternative)
357
+
358
+ For localhost development or if you prefer the traditional OAuth flow:
344
359
 
345
360
  ```bash
346
361
  npm run auth-server
@@ -348,14 +363,11 @@ npm run auth-server
348
363
 
349
364
  This starts a local server on port 3333 to handle the OAuth callback.
350
365
 
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`
366
+ 1. In your AI assistant, use the `auth` tool with `action=authenticate, method=browser`
356
367
  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
368
+ 3. Sign in and grant permissions — tokens are saved automatically
369
+
370
+ > **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
371
 
360
372
  ## Directory Structure
361
373
 
@@ -409,7 +421,11 @@ You're using the Secret **ID** instead of the Secret **Value**. Go to Azure Port
409
421
 
410
422
  ### Authentication URL doesn't work
411
423
 
412
- Start the auth server first: `npm run auth-server`
424
+ If using browser flow: start the auth server first with `npm run auth-server`. If using device code flow: visit `microsoft.com/devicelogin` instead.
425
+
426
+ ### Device code "invalid_client"
427
+
428
+ Enable "Allow public client flows" in Azure Portal > App registrations > Authentication > Advanced settings.
413
429
 
414
430
  ### Empty API responses
415
431
 
@@ -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 TokenStorage = require('./token-storage');
6
+ const config = require('../config');
5
7
  const { authTools } = require('./tools');
6
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
+ });
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,8 @@ 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,
31
42
  ensureAuthenticated,
32
43
  };
package/auth/tools.js CHANGED
@@ -3,6 +3,7 @@
3
3
  */
4
4
  const config = require('../config');
5
5
  const tokenManager = require('./token-manager');
6
+ const { initiateDeviceCodeFlow, pollForToken } = require('./device-code');
6
7
 
7
8
  /**
8
9
  * About tool handler
@@ -46,18 +47,14 @@ async function handleAbout() {
46
47
  }
47
48
 
48
49
  /**
49
- * Authentication tool handler
50
+ * Authentication tool handler — supports browser redirect and device code flow.
50
51
  * @param {object} args - Tool arguments
51
52
  * @returns {object} - MCP response
52
53
  */
53
54
  async function handleAuthenticate(args) {
54
- const _force = args && args.force === true;
55
-
56
55
  // For test mode, create a test token
57
56
  if (config.USE_TEST_MODE) {
58
- // Create a test token with a 1-hour expiry
59
57
  tokenManager.createTestTokens();
60
-
61
58
  return {
62
59
  content: [
63
60
  {
@@ -68,43 +65,205 @@ async function handleAuthenticate(args) {
68
65
  };
69
66
  }
70
67
 
71
- // For real authentication, generate an auth URL and instruct the user to visit it
68
+ const method = args?.method || config.AUTH_CONFIG.defaultAuthMethod;
69
+
70
+ if (method === 'device-code') {
71
+ return handleDeviceCodeAuth();
72
+ }
73
+
74
+ // Browser redirect flow (existing behaviour)
72
75
  const authUrl = `${config.AUTH_CONFIG.authServerUrl}/auth?client_id=${config.AUTH_CONFIG.clientId}`;
76
+ return {
77
+ content: [
78
+ {
79
+ type: 'text',
80
+ 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.`,
81
+ },
82
+ ],
83
+ };
84
+ }
85
+
86
+ // Module-level state for pending device code flow
87
+ let pendingDeviceCode = null;
88
+
89
+ /**
90
+ * Device code flow step 1 — request a code for the user to enter.
91
+ * Returns the code + URL immediately. Call device-code-complete to finish.
92
+ * @returns {object} - MCP response
93
+ */
94
+ async function handleDeviceCodeAuth() {
95
+ const clientId = config.AUTH_CONFIG.clientId;
96
+ if (!clientId) {
97
+ return {
98
+ content: [
99
+ {
100
+ type: 'text',
101
+ text: 'Error: OUTLOOK_CLIENT_ID is not configured.',
102
+ },
103
+ ],
104
+ };
105
+ }
106
+
107
+ console.error('[AUTH] Starting device code flow...');
108
+ const response = await initiateDeviceCodeFlow(
109
+ clientId,
110
+ config.AUTH_CONFIG.scopes
111
+ );
112
+
113
+ // Store for the completion step
114
+ pendingDeviceCode = {
115
+ deviceCode: response.deviceCode,
116
+ interval: response.interval,
117
+ expiresIn: response.expiresIn,
118
+ expiresAt: Date.now() + response.expiresIn * 1000,
119
+ };
120
+
121
+ console.error(
122
+ `[AUTH] Device code: ${response.userCode}, expires in ${response.expiresIn}s`
123
+ );
73
124
 
74
125
  return {
75
126
  content: [
76
127
  {
77
128
  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.`,
129
+ text: [
130
+ `## Device Code Authentication\n`,
131
+ `Visit: **${response.verificationUri}**`,
132
+ `Enter code: **${response.userCode}**\n`,
133
+ `The code expires in ${Math.floor(response.expiresIn / 60)} minutes.\n`,
134
+ `After entering the code and signing in, call this tool again with \`action=device-code-complete\` to finish authentication.`,
135
+ ].join('\n'),
79
136
  },
80
137
  ],
81
138
  };
82
139
  }
83
140
 
84
141
  /**
85
- * Check authentication status tool handler
142
+ * Device code flow step 2 — poll until the user completes authentication.
143
+ * @returns {object} - MCP response
144
+ */
145
+ async function handleDeviceCodeComplete() {
146
+ if (!pendingDeviceCode) {
147
+ return {
148
+ content: [
149
+ {
150
+ type: 'text',
151
+ text: 'No pending device code flow. Call authenticate with method=device-code first.',
152
+ },
153
+ ],
154
+ };
155
+ }
156
+
157
+ if (Date.now() > pendingDeviceCode.expiresAt) {
158
+ pendingDeviceCode = null;
159
+ return {
160
+ content: [
161
+ {
162
+ type: 'text',
163
+ text: 'Device code has expired. Please start a new authentication with action=authenticate.',
164
+ },
165
+ ],
166
+ };
167
+ }
168
+
169
+ const clientId = config.AUTH_CONFIG.clientId;
170
+
171
+ try {
172
+ console.error('[AUTH] Polling for device code completion...');
173
+ const tokenResponse = await pollForToken(
174
+ clientId,
175
+ pendingDeviceCode.deviceCode,
176
+ pendingDeviceCode.interval,
177
+ Math.ceil((pendingDeviceCode.expiresAt - Date.now()) / 1000)
178
+ );
179
+
180
+ pendingDeviceCode = null;
181
+
182
+ // Save tokens using TokenStorage
183
+ const TokenStorage = require('./token-storage');
184
+ const tokenStorage = new TokenStorage({
185
+ clientId: config.AUTH_CONFIG.clientId,
186
+ clientSecret: config.AUTH_CONFIG.clientSecret,
187
+ tokenStorePath: config.AUTH_CONFIG.tokenStorePath,
188
+ scopes: config.AUTH_CONFIG.scopes,
189
+ tokenEndpoint: config.AUTH_CONFIG.tokenEndpoint,
190
+ });
191
+
192
+ tokenStorage.tokens = {
193
+ access_token: tokenResponse.access_token,
194
+ refresh_token: tokenResponse.refresh_token,
195
+ expires_in: tokenResponse.expires_in,
196
+ expires_at: Date.now() + tokenResponse.expires_in * 1000,
197
+ scope: tokenResponse.scope,
198
+ token_type: tokenResponse.token_type,
199
+ };
200
+ await tokenStorage._saveTokensToFile();
201
+
202
+ console.error('[AUTH] Device code flow completed successfully.');
203
+
204
+ return {
205
+ content: [
206
+ {
207
+ type: 'text',
208
+ text: 'Authentication successful! Tokens saved. You can now use Outlook tools.',
209
+ },
210
+ ],
211
+ };
212
+ } catch (error) {
213
+ pendingDeviceCode = null;
214
+ return {
215
+ content: [
216
+ {
217
+ type: 'text',
218
+ text: `Authentication failed: ${error.message}`,
219
+ },
220
+ ],
221
+ };
222
+ }
223
+ }
224
+
225
+ /**
226
+ * Check authentication status — attempts token refresh if expired.
86
227
  * @returns {object} - MCP response
87
228
  */
88
229
  async function handleCheckAuthStatus() {
89
230
  console.error('[CHECK-AUTH-STATUS] Starting authentication status check');
90
231
 
91
- const tokens = tokenManager.loadTokenCache();
232
+ // Use TokenStorage for accurate status (includes refresh attempt)
233
+ const TokenStorage = require('./token-storage');
234
+ const tokenStorage = new TokenStorage({
235
+ clientId: config.AUTH_CONFIG.clientId,
236
+ clientSecret: config.AUTH_CONFIG.clientSecret,
237
+ tokenStorePath: config.AUTH_CONFIG.tokenStorePath,
238
+ scopes: config.AUTH_CONFIG.scopes,
239
+ tokenEndpoint: config.AUTH_CONFIG.tokenEndpoint,
240
+ });
92
241
 
93
- console.error(`[CHECK-AUTH-STATUS] Tokens loaded: ${tokens ? 'YES' : 'NO'}`);
242
+ const accessToken = await tokenStorage.getValidAccessToken();
94
243
 
95
- if (!tokens || !tokens.access_token) {
96
- console.error('[CHECK-AUTH-STATUS] No valid access token found');
244
+ if (!accessToken) {
245
+ console.error('[CHECK-AUTH-STATUS] No valid access token');
97
246
  return {
98
247
  content: [{ type: 'text', text: 'Not authenticated' }],
99
248
  };
100
249
  }
101
250
 
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()}`);
251
+ const expiresAt = tokenStorage.getExpiryTime();
252
+ const expiresIn = expiresAt
253
+ ? Math.round((expiresAt - Date.now()) / 60000)
254
+ : 'unknown';
255
+
256
+ console.error(
257
+ `[CHECK-AUTH-STATUS] Authenticated, token expires in ~${expiresIn} min`
258
+ );
105
259
 
106
260
  return {
107
- content: [{ type: 'text', text: 'Authenticated and ready' }],
261
+ content: [
262
+ {
263
+ type: 'text',
264
+ text: `Authenticated and ready (token expires in ~${expiresIn} minutes)`,
265
+ },
266
+ ],
108
267
  };
109
268
  }
110
269
 
@@ -113,7 +272,7 @@ const authTools = [
113
272
  {
114
273
  name: 'auth',
115
274
  description:
116
- 'Manage authentication with Microsoft Graph API. action=status (default) checks auth state, action=authenticate starts OAuth flow, action=about shows server info.',
275
+ '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
276
  annotations: {
118
277
  title: 'Authentication',
119
278
  readOnlyHint: false,
@@ -125,9 +284,15 @@ const authTools = [
125
284
  properties: {
126
285
  action: {
127
286
  type: 'string',
128
- enum: ['status', 'authenticate', 'about'],
287
+ enum: ['status', 'authenticate', 'device-code-complete', 'about'],
129
288
  description: 'Action to perform (default: status)',
130
289
  },
290
+ method: {
291
+ type: 'string',
292
+ enum: ['device-code', 'browser'],
293
+ description:
294
+ 'Auth method for action=authenticate. device-code (default): no auth server needed, works remotely. browser: traditional OAuth redirect via port 3333.',
295
+ },
131
296
  force: {
132
297
  type: 'boolean',
133
298
  description:
@@ -141,6 +306,8 @@ const authTools = [
141
306
  switch (action) {
142
307
  case 'authenticate':
143
308
  return handleAuthenticate(args);
309
+ case 'device-code-complete':
310
+ return handleDeviceCodeComplete();
144
311
  case 'about':
145
312
  return handleAbout();
146
313
  case 'status':
@@ -155,5 +322,7 @@ module.exports = {
155
322
  authTools,
156
323
  handleAbout,
157
324
  handleAuthenticate,
325
+ handleDeviceCodeAuth,
326
+ handleDeviceCodeComplete,
158
327
  handleCheckAuthStatus,
159
328
  };
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
@@ -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.1",
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
  };