apple-notes-mcp 1.4.4 → 2.0.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.
package/README.md CHANGED
@@ -86,12 +86,21 @@ On first use, macOS will ask for permission to automate Notes.app. Click "OK" to
86
86
  | **Folder Management** | Create, list, and delete folders with full hierarchical path support |
87
87
  | **Multi-Account** | Work with iCloud, Gmail, Exchange, or any configured account |
88
88
  | **Batch Operations** | Delete or move multiple notes at once |
89
- | **Checklist State** | Read checklist done/undone state directly from the Notes database |
89
+ | **Checklist State** | Read checklist done/undone state directly from the Notes database (requires Full Disk Access) |
90
90
  | **Export** | Export all notes as JSON or get individual notes as Markdown |
91
- | **Attachments** | List attachments in notes |
91
+ | **Attachments** | List attachments, save them to disk, or fetch their bytes as base64 |
92
92
  | **Sync Awareness** | Detect iCloud sync in progress, warn about incomplete results |
93
93
  | **Collaboration** | Detect shared notes, warn before modifying |
94
- | **Diagnostics** | Health check, sync status, and statistics tools |
94
+ | **Diagnostics** | `health-check` plus a richer `doctor` (reachability, automation permission, accounts, Full Disk Access), sync status, and statistics |
95
+
96
+ Read/list/get tools also return **structured JSON** (`structuredContent`) alongside the text, so agents can consume results without parsing prose.
97
+
98
+ ### MCP resources & prompts
99
+
100
+ Resources expose read-only context the client can attach without a tool call:
101
+ `notes://accounts`, `notes://folders`, `notes://stats`, and the
102
+ `notes://note/{id}` template (returns the note as Markdown). Prompts package
103
+ common workflows: `find-note`, `weekly-review`, `new-meeting-note`.
95
104
 
96
105
  ---
97
106
 
@@ -587,6 +596,33 @@ Lists attachments in a note.
587
596
 
588
597
  ---
589
598
 
599
+ #### `save-attachment`
600
+
601
+ Saves a note attachment to disk.
602
+
603
+ | Parameter | Type | Required | Description |
604
+ |-----------|------|----------|-------------|
605
+ | `noteId` | string | Yes | CoreData note ID (from `search-notes`/`list-notes`) |
606
+ | `attachmentId` | string | Yes | Attachment ID (from `list-attachments`) |
607
+ | `savePath` | string | Yes | Absolute destination file path. Must be under your home directory, a temp directory, or `/Volumes` |
608
+
609
+ **Returns:** Confirmation with the saved path, name, and content type (also in `structuredContent`).
610
+
611
+ ---
612
+
613
+ #### `fetch-attachment`
614
+
615
+ Returns a note attachment's bytes as base64, without writing to disk (the read counterpart to `save-attachment`).
616
+
617
+ | Parameter | Type | Required | Description |
618
+ |-----------|------|----------|-------------|
619
+ | `noteId` | string | Yes | CoreData note ID (from `search-notes`/`list-notes`) |
620
+ | `attachmentId` | string | Yes | Attachment ID (from `list-attachments`) |
621
+
622
+ **Returns:** The attachment name, content type, byte count, and base64 payload in `structuredContent.base64`.
623
+
624
+ ---
625
+
590
626
  ### Diagnostics
591
627
 
592
628
  #### `health-check`
@@ -599,6 +635,16 @@ Verifies Notes.app connectivity and permissions.
599
635
 
600
636
  ---
601
637
 
638
+ #### `doctor`
639
+
640
+ Run a full setup diagnostic: Notes.app reachability, the Automation permission, configured accounts, and Full Disk Access — each reported as ok / warn / fail with an actionable message. This is the richer counterpart to `health-check`; reach for it first when something isn't working.
641
+
642
+ **Parameters:** None
643
+
644
+ **Returns:** A per-check report (`structuredContent` carries the raw `{healthy, checks[]}`). The Full Disk Access check tells you whether checklist-state features will work — see [Full Disk Access Setup](docs/FULL-DISK-ACCESS.md).
645
+
646
+ ---
647
+
602
648
  #### `get-notes-stats`
603
649
 
