bear-notes-mcp 2.8.2 → 2.9.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
@@ -21,7 +21,7 @@ Search, read, create, and update your Bear Notes from any AI assistant.
21
21
  - **`bear-search-notes`** - Find notes by searching text content, filtering by tags, or date ranges. Includes OCR search in attachments
22
22
  - **`bear-add-text`** - Insert text at the beginning or end of a Bear note, or within a specific section identified by its header
23
23
  - **`bear-replace-text`** - Replace content in an existing Bear note — either the full body or a specific section. Requires content replacement to be enabled in settings.
24
- - **`bear-add-file`** - Attach a file (image, PDF, Excel, etc.) to an existing Bear note using base64-encoded content
24
+ - **`bear-add-file`** - Attach a file to an existing Bear note. Provide a local file path (preferred) or base64-encoded content.
25
25
  - **`bear-list-tags`** - List all tags in your Bear library as a hierarchical tree with note counts
26
26
  - **`bear-find-untagged-notes`** - Find notes in your Bear library that have no tags assigned
27
27
  - **`bear-add-tag`** - Add one or more tags to an existing Bear note
package/dist/config.js CHANGED
@@ -1,4 +1,4 @@
1
- export const APP_VERSION = '2.8.2';
1
+ export const APP_VERSION = '2.9.0';
2
2
  export const BEAR_URL_SCHEME = 'bear://x-callback-url/';
3
3
  export const CORE_DATA_EPOCH_OFFSET = 978307200; // 2001-01-01 to Unix epoch
4
4
  export const DEFAULT_SEARCH_LIMIT = 50;
package/dist/main.js CHANGED
@@ -1,11 +1,13 @@
1
1
  #!/usr/bin/env node
2
+ import { readFileSync } from 'node:fs';
3
+ import { basename } from 'node:path';
2
4
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
3
5
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
4
6
  import { z } from 'zod';
5
7
  import { APP_VERSION, ENABLE_CONTENT_REPLACEMENT, ENABLE_NEW_NOTE_CONVENTIONS } from './config.js';
6
8
  import { applyNoteConventions } from './note-conventions.js';
7
9
  import { cleanBase64, createToolResponse, handleNoteTextUpdate, logger } from './utils.js';
8
- import { awaitNoteCreation, getNoteContent, searchNotes } from './notes.js';
10
+ import { awaitNoteCreation, findNotesByTitle, getNoteContent, searchNotes } from './notes.js';
9
11
  import { findUntaggedNotes, listTags } from './tags.js';
10
12
  import { buildBearUrl, executeBearXCallbackApi } from './bear-urls.js';
