bear-notes-mcp 2.2.0 → 2.4.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
@@ -1,23 +1,32 @@
1
1
  # Bear Notes MCP Server
2
2
 
3
- Search, read, and update your Bear Notes
3
+ Search, read, create, and update your Bear Notes from any AI assistant.
4
4
 
5
- Want to use this Bear Notes MCP server with Claude Code, Cursor, Codex, or other AI assistants? You can run it as a standalone MCP server.
5
+ **Full documentation and source code: [claude-desktop-extension-bear-notes](https://github.com/vasylenko/claude-desktop-extension-bear-notes)**
6
6
 
7
- **Read more about the project here -- [claude-desktop-extension-bear-notes](https://github.com/vasylenko/claude-desktop-extension-bear-notes)**
7
+ ## Key Features
8
+
9
+ - **9 MCP tools** for full Bear Notes integration
10
+ - **OCR search** across images and PDFs attached to notes
11
+ - **Date-based search** with relative dates ("yesterday", "last week", etc.)
12
+ - **Configurable new note convention** for tag placement (opt-in)
13
+ - **Local-only** — no network calls, all data stays on your Mac
8
14
 
9
15
  ## Tools
10
16
 
11
- - **`bear-search-notes`** - Find notes by text content or tags, returns list with IDs for further actions
12
- - **`bear-open-note`** - Read full content of a specific note including text, formatting, and metadata
13
- - **`bear-create-note`** - Create new notes with optional title, content, and tags
14
- - **`bear-add-text`** - Add text to an existing note at the beginning or end, optionally targeting a specific section
15
- - **`bear-add-file`** - Attach files (images, PDFs, spreadsheets, etc.) to existing notes
17
+ <!-- TOOLS:START -->
18
+ - **`bear-open-note`** - Read the full text content of a Bear note including OCR'd text from attached images and PDFs
19
+ - **`bear-create-note`** - Create a new note in your Bear library with optional title, content, and tags
20
+ - **`bear-search-notes`** - Find notes by searching text content, filtering by tags, or date ranges. Includes OCR search in attachments
21
+ - **`bear-add-text`** - Add text to an existing Bear note at the beginning or end, optionally targeting a specific section
22
+ - **`bear-add-file`** - Attach a file (image, PDF, Excel, etc.) to an existing Bear note using base64-encoded content
16
23
  - **`bear-list-tags`** - List all tags in your Bear library as a hierarchical tree with note counts
17
- - **`bear-find-untagged-notes`** - Find notes that have no tags assigned
18
- - **`bear-add-tag`** - Add one or more tags to an existing note
24
+ - **`bear-find-untagged-notes`** - Find notes in your Bear library that have no tags assigned
25
+ - **`bear-add-tag`** - Add one or more tags to an existing Bear note
26
+ - **`bear-archive-note`** - Archive a Bear note to remove it from active lists without deleting it
27
+ <!-- TOOLS:END -->
19
28
 
20
- **Requirements**: Node.js 22.13.0+
29
+ **Requirements**: Node.js 24.13.0+
21
30
 
22
31
  ## Quick Start - Claude Code (One Command)
23
32
 
@@ -41,6 +50,29 @@ Add to your MCP configuration file:
41
50
  }
42
51
  ```
43
52
 
53
+ ## Configuration
54
+
55
+ | Variable | Default | Description |
56
+ |---|---|---|
57
+ | `UI_DEBUG_TOGGLE` | `false` | Enable debug logging for troubleshooting |
58
+ | `UI_ENABLE_NEW_NOTE_CONVENTION` | `false` | Place tags right after the note title instead of at the bottom |
59
+
60
+ Example with configuration:
61
+ ```json
62
+ {
63
+ "mcpServers": {
64
+ "bear-notes": {
65
+ "command": "npx",
66
+ "args": ["-y", "bear-notes-mcp@latest"],
67
+ "env": {
68
+ "UI_ENABLE_NEW_NOTE_CONVENTION": "true",
69
+ "UI_DEBUG_TOGGLE": "true"
70
+ }
71
+ }
72
+ }
73
+ }
74
+ ```
75
+
44
76
  ## Advanced: Local Development Build
45
77
 
46
78
  **Step 1: Clone and build**
package/dist/config.js CHANGED
@@ -1,11 +1,9 @@
1
- export const APP_VERSION = '2.2.0';
1
+ export const APP_VERSION = '2.4.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;
5
5
  export const BEAR_DATABASE_PATH = 'Library/Group Containers/9K33E3U3T4.net.shinyfrog.bear/Application Data/database.sqlite';
6
+ export const ENABLE_NEW_NOTE_CONVENTIONS = process.env.UI_ENABLE_NEW_NOTE_CONVENTION === 'true';
6
7
  export const ERROR_MESSAGES = {
7
8
  BEAR_DATABASE_NOT_FOUND: 'Bear database not found. Please ensure Bear Notes is installed and has been opened at least once.',
8
- MISSING_SEARCH_PARAM: 'Please provide either a search term or a tag to search for notes.',
9
- MISSING_NOTE_ID: 'Please provide a note identifier. Use bear-search-notes first to find the note ID.',
10
- MISSING_TEXT_PARAM: 'Text input parameter is required and must be a non-empty string',
11
9
  };
package/dist/main.js CHANGED
@@ -2,7 +2,8 @@
2
2
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
3
3
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
4
4
  import { z } from 'zod';
5
- import { APP_VERSION, ERROR_MESSAGES } from './config.js';
5
+ import { APP_VERSION, ENABLE_NEW_NOTE_CONVENTIONS } from './config.js';
6
+ import { applyNoteConventions } from './note-conventions.js';
6
7
  import { cleanBase64, createToolResponse, handleAddText, logger } from './utils.js';
7
8
  import { getNoteContent, searchNotes } from './notes.js';
8
9
  import { findUntaggedNotes, listTags } from './tags.js';
@@ -15,22 +16,23 @@ server.registerTool('bear-open-note', {
15
16
  title: 'Open Bear Note',
16
17
  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.',
17
18
  inputSchema: {
18
- identifier: z.string().describe('Exact note identifier (ID) obtained from bear-search-notes'),
19
+ id: z
20
+ .string()
21
+ .trim()
22
+ .min(1, 'Note ID is required')
23
+ .describe('Exact note identifier (ID) obtained from bear-search-notes'),
19
24
  },
20
25
  annotations: {
21
26
  readOnlyHint: true,
22
27
  idempotentHint: true,
23
28
  openWorldHint: false,
24
29
  },
25
- }, async ({ identifier }) => {
26
- logger.info(`bear-open-note called with identifier: ${identifier}, includeFiles: always`);
27
- if (!identifier || !identifier.trim()) {
28
- throw new Error(ERROR_MESSAGES.MISSING_NOTE_ID);
29
- }
30
+ }, async ({ id }) => {
31
+ logger.info(`bear-open-note called with id: ${id}, includeFiles: always`);
30
32
  try {
31
- const noteWithContent = getNoteContent(identifier.trim());
33
+ const noteWithContent = getNoteContent(id);
32
34
  if (!noteWithContent) {
33
- return createToolResponse(`Note with ID '${identifier}' not found. The note may have been deleted, archived, or the ID may be incorrect.
35
+ return createToolResponse(`Note with ID '${id}' not found. The note may have been deleted, archived, or the ID may be incorrect.
34
36
 
35
37
  Use bear-search-notes to find the correct note identifier.`);
36
38
  }
