bear-notes-mcp 2.4.1 → 2.5.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
@@ -6,10 +6,11 @@ Search, read, create, and update your Bear Notes from any AI assistant.
6
6
 
7
7
  ## Key Features
8
8
 
9
- - **9 MCP tools** for full Bear Notes integration
9
+ - **10 MCP tools** for full Bear Notes integration
10
10
  - **OCR search** across images and PDFs attached to notes
11
11
  - **Date-based search** with relative dates ("yesterday", "last week", etc.)
12
12
  - **Configurable new note convention** for tag placement (opt-in)
13
+ - **Content replacement** for replacing note body or specific sections (opt-in)
13
14
  - **Local-only** — no network calls, all data stays on your Mac
14
15
 
15
16
  ## Tools
@@ -19,6 +20,7 @@ Search, read, create, and update your Bear Notes from any AI assistant.
19
20
  - **`bear-create-note`** - Create a new note in your Bear library with optional title, content, and tags
20
21
  - **`bear-search-notes`** - Find notes by searching text content, filtering by tags, or date ranges. Includes OCR search in attachments
21
22
  - **`bear-add-text`** - Add text to an existing Bear note at the beginning or end, optionally targeting a specific section
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.
22
24
  - **`bear-add-file`** - Attach a file (image, PDF, Excel, etc.) to an existing Bear note using base64-encoded content
23
25
  - **`bear-list-tags`** - List all tags in your Bear library as a hierarchical tree with note counts
24
26
  - **`bear-find-untagged-notes`** - Find notes in your Bear library that have no tags assigned
@@ -56,6 +58,7 @@ Add to your MCP configuration file:
56
58
  |---|---|---|
57
59
  | `UI_DEBUG_TOGGLE` | `false` | Enable debug logging for troubleshooting |
58
60
  | `UI_ENABLE_NEW_NOTE_CONVENTION` | `false` | Place tags right after the note title instead of at the bottom |
61
+ | `UI_ENABLE_CONTENT_REPLACEMENT` | `false` | Enable the `bear-replace-text` tool for replacing note content |
59
62
 
60
63
  Example with configuration:
