diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json index 51d74d5..5b927fa 100644 --- a/.agents/plugins/marketplace.json +++ b/.agents/plugins/marketplace.json @@ -6,7 +6,7 @@ "plugins": [ { "name": "apple-notes", - "version": "2.6.15", + "version": "2.6.16", "source": { "source": "local", "path": "./codex" diff --git a/.antigravity-plugin/marketplace.json b/.antigravity-plugin/marketplace.json index bfaff9d..5079a08 100644 --- a/.antigravity-plugin/marketplace.json +++ b/.antigravity-plugin/marketplace.json @@ -7,7 +7,7 @@ "plugins": [ { "name": "apple-notes", - "version": "2.6.15", + "version": "2.6.16", "description": "Manage Apple Notes through natural language - create, search, read, update, delete, organize folders, inspect metadata, run diagnostics, and work with attachments (macOS only).", "source": { "source": "local", diff --git a/.antigravity-plugin/plugin.json b/.antigravity-plugin/plugin.json index f6234aa..fd50902 100644 --- a/.antigravity-plugin/plugin.json +++ b/.antigravity-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "apple-notes", - "version": "2.6.15", + "version": "2.6.16", "description": "Manage Apple Notes through natural language - create, search, read, update, delete, organize folders, inspect metadata, run diagnostics, and work with attachments (macOS only).", "author": { "name": "Rob Sweet", diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 58d5454..fed22bf 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -11,7 +11,7 @@ { "name": "apple-notes", "description": "Manage Apple Notes through natural language - create, search, read, update, delete, and organize notes and folders", - "version": "2.6.15", + "version": "2.6.16", "author": { "name": "Rob Sweet", "email": "rob@superiortech.io" diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index d367513..ce3a6f3 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "apple-notes", - "version": "2.6.15", + "version": "2.6.16", "description": "Manage Apple Notes through natural language - create, search, read, update, delete, and organize notes and folders (macOS only)", "author": { "name": "Rob Sweet", diff --git a/CHANGELOG.md b/CHANGELOG.md index bc3f39d..e4c3d94 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ ## [Unreleased] +## [2.6.16] - 2026-08-06 + +### Fixed +- **Every tool advertised an output schema that rejected undeclared keys, discarding otherwise-correct results.** The MCP **client** validates a result's `structuredContent` against the JSON Schema the server *advertised*, not against the server's own zod object — and a bare zod raw shape renders as `additionalProperties: false`. So any field a handler emits that its schema doesn't enumerate is a hard client-side `-32602 … data must NOT have additional properties`, throwing away a payload the handler computed correctly. The server never notices, because zod's own parse silently *strips* unknown keys instead of failing, which is exactly why the `registerTool`/`outputSchema` migration's "all fields optional, no `.strict()`" read as permissive: it covered optionality, not undeclared keys. All **36 tools** in this repo were advertising `additionalProperties: false`. Every tool now registers through a wrapper applying `.passthrough()`, advertising `additionalProperties: true` — the contract that migration intended. Found while fixing the same defect in the sibling apple-mail-mcp (sweetrb/apple-mail-mcp#135), where it was not latent: it broke `get-mail-stats` on every call for anyone with IMAP configured. + +### Added +- **The outputSchema contract test now asserts that every tool tolerates undeclared keys.** The existing checks — every tool has an `outputSchema`, none requires a field — could not see this class, because they inspect the advertised schema and round-trip only the diagnostic tools; a tool whose payload carries an undeclared key passes CI and fails in the user's client. The suite now fails any tool advertising `additionalProperties: false`, so this cannot silently return. + ## [2.6.15] - 2026-08-05 ### Changed - Dependency bump via Dependabot; committed bundle rebuilt. (automated) diff --git a/build/index.js b/build/index.js index 9b93381..5fd959d 100755 --- a/build/index.js +++ b/build/index.js @@ -42168,7 +42168,15 @@ var folderNameSchema = { name: external_exports.string().min(1, "Folder name is required").max(MAX.FOLDER), account: external_exports.string().max(MAX.ACCOUNT).optional().describe("Account name (defaults to iCloud)") }; -server.registerTool( +function registerTool(name, config2, cb) { + const { outputSchema, ...rest } = config2; + return server.registerTool( + name, + outputSchema ? { ...rest, outputSchema: external_exports.object(outputSchema).passthrough() } : rest, + cb + ); +} +registerTool( "create-note", { description: "Use when: the user wants to create a brand-new Apple Note.\nReturns: the new note's title and id \u2014 reuse the id for follow-up reads/edits.\nDo not use when: editing an existing note (use update-note).\nNote: the title is prepended as an

; true Apple Notes checklists cannot be created via AppleScript (see the content field). A 'folder' must already exist \u2014 create-folder first (it is idempotent), since this tool does not create it.", @@ -42214,7 +42222,7 @@ server.registerTool( }); }, "Error creating note") ); -server.registerTool( +registerTool( "search-notes", { description: "Use when: finding notes by a keyword in the title (or body with searchContent=true) and you need their ids.\nReturns: matching notes with title, folder, and id.\nDo not use when: you already have a note id (use get-note-content) or want every note (use list-notes).\nPrefer this first to obtain ids for subsequent read/update/delete/move calls.", @@ -42287,7 +42295,7 @@ ${noteList}${truncationNote}${syncNote}`, ); }, "Error searching notes") ); -server.registerTool( +registerTool( "get-note-content", { description: "Use when: reading the full body text of one known note, by id (preferred) or title.\nReturns: the note's content plus parsed hashtags, and strippedImages/truncated when the body was capped.\nDo not use when: you only need metadata (get-note-details) or Markdown with checklist state (get-note-markdown).\nNote: password-protected notes must be unlocked in Notes.app first.\nSafety: inline images larger than APPLE_NOTES_MCP_MAX_INLINE_IMAGE_BYTES (default 256 KB) are replaced with '[inline image omitted: ...]' text placeholders, so the returned body is lossy whenever truncated is true \u2014 do NOT write it back with update-note or the real images are replaced by that text. Use append-to-note to add content, or export the images with save-attachment / fetch-attachment first.", @@ -42362,7 +42370,7 @@ server.registerTool( }); }, "Error retrieving note content") ); -server.registerTool( +registerTool( "get-note-plaintext", { description: "Use when: reading one note's body as plain text with no HTML, by id (preferred) or title.\nReturns: the note's plaintext exactly as Notes exposes it.\nDo not use when: you need the HTML body (get-note-content) or Markdown with checklist state (get-note-markdown).\nNote: this reads the note's native plaintext property, so it skips the HTML-to-text conversion; password-protected notes must be unlocked in Notes.app first.", @@ -42412,7 +42420,7 @@ server.registerTool( return successResponse(plaintext, { title, plaintext }); }, "Error retrieving note plaintext") ); -server.registerTool( +registerTool( "get-note-by-id", { description: "Use when: you have a note id and need its metadata only.\nReturns: id, title, created, modified, shared, passwordProtected.\nDo not use when: you need the body text (get-note-content) or only have a title (get-note-details).", @@ -42444,7 +42452,7 @@ server.registerTool( return successResponse(JSON.stringify(metadata, null, 2), metadata); }, "Error retrieving note") ); -server.registerTool( +registerTool( "get-note-details", { description: "Use when: you have a note title (not an id) and need its metadata.\nReturns: id, title, created, modified, shared, passwordProtected, account.\nDo not use when: you have an id (get-note-by-id) or need the body text (get-note-content).\nUse the returned id for reliable follow-up operations.", @@ -42476,7 +42484,7 @@ server.registerTool( return successResponse(JSON.stringify(metadata, null, 2), metadata); }, "Error retrieving note details") ); -server.registerTool( +registerTool( "show-note", { description: "Use when: the user wants to reveal a known note in Notes.app by id.\nReturns: confirmation that Notes.app accepted the show command.\nDo not use when: you only need note content (get-note-content) or metadata (get-note-by-id).\nNote: this opens or focuses the Notes UI.", @@ -42497,7 +42505,7 @@ server.registerTool( return successResponse(`Shown note with ID "${id}" in Notes.app`, { id, separately }); }, "Error showing note") ); -server.registerTool( +registerTool( "get-note-link", { description: "Use when: you need the notes:// deep-link URL for a note so it can be stored in a Reminders task, shared, or opened directly.\nReturns: a notes://showNote?identifier= URL that opens the note in Notes.app on iOS and macOS.\nDo not use when: you only need the note's CoreData id (get-note-by-id) or want to reveal the note on screen (show-note).\nNote: the primary path reads the note's identifier from the Notes database, so it needs Full Disk Access for the app that launches this server; macOS 12-15 can fall back to the AppleScript 'note link' property, which macOS 26+ no longer exposes. Password-protected notes cannot be linked.", @@ -42552,7 +42560,7 @@ server.registerTool( return successResponse(`Note link: ${url}`, { title, url }); }, "Error getting note link") ); -server.registerTool( +registerTool( "show-folder", { description: "Use when: the user wants to reveal a known folder in Notes.app by id.\nReturns: confirmation that Notes.app accepted the show command.\nDo not use when: you only need the folder list (list-folders).\nNote: this opens or focuses the Notes UI. Get the id from list-folders.", @@ -42573,7 +42581,7 @@ server.registerTool( return successResponse(`Shown folder with ID "${id}" in Notes.app`, { id, separately }); }, "Error showing folder") ); -server.registerTool( +registerTool( "show-account", { description: "Use when: the user wants to reveal a known account in Notes.app by id.\nReturns: confirmation that Notes.app accepted the show command.\nDo not use when: you only need the account list (list-accounts).\nNote: this opens or focuses the Notes UI. Get the id from list-accounts.", @@ -42594,7 +42602,7 @@ server.registerTool( return successResponse(`Shown account with ID "${id}" in Notes.app`, { id, separately }); }, "Error showing account") ); -server.registerTool( +registerTool( "update-note", { description: "Use when: changing the title and/or replacing the body of an existing note, by id (preferred) or title.\nReturns: confirmation; warns when the note is shared.\nDo not use when: creating a new note (create-note).\nSafety: newContent REPLACES the entire body \u2014 it does not append. Read the note first if you need to preserve existing text, and run list-attachments first when the note may hold files, images, scans, PDFs, or audio, since a full-body replace can drop embedded attachments. Edits to shared notes are immediately visible to all collaborators.", @@ -42670,7 +42678,7 @@ server.registerTool( }); }, "Error updating note") ); -server.registerTool( +registerTool( "append-to-note", { description: "Use when: adding content to an existing note without replacing it, by id (preferred) or title.\nReturns: confirmation with the note id and title.\nDo not use when: creating a new note (create-note) or replacing the entire body (update-note).\nSafety: reads the existing body first, concatenates, then writes back. Run list-attachments first if the note may hold embedded files \u2014 a full-body rewrite can drop attachments.", @@ -42785,7 +42793,7 @@ server.registerTool( "Error appending to note" ) ); -server.registerTool( +registerTool( "delete-note", { description: "Use when: permanently deleting a single note, by id (preferred) or title.\nReturns: confirmation; warns when the note was shared.\nDo not use when: deleting many notes (batch-delete-notes) or just relocating one (move-note).\nSafety: requires explicit user confirmation before deleting. Prefer search-notes/list-notes first to show the affected note id and title. Deleting a shared note removes collaborator access.", @@ -42840,7 +42848,7 @@ server.registerTool( }); }, "Error deleting note") ); -server.registerTool( +registerTool( "move-note", { description: "Use when: moving one note to a different folder, by id (preferred) or title.\nReturns: confirmation of the note and destination folder.\nDo not use when: moving many notes (batch-move-notes).\nNote: the note is relocated in place via Notes.app's native move, preserving its id, creation date, and all attachments. The destination folder must already exist (create-folder).", @@ -42898,7 +42906,7 @@ server.registerTool( }); }, "Error moving note") ); -server.registerTool( +registerTool( "list-notes", { description: "Use when: enumerating notes in an account or folder; supports modifiedSince and limit for large collections.\nReturns: note titles only (no content or ids).\nDo not use when: you need content (get-note-content) or ids for follow-up edits (use search-notes).\nNote: warns if iCloud sync is active and results may be partial.", @@ -42952,7 +42960,7 @@ ${noteList}${syncNote}`, ); }, "Error listing notes") ); -server.registerTool( +registerTool( "get-selected-notes", { description: "Use when: the user asks what note(s) are currently selected in Notes.app.\nReturns: selected note metadata with ids for follow-up operations.\nDo not use when: searching all notes (search-notes) or listing a folder (list-notes).\nNote: reads Notes.app UI selection; it may be empty if Notes is closed or no note is selected.", @@ -42975,7 +42983,7 @@ server.registerTool( ${noteList}`, { notes, count: notes.length }); }, "Error getting selected notes") ); -server.registerTool( +registerTool( "list-folders", { description: "Use when: listing all folders, with full nested paths, for an account.\nReturns: folder names/paths.\nDo not use when: listing notes (list-notes).\nNote: warns if iCloud sync is active.", @@ -43015,7 +43023,7 @@ ${folderList}${syncNote}`, { }); }, "Error listing folders") ); -server.registerTool( +registerTool( "create-folder", { description: "Use when: creating a folder, including nested paths like 'Work/Clients' (intermediate folders are created, existing ones skipped).\nReturns: confirmation.\nDo not use when: creating a note (create-note).", @@ -43041,7 +43049,7 @@ server.registerTool( }); }, "Error creating folder") ); -server.registerTool( +registerTool( "delete-folder", { description: "Use when: deleting an existing folder by name or nested path.\nReturns: confirmation.\nDo not use when: deleting a note (delete-note).\nSafety: requires explicit user confirmation. Deletion fails if the folder still contains notes \u2014 list or move those notes first.", @@ -43061,7 +43069,7 @@ server.registerTool( return successResponse(`Folder deleted: "${name}"`, { ok: true, folder: name }); }, "Error deleting folder") ); -server.registerTool( +registerTool( "list-accounts", { description: "Use when: discovering which Notes accounts exist (iCloud, Gmail, Exchange, etc.) before targeting one.\nReturns: account names.\nDo not use when: you already know the account, or are working by note id (ids are account-independent).", @@ -43088,7 +43096,7 @@ ${accountList}`, { }); }, "Error listing accounts") ); -server.registerTool( +registerTool( "get-default-location", { description: "Use when: discovering where Notes.app will create new notes by default.\nReturns: default account and default folder metadata.\nDo not use when: you already have an explicit account/folder target.", @@ -43105,7 +43113,7 @@ Default folder: ${location.folder.name} [id: ${location.folder.id}]`; return successResponse(message, { ...location }); }, "Error getting default Notes location") ); -server.registerTool( +registerTool( "list-shared-notes", { description: "Use when: finding notes shared with collaborators.\nReturns: shared notes with title, account, and id.\nDo not use when: searching all notes (search-notes).\nNote: edits or deletes to these notes affect all collaborators.", @@ -43136,7 +43144,7 @@ ${noteList} ); }, "Error listing shared notes") ); -server.registerTool( +registerTool( "get-sync-status", { description: "Use when: checking whether iCloud sync is in progress before trusting read results.\nReturns: sync active/idle, pending upload count, and seconds since last change.\nDo not use when: you need note data \u2014 this is a read-only diagnostics tool.", @@ -43175,7 +43183,7 @@ server.registerTool( return successResponse(lines.join("\n"), { ...status }); }, "Error checking sync status") ); -server.registerTool( +registerTool( "health-check", { description: "Use when: a quick check that Notes.app is reachable and (optionally) Full Disk Access is granted for checklist features.\nReturns: pass/fail per check.\nDo not use when: you need detailed, actionable setup diagnostics (use doctor).\nRead-only.", @@ -43206,7 +43214,7 @@ ${fdaLine}`, { }); }, "Error running health check") ); -server.registerTool( +registerTool( "doctor", { description: "Use when: diagnosing setup problems (Notes.app automation permission, account state, Full Disk Access) with actionable guidance.\nReturns: a detailed report plus structured fields.\nDo not use when: you just need a quick pass/fail (health-check).\nRead-only.", @@ -43221,7 +43229,7 @@ server.registerTool( return successResponse(formatDoctorReport(report), { ...report }); }, "Error running doctor") ); -server.registerTool( +registerTool( "get-notes-stats", { description: "Use when: summarizing the library \u2014 total notes, per-account/folder counts, and recent activity.\nReturns: aggregate statistics; flags partial coverage when some scopes were unreadable.\nDo not use when: you need individual notes (list-notes/search-notes).\nRead-only.", @@ -43266,7 +43274,7 @@ server.registerTool( return successResponse(lines.join("\n"), { ...stats }); }, "Error getting notes statistics") ); -server.registerTool( +registerTool( "list-attachments", { description: "Use when: listing the attachments of one note, by id (preferred) or title.\nReturns: each attachment's name, content type, and id (use with save-attachment/fetch-attachment).\nDo not use when: you want the attachment bytes (fetch-attachment) or a file on disk (save-attachment).", @@ -43321,7 +43329,7 @@ ${attachmentList}`, ); }, "Error listing attachments") ); -server.registerTool( +registerTool( "batch-delete-notes", { description: "Use when: permanently deleting multiple notes by id in one call.\nReturns: per-id success/failure counts.\nDo not use when: deleting a single note (delete-note).\nSafety: requires explicit user confirmation; this is destructive and not undoable. Prefer search-notes/list-notes first to confirm the exact ids being deleted.", @@ -43357,7 +43365,7 @@ server.registerTool( }) : errorResponse(lines.join("\n")); }, "Error performing batch delete") ); -server.registerTool( +registerTool( "batch-move-notes", { description: "Use when: moving multiple notes by id into one destination folder.\nReturns: per-id success/failure counts.\nDo not use when: moving a single note (move-note).\nNote: the destination folder must already exist (create-folder).", @@ -43399,7 +43407,7 @@ server.registerTool( }) : errorResponse(lines.join("\n")); }, "Error performing batch move") ); -server.registerTool( +registerTool( "save-attachment", { description: "Use when: writing one note attachment to a file on disk.\nReturns: the saved path.\nDo not use when: you want the bytes in-memory as base64 (fetch-attachment).\nSafety: writes a file; savePath must be absolute and under the home directory, a temp dir, or /Volumes. Get the ids from list-attachments first.", @@ -43426,7 +43434,7 @@ server.registerTool( }); }, "Error saving attachment") ); -server.registerTool( +registerTool( "fetch-attachment", { description: "Use when: retrieving one note attachment's bytes inline as base64 (no file written).\nReturns: name, content type, byte count, and base64 data.\nDo not use when: you want it saved to disk (save-attachment).\nNote: get the ids from list-attachments first.", @@ -43452,7 +43460,7 @@ server.registerTool( ); }, "Error fetching attachment") ); -server.registerTool( +registerTool( "show-attachment", { description: "Use when: the user wants to reveal one note attachment in Notes.app.\nReturns: confirmation that Notes.app revealed the attachment.\nDo not use when: you want the bytes (fetch-attachment) or a file on disk (save-attachment).\nNote: this opens or focuses the Notes UI. Get the ids from list-attachments first.", @@ -43479,7 +43487,7 @@ server.registerTool( }); }, "Error showing attachment") ); -server.registerTool( +registerTool( "export-notes-json", { description: "Use when: exporting the entire notes library as structured JSON for backup or bulk processing.\nReturns: a summary plus the full JSON of all notes, folders, and accounts.\nDo not use when: you need a single note (get-note-content) \u2014 this reads everything and can be large.\nRead-only.", @@ -43511,7 +43519,7 @@ Full JSON export:` }; }, "Error exporting notes") ); -server.registerTool( +registerTool( "get-note-markdown", { description: "Use when: reading a note as Markdown, with checklist items annotated [x]/[ ] when Full Disk Access is granted.\nReturns: the note's Markdown.\nDo not use when: you need the raw HTML/plaintext body (get-note-content) or only metadata (get-note-details).\nNote: falls back to plain lists (no checkmarks) without Full Disk Access.", @@ -43544,7 +43552,7 @@ server.registerTool( return successResponse(markdown, { markdown }); }, "Error getting note as markdown") ); -server.registerTool( +registerTool( "get-checklist-state", { description: "Use when: reading the checked/unchecked state of a note's checklist items, by id.\nReturns: each item's text and done state plus checked/total counts.\nDo not use when: you only have a title (get the id via search-notes first) or want the full body text (get-note-content).\nNote: requires Full Disk Access; reads the NoteStore database directly.", @@ -43580,7 +43588,7 @@ ${summary}`, ); }, "Error reading checklist state") ); -server.registerTool( +registerTool( "get-note-metadata", { description: "[BETA] Use when: reading note metadata AppleScript cannot expose \u2014 pinned state, checklist flags, trash/recovery state, preview snippet, password hint \u2014 by id.\nReturns: a metadata object; fields vary by macOS version and are omitted when unavailable.\nDo not use when: you need the body (get-note-content) or per-item checklist state (get-checklist-state).\nNote: reads the NoteStore SQLite database read-only and requires Full Disk Access. BETA \u2014 the database schema changes between macOS releases, so some fields may be absent. Works on trashed notes that AppleScript can no longer resolve.", diff --git a/codex/.codex-plugin/plugin.json b/codex/.codex-plugin/plugin.json index a3387b4..7d97d50 100644 --- a/codex/.codex-plugin/plugin.json +++ b/codex/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "apple-notes", - "version": "2.6.15", + "version": "2.6.16", "description": "Manage Apple Notes through natural language - create, search, read, update, delete, organize folders, inspect metadata, run diagnostics, and work with attachments (macOS only).", "author": { "name": "Rob Sweet", diff --git a/package.json b/package.json index 5aa3811..679348a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "apple-notes-mcp", - "version": "2.6.15", + "version": "2.6.16", "packageManager": "pnpm@11.9.0", "description": "MCP server for Apple Notes - create, search, update, and manage notes via Claude and other AI assistants", "type": "module", diff --git a/src/index.ts b/src/index.ts index f395225..ee7bbb5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -21,7 +21,12 @@ */ import { createRequire } from "module"; -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { + McpServer, + type RegisteredTool, + type ToolCallback, +} from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { ToolAnnotations } from "@modelcontextprotocol/sdk/types.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { z } from "zod"; import { AppleNotesManager } from "@/services/appleNotesManager.js"; @@ -167,7 +172,52 @@ const folderNameSchema = { // --- create-note --- -server.registerTool( +/** + * Register a tool, advertising its `outputSchema` as PERMISSIVE. + * + * The MCP **client** validates a result's `structuredContent` against the JSON + * Schema the server advertised — not against the server's own zod object. A + * bare zod raw shape renders as `additionalProperties: false`, so a payload + * carrying any field the schema didn't enumerate is rejected client-side with + * `-32602 … data must NOT have additional properties`, discarding a result the + * handler produced correctly. The server never sees it, because zod's own parse + * silently *strips* unknown keys rather than failing — which is why the + * registerTool/outputSchema migration's "all fields optional, no `.strict()`" + * was believed to be permissive. It covered optionality; it did not cover + * undeclared keys. + * + * `.passthrough()` advertises `additionalProperties: true`, which is the + * contract that migration intended: a declared field documents the shape, an + * undeclared one is carried through instead of nuking the whole result. This is + * not hypothetical — it took down `get-mail-stats` in the sibling + * apple-mail-mcp (sweetrb/apple-mail-mcp#135), where every tool was likewise + * advertising `additionalProperties: false` and the one tool whose payload + * carried an undeclared key failed on every call. Enforced for every tool here + * by the outputSchema contract test. + */ +function registerTool< + OutputArgs extends z.ZodRawShape, + InputArgs extends undefined | z.ZodRawShape = undefined, +>( + name: string, + config: { + title?: string; + description?: string; + inputSchema?: InputArgs; + outputSchema?: OutputArgs; + annotations?: ToolAnnotations; + }, + cb: ToolCallback +): RegisteredTool { + const { outputSchema, ...rest } = config; + return server.registerTool( + name, + outputSchema ? { ...rest, outputSchema: z.object(outputSchema).passthrough() } : rest, + cb + ); +} + +registerTool( "create-note", { description: @@ -246,7 +296,7 @@ server.registerTool( // --- search-notes --- -server.registerTool( +registerTool( "search-notes", { description: @@ -346,7 +396,7 @@ server.registerTool( // --- get-note-content --- -server.registerTool( +registerTool( "get-note-content", { description: @@ -447,7 +497,7 @@ server.registerTool( // --- get-note-plaintext --- -server.registerTool( +registerTool( "get-note-plaintext", { description: @@ -519,7 +569,7 @@ server.registerTool( // --- get-note-by-id --- -server.registerTool( +registerTool( "get-note-by-id", { description: @@ -559,7 +609,7 @@ server.registerTool( // --- get-note-details --- -server.registerTool( +registerTool( "get-note-details", { description: @@ -599,7 +649,7 @@ server.registerTool( // --- show-note --- -server.registerTool( +registerTool( "show-note", { description: @@ -627,7 +677,7 @@ server.registerTool( // --- get-note-link --- -server.registerTool( +registerTool( "get-note-link", { description: @@ -700,7 +750,7 @@ server.registerTool( // --- show-folder --- -server.registerTool( +registerTool( "show-folder", { description: @@ -728,7 +778,7 @@ server.registerTool( // --- show-account --- -server.registerTool( +registerTool( "show-account", { description: @@ -756,7 +806,7 @@ server.registerTool( // --- update-note --- -server.registerTool( +registerTool( "update-note", { description: @@ -874,7 +924,7 @@ server.registerTool( // --- append-to-note --- -server.registerTool( +registerTool( "append-to-note", { description: @@ -1052,7 +1102,7 @@ server.registerTool( // --- delete-note --- -server.registerTool( +registerTool( "delete-note", { description: @@ -1137,7 +1187,7 @@ server.registerTool( // --- move-note --- -server.registerTool( +registerTool( "move-note", { description: @@ -1219,7 +1269,7 @@ server.registerTool( // --- list-notes --- -server.registerTool( +registerTool( "list-notes", { description: @@ -1284,7 +1334,7 @@ server.registerTool( // --- get-selected-notes --- -server.registerTool( +registerTool( "get-selected-notes", { description: @@ -1315,7 +1365,7 @@ server.registerTool( // --- list-folders --- -server.registerTool( +registerTool( "list-folders", { description: @@ -1361,7 +1411,7 @@ server.registerTool( // --- create-folder --- -server.registerTool( +registerTool( "create-folder", { description: @@ -1397,7 +1447,7 @@ server.registerTool( // --- delete-folder --- -server.registerTool( +registerTool( "delete-folder", { description: @@ -1427,7 +1477,7 @@ server.registerTool( // --- list-accounts --- -server.registerTool( +registerTool( "list-accounts", { description: @@ -1461,7 +1511,7 @@ server.registerTool( // --- get-default-location --- -server.registerTool( +registerTool( "get-default-location", { description: @@ -1487,7 +1537,7 @@ server.registerTool( // --- list-shared-notes --- -server.registerTool( +registerTool( "list-shared-notes", { description: @@ -1529,7 +1579,7 @@ server.registerTool( // --- get-sync-status --- -server.registerTool( +registerTool( "get-sync-status", { description: @@ -1576,7 +1626,7 @@ server.registerTool( // --- health-check --- -server.registerTool( +registerTool( "health-check", { description: @@ -1622,7 +1672,7 @@ server.registerTool( // --- doctor --- -server.registerTool( +registerTool( "doctor", { description: @@ -1643,7 +1693,7 @@ server.registerTool( // --- get-notes-stats --- -server.registerTool( +registerTool( "get-notes-stats", { description: @@ -1702,7 +1752,7 @@ server.registerTool( // --- list-attachments --- -server.registerTool( +registerTool( "list-attachments", { description: @@ -1777,7 +1827,7 @@ server.registerTool( // --- batch-delete-notes --- -server.registerTool( +registerTool( "batch-delete-notes", { description: @@ -1826,7 +1876,7 @@ server.registerTool( // --- batch-move-notes --- -server.registerTool( +registerTool( "batch-move-notes", { description: @@ -1888,7 +1938,7 @@ server.registerTool( // --- save-attachment --- -server.registerTool( +registerTool( "save-attachment", { description: @@ -1931,7 +1981,7 @@ server.registerTool( // --- fetch-attachment --- -server.registerTool( +registerTool( "fetch-attachment", { description: @@ -1969,7 +2019,7 @@ server.registerTool( // --- show-attachment --- -server.registerTool( +registerTool( "show-attachment", { description: @@ -2011,7 +2061,7 @@ server.registerTool( // --- export-notes-json --- -server.registerTool( +registerTool( "export-notes-json", { description: @@ -2046,7 +2096,7 @@ server.registerTool( // --- get-note-markdown --- -server.registerTool( +registerTool( "get-note-markdown", { description: @@ -2100,7 +2150,7 @@ server.registerTool( // --- get-checklist-state --- -server.registerTool( +registerTool( "get-checklist-state", { description: @@ -2148,7 +2198,7 @@ server.registerTool( // --- get-note-metadata (BETA) --- -server.registerTool( +registerTool( "get-note-metadata", { description: diff --git a/test/output-schema.test.ts b/test/output-schema.test.ts index 9f990e4..5bd77a1 100644 --- a/test/output-schema.test.ts +++ b/test/output-schema.test.ts @@ -65,6 +65,33 @@ describe("outputSchema contract (real server over stdio)", () => { ).toEqual([]); }); + it("every outputSchema tolerates undeclared keys (additionalProperties !== false)", async () => { + // The CLIENT validates structuredContent against the ADVERTISED JSON Schema + // (client/index.js -> "Structured content does not match the tool's output + // schema"), so `additionalProperties: false` makes any field the schema + // didn't enumerate a hard -32602 that discards an otherwise-correct result. + // The server never notices, because zod's own parse strips unknown keys + // instead of failing — so nothing but this assertion catches it. A bare zod + // raw shape renders as additionalProperties:false; registerTool() in + // src/index.ts wraps every shape in .passthrough() to prevent that. + // Not hypothetical: this took down get-mail-stats in the sibling + // apple-mail-mcp (sweetrb/apple-mail-mcp#135). + const { tools } = await client.listTools(); + const offenders = tools + .filter( + (t) => + (t.outputSchema as { additionalProperties?: unknown } | undefined) + ?.additionalProperties === false + ) + .map((t) => t.name); + expect( + offenders, + `outputSchemas must tolerate undeclared keys — these advertise ` + + `additionalProperties:false, so any field they don't enumerate is rejected ` + + `client-side and the whole result is lost: ${offenders.join(", ")}` + ).toEqual([]); + }); + it("diagnostic tools' real output validates against their outputSchema (when reachable)", async () => { // The SDK throws an "Output validation error" McpError when a success // result's structuredContent is missing or fails its schema — the only