@@ -47,7 +49,7 @@ Use bear-search-notes to find the correct note identifier.`);
47
49
  ${noteText}`);
48
50
  }
49
51
  catch (error) {
50
- logger.error(`bear-open-note failed: ${error}`);
52
+ logger.error('bear-open-note failed:', error);
51
53
  throw error;
52
54
  }
53
55
  });
@@ -57,10 +59,19 @@ server.registerTool('bear-create-note', {
57
59
  inputSchema: {
58
60
  title: z
59
61
  .string()
62
+ .trim()
60
63
  .optional()
61
64
  .describe('Note title, e.g., "Meeting Notes" or "Research Ideas"'),
62
- text: z.string().optional().describe('Note content in markdown format'),
63
- tags: z.string().optional().describe('Tags separated by commas, e.g., "work,project,urgent"'),
65
+ text: z
66
+ .string()
67
+ .trim()
68
+ .optional()
69
+ .describe('Note content in markdown format. Do not include a title heading — Bear adds it automatically from the title parameter.'),
70
+ tags: z
71
+ .string()
72
+ .trim()
73
+ .optional()
74
+ .describe('Tags separated by commas, e.g., "work,project,urgent"'),
64
75
  },
65
76
  annotations: {
66
77
  readOnlyHint: false,
@@ -71,26 +82,30 @@ server.registerTool('bear-create-note', {
71
82
  }, async ({ title, text, tags }) => {
72
83
  logger.debug(`bear-create-note called with title: ${title ? '"' + title + '"' : 'none'}, text length: ${text ? text.length : 0}, tags: ${tags || 'none'}`);
73
84
  try {
74
- const url = buildBearUrl('create', { title, text, tags });
85
+ // If ENABLE_NOTE_CONVENTIONS is true, embed tags in the text body using Bear's inline tag syntax, rather than passing as URL parameters
86
+ const { text: createText, tags: createTags } = ENABLE_NEW_NOTE_CONVENTIONS
87
+ ? applyNoteConventions({ text, tags })
88
+ : { text, tags };
89
+ const url = buildBearUrl('create', { title, text: createText, tags: createTags });
75
90
  await executeBearXCallbackApi(url);
76
91
  const responseLines = ['Bear note created successfully!', ''];
77
- if (title?.trim()) {
78
- responseLines.push(`Title: "${title.trim()}"`);
92
+ if (title) {
93
+ responseLines.push(`Title: "${title}"`);
79
94
  }
80
- if (text?.trim()) {
81
- responseLines.push(`Content: ${text.trim().length} characters`);
95
+ if (text) {
96
+ responseLines.push(`Content: ${text.length} characters`);
82
97
  }
83
- if (tags?.trim()) {
84
- responseLines.push(`Tags: ${tags.trim()}`);
98
+ if (tags) {
99
+ responseLines.push(`Tags: ${tags}`);
85
100
  }
86
- const hasContent = title?.trim() || text?.trim() || tags?.trim();
101
+ const hasContent = title || text || tags;
87
102
  const finalMessage = hasContent ? responseLines.join('\n') : 'Empty note created';
88
103
  return createToolResponse(`${finalMessage}
89
104
 
90
105
  The note has been added to your Bear Notes library.`);
91
106
  }
92
107
  catch (error) {
93
- logger.error(`bear-create-note failed: ${error}`);
108
+ logger.error('bear-create-note failed:', error);
94
109
  throw error;
95
110
  }
96
111
  });
@@ -98,8 +113,8 @@ server.registerTool('bear-search-notes', {
98
113
  title: 'Find Bear Notes',
99
114
  description: 'Find notes in your Bear library by searching text content, filtering by tags, or date ranges. Always searches within attached images and PDF files via OCR. Returns a list with titles and IDs - use "Open Bear Note" to read full content.',
100
115
  inputSchema: {
101
- term: z.string().optional().describe('Text to search for in note titles and content'),
102
- tag: z.string().optional().describe('Tag to filter notes by (without # symbol)'),
116
+ term: z.string().trim().optional().describe('Text to search for in note titles and content'),
117
+ tag: z.string().trim().optional().describe('Tag to filter notes by (without # symbol)'),
103
118
  limit: z.number().optional().describe('Maximum number of results to return (default: 50)'),
104
119
  createdAfter: z
105
120
  .string()
@@ -139,10 +154,10 @@ server.registerTool('bear-search-notes', {
139
154
  const { notes, totalCount } = searchNotes(term, tag, limit, Object.keys(dateFilter).length > 0 ? dateFilter : undefined, pinned);
140
155
  if (notes.length === 0) {
141
156
  const searchCriteria = [];
142
- if (term?.trim())
143
- searchCriteria.push(`term "${term.trim()}"`);
144
- if (tag?.trim())
145
- searchCriteria.push(`tag "${tag.trim()}"`);
157
+ if (term)
158
+ searchCriteria.push(`term "${term}"`);
159
+ if (tag)
160
+ searchCriteria.push(`tag "${tag}"`);
146
161
  if (createdAfter)
147
162
  searchCriteria.push(`created after "${createdAfter}"`);
148
163
  if (createdBefore)
@@ -180,7 +195,7 @@ Try different search criteria or check if notes exist in Bear Notes.`);
180
195
  return createToolResponse(resultLines.join('\n'));
181
196
  }
182
197
  catch (error) {
183
- logger.error(`bear-search-notes failed: ${error}`);
198
+ logger.error('bear-search-notes failed:', error);
184
199
  throw error;
185
200
  }
186
201
  });
@@ -188,10 +203,19 @@ server.registerTool('bear-add-text', {
188
203
  title: 'Add Text to Note',
189
204
  description: 'Add text to an existing Bear note at the beginning or end. Can target a specific section using header. Use bear-search-notes first to get the note ID.',
190
205
  inputSchema: {
191
- id: z.string().describe('Note identifier (ID) from bear-search-notes'),
192
- text: z.string().describe('Text content to add to the note'),
206
+ id: z
207
+ .string()
208
+ .trim()
209
+ .min(1, 'Note ID is required')
210
+ .describe('Note identifier (ID) from bear-search-notes'),
211
+ text: z
212
+ .string()
213
+ .trim()
214
+ .min(1, 'Text content is required')
215
+ .describe('Text content to add to the note'),
193
216
  header: z
194
217
  .string()
218
+ .trim()
195
219
  .optional()
196
220
  .describe('Optional section header to target (adds text within that section)'),
197
221
  position: z
@@ -213,13 +237,22 @@ server.registerTool('bear-add-file', {
213
237
  title: 'Add File to Note',
214
238
  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.',
215
239
  inputSchema: {
216
- base64_content: z.string().describe('Base64-encoded file content'),
217
- filename: z.string().describe('Filename with extension (e.g., budget.xlsx, report.pdf)'),
240
+ base64_content: z
241
+ .string()
242
+ .trim()
243
+ .min(1, 'Base64 file content is required')
244
+ .describe('Base64-encoded file content'),
245
+ filename: z
246
+ .string()
247
+ .trim()
248
+ .min(1, 'Filename is required')
249
+ .describe('Filename with extension (e.g., budget.xlsx, report.pdf)'),
218
250
  id: z
219
251
  .string()
252
+ .trim()
220
253
  .optional()
221
254
  .describe('Exact note identifier (ID) obtained from bear-search-notes'),
222
- title: z.string().optional().describe('Note title if ID is not available'),
255
+ title: z.string().trim().optional().describe('Note title if ID is not available'),
223
256
  },
224
257
  annotations: {
225
258
  readOnlyHint: false,
@@ -229,12 +262,6 @@ server.registerTool('bear-add-file', {
229
262
  },
230
263
  }, async ({ base64_content, filename, id, title }) => {
231
264
  logger.info(`bear-add-file called with base64_content: ${base64_content ? 'provided' : 'none'}, filename: ${filename || 'none'}, id: ${id || 'none'}, title: ${title || 'none'}`);
232
- if (!base64_content || !base64_content.trim()) {
233
- throw new Error('base64_content is required');
234
- }
235
- if (!filename || !filename.trim()) {
236
- throw new Error('filename is required');
237
- }
238
265
  if (!id && !title) {
239
266
  throw new Error('Either note ID or title is required. Use bear-search-notes to find the note ID.');
240
267
  }
@@ -243,7 +270,7 @@ server.registerTool('bear-add-file', {
243
270
  const cleanedBase64 = cleanBase64(base64_content);
244
271
  // Fail fast with helpful message rather than cryptic Bear error
245
272
  if (id) {
246
- const existingNote = getNoteContent(id.trim());
273
+ const existingNote = getNoteContent(id);
247
274
  if (!existingNote) {
248
275
  return createToolResponse(`Note with ID '${id}' not found. The note may have been deleted, archived, or the ID may be incorrect.
249
276
 
@@ -251,23 +278,23 @@ Use bear-search-notes to find the correct note identifier.`);
251
278
  }
252
279
  }
253
280
  const url = buildBearUrl('add-file', {
254
- id: id?.trim(),
255
- title: title?.trim(),
281
+ id,
282
+ title,
256
283
  file: cleanedBase64,
257
- filename: filename.trim(),
284
+ filename,
258
285
  mode: 'append',
259
286
  });
260
- logger.debug(`Executing Bear add-file URL for: ${filename.trim()}`);
287
+ logger.debug(`Executing Bear add-file URL for: ${filename}`);
261
288
  await executeBearXCallbackApi(url);
262
- const noteIdentifier = id ? `Note ID: ${id.trim()}` : `Note title: "${title.trim()}"`;
263
- return createToolResponse(`File "${filename.trim()}" added successfully!
289
+ const noteIdentifier = id ? `Note ID: ${id}` : `Note title: "${title}"`;
290
+ return createToolResponse(`File "${filename}" added successfully!
264
291
 
265
292
  ${noteIdentifier}
266
293
 
267
294
  The file has been attached to your Bear note.`);
268
295
  }
269
296
  catch (error) {
270
- logger.error(`bear-add-file failed: ${error}`);
297
+ logger.error('bear-add-file failed:', error);
271
298
  throw error;
272
299
  }
273
300
  });
@@ -321,7 +348,7 @@ server.registerTool('bear-list-tags', {
321
348
  return createToolResponse(header + '\n' + lines.join('\n'));
322
349
  }
323
350
  catch (error) {
324
- logger.error(`bear-list-tags failed: ${error}`);
351
+ logger.error('bear-list-tags failed:', error);
325
352
  throw error;
326
353
  }
327
354
  });
@@ -363,7 +390,7 @@ server.registerTool('bear-find-untagged-notes', {
363
390
  return createToolResponse(lines.join('\n'));
364
391
  }
365
392
  catch (error) {
366
- logger.error(`bear-find-untagged-notes failed: ${error}`);
393
+ logger.error('bear-find-untagged-notes failed:', error);
367
394
  throw error;
368
395
  }
369
396
  });
@@ -373,9 +400,12 @@ server.registerTool('bear-add-tag', {
373
400
  inputSchema: {
374
401
  id: z
375
402
  .string()
403
+ .trim()
404
+ .min(1, 'Note ID is required')
376
405
  .describe('Note identifier (ID) from bear-search-notes or bear-find-untagged-notes'),
377
406
  tags: z
378
- .array(z.string())
407
+ .array(z.string().trim().min(1, 'Tag name cannot be empty'))
408
+ .min(1, 'At least one tag is required')
379
409
  .describe('Tag names without # symbol (e.g., ["career", "career/meetings"])'),
380
410
  },
381
411
  annotations: {
@@ -386,14 +416,8 @@ server.registerTool('bear-add-tag', {
386
416
  },
387
417
  }, async ({ id, tags }) => {
388
418
  logger.info(`bear-add-tag called with id: ${id}, tags: [${tags.join(', ')}]`);
389
- if (!id || !id.trim()) {
390
- throw new Error(ERROR_MESSAGES.MISSING_NOTE_ID);
391
- }
392
- if (!tags || tags.length === 0) {
393
- throw new Error('At least one tag is required');
394
- }
395
419
  try {
396
- const existingNote = getNoteContent(id.trim());
420
+ const existingNote = getNoteContent(id);
397
421
  if (!existingNote) {
398
422
  return createToolResponse(`Note with ID '${id}' not found. The note may have been deleted, archived, or the ID may be incorrect.
399
423
 
@@ -401,7 +425,7 @@ Use bear-search-notes to find the correct note identifier.`);
401
425
  }
402
426
  const tagsString = tags.join(',');
403
427
  const url = buildBearUrl('add-text', {
404
- id: id.trim(),
428
+ id,
405
429
  tags: tagsString,
406
430
  mode: 'prepend',
407
431
  open_note: 'no',
@@ -418,7 +442,49 @@ Tags: ${tagList}
418
442
  The tags have been added to the beginning of the note.`);
419
443
  }
420
444
  catch (error) {
421
- logger.error(`bear-add-tag failed: ${error}`);
445
+ logger.error('bear-add-tag failed:', error);
446
+ throw error;
447
+ }
448
+ });
449
+ server.registerTool('bear-archive-note', {
450
+ title: 'Archive Bear Note',
451
+ description: "Move a note to Bear's archive. The note will no longer appear in regular searches but can be found in Bear's Archive section. Use bear-search-notes first to get the note ID.",
452
+ inputSchema: {
453
+ id: z
454
+ .string()
455
+ .trim()
456
+ .min(1, 'Note ID is required')
457
+ .describe('Note identifier (ID) from bear-search-notes or bear-open-note'),
458
+ },
459
+ annotations: {
460
+ readOnlyHint: false,
461
+ destructiveHint: true,
462
+ idempotentHint: true,
463
+ openWorldHint: true,
464
+ },
465
+ }, async ({ id }) => {
466
+ logger.info(`bear-archive-note called with id: ${id}`);
467
+ try {
468
+ const existingNote = getNoteContent(id);
469
+ if (!existingNote) {
470
+ return createToolResponse(`Note with ID '${id}' not found. The note may have been deleted, archived, or the ID may be incorrect.
471
+
472
+ Use bear-search-notes to find the correct note identifier.`);
473
+ }
474
+ const url = buildBearUrl('archive', {
475
+ id,
476
+ show_window: 'no',
477
+ });
478
+ await executeBearXCallbackApi(url);
479
+ return createToolResponse(`Note archived successfully!
480
+
481
+ Note: "${existingNote.title}"
482
+ ID: ${id}
483
+
484
+ The note has been moved to Bear's archive.`);
485
+ }
486
+ catch (error) {
487
+ logger.error('bear-archive-note failed:', error);
422
488
  throw error;
423
489
  }
424
490
  });
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Applies note creation conventions by embedding tags as Bear inline syntax
3
+ * at the start of the text body, rather than passing them as URL parameters
4
+ * (which places them at the bottom of the note).
5
+ */
6
+ export function applyNoteConventions(input) {
7
+ if (!input.tags) {
8
+ return { text: input.text, tags: undefined };
9
+ }
10
+ const tagLine = input.tags
11
+ .split(',')
12
+ .map((t) => t.trim())
13
+ .filter(Boolean)
14
+ .map(toBearTagSyntax)
15
+ .filter(Boolean)
16
+ .join(' ');
17
+ // All tags were invalid (e.g., "###,,,") — pass text through unchanged
18
+ if (!tagLine) {
19
+ return { text: input.text, tags: undefined };
20
+ }
21
+ const text = input.text ? `${tagLine}\n---\n${input.text}` : tagLine;
22
+ return { text, tags: undefined };
23
+ }
24
+ /**
25
+ * Bear uses `#tag` for simple tags and `#tag#` (closing hash) for
26
+ * multi-word tags containing spaces. Slashes create hierarchy without
27
+ * requiring a closing hash.
28
+ */
29
+ function toBearTagSyntax(raw) {
30
+ const cleaned = raw.replace(/^#+|#+$/g, '').trim();
31
+ if (!cleaned)
32
+ return '';
33
+ const needsClosingHash = cleaned.includes(' ');
34
+ return needsClosingHash ? `#${cleaned}#` : `#${cleaned}`;
35
+ }
@@ -0,0 +1,78 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { applyNoteConventions } from './note-conventions.js';
3
+ describe('applyNoteConventions', () => {
4
+ describe('pass-through when no tags provided', () => {
5
+ it('undefined tags returns text unchanged', () => {
6
+ const result = applyNoteConventions({ text: 'hello', tags: undefined });
7
+ expect(result).toEqual({ text: 'hello', tags: undefined });
8
+ });
9
+ it('empty string tags returns text unchanged', () => {
10
+ const result = applyNoteConventions({ text: 'hello', tags: '' });
11
+ expect(result).toEqual({ text: 'hello', tags: undefined });
12
+ });
13
+ it('both text and tags undefined returns both unchanged', () => {
14
+ const result = applyNoteConventions({ text: undefined, tags: undefined });
15
+ expect(result).toEqual({ text: undefined, tags: undefined });
16
+ });
17
+ });
18
+ describe('tags only, no text', () => {
19
+ it('multiple tags produce tag line without separator', () => {
20
+ const result = applyNoteConventions({ text: undefined, tags: 'work,urgent' });
21
+ expect(result).toEqual({ text: '#work #urgent', tags: undefined });
22
+ });
23
+ it('empty string text treated as no text', () => {
24
+ const result = applyNoteConventions({ text: '', tags: 'work' });
25
+ expect(result).toEqual({ text: '#work', tags: undefined });
26
+ });
27
+ });
28
+ describe('tags + text composition', () => {
29
+ it('multiple tags and text joined with separator', () => {
30
+ const result = applyNoteConventions({ text: 'body', tags: 'work,urgent' });
31
+ expect(result).toEqual({ text: '#work #urgent\n---\nbody', tags: undefined });
32
+ });
33
+ it('single tag and text joined with separator', () => {
34
+ const result = applyNoteConventions({ text: 'body', tags: 'work' });
35
+ expect(result).toEqual({ text: '#work\n---\nbody', tags: undefined });
36
+ });
37
+ });
38
+ describe('closing hash rules', () => {
39
+ it('nested tag without spaces has no closing hash', () => {
40
+ const result = applyNoteConventions({ text: undefined, tags: 'work/meetings' });
41
+ expect(result).toEqual({ text: '#work/meetings', tags: undefined });
42
+ });
43
+ it('tag with space gets closing hash', () => {
44
+ const result = applyNoteConventions({ text: undefined, tags: 'my tag' });
45
+ expect(result).toEqual({ text: '#my tag#', tags: undefined });
46
+ });
47
+ it('nested tag with spaces gets closing hash', () => {
48
+ const result = applyNoteConventions({ text: undefined, tags: 'work/meeting notes' });
49
+ expect(result).toEqual({ text: '#work/meeting notes#', tags: undefined });
50
+ });
51
+ it('simple tag has no closing hash', () => {
52
+ const result = applyNoteConventions({ text: undefined, tags: 'urgent' });
53
+ expect(result).toEqual({ text: '#urgent', tags: undefined });
54
+ });
55
+ it('mixed tags apply closing hash per-tag', () => {
56
+ const result = applyNoteConventions({ text: undefined, tags: 'work/meetings,urgent,my tag' });
57
+ expect(result).toEqual({ text: '#work/meetings #urgent #my tag#', tags: undefined });
58
+ });
59
+ });
60
+ describe('tag cleanup edge cases', () => {
61
+ it('strips leading and trailing hash symbols from tags', () => {
62
+ const result = applyNoteConventions({ text: undefined, tags: '#work,##urgent#' });
63
+ expect(result).toEqual({ text: '#work #urgent', tags: undefined });
64
+ });
65
+ it('all-invalid tags pass text through unchanged', () => {
66
+ const result = applyNoteConventions({ text: 'hello', tags: '###,,, ' });
67
+ expect(result).toEqual({ text: 'hello', tags: undefined });
68
+ });
69
+ it('empty segments between commas are filtered out', () => {
70
+ const result = applyNoteConventions({ text: undefined, tags: 'work, , ,urgent' });
71
+ expect(result).toEqual({ text: '#work #urgent', tags: undefined });
72
+ });
73
+ it('whitespace around tags is trimmed', () => {
74
+ const result = applyNoteConventions({ text: undefined, tags: ' work , urgent ' });
75
+ expect(result).toEqual({ text: '#work #urgent', tags: undefined });
76
+ });
77
+ });
78
+ });
package/dist/utils.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import createDebug from 'debug';
2
- import { CORE_DATA_EPOCH_OFFSET, ERROR_MESSAGES } from './config.js';
2
+ import { CORE_DATA_EPOCH_OFFSET } from './config.js';
3
3
  import { getNoteContent } from './notes.js';
4
4
  import { buildBearUrl, executeBearXCallbackApi } from './bear-urls.js';
5
5
  export const logger = {
@@ -144,35 +144,29 @@ export function createToolResponse(text) {
144
144
  export async function handleAddText(mode, { id, text, header }) {
145
145
  const action = mode === 'append' ? 'appended' : 'prepended';
146
146
  logger.info(`bear-add-text-${mode} called with id: ${id}, text length: ${text.length}, header: ${header || 'none'}`);
147
- if (!id || !id.trim()) {
148
- throw new Error(ERROR_MESSAGES.MISSING_NOTE_ID);
149
- }
150
- if (!text || !text.trim()) {
151
- throw new Error(ERROR_MESSAGES.MISSING_TEXT_PARAM);
152
- }
153
147
  try {
154
- const existingNote = getNoteContent(id.trim());
148
+ const existingNote = getNoteContent(id);
155
149
  if (!existingNote) {
156
150
  return createToolResponse(`Note with ID '${id}' not found. The note may have been deleted, archived, or the ID may be incorrect.
157
151
 
158
152
  Use bear-search-notes to find the correct note identifier.`);
159
153
  }
160
154
  // Strip markdown header syntax from header parameter for Bear API
161
- const cleanHeader = header?.trim().replace(/^#+\s*/, '');
155
+ const cleanHeader = header?.replace(/^#+\s*/, '');
162
156
  const url = buildBearUrl('add-text', {
163
- id: id.trim(),
164
- text: text.trim(),
157
+ id,
158
+ text,
165
159
  header: cleanHeader,
166
160
  mode,
167
161
  });
168
162
  logger.debug(`Executing Bear URL: ${url}`);
169
163
  await executeBearXCallbackApi(url);
170
164
  const responseLines = [`Text ${action} to note "${existingNote.title}" successfully!`, ''];
171
- responseLines.push(`Text: ${text.trim().length} characters`);
172
- if (header?.trim()) {
173
- responseLines.push(`Section: ${header.trim()}`);
165
+ responseLines.push(`Text: ${text.length} characters`);
166
+ if (header) {
167
+ responseLines.push(`Section: ${header}`);
174
168
  }
175
- responseLines.push(`Note ID: ${id.trim()}`);
169
+ responseLines.push(`Note ID: ${id}`);
176
170
  return createToolResponse(`${responseLines.join('\n')}
177
171
 
178
172
  The text has been added to your Bear note.`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bear-notes-mcp",
3
- "version": "2.2.0",
3
+ "version": "2.4.0",
4
4
  "description": "Bear Notes MCP server with TypeScript and native SQLite",
5
5
  "type": "module",
6
6
  "main": "dist/main.js",
@@ -9,6 +9,7 @@
9
9
  "dev": "tsx src/main.ts --debug",
10
10
  "start": "node dist/main.js",
11
11
  "test": "vitest run",
12
+ "test:system": "npm run build && vitest run --config vitest.system.config.ts",
12
13
  "test:watch": "vitest",
13
14
  "lint": "eslint src --fix",
14
15
  "lint:check": "eslint src",
@@ -19,23 +20,24 @@
19
20
  "prepublishOnly": "npm run build && mv docs/NPM.md README.md"
20
21
  },
21
22
  "dependencies": {
22
- "@modelcontextprotocol/sdk": "^1.25.2",
23
+ "@modelcontextprotocol/sdk": "^1.26.0",
23
24
  "debug": "^4.4.3",
24
- "zod": "^4.3.5",
25
+ "zod": "^4.3.6",
25
26
  "zod-to-json-schema": "^3.25.1"
26
27
  },
27
28
  "devDependencies": {
28
29
  "@anthropic-ai/mcpb": "^2.1.2",
30
+ "@modelcontextprotocol/inspector": "^0.20.0",
29
31
  "@types/debug": "^4.1.12",
30
- "@types/node": "^24.10.9",
31
- "@typescript-eslint/eslint-plugin": "^8.53.0",
32
- "@typescript-eslint/parser": "^8.53.0",
32
+ "@types/node": "^24.10.13",
33
+ "@typescript-eslint/eslint-plugin": "^8.55.0",
34
+ "@typescript-eslint/parser": "^8.55.0",
33
35
  "eslint": "^9.39.2",
34
36
  "eslint-plugin-import": "^2.32.0",
35
- "prettier": "^3.8.0",
37
+ "prettier": "^3.8.1",
36
38
  "tsx": "^4.21.0",
37
39
  "typescript": "^5.9.3",
38
- "vitest": "^4.0.17"
40
+ "vitest": "^4.0.18"
39
41
  },
40
42
  "engines": {
41
43
  "node": ">=24.13.0"