@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 +5 -0
- package/README.md +29 -12
- package/auth/device-code.js +139 -0
- package/auth/index.js +18 -6
- package/auth/tools.js +195 -19
- package/categories/index.js +26 -5
- package/config.js +4 -0
- package/email/conversations.js +59 -1
- package/email/mail-tips.js +36 -3
- package/email/search.js +132 -59
- package/email/send.js +32 -43
- package/index.js +4 -1
- 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
|
@@ -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.
|
|
153
|
-
3.
|
|
154
|
-
4.
|
|
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
|
-
###
|
|
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
|
-
|
|
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
|
|
358
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 |
|
|
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
|
-
|
|
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:
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
248
|
+
const accessToken = await tokenStorage.getValidAccessToken();
|
|
94
249
|
|
|
95
|
-
if (!
|
|
96
|
-
console.error('[CHECK-AUTH-STATUS] No valid access token
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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: [
|
|
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
|
};
|
package/categories/index.js
CHANGED
|
@@ -305,17 +305,26 @@ async function handleUpdateCategory(args) {
|
|
|
305
305
|
updateData
|
|
306
306
|
);
|
|
307
307
|
|
|
308
|
-
|
|
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**: ${
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
package/email/conversations.js
CHANGED
|
@@ -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
|
-
|
|
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();
|
package/email/mail-tips.js
CHANGED
|
@@ -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:
|
|
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
|
-
//
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
219
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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 },
|
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.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",
|
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
|
};
|