604
650
  Gets comprehensive statistics about your notes.
@@ -730,10 +776,46 @@ The entrypoint is written as:
730
776
 
731
777
  ---
732
778
 
779
+ ## Configuration
780
+
781
+ ### Environment variables
782
+
783
+ All configuration is optional — the server works out of the box. Override behavior with these variables (set them in your MCP client's `env` block, or via the [config file](#configuration-file-when-the-host-strips-env) below):
784
+
785
+ | Variable | Default | Description |
786
+ |----------|---------|-------------|
787
+ | `APPLE_NOTES_MCP_MAX_BUFFER` | `67108864` (64 MB) | Max bytes captured from a single AppleScript invocation. Raise it if a very large export/list is truncated; lower it to cap memory. |
788
+ | `APPLE_NOTES_MCP_CONFIG_FILE` | `~/Library/Application Support/apple-notes-mcp/config.json` | Path to the JSON config file (see below). |
789
+ | `DEBUG` / `VERBOSE` | unset | Set either to enable verbose diagnostic logging to stderr. |
790
+
791
+ ### Configuration file (when the host strips `env`)
792
+
793
+ Some host apps (e.g. Claude Desktop) launch the MCP server with a scrubbed
794
+ environment and ignore the `env` block in their server config, so there's no way
795
+ to pass `APPLE_NOTES_MCP_*` settings through it. In that case, put them in a JSON
796
+ file the host doesn't manage — `APPLE_NOTES_MCP_CONFIG_FILE`, or by default
797
+ `~/Library/Application Support/apple-notes-mcp/config.json`:
798
+
799
+ ```json
800
+ {
801
+ "APPLE_NOTES_MCP_MAX_BUFFER": "134217728",
802
+ "DEBUG": "1"
803
+ }
804
+ ```
805
+
806
+ The server reads it at startup and merges values into the environment **without
807
+ overriding** anything already set there (so an explicit `env` still wins). This
808
+ is the recommended way to configure the server under Claude Desktop. Apple Notes
809
+ MCP stores no secrets, but as a general rule keep only non-secret config here.
810
+
811
+ ---
812
+
733
813
  ## Full Disk Access for Checklist Features
734
814
 
735
815
  The `get-checklist-state` tool and checklist annotations in `get-note-markdown` read directly from the Apple Notes SQLite database. This requires **Full Disk Access** for the process running the MCP server.
736
816
 
817
+ > 📘 **For the full why-and-how walkthrough (which app to grant, verifying with `doctor`, graceful degradation), see the [Full Disk Access Setup Guide](docs/FULL-DISK-ACCESS.md).** The summary below is the quick version.
818
+
737
819
  ### How to Grant Full Disk Access
738
820
 
739
821
  1. Open **System Settings** (or System Preferences on older macOS)
@@ -768,13 +850,22 @@ All other tools work normally without Full Disk Access. Only checklist state fea
768
850
  | Limitation | Reason |
769
851
  |------------|--------|
770
852
  | macOS only | Apple Notes and AppleScript are macOS-specific |
771
- | No attachment content | Attachments can be listed but not downloaded via AppleScript |
772
- | No pinned notes | Pin status is not exposed via AppleScript |
853
+ | Batch ops run per-note | `batch-delete-notes` / `batch-move-notes` apply each note individually rather than as one bulk operation — AppleScript has no bulk equivalent to IMAP's `UID STORE`/`MOVE`. This is deliberate: it preserves per-note success/failure reporting. ([#26](https://github.com/sweetrb/apple-notes-mcp/issues/26)) |
854
+ | No pinned notes | Pin status is not exposed via AppleScript ([#28](https://github.com/sweetrb/apple-notes-mcp/issues/28)) |
773
855
  | Limited rich formatting | Use `format: "html"` on create/update for headings, lists, bold, code blocks; some complex formatting may not render |
774
856
  | Title matching | Most operations require exact title matches |
775
- | Checklist state | Requires Full Disk Access to read done/undone state from the database |
857
+ | Checklist state | Requires [Full Disk Access](docs/FULL-DISK-ACCESS.md) to read done/undone state from the database |
776
858
  | Checklist **creation** | Not supported. AppleScript's `body of note` setter strips `<input type="checkbox">` and ignores any checklist-styling CSS class. Apple Notes stores checklist items as a protobuf paragraph style (`style_type=103`) that AppleScript doesn't expose, and the SQLite database is read-only. See [Creating Checklists](#creating-checklists) below for the workaround. |
777
859
 
860
+ ### Roadmap
861
+
862
+ A few capabilities are deliberately deferred to a future release, tracked as open issues:
863
+
864
+ - **Pinned-note support** ([#28](https://github.com/sweetrb/apple-notes-mcp/issues/28)) — Apple doesn't expose pin status via AppleScript.
865
+ - **Tags / hashtags** ([#29](https://github.com/sweetrb/apple-notes-mcp/issues/29)).
866
+ - **Note links** ([#30](https://github.com/sweetrb/apple-notes-mcp/issues/30)).
867
+ - **Local integration-test suite** ([#31](https://github.com/sweetrb/apple-notes-mcp/issues/31)).
868
+
778
869
  ### Creating Checklists
779
870
 
780
871
  **There is no programmatic way to create a true Apple Notes checklist via AppleScript** — and therefore no way via this MCP server. This is an Apple limitation, not a bug.
package/build/index.js CHANGED
@@ -27,6 +27,12 @@ import { AppleNotesManager } from "./services/appleNotesManager.js";
27
27
  import { getSyncStatus, withSyncAwarenessSync } from "./utils/syncDetection.js";
28
28
  import { getChecklistItems, hasFullDiskAccess } from "./utils/checklistParser.js";
29
29
  import { detectChecklistAttempt } from "./utils/contentWarnings.js";
30
+ import { runDoctor, formatDoctorReport } from "./tools/doctor.js";
31
+ import { loadFileConfig } from "./services/fileConfig.js";
32
+ import { registerResourcesAndPrompts } from "./tools/resourcesAndPrompts.js";
33
+ // Load file-based config FIRST (#24) — before anything reads APPLE_NOTES_MCP_*.
34
+ // Lets users configure the server when the host app strips the MCP env block.
35
+ loadFileConfig();
30
36
  // Read version from package.json to keep it in sync
31
37
  const require = createRequire(import.meta.url);
32
38
  const { version } = require("../package.json");
@@ -46,25 +52,19 @@ const server = new McpServer({
46
52
  * Handles all AppleScript execution and note operations.
47
53
  */
48
54
  const notesManager = new AppleNotesManager();
49
- // =============================================================================
50
- // Response Helpers
51
- // =============================================================================
52
55
  /**
53
- * Creates a successful MCP tool response.
54
- *
55
- * @param message - The success message to display
56
- * @returns Formatted MCP response object
56
+ * Creates a successful MCP tool response. Pass `structured` to attach typed JSON
57
+ * (`structuredContent`) alongside the human-readable text so agents can consume
58
+ * results without parsing prose (#21).
57
59
  */
58
- function successResponse(message) {
59
- return {
60
- content: [{ type: "text", text: message }],
61
- };
60
+ function successResponse(message, structured) {
61
+ const res = { content: [{ type: "text", text: message }] };
62
+ if (structured)
63
+ res.structuredContent = structured;
64
+ return res;
62
65
  }
63
66
  /**
64
67
  * Creates an error MCP tool response.
65
- *
66
- * @param message - The error message to display
67
- * @returns Formatted MCP error response object
68
68
  */
69
69
  function errorResponse(message) {
70
70
  return {
@@ -74,10 +74,6 @@ function errorResponse(message) {
74
74
  }
75
75
  /**
76
76
  * Wraps a tool handler with consistent error handling.
77
- *
78
- * @param handler - The async function to execute
79
- * @param errorPrefix - Prefix for error messages (e.g., "Error creating note")
80
- * @returns Wrapped handler with try/catch
81
77
  */
82
78
  function withErrorHandling(handler, errorPrefix) {
83
79
  return async (params) => {
@@ -164,7 +160,7 @@ server.tool("search-notes", {
164
160
  }
165
161
  const syncNote = syncWarnings.length > 0 ? `\n\n${syncWarnings.join(" ")}` : "";
166
162
  if (notes.length === 0) {
167
- return successResponse(`No notes found matching "${query}" in ${searchType}${folderInfo}${dateInfo}${syncNote}`);
163
+ return successResponse(`No notes found matching "${query}" in ${searchType}${folderInfo}${dateInfo}${syncNote}`, { notes: [], count: 0 });
168
164
  }
169
165
  // Format each note with ID and folder info, highlighting Recently Deleted
170
166
  const noteList = notes
@@ -179,7 +175,7 @@ server.tool("search-notes", {
179
175
  return ` - ${n.title}${idSuffix}`;
180
176
  })
181
177
  .join("\n");
182
- return successResponse(`Found ${notes.length} notes (searched ${searchType}${folderInfo}${dateInfo}${limitInfo}):\n${noteList}${syncNote}`);
178
+ return successResponse(`Found ${notes.length} notes (searched ${searchType}${folderInfo}${dateInfo}${limitInfo}):\n${noteList}${syncNote}`, { notes, count: notes.length });
183
179
  }, "Error searching notes"));
184
180
  // --- get-note-content ---
185
181
  server.tool("get-note-content", {
@@ -204,7 +200,7 @@ server.tool("get-note-content", {
204
200
  if (!content) {
205
201
  return errorResponse(`Failed to read content of note "${note.title}"`);
206
202
  }
207
- return successResponse(content);
203
+ return successResponse(content, { title: note.title, content });
208
204
  }
209
205
  // Fall back to title-based lookup
210
206
  if (!title) {
@@ -222,7 +218,7 @@ server.tool("get-note-content", {
222
218
  if (!content) {
223
219
  return errorResponse(`Failed to read content of note "${title}"`);
224
220
  }
225
- return successResponse(content);
221
+ return successResponse(content, { title, content });
226
222
  }, "Error retrieving note content"));
227
223
  // --- get-note-by-id ---
228
224
  server.tool("get-note-by-id", {
@@ -241,7 +237,7 @@ server.tool("get-note-by-id", {
241
237
  shared: note.shared,
242
238
  passwordProtected: note.passwordProtected,
243
239
  };
244
- return successResponse(JSON.stringify(metadata, null, 2));
240
+ return successResponse(JSON.stringify(metadata, null, 2), metadata);
245
241
  }, "Error retrieving note"));
246
242
  // --- get-note-details ---
247
243
  server.tool("get-note-details", noteTitleSchema, withErrorHandling(({ title, account }) => {
@@ -259,7 +255,7 @@ server.tool("get-note-details", noteTitleSchema, withErrorHandling(({ title, acc
259
255
  passwordProtected: note.passwordProtected,
260
256
  account: note.account,
261
257
  };
262
- return successResponse(JSON.stringify(metadata, null, 2));
258
+ return successResponse(JSON.stringify(metadata, null, 2), metadata);
263
259
  }, "Error retrieving note details"));
264
260
  // --- update-note ---
265
261
  server.tool("update-note", {
@@ -433,10 +429,13 @@ server.tool("list-notes", {
433
429
  }
434
430
  const syncNote = syncWarnings.length > 0 ? `\n\n${syncWarnings.join(" ")}` : "";
435
431
  if (notes.length === 0) {
436
- return successResponse(`No notes found${location}${acct}${dateInfo}${syncNote}`);
432
+ return successResponse(`No notes found${location}${acct}${dateInfo}${syncNote}`, {
433
+ notes: [],
434
+ count: 0,
435
+ });
437
436
  }
438
437
  const noteList = notes.map((t) => ` - ${t}`).join("\n");
439
- return successResponse(`Found ${notes.length} notes${location}${acct}${dateInfo}${limitInfo}:\n${noteList}${syncNote}`);
438
+ return successResponse(`Found ${notes.length} notes${location}${acct}${dateInfo}${limitInfo}:\n${noteList}${syncNote}`, { notes, count: notes.length });
440
439
  }, "Error listing notes"));
441
440
  // =============================================================================
442
441
  // Folder Tools
@@ -458,10 +457,13 @@ server.tool("list-folders", {
458
457
  }
459
458
  const syncNote = syncWarnings.length > 0 ? `\n\n${syncWarnings.join(" ")}` : "";
460
459
  if (folders.length === 0) {
461
- return successResponse(`No folders found${acct}${syncNote}`);
460
+ return successResponse(`No folders found${acct}${syncNote}`, { folders: [], count: 0 });
462
461
  }
463
462
  const folderList = folders.map((f) => ` - ${f.name}`).join("\n");
464
- return successResponse(`Found ${folders.length} folders${acct}:\n${folderList}${syncNote}`);
463
+ return successResponse(`Found ${folders.length} folders${acct}:\n${folderList}${syncNote}`, {
464
+ folders,
465
+ count: folders.length,
466
+ });
465
467
  }, "Error listing folders"));
466
468
  // --- create-folder ---
467
469
  server.tool("create-folder", {
@@ -492,10 +494,13 @@ server.tool("delete-folder", folderNameSchema, withErrorHandling(({ name, accoun
492
494
  server.tool("list-accounts", {}, withErrorHandling(() => {
493
495
  const accounts = notesManager.listAccounts();
494
496
  if (accounts.length === 0) {
495
- return successResponse("No Notes accounts found");
497
+ return successResponse("No Notes accounts found", { accounts: [], count: 0 });
496
498
  }
497
499
  const accountList = accounts.map((a) => ` - ${a.name}`).join("\n");
498
- return successResponse(`Found ${accounts.length} accounts:\n${accountList}`);
500
+ return successResponse(`Found ${accounts.length} accounts:\n${accountList}`, {
501
+ accounts,
502
+ count: accounts.length,
503
+ });
499
504
  }, "Error listing accounts"));
500
505
  // =============================================================================
501
506
  // Collaboration Tools
@@ -504,7 +509,7 @@ server.tool("list-accounts", {}, withErrorHandling(() => {
504
509
  server.tool("list-shared-notes", {}, withErrorHandling(() => {
505
510
  const sharedNotes = notesManager.listSharedNotes();
506
511
  if (sharedNotes.length === 0) {
507
- return successResponse("No shared notes found. You have no notes shared with collaborators.");
512
+ return successResponse("No shared notes found. You have no notes shared with collaborators.", { notes: [], count: 0 });
508
513
  }
509
514
  const noteList = sharedNotes
510
515
  .map((n) => {
@@ -513,7 +518,7 @@ server.tool("list-shared-notes", {}, withErrorHandling(() => {
513
518
  })
514
519
  .join("\n");
515
520
  return successResponse(`Found ${sharedNotes.length} shared note(s):\n${noteList}\n\n` +
516
- `⚠️ Changes to shared notes are visible to all collaborators.`);
521
+ `⚠️ Changes to shared notes are visible to all collaborators.`, { notes: sharedNotes, count: sharedNotes.length });
517
522
  }, "Error listing shared notes"));
518
523
  // =============================================================================
519
524
  // Diagnostics Tools
@@ -522,7 +527,7 @@ server.tool("list-shared-notes", {}, withErrorHandling(() => {
522
527
  server.tool("get-sync-status", {}, withErrorHandling(() => {
523
528
  const status = getSyncStatus();
524
529
  if (status.error) {
525
- return successResponse(`⚠️ Sync status unknown: ${status.error}`);
530
+ return successResponse(`⚠️ Sync status unknown: ${status.error}`, { ...status });
526
531
  }
527
532
  const lines = [];
528
533
  if (status.syncDetected) {
@@ -542,7 +547,7 @@ server.tool("get-sync-status", {}, withErrorHandling(() => {
542
547
  lines.push("");
543
548
  lines.push(` Last activity: ${status.secondsSinceLastChange}s ago`);
544
549
  }
545
- return successResponse(lines.join("\n"));
550
+ return successResponse(lines.join("\n"), { ...status });
546
551
  }, "Error checking sync status"));
547
552
  // --- health-check ---
548
553
  server.tool("health-check", {}, withErrorHandling(() => {
@@ -562,6 +567,13 @@ server.tool("health-check", {}, withErrorHandling(() => {
562
567
  : " ⓘ full_disk_access: Not granted (optional — needed for get-checklist-state and checklist annotations in get-note-markdown). Grant in System Settings > Privacy & Security > Full Disk Access.";
563
568
  return successResponse(`${statusIcon} ${statusText}\n\n${checkLines}\n${fdaLine}`);
564
569
  }, "Error running health check"));
570
+ // --- doctor ---
571
+ server.tool("doctor", {}, withErrorHandling(() => {
572
+ // Richer than health-check: Notes.app permission, account state, and Full
573
+ // Disk Access with actionable messages + structuredContent (#22).
574
+ const report = runDoctor(notesManager);
575
+ return successResponse(formatDoctorReport(report), { ...report });
576
+ }, "Error running doctor"));
565
577
  // --- get-notes-stats ---
566
578
  server.tool("get-notes-stats", {}, withErrorHandling(() => {
567
579
  const stats = notesManager.getNotesStats();
@@ -587,7 +599,7 @@ server.tool("get-notes-stats", {}, withErrorHandling(() => {
587
599
  lines.push(` Last 24 hours: ${stats.recentlyModified.last24h}`);
588
600
  lines.push(` Last 7 days: ${stats.recentlyModified.last7d}`);
589
601
  lines.push(` Last 30 days: ${stats.recentlyModified.last30d}`);
590
- return successResponse(lines.join("\n"));
602
+ return successResponse(lines.join("\n"), { ...stats });
591
603
  }, "Error getting notes statistics"));
592
604
  // --- list-attachments ---
593
605
  server.tool("list-attachments", {
@@ -606,10 +618,13 @@ server.tool("list-attachments", {
606
618
  }
607
619
  const attachments = notesManager.listAttachmentsById(id);
608
620
  if (attachments.length === 0) {
609
- return successResponse(`Note "${note.title}" has no attachments`);
621
+ return successResponse(`Note "${note.title}" has no attachments`, {
622
+ attachments: [],
623
+ count: 0,
624
+ });
610
625
  }
611
626
  const attachmentList = attachments.map((a) => ` - ${a.name} (${a.contentType})`).join("\n");
612
- return successResponse(`Found ${attachments.length} attachment(s) in "${note.title}":\n${attachmentList}`);
627
+ return successResponse(`Found ${attachments.length} attachment(s) in "${note.title}":\n${attachmentList}`, { attachments, count: attachments.length });
613
628
  }
614
629
  // Fall back to title-based lookup
615
630
  if (!title) {
@@ -621,10 +636,10 @@ server.tool("list-attachments", {
621
636
  }
622
637
  const attachments = notesManager.listAttachments(title, account);
623
638
  if (attachments.length === 0) {
624
- return successResponse(`Note "${title}" has no attachments`);
639
+ return successResponse(`Note "${title}" has no attachments`, { attachments: [], count: 0 });
625
640
  }
626
641
  const attachmentList = attachments.map((a) => ` - ${a.name} (${a.contentType})`).join("\n");
627
- return successResponse(`Found ${attachments.length} attachment(s) in "${title}":\n${attachmentList}`);
642
+ return successResponse(`Found ${attachments.length} attachment(s) in "${title}":\n${attachmentList}`, { attachments, count: attachments.length });
628
643
  }, "Error listing attachments"));
629
644
  // --- batch-delete-notes ---
630
645
  server.tool("batch-delete-notes", {
@@ -669,6 +684,42 @@ server.tool("batch-move-notes", {
669
684
  }
670
685
  return succeeded > 0 ? successResponse(lines.join("\n")) : errorResponse(lines.join("\n"));
671
686
  }, "Error performing batch move"));
687
+ // --- save-attachment ---
688
+ server.tool("save-attachment", {
689
+ noteId: z.string().min(1, "noteId is required").describe("CoreData note id (from search/list)"),
690
+ attachmentId: z
691
+ .string()
692
+ .min(1, "attachmentId is required")
693
+ .describe("Attachment id (from list-attachments)"),
694
+ savePath: z
695
+ .string()
696
+ .min(1, "savePath is required")
697
+ .describe("Absolute destination file path (must be under home, temp, or /Volumes)"),
698
+ }, withErrorHandling(({ noteId, attachmentId, savePath }) => {
699
+ const r = notesManager.saveAttachmentById(noteId, attachmentId, savePath);
700
+ if (!r.success) {
701
+ return errorResponse(`Failed to save attachment: ${r.error ?? "unknown error"}`);
702
+ }
703
+ return successResponse(`Saved "${r.name ?? "attachment"}" to ${r.savedPath}`, {
704
+ savedPath: r.savedPath,
705
+ name: r.name,
706
+ contentType: r.contentType,
707
+ });
708
+ }, "Error saving attachment"));
709
+ // --- fetch-attachment ---
710
+ server.tool("fetch-attachment", {
711
+ noteId: z.string().min(1, "noteId is required").describe("CoreData note id (from search/list)"),
712
+ attachmentId: z
713
+ .string()
714
+ .min(1, "attachmentId is required")
715
+ .describe("Attachment id (from list-attachments)"),
716
+ }, withErrorHandling(({ noteId, attachmentId }) => {
717
+ const r = notesManager.getAttachmentBase64ById(noteId, attachmentId);
718
+ if (!r.success || !r.base64) {
719
+ return errorResponse(`Failed to fetch attachment: ${r.error ?? "unknown error"}`);
720
+ }
721
+ return successResponse(`Fetched "${r.name ?? "attachment"}" (${r.contentType ?? "unknown type"}, ${r.bytes ?? 0} bytes) as base64.`, { name: r.name, contentType: r.contentType, bytes: r.bytes, base64: r.base64 });
722
+ }, "Error fetching attachment"));
672
723
  // --- export-notes-json ---
673
724
  server.tool("export-notes-json", {}, withErrorHandling(() => {
674
725
  const exportData = notesManager.exportNotesAsJson();
@@ -684,6 +735,7 @@ server.tool("export-notes-json", {}, withErrorHandling(() => {
684
735
  text: JSON.stringify(exportData, null, 2),
685
736
  },
686
737
  ],
738
+ structuredContent: { ...exportData },
687
739
  };
688
740
  }, "Error exporting notes"));
689
741
  // --- get-note-markdown ---
@@ -701,7 +753,7 @@ server.tool("get-note-markdown", {
701
753
  if (!markdown) {
702
754
  return errorResponse(`Note with ID "${id}" not found or has no content`);
703
755
  }
704
- return successResponse(markdown);
756
+ return successResponse(markdown, { markdown });
705
757
  }
706
758
  // Fall back to title-based lookup
707
759
  if (!title) {
@@ -711,7 +763,7 @@ server.tool("get-note-markdown", {
711
763
  if (!markdown) {
712
764
  return errorResponse(`Note "${title}" not found or has no content. Use search-notes to find notes, then use the note's ID for reliable operations.`);
713
765
  }
714
- return successResponse(markdown);
766
+ return successResponse(markdown, { markdown });
715
767
  }, "Error getting note as markdown"));
716
768
  // --- get-checklist-state ---
717
769
  server.tool("get-checklist-state", {
@@ -733,7 +785,7 @@ server.tool("get-checklist-state", {
733
785
  .map((item) => `${item.done ? "[x]" : "[ ]"} ${item.text}`)
734
786
  .join("\n");
735
787
  const checked = result.items.filter((i) => i.done).length;
736
- return successResponse(`Checklist for "${note.title}" (${checked}/${result.items.length} done):\n${summary}`);
788
+ return successResponse(`Checklist for "${note.title}" (${checked}/${result.items.length} done):\n${summary}`, { items: result.items, checked, total: result.items.length });
737
789
  }, "Error reading checklist state"));
738
790
  // =============================================================================
739
791
  // Server Startup
@@ -744,5 +796,7 @@ server.tool("get-checklist-state", {
744
796
  * The server uses stdio transport for communication with MCP clients.
745
797
  * This is the standard transport for CLI-based MCP servers.
746
798
  */
799
+ // Register read-only resources and workflow prompts (#23).
800
+ registerResourcesAndPrompts(server, notesManager);
747
801
  const transport = new StdioServerTransport();
748
802
  await server.connect(transport);