@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.
Files changed (67) hide show
  1. package/.env.example +30 -3
  2. package/README.md +67 -27
  3. package/advanced/index.js +44 -174
  4. package/auth/auth-errors.js +23 -1
  5. package/auth/oauth-server.js +7 -1
  6. package/auth/token-manager.js +7 -3
  7. package/auth/token-storage.js +28 -30
  8. package/auth/tools.js +61 -82
  9. package/calendar/attendees.js +36 -0
  10. package/calendar/cancel.js +9 -25
  11. package/calendar/create.js +42 -48
  12. package/calendar/decline.js +10 -25
  13. package/calendar/delete.js +10 -25
  14. package/calendar/index.js +20 -37
  15. package/calendar/list.js +4 -16
  16. package/calendar/preview.js +461 -0
  17. package/calendar/update.js +55 -83
  18. package/categories/index.js +68 -265
  19. package/config.js +29 -1
  20. package/contacts/index.js +72 -128
  21. package/email/attachments.js +43 -125
  22. package/email/conversations.js +44 -78
  23. package/email/delta.js +69 -46
  24. package/email/draft.js +170 -103
  25. package/email/export.js +145 -110
  26. package/email/folder-utils.js +3 -2
  27. package/email/headers.js +11 -49
  28. package/email/index.js +86 -110
  29. package/email/list.js +4 -17
  30. package/email/mail-tips.js +86 -57
  31. package/email/mark-as-read.js +13 -49
  32. package/email/mime.js +39 -51
  33. package/email/read.js +16 -50
  34. package/email/search.js +47 -87
  35. package/email/send.js +82 -48
  36. package/folder/create.js +6 -25
  37. package/folder/delete.js +117 -38
  38. package/folder/index.js +19 -17
  39. package/folder/list.js +5 -17
  40. package/folder/move.js +13 -42
  41. package/folder/resolve.js +11 -6
  42. package/folder/stats.js +18 -27
  43. package/index.js +39 -45
  44. package/llms-install.md +22 -4
  45. package/llms.txt +20 -11
  46. package/outlook-auth-server.js +10 -3
  47. package/package.json +4 -1
  48. package/request-handler.js +217 -116
  49. package/rules/create.js +28 -71
  50. package/rules/index.js +52 -93
  51. package/rules/list.js +7 -19
  52. package/rules/rule-builder.js +59 -22
  53. package/rules/update.js +27 -61
  54. package/server.js +41 -0
  55. package/settings/index.js +162 -145
  56. package/tools.js +30 -0
  57. package/utils/field-presets.js +4 -2
  58. package/utils/graph-api.js +65 -22
  59. package/utils/logger.js +251 -0
  60. package/utils/mock-data.js +91 -2
  61. package/utils/read-only.js +59 -0
  62. package/utils/response-formatter.js +54 -15
  63. package/utils/risk-classes.js +324 -0
  64. package/utils/safe-write.js +372 -6
  65. package/utils/safety.js +247 -42
  66. package/utils/server-instructions.js +73 -0
  67. package/utils/tool-error.js +33 -0
@@ -2,142 +2,243 @@
2
2
  * MCP request dispatcher for the Outlook Assistant server.
3
3
  *
4
4
  * Extracted from index.js so the dispatch + error-shaping logic is
