@littlebearapps/outlook-assistant 3.13.0 → 3.14.0

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 +27 -3
  2. package/README.md +66 -26
  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 +45 -76
  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 +335 -0
  17. package/calendar/update.js +42 -86
  18. package/categories/index.js +59 -264
  19. package/config.js +29 -1
  20. package/contacts/index.js +72 -128
  21. package/email/attachments.js +42 -124
  22. package/email/conversations.js +44 -78
  23. package/email/delta.js +10 -34
  24. package/email/draft.js +140 -96
  25. package/email/export.js +141 -110
  26. package/email/folder-utils.js +3 -2
  27. package/email/headers.js +11 -49
  28. package/email/index.js +85 -109
  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 +14 -49
  33. package/email/read.js +16 -50
  34. package/email/search.js +46 -86
  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 +17 -16
  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 +6 -20
  43. package/index.js +19 -43
  44. package/llms-install.md +22 -4
  45. package/llms.txt +17 -8
  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 +27 -70
  50. package/rules/index.js +30 -92
  51. package/rules/list.js +5 -17
  52. package/rules/rule-builder.js +57 -20
  53. package/rules/update.js +26 -60
  54. package/server.js +37 -0
  55. package/settings/index.js +142 -143
  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 +109 -25
  66. package/utils/server-instructions.js +62 -0
  67. package/utils/tool-error.js +33 -0
package/llms-install.md CHANGED
@@ -18,6 +18,16 @@ Add to your MCP client configuration:
18
18
  }
19
19
  ```
20
20
 
21
+ ## Plugin Install (Claude Code, GitHub Copilot CLI, Cursor)
22
+
23
+ The plugin in `plugins/outlook-assistant/` runs a pinned server version and adds the `using-outlook-assistant` skill and a safety hook that asks the user before sends, deletes, rules and automatic replies.
24
+
25
+ - Claude Code: `claude plugin marketplace add littlebearapps/outlook-assistant`, then `claude plugin install outlook-assistant@littlebearapps`. The plugin asks for its settings (client ID, read-only mode, confirmation level and others) when enabled.
26
+ - GitHub Copilot CLI: `copilot plugin marketplace add littlebearapps/outlook-assistant`, then `copilot plugin install outlook-assistant@littlebearapps`. No plugin settings: the user gives the client ID at sign-in, and the hook's confirmation level comes from the `OUTLOOK_CONFIRM_LEVEL` environment variable (`outward`, `all-writes` or `off`).
27
+ - Cursor (v3.14.0 plugin or later): loads the folder as a Cursor plugin (`.cursor-plugin/plugin.json`); in Cursor CLI, pass it with `--plugin-dir`. The v3.13.0 plugin fails sign-in in Cursor with `AADSTS900023`; use the manual config instead.
28
+
29
+ Client limits (for example, Cursor's prompt doesn't show the hook's reason): `docs/how-to/getting-started/supported-clients.md`.
30
+
21
31
  ## Prerequisites
22
32
 
23
33
  1. **Node.js 18.18 or newer** must be installed
@@ -71,14 +81,19 @@ Add these to the same `env` block if needed:
71
81
  |----------|---------|
72
82
  | `OUTLOOK_AUTH_AUDIENCE` | `consumers` for Azure apps registered as personal-accounts-only (fixes `AADSTS9002331`); `organizations` or a tenant GUID for work-only apps. Default `common` |
73
83
  | `OUTLOOK_DEFAULT_TIMEZONE` | IANA timezone for calendar times (default `Australia/Melbourne`) |
74
- | `OUTLOOK_MAX_EMAILS_PER_SESSION` | Default per-session cap for `send-email`, `draft` and `manage-rules` (override one tool with `OUTLOOK_MAX_<TOOL>_PER_SESSION`, e.g. `OUTLOOK_MAX_SEND_EMAIL_PER_SESSION`) |
75
- | `OUTLOOK_ALLOWED_RECIPIENTS` | Comma-separated allowlist of recipient domains/addresses |
84
+ | `OUTLOOK_MAX_EMAILS_PER_SESSION` | Default per-session cap, counted separately per tool, for `send-email` (including `draft` send), `draft` create/update/reply/reply-all/forward, `manage-rules` and `create-event` (override one tool with `OUTLOOK_MAX_<TOOL>_PER_SESSION`, e.g. `OUTLOOK_MAX_SEND_EMAIL_PER_SESSION`) |
85
+ | `OUTLOOK_ALLOWED_RECIPIENTS` | Comma-separated allowlist of recipient domains/addresses for sends, drafts, rule forwards and event attendees |
86
+ | `OUTLOOK_READ_ONLY` | `true` refuses every tool call that would change something (sending, drafts, moves, deletes, rules, settings, file writes), dry runs included; reads and sign-in still work |
76
87
  | `OUTLOOK_SHARED_MAILBOX` | Opt-in shared-mailbox support, work/school accounts only: `read` or `true` (read and organise). Also add `Mail.Read.Shared` (and `Mail.ReadWrite.Shared` for `true`) in Azure, restart, then run `auth` with `action=authenticate` and `force=true` |
77
88
  | `OUTLOOK_SEARCH_SCAN_LIMIT` | Messages scanned by the local search fallback on personal accounts (default 500, max 5000) |
89
+ | `OUTLOOK_EXPORT_DIR` | Extra folder `export` and attachment downloads may write to (besides the temp directory, `~/Downloads` and `~/Documents`) |
78
90
  | `OUTLOOK_REQUEST_TIMEOUT_MS` | Per-attempt Graph inactivity timeout in milliseconds (default 60000); not an overall deadline |
91
+ | `OUTLOOK_DEBUG` | `true` for detailed stderr logs while troubleshooting (addresses and IDs redacted). Off by default: one line per tool call, no arguments |
79
92
 
80
93
  Run `npx @littlebearapps/outlook-assistant --help` for the full list of environment variables.
81
94
 
95
+ `OUTLOOK_CONFIRM_LEVEL` is not a server variable: the plugin's safety hook reads it from the client's environment in GitHub Copilot, VS Code and Cursor. Don't put it in the server's `env` block.
96
+
82
97
  ## Configuration Files by Client
83
98
 
84
99
  ### Claude Desktop
@@ -86,9 +101,12 @@ File: `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS)
86
101
 