61
64
  ```json
@@ -66,6 +69,7 @@ Example with configuration:
66
69
  "args": ["-y", "bear-notes-mcp@latest"],
67
70
  "env": {
68
71
  "UI_ENABLE_NEW_NOTE_CONVENTION": "true",
72
+ "UI_ENABLE_CONTENT_REPLACEMENT": "true",
69
73
  "UI_DEBUG_TOGGLE": "true"
70
74
  }
71
75
  }
package/dist/bear-urls.js CHANGED
@@ -27,14 +27,13 @@ export function buildBearUrl(action, params = {}) {
27
27
  if (params.mode !== undefined) {
28
28
  urlParams.set('mode', params.mode);
29
29
  }
30
+ if (params.new_line !== undefined) {
31
+ urlParams.set('new_line', params.new_line);
32
+ }
30
33
  // UX params with defaults
31
34
  urlParams.set('open_note', params.open_note ?? 'yes');
32
35
  urlParams.set('new_window', params.new_window ?? 'no');
33
36
  urlParams.set('show_window', params.show_window ?? 'yes');
34
- // Add required Bear API parameters for add-text action
35
- if (action === 'add-text') {
36
- urlParams.set('new_line', 'yes'); // Ensures text appears on new line
37
- }
38
37
  // Convert URLSearchParams to proper URL encoding (Bear expects %20 not +)
39
38
  const queryString = urlParams.toString().replace(/\+/g, '%20');
40
39
  const finalUrl = `${baseUrl}?${queryString}`;
package/dist/config.js CHANGED
@@ -1,9 +1,10 @@
1
- export const APP_VERSION = '2.4.1';
1
+ export const APP_VERSION = '2.5.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
6
  export const ENABLE_NEW_NOTE_CONVENTIONS = process.env.UI_ENABLE_NEW_NOTE_CONVENTION === 'true';
7
+ export const ENABLE_CONTENT_REPLACEMENT = process.env.UI_ENABLE_CONTENT_REPLACEMENT === 'true';
7
8
  export const ERROR_MESSAGES = {
8
9
  BEAR_DATABASE_NOT_FOUND: 'Bear database not found. Please ensure Bear Notes is installed and has been opened at least once.',
9
10
  };
package/dist/main.js CHANGED
@@ -2,9 +2,9 @@
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, ENABLE_NEW_NOTE_CONVENTIONS } from './config.js';
5
+ import { APP_VERSION, ENABLE_CONTENT_REPLACEMENT, ENABLE_NEW_NOTE_CONVENTIONS } from './config.js';
6
6
  import { applyNoteConventions } from './note-conventions.js';
7
- import { cleanBase64, createToolResponse, handleAddText, logger } from './utils.js';
7
+ import { cleanBase64, createToolResponse, handleNoteTextUpdate, logger } from './utils.js';
8
8
  import { getNoteContent, searchNotes } from './notes.js';
9
9
  import { findUntaggedNotes, listTags } from './tags.js';
10
10
  import { buildBearUrl, executeBearXCallbackApi } from './bear-urls.js';
@@ -225,13 +225,60 @@ server.registerTool('bear-add-text', {
225
225
  },
226
226
  annotations: {
227
227
  readOnlyHint: false,
228
- destructiveHint: true,
228
+ destructiveHint: false,
229
229
  idempotentHint: false,
230
230
  openWorldHint: true,
231
231
  },
232
232
  }, async ({ id, text, header, position }) => {
233
233
  const mode = position === 'beginning' ? 'prepend' : 'append';
234
- return handleAddText(mode, { id, text, header });
234
+ return handleNoteTextUpdate(mode, { id, text, header });
235
+ });
236
+ server.registerTool('bear-replace-text', {
237
+ title: 'Replace Note Content',
238
+ description: 'Replace content in an existing Bear note — either the full body or a specific section. Requires content replacement to be enabled in extension settings. Use bear-search-notes first to get the note ID.',
239
+ inputSchema: {
240
+ id: z
241
+ .string()
242
+ .trim()
243
+ .min(1, 'Note ID is required')
244
+ .describe('Note identifier (ID) from bear-search-notes'),
245
+ scope: z
246
+ .enum(['section', 'full-note-body'])
247
+ .describe("Replacement target: 'section' replaces under a specific header (requires header), 'full-note-body' replaces the entire note body (header must not be set)"),
248
+ text: z
249
+ .string()
250
+ .trim()
251
+ .min(1, 'Text content is required')
252
+ .describe('Replacement text content'),
253
+ header: z
254
+ .string()
255
+ .trim()
256
+ .optional()
257
+ .describe('Section header to target — required when scope is "section", forbidden when scope is "full-note-body"'),
258
+ },
259
+ annotations: {
260
+ readOnlyHint: false,
261
+ destructiveHint: true,
262
+ idempotentHint: true,
263
+ openWorldHint: true,
264
+ },
265
+ }, async ({ id, scope, text, header }) => {
266
+ if (!ENABLE_CONTENT_REPLACEMENT) {
267
+ return createToolResponse(`Content replacement is not enabled.
268
+
269
+ To use replace mode, enable "Content Replacement" in the Bear Notes extension settings.`);
270
+ }
271
+ if (scope === 'section' && !header) {
272
+ return createToolResponse(`scope is "section" but no header was provided.
273
+
274
+ Set the header parameter to the section heading you want to replace.`);
275
+ }
276
+ if (scope === 'full-note-body' && header) {
277
+ return createToolResponse(`scope is "full-note-body" but a header was provided.
278
+
279
+ Remove the header parameter to replace the full note body, or change scope to "section".`);
280
+ }
281
+ return handleNoteTextUpdate('replace', { id, text, header });
235
282
  });
236
283
  server.registerTool('bear-add-file', {
237
284
  title: 'Add File to Note',
package/dist/utils.js CHANGED
@@ -134,16 +134,43 @@ export function createToolResponse(text) {
134
134
  };
135
135
  }
136
136
  /**
137
- * Shared handler for adding text to Bear notes (append or prepend).
137
+ * Strips a matching markdown heading from the start of text to prevent header duplication.
138
+ * Bear's add-text API with mode=replace keeps the original section header, so if the
139
+ * replacement text also starts with that header, it appears twice in the note.
140
+ *
141
+ * @param text - The replacement text that may start with a duplicate heading
142
+ * @param header - The cleaned header name (no # prefix) to match against
143
+ * @returns Text with the leading heading removed if it matched, otherwise unchanged
144
+ */
145
+ export function stripLeadingHeader(text, header) {
146
+ if (!header)
147
+ return text;
148
+ const escaped = header.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
149
+ const leadingHeaderRegex = new RegExp(`^#{1,6}\\s+${escaped}\\s*\\n?`, 'i');
150
+ return text.replace(leadingHeaderRegex, '');
151
+ }
152
+ /**
153
+ * Checks whether a markdown heading matching the given header text exists in the note.
154
+ * Strips markdown prefix from input (e.g., "## Foo" → "Foo") and matches case-insensitively.
155
+ * Escapes regex special characters so headers like "Q&A" or "Details (v2)" match literally.
156
+ */
157
+ export function noteHasHeader(noteText, header) {
158
+ const cleanHeader = header.replace(/^#+\s*/, '');
159
+ const escaped = cleanHeader.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
160
+ const headerRegex = new RegExp(`^#{1,6}\\s+${escaped}\\s*$`, 'mi');
161
+ return headerRegex.test(noteText);
162
+ }
163
+ /**
164
+ * Shared handler for note text operations (append, prepend, or replace).
138
165
  * Consolidates common validation, execution, and response logic.
139
166
  *
140
- * @param mode - Whether to append or prepend text
167
+ * @param mode - Whether to append, prepend, or replace text
141
168
  * @param params - Note ID, text content, and optional header
142
169
  * @returns Formatted response indicating success or failure
143
170
  */
144
- export async function handleAddText(mode, { id, text, header }) {
145
- const action = mode === 'append' ? 'appended' : 'prepended';
146
- logger.info(`bear-add-text-${mode} called with id: ${id}, text length: ${text.length}, header: ${header || 'none'}`);
171
+ export async function handleNoteTextUpdate(mode, { id, text, header }) {
172
+ const action = mode === 'append' ? 'appended' : mode === 'prepend' ? 'prepended' : 'replaced';
173
+ logger.info(`handleNoteTextUpdate(${mode}) id: ${id}, text length: ${text.length}, header: ${header || 'none'}`);
147
174
  try {
148
175
  const existingNote = getNoteContent(id);
149
176
  if (!existingNote) {
@@ -151,28 +178,51 @@ export async function handleAddText(mode, { id, text, header }) {
151
178
 
152
179
  Use bear-search-notes to find the correct note identifier.`);
153
180
  }
154
- // Strip markdown header syntax from header parameter for Bear API
181
+ // Strip markdown header syntax once reused for both validation and Bear API
155
182
  const cleanHeader = header?.replace(/^#+\s*/, '');
183
+ // Bear silently ignores replace-with-header when the section doesn't exist — fail early with a clear message
184
+ if (mode === 'replace' && cleanHeader) {
185
+ if (!existingNote.text || !noteHasHeader(existingNote.text, cleanHeader)) {
186
+ return createToolResponse(`Section "${cleanHeader}" not found in note "${existingNote.title}".
187
+
188
+ Check the note content with bear-open-note to see available sections.`);
189
+ }
190
+ }
191
+ // Bear's replace mode preserves the original heading (section header or note title),
192
+ // so if the AI includes it in the replacement text, the result has a duplicate.
193
+ const cleanText = mode === 'replace' ? stripLeadingHeader(text, cleanHeader || existingNote.title) : text;
156
194
  const url = buildBearUrl('add-text', {
157
195
  id,
158
- text,
196
+ text: cleanText,
159
197
  header: cleanHeader,
160
198
  mode,
199
+ // Ensures appended/prepended text starts on its own line, not glued to existing content.
200
+ // Not needed for replace — there's no preceding content to separate from.
201
+ new_line: mode !== 'replace' ? 'yes' : undefined,
161
202
  });
162
203
  logger.debug(`Executing Bear URL: ${url}`);
163
204
  await executeBearXCallbackApi(url);
164
- const responseLines = [`Text ${action} to note "${existingNote.title}" successfully!`, ''];
205
+ const preposition = mode === 'replace' ? 'in' : 'to';
206
+ const responseLines = [
207
+ `Text ${action} ${preposition} note "${existingNote.title}" successfully!`,
208
+ '',
209
+ ];
165
210
  responseLines.push(`Text: ${text.length} characters`);
166
- if (header) {
167
- responseLines.push(`Section: ${header}`);
211
+ if (cleanHeader) {
212
+ responseLines.push(`Section: ${cleanHeader}`);
168
213
  }
169
214
  responseLines.push(`Note ID: ${id}`);
215
+ const trailingMessage = mode === 'replace'
216
+ ? cleanHeader
217
+ ? 'The section content has been replaced in your Bear note.'
218
+ : 'The note content has been replaced in your Bear note.'
219
+ : 'The text has been added to your Bear note.';
170
220
  return createToolResponse(`${responseLines.join('\n')}
171
221
 
172
- The text has been added to your Bear note.`);
222
+ ${trailingMessage}`);
173
223
  }
174
224
  catch (error) {
175
- logger.error(`bear-add-text-${mode} failed: ${error}`);
225
+ logger.error(`handleNoteTextUpdate(${mode}) failed: ${error}`);
176
226
  throw error;
177
227
  }
178
228
  }
@@ -1,5 +1,5 @@
1
1
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
- import { convertCoreDataTimestamp, parseDateString } from './utils.js';
2
+ import { convertCoreDataTimestamp, noteHasHeader, parseDateString, stripLeadingHeader, } from './utils.js';
3
3
  describe('parseDateString', () => {
4
4
  beforeEach(() => {
5
5
  // Fix "now" to January 15, 2026 for predictable tests
@@ -25,6 +25,79 @@ describe('parseDateString', () => {
25
25
  expect(result.getSeconds()).toBe(59);
26
26
  });
27
27
  });
28
+ describe('noteHasHeader', () => {
29
+ const noteText = [
30
+ '# Title',
31
+ 'Intro paragraph',
32
+ '',
33
+ '## Details',
34
+ 'Some details here',
35
+ '',
36
+ '### Q&A',
37
+ 'Questions and answers',
38
+ '',
39
+ '## Details (v2)',
40
+ 'Updated details',
41
+ '',
42
+ '## v1.0 Release',
43
+ 'Release notes',
44
+ ].join('\n');
45
+ it('finds an exact header match', () => {
46
+ expect(noteHasHeader(noteText, 'Details')).toBe(true);
47
+ });
48
+ it('strips markdown prefix from header input', () => {
49
+ expect(noteHasHeader(noteText, '## Details')).toBe(true);
50
+ expect(noteHasHeader(noteText, '### Q&A')).toBe(true);
51
+ });
52
+ it('matches case-insensitively', () => {
53
+ expect(noteHasHeader(noteText, 'details')).toBe(true);
54
+ expect(noteHasHeader(noteText, 'DETAILS')).toBe(true);
55
+ });
56
+ it('rejects partial header name', () => {
57
+ expect(noteHasHeader(noteText, 'Detail')).toBe(false);
58
+ });
59
+ it('handles parentheses in header name', () => {
60
+ expect(noteHasHeader(noteText, 'Details (v2)')).toBe(true);
61
+ });
62
+ it('handles ampersand in header name', () => {
63
+ expect(noteHasHeader(noteText, 'Q&A')).toBe(true);
64
+ });
65
+ it('handles dots in header name', () => {
66
+ expect(noteHasHeader(noteText, 'v1.0 Release')).toBe(true);
67
+ });
68
+ it('returns false for empty note text', () => {
69
+ expect(noteHasHeader('', 'Details')).toBe(false);
70
+ });
71
+ it('returns false for empty header input', () => {
72
+ expect(noteHasHeader(noteText, '')).toBe(false);
73
+ });
74
+ });
75
+ describe('stripLeadingHeader', () => {
76
+ it('strips matching header with exact case', () => {
77
+ expect(stripLeadingHeader('## Details\nNew content', 'Details')).toBe('New content');
78
+ });
79
+ it('strips matching header case-insensitively', () => {
80
+ expect(stripLeadingHeader('## DETAILS\nNew content', 'Details')).toBe('New content');
81
+ expect(stripLeadingHeader('## details\nNew content', 'Details')).toBe('New content');
82
+ });
83
+ it('strips matching header at any heading level', () => {
84
+ expect(stripLeadingHeader('### Details\nNew content', 'Details')).toBe('New content');
85
+ expect(stripLeadingHeader('#### Details\nNew content', 'Details')).toBe('New content');
86
+ });
87
+ it('does not strip when header text does not match', () => {
88
+ expect(stripLeadingHeader('## Other\nNew content', 'Details')).toBe('## Other\nNew content');
89
+ });
90
+ it('does not strip when text does not start with a header', () => {
91
+ expect(stripLeadingHeader('New content', 'Details')).toBe('New content');
92
+ });
93
+ it('handles special characters in header name', () => {
94
+ expect(stripLeadingHeader('## Details (v2)\nNew content', 'Details (v2)')).toBe('New content');
95
+ expect(stripLeadingHeader('## Q&A\nNew content', 'Q&A')).toBe('New content');
96
+ });
97
+ it('returns text unchanged when header is empty string', () => {
98
+ expect(stripLeadingHeader('## Details\nNew content', '')).toBe('## Details\nNew content');
99
+ });
100
+ });
28
101
  describe('convertCoreDataTimestamp', () => {
29
102
  it('converts Core Data timestamp to correct ISO string', () => {
30
103
  // Core Data timestamp 0 = 2001-01-01 00:00:00 UTC
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bear-notes-mcp",
3
- "version": "2.4.1",
3
+ "version": "2.5.0",
4
4
  "description": "Bear Notes MCP server with TypeScript and native SQLite",
5
5
  "type": "module",
6
6
  "main": "dist/main.js",