5
- * unit-testable without starting the stdio transport.
5
+ * unit-testable without starting the stdio transport. The SDK answers
6
+ * `initialize` and `ping` itself and negotiates the protocol version; every
7
+ * other request lands here.
6
8
  *
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.
9
+ * Two kinds of failure, kept apart (#276):
10
+ * - Protocol errors are thrown as McpError, which the SDK sends as a real
11
+ * JSON-RPC error: unknown method (-32601), unknown tool (-32602), or a
12
+ * failure inside the dispatcher itself (-32603).
13
+ * - Tool failures (bad arguments, a throwing handler) are returned as a
14
+ * visible tool-error result (`{ content: [...], isError: true }`) so the
15
+ * model can read them and correct itself. A content-less `{ error }` object
16
+ * would be coerced by the SDK into `{ content: [] }`, which clients render
17
+ * as EMPTY OUTPUT (#213).
12
18
  */
19
+ const { McpError, ErrorCode } = require('@modelcontextprotocol/sdk/types.js');
13
20
  const config = require('./config');
14
21
  const { coerceArgsAgainstSchema } = require('./utils/schema-coerce');
22
+ const { readOnlyRefusal } = require('./utils/read-only');
23
+ const { riskMeta, supportsDryRun, TOOL_RISK } = require('./utils/risk-classes');
24
+ const { DRY_RUN_LABEL } = require('./utils/safety');
25
+ const { toolError } = require('./utils/tool-error');
26
+ const { log, withCallContext, formatNoteValue } = require('./utils/logger');
15
27
 
16
28
  /**
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>}
29
+ * A visible tool-error result.
30
+ * @param {string} text
31
+ * @returns {{content: Array<{type: string, text: string}>, isError: true}}
20
32
  */
21
- function createRequestHandler(TOOLS) {
22
- return async (request) => {
23
- try {
24
- const { method, params, id } = request;
25
- console.error(`REQUEST: ${method} [${id}]`);
33
+ function toolErrorResult(text) {
34
+ return { content: [{ type: 'text', text }], isError: true };
35
+ }
26
36
 
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
- }
37
+ /**
38
+ * tools/list result: the public fields of every tool (never the handler).
39
+ * @param {Array<object>} TOOLS
40
+ */
41
+ function listTools(TOOLS) {
42
+ log.debug(`tools/list: ${TOOLS.length} tools`);
43
+ return {
44
+ tools: TOOLS.map((tool) => {
45
+ // Client-specific flags derived from the risk map (#271), e.g.
46
+ // Claude's anthropic/requiresUserInteraction. Others ignore them.
47
+ const meta = riskMeta(tool.name);
48
+ return {
49
+ name: tool.name,
50
+ ...(tool.title && { title: tool.title }),
51
+ description: tool.description,
52
+ inputSchema: tool.inputSchema,
53
+ ...(tool.annotations && { annotations: tool.annotations }),
54
+ ...(meta && { _meta: meta }),
55
+ };
56
+ }),
57
+ };
58
+ }
44
59
 
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(', ')}`);
60
+ /**
61
+ * The refusal for `dryRun: true` on a call that doesn't honour it (#274), or
62
+ * null. Handlers for those actions ignore the flag and really write, so the
63
+ * call never reaches them.
64
+ * @param {string} toolName
65
+ * @param {object} args - validated arguments
66
+ */
67
+ function dryRunRefusal(toolName, args) {
68
+ if (args.dryRun !== true || supportsDryRun(toolName, args.action)) {
69
+ return null;
70
+ }
71
+ const action = args.action ?? TOOL_RISK[toolName]?.defaultAction;
72
+ const call = action ? `${toolName} action=${action}` : toolName;
73
+ return toolError(
74
+ `dryRun is not supported for ${call}; nothing was changed.`,
75
+ {
76
+ nextStep:
77
+ 'Describe the change to the user and ask for confirmation, then call it without dryRun.',
78
+ }
79
+ );
80
+ }
50
81
 
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
- }
82
+ /**
83
+ * Mark a supported dry run's result as a preview: `_meta.dryRun` and the
84
+ * DRY_RUN_LABEL first line, for handlers that don't set them themselves
85
+ * (send-email, draft create, manage-rules create/update).
86
+ * @param {object} result
87
+ */
88
+ function labelDryRun(result) {
89
+ if (!result || result.isError) return result;
90
+ const content = Array.isArray(result.content) ? [...result.content] : [];
91
+ const first = content[0];
92
+ if (first?.type === 'text' && !first.text.startsWith(DRY_RUN_LABEL)) {
93
+ content[0] = { ...first, text: `${DRY_RUN_LABEL}\n\n${first.text}` };
94
+ }
95
+ return { ...result, content, _meta: { ...result._meta, dryRun: true } };
96
+ }
60
97
 
61
- // Required empty responses for other capabilities
62
- if (method === 'resources/list') return { resources: [] };
63
- if (method === 'prompts/list') return { prompts: [] };
98
+ /**
99
+ * Run a tool's handler with validated arguments, unless read-only mode
100
+ * (#271) or an unsupported dryRun (#274) refuses the call first. Read-only
101
+ * mode is checked first, so it refuses even a supported dry run of a
102
+ * non-read call.
103
+ * @param {object} tool
104
+ * @param {object} args
105
+ */
106
+ async function runTool(tool, args) {
107
+ if (config.READ_ONLY) {
108
+ const refusal = readOnlyRefusal(tool.name, args);
109
+ if (refusal) return refusal;
110
+ }
111
+ const refusal = dryRunRefusal(tool.name, args);
112
+ if (refusal) return refusal;
113
+ const result = await tool.handler(args);
114
+ return args.dryRun === true ? labelDryRun(result) : result;
115
+ }
64
116
 
65
- // Tool call handler
66
- if (method === 'tools/call') {
67
- try {
68
- const { name, arguments: args = {} } = params || {};
117
+ /**
118
+ * The action to show on the call line: only a value from the tool's own
119
+ * `action` enum, `?` for anything else, so free text never reaches the log.
120
+ * @param {object|undefined} tool
121
+ * @param {object} args
122
+ * @returns {string|undefined}
123
+ */
124
+ function loggableAction(tool, args) {
125
+ const action = args && args.action;
126
+ if (action === undefined) return undefined;
127
+ const allowed = tool?.inputSchema?.properties?.action?.enum;
128
+ return Array.isArray(allowed) && allowed.includes(action) ? action : '?';
129
+ }
69
130
 
70
- console.error(`TOOL CALL: ${name}`);
131
+ /**
132
+ * The one default-level line per tool call (#278): tool name, action,
133
+ * outcome and duration, plus any notes (e.g. a Graph status) collected
134
+ * during the call. Never the arguments.
135
+ */
136
+ function logToolCall({ tool, action, outcome, startedAt, notes }) {
137
+ const parts = [`tool=${tool}`];
138
+ if (action !== undefined) parts.push(`action=${action}`);
139
+ parts.push(`outcome=${outcome}`, `ms=${Date.now() - startedAt}`);
140
+ for (const [key, value] of notes) {
141
+ parts.push(`${key}=${formatNoteValue(value)}`);
142
+ }
143
+ log.info(parts.join(' '));
144
+ }
71
145
 
72
- // Find the tool handler
73
- const tool = TOOLS.find((t) => t.name === name);
146
+ /**
147
+ * tools/call: validate arguments, then run the tool's handler. Logs one
148
+ * line per call (see logToolCall).
149
+ * @param {Array<object>} TOOLS
150
+ * @param {object} [params]
151
+ */
152
+ function callTool(TOOLS, params) {
153
+ const { name, arguments: args = {} } = params || {};
154
+ const tool = TOOLS.find((t) => t.name === name);
155
+ const startedAt = Date.now();
74
156
 
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
- }
157
+ return withCallContext(async (ctx) => {
158
+ const line = {
159
+ tool: tool ? name : '?',
160
+ action: loggableAction(tool, args),
161
+ startedAt,
162
+ notes: ctx.notes,
163
+ };
164
+ if (!tool || !tool.handler) {
165
+ logToolCall({ ...line, outcome: 'unknown-tool' });
166
+ throw new McpError(ErrorCode.InvalidParams, `Unknown tool: ${name}`);
167
+ }
168
+ log.debug(
169
+ `tools/call ${name} args: ${Object.keys(args || {}).join(', ') || '(none)'}`
170
+ );
97
171
 
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
- }
172
+ const { result, error } = await runToolCall(tool, name, args);
173
+ if (error) {
174
+ // Class name only (e.g. TypeError): the message can carry user data.
175
+ const errorClass = /^[A-Za-z]{1,40}$/.test(error?.name)
176
+ ? error.name
177
+ : 'Error';
178
+ ctx.notes.set('error', errorClass);
179
+ logToolCall({ ...line, outcome: 'thrown' });
180
+ } else {
181
+ logToolCall({ ...line, outcome: result?.isError ? 'isError' : 'ok' });
182
+ }
183
+ return result;
184
+ });
185
+ }
186
+
187
+ /**
188
+ * Coerce and validate the arguments, then run the handler.
189
+ * @returns {Promise<{result: object, error?: Error}>}
190
+ */
191
+ async function runToolCall(tool, name, args) {
192
+ try {
193
+ // Coerce + validate args against the tool's inputSchema before
194
+ // dispatching. Catches array-as-string, boolean-as-string, unknown
195
+ // params, and out-of-enum action values at the MCP boundary so
196
+ // handlers receive properly-typed JS values. (#160, #162)
197
+ if (tool.inputSchema) {
198
+ const coerced = coerceArgsAgainstSchema(args, tool.inputSchema);
199
+ if (coerced.error) {
200
+ return {
201
+ result: toolErrorResult(
202
+ `Invalid arguments for tool '${name}':\n${coerced.error}`
203
+ ),
204
+ };
123
205
  }
206
+ return { result: await runTool(tool, coerced.args) };
207
+ }
208
+ return { result: await runTool(tool, args) };
209
+ } catch (error) {
210
+ log.debug('Error in tools/call:', error);
211
+ return {
212
+ result: toolErrorResult(`Error processing tool call: ${error.message}`),
213
+ error,
214
+ };
215
+ }
216
+ }
124
217
 
125
- // For any other method, return method not found
126
- return {
127
- error: {
128
- code: -32601,
129
- message: `Method not found: ${method}`,
130
- },
131
- };
218
+ /**
219
+ * Build the MCP fallbackRequestHandler for a given tool set.
220
+ * @param {Array<{name: string, title?: string, description?: string, inputSchema?: object, annotations?: object, handler?: Function}>} TOOLS
221
+ * @returns {(request: object) => Promise<object>}
222
+ */
223
+ function createRequestHandler(TOOLS) {
224
+ return async (request) => {
225
+ const { method, params, id } = request;
226
+ log.debug(`REQUEST: ${method} [${id}]`);
227
+
228
+ try {
229
+ if (method === 'tools/list') return listTools(TOOLS);
230
+ if (method === 'tools/call') return await callTool(TOOLS, params);
132
231
  } 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
- };
232
+ if (error instanceof McpError) throw error;
233
+ log.info(`Error in fallbackRequestHandler: ${error.name || 'Error'}`);
234
+ log.debug('Error in fallbackRequestHandler:', error);
235
+ throw new McpError(
236
+ ErrorCode.InternalError,
237
+ `Error processing request: ${error.message}`
238
+ );
140
239
  }
240
+
241
+ throw new McpError(ErrorCode.MethodNotFound, `Method not found: ${method}`);
141
242
  };
142
243
  }
143
244
 
package/rules/create.js CHANGED
@@ -3,16 +3,17 @@
3
3
  */
4
4
  const { callGraphAPI } = require('../utils/graph-api');
5
5
  const { ensureAuthenticated } = require('../auth');
6
- const { checkRateLimit } = require('../utils/safety');
7
- const { formatRuleDryRunPreview } = require('../utils/safety');
6
+ const { checkRateLimit, formatRuleDryRunPreview } = require('../utils/safety');
8
7
  const { getInboxRules } = require('./list');
9
8
  const {
10
9
  buildConditions,
11
10
  buildActions,
12
11
  buildExceptions,
12
+ checkRuleRecipients,
13
13
  hasAnyCondition,
14
14
  hasAnyAction,
15
15
  } = require('./rule-builder');
16
+ const { toolError, authRequiredError } = require('../utils/tool-error');
16
17
 
17
18
  /**
18
19
  * Create rule handler
@@ -22,55 +23,31 @@ const {
22
23
  async function handleCreateRule(args) {
23
24
  const { name, isEnabled = true, sequence, dryRun } = args;
24
25
 
25
- // Rate limit rule creation
26
- const rateLimitError = checkRateLimit('manage-rules');
27
- if (rateLimitError) return rateLimitError;
28
-
29
26
  // Validate sequence parameter
30
27
  if (sequence !== undefined && (isNaN(sequence) || sequence < 1)) {
31
- return {
32
- content: [
33
- {
34
- type: 'text',
35
- text: 'Sequence must be a positive number greater than zero.',
36
- },
37
- ],
38
- };
28
+ return toolError('Sequence must be a positive number greater than zero.');
39
29
  }
40
30
 
41
31
  if (!name) {
42
- return {
43
- content: [
44
- {
45
- type: 'text',
46
- text: 'Rule name is required.',
47
- },
48
- ],
49
- };
32
+ return toolError('Rule name is required.');
50
33
  }
51
34
 
52
35
  if (!hasAnyCondition(args)) {
53
- return {
54
- content: [
55
- {
56
- type: 'text',
57
- text: 'At least one condition is required. Available conditions: fromAddresses, containsSubject, bodyContains, bodyOrSubjectContains, senderContains, recipientContains, sentToAddresses, hasAttachments, importance, sensitivity, sentToMe, sentOnlyToMe, sentCcMe, isAutomaticReply.',
58
- },
59
- ],
60
- };
36
+ return toolError(
37
+ 'At least one condition is required. Available conditions: fromAddresses, containsSubject, bodyContains, bodyOrSubjectContains, senderContains, recipientContains, sentToAddresses, hasAttachments, importance, sensitivity, sentToMe, sentOnlyToMe, sentCcMe, isAutomaticReply.'
38
+ );
61
39
  }
62
40
 
63
41
  if (!hasAnyAction(args)) {
64
- return {
65
- content: [
66
- {
67
- type: 'text',
68
- text: 'At least one action is required. Available actions: moveToFolder, copyToFolder, markAsRead, markImportance, forwardTo, redirectTo, assignCategories, stopProcessingRules, deleteMessage.',
69
- },
70
- ],
71
- };
42
+ return toolError(
43
+ 'At least one action is required. Available actions: moveToFolder, copyToFolder, markAsRead, markImportance, forwardTo, redirectTo, assignCategories, stopProcessingRules, deleteMessage.'
44
+ );
72
45
  }
73
46
 
47
+ // Refuse the whole rule if the allowlist blocks any forwarding (#273)
48
+ const recipientError = checkRuleRecipients(args, { dryRun });
49
+ if (recipientError) return recipientError;
50
+
74
51
  try {
75
52
  const accessToken = await ensureAuthenticated();
76
53
 
@@ -87,14 +64,9 @@ async function handleCreateRule(args) {
87
64
  // Check for fatal warnings (folder not found = no valid action)
88
65
  const folderNotFound = actWarnings.some((w) => w.includes('not found'));
89
66
  if (folderNotFound && Object.keys(actions).length === 0) {
90
- return {
91
- content: [
92
- {
93
- type: 'text',
94
- text: actWarnings.filter((w) => w.includes('not found')).join('\n'),
95
- },
96
- ],
97
- };
67
+ return toolError(
68
+ actWarnings.filter((w) => w.includes('not found')).join('\n')
69
+ );
98
70
  }
99
71
 
100
72
  // Determine sequence
@@ -132,7 +104,7 @@ async function handleCreateRule(args) {
132
104
  // Dry-run: preview without creating
133
105
  if (dryRun) {
134
106
  const preview = formatRuleDryRunPreview(rule);
135
- let text = `DRY RUN — Rule preview (not created):\n\n${preview}`;
107
+ let text = `Rule preview (not created):\n\n${preview}`;
136
108
  if (allWarnings.length > 0) {
137
109
  text += `\n\nWarnings:\n${allWarnings.map((w) => `- ${w}`).join('\n')}`;
138
110
  }
@@ -141,6 +113,10 @@ async function handleCreateRule(args) {
141
113
  };
142
114
  }
143
115
 
116
+ // Rate limit only real writes, so a dry run never uses up a slot (#273)
117
+ const rateLimitError = checkRateLimit('manage-rules');
118
+ if (rateLimitError) return rateLimitError;
119
+
144
120
  // Create the rule
145
121
  const response = await callGraphAPI(
146
122
  accessToken,
@@ -167,34 +143,15 @@ async function handleCreateRule(args) {
167
143
  };
168
144
  }
169
145
 
170
- return {
171
- content: [
172
- {
173
- type: 'text',
174
- text: "Failed to create rule. The server didn't return a rule ID.",
175
- },
176
- ],
177
- };
146
+ return toolError(
147
+ "Failed to create rule. The server didn't return a rule ID."
148
+ );
178
149
  } catch (error) {
179
150
  if (error.message === 'Authentication required') {
180
- return {
181
- content: [
182
- {
183
- type: 'text',
184
- text: "Authentication required. Please use the 'auth' tool with action=authenticate first.",
185
- },
186
- ],
187
- };
151
+ return authRequiredError();
188
152
  }
189
153
 
190
- return {
191
- content: [
192
- {
193
- type: 'text',
194
- text: `Error creating rule: ${error.message}`,
195
- },
196
- ],
197
- };
154
+ return toolError(`Error creating rule: ${error.message}`);
198
155
  }
199
156
  }
200
157