87
102
  ### Claude Code
88
103
  ```bash
89
- claude mcp add outlook -- npx @littlebearapps/outlook-assistant
104
+ claude mcp add outlook -e OUTLOOK_CLIENT_ID=<user-must-provide> -- npx -y @littlebearapps/outlook-assistant
90
105
  ```
91
106
 
107
+ ### VS Code / GitHub Copilot
108
+ File: `.vscode/mcp.json` in your project root, or the user `mcp.json` (Command Palette → **MCP: Open User Configuration**). VS Code uses a top-level `servers` key instead of `mcpServers`, with `"type": "stdio"` on the entry.
109
+
92
110
  ### Cursor
93
111
  File: `.cursor/mcp.json` in your project root
94
112
 
@@ -111,5 +129,5 @@ After authentication, test with:
111
129
  | Device code "invalid_client" | Enable "Allow public client flows" in Azure → Authentication → Advanced settings |
112
130
  | "Shared-mailbox support is turned off" | Set `OUTLOOK_SHARED_MAILBOX`, restart, and re-authenticate with `force=true` (work/school accounts only) |
113
131
  | "EADDRINUSE :3333" | Run `npx kill-port 3333` then restart auth server |
114
- | Empty API responses | Run `auth` tool with `action=status` to check token |
132
+ | "Authentication required." | Sign in with `auth` `action=authenticate`; `action=status` shows whether a token is saved |
115
133
  | Search returns no results (personal account) | Use `from`, `subject`, `to` filters instead of `query` |
