@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 +5 -0
- package/README.md +25 -9
- package/auth/device-code.js +139 -0
- package/auth/index.js +16 -5
- package/auth/tools.js +187 -18
- package/config.js +4 -0
- package/llms-install.md +90 -0
- package/package.json +3 -2
- package/utils/graph-api.js +58 -0
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
|
-
###
|
|
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
|
-
|
|
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
|
|
358
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
242
|
+
const accessToken = await tokenStorage.getValidAccessToken();
|
|
94
243
|
|
|
95
|
-
if (!
|
|
96
|
-
console.error('[CHECK-AUTH-STATUS] No valid access token
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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: [
|
|
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
|
package/llms-install.md
ADDED
|
@@ -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.
|
|
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",
|
package/utils/graph-api.js
CHANGED
|
@@ -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
|
};
|