@littlebearapps/outlook-assistant 3.8.2 → 3.9.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/README.md +5 -5
- package/advanced/index.js +3 -1
- package/calendar/index.js +1 -1
- package/calendar/list.js +114 -22
- package/categories/index.js +2 -1
- package/contacts/index.js +3 -1
- package/email/folder-utils.js +13 -13
- package/email/index.js +20 -7
- package/email/search.js +252 -151
- package/folder/create.js +39 -27
- package/folder/delete.js +26 -36
- package/folder/index.js +19 -7
- package/folder/list.js +51 -66
- package/folder/move.js +17 -17
- package/folder/resolve.js +315 -0
- package/folder/stats.js +12 -75
- package/llms.txt +5 -5
- package/package.json +6 -2
- package/request-handler.js +144 -0
- package/utils/graph-api.js +11 -1
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared, path-aware, ambiguity-aware mail-folder resolver. (#216)
|
|
3
|
+
*
|
|
4
|
+
* Replaces the two top-level-only resolvers (`getFolderIdByName` in
|
|
5
|
+
* email/folder-utils.js and `resolveFolderName` in folder/stats.js) that could
|
|
6
|
+
* not address nested folders. Accepts, in priority order:
|
|
7
|
+
* 1. an explicit folder ID (never guessed from a name),
|
|
8
|
+
* 2. a well-known alias (inbox, archive, sent, ...),
|
|
9
|
+
* 3. a folder PATH like "Triage/Delete" or "Inbox/Clients/Acme"
|
|
10
|
+
* (case-insensitive, traversed segment-by-segment via childFolders),
|
|
11
|
+
* 4. a bare display name (unique top-level match wins for back-compat;
|
|
12
|
+
* otherwise the whole tree is searched, with ambiguity reported).
|
|
13
|
+
*
|
|
14
|
+
* Returns `{ id, displayName, parentId, path }`. Throws a caller-friendly Error
|
|
15
|
+
* on not-found or ambiguity (listing candidate paths + IDs).
|
|
16
|
+
*
|
|
17
|
+
* `/` is the path separator, so a folder whose display name literally contains
|
|
18
|
+
* `/` cannot be addressed by path — use its folderId (documented on the tool).
|
|
19
|
+
*/
|
|
20
|
+
const { callGraphAPI } = require('../utils/graph-api');
|
|
21
|
+
|
|
22
|
+
// Alias → Graph well-known folder name (usable directly as a path segment).
|
|
23
|
+
const WELL_KNOWN = {
|
|
24
|
+
inbox: 'inbox',
|
|
25
|
+
drafts: 'drafts',
|
|
26
|
+
sent: 'sentitems',
|
|
27
|
+
sentitems: 'sentitems',
|
|
28
|
+
'sent items': 'sentitems',
|
|
29
|
+
deleted: 'deleteditems',
|
|
30
|
+
deleteditems: 'deleteditems',
|
|
31
|
+
'deleted items': 'deleteditems',
|
|
32
|
+
junk: 'junkemail',
|
|
33
|
+
junkemail: 'junkemail',
|
|
34
|
+
'junk email': 'junkemail',
|
|
35
|
+
spam: 'junkemail',
|
|
36
|
+
archive: 'archive',
|
|
37
|
+
outbox: 'outbox',
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
// NB: mailFolder has no selectable `wellKnownName` property (Graph 400s on it,
|
|
41
|
+
// verified against consumer accounts) — well-known folders are addressed by
|
|
42
|
+
// name in the URL, not via a returned field.
|
|
43
|
+
const FOLDER_SELECT = 'id,displayName,parentFolderId,childFolderCount';
|
|
44
|
+
// Safety caps against pathological/looping nesting when walking the tree for a
|
|
45
|
+
// bare-name search. Beyond these we refuse to guess and ask for a path/ID.
|
|
46
|
+
const MAX_TREE_DEPTH = 20;
|
|
47
|
+
const MAX_TREE_REQUESTS = 200;
|
|
48
|
+
|
|
49
|
+
function notFoundError(spec) {
|
|
50
|
+
return new Error(
|
|
51
|
+
`Folder "${spec}" not found. Use \`folders\` action=list to see folders ` +
|
|
52
|
+
`(with IDs and full paths), pass a folder path like "Parent/Child", or a folderId.`
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function ambiguousError(spec, candidates) {
|
|
57
|
+
const shown = candidates.slice(0, 20);
|
|
58
|
+
const lines = shown
|
|
59
|
+
.map((c) => ` - ${c.path} (folderId: ${c.id})`)
|
|
60
|
+
.join('\n');
|
|
61
|
+
const more =
|
|
62
|
+
candidates.length > shown.length
|
|
63
|
+
? `\n … and ${candidates.length - shown.length} more`
|
|
64
|
+
: '';
|
|
65
|
+
return new Error(
|
|
66
|
+
`Folder "${spec}" is ambiguous — ${candidates.length} folders match:\n${lines}${more}\n` +
|
|
67
|
+
`Disambiguate with a folder path (e.g. "Parent/Child") or a folderId.`
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* List child folders of `parentId` (or top-level when null), following
|
|
73
|
+
* @odata.nextLink so folders with many children resolve completely.
|
|
74
|
+
* @returns {Promise<Array<{id, displayName, parentFolderId, childFolderCount}>>}
|
|
75
|
+
*/
|
|
76
|
+
async function listChildFolders(accessToken, parentId, select = FOLDER_SELECT) {
|
|
77
|
+
const all = [];
|
|
78
|
+
let path = parentId
|
|
79
|
+
? `me/mailFolders/${parentId}/childFolders`
|
|
80
|
+
: 'me/mailFolders';
|
|
81
|
+
let params = { $top: 100, $select: select };
|
|
82
|
+
// Follow pagination; nextLink already encodes params. Guard against a
|
|
83
|
+
// repeated/malformed nextLink so a bad server response can't loop forever.
|
|
84
|
+
const seen = new Set();
|
|
85
|
+
while (path) {
|
|
86
|
+
if (seen.has(path)) {
|
|
87
|
+
throw new Error(
|
|
88
|
+
'Graph returned a repeated folder pagination link — aborting to avoid a loop.'
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
seen.add(path);
|
|
92
|
+
const resp = await callGraphAPI(accessToken, 'GET', path, null, params);
|
|
93
|
+
if (Array.isArray(resp.value)) {
|
|
94
|
+
all.push(...resp.value);
|
|
95
|
+
}
|
|
96
|
+
path = resp['@odata.nextLink'] || null;
|
|
97
|
+
params = {};
|
|
98
|
+
}
|
|
99
|
+
return all;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function toRecord(folder, path) {
|
|
103
|
+
return {
|
|
104
|
+
id: folder.id,
|
|
105
|
+
displayName: folder.displayName,
|
|
106
|
+
parentId: folder.parentFolderId || null,
|
|
107
|
+
path: path || folder.displayName,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function resolveWellKnown(accessToken, alias) {
|
|
112
|
+
const resp = await callGraphAPI(
|
|
113
|
+
accessToken,
|
|
114
|
+
'GET',
|
|
115
|
+
`me/mailFolders/${WELL_KNOWN[alias]}`,
|
|
116
|
+
null,
|
|
117
|
+
{ $select: FOLDER_SELECT }
|
|
118
|
+
);
|
|
119
|
+
return toRecord(resp, resp.displayName);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function resolveById(accessToken, id) {
|
|
123
|
+
const resp = await callGraphAPI(
|
|
124
|
+
accessToken,
|
|
125
|
+
'GET',
|
|
126
|
+
`me/mailFolders/${id}`,
|
|
127
|
+
null,
|
|
128
|
+
{ $select: FOLDER_SELECT }
|
|
129
|
+
);
|
|
130
|
+
return toRecord(resp, resp.displayName);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Build a flat list of every folder with its full path, breadth-first.
|
|
135
|
+
* Accepts an already-fetched top-level list to avoid re-fetching.
|
|
136
|
+
*/
|
|
137
|
+
async function buildTree(accessToken, topLevel) {
|
|
138
|
+
const top = topLevel || (await listChildFolders(accessToken, null));
|
|
139
|
+
const out = [];
|
|
140
|
+
const visited = new Set();
|
|
141
|
+
const queue = top.map((f) => ({ folder: f, path: f.displayName, depth: 1 }));
|
|
142
|
+
let requests = 0;
|
|
143
|
+
// Index-based iteration (no O(n^2) shift); visited-set defends against
|
|
144
|
+
// cyclic/duplicate API data.
|
|
145
|
+
for (let i = 0; i < queue.length; i++) {
|
|
146
|
+
const { folder, path, depth } = queue[i];
|
|
147
|
+
if (visited.has(folder.id)) {
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
visited.add(folder.id);
|
|
151
|
+
out.push(toRecord(folder, path));
|
|
152
|
+
if (folder.childFolderCount > 0) {
|
|
153
|
+
// Refuse to return a "unique" bare-name result from a truncated scan —
|
|
154
|
+
// a match could exist in the unexplored remainder. Ask for a path/ID.
|
|
155
|
+
if (depth >= MAX_TREE_DEPTH) {
|
|
156
|
+
throw new Error(
|
|
157
|
+
`Folder tree exceeds the ${MAX_TREE_DEPTH}-level resolution limit — ` +
|
|
158
|
+
'use an explicit folder path or folderId.'
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
if (++requests > MAX_TREE_REQUESTS) {
|
|
162
|
+
throw new Error(
|
|
163
|
+
'Folder tree is too large to search by bare name — ' +
|
|
164
|
+
'use an explicit folder path or folderId.'
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
const children = await listChildFolders(accessToken, folder.id);
|
|
168
|
+
for (const child of children) {
|
|
169
|
+
queue.push({
|
|
170
|
+
folder: child,
|
|
171
|
+
path: `${path}/${child.displayName}`,
|
|
172
|
+
depth: depth + 1,
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return out;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function matchName(folders, name) {
|
|
181
|
+
const lower = name.toLowerCase();
|
|
182
|
+
return folders.filter((f) => f.displayName.toLowerCase() === lower);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Resolve a bare display name: a unique top-level match wins (fast path,
|
|
187
|
+
* back-compat); otherwise search the whole tree, reporting ambiguity.
|
|
188
|
+
*/
|
|
189
|
+
async function resolveByName(accessToken, name) {
|
|
190
|
+
const top = await listChildFolders(accessToken, null);
|
|
191
|
+
const topMatches = matchName(top, name);
|
|
192
|
+
if (topMatches.length === 1) {
|
|
193
|
+
return toRecord(topMatches[0], topMatches[0].displayName);
|
|
194
|
+
}
|
|
195
|
+
if (topMatches.length > 1) {
|
|
196
|
+
throw ambiguousError(
|
|
197
|
+
name,
|
|
198
|
+
topMatches.map((m) => ({ id: m.id, path: m.displayName }))
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
// Not top-level — search nested folders.
|
|
202
|
+
const tree = await buildTree(accessToken, top);
|
|
203
|
+
const matches = matchName(tree, name);
|
|
204
|
+
if (matches.length === 0) {
|
|
205
|
+
throw notFoundError(name);
|
|
206
|
+
}
|
|
207
|
+
if (matches.length > 1) {
|
|
208
|
+
throw ambiguousError(name, matches);
|
|
209
|
+
}
|
|
210
|
+
return matches[0];
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Resolve a path (segments already split/trimmed) by traversing childFolders.
|
|
215
|
+
*/
|
|
216
|
+
async function resolvePath(accessToken, segments) {
|
|
217
|
+
let current;
|
|
218
|
+
const first = segments[0];
|
|
219
|
+
if (WELL_KNOWN[first.toLowerCase()]) {
|
|
220
|
+
current = await resolveWellKnown(accessToken, first.toLowerCase());
|
|
221
|
+
} else {
|
|
222
|
+
const top = await listChildFolders(accessToken, null);
|
|
223
|
+
const matches = matchName(top, first);
|
|
224
|
+
if (matches.length === 0) {
|
|
225
|
+
throw notFoundError(first);
|
|
226
|
+
}
|
|
227
|
+
if (matches.length > 1) {
|
|
228
|
+
throw ambiguousError(
|
|
229
|
+
first,
|
|
230
|
+
matches.map((m) => ({ id: m.id, path: m.displayName }))
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
current = toRecord(matches[0], matches[0].displayName);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
for (let i = 1; i < segments.length; i++) {
|
|
237
|
+
const seg = segments[i];
|
|
238
|
+
const children = await listChildFolders(accessToken, current.id);
|
|
239
|
+
const matches = matchName(children, seg);
|
|
240
|
+
if (matches.length === 0) {
|
|
241
|
+
throw notFoundError(`${current.path}/${seg}`);
|
|
242
|
+
}
|
|
243
|
+
if (matches.length > 1) {
|
|
244
|
+
throw ambiguousError(
|
|
245
|
+
`${current.path}/${seg}`,
|
|
246
|
+
matches.map((m) => ({
|
|
247
|
+
id: m.id,
|
|
248
|
+
path: `${current.path}/${m.displayName}`,
|
|
249
|
+
}))
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
current = {
|
|
253
|
+
id: matches[0].id,
|
|
254
|
+
displayName: matches[0].displayName,
|
|
255
|
+
parentId: current.id,
|
|
256
|
+
path: `${current.path}/${matches[0].displayName}`,
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
return current;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Resolve a folder from a name/path and/or explicit ID.
|
|
264
|
+
* @param {string} accessToken
|
|
265
|
+
* @param {{name?: string, id?: string}} spec
|
|
266
|
+
* @returns {Promise<{id: string, displayName: string, parentId: string|null, path: string}>}
|
|
267
|
+
* `path` is the full slash-separated path when resolved by name/path/alias;
|
|
268
|
+
* when resolved by ID it is the folder's display name only (ancestors are not
|
|
269
|
+
* fetched).
|
|
270
|
+
*/
|
|
271
|
+
async function resolveFolder(accessToken, spec = {}) {
|
|
272
|
+
const id = (spec.id || '').trim();
|
|
273
|
+
if (id) {
|
|
274
|
+
return resolveById(accessToken, id);
|
|
275
|
+
}
|
|
276
|
+
const name = (spec.name || '').trim();
|
|
277
|
+
if (!name) {
|
|
278
|
+
throw new Error('A folder name, path, or folderId is required.');
|
|
279
|
+
}
|
|
280
|
+
if (name.includes('/')) {
|
|
281
|
+
// Trim each segment and drop leading/trailing empties ("/Inbox/" is fine),
|
|
282
|
+
// but reject INTERIOR empty segments ("A//B") so a typo can't silently
|
|
283
|
+
// collapse to a real folder — important for the destructive delete path.
|
|
284
|
+
const segments = name.split('/').map((s) => s.trim());
|
|
285
|
+
while (segments.length && segments[0] === '') {
|
|
286
|
+
segments.shift();
|
|
287
|
+
}
|
|
288
|
+
while (segments.length && segments[segments.length - 1] === '') {
|
|
289
|
+
segments.pop();
|
|
290
|
+
}
|
|
291
|
+
if (segments.length === 0) {
|
|
292
|
+
throw new Error(`Invalid folder path "${spec.name}".`);
|
|
293
|
+
}
|
|
294
|
+
if (segments.some((s) => s === '')) {
|
|
295
|
+
throw new Error(
|
|
296
|
+
`Invalid folder path "${spec.name}": empty path segment.`
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
if (segments.length === 1) {
|
|
300
|
+
return resolveFolder(accessToken, { name: segments[0] });
|
|
301
|
+
}
|
|
302
|
+
return resolvePath(accessToken, segments);
|
|
303
|
+
}
|
|
304
|
+
if (WELL_KNOWN[name.toLowerCase()]) {
|
|
305
|
+
return resolveWellKnown(accessToken, name.toLowerCase());
|
|
306
|
+
}
|
|
307
|
+
return resolveByName(accessToken, name);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
module.exports = {
|
|
311
|
+
WELL_KNOWN,
|
|
312
|
+
resolveFolder,
|
|
313
|
+
listChildFolders,
|
|
314
|
+
buildTree,
|
|
315
|
+
};
|
package/folder/stats.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
const { callGraphAPI } = require('../utils/graph-api');
|
|
8
8
|
const { ensureAuthenticated } = require('../auth');
|
|
9
|
+
const { resolveFolder } = require('./resolve');
|
|
9
10
|
const config = require('../config');
|
|
10
11
|
|
|
11
12
|
const { VERBOSITY, DEFAULT_LIMITS } = config;
|
|
@@ -17,24 +18,25 @@ const { VERBOSITY, DEFAULT_LIMITS } = config;
|
|
|
17
18
|
*/
|
|
18
19
|
async function handleGetFolderStats(args) {
|
|
19
20
|
const folderName = args.folder || 'inbox';
|
|
21
|
+
const folderIdArg = args.folderId || '';
|
|
20
22
|
const verbosity = args.outputVerbosity || VERBOSITY.STANDARD;
|
|
21
23
|
|
|
22
24
|
try {
|
|
23
25
|
const accessToken = await ensureAuthenticated();
|
|
24
26
|
|
|
25
|
-
// Resolve folder name
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
27
|
+
// Resolve folder (name/path/alias/ID → concrete folder). (#216)
|
|
28
|
+
let resolved;
|
|
29
|
+
try {
|
|
30
|
+
resolved = await resolveFolder(accessToken, {
|
|
31
|
+
name: folderName,
|
|
32
|
+
id: folderIdArg,
|
|
33
|
+
});
|
|
34
|
+
} catch (resolveError) {
|
|
29
35
|
return {
|
|
30
|
-
content: [
|
|
31
|
-
{
|
|
32
|
-
type: 'text',
|
|
33
|
-
text: `Folder "${folderName}" not found.`,
|
|
34
|
-
},
|
|
35
|
-
],
|
|
36
|
+
content: [{ type: 'text', text: resolveError.message }],
|
|
36
37
|
};
|
|
37
38
|
}
|
|
39
|
+
const folderId = resolved.id;
|
|
38
40
|
|
|
39
41
|
// Get folder details with full stats
|
|
40
42
|
const folder = await callGraphAPI(
|
|
@@ -90,71 +92,6 @@ async function handleGetFolderStats(args) {
|
|
|
90
92
|
}
|
|
91
93
|
}
|
|
92
94
|
|
|
93
|
-
/**
|
|
94
|
-
* Resolve folder name to ID
|
|
95
|
-
* @param {string} accessToken - Access token
|
|
96
|
-
* @param {string} folderName - Folder name or well-known name
|
|
97
|
-
* @returns {Promise<string|null>} - Folder ID or null
|
|
98
|
-
*/
|
|
99
|
-
async function resolveFolderName(accessToken, folderName) {
|
|
100
|
-
const wellKnownFolders = {
|
|
101
|
-
inbox: 'inbox',
|
|
102
|
-
sent: 'sentitems',
|
|
103
|
-
sentitems: 'sentitems',
|
|
104
|
-
'sent items': 'sentitems',
|
|
105
|
-
drafts: 'drafts',
|
|
106
|
-
deleted: 'deleteditems',
|
|
107
|
-
deleteditems: 'deleteditems',
|
|
108
|
-
'deleted items': 'deleteditems',
|
|
109
|
-
junk: 'junkemail',
|
|
110
|
-
junkemail: 'junkemail',
|
|
111
|
-
'junk email': 'junkemail',
|
|
112
|
-
spam: 'junkemail',
|
|
113
|
-
archive: 'archive',
|
|
114
|
-
outbox: 'outbox',
|
|
115
|
-
};
|
|
116
|
-
|
|
117
|
-
const normalised = folderName.toLowerCase().trim();
|
|
118
|
-
|
|
119
|
-
// Check if it's a well-known folder
|
|
120
|
-
if (wellKnownFolders[normalised]) {
|
|
121
|
-
try {
|
|
122
|
-
const response = await callGraphAPI(
|
|
123
|
-
accessToken,
|
|
124
|
-
'GET',
|
|
125
|
-
`me/mailFolders/${wellKnownFolders[normalised]}`,
|
|
126
|
-
null,
|
|
127
|
-
{ $select: 'id' }
|
|
128
|
-
);
|
|
129
|
-
return response.id;
|
|
130
|
-
} catch (_error) {
|
|
131
|
-
// Fall through to search
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
// Search for folder by name
|
|
136
|
-
try {
|
|
137
|
-
const response = await callGraphAPI(
|
|
138
|
-
accessToken,
|
|
139
|
-
'GET',
|
|
140
|
-
'me/mailFolders',
|
|
141
|
-
null,
|
|
142
|
-
{
|
|
143
|
-
$filter: `displayName eq '${folderName}'`,
|
|
144
|
-
$select: 'id',
|
|
145
|
-
}
|
|
146
|
-
);
|
|
147
|
-
|
|
148
|
-
if (response.value && response.value.length > 0) {
|
|
149
|
-
return response.value[0].id;
|
|
150
|
-
}
|
|
151
|
-
} catch (error) {
|
|
152
|
-
console.error(`Error searching for folder: ${error.message}`);
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
return null;
|
|
156
|
-
}
|
|
157
|
-
|
|
158
95
|
/**
|
|
159
96
|
* Get date range of emails in folder
|
|
160
97
|
* @param {string} accessToken - Access token
|
package/llms.txt
CHANGED
|
@@ -22,13 +22,13 @@ Built by [Little Bear Apps](https://littlebearapps.com).
|
|
|
22
22
|
|
|
23
23
|
## Key Differentiators
|
|
24
24
|
|
|
25
|
-
- **Progressive search**: Automatically falls back through 4 search strategies when Microsoft's `$search` API is unavailable (personal accounts) — most Graph API wrappers fail silently. Explicit "no results" messaging instead of unfiltered fallback.
|
|
25
|
+
- **Progressive search**: Automatically falls back through 4 search strategies when Microsoft's `$search` API is unavailable (personal accounts) — most Graph API wrappers fail silently. Explicit "no results" messaging instead of unfiltered fallback. Cross-folder search (`searchAllFolders`) reliably returns a superset of inbox results.
|
|
26
26
|
- **Remote-friendly auth**: Device code flow (default) — no auth server, no port forwarding, no SSH tunnels. State persists across MCP server restarts. Works from Untether, mosh, SSH, and headless environments.
|
|
27
27
|
- **Email forensics**: Built-in header analysis for DKIM, SPF, DMARC authentication, delivery chains, and spam scores — useful for phishing investigation and compliance
|
|
28
28
|
- **Delta sync**: Incremental inbox monitoring — returns only new, modified, and deleted emails since last check, with tokens for continuous polling
|
|
29
29
|
- **Batch operations**: Flag, move, export, or categorise multiple emails in a single tool call; search-driven export for batch archiving without collecting IDs
|
|
30
30
|
- **Pre-send intelligence**: Check recipients for out-of-office, mailbox full, delivery restrictions, and moderation before sending — no other Outlook MCP server offers this
|
|
31
|
-
- **Compound automation**: Rules + categories + folders + Focused Inbox for complete inbox management in one conversation
|
|
31
|
+
- **Compound automation**: Rules + categories + nested folders (addressable by path or ID) + Focused Inbox for complete inbox management in one conversation
|
|
32
32
|
|
|
33
33
|
## Safety & Token Efficiency
|
|
34
34
|
|
|
@@ -63,7 +63,7 @@ Requires an Azure app registration with Microsoft Graph delegated permissions. S
|
|
|
63
63
|
- **Email (8 tools)**: `search-emails`, `read-email`, `send-email`, `draft`, `update-email`, `attachments`, `export`, `get-mail-tips`
|
|
64
64
|
- **Calendar (3 tools)**: `list-events`, `create-event`, `manage-event`
|
|
65
65
|
- **Contacts (2 tools)**: `manage-contact`, `search-people`
|
|
66
|
-
- **Folders (1 tool)**: `folders` — list, create, move, stats
|
|
66
|
+
- **Folders (1 tool)**: `folders` — list, create, move, stats, delete; folders addressable by nested path (`Parent/Child`) or ID
|
|
67
67
|
- **Rules (1 tool)**: `manage-rules` — list, create, update, reorder, delete
|
|
68
68
|
- **Categories (3 tools)**: `manage-category`, `apply-category`, `manage-focused-inbox`
|
|
69
69
|
- **Settings (1 tool)**: `mailbox-settings` — get, set auto-replies, set working hours
|
|
@@ -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.8.x
|
|
82
|
+
- [CHANGELOG](https://raw.githubusercontent.com/littlebearapps/outlook-assistant/main/CHANGELOG.md): Version history (current: v3.9.0 — nested folder addressing by path/ID (#216); reliable cross-folder search with `kqlQuery` renamed to `searchExpression` (#169); plus the v3.8.3 patch — security `overrides` clearing the audit gate (#215), `openWorldHint` on external-content tools (#92), canonical UTC ISO-8601 `list-events` times (#118))
|
|
83
|
+
- [ROADMAP](https://raw.githubusercontent.com/littlebearapps/outlook-assistant/main/ROADMAP.md): Active milestones (v3.7.5 polish, v3.8.x carry-over, v3.10.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.9.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",
|
|
@@ -58,6 +58,7 @@
|
|
|
58
58
|
"files": [
|
|
59
59
|
"index.js",
|
|
60
60
|
"config.js",
|
|
61
|
+
"request-handler.js",
|
|
61
62
|
"outlook-auth-server.js",
|
|
62
63
|
"auth/",
|
|
63
64
|
"calendar/",
|
|
@@ -96,6 +97,9 @@
|
|
|
96
97
|
"node": ">=18.18.0"
|
|
97
98
|
},
|
|
98
99
|
"overrides": {
|
|
99
|
-
"minimatch": ">=3.1.3"
|
|
100
|
+
"minimatch": ">=3.1.3",
|
|
101
|
+
"hono": "^4.12.31",
|
|
102
|
+
"fast-uri": "^3.1.4",
|
|
103
|
+
"body-parser": "^2.3.0"
|
|
100
104
|
}
|
|
101
105
|
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP request dispatcher for the Outlook Assistant server.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from index.js so the dispatch + error-shaping logic is
|
|
5
|
+
* unit-testable without starting the stdio transport.
|
|
6
|
+
*
|
|
7
|
+
* IMPORTANT (#213): a `tools/call` that fails MUST return a visible MCP
|
|
8
|
+
* tool-error result (`{ content: [...], isError: true }`). Returning a
|
|
9
|
+
* content-less `{ error: {...} }` object gets coerced by the SDK into
|
|
10
|
+
* `{ content: [] }`, which the client renders as EMPTY OUTPUT — the exact
|
|
11
|
+
* symptom reported for device-code auth in a remote connector session.
|
|
12
|
+
*/
|
|
13
|
+
const config = require('./config');
|
|
14
|
+
const { coerceArgsAgainstSchema } = require('./utils/schema-coerce');
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Build the MCP fallbackRequestHandler for a given tool set.
|
|
18
|
+
* @param {Array<{name: string, description?: string, inputSchema?: object, annotations?: object, handler?: Function}>} TOOLS
|
|
19
|
+
* @returns {(request: object) => Promise<object>}
|
|
20
|
+
*/
|
|
21
|
+
function createRequestHandler(TOOLS) {
|
|
22
|
+
return async (request) => {
|
|
23
|
+
try {
|
|
24
|
+
const { method, params, id } = request;
|
|
25
|
+
console.error(`REQUEST: ${method} [${id}]`);
|
|
26
|
+
|
|
27
|
+
// Initialize handler
|
|
28
|
+
if (method === 'initialize') {
|
|
29
|
+
console.error(`INITIALIZE REQUEST: ID [${id}]`);
|
|
30
|
+
return {
|
|
31
|
+
protocolVersion: '2024-11-05',
|
|
32
|
+
capabilities: {
|
|
33
|
+
tools: TOOLS.reduce((acc, tool) => {
|
|
34
|
+
acc[tool.name] = {};
|
|
35
|
+
return acc;
|
|
36
|
+
}, {}),
|
|
37
|
+
},
|
|
38
|
+
serverInfo: {
|
|
39
|
+
name: config.SERVER_NAME,
|
|
40
|
+
version: config.SERVER_VERSION,
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Tools list handler
|
|
46
|
+
if (method === 'tools/list') {
|
|
47
|
+
console.error(`TOOLS LIST REQUEST: ID [${id}]`);
|
|
48
|
+
console.error(`TOOLS COUNT: ${TOOLS.length}`);
|
|
49
|
+
console.error(`TOOLS NAMES: ${TOOLS.map((t) => t.name).join(', ')}`);
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
tools: TOOLS.map((tool) => ({
|
|
53
|
+
name: tool.name,
|
|
54
|
+
description: tool.description,
|
|
55
|
+
inputSchema: tool.inputSchema,
|
|
56
|
+
...(tool.annotations && { annotations: tool.annotations }),
|
|
57
|
+
})),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Required empty responses for other capabilities
|
|
62
|
+
if (method === 'resources/list') return { resources: [] };
|
|
63
|
+
if (method === 'prompts/list') return { prompts: [] };
|
|
64
|
+
|
|
65
|
+
// Tool call handler
|
|
66
|
+
if (method === 'tools/call') {
|
|
67
|
+
try {
|
|
68
|
+
const { name, arguments: args = {} } = params || {};
|
|
69
|
+
|
|
70
|
+
console.error(`TOOL CALL: ${name}`);
|
|
71
|
+
|
|
72
|
+
// Find the tool handler
|
|
73
|
+
const tool = TOOLS.find((t) => t.name === name);
|
|
74
|
+
|
|
75
|
+
if (tool && tool.handler) {
|
|
76
|
+
// Coerce + validate args against the tool's inputSchema before
|
|
77
|
+
// dispatching. Catches array-as-string, boolean-as-string, unknown
|
|
78
|
+
// params, and out-of-enum action values at the MCP boundary so
|
|
79
|
+
// handlers receive properly-typed JS values. (#160, #162)
|
|
80
|
+
if (tool.inputSchema) {
|
|
81
|
+
const coerced = coerceArgsAgainstSchema(args, tool.inputSchema);
|
|
82
|
+
if (coerced.error) {
|
|
83
|
+
return {
|
|
84
|
+
content: [
|
|
85
|
+
{
|
|
86
|
+
type: 'text',
|
|
87
|
+
text: `Invalid arguments for tool '${name}':\n${coerced.error}`,
|
|
88
|
+
},
|
|
89
|
+
],
|
|
90
|
+
isError: true,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
return await tool.handler(coerced.args);
|
|
94
|
+
}
|
|
95
|
+
return await tool.handler(args);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Tool not found — return visible isError content, not a
|
|
99
|
+
// content-less { error } (which renders as empty output). (#213)
|
|
100
|
+
return {
|
|
101
|
+
content: [
|
|
102
|
+
{
|
|
103
|
+
type: 'text',
|
|
104
|
+
text: `Tool not found: ${name}`,
|
|
105
|
+
},
|
|
106
|
+
],
|
|
107
|
+
isError: true,
|
|
108
|
+
};
|
|
109
|
+
} catch (error) {
|
|
110
|
+
console.error(`Error in tools/call:`, error);
|
|
111
|
+
// Surface the failure as visible tool-error content so it is not
|
|
112
|
+
// silently rendered as empty output by the client. (#213)
|
|
113
|
+
return {
|
|
114
|
+
content: [
|
|
115
|
+
{
|
|
116
|
+
type: 'text',
|
|
117
|
+
text: `Error processing tool call: ${error.message}`,
|
|
118
|
+
},
|
|
119
|
+
],
|
|
120
|
+
isError: true,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// For any other method, return method not found
|
|
126
|
+
return {
|
|
127
|
+
error: {
|
|
128
|
+
code: -32601,
|
|
129
|
+
message: `Method not found: ${method}`,
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
} catch (error) {
|
|
133
|
+
console.error(`Error in fallbackRequestHandler:`, error);
|
|
134
|
+
return {
|
|
135
|
+
error: {
|
|
136
|
+
code: -32603,
|
|
137
|
+
message: `Error processing request: ${error.message}`,
|
|
138
|
+
},
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
module.exports = { createRequestHandler };
|
package/utils/graph-api.js
CHANGED
|
@@ -90,8 +90,18 @@ async function callGraphAPI(
|
|
|
90
90
|
headers.Prefer = 'IdType="ImmutableId"';
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
-
// Merge any extra headers (caller overrides take precedence)
|
|
93
|
+
// Merge any extra headers (caller overrides take precedence). `Prefer`
|
|
94
|
+
// is multi-valued in HTTP (comma-separated); combine both values rather
|
|
95
|
+
// than letting a caller Prefer (e.g. outlook.timezone) clobber the global
|
|
96
|
+
// immutable-IDs Prefer, or vice versa.
|
|
97
|
+
const combinedPrefer =
|
|
98
|
+
headers.Prefer && extraHeaders.Prefer
|
|
99
|
+
? `${headers.Prefer}, ${extraHeaders.Prefer}`
|
|
100
|
+
: null;
|
|
94
101
|
Object.assign(headers, extraHeaders);
|
|
102
|
+
if (combinedPrefer) {
|
|
103
|
+
headers.Prefer = combinedPrefer;
|
|
104
|
+
}
|
|
95
105
|
|
|
96
106
|
const options = {
|
|
97
107
|
method: method,
|