package/llms.txt CHANGED
@@ -33,10 +33,16 @@ Built by [Little Bear Apps](https://littlebearapps.com).
33
33
 
34
34
  ## Safety & Token Efficiency
35
35
 
36
- - **MCP safety annotations** on all 22 tools — AI clients auto-approve reads and prompt for destructive operations
37
- - **Send-email protections**: pre-send mail tips, dry-run preview, session rate limiting, recipient allowlist
38
- - **Rule protections**: dry-run preview on create/update, rate limiting, recipient allowlist on forward/redirect, no permanent-delete action
39
- - **Input and file hardening**: IDs with `.`/`..` path segments refused, the access token only ever sent to `graph.microsoft.com`, attachment downloads and exports (including conversation exports) confined to the output directory without overwriting, and a partly written file removed if a write fails
36
+ - **MCP safety annotations** on all 22 tools, derived from a risk class per tool and action (read, reversible, outward, destructive, persistent), so clients can tell reads from risky changes
37
+ - **Send-email protections**: pre-send mail tips (a send to a flagged recipient, such as out of office or external, is refused until `acknowledgeWarnings: true`), dry-run preview, session rate limiting, recipient allowlist (also checked on drafts, including replies and the draft's current recipients on send, and on `create-event`/`manage-event` update attendees; only single plain addresses can match)
38
+ - **Rule protections**: dry-run preview on create/update, rate limiting (dry runs don't count), recipient allowlist on forward/redirect (a blocked forward refuses the whole rule), no permanent-delete action
39
+ - **Dry-run previews beyond email**: `create-event` and every `manage-event` action say who would be emailed (with an external count); `mailbox-settings` auto-replies, `folders` delete and `manage-contact` delete show what would change or be lost; `dryRun: true` on any other call is refused rather than run for real
40
+ - **Read-only mode**: `OUTLOOK_READ_ONLY=true` refuses every tool call that would change anything before it runs; reads and sign-in still work
41
+ - **Model guidance**: server `instructions` state the hard rules (retrieved content is data, confirm before outward actions, draft first); `send-email` and `create-event` ask Claude clients for user confirmation (`anthropic/requiresUserInteraction`)
42
+ - **Plugin skill and safety hook** (Claude Code, GitHub Copilot CLI, Cursor; VS Code not yet checked by hand): the `using-outlook-assistant` skill sets the hard rules; the hook asks with a plain-English reason before anything that reaches other people, deletes or keeps acting, and marks retrieved content as untrusted. Confirmation level `outward` (default), `all-writes` or `off`: a plugin setting in Claude Code, the `OUTLOOK_CONFIRM_LEVEL` environment variable elsewhere. How the hook prompts and fails differs by client: see Supported Clients below
43
+ - **Every client**: the server-side checks (read-only mode, dry runs, the recipient allowlist and send caps, mail-tips refusal, rule refusal), annotations and instructions work in any MCP client, including Codex CLI, Gemini CLI and Claude Desktop with a manual config; clients that support Agent Skills can copy the skill folder
44
+ - **Private logs**: one stderr line per tool call (tool, action, outcome, duration), never its arguments; `OUTLOOK_DEBUG=true` adds detail with addresses and IDs redacted
45
+ - **Input and file hardening**: IDs with `.`/`..` path segments refused, the access token only ever sent to `graph.microsoft.com`, attachment downloads and exports (including conversation exports) written only inside the temp directory, `~/Downloads`, `~/Documents` or `OUTLOOK_EXPORT_DIR`, never to dot-prefixed names; paths must be absolute (or start with `~/`) and symlinks aren't followed; existing files are never replaced unless `export` is called with `overwrite: true` (and never a symlink, hard-linked file or dotfile); files are created `0600` and new folders `0700`; a partly written file is removed if a write fails
40
46
  - **Throttling-aware Graph client**: `429` (and `503`/`504` for non-POST requests) retried honouring `Retry-After`, a per-attempt inactivity timeout (`OUTLOOK_REQUEST_TIMEOUT_MS`, default 60000 ms) and at most 4 requests in flight, so bulk operations neither hang nor throttle themselves
41
47
  - **Shared mailboxes off by default**: `.Shared` scopes are requested only when `OUTLOOK_SHARED_MAILBOX` is set (`read` keeps shared access read-only)
42
48
  - **Token-optimised**: 22 tools instead of 55 saves ~11,000 tokens per turn (~64% reduction), improving AI accuracy and context efficiency
@@ -58,13 +64,13 @@ Built by [Little Bear Apps](https://littlebearapps.com).
58
64
  }
59
65
  ```
60
66
 
61
- `OUTLOOK_CLIENT_SECRET` is only needed for the browser sign-in flow; the default device-code flow uses the client ID alone. Claude Code users can instead install the plugin: `claude plugin marketplace add littlebearapps/outlook-assistant`, then `claude plugin install outlook-assistant@littlebearapps`. Requires an Azure app registration with Microsoft Graph delegated permissions. See README for full setup.
67
+ `OUTLOOK_CLIENT_SECRET` is only needed for the browser sign-in flow; the default device-code flow uses the client ID alone. Claude Code users can instead install the plugin (server, skill and safety hook): `claude plugin marketplace add littlebearapps/outlook-assistant`, then `claude plugin install outlook-assistant@littlebearapps`. The same plugin folder also loads in GitHub Copilot CLI (VS Code not yet checked by hand) and, from v3.14.0, Cursor. Requires an Azure app registration with Microsoft Graph delegated permissions. See README for full setup.
62
68
 
63
69
  ## Tool Categories
64
70
 
65
71
  - **Authentication (1 tool)**: `auth` — status, authenticate (device code by default), device-code-complete, about (version, granted scopes, shared-mailbox status)
66
72
  - **Email (8 tools)**: `search-emails`, `read-email`, `send-email`, `draft`, `update-email`, `attachments`, `export`, `get-mail-tips`
67
- - **Calendar (3 tools)**: `list-events` (`startAfter`, `startBefore`, `subject` filters), `create-event`, `manage-event` (update, decline, cancel, delete)
73
+ - **Calendar (3 tools)**: `list-events` (`startAfter`, `startBefore`, `subject` filters), `create-event` (`dryRun`; retry-safe `transactionId`), `manage-event` (update, decline, cancel, delete; `dryRun` on all)
68
74
  - **Contacts (2 tools)**: `manage-contact`, `search-people`
69
75
  - **Folders (1 tool)**: `folders` — list, create, move, stats, delete; folders addressable by nested path (`Parent/Child`) or ID
70
76
  - **Rules (1 tool)**: `manage-rules` — list, create, update, reorder, delete
@@ -77,12 +83,15 @@ Built by [Little Bear Apps](https://littlebearapps.com).
77
83
  - [README](https://raw.githubusercontent.com/littlebearapps/outlook-assistant/main/README.md): Full documentation including setup, Azure configuration, and usage
78
84
  - [Tools Reference](https://raw.githubusercontent.com/littlebearapps/outlook-assistant/main/docs/quickrefs/tools-reference.md): All 22 tools with parameters and safety annotations
79
85
  - [Connect Outlook to Claude](https://raw.githubusercontent.com/littlebearapps/outlook-assistant/main/docs/how-to/getting-started/connect-outlook-to-claude.md): Step-by-step setup guide for Claude Desktop / Claude Code
86
+ - [Supported Clients](https://raw.githubusercontent.com/littlebearapps/outlook-assistant/main/docs/how-to/getting-started/supported-clients.md): What each client gets — plugin, skill, safety hook and server-side checks — for Claude Code, GitHub Copilot, Cursor, Codex CLI, Gemini CLI and Claude Desktop
87
+ - [Plugin README](https://raw.githubusercontent.com/littlebearapps/outlook-assistant/main/plugins/outlook-assistant/README.md): Plugin settings, the skill and the safety hook in each client
88
+ - [Cross-client Matrix](https://raw.githubusercontent.com/littlebearapps/outlook-assistant/main/docs/cross-client-matrix.md): Which clients and models have been checked against the safety scenarios, and when
80
89
  - [Verify Your Connection](https://raw.githubusercontent.com/littlebearapps/outlook-assistant/main/docs/how-to/getting-started/verify-your-connection.md): Test and troubleshoot the connection after installation
81
90
  - [Azure Setup](https://raw.githubusercontent.com/littlebearapps/outlook-assistant/main/docs/guides/azure-setup.md): Azure app registration and API permissions walkthrough
82
91
  - [Troubleshooting](https://raw.githubusercontent.com/littlebearapps/outlook-assistant/main/docs/troubleshooting.md): Known errors and fixes — auth, search, export, shared mailboxes
83
92
  - [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/>)
84
93
  - [CLAUDE.md](https://raw.githubusercontent.com/littlebearapps/outlook-assistant/main/CLAUDE.md): Quick reference for development
85
94
  - [CONTRIBUTING](https://raw.githubusercontent.com/littlebearapps/outlook-assistant/main/CONTRIBUTING.md): Contribution guidelines
86
- - [CHANGELOG](https://raw.githubusercontent.com/littlebearapps/outlook-assistant/main/CHANGELOG.md): Version history (current: v3.13.0 — marketplace plugin bundle for Claude Code, GitHub Copilot and Cursor; Azure client ID can be given at sign-in and saved locally; client secret documented as browser-flow only)
87
- - [ROADMAP](https://raw.githubusercontent.com/littlebearapps/outlook-assistant/main/ROADMAP.md): Active milestones (v3.12.x tool description audit, the patch-release fix queue, v3.8.x carry-over, v3.13.0+ new Graph APIs)
95
+ - [CHANGELOG](https://raw.githubusercontent.com/littlebearapps/outlook-assistant/main/CHANGELOG.md): Version history (current: v3.14.0 — security and safety release: read-only mode, server instructions, more dry-run previews, mail-tips refusal, the plugin skill and a safety hook for Claude Code, GitHub Copilot and Cursor, and fixes for three advisories covering the recipient allowlist, export file writes and dry runs)
96
+ - [ROADMAP](https://raw.githubusercontent.com/littlebearapps/outlook-assistant/main/ROADMAP.md): Active milestones (v3.14.0 safety skill, hooks and MCP hardening; v3.15.0 structured outputs and paging; v4.0.0 MCP 2026-07-28 and server-side confirmation; the patch-release fix queue; v3.8.x carry-over; v3.16.0+ new Graph APIs)
88
97
  - [SECURITY](https://raw.githubusercontent.com/littlebearapps/outlook-assistant/main/SECURITY.md): Security policy (report vulnerabilities privately via GitHub private vulnerability reporting; acknowledged within 7 days), token handling, and MCP safety controls
@@ -35,6 +35,9 @@ require('dotenv').config();
35
35
 
36
36
  // Import scopes and token path from central config to stay in sync
37
37
  const { AUTH_CONFIG: centralAuth } = require('./config');
38
+ // Console output never includes the secret, the CSRF state or an auth code,
39
+ // and Azure error text is redacted (it can carry the user's address) (#278).
40
+ const { redact } = require('./utils/logger');
38
41
 
39
42
  // Log to console
40
43
  console.log('Starting Outlook Authentication Server');
@@ -64,7 +67,9 @@ const server = http.createServer((req, res) => {
64
67
 
65
68
  if (query.error) {
66
69
  console.error(
67
- `Authentication error: ${query.error} - ${query.error_description}`
70
+ redact(
71
+ `Authentication error: ${query.error} - ${query.error_description}`
72
+ )
68
73
  );
69
74
  res.writeHead(400, SECURITY_HEADERS);
70
75
  res.end(`
@@ -147,7 +152,7 @@ const server = http.createServer((req, res) => {
147
152
  `);
148
153
  })
149
154
  .catch((error) => {
150
- console.error(`Token exchange error: ${error.message}`);
155
+ console.error(redact(`Token exchange error: ${error.message}`));
151
156
  res.writeHead(500, SECURITY_HEADERS);
152
157
  res.end(`
153
158
  <html>
@@ -253,7 +258,9 @@ const server = http.createServer((req, res) => {
253
258
  // Use the audience from config (defaults to "common"; configurable via
254
259
  // OUTLOOK_AUTH_AUDIENCE for personal-only / single-tenant Azure apps).
255
260
  const authUrl = `${AUTH_CONFIG.authorizeEndpoint}?${querystring.stringify(authParams)}`;
256
- console.log(`Redirecting to: ${authUrl}`);
261
+ console.log(
262
+ `Redirecting to Microsoft sign-in: ${AUTH_CONFIG.authorizeEndpoint}`
263
+ );
257
264
 
258
265
  // Redirect to Microsoft's login page
259
266
  res.writeHead(302, { Location: authUrl });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@littlebearapps/outlook-assistant",
3
- "version": "3.13.0",
3
+ "version": "3.14.0",
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",
@@ -60,6 +60,8 @@
60
60
  "index.js",
61
61
  "config.js",
62
62
  "request-handler.js",
63
+ "server.js",
64
+ "tools.js",
63
65
  "outlook-auth-server.js",
64
66
  "auth/",
65
67
  "calendar/",
@@ -86,6 +88,7 @@
86
88
  "@commitlint/config-conventional": "^20.4.3",
87
89
  "@eslint/js": "^10.0.1",
88
90
  "@modelcontextprotocol/inspector": "^2.8.0",
91
+ "ajv": "^8.20.0",
89
92
  "eslint": "^10.0.2",
90
93
  "globals": "^17.4.0",
91
94
  "husky": "^9.1.7",
@@ -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