11
13
  const server = new McpServer({
@@ -16,7 +18,7 @@ const server = new McpServer({
16
18
  'This server integrates with Bear, a markdown note-taking app.',
17
19
  'Each note has a unique ID, a title, a body, and optional tags.',
18
20
  'Notes use markdown headings (##, ###, etc.) to define sections.',
19
- 'Use bear-search-notes to find note IDs before reading or modifying notes.',
21
+ 'Use bear-search-notes to find note IDs before reading or modifying notes, or provide an exact title to bear-open-note for direct lookup.',
20
22
  'To modify note content: bear-add-text inserts text without touching existing content; bear-replace-text overwrites content.',
21
23
  'When targeting a section by header, operations apply only to the direct content under that header — not nested sub-sections.',
22
24
  'To modify sub-sections, make separate calls targeting each sub-header.',
@@ -24,22 +26,51 @@ const server = new McpServer({
24
26
  });
25
27
  server.registerTool('bear-open-note', {
26
28
  title: 'Open Bear Note',
27
- description: 'Read the full text content of a Bear note from your library. Always includes text extracted from attached images and PDFs (aka OCR search) with clear labeling.',
29
+ description: 'Read the full text content of a Bear note by its ID or title. Supports direct title lookup as an alternative to searching first. Always includes text extracted from attached images and PDFs (aka OCR search) with clear labeling.',
28
30
  inputSchema: {
29
31
  id: z
30
32
  .string()
31
33
  .trim()
32
- .min(1, 'Note ID is required')
33
- .describe('Exact note identifier (ID) obtained from bear-search-notes'),
34
+ .optional()
35
+ .describe('Note identifier (ID) from bear-search-notes. Either id or title must be provided.'),
36
+ title: z
37
+ .string()
38
+ .trim()
39
+ .optional()
40
+ .describe('Exact note title for direct lookup (case-insensitive). Either id or title must be provided. If multiple notes share the same title, returns a list for disambiguation.'),
34
41
  },
35
42
  annotations: {
36
43
  readOnlyHint: true,
37
44
  idempotentHint: true,
38
45
  openWorldHint: false,
39
46
  },
40
- }, async ({ id }) => {
41
- logger.info(`bear-open-note called with id: ${id}, includeFiles: always`);
47
+ }, async ({ id, title }) => {
48
+ logger.info(`bear-open-note called with id: ${id || 'none'}, title: ${title ? '"' + title + '"' : 'none'}`);
49
+ if (!id && !title) {
50
+ return createToolResponse('Either note ID or title is required. Use bear-search-notes to find the note ID, or provide the exact title.');
51
+ }
42
52
  try {
53
+ // Title lookup path: find by title, then fetch full content
54
+ if (!id && title) {
55
+ const matches = findNotesByTitle(title);
56
+ if (matches.length === 0) {
57
+ return createToolResponse(`No note found with title "${title}". The note may have been deleted, archived, or the title may be different.
58
+
59
+ Use bear-search-notes to find notes by partial text match.`);
60
+ }
61
+ if (matches.length > 1) {
62
+ const matchList = matches
63
+ .map((m, i) => `${i + 1}. ID: ${m.identifier} (modified: ${m.modification_date})`)
64
+ .join('\n');
65
+ return createToolResponse(`Multiple notes found with title "${title}":
66
+
67
+ ${matchList}
68
+
69
+ Use bear-open-note with a specific ID to open the desired note.`);
70
+ }
71
+ // Exactly one match — fetch full content by ID
72
+ id = matches[0].identifier;
73
+ }
43
74
  const noteWithContent = getNoteContent(id);
44
75
  if (!noteWithContent) {
45
76
  return createToolResponse(`Note with ID '${id}' not found. The note may have been deleted, archived, or the ID may be incorrect.
@@ -309,18 +340,26 @@ Remove the header parameter to replace the full note body, or change scope to "s
309
340
  });
310
341
  server.registerTool('bear-add-file', {
311
342
  title: 'Add File to Note',
312
- description: 'Attach a file to an existing Bear note. Encode the file to base64 using shell commands (e.g., base64 /path/to/file.xlsx) and provide the encoded content. Use bear-search-notes first to get the note ID.',
343
+ description: 'Attach a file to an existing Bear note. Preferred: provide file_path for files on disk the server reads and encodes them automatically. Alternative: provide base64_content with pre-encoded data. Use bear-search-notes first to get the note ID.',
313
344
  inputSchema: {
345
+ file_path: z
346
+ .string()
347
+ .trim()
348
+ .min(1)
349
+ .optional()
350
+ .describe('Path to a file on disk. Preferred over base64_content when the file already exists locally.'),
314
351
  base64_content: z
315
352
  .string()
316
353
  .trim()
317
- .min(1, 'Base64 file content is required')
318
- .describe('Base64-encoded file content'),
354
+ .min(1)
355
+ .optional()
356
+ .describe('Base64-encoded file content. Use file_path instead when the file exists on disk.'),
319
357
  filename: z
320
358
  .string()
321
359
  .trim()
322
- .min(1, 'Filename is required')
323
- .describe('Filename with extension (e.g., budget.xlsx, report.pdf)'),
360
+ .min(1)
361
+ .optional()
362
+ .describe('Filename with extension (e.g., budget.xlsx, report.pdf). Required when using base64_content. Auto-inferred from file_path when omitted.'),
324
363
  id: z
325
364
  .string()
326
365
  .trim()
@@ -334,14 +373,49 @@ server.registerTool('bear-add-file', {
334
373
  idempotentHint: false,
335
374
  openWorldHint: true,
336
375
  },
337
- }, async ({ base64_content, filename, id, title }) => {
338
- logger.info(`bear-add-file called with base64_content: ${base64_content ? 'provided' : 'none'}, filename: ${filename || 'none'}, id: ${id || 'none'}, title: ${title || 'none'}`);
376
+ }, async ({ file_path, base64_content, filename, id, title }) => {
377
+ logger.info(`bear-add-file called with file_path: ${file_path || 'none'}, base64_content: ${base64_content ? 'provided' : 'none'}, filename: ${filename || 'none'}, id: ${id || 'none'}, title: ${title || 'none'}`);
339
378
  if (!id && !title) {
340
- throw new Error('Either note ID or title is required. Use bear-search-notes to find the note ID.');
379
+ return createToolResponse('Either note ID or title is required. Use bear-search-notes to find the note ID.');
380
+ }
381
+ if (file_path && base64_content) {
382
+ return createToolResponse('Provide either file_path or base64_content, not both.');
383
+ }
384
+ if (!file_path && !base64_content) {
385
+ return createToolResponse('Either file_path or base64_content is required.');
386
+ }
387
+ if (base64_content && !filename) {
388
+ return createToolResponse('filename is required when using base64_content.');
341
389
  }
342
390
  try {
343
- // base64 CLI adds line breaks that break URL encoding
344
- const cleanedBase64 = cleanBase64(base64_content);
391
+ let fileData;
392
+ let resolvedFilename;
393
+ if (file_path) {
394
+ // Read file from disk and encode — avoids the LLM producing thousands of base64 tokens
395
+ try {
396
+ const buffer = readFileSync(file_path);
397
+ if (buffer.length === 0) {
398
+ return createToolResponse(`File is empty: ${file_path}`);
399
+ }
400
+ fileData = buffer.toString('base64');
401
+ }
402
+ catch (err) {
403
+ const code = err.code;
404
+ if (code === 'ENOENT') {
405
+ return createToolResponse(`File not found: ${file_path}`);
406
+ }
407
+ if (code === 'EACCES') {
408
+ return createToolResponse(`Permission denied: ${file_path}`);
409
+ }
410
+ return createToolResponse(`Cannot read file: ${err instanceof Error ? err.message : String(err)}`);
411
+ }
412
+ resolvedFilename = filename || basename(file_path);
413
+ }
414
+ else {
415
+ // base64_content path — strip whitespace that base64 CLI adds
416
+ fileData = cleanBase64(base64_content);
417
+ resolvedFilename = filename;
418
+ }
345
419
  // Fail fast with helpful message rather than cryptic Bear error
346
420
  if (id) {
347
421
  const existingNote = getNoteContent(id);
@@ -354,14 +428,14 @@ Use bear-search-notes to find the correct note identifier.`);
354
428
  const url = buildBearUrl('add-file', {
355
429
  id,
356
430
  title,
357
- file: cleanedBase64,
358
- filename,
431
+ file: fileData,
432
+ filename: resolvedFilename,
359
433
  mode: 'append',
360
434
  });
361
- logger.debug(`Executing Bear add-file URL for: ${filename}`);
435
+ logger.debug(`Executing Bear add-file URL for: ${resolvedFilename}`);
362
436
  await executeBearXCallbackApi(url);
363
437
  const noteIdentifier = id ? `Note ID: ${id}` : `Note title: "${title}"`;
364
- return createToolResponse(`File "${filename}" added successfully!
438
+ return createToolResponse(`File "${resolvedFilename}" added successfully!
365
439
 
366
440
  ${noteIdentifier}
367
441
 
package/dist/notes.js CHANGED
@@ -121,6 +121,57 @@ export function getNoteContent(identifier) {
121
121
  }
122
122
  return null;
123
123
  }
124
+ /**
125
+ * Finds Bear notes matching an exact title (case-insensitive).
126
+ * Returns lightweight match objects for disambiguation — call getNoteContent()
127
+ * with the chosen identifier to retrieve the full note.
128
+ *
129
+ * @param title - The exact note title to match
130
+ * @returns Array of matching notes (empty if none found)
131
+ * @throws Error if database access fails
132
+ */
133
+ export function findNotesByTitle(title) {
134
+ logger.info(`findNotesByTitle called with title: "${title}"`);
135
+ if (!title || typeof title !== 'string' || !title.trim()) {
136
+ logAndThrow('Database error: Invalid note title provided');
137
+ }
138
+ const db = openBearDatabase();
139
+ try {
140
+ const query = `
141
+ SELECT ZTITLE as title,
142
+ ZUNIQUEIDENTIFIER as identifier,
143
+ ZMODIFICATIONDATE as modificationDate
144
+ FROM ZSFNOTE
145
+ WHERE ZTITLE = ? COLLATE NOCASE
146
+ AND ZARCHIVED = 0
147
+ AND ZTRASHED = 0
148
+ AND ZENCRYPTED = 0
149
+ ORDER BY ZMODIFICATIONDATE DESC
150
+ `;
151
+ const stmt = db.prepare(query);
152
+ const rows = stmt.all(title.trim());
153
+ if (!rows || rows.length === 0) {
154
+ logger.info(`No notes found with title: "${title}"`);
155
+ return [];
156
+ }
157
+ logger.info(`Found ${rows.length} note(s) with title: "${title}"`);
158
+ return rows.map((row) => {
159
+ const r = row;
160
+ return {
161
+ identifier: r.identifier,
162
+ title: r.title || 'Untitled',
163
+ modification_date: convertCoreDataTimestamp(r.modificationDate),
164
+ };
165
+ });
166
+ }
167
+ catch (error) {
168
+ logAndThrow(`Database error: Failed to find notes by title: ${error instanceof Error ? error.message : String(error)}`);
169
+ }
170
+ finally {
171
+ closeBearDatabase(db);
172
+ }
173
+ return [];
174
+ }
124
175
  /**
125
176
  * Searches Bear notes by content or tags with optional filtering.
126
177
  * Returns a list of notes without full content for performance.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bear-notes-mcp",
3
- "version": "2.8.2",
3
+ "version": "2.9.0",
4
4
  "description": "Bear Notes MCP server with TypeScript and native SQLite",
5
5
  "type": "module",
6
6
  "main": "dist/main.js",