@littlebearapps/outlook-assistant 3.10.0 → 3.11.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 +7 -0
- package/README.md +31 -3
- package/auth/auth-errors.js +89 -0
- package/auth/oauth-server.js +4 -3
- package/auth/token-storage.js +19 -8
- package/auth/tools.js +4 -13
- package/email/export.js +124 -26
- package/email/index.js +5 -3
- package/email/search.js +35 -3
- package/index.js +59 -0
- package/llms-install.md +1 -1
- package/llms.txt +2 -2
- package/package.json +1 -1
package/.env.example
CHANGED
|
@@ -24,6 +24,13 @@ USE_TEST_MODE=false
|
|
|
24
24
|
# Optional: Enable immutable IDs (IDs persist through folder moves)
|
|
25
25
|
# OUTLOOK_IMMUTABLE_IDS=true
|
|
26
26
|
|
|
27
|
+
# How many recent messages the client-side search fallback scans before giving
|
|
28
|
+
# up. Personal Outlook.com rejects the server-side recipient filter, so a `to`
|
|
29
|
+
# search is matched locally within this window — on a large archive the default
|
|
30
|
+
# silently excludes older mail. Max 5000. Pair `to` with receivedAfter/
|
|
31
|
+
# receivedBefore rather than raising this if you can.
|
|
32
|
+
# OUTLOOK_SEARCH_SCAN_LIMIT=500
|
|
33
|
+
|
|
27
34
|
# Optional: Default authentication method (device-code or browser)
|
|
28
35
|
# device-code: No auth server needed, works remotely/headless
|
|
29
36
|
# browser: Traditional OAuth redirect via localhost:3333
|
package/README.md
CHANGED
|
@@ -161,6 +161,17 @@ Or run directly without installing:
|
|
|
161
161
|
npx @littlebearapps/outlook-assistant
|
|
162
162
|
```
|
|
163
163
|
|
|
164
|
+
To check which version you have, or to see the available options:
|
|
165
|
+
|
|
166
|
+
```bash
|
|
167
|
+
outlook-assistant --version # prints e.g. 3.11.1
|
|
168
|
+
outlook-assistant --help # usage, options and key environment variables
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
With no arguments the server speaks the Model Context Protocol over stdio. It's
|
|
172
|
+
normally launched by your MCP client rather than run by hand — started from a
|
|
173
|
+
terminal it will simply wait on stdin.
|
|
174
|
+
|
|
164
175
|
### 2. Register an Azure App
|
|
165
176
|
|
|
166
177
|
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:
|
|
@@ -278,6 +289,17 @@ cd outlook-assistant
|
|
|
278
289
|
npm install
|
|
279
290
|
```
|
|
280
291
|
|
|
292
|
+
### CLI options
|
|
293
|
+
|
|
294
|
+
| Option | What it does |
|
|
295
|
+
|--------|-------------|
|
|
296
|
+
| `-v`, `--version` | Print the version to stdout and exit 0 |
|
|
297
|
+
| `-h`, `--help` | Print usage, options and key environment variables, and exit 0 |
|
|
298
|
+
| _(none)_ | Start the MCP server on stdio — the normal mode, invoked by your MCP client |
|
|
299
|
+
|
|
300
|
+
An unrecognised argument is reported on stderr and exits 1, rather than starting
|
|
301
|
+
a server that would ignore it.
|
|
302
|
+
|
|
281
303
|
## Azure App Registration
|
|
282
304
|
|
|
283
305
|
> **First time with Azure?** The [Azure Setup Guide](docs/guides/azure-setup.md) covers everything from creating an account to your first authentication, including billing setup and common pitfalls.
|
|
@@ -344,6 +366,7 @@ USE_TEST_MODE=false
|
|
|
344
366
|
| `OUTLOOK_DEFAULT_TIMEZONE` | IANA timezone applied to calendar events when callers don't pass one (e.g. `Europe/London`, `America/New_York`). | `Australia/Melbourne` |
|
|
345
367
|
| `OUTLOOK_MAX_EMAILS_PER_SESSION` | Cap on `send-email` + `draft send` per MCP server lifetime. | unlimited |
|
|
346
368
|
| `OUTLOOK_ALLOWED_RECIPIENTS` | Comma-separated allowlist of domains/addresses for sends, drafts, and rule forwards. | unrestricted |
|
|
369
|
+
| `OUTLOOK_SEARCH_SCAN_LIMIT` | How many recent messages the client-side search fallback scans. Personal accounts match `to` locally within this window, so the default caps how far back a `to` search reaches. Max 5000. | `500` |
|
|
347
370
|
|
|
348
371
|
### MCP Client Configuration
|
|
349
372
|
|
|
@@ -446,7 +469,11 @@ npm run auth-server
|
|
|
446
469
|
|
|
447
470
|
### "Invalid client secret" (AADSTS7000215)
|
|
448
471
|
|
|
449
|
-
You're using the Secret **ID** instead of the Secret **Value**. Go to Azure Portal > Certificates & secrets and copy the **Value** column
|
|
472
|
+
You're using the Secret **ID** instead of the Secret **Value**. Go to Azure Portal > Certificates & secrets and copy the **Value** column into `OUTLOOK_CLIENT_SECRET`.
|
|
473
|
+
|
|
474
|
+
The Value is shown only once, when the secret is created — if you've navigated away it can't be read again, so create a new secret. An **expired** secret produces this same error, so check the Expires column too.
|
|
475
|
+
|
|
476
|
+
Since v3.11.0 the server detects this error and appends the explanation to Microsoft's original message, so you see both the raw error code and what to do about it.
|
|
450
477
|
|
|
451
478
|
### Authentication URL doesn't work
|
|
452
479
|
|
|
@@ -497,7 +524,7 @@ USE_TEST_MODE=true npm start
|
|
|
497
524
|
| [Getting Started](docs/how-to/getting-started/connect-outlook-to-claude.md) | Install, configure, and authenticate — start here |
|
|
498
525
|
| [Azure Setup Guide](docs/guides/azure-setup.md) | Azure account creation, app registration, permissions, and secrets |
|
|
499
526
|
| [How-To Guides](docs/how-to/index.md) | 29 practical guides for email, calendar, contacts, and settings |
|
|
500
|
-
| [Roadmap](ROADMAP.md) | Active milestones (v3.
|
|
527
|
+
| [Roadmap](ROADMAP.md) | Active milestones (v3.11.2, v3.8.x, v3.12.0+) and recent releases |
|
|
501
528
|
| [Troubleshooting & FAQ](docs/how-to/getting-started/verify-your-connection.md#common-connection-problems) | Common problems, re-authentication, and frequently asked questions |
|
|
502
529
|
| [Tools Reference](docs/quickrefs/tools-reference.md) | All 22 tools with parameters |
|
|
503
530
|
| [AI Agent Guide](docs/how-to/ai-agents/using-outlook-assistant-in-agents.md) | Tool selection and workflow patterns for AI agents |
|
|
@@ -506,7 +533,8 @@ Full documentation: [docs/](docs/README.md)
|
|
|
506
533
|
|
|
507
534
|
## Known Limitations
|
|
508
535
|
|
|
509
|
-
- **Personal account search**: Free-text `query` and the raw `searchExpression` (formerly `kqlQuery`) rely on Microsoft's `$search` API, which has limited support on personal Outlook.com accounts. `query` mitigates this with progressive fallback (OData filters, boolean filters, then a client-side scan). Field-scoped `$search` (e.g. `subject:"…"`) is rejected outright there; since v3.10.0 `from:`/`to:`/`subject:` expressions are translated into the closest equivalent OData filters and retried, but boolean operators, grouping, wildcards and other field prefixes are not — those still terminate with an explicit no-results rather than a silent broader search. Structured filters (`from`, `subject`, `to`, `receivedAfter`) remain the most direct route. Cross-folder search (`searchAllFolders: true`) returns a superset of inbox-only results.
|
|
536
|
+
- **Personal account search**: Free-text `query` and the raw `searchExpression` (formerly `kqlQuery`) rely on Microsoft's `$search` API, which has limited support on personal Outlook.com accounts. `query` mitigates this with progressive fallback (OData filters, boolean filters, then a client-side scan). Field-scoped `$search` (e.g. `subject:"…"`) is rejected outright there; since v3.10.0 `from:`/`to:`/`subject:` expressions are translated into the closest equivalent OData filters and retried, but boolean operators, grouping, wildcards and other field prefixes are not — those still terminate with an explicit no-results rather than a silent broader search. Structured filters (`from`, `subject`, `to`, `receivedAfter`) remain the most direct route. Cross-folder search (`searchAllFolders: true`) returns a superset of inbox-only results. Note that `query` and `searchExpression` are not interchangeable there: `searchExpression` goes to `$search`, which matches the whole message including the body and ranks by relevance rather than date, while `query` falls back to a subject substring match that never reads bodies.
|
|
537
|
+
- **`to` search depth on personal accounts**: the server-side recipient filter is rejected, so `to` is matched locally over the 500 most recent messages (`OUTLOOK_SEARCH_SCAN_LIMIT`, max 5000). On a large archive that excludes older mail — pair `to` with `receivedAfter`/`receivedBefore`. Since v3.11.1 the response says so whenever the scan was truncated, whether or not it matched.
|
|
510
538
|
- **Focused Inbox**: Only available on work/school Microsoft 365 accounts.
|
|
511
539
|
- **Shared mailboxes**: Require `Mail.Read.Shared` permission and a work/school account.
|
|
512
540
|
- **Meeting room search**: Requires `Place.Read.All` permission with admin consent (work/school accounts only).
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Azure AD (AADSTS) error translation.
|
|
3
|
+
*
|
|
4
|
+
* Microsoft's identity platform returns accurate but unfriendly errors: the
|
|
5
|
+
* error_description is a wall of prose ending in a correlation ID, and the
|
|
6
|
+
* single most common setup mistake — pasting the client secret's *ID* instead
|
|
7
|
+
* of its *Value* — surfaces only as `AADSTS7000215: Invalid client secret
|
|
8
|
+
* provided`. (#69)
|
|
9
|
+
*
|
|
10
|
+
* This module maps known codes to actionable remediation text. It only ever
|
|
11
|
+
* *adds* to the original message; the raw Azure error is always preserved so
|
|
12
|
+
* that searching for the code still works.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Known Azure error signatures and their remediation hints.
|
|
17
|
+
* Ordered most-specific first; every matching entry contributes a hint.
|
|
18
|
+
*/
|
|
19
|
+
const AUTH_ERROR_HINTS = [
|
|
20
|
+
{
|
|
21
|
+
// The Secret ID vs Secret Value mistake. First row of docs/troubleshooting.md.
|
|
22
|
+
test: /AADSTS7000215/i,
|
|
23
|
+
hint:
|
|
24
|
+
'Invalid client secret. The usual cause is pasting the **Secret ID** (a UUID like `a1b2c3d4-…`) instead of the **Secret Value** (a longer string with mixed case and symbols). ' +
|
|
25
|
+
'In Azure → App registrations → your app → Certificates & secrets → Client secrets, copy the *Value* column into `OUTLOOK_CLIENT_SECRET`. ' +
|
|
26
|
+
'The Secret Value is shown only once, when the secret is created — if you have navigated away it can no longer be read, so create a new secret. ' +
|
|
27
|
+
'An expired secret produces this same error, so check the Expires column too. ' +
|
|
28
|
+
'See docs/guides/azure-setup.md#4-create-a-client-secret.',
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
test: /AADSTS9002331|personal.*account/i,
|
|
32
|
+
hint: 'This app registration appears to accept personal Microsoft accounts only. Set `OUTLOOK_AUTH_AUDIENCE=consumers` (or the correct tenant GUID) in your MCP env and retry.',
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
test: /AADSTS7000218|unauthorized_client|invalid_client/i,
|
|
36
|
+
// AADSTS7000215 is also delivered as error=invalid_client, but it means the
|
|
37
|
+
// secret is wrong, not that public client flows are disabled. Suppress the
|
|
38
|
+
// generic hint so the specific one above is not drowned out.
|
|
39
|
+
notWhen: /AADSTS7000215/i,
|
|
40
|
+
hint: "Enable 'Allow public client flows' under Azure → App registration → Authentication → Advanced settings, and add the `nativeclient` redirect URI (Mobile and desktop applications platform).",
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
test: /AADSTS700016/i,
|
|
44
|
+
hint: 'The application was not found in this directory. Check `OUTLOOK_CLIENT_ID` matches the Application (client) ID in Azure, and that `OUTLOOK_AUTH_AUDIENCE` targets the right tenant.',
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
test: /invalid_grant|AADSTS700082|AADSTS50173/i,
|
|
48
|
+
hint: 'The refresh token is expired or has been revoked (refresh tokens last ~90 days). Re-authenticate with the `auth` tool: `action=authenticate`, then `action=device-code-complete`.',
|
|
49
|
+
},
|
|
50
|
+
];
|
|
51
|
+
|
|
52
|
+
/** Normalise an Error or string into a searchable message. */
|
|
53
|
+
function toMessage(input) {
|
|
54
|
+
if (input === null || input === undefined) return '';
|
|
55
|
+
if (input instanceof Error) return input.message || String(input);
|
|
56
|
+
if (typeof input === 'object' && input.message) return String(input.message);
|
|
57
|
+
return String(input);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Return remediation hints for a Microsoft auth error.
|
|
62
|
+
* @param {Error|string|null|undefined} input - Error or error_description
|
|
63
|
+
* @returns {string[]} - Zero or more hints; empty when the error is unknown
|
|
64
|
+
*/
|
|
65
|
+
function getAuthErrorHints(input) {
|
|
66
|
+
const msg = toMessage(input);
|
|
67
|
+
if (!msg) return [];
|
|
68
|
+
return AUTH_ERROR_HINTS.filter(
|
|
69
|
+
(e) => e.test.test(msg) && !(e.notWhen && e.notWhen.test(msg))
|
|
70
|
+
).map((e) => e.hint);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Append any known remediation hints to an auth error message.
|
|
75
|
+
* Returns the message unchanged when nothing is known, so callers can use this
|
|
76
|
+
* unconditionally without polluting unrelated errors.
|
|
77
|
+
* @param {Error|string} input
|
|
78
|
+
* @returns {string}
|
|
79
|
+
*/
|
|
80
|
+
function describeAuthError(input) {
|
|
81
|
+
const msg = toMessage(input);
|
|
82
|
+
const hints = getAuthErrorHints(msg);
|
|
83
|
+
if (!hints.length) return msg;
|
|
84
|
+
return [msg, '', 'Suggested fixes:', ...hints.map((h) => `- ${h}`)].join(
|
|
85
|
+
'\n'
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
module.exports = { getAuthErrorHints, describeAuthError, AUTH_ERROR_HINTS };
|
package/auth/oauth-server.js
CHANGED
|
@@ -38,7 +38,8 @@ const templates = {
|
|
|
38
38
|
<body style="font-family: Arial, sans-serif; text-align: center; margin-top: 50px;">
|
|
39
39
|
<h1 style="color: #e74c3c;">❌ Token Exchange Failed</h1>
|
|
40
40
|
<p>Failed to exchange authorization code for access token.</p>
|
|
41
|
-
<p><strong>Error:</strong
|
|
41
|
+
<p><strong>Error:</strong></p>
|
|
42
|
+
<pre style="white-space: pre-wrap; text-align: left; display: inline-block; max-width: 40em; font-family: inherit;">${escapeHtml(error instanceof Error ? error.message : String(error))}</pre>
|
|
42
43
|
<p>You can close this window and try again.</p>
|
|
43
44
|
</body>
|
|
44
45
|
</html>`,
|
|
@@ -227,7 +228,7 @@ function setupOAuthRoutes(
|
|
|
227
228
|
module.exports = {
|
|
228
229
|
setupOAuthRoutes,
|
|
229
230
|
createAuthConfig,
|
|
230
|
-
//
|
|
231
|
-
|
|
231
|
+
// Exported so the rendered error pages can be asserted on directly (#69).
|
|
232
|
+
templates,
|
|
232
233
|
};
|
|
233
234
|
// Adding a newline at the end of the file as requested by Gemini Code Assist
|
package/auth/token-storage.js
CHANGED
|
@@ -3,6 +3,7 @@ const fsSync = require('fs');
|
|
|
3
3
|
const path = require('path');
|
|
4
4
|
const https = require('https');
|
|
5
5
|
const querystring = require('querystring');
|
|
6
|
+
const { describeAuthError } = require('./auth-errors');
|
|
6
7
|
|
|
7
8
|
class TokenStorage {
|
|
8
9
|
constructor(config) {
|
|
@@ -131,16 +132,22 @@ class TokenStorage {
|
|
|
131
132
|
return await this.refreshAccessToken();
|
|
132
133
|
} catch (refreshError) {
|
|
133
134
|
console.error('Failed to refresh access token:', refreshError);
|
|
134
|
-
|
|
135
|
-
|
|
135
|
+
// Drop the in-memory tokens so callers re-authenticate. The save
|
|
136
|
+
// below is intentionally a no-op — `_saveTokensToFile` returns early
|
|
137
|
+
// when `tokens` is null — which is the behaviour we want: a transient
|
|
138
|
+
// network failure must not erase a still-valid refresh token from
|
|
139
|
+
// disk. The next process start reloads it and retries. (#72)
|
|
140
|
+
this.tokens = null;
|
|
141
|
+
await this._saveTokensToFile();
|
|
136
142
|
return null;
|
|
137
143
|
}
|
|
138
144
|
} else {
|
|
139
145
|
console.warn(
|
|
140
146
|
'No refresh token available. Cannot refresh access token.'
|
|
141
147
|
);
|
|
142
|
-
|
|
143
|
-
|
|
148
|
+
// Same as above: clears memory, leaves the file alone. (#72)
|
|
149
|
+
this.tokens = null;
|
|
150
|
+
await this._saveTokensToFile();
|
|
144
151
|
return null;
|
|
145
152
|
}
|
|
146
153
|
}
|
|
@@ -226,8 +233,10 @@ class TokenStorage {
|
|
|
226
233
|
console.error('Error refreshing token:', responseBody);
|
|
227
234
|
reject(
|
|
228
235
|
new Error(
|
|
229
|
-
|
|
230
|
-
|
|
236
|
+
describeAuthError(
|
|
237
|
+
responseBody.error_description ||
|
|
238
|
+
`Token refresh failed with status ${res.statusCode}`
|
|
239
|
+
)
|
|
231
240
|
)
|
|
232
241
|
);
|
|
233
242
|
}
|
|
@@ -320,8 +329,10 @@ class TokenStorage {
|
|
|
320
329
|
);
|
|
321
330
|
reject(
|
|
322
331
|
new Error(
|
|
323
|
-
|
|
324
|
-
|
|
332
|
+
describeAuthError(
|
|
333
|
+
responseBody.error_description ||
|
|
334
|
+
`Token exchange failed with status ${res.statusCode}`
|
|
335
|
+
)
|
|
325
336
|
)
|
|
326
337
|
);
|
|
327
338
|
}
|
package/auth/tools.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* Authentication-related tools for the Outlook Assistant server
|
|
3
3
|
*/
|
|
4
4
|
const config = require('../config');
|
|
5
|
+
const { getAuthErrorHints } = require('./auth-errors');
|
|
5
6
|
const fs = require('fs');
|
|
6
7
|
const path = require('path');
|
|
7
8
|
const tokenManager = require('./token-manager');
|
|
@@ -264,20 +265,10 @@ async function handleDeviceCodeAuth() {
|
|
|
264
265
|
function buildDeviceCodeErrorResponse(error) {
|
|
265
266
|
const msg = (error && error.message) || String(error);
|
|
266
267
|
const code = error && error.code;
|
|
267
|
-
|
|
268
|
+
// Shared AADSTS hint table (#69) so this path and the token-endpoint paths in
|
|
269
|
+
// token-storage.js cannot drift apart.
|
|
270
|
+
const hints = getAuthErrorHints(msg);
|
|
268
271
|
|
|
269
|
-
if (/AADSTS9002331/i.test(msg) || /personal.*account/i.test(msg)) {
|
|
270
|
-
hints.push(
|
|
271
|
-
'This app registration appears to accept personal Microsoft accounts only. Set `OUTLOOK_AUTH_AUDIENCE=consumers` (or the correct tenant GUID) in your MCP env and retry.'
|
|
272
|
-
);
|
|
273
|
-
}
|
|
274
|
-
if (
|
|
275
|
-
/invalid_client|unauthorized_client|AADSTS7000218|AADSTS700016/i.test(msg)
|
|
276
|
-
) {
|
|
277
|
-
hints.push(
|
|
278
|
-
"Enable 'Allow public client flows' under Azure → App registration → Authentication → Advanced settings, and add the `nativeclient` redirect URI (Mobile and desktop applications platform)."
|
|
279
|
-
);
|
|
280
|
-
}
|
|
281
272
|
if (
|
|
282
273
|
code === 'ENOTFOUND' ||
|
|
283
274
|
code === 'ETIMEDOUT' ||
|
package/email/export.js
CHANGED
|
@@ -78,26 +78,32 @@ async function handleExportEmail(args) {
|
|
|
78
78
|
};
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
-
// Generate filename based on email metadata
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
81
|
+
// Generate filename based on email metadata. The time matters: a
|
|
82
|
+
// date-only name collides across any same-day reply chain, and the old
|
|
83
|
+
// behaviour was to overwrite silently.
|
|
84
|
+
const timestamp = filenameTimestamp(email.receivedDateTime);
|
|
85
85
|
const safeSubject = sanitizeFilename(email.subject || 'no-subject');
|
|
86
86
|
const extension = getExtension(format);
|
|
87
|
-
const
|
|
87
|
+
const defaultBase = `${timestamp}_${safeSubject}`;
|
|
88
|
+
|
|
89
|
+
// Paths claimed while writing this message (main file + attachments).
|
|
90
|
+
const claimedPaths = new Set();
|
|
88
91
|
|
|
89
92
|
// Determine final save path
|
|
90
93
|
let finalPath;
|
|
91
|
-
if (
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
94
|
+
if (
|
|
95
|
+
savePath &&
|
|
96
|
+
!(fs.existsSync(savePath) && fs.statSync(savePath).isDirectory())
|
|
97
|
+
) {
|
|
98
|
+
// An explicit file path is the caller's to control — honour it exactly,
|
|
99
|
+
// including overwriting, since that is what an explicit path means.
|
|
100
|
+
finalPath = savePath;
|
|
98
101
|
} else {
|
|
99
|
-
//
|
|
100
|
-
|
|
102
|
+
// A directory (or the default temp dir) means we choose the name, so
|
|
103
|
+
// never clobber a file that is already there.
|
|
104
|
+
const dir = savePath || os.tmpdir();
|
|
105
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
106
|
+
finalPath = claimUniquePath(dir, defaultBase, extension, claimedPaths);
|
|
101
107
|
}
|
|
102
108
|
|
|
103
109
|
// Export based on format
|
|
@@ -152,7 +158,8 @@ async function handleExportEmail(args) {
|
|
|
152
158
|
attachmentsSaved = await saveAttachments(
|
|
153
159
|
accessToken,
|
|
154
160
|
emailId,
|
|
155
|
-
path.dirname(finalPath)
|
|
161
|
+
path.dirname(finalPath),
|
|
162
|
+
claimedPaths
|
|
156
163
|
);
|
|
157
164
|
}
|
|
158
165
|
|
|
@@ -372,6 +379,21 @@ async function handleBatchExportEmails(args) {
|
|
|
372
379
|
);
|
|
373
380
|
resultText += `| Total Size | ${(totalBytes / 1024).toFixed(1)} KB |\n`;
|
|
374
381
|
|
|
382
|
+
// Requested id -> written path, so a caller can reconcile without
|
|
383
|
+
// listing the directory. A batch that silently lost messages to
|
|
384
|
+
// filename collisions still reported "Successful N / Failed 0", and the
|
|
385
|
+
// loss was only ever caught by counting distinct Message-IDs by hand.
|
|
386
|
+
const manifest = successful.map((r) => ({
|
|
387
|
+
emailId: r.emailId,
|
|
388
|
+
filePath: r.filePath,
|
|
389
|
+
}));
|
|
390
|
+
const disambiguated = manifest.filter((entry) =>
|
|
391
|
+
/_\d+\.[^.]+$/.test(entry.filePath)
|
|
392
|
+
);
|
|
393
|
+
if (disambiguated.length > 0) {
|
|
394
|
+
resultText += `\n> ${disambiguated.length} file name(s) were disambiguated with a numeric suffix — messages sharing a timestamp and subject, or names already present in the output directory. Nothing was overwritten.\n`;
|
|
395
|
+
}
|
|
396
|
+
|
|
375
397
|
if (failed.length > 0) {
|
|
376
398
|
resultText += `\n### Failed Exports\n\n`;
|
|
377
399
|
for (const f of failed.slice(0, 10)) {
|
|
@@ -396,6 +418,7 @@ async function handleBatchExportEmails(args) {
|
|
|
396
418
|
successful: successful.length,
|
|
397
419
|
failed: failed.length,
|
|
398
420
|
totalBytes: totalBytes,
|
|
421
|
+
manifest,
|
|
399
422
|
},
|
|
400
423
|
};
|
|
401
424
|
} catch (error) {
|
|
@@ -488,6 +511,8 @@ async function exportWithConcurrency(
|
|
|
488
511
|
) {
|
|
489
512
|
const results = [];
|
|
490
513
|
const inProgress = new Set();
|
|
514
|
+
// Shared across the batch so concurrent exports cannot claim the same path.
|
|
515
|
+
const claimedPaths = new Set();
|
|
491
516
|
let index = 0;
|
|
492
517
|
|
|
493
518
|
while (index < emailIds.length || inProgress.size > 0) {
|
|
@@ -499,7 +524,8 @@ async function exportWithConcurrency(
|
|
|
499
524
|
emailId,
|
|
500
525
|
format,
|
|
501
526
|
outputDir,
|
|
502
|
-
includeAttachments
|
|
527
|
+
includeAttachments,
|
|
528
|
+
claimedPaths
|
|
503
529
|
).then((result) => {
|
|
504
530
|
inProgress.delete(promise);
|
|
505
531
|
results.push(result);
|
|
@@ -526,7 +552,8 @@ async function exportSingleForBatch(
|
|
|
526
552
|
emailId,
|
|
527
553
|
format,
|
|
528
554
|
outputDir,
|
|
529
|
-
includeAttachments
|
|
555
|
+
includeAttachments,
|
|
556
|
+
claimedPaths
|
|
530
557
|
) {
|
|
531
558
|
try {
|
|
532
559
|
const selectFields = getEmailFields('export');
|
|
@@ -538,13 +565,15 @@ async function exportSingleForBatch(
|
|
|
538
565
|
{ $select: selectFields }
|
|
539
566
|
);
|
|
540
567
|
|
|
541
|
-
const timestamp =
|
|
542
|
-
.toISOString()
|
|
543
|
-
.slice(0, 10);
|
|
568
|
+
const timestamp = filenameTimestamp(email.receivedDateTime);
|
|
544
569
|
const safeSubject = sanitizeFilename(email.subject || 'no-subject');
|
|
545
570
|
const extension = getExtension(format);
|
|
546
|
-
const
|
|
547
|
-
|
|
571
|
+
const filePath = claimUniquePath(
|
|
572
|
+
outputDir,
|
|
573
|
+
`${timestamp}_${safeSubject}`,
|
|
574
|
+
extension,
|
|
575
|
+
claimedPaths
|
|
576
|
+
);
|
|
548
577
|
|
|
549
578
|
let content;
|
|
550
579
|
if (format === EXPORT_FORMATS.MIME || format === EXPORT_FORMATS.EML) {
|
|
@@ -562,7 +591,12 @@ async function exportSingleForBatch(
|
|
|
562
591
|
// Handle attachments if requested
|
|
563
592
|
let attachmentCount = 0;
|
|
564
593
|
if (includeAttachments && email.hasAttachments) {
|
|
565
|
-
const saved = await saveAttachments(
|
|
594
|
+
const saved = await saveAttachments(
|
|
595
|
+
accessToken,
|
|
596
|
+
emailId,
|
|
597
|
+
outputDir,
|
|
598
|
+
claimedPaths
|
|
599
|
+
);
|
|
566
600
|
attachmentCount = saved.length;
|
|
567
601
|
}
|
|
568
602
|
|
|
@@ -585,7 +619,7 @@ async function exportSingleForBatch(
|
|
|
585
619
|
/**
|
|
586
620
|
* Save email attachments to directory
|
|
587
621
|
*/
|
|
588
|
-
async function saveAttachments(accessToken, emailId, outputDir) {
|
|
622
|
+
async function saveAttachments(accessToken, emailId, outputDir, claimedPaths) {
|
|
589
623
|
const saved = [];
|
|
590
624
|
|
|
591
625
|
try {
|
|
@@ -602,9 +636,16 @@ async function saveAttachments(accessToken, emailId, outputDir) {
|
|
|
602
636
|
for (const att of response.value) {
|
|
603
637
|
if (att.contentBytes) {
|
|
604
638
|
const safeFilename = sanitizeFilename(att.name || 'attachment');
|
|
605
|
-
|
|
639
|
+
// `emailId.substring(0, 8)` was not a disambiguator: Graph message ids
|
|
640
|
+
// within one mailbox share a long common prefix, so every message's
|
|
641
|
+
// `invoice.pdf` resolved to the same path and all but the last were
|
|
642
|
+
// overwritten. Claim a unique path instead.
|
|
643
|
+
const { base, extension } = splitExtension(safeFilename);
|
|
644
|
+
const filePath = claimUniquePath(
|
|
606
645
|
outputDir,
|
|
607
|
-
`${emailId.substring(0, 8)}_${
|
|
646
|
+
`${emailId.substring(0, 8)}_${base}`,
|
|
647
|
+
extension,
|
|
648
|
+
claimedPaths || new Set()
|
|
608
649
|
);
|
|
609
650
|
const buffer = Buffer.from(att.contentBytes, 'base64');
|
|
610
651
|
fs.writeFileSync(filePath, buffer);
|
|
@@ -622,6 +663,63 @@ async function saveAttachments(accessToken, emailId, outputDir) {
|
|
|
622
663
|
return saved;
|
|
623
664
|
}
|
|
624
665
|
|
|
666
|
+
/**
|
|
667
|
+
* Format a message timestamp for use in a filename.
|
|
668
|
+
*
|
|
669
|
+
* Date-only was the collision: a same-day reply chain is extremely common, and
|
|
670
|
+
* every message in it normalises to the same `<date>_<subject>` name. Keeping
|
|
671
|
+
* the time disambiguates the realistic case. Mirrors the sanitisation #82
|
|
672
|
+
* applied to the aggregated CSV name.
|
|
673
|
+
*
|
|
674
|
+
* @param {string} isoDateTime - Message receivedDateTime
|
|
675
|
+
* @returns {string} - e.g. `2023-06-15T01-26-00`
|
|
676
|
+
*/
|
|
677
|
+
function filenameTimestamp(isoDateTime) {
|
|
678
|
+
const parsed = new Date(isoDateTime);
|
|
679
|
+
if (Number.isNaN(parsed.getTime())) return 'undated';
|
|
680
|
+
return parsed.toISOString().slice(0, 19).replace(/[:.]/g, '-');
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
/**
|
|
684
|
+
* Claim a not-yet-used path in `outputDir`, appending `_2`, `_3`, ... until the
|
|
685
|
+
* name is free both on disk and among the paths already claimed in this batch.
|
|
686
|
+
*
|
|
687
|
+
* Silent overwrite is the dangerous part of the collision defect: the exporter
|
|
688
|
+
* reported `Successful N / Failed 0` while messages vanished. Never overwrite —
|
|
689
|
+
* disambiguate instead, and let the caller reconcile via the manifest.
|
|
690
|
+
*
|
|
691
|
+
* The claim is synchronous, so it is atomic with respect to the event loop and
|
|
692
|
+
* safe under the batch exporter's 4-way concurrency even though the write
|
|
693
|
+
* itself happens after an await.
|
|
694
|
+
*
|
|
695
|
+
* @param {string} outputDir - Target directory
|
|
696
|
+
* @param {string} base - Filename without extension
|
|
697
|
+
* @param {string} extension - Extension without a leading dot
|
|
698
|
+
* @param {Set<string>} claimed - Paths already claimed by this batch
|
|
699
|
+
* @returns {string} - An unused absolute path, now claimed
|
|
700
|
+
*/
|
|
701
|
+
function claimUniquePath(outputDir, base, extension, claimed) {
|
|
702
|
+
let candidate = path.join(outputDir, `${base}.${extension}`);
|
|
703
|
+
let suffix = 1;
|
|
704
|
+
while (claimed.has(candidate) || fs.existsSync(candidate)) {
|
|
705
|
+
suffix += 1;
|
|
706
|
+
candidate = path.join(outputDir, `${base}_${suffix}.${extension}`);
|
|
707
|
+
}
|
|
708
|
+
claimed.add(candidate);
|
|
709
|
+
return candidate;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
/**
|
|
713
|
+
* Split a filename into base and extension for collision-safe claiming.
|
|
714
|
+
* @param {string} name - Sanitised filename, possibly with an extension
|
|
715
|
+
* @returns {{base: string, extension: string}}
|
|
716
|
+
*/
|
|
717
|
+
function splitExtension(name) {
|
|
718
|
+
const dot = name.lastIndexOf('.');
|
|
719
|
+
if (dot <= 0) return { base: name, extension: '' };
|
|
720
|
+
return { base: name.slice(0, dot), extension: name.slice(dot + 1) };
|
|
721
|
+
}
|
|
722
|
+
|
|
625
723
|
/**
|
|
626
724
|
* Sanitize filename for filesystem
|
|
627
725
|
*/
|
package/email/index.js
CHANGED
|
@@ -68,12 +68,13 @@ const emailTools = [
|
|
|
68
68
|
// Search/list params
|
|
69
69
|
query: {
|
|
70
70
|
type: 'string',
|
|
71
|
-
description:
|
|
71
|
+
description:
|
|
72
|
+
'Search query text. Omit for list mode. On personal Outlook.com accounts Graph `$search` is unavailable, so this falls back to a subject substring match (all words must appear in the subject) — precise, but it does NOT search message bodies. Use `searchExpression` when you need body content.',
|
|
72
73
|
},
|
|
73
74
|
searchExpression: {
|
|
74
75
|
type: 'string',
|
|
75
76
|
description:
|
|
76
|
-
'Raw Microsoft Graph `$search` expression for advanced server-side search, e.g. `subject:"invoice"`, `from:github.com`, or `foo OR bar`. Quote your own phrases; a single bare token is auto-quoted. Pair with `searchAllFolders: true` for cross-folder search. Bypasses other search params. NOTE: personal Outlook.com accounts reject field-scoped `$search` outright; since v3.10.0 recognised `from:`/`to:`/`subject:` expressions are translated into the closest equivalent OData filters and retried automatically (a `subject:` term becomes a substring match, so it is close but not identical) (reported as strategy `raw-kql-translated`). Expressions that cannot be translated exactly — free text, `AND`/`OR`, unknown prefixes — are not retried, so use `query` for those there.',
|
|
77
|
+
'Raw Microsoft Graph `$search` expression for advanced server-side search, e.g. `subject:"invoice"`, `from:github.com`, or `foo OR bar`. Quote your own phrases; a single bare token is auto-quoted. Pair with `searchAllFolders: true` for cross-folder search. Bypasses other search params. NOTE: personal Outlook.com accounts reject field-scoped `$search` outright; since v3.10.0 recognised `from:`/`to:`/`subject:` expressions are translated into the closest equivalent OData filters and retried automatically (a `subject:` term becomes a substring match, so it is close but not identical) (reported as strategy `raw-kql-translated`). Expressions that cannot be translated exactly — free text, `AND`/`OR`, unknown prefixes — are not retried, so use `query` for those there. RELEVANCE, NOT RECENCY: an untranslated expression is answered by Graph `$search` over the whole message including the body, ranked by relevance and not sorted by date, so top hits can look unrelated to a caller expecting a subject match. `query` is the more predictable choice for a term you expect in a subject line; `searchExpression` is the one that reaches body text.',
|
|
77
78
|
},
|
|
78
79
|
kqlQuery: {
|
|
79
80
|
type: 'string',
|
|
@@ -90,7 +91,8 @@ const emailTools = [
|
|
|
90
91
|
},
|
|
91
92
|
to: {
|
|
92
93
|
type: 'string',
|
|
93
|
-
description:
|
|
94
|
+
description:
|
|
95
|
+
'Filter by recipient email/name. Personal Outlook.com accounts reject the server-side recipient filter, in which case this is matched locally over the 500 most recent messages only (raise with `OUTLOOK_SEARCH_SCAN_LIMIT`). On a large archive, pair `to` with `receivedAfter`/`receivedBefore` to reach older mail; the response says so when the scan was truncated.',
|
|
94
96
|
},
|
|
95
97
|
subject: {
|
|
96
98
|
type: 'string',
|
package/email/search.js
CHANGED
|
@@ -79,9 +79,19 @@ async function handleSearchEmails(args) {
|
|
|
79
79
|
// this, a `from`+`to` search would drop every row at default verbosity and
|
|
80
80
|
// return "No emails found" — making `outputVerbosity`, a presentation
|
|
81
81
|
// parameter, decide which messages are found.
|
|
82
|
+
//
|
|
83
|
+
// A SINGLE term needs the richer preset too when it is `to` or `query`. The
|
|
84
|
+
// date/boolean rung applies no search term server-side, so it narrows by
|
|
85
|
+
// every supplied term locally — and `filterToClientSide` reads
|
|
86
|
+
// `toRecipients` while `filterQueryClientSide` reads `bodyPreview`, neither
|
|
87
|
+
// of which the `list` preset requests. Left lean, those matchers see
|
|
88
|
+
// `undefined` on every row and drop the entire result set.
|
|
82
89
|
const searchTermCount = [query, from, to, subject].filter(Boolean).length;
|
|
90
|
+
const needsMatcherFields = Boolean(to) || Boolean(query);
|
|
83
91
|
const selectFields = getEmailFields(
|
|
84
|
-
verbosity === VERBOSITY.FULL || searchTermCount > 1
|
|
92
|
+
verbosity === VERBOSITY.FULL || searchTermCount > 1 || needsMatcherFields
|
|
93
|
+
? 'search'
|
|
94
|
+
: 'list'
|
|
85
95
|
);
|
|
86
96
|
|
|
87
97
|
try {
|
|
@@ -1300,9 +1310,20 @@ function addBooleanFilters(params, filterTerms) {
|
|
|
1300
1310
|
}
|
|
1301
1311
|
}
|
|
1302
1312
|
|
|
1303
|
-
//
|
|
1313
|
+
// AND onto any $filter the caller already built — never replace it.
|
|
1314
|
+
//
|
|
1315
|
+
// The single-term rung sets `$filter` from the search term (e.g.
|
|
1316
|
+
// `toRecipients/any(...)`) and then calls this to add the date/boolean
|
|
1317
|
+
// window. Assigning here silently dropped that term, so a `to` + date-window
|
|
1318
|
+
// search issued a DATE-ONLY request and returned the whole window labelled
|
|
1319
|
+
// `single-term-to` with `appliedTerms: ['to']` — a superset presented as a
|
|
1320
|
+
// filtered result. Affected `from`, `to`, `subject` and `query` alike.
|
|
1321
|
+
//
|
|
1322
|
+
// Every condition either side is a conjunct, so a flat ' and ' join is
|
|
1323
|
+
// sound; there is no top-level `or` that would need parenthesising.
|
|
1304
1324
|
if (filterConditions.length > 0) {
|
|
1305
|
-
|
|
1325
|
+
const added = filterConditions.join(' and ');
|
|
1326
|
+
params.$filter = params.$filter ? `${params.$filter} and ${added}` : added;
|
|
1306
1327
|
}
|
|
1307
1328
|
}
|
|
1308
1329
|
|
|
@@ -1506,6 +1527,17 @@ function formatSearchResults(response, folder, verbosity, searchAllFolders) {
|
|
|
1506
1527
|
} else {
|
|
1507
1528
|
searchNote = `\n\n_Search strategy: ${strategy}_`;
|
|
1508
1529
|
}
|
|
1530
|
+
|
|
1531
|
+
// A local scan that filled its budget did not see the whole mailbox, so
|
|
1532
|
+
// these results are a bounded sample rather than the complete set. #231
|
|
1533
|
+
// says so only when the search returns nothing; a truncated scan that
|
|
1534
|
+
// DID match is exactly as incomplete and reads as authoritative. On a
|
|
1535
|
+
// large archive that silently caps historical searches.
|
|
1536
|
+
if (response._searchInfo.truncated) {
|
|
1537
|
+
const scanned = response._searchInfo.candidatesScanned;
|
|
1538
|
+
const limit = response._searchInfo.scanLimit;
|
|
1539
|
+
searchNote += `\n\n> **Partial coverage**: matched locally within the ${scanned} most recent messages, hitting the ${limit} scan limit — older matches were not seen. Narrow with \`receivedAfter\`/\`receivedBefore\` to search further back.`;
|
|
1540
|
+
}
|
|
1509
1541
|
}
|
|
1510
1542
|
|
|
1511
1543
|
// Format results using shared formatter
|
package/index.js
CHANGED
|
@@ -5,6 +5,65 @@
|
|
|
5
5
|
* A Model Context Protocol server that provides access to
|
|
6
6
|
* Microsoft Outlook through the Microsoft Graph API.
|
|
7
7
|
*/
|
|
8
|
+
// CLI flag handling (#68).
|
|
9
|
+
//
|
|
10
|
+
// Deliberately the first thing that runs: it must complete before the SDK
|
|
11
|
+
// imports, before the auth modules load, and before the startup banner below
|
|
12
|
+
// writes to stderr — otherwise `--version` output is buried in server noise and
|
|
13
|
+
// the process never exits (the SIGTERM handler below keeps it alive).
|
|
14
|
+
//
|
|
15
|
+
// `config.js` is required lazily here so this costs nothing on the normal
|
|
16
|
+
// server path; it is the single source of truth for the version, which it
|
|
17
|
+
// reads from package.json.
|
|
18
|
+
const cliArgs = process.argv.slice(2);
|
|
19
|
+
if (cliArgs.length > 0) {
|
|
20
|
+
const HELP_TEXT = `outlook-assistant — MCP server for Microsoft Outlook via the Microsoft Graph API.
|
|
21
|
+
|
|
22
|
+
Usage:
|
|
23
|
+
outlook-assistant [options]
|
|
24
|
+
|
|
25
|
+
Options:
|
|
26
|
+
-v, --version Print the version and exit
|
|
27
|
+
-h, --help Show this help and exit
|
|
28
|
+
|
|
29
|
+
With no options the server starts and speaks the Model Context Protocol over
|
|
30
|
+
stdio. It is normally launched by an MCP client (Claude Desktop, Claude Code)
|
|
31
|
+
rather than run by hand — started from a terminal it will simply wait on stdin.
|
|
32
|
+
|
|
33
|
+
Key environment variables:
|
|
34
|
+
OUTLOOK_CLIENT_ID Azure app registration client ID
|
|
35
|
+
OUTLOOK_CLIENT_SECRET Client secret VALUE (not the Secret ID)
|
|
36
|
+
OUTLOOK_AUTH_METHOD device-code (default) | browser
|
|
37
|
+
OUTLOOK_AUTH_AUDIENCE common | consumers | organizations | <tenant-guid>
|
|
38
|
+
OUTLOOK_MAX_EMAILS_PER_SESSION Cap on sends per session
|
|
39
|
+
OUTLOOK_ALLOWED_RECIPIENTS Comma-separated recipient allowlist
|
|
40
|
+
USE_TEST_MODE Set to "true" to run against mock data
|
|
41
|
+
|
|
42
|
+
Documentation: https://github.com/littlebearapps/outlook-assistant`;
|
|
43
|
+
|
|
44
|
+
const KNOWN_FLAGS = new Set(['--version', '-v', '--help', '-h']);
|
|
45
|
+
|
|
46
|
+
// Validate every argument before acting on any of them. Checking for a
|
|
47
|
+
// recognised flag first would let `--version --nope` succeed and silently
|
|
48
|
+
// swallow the typo — an unrecognised argument is a user error regardless of
|
|
49
|
+
// what else is on the command line.
|
|
50
|
+
const unknown = cliArgs.find((arg) => !KNOWN_FLAGS.has(arg));
|
|
51
|
+
if (unknown) {
|
|
52
|
+
console.error(
|
|
53
|
+
`outlook-assistant: unrecognised argument '${unknown}'\nRun 'outlook-assistant --help' for usage.`
|
|
54
|
+
);
|
|
55
|
+
process.exit(1);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (cliArgs.includes('--version') || cliArgs.includes('-v')) {
|
|
59
|
+
console.log(require('./config').SERVER_VERSION);
|
|
60
|
+
process.exit(0);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
console.log(HELP_TEXT);
|
|
64
|
+
process.exit(0);
|
|
65
|
+
}
|
|
66
|
+
|
|
8
67
|
const { Server } = require('@modelcontextprotocol/sdk/server/index.js');
|
|
9
68
|
const {
|
|
10
69
|
StdioServerTransport,
|
package/llms-install.md
CHANGED
|
@@ -83,7 +83,7 @@ After authentication, test with:
|
|
|
83
83
|
|
|
84
84
|
| Problem | Solution |
|
|
85
85
|
|---------|----------|
|
|
86
|
-
| "Invalid client secret" (AADSTS7000215) | Use the secret **Value**, not the Secret ID |
|
|
86
|
+
| "Invalid client secret" (AADSTS7000215) | Use the secret **Value**, not the Secret ID. Also check it hasn't expired. v3.11.0+ appends an explanation to Microsoft's raw error |
|
|
87
87
|
| Auth URL doesn't work | Start the auth server first |
|
|
88
88
|
| "EADDRINUSE :3333" | Run `npx kill-port 3333` then restart auth server |
|
|
89
89
|
| Empty API responses | Run `auth` tool with `action=status` to check token |
|
package/llms.txt
CHANGED
|
@@ -79,6 +79,6 @@ Requires an Azure app registration with Microsoft Graph delegated permissions. S
|
|
|
79
79
|
- [FAQ](https://raw.githubusercontent.com/littlebearapps/outlook-assistant/main/docs/faq/faq.md): Frequently asked questions — install, accounts, permissions, tokens, send safety, updates, uninstall (also at <https://littlebearapps.com/help/outlook-assistant/faq/>)
|
|
80
80
|
- [CLAUDE.md](https://raw.githubusercontent.com/littlebearapps/outlook-assistant/main/CLAUDE.md): Quick reference for development
|
|
81
81
|
- [CONTRIBUTING](https://raw.githubusercontent.com/littlebearapps/outlook-assistant/main/CONTRIBUTING.md): Contribution guidelines
|
|
82
|
-
- [CHANGELOG](https://raw.githubusercontent.com/littlebearapps/outlook-assistant/main/CHANGELOG.md): Version history (current: v3.
|
|
83
|
-
- [ROADMAP](https://raw.githubusercontent.com/littlebearapps/outlook-assistant/main/ROADMAP.md): Active milestones (v3.
|
|
82
|
+
- [CHANGELOG](https://raw.githubusercontent.com/littlebearapps/outlook-assistant/main/CHANGELOG.md): Version history (current: v3.11.1 — search and export correctness: a search term combined with a date or boolean filter was silently overwritten, so the request carried only the date window and the whole window came back reported as a filtered result; batch export named files `<date>_<subject>`, so a same-day reply chain overwrote itself on disk while the summary reported `Failed 0` — filenames now carry the time, collisions get a numeric suffix instead of clobbering, and a manifest maps each requested ID to the file actually written; a truncated local scan is now disclosed when it matched, not only when it returned nothing; `query` versus `searchExpression` and the 500-message `to` scan cap documented. Preceded by v3.11.0 — fixes & polish: `--version`/`--help` CLI flags (#68), `AADSTS7000215` explaining the Secret ID vs Secret Value mistake via one shared AADSTS hint table (#69), token-refresh round trip covered end to end (#72), and all 17 development-dependency advisories cleared; and v3.10.0 — search correctness: field-scoped `searchExpression` translated to OData filters and retried on personal accounts (#217), two-filter searches no longer returning the single-filter superset (#229), `from`/`to` filter values OData-escaped (#230), no-results guidance derived from what was actually attempted (#231))
|
|
83
|
+
- [ROADMAP](https://raw.githubusercontent.com/littlebearapps/outlook-assistant/main/ROADMAP.md): Active milestones (v3.11.2 tool description audit, v3.8.x carry-over, v3.12.0+ new Graph APIs)
|
|
84
84
|
- [SECURITY](https://raw.githubusercontent.com/littlebearapps/outlook-assistant/main/SECURITY.md): Security policy, token handling, and MCP safety controls
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@littlebearapps/outlook-assistant",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.11.1",
|
|
4
4
|
"mcpName": "io.github.littlebearapps/outlook-assistant",
|
|
5
5
|
"description": "Outlook Assistant — MCP server with 22 tools for email, calendar, contacts, and settings via Microsoft Graph API",
|
|
6
6
|
"main": "index.js",
|