@littlebearapps/outlook-assistant 3.13.0 → 3.14.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 +30 -3
- package/README.md +67 -27
- package/advanced/index.js +44 -174
- package/auth/auth-errors.js +23 -1
- package/auth/oauth-server.js +7 -1
- package/auth/token-manager.js +7 -3
- package/auth/token-storage.js +28 -30
- package/auth/tools.js +61 -82
- package/calendar/attendees.js +36 -0
- package/calendar/cancel.js +9 -25
- package/calendar/create.js +42 -48
- package/calendar/decline.js +10 -25
- package/calendar/delete.js +10 -25
- package/calendar/index.js +20 -37
- package/calendar/list.js +4 -16
- package/calendar/preview.js +461 -0
- package/calendar/update.js +55 -83
- package/categories/index.js +68 -265
- package/config.js +29 -1
- package/contacts/index.js +72 -128
- package/email/attachments.js +43 -125
- package/email/conversations.js +44 -78
- package/email/delta.js +69 -46
- package/email/draft.js +170 -103
- package/email/export.js +145 -110
- package/email/folder-utils.js +3 -2
- package/email/headers.js +11 -49
- package/email/index.js +86 -110
- package/email/list.js +4 -17
- package/email/mail-tips.js +86 -57
- package/email/mark-as-read.js +13 -49
- package/email/mime.js +39 -51
- package/email/read.js +16 -50
- package/email/search.js +47 -87
- package/email/send.js +82 -48
- package/folder/create.js +6 -25
- package/folder/delete.js +117 -38
- package/folder/index.js +19 -17
- package/folder/list.js +5 -17
- package/folder/move.js +13 -42
- package/folder/resolve.js +11 -6
- package/folder/stats.js +18 -27
- package/index.js +39 -45
- package/llms-install.md +22 -4
- package/llms.txt +20 -11
- package/outlook-auth-server.js +10 -3
- package/package.json +4 -1
- package/request-handler.js +217 -116
- package/rules/create.js +28 -71
- package/rules/index.js +52 -93
- package/rules/list.js +7 -19
- package/rules/rule-builder.js +59 -22
- package/rules/update.js +27 -61
- package/server.js +41 -0
- package/settings/index.js +162 -145
- package/tools.js +30 -0
- package/utils/field-presets.js +4 -2
- package/utils/graph-api.js +65 -22
- package/utils/logger.js +251 -0
- package/utils/mock-data.js +91 -2
- package/utils/read-only.js +59 -0
- package/utils/response-formatter.js +54 -15
- package/utils/risk-classes.js +324 -0
- package/utils/safe-write.js +372 -6
- package/utils/safety.js +247 -42
- package/utils/server-instructions.js +73 -0
- package/utils/tool-error.js +33 -0
package/rules/index.js
CHANGED
|
@@ -7,6 +7,8 @@ const handleUpdateRule = require('./update');
|
|
|
7
7
|
const { callGraphAPI } = require('../utils/graph-api');
|
|
8
8
|
const { ensureAuthenticated } = require('../auth');
|
|
9
9
|
const { checkRateLimit } = require('../utils/safety');
|
|
10
|
+
const { toolMetadata } = require('../utils/risk-classes');
|
|
11
|
+
const { toolError, authRequiredError } = require('../utils/tool-error');
|
|
10
12
|
|
|
11
13
|
/**
|
|
12
14
|
* Delete rule handler
|
|
@@ -16,19 +18,8 @@ const { checkRateLimit } = require('../utils/safety');
|
|
|
16
18
|
async function handleDeleteRule(args) {
|
|
17
19
|
const { ruleName, ruleId } = args;
|
|
18
20
|
|
|
19
|
-
// Rate limit
|
|
20
|
-
const rateLimitError = checkRateLimit('manage-rules');
|
|
21
|
-
if (rateLimitError) return rateLimitError;
|
|
22
|
-
|
|
23
21
|
if (!ruleName && !ruleId) {
|
|
24
|
-
return
|
|
25
|
-
content: [
|
|
26
|
-
{
|
|
27
|
-
type: 'text',
|
|
28
|
-
text: 'Either ruleName or ruleId is required.',
|
|
29
|
-
},
|
|
30
|
-
],
|
|
31
|
-
};
|
|
22
|
+
return toolError('Either ruleName or ruleId is required.');
|
|
32
23
|
}
|
|
33
24
|
|
|
34
25
|
try {
|
|
@@ -42,19 +33,17 @@ async function handleDeleteRule(args) {
|
|
|
42
33
|
const rules = await getInboxRules(accessToken);
|
|
43
34
|
const rule = rules.find((r) => r.displayName === ruleName);
|
|
44
35
|
if (!rule) {
|
|
45
|
-
return {
|
|
46
|
-
content: [
|
|
47
|
-
{
|
|
48
|
-
type: 'text',
|
|
49
|
-
text: `Rule with name "${ruleName}" not found.`,
|
|
50
|
-
},
|
|
51
|
-
],
|
|
52
|
-
};
|
|
36
|
+
return toolError(`Rule with name "${ruleName}" not found.`);
|
|
53
37
|
}
|
|
54
38
|
resolvedId = rule.id;
|
|
55
39
|
displayName = ruleName;
|
|
56
40
|
}
|
|
57
41
|
|
|
42
|
+
// Checked only once there is a rule to delete, so a bad call doesn't
|
|
43
|
+
// use up a slot (#279)
|
|
44
|
+
const rateLimitError = checkRateLimit('manage-rules');
|
|
45
|
+
if (rateLimitError) return rateLimitError;
|
|
46
|
+
|
|
58
47
|
await callGraphAPI(
|
|
59
48
|
accessToken,
|
|
60
49
|
'DELETE',
|
|
@@ -71,23 +60,9 @@ async function handleDeleteRule(args) {
|
|
|
71
60
|
};
|
|
72
61
|
} catch (error) {
|
|
73
62
|
if (error.message === 'Authentication required') {
|
|
74
|
-
return
|
|
75
|
-
content: [
|
|
76
|
-
{
|
|
77
|
-
type: 'text',
|
|
78
|
-
text: "Authentication required. Please use the 'auth' tool with action=authenticate first.",
|
|
79
|
-
},
|
|
80
|
-
],
|
|
81
|
-
};
|
|
63
|
+
return authRequiredError();
|
|
82
64
|
}
|
|
83
|
-
return {
|
|
84
|
-
content: [
|
|
85
|
-
{
|
|
86
|
-
type: 'text',
|
|
87
|
-
text: `Error deleting rule: ${error.message}`,
|
|
88
|
-
},
|
|
89
|
-
],
|
|
90
|
-
};
|
|
65
|
+
return toolError(`Error deleting rule: ${error.message}`);
|
|
91
66
|
}
|
|
92
67
|
}
|
|
93
68
|
|
|
@@ -100,25 +75,15 @@ async function handleEditRuleSequence(args) {
|
|
|
100
75
|
const { ruleName, sequence } = args;
|
|
101
76
|
|
|
102
77
|
if (!ruleName) {
|
|
103
|
-
return
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
type: 'text',
|
|
107
|
-
text: 'Rule name is required. Please specify the exact name of an existing rule.',
|
|
108
|
-
},
|
|
109
|
-
],
|
|
110
|
-
};
|
|
78
|
+
return toolError(
|
|
79
|
+
'Rule name is required. Please specify the exact name of an existing rule.'
|
|
80
|
+
);
|
|
111
81
|
}
|
|
112
82
|
|
|
113
83
|
if (!sequence || isNaN(sequence) || sequence < 1) {
|
|
114
|
-
return
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
type: 'text',
|
|
118
|
-
text: 'A positive sequence number is required. Lower numbers run first (higher priority).',
|
|
119
|
-
},
|
|
120
|
-
],
|
|
121
|
-
};
|
|
84
|
+
return toolError(
|
|
85
|
+
'A positive sequence number is required. Lower numbers run first (higher priority).'
|
|
86
|
+
);
|
|
122
87
|
}
|
|
123
88
|
|
|
124
89
|
try {
|
|
@@ -127,16 +92,13 @@ async function handleEditRuleSequence(args) {
|
|
|
127
92
|
|
|
128
93
|
const rule = rules.find((r) => r.displayName === ruleName);
|
|
129
94
|
if (!rule) {
|
|
130
|
-
return {
|
|
131
|
-
content: [
|
|
132
|
-
{
|
|
133
|
-
type: 'text',
|
|
134
|
-
text: `Rule with name "${ruleName}" not found.`,
|
|
135
|
-
},
|
|
136
|
-
],
|
|
137
|
-
};
|
|
95
|
+
return toolError(`Rule with name "${ruleName}" not found.`);
|
|
138
96
|
}
|
|
139
97
|
|
|
98
|
+
// Reordering is a write, so it shares the manage-rules limit (#273)
|
|
99
|
+
const rateLimitError = checkRateLimit('manage-rules');
|
|
100
|
+
if (rateLimitError) return rateLimitError;
|
|
101
|
+
|
|
140
102
|
await callGraphAPI(
|
|
141
103
|
accessToken,
|
|
142
104
|
'PATCH',
|
|
@@ -144,34 +106,41 @@ async function handleEditRuleSequence(args) {
|
|
|
144
106
|
{ sequence }
|
|
145
107
|
);
|
|
146
108
|
|
|
109
|
+
// Exchange may renumber other rules to make room, and deleting a rule
|
|
110
|
+
// later doesn't shift them back (#307), so show the resulting order.
|
|
111
|
+
let order = '';
|
|
112
|
+
try {
|
|
113
|
+
const after = await getInboxRules(accessToken);
|
|
114
|
+
const before = new Map(rules.map((r) => [r.id, r.sequence]));
|
|
115
|
+
const lines = [...after]
|
|
116
|
+
.sort((a, b) => (a.sequence ?? 0) - (b.sequence ?? 0))
|
|
117
|
+
.map((r) => `${r.sequence}: ${r.displayName}`);
|
|
118
|
+
const moved = after.filter(
|
|
119
|
+
(r) => r.id !== rule.id && before.get(r.id) !== r.sequence
|
|
120
|
+
);
|
|
121
|
+
const movedNote =
|
|
122
|
+
moved.length > 0
|
|
123
|
+
? `\n\nRenumbered by Exchange: ${moved.map((r) => `"${r.displayName}" ${before.get(r.id)} → ${r.sequence}`).join('; ')}. Tell the user; deleting a rule later doesn't shift them back.`
|
|
124
|
+
: '\n\nNo other rule was renumbered.';
|
|
125
|
+
order = `\n\nRule order now (sequence: name):\n${lines.join('\n')}${movedNote}`;
|
|
126
|
+
} catch (_error) {
|
|
127
|
+
// The reorder succeeded; the order listing is a courtesy.
|
|
128
|
+
}
|
|
129
|
+
|
|
147
130
|
return {
|
|
148
131
|
content: [
|
|
149
132
|
{
|
|
150
133
|
type: 'text',
|
|
151
|
-
text: `Successfully updated the sequence of rule "${ruleName}" to ${sequence}
|
|
134
|
+
text: `Successfully updated the sequence of rule "${ruleName}" to ${sequence}.${order}`,
|
|
152
135
|
},
|
|
153
136
|
],
|
|
154
137
|
};
|
|
155
138
|
} catch (error) {
|
|
156
139
|
if (error.message === 'Authentication required') {
|
|
157
|
-
return
|
|
158
|
-
content: [
|
|
159
|
-
{
|
|
160
|
-
type: 'text',
|
|
161
|
-
text: "Authentication required. Please use the 'auth' tool with action=authenticate first.",
|
|
162
|
-
},
|
|
163
|
-
],
|
|
164
|
-
};
|
|
140
|
+
return authRequiredError();
|
|
165
141
|
}
|
|
166
142
|
|
|
167
|
-
return {
|
|
168
|
-
content: [
|
|
169
|
-
{
|
|
170
|
-
type: 'text',
|
|
171
|
-
text: `Error updating rule sequence: ${error.message}`,
|
|
172
|
-
},
|
|
173
|
-
],
|
|
174
|
-
};
|
|
143
|
+
return toolError(`Error updating rule sequence: ${error.message}`);
|
|
175
144
|
}
|
|
176
145
|
}
|
|
177
146
|
|
|
@@ -180,13 +149,8 @@ const rulesTools = [
|
|
|
180
149
|
{
|
|
181
150
|
name: 'manage-rules',
|
|
182
151
|
description:
|
|
183
|
-
'Server-side inbox
|
|
184
|
-
|
|
185
|
-
title: 'Inbox Rules',
|
|
186
|
-
readOnlyHint: false,
|
|
187
|
-
destructiveHint: true,
|
|
188
|
-
openWorldHint: false,
|
|
189
|
-
},
|
|
152
|
+
'Server-side inbox rules (destructive: covers `delete`; `dryRun` previews create/update). Rules run on the Exchange server whichever client is open. action=`list` (default) returns rules with id/name/sequence; `includeDetails: true` adds conditions/actions/exceptions. action=`create` builds a rule from condition params (12, e.g. fromAddresses, containsSubject, bodyContains, hasAttachments, importance, sensitivity), action params (9, e.g. moveToFolder/copyToFolder — folder name, nested path like `Triage/Delete`, or ID — forwardTo, redirectTo, assignCategories, markAsRead, delete) and optional `except*` exceptions. action=`update` patches fields by `ruleId` or `ruleName`. action=`reorder` changes priority via `sequence` (lower runs first). action=`delete` removes a rule. The recipient allowlist applies to forwardTo/redirectTo. There is no `permanentDelete` action. Changes count against the session limit (`OUTLOOK_MAX_MANAGE_RULES_PER_SESSION`, else `OUTLOOK_MAX_EMAILS_PER_SESSION`); 0 refuses them.',
|
|
153
|
+
...toolMetadata('manage-rules', 'Inbox Rules'),
|
|
190
154
|
inputSchema: {
|
|
191
155
|
type: 'object',
|
|
192
156
|
properties: {
|
|
@@ -217,7 +181,7 @@ const rulesTools = [
|
|
|
217
181
|
dryRun: {
|
|
218
182
|
type: 'boolean',
|
|
219
183
|
description:
|
|
220
|
-
'Preview
|
|
184
|
+
'Preview only (action=create or update): shows the rule without creating or changing it. Other actions refuse dryRun and change nothing. Default false.',
|
|
221
185
|
},
|
|
222
186
|
isEnabled: {
|
|
223
187
|
type: 'boolean',
|
|
@@ -404,14 +368,9 @@ const rulesTools = [
|
|
|
404
368
|
case 'list':
|
|
405
369
|
return handleListRules(args);
|
|
406
370
|
default:
|
|
407
|
-
return
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
type: 'text',
|
|
411
|
-
text: `Unknown action '${action}'. Valid actions: list, create, update, reorder, delete.`,
|
|
412
|
-
},
|
|
413
|
-
],
|
|
414
|
-
};
|
|
371
|
+
return toolError(
|
|
372
|
+
`Unknown action '${action}'. Valid actions: list, create, update, reorder, delete.`
|
|
373
|
+
);
|
|
415
374
|
}
|
|
416
375
|
},
|
|
417
376
|
},
|
package/rules/list.js
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
*/
|
|
4
4
|
const { callGraphAPI } = require('../utils/graph-api');
|
|
5
5
|
const { ensureAuthenticated } = require('../auth');
|
|
6
|
+
const { toolError, authRequiredError } = require('../utils/tool-error');
|
|
7
|
+
const { log } = require('../utils/logger');
|
|
6
8
|
|
|
7
9
|
/**
|
|
8
10
|
* List rules handler
|
|
@@ -27,24 +29,10 @@ async function handleListRules(args) {
|
|
|
27
29
|
};
|
|
28
30
|
} catch (error) {
|
|
29
31
|
if (error.message === 'Authentication required') {
|
|
30
|
-
return
|
|
31
|
-
content: [
|
|
32
|
-
{
|
|
33
|
-
type: 'text',
|
|
34
|
-
text: "Authentication required. Please use the 'auth' tool with action=authenticate first.",
|
|
35
|
-
},
|
|
36
|
-
],
|
|
37
|
-
};
|
|
32
|
+
return authRequiredError();
|
|
38
33
|
}
|
|
39
34
|
|
|
40
|
-
return {
|
|
41
|
-
content: [
|
|
42
|
-
{
|
|
43
|
-
type: 'text',
|
|
44
|
-
text: `Error listing rules: ${error.message}`,
|
|
45
|
-
},
|
|
46
|
-
],
|
|
47
|
-
};
|
|
35
|
+
return toolError(`Error listing rules: ${error.message}`);
|
|
48
36
|
}
|
|
49
37
|
}
|
|
50
38
|
|
|
@@ -64,7 +52,7 @@ async function getInboxRules(accessToken) {
|
|
|
64
52
|
|
|
65
53
|
return response.value || [];
|
|
66
54
|
} catch (error) {
|
|
67
|
-
|
|
55
|
+
log.debug(`Error getting inbox rules: ${error.message}`);
|
|
68
56
|
throw error;
|
|
69
57
|
}
|
|
70
58
|
}
|
|
@@ -165,7 +153,7 @@ function formatRuleConditions(rule) {
|
|
|
165
153
|
}
|
|
166
154
|
|
|
167
155
|
// Booleans
|
|
168
|
-
if (c.
|
|
156
|
+
if (c.hasAttachments === true) conditions.push('Has attachment');
|
|
169
157
|
if (c.sentToMe === true) conditions.push('Sent to me');
|
|
170
158
|
if (c.sentOnlyToMe === true) conditions.push('Sent only to me');
|
|
171
159
|
if (c.sentCcMe === true) conditions.push('I am in CC');
|
|
@@ -281,7 +269,7 @@ function formatRuleExceptions(rule) {
|
|
|
281
269
|
if (e.recipientContains?.length > 0) {
|
|
282
270
|
parts.push(`Recipient contains: "${e.recipientContains.join('", "')}"`);
|
|
283
271
|
}
|
|
284
|
-
if (e.
|
|
272
|
+
if (e.hasAttachments === true) parts.push('Has attachment');
|
|
285
273
|
if (e.importance) parts.push(`Importance: ${e.importance}`);
|
|
286
274
|
if (e.sensitivity) parts.push(`Sensitivity: ${e.sensitivity}`);
|
|
287
275
|
if (e.sentToMe === true) parts.push('Sent to me');
|
package/rules/rule-builder.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
const { resolveFolder, looksLikeFolderId } = require('../folder/resolve');
|
|
6
6
|
const { checkRecipientAllowlist } = require('../utils/safety');
|
|
7
|
+
const { toolError } = require('../utils/tool-error');
|
|
7
8
|
|
|
8
9
|
const VALID_IMPORTANCE = ['low', 'normal', 'high'];
|
|
9
10
|
const VALID_SENSITIVITY = ['normal', 'personal', 'private', 'confidential'];
|
|
@@ -75,7 +76,7 @@ function buildConditions(args) {
|
|
|
75
76
|
}
|
|
76
77
|
|
|
77
78
|
// Boolean conditions
|
|
78
|
-
if (args.hasAttachments === true) conditions.
|
|
79
|
+
if (args.hasAttachments === true) conditions.hasAttachments = true;
|
|
79
80
|
if (args.sentToMe === true) conditions.sentToMe = true;
|
|
80
81
|
if (args.sentOnlyToMe === true) conditions.sentOnlyToMe = true;
|
|
81
82
|
if (args.sentCcMe === true) conditions.sentCcMe = true;
|
|
@@ -197,38 +198,35 @@ async function buildActions(args, accessToken) {
|
|
|
197
198
|
}
|
|
198
199
|
}
|
|
199
200
|
|
|
200
|
-
// Recipient-based actions (comma-separated emails → recipient objects)
|
|
201
|
+
// Recipient-based actions (comma-separated emails → recipient objects).
|
|
202
|
+
// create/update refuse blocked recipients up front (checkRuleRecipients);
|
|
203
|
+
// this is the backstop, so a blocked forward is never silently dropped.
|
|
201
204
|
if (args.forwardTo) {
|
|
202
205
|
const recipients = formatRecipientObjects(args.forwardTo);
|
|
203
206
|
if (recipients.length > 0) {
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
warnings.push(
|
|
208
|
-
`Forward recipients blocked by allowlist: ${args.forwardTo}`
|
|
209
|
-
);
|
|
210
|
-
} else {
|
|
211
|
-
actions.forwardTo = recipients;
|
|
212
|
-
warnings.push(
|
|
213
|
-
`This rule will forward all matching emails to: ${args.forwardTo}. Verify these addresses are correct.`
|
|
207
|
+
if (checkRecipientAllowlist(recipients)) {
|
|
208
|
+
throw new Error(
|
|
209
|
+
`forwardTo recipients blocked by OUTLOOK_ALLOWED_RECIPIENTS: ${args.forwardTo}`
|
|
214
210
|
);
|
|
215
211
|
}
|
|
212
|
+
actions.forwardTo = recipients;
|
|
213
|
+
warnings.push(
|
|
214
|
+
`This rule will forward all matching emails to: ${args.forwardTo}. Verify these addresses are correct.`
|
|
215
|
+
);
|
|
216
216
|
}
|
|
217
217
|
}
|
|
218
218
|
if (args.redirectTo) {
|
|
219
219
|
const recipients = formatRecipientObjects(args.redirectTo);
|
|
220
220
|
if (recipients.length > 0) {
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
`Redirect recipients blocked by allowlist: ${args.redirectTo}`
|
|
225
|
-
);
|
|
226
|
-
} else {
|
|
227
|
-
actions.redirectTo = recipients;
|
|
228
|
-
warnings.push(
|
|
229
|
-
`This rule will redirect all matching emails to: ${args.redirectTo}. The original sender will appear as the sender.`
|
|
221
|
+
if (checkRecipientAllowlist(recipients)) {
|
|
222
|
+
throw new Error(
|
|
223
|
+
`redirectTo recipients blocked by OUTLOOK_ALLOWED_RECIPIENTS: ${args.redirectTo}`
|
|
230
224
|
);
|
|
231
225
|
}
|
|
226
|
+
actions.redirectTo = recipients;
|
|
227
|
+
warnings.push(
|
|
228
|
+
`This rule will redirect all matching emails to: ${args.redirectTo}. The original sender will appear as the sender.`
|
|
229
|
+
);
|
|
232
230
|
}
|
|
233
231
|
}
|
|
234
232
|
|
|
@@ -240,6 +238,44 @@ async function buildActions(args, accessToken) {
|
|
|
240
238
|
return { actions, warnings };
|
|
241
239
|
}
|
|
242
240
|
|
|
241
|
+
/** Rule actions that send matching mail to other people. */
|
|
242
|
+
const RECIPIENT_ACTIONS = ['forwardTo', 'redirectTo'];
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Refuse a rule whose forwardTo/redirectTo includes an address that
|
|
246
|
+
* OUTLOOK_ALLOWED_RECIPIENTS blocks. The whole rule (or update) is refused,
|
|
247
|
+
* never saved with the blocked forwarding dropped, and a dry run reports the
|
|
248
|
+
* refusal too. Needs no Graph call, so it runs before sign-in. (#273)
|
|
249
|
+
* @param {object} args - Tool arguments
|
|
250
|
+
* @param {{operation?: 'create'|'update', dryRun?: boolean}} [options]
|
|
251
|
+
* @returns {object|null} - toolError naming the blocked addresses, or null
|
|
252
|
+
*/
|
|
253
|
+
function checkRuleRecipients(args, { operation = 'create', dryRun } = {}) {
|
|
254
|
+
const blocked = [];
|
|
255
|
+
for (const param of RECIPIENT_ACTIONS) {
|
|
256
|
+
const addresses = formatRecipientObjects(args[param])
|
|
257
|
+
.filter((recipient) => checkRecipientAllowlist([recipient]))
|
|
258
|
+
.map((recipient) => recipient.emailAddress.address);
|
|
259
|
+
if (addresses.length > 0) {
|
|
260
|
+
blocked.push(`${param}: ${addresses.join(', ')}`);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
if (blocked.length === 0) return null;
|
|
264
|
+
|
|
265
|
+
const subject = operation === 'update' ? 'Rule update' : 'Rule';
|
|
266
|
+
const lead = dryRun
|
|
267
|
+
? `DRY RUN — ${subject.toLowerCase()} would be refused`
|
|
268
|
+
: `${subject} refused`;
|
|
269
|
+
const outcome = operation === 'update' ? 'changed' : 'created';
|
|
270
|
+
return toolError(
|
|
271
|
+
`${lead}: OUTLOOK_ALLOWED_RECIPIENTS does not allow ${blocked.join('; ')}. Nothing was ${outcome}; a rule is refused whole rather than saved without the blocked forwarding.`,
|
|
272
|
+
{
|
|
273
|
+
nextStep:
|
|
274
|
+
'Remove the blocked addresses from forwardTo/redirectTo and retry, or ask the user to add them to OUTLOOK_ALLOWED_RECIPIENTS in the server configuration and restart the server.',
|
|
275
|
+
}
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
|
|
243
279
|
/**
|
|
244
280
|
* Build a Graph API exceptions object from except* prefixed parameters.
|
|
245
281
|
* @param {object} args - Tool arguments
|
|
@@ -267,7 +303,7 @@ function buildExceptions(args) {
|
|
|
267
303
|
exceptions.bodyContains = parseCommaSeparated(args.exceptBodyContains);
|
|
268
304
|
}
|
|
269
305
|
if (args.exceptHasAttachments === true) {
|
|
270
|
-
exceptions.
|
|
306
|
+
exceptions.hasAttachments = true;
|
|
271
307
|
}
|
|
272
308
|
|
|
273
309
|
return { exceptions, warnings };
|
|
@@ -324,6 +360,7 @@ module.exports = {
|
|
|
324
360
|
buildConditions,
|
|
325
361
|
buildActions,
|
|
326
362
|
buildExceptions,
|
|
363
|
+
checkRuleRecipients,
|
|
327
364
|
hasAnyCondition,
|
|
328
365
|
hasAnyAction,
|
|
329
366
|
};
|
package/rules/update.js
CHANGED
|
@@ -9,7 +9,9 @@ const {
|
|
|
9
9
|
buildConditions,
|
|
10
10
|
buildActions,
|
|
11
11
|
buildExceptions,
|
|
12
|
+
checkRuleRecipients,
|
|
12
13
|
} = require('./rule-builder');
|
|
14
|
+
const { toolError, authRequiredError } = require('../utils/tool-error');
|
|
13
15
|
|
|
14
16
|
/**
|
|
15
17
|
* Update rule handler — modify an existing rule by name or ID.
|
|
@@ -20,32 +22,23 @@ const {
|
|
|
20
22
|
async function handleUpdateRule(args) {
|
|
21
23
|
const { ruleName, ruleId, name, isEnabled, sequence, dryRun } = args;
|
|
22
24
|
|
|
23
|
-
// Rate limit
|
|
24
|
-
const rateLimitError = checkRateLimit('manage-rules');
|
|
25
|
-
if (rateLimitError) return rateLimitError;
|
|
26
|
-
|
|
27
25
|
if (!ruleName && !ruleId) {
|
|
28
|
-
return
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
type: 'text',
|
|
32
|
-
text: 'Either ruleName or ruleId is required to identify the rule to update.',
|
|
33
|
-
},
|
|
34
|
-
],
|
|
35
|
-
};
|
|
26
|
+
return toolError(
|
|
27
|
+
'Either ruleName or ruleId is required to identify the rule to update.'
|
|
28
|
+
);
|
|
36
29
|
}
|
|
37
30
|
|
|
38
31
|
if (sequence !== undefined && (isNaN(sequence) || sequence < 1)) {
|
|
39
|
-
return
|
|
40
|
-
content: [
|
|
41
|
-
{
|
|
42
|
-
type: 'text',
|
|
43
|
-
text: 'Sequence must be a positive number greater than zero.',
|
|
44
|
-
},
|
|
45
|
-
],
|
|
46
|
-
};
|
|
32
|
+
return toolError('Sequence must be a positive number greater than zero.');
|
|
47
33
|
}
|
|
48
34
|
|
|
35
|
+
// Refuse the whole update if the allowlist blocks any forwarding (#273)
|
|
36
|
+
const recipientError = checkRuleRecipients(args, {
|
|
37
|
+
operation: 'update',
|
|
38
|
+
dryRun,
|
|
39
|
+
});
|
|
40
|
+
if (recipientError) return recipientError;
|
|
41
|
+
|
|
49
42
|
try {
|
|
50
43
|
const accessToken = await ensureAuthenticated();
|
|
51
44
|
|
|
@@ -63,14 +56,7 @@ async function handleUpdateRule(args) {
|
|
|
63
56
|
}
|
|
64
57
|
|
|
65
58
|
if (!currentRule) {
|
|
66
|
-
return {
|
|
67
|
-
content: [
|
|
68
|
-
{
|
|
69
|
-
type: 'text',
|
|
70
|
-
text: `Rule "${ruleName || ruleId}" not found.`,
|
|
71
|
-
},
|
|
72
|
-
],
|
|
73
|
-
};
|
|
59
|
+
return toolError(`Rule "${ruleName || ruleId}" not found.`);
|
|
74
60
|
}
|
|
75
61
|
|
|
76
62
|
// Build PATCH payload — only include fields the user provided
|
|
@@ -107,14 +93,9 @@ async function handleUpdateRule(args) {
|
|
|
107
93
|
// Check for fatal folder-not-found
|
|
108
94
|
const folderNotFound = actWarnings.some((w) => w.includes('not found'));
|
|
109
95
|
if (folderNotFound && Object.keys(actions).length === 0) {
|
|
110
|
-
return
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
type: 'text',
|
|
114
|
-
text: actWarnings.filter((w) => w.includes('not found')).join('\n'),
|
|
115
|
-
},
|
|
116
|
-
],
|
|
117
|
-
};
|
|
96
|
+
return toolError(
|
|
97
|
+
actWarnings.filter((w) => w.includes('not found')).join('\n')
|
|
98
|
+
);
|
|
118
99
|
}
|
|
119
100
|
|
|
120
101
|
// Build exceptions if any except* param was provided
|
|
@@ -126,14 +107,9 @@ async function handleUpdateRule(args) {
|
|
|
126
107
|
|
|
127
108
|
// Check that something is actually being changed
|
|
128
109
|
if (Object.keys(patch).length === 0) {
|
|
129
|
-
return
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
type: 'text',
|
|
133
|
-
text: `No changes specified for rule "${currentRule.displayName}". Provide at least one parameter to update (name, isEnabled, sequence, conditions, actions, or exceptions).`,
|
|
134
|
-
},
|
|
135
|
-
],
|
|
136
|
-
};
|
|
110
|
+
return toolError(
|
|
111
|
+
`No changes specified for rule "${currentRule.displayName}". Provide at least one parameter to update (name, isEnabled, sequence, conditions, actions, or exceptions).`
|
|
112
|
+
);
|
|
137
113
|
}
|
|
138
114
|
|
|
139
115
|
// Dry-run: show before/after preview
|
|
@@ -142,7 +118,7 @@ async function handleUpdateRule(args) {
|
|
|
142
118
|
const currentPreview = formatRuleDryRunPreview(currentRule);
|
|
143
119
|
const updatedPreview = formatRuleDryRunPreview(updatedRule);
|
|
144
120
|
|
|
145
|
-
let text = `
|
|
121
|
+
let text = `Update preview for "${currentRule.displayName}" (not applied):\n\n`;
|
|
146
122
|
text += `CURRENT:\n${currentPreview}\n\n`;
|
|
147
123
|
text += `AFTER UPDATE:\n${updatedPreview}`;
|
|
148
124
|
if (allWarnings.length > 0) {
|
|
@@ -153,6 +129,10 @@ async function handleUpdateRule(args) {
|
|
|
153
129
|
};
|
|
154
130
|
}
|
|
155
131
|
|
|
132
|
+
// Rate limit only real writes, so a dry run never uses up a slot (#273)
|
|
133
|
+
const rateLimitError = checkRateLimit('manage-rules');
|
|
134
|
+
if (rateLimitError) return rateLimitError;
|
|
135
|
+
|
|
156
136
|
// Execute PATCH
|
|
157
137
|
await callGraphAPI(
|
|
158
138
|
accessToken,
|
|
@@ -197,24 +177,10 @@ async function handleUpdateRule(args) {
|
|
|
197
177
|
};
|
|
198
178
|
} catch (error) {
|
|
199
179
|
if (error.message === 'Authentication required') {
|
|
200
|
-
return
|
|
201
|
-
content: [
|
|
202
|
-
{
|
|
203
|
-
type: 'text',
|
|
204
|
-
text: "Authentication required. Please use the 'auth' tool with action=authenticate first.",
|
|
205
|
-
},
|
|
206
|
-
],
|
|
207
|
-
};
|
|
180
|
+
return authRequiredError();
|
|
208
181
|
}
|
|
209
182
|
|
|
210
|
-
return {
|
|
211
|
-
content: [
|
|
212
|
-
{
|
|
213
|
-
type: 'text',
|
|
214
|
-
text: `Error updating rule: ${error.message}`,
|
|
215
|
-
},
|
|
216
|
-
],
|
|
217
|
-
};
|
|
183
|
+
return toolError(`Error updating rule: ${error.message}`);
|
|
218
184
|
}
|
|
219
185
|
}
|
|
220
186
|
|
package/server.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP server factory: builds the SDK Server for the tool registry.
|
|
3
|
+
*
|
|
4
|
+
* Kept apart from index.js (CLI flags, startup warnings, stdio transport) so
|
|
5
|
+
* protocol behaviour can be tested over an in-memory transport.
|
|
6
|
+
*/
|
|
7
|
+
const { Server } = require('@modelcontextprotocol/sdk/server/index.js');
|
|
8
|
+
const config = require('./config');
|
|
9
|
+
const { createRequestHandler } = require('./request-handler');
|
|
10
|
+
const { TOOLS } = require('./tools');
|
|
11
|
+
const { serverInstructions } = require('./utils/server-instructions');
|
|
12
|
+
const { blockedTools } = require('./utils/safety');
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @param {Array<object>} [tools] - tool definitions (default: the registry)
|
|
16
|
+
* @returns {Server}
|
|
17
|
+
*/
|
|
18
|
+
function createServer(tools = TOOLS) {
|
|
19
|
+
const server = new Server(
|
|
20
|
+
{ name: config.SERVER_NAME, version: config.SERVER_VERSION },
|
|
21
|
+
{
|
|
22
|
+
// Spec shape: the tool list never changes at runtime. Nothing else
|
|
23
|
+
// (resources, prompts, logging) is declared, so those methods return
|
|
24
|
+
// -32601 rather than empty stubs. (#276)
|
|
25
|
+
capabilities: { tools: { listChanged: false } },
|
|
26
|
+
// Model-facing safety rules and usage tips, sent in the initialize
|
|
27
|
+
// result (#271), naming any tool a session limit of 0 blocks (#302).
|
|
28
|
+
instructions: serverInstructions({
|
|
29
|
+
readOnly: config.READ_ONLY,
|
|
30
|
+
blockedTools: blockedTools(),
|
|
31
|
+
}),
|
|
32
|
+
}
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
// Handle all requests. Dispatch + error-shaping logic lives in
|
|
36
|
+
// request-handler.js so it is unit-testable without starting the transport.
|
|
37
|
+
server.fallbackRequestHandler = createRequestHandler(tools);
|
|
38
|
+
return server;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
module.exports = { createServer };
|