bear-notes-mcp 2.1.0-rc.1

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/LICENSE.md ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Serhii Vasylenko
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,109 @@
1
+ # Bear Notes MCP Server
2
+
3
+ Search, read, and update your Bear Notes
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.
6
+
7
+ **Read more about the project here -- [claude-desktop-extension-bear-notes](https://github.com/vasylenko/claude-desktop-extension-bear-notes)**
8
+
9
+ ## Tools
10
+
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-append`** - Add text to the end of existing notes or specific sections
15
+ - **`bear-add-text-prepend`** - Insert text at the beginning of existing notes or sections
16
+ - **`bear-add-file`** - Attach files (images, PDFs, spreadsheets, etc.) to existing notes
17
+
18
+ **Requirements**: Node.js 22.13.0+
19
+
20
+ ## Quick Start - Claude Code (One Command)
21
+
22
+ **For Node.js 22.13.0+ / 23.4.0+ / 24.x+ / 25.x+ (recommended):**
23
+ ```bash
24
+ claude mcp add bear-notes --transport stdio -- npx -y bear-notes-mcp@latest
25
+ ```
26
+
27
+ **For Node.js 22.5.0-22.12.x or 23.0.0-23.3.x (older versions):**
28
+ ```bash
29
+ claude mcp add bear-notes --transport stdio --env NODE_OPTIONS="--experimental-sqlite" -- npx -y bear-notes-mcp@latest
30
+ ```
31
+
32
+ That's it! The server will be downloaded from npm and configured automatically.
33
+
34
+ ## Quick Start - Other AI Assistants
35
+
36
+ **Check your Node.js version:** `node --version`
37
+
38
+ **For Node.js 22.13.0+ / 23.4.0+ / 24.x+ / 25.x+ (recommended):**
39
+ ```json
40
+ {
41
+ "mcpServers": {
42
+ "bear-notes": {
43
+ "command": "npx",
44
+ "args": ["-y", "bear-notes-mcp@latest"]
45
+ }
46
+ }
47
+ }
48
+ ```
49
+
50
+ **For Node.js 22.5.0-22.12.x or 23.0.0-23.3.x (older versions):**
51
+ ```json
52
+ {
53
+ "mcpServers": {
54
+ "bear-notes": {
55
+ "command": "npx",
56
+ "args": ["-y", "bear-notes-mcp@latest"],
57
+ "env": {
58
+ "NODE_OPTIONS": "--experimental-sqlite"
59
+ }
60
+ }
61
+ }
62
+ }
63
+ ```
64
+
65
+ ## Advanced: Local Development Build
66
+
67
+ **Step 1: Clone and build**
68
+ ```bash
69
+ git clone https://github.com/vasylenko/claude-desktop-extension-bear-notes.git
70
+ cd claude-desktop-extension-bear-notes
71
+ npm install
72
+ npm run build
73
+ ```
74
+
75
+ **Step 2: Configure with local path**
76
+
77
+ For Claude Code (Node.js 22.13.0+):
78
+ ```bash
79
+ claude mcp add bear-notes --transport stdio -- node /absolute/path/to/dist/main.js
80
+ ```
81
+
82
+ For Claude Code (Node.js 22.5.0-22.12.x):
83
+ ```bash
84
+ claude mcp add bear-notes --transport stdio -- node --experimental-sqlite /absolute/path/to/dist/main.js
85
+ ```
86
+
87
+ For other AI assistants (Node.js 22.13.0+):
88
+ ```json
89
+ {
90
+ "mcpServers": {
91
+ "bear-notes": {
92
+ "command": "node",
93
+ "args": ["/absolute/path/to/dist/main.js"]
94
+ }
95
+ }
96
+ }
97
+ ```
98
+
99
+ For other AI assistants (Node.js 22.5.0-22.12.x):
100
+ ```json
101
+ {
102
+ "mcpServers": {
103
+ "bear-notes": {
104
+ "command": "node",
105
+ "args": ["--experimental-sqlite", "/absolute/path/to/dist/main.js"]
106
+ }
107
+ }
108
+ }
109
+ ```
@@ -0,0 +1,87 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { BEAR_URL_SCHEME } from './config.js';
3
+ import { logAndThrow, logger } from './utils.js';
4
+ /**
5
+ * Builds a Bear x-callback-url for various actions with proper parameter encoding.
6
+ * Includes required UX parameters for optimal user experience.
7
+ *
8
+ * @param action - Bear API action (e.g., 'create', 'add-text')
9
+ * @param params - Parameters for the specific action
10
+ * @returns Properly encoded x-callback-url string
11
+ */
12
+ export function buildBearUrl(action, params = {}) {
13
+ logger.debug(`Building Bear URL for action: ${action}`);
14
+ if (!action || typeof action !== 'string' || !action.trim()) {
15
+ logAndThrow('Bear URL error: Action parameter is required and must be a non-empty string');
16
+ }
17
+ const baseUrl = `${BEAR_URL_SCHEME}${action.trim()}`;
18
+ const urlParams = new URLSearchParams();
19
+ // Add provided parameters with proper encoding
20
+ const stringParams = ['title', 'text', 'tags', 'id', 'header', 'file', 'filename'];
21
+ for (const key of stringParams) {
22
+ const value = params[key];
23
+ if (value !== undefined && value.trim()) {
24
+ urlParams.set(key, value.trim());
25
+ }
26
+ }
27
+ if (params.mode !== undefined) {
28
+ urlParams.set('mode', params.mode);
29
+ }
30
+ // UX params with defaults
31
+ urlParams.set('open_note', params.open_note ?? 'yes');
32
+ urlParams.set('new_window', params.new_window ?? 'no');
33
+ 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
+ // Convert URLSearchParams to proper URL encoding (Bear expects %20 not +)
39
+ const queryString = urlParams.toString().replace(/\+/g, '%20');
40
+ const finalUrl = `${baseUrl}?${queryString}`;
41
+ logger.debug(`Built Bear URL: ${finalUrl}`);
42
+ return finalUrl;
43
+ }
44
+ /**
45
+ * Executes a Bear x-callback-url using macOS subprocess execution.
46
+ * Platform-specific function that requires macOS with Bear Notes installed.
47
+ *
48
+ * @param url - The x-callback-url to execute
49
+ * @returns Promise that resolves when the command completes successfully
50
+ * @throws Error if platform is not macOS or subprocess execution fails
51
+ */
52
+ export function executeBearXCallbackApi(url) {
53
+ logger.debug('Executing Bear x-callback-url');
54
+ if (!url || typeof url !== 'string' || !url.trim()) {
55
+ logAndThrow('Bear URL error: URL parameter is required and must be a non-empty string');
56
+ }
57
+ return new Promise((resolve, reject) => {
58
+ logger.debug('Launching Bear Notes via x-callback-url');
59
+ const child = spawn('open', ['-g', url.trim()], {
60
+ stdio: 'pipe',
61
+ detached: false,
62
+ });
63
+ let errorOutput = '';
64
+ if (child.stderr) {
65
+ child.stderr.on('data', (data) => {
66
+ errorOutput += data.toString();
67
+ });
68
+ }
69
+ child.on('close', (code) => {
70
+ if (code === 0) {
71
+ logger.debug('Bear x-callback-url executed successfully');
72
+ resolve();
73
+ }
74
+ else {
75
+ const errorMessage = `Bear URL error: Failed to execute x-callback-url (exit code: ${code})`;
76
+ const fullError = errorOutput ? `${errorMessage}. Error: ${errorOutput}` : errorMessage;
77
+ logger.error(fullError);
78
+ reject(new Error(fullError));
79
+ }
80
+ });
81
+ child.on('error', (error) => {
82
+ const errorMessage = `Bear URL error: Failed to spawn subprocess: ${error.message}`;
83
+ logger.error(errorMessage);
84
+ reject(new Error(errorMessage));
85
+ });
86
+ });
87
+ }
package/dist/config.js ADDED
@@ -0,0 +1,11 @@
1
+ export const APP_VERSION = '2.1.0';
2
+ export const BEAR_URL_SCHEME = 'bear://x-callback-url/';
3
+ export const CORE_DATA_EPOCH_OFFSET = 978307200; // 2001-01-01 to Unix epoch
4
+ export const DEFAULT_SEARCH_LIMIT = 50;
5
+ export const BEAR_DATABASE_PATH = 'Library/Group Containers/9K33E3U3T4.net.shinyfrog.bear/Application Data/database.sqlite';
6
+ export const ERROR_MESSAGES = {
7
+ 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
+ };
@@ -0,0 +1,40 @@
1
+ import { DatabaseSync } from 'node:sqlite';
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { existsSync } from 'node:fs';
5
+ import { BEAR_DATABASE_PATH, ERROR_MESSAGES } from './config.js';
6
+ import { logAndThrow, logger } from './utils.js';
7
+ function getBearDatabasePath() {
8
+ // Environment override for testing
9
+ const envPath = process.env.BEAR_DB_PATH;
10
+ if (envPath) {
11
+ logger.debug(`Using environment override database path: ${envPath}`);
12
+ return envPath;
13
+ }
14
+ const defaultPath = join(homedir(), BEAR_DATABASE_PATH);
15
+ if (!existsSync(defaultPath)) {
16
+ logger.error(`Bear database not found at: ${defaultPath}`);
17
+ logAndThrow(`Database error: ${ERROR_MESSAGES.BEAR_DATABASE_NOT_FOUND}`);
18
+ }
19
+ logger.debug(`Using default Bear database path: ${defaultPath}`);
20
+ return defaultPath;
21
+ }
22
+ /**
23
+ * Opens a read-only connection to Bear's SQLite database.
24
+ *
25
+ * @returns DatabaseSync instance for querying Bear notes
26
+ * @throws Error if Bear database is not found or cannot be opened
27
+ */
28
+ export function openBearDatabase() {
29
+ const databasePath = getBearDatabasePath();
30
+ logger.info(`Opening Bear database at: ${databasePath}`);
31
+ try {
32
+ const db = new DatabaseSync(databasePath);
33
+ logger.debug('Bear database opened successfully');
34
+ return db;
35
+ }
36
+ catch (error) {
37
+ logger.error(`Failed to open Bear database: ${error}`);
38
+ logAndThrow(`Database error: Failed to open Bear database: ${error instanceof Error ? error.message : String(error)}`);
39
+ }
40
+ }
package/dist/main.js ADDED
@@ -0,0 +1,422 @@
1
+ #!/usr/bin/env node
2
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
3
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
4
+ import { z } from 'zod';
5
+ import { APP_VERSION, ERROR_MESSAGES } from './config.js';
6
+ import { cleanBase64, createToolResponse, handleAddText, logger } from './utils.js';
7
+ import { getNoteContent, searchNotes } from './notes.js';
8
+ import { findUntaggedNotes, listTags } from './tags.js';
9
+ import { buildBearUrl, executeBearXCallbackApi } from './bear-urls.js';
10
+ const server = new McpServer({
11
+ name: 'bear-notes-mcp',
12
+ version: APP_VERSION,
13
+ });
14
+ server.registerTool('bear-open-note', {
15
+ title: 'Open Bear Note',
16
+ 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
+ inputSchema: {
18
+ identifier: z.string().describe('Exact note identifier (ID) obtained from bear-search-notes'),
19
+ },
20
+ annotations: {
21
+ readOnlyHint: true,
22
+ idempotentHint: true,
23
+ openWorldHint: false,
24
+ },
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
+ try {
31
+ const noteWithContent = getNoteContent(identifier.trim());
32
+ if (!noteWithContent) {
33
+ return createToolResponse(`Note with ID '${identifier}' not found. The note may have been deleted, archived, or the ID may be incorrect.
34
+
35
+ Use bear-search-notes to find the correct note identifier.`);
36
+ }
37
+ const noteInfo = [
38
+ `**${noteWithContent.title}**`,
39
+ `Modified: ${noteWithContent.modification_date}`,
40
+ `ID: ${noteWithContent.identifier}`,
41
+ ];
42
+ const noteText = noteWithContent.text || '*This note appears to be empty.*';
43
+ return createToolResponse(`${noteInfo.join('\n')}
44
+
45
+ ---
46
+
47
+ ${noteText}`);
48
+ }
49
+ catch (error) {
50
+ logger.error(`bear-open-note failed: ${error}`);
51
+ throw error;
52
+ }
53
+ });
54
+ server.registerTool('bear-create-note', {
55
+ title: 'Create New Note',
56
+ description: 'Create a new note in your Bear library with optional title, content, and tags. The note will be immediately available in Bear app.',
57
+ inputSchema: {
58
+ title: z
59
+ .string()
60
+ .optional()
61
+ .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"'),
64
+ },
65
+ annotations: {
66
+ readOnlyHint: false,
67
+ destructiveHint: false,
68
+ idempotentHint: false,
69
+ openWorldHint: true,
70
+ },
71
+ }, async ({ title, text, tags }) => {
72
+ logger.debug(`bear-create-note called with title: ${title ? '"' + title + '"' : 'none'}, text length: ${text ? text.length : 0}, tags: ${tags || 'none'}`);
73
+ try {
74
+ const url = buildBearUrl('create', { title, text, tags });
75
+ await executeBearXCallbackApi(url);
76
+ const responseLines = ['Bear note created successfully!', ''];
77
+ if (title?.trim()) {
78
+ responseLines.push(`Title: "${title.trim()}"`);
79
+ }
80
+ if (text?.trim()) {
81
+ responseLines.push(`Content: ${text.trim().length} characters`);
82
+ }
83
+ if (tags?.trim()) {
84
+ responseLines.push(`Tags: ${tags.trim()}`);
85
+ }
86
+ const hasContent = title?.trim() || text?.trim() || tags?.trim();
87
+ const finalMessage = hasContent ? responseLines.join('\n') : 'Empty note created';
88
+ return createToolResponse(`${finalMessage}
89
+
90
+ The note has been added to your Bear Notes library.`);
91
+ }
92
+ catch (error) {
93
+ logger.error(`bear-create-note failed: ${error}`);
94
+ throw error;
95
+ }
96
+ });
97
+ server.registerTool('bear-search-notes', {
98
+ title: 'Find Bear Notes',
99
+ 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
+ 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)'),
103
+ limit: z.number().optional().describe('Maximum number of results to return (default: 50)'),
104
+ createdAfter: z
105
+ .string()
106
+ .optional()
107
+ .describe('Filter notes created on or after this date. Supports: relative dates ("today", "yesterday", "last week", "start of last month"), ISO format (YYYY-MM-DD). Use "start of last month" for the beginning of the previous month.'),
108
+ createdBefore: z
109
+ .string()
110
+ .optional()
111
+ .describe('Filter notes created on or before this date. Supports: relative dates ("today", "yesterday", "last week", "end of last month"), ISO format (YYYY-MM-DD). Use "end of last month" for the end of the previous month.'),
112
+ modifiedAfter: z
113
+ .string()
114
+ .optional()
115
+ .describe('Filter notes modified on or after this date. Supports: relative dates ("today", "yesterday", "last week", "start of last month"), ISO format (YYYY-MM-DD). Use "start of last month" for the beginning of the previous month.'),
116
+ modifiedBefore: z
117
+ .string()
118
+ .optional()
119
+ .describe('Filter notes modified on or before this date. Supports: relative dates ("today", "yesterday", "last week", "end of last month"), ISO format (YYYY-MM-DD). Use "end of last month" for the end of the previous month.'),
120
+ },
121
+ annotations: {
122
+ readOnlyHint: true,
123
+ idempotentHint: true,
124
+ openWorldHint: false,
125
+ },
126
+ }, async ({ term, tag, limit, createdAfter, createdBefore, modifiedAfter, modifiedBefore, }) => {
127
+ logger.info(`bear-search-notes called with term: "${term || 'none'}", tag: "${tag || 'none'}", limit: ${limit || 'default'}, createdAfter: "${createdAfter || 'none'}", createdBefore: "${createdBefore || 'none'}", modifiedAfter: "${modifiedAfter || 'none'}", modifiedBefore: "${modifiedBefore || 'none'}", includeFiles: always`);
128
+ try {
129
+ const dateFilter = {
130
+ ...(createdAfter && { createdAfter }),
131
+ ...(createdBefore && { createdBefore }),
132
+ ...(modifiedAfter && { modifiedAfter }),
133
+ ...(modifiedBefore && { modifiedBefore }),
134
+ };
135
+ const notes = searchNotes(term, tag, limit, Object.keys(dateFilter).length > 0 ? dateFilter : undefined);
136
+ if (notes.length === 0) {
137
+ const searchCriteria = [];
138
+ if (term?.trim())
139
+ searchCriteria.push(`term "${term.trim()}"`);
140
+ if (tag?.trim())
141
+ searchCriteria.push(`tag "${tag.trim()}"`);
142
+ if (createdAfter)
143
+ searchCriteria.push(`created after "${createdAfter}"`);
144
+ if (createdBefore)
145
+ searchCriteria.push(`created before "${createdBefore}"`);
146
+ if (modifiedAfter)
147
+ searchCriteria.push(`modified after "${modifiedAfter}"`);
148
+ if (modifiedBefore)
149
+ searchCriteria.push(`modified before "${modifiedBefore}"`);
150
+ return createToolResponse(`No notes found matching ${searchCriteria.join(', ')}.
151
+
152
+ Try different search criteria or check if notes exist in Bear Notes.`);
153
+ }
154
+ const resultLines = [`Found ${notes.length} note${notes.length === 1 ? '' : 's'}:`, ''];
155
+ notes.forEach((note, index) => {
156
+ const noteTitle = note.title || 'Untitled';
157
+ const modifiedDate = new Date(note.modification_date).toLocaleDateString();
158
+ const createdDate = new Date(note.creation_date).toLocaleDateString();
159
+ resultLines.push(`${index + 1}. **${noteTitle}**`);
160
+ resultLines.push(` Created: ${createdDate}`);
161
+ resultLines.push(` Modified: ${modifiedDate}`);
162
+ resultLines.push(` ID: ${note.identifier}`);
163
+ resultLines.push('');
164
+ });
165
+ resultLines.push('Use bear-open-note with an ID to read the full content of any note.');
166
+ return createToolResponse(resultLines.join('\n'));
167
+ }
168
+ catch (error) {
169
+ logger.error(`bear-search-notes failed: ${error}`);
170
+ throw error;
171
+ }
172
+ });
173
+ server.registerTool('bear-add-text', {
174
+ title: 'Add Text to Note',
175
+ 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.',
176
+ inputSchema: {
177
+ id: z.string().describe('Note identifier (ID) from bear-search-notes'),
178
+ text: z.string().describe('Text content to add to the note'),
179
+ header: z
180
+ .string()
181
+ .optional()
182
+ .describe('Optional section header to target (adds text within that section)'),
183
+ position: z
184
+ .enum(['beginning', 'end'])
185
+ .optional()
186
+ .describe("Where to insert: 'end' (default) for appending, logs, updates; 'beginning' for prepending, summaries, top of mind, etc."),
187
+ },
188
+ annotations: {
189
+ readOnlyHint: false,
190
+ destructiveHint: true,
191
+ idempotentHint: false,
192
+ openWorldHint: true,
193
+ },
194
+ }, async ({ id, text, header, position }) => {
195
+ const mode = position === 'beginning' ? 'prepend' : 'append';
196
+ return handleAddText(mode, { id, text, header });
197
+ });
198
+ server.registerTool('bear-add-file', {
199
+ title: 'Add File to Note',
200
+ 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.',
201
+ inputSchema: {
202
+ base64_content: z.string().describe('Base64-encoded file content'),
203
+ filename: z.string().describe('Filename with extension (e.g., budget.xlsx, report.pdf)'),
204
+ id: z
205
+ .string()
206
+ .optional()
207
+ .describe('Exact note identifier (ID) obtained from bear-search-notes'),
208
+ title: z.string().optional().describe('Note title if ID is not available'),
209
+ },
210
+ annotations: {
211
+ readOnlyHint: false,
212
+ destructiveHint: true,
213
+ idempotentHint: false,
214
+ openWorldHint: true,
215
+ },
216
+ }, async ({ base64_content, filename, id, title }) => {
217
+ logger.info(`bear-add-file called with base64_content: ${base64_content ? 'provided' : 'none'}, filename: ${filename || 'none'}, id: ${id || 'none'}, title: ${title || 'none'}`);
218
+ if (!base64_content || !base64_content.trim()) {
219
+ throw new Error('base64_content is required');
220
+ }
221
+ if (!filename || !filename.trim()) {
222
+ throw new Error('filename is required');
223
+ }
224
+ if (!id && !title) {
225
+ throw new Error('Either note ID or title is required. Use bear-search-notes to find the note ID.');
226
+ }
227
+ try {
228
+ // base64 CLI adds line breaks that break URL encoding
229
+ const cleanedBase64 = cleanBase64(base64_content);
230
+ // Fail fast with helpful message rather than cryptic Bear error
231
+ if (id) {
232
+ const existingNote = getNoteContent(id.trim());
233
+ if (!existingNote) {
234
+ return createToolResponse(`Note with ID '${id}' not found. The note may have been deleted, archived, or the ID may be incorrect.
235
+
236
+ Use bear-search-notes to find the correct note identifier.`);
237
+ }
238
+ }
239
+ const url = buildBearUrl('add-file', {
240
+ id: id?.trim(),
241
+ title: title?.trim(),
242
+ file: cleanedBase64,
243
+ filename: filename.trim(),
244
+ mode: 'append',
245
+ });
246
+ logger.debug(`Executing Bear add-file URL for: ${filename.trim()}`);
247
+ await executeBearXCallbackApi(url);
248
+ const noteIdentifier = id ? `Note ID: ${id.trim()}` : `Note title: "${title.trim()}"`;
249
+ return createToolResponse(`File "${filename.trim()}" added successfully!
250
+
251
+ ${noteIdentifier}
252
+
253
+ The file has been attached to your Bear note.`);
254
+ }
255
+ catch (error) {
256
+ logger.error(`bear-add-file failed: ${error}`);
257
+ throw error;
258
+ }
259
+ });
260
+ /**
261
+ * Formats tag hierarchy as tree-style text output.
262
+ * Uses box-drawing characters for visual tree structure.
263
+ */
264
+ function formatTagTree(tags, isLast = []) {
265
+ const lines = [];
266
+ for (let i = 0; i < tags.length; i++) {
267
+ const tag = tags[i];
268
+ const isLastItem = i === tags.length - 1;
269
+ // Build the prefix using box-drawing characters
270
+ let linePrefix = '';
271
+ for (let j = 0; j < isLast.length; j++) {
272
+ linePrefix += isLast[j] ? ' ' : '│ ';
273
+ }
274
+ linePrefix += isLastItem ? '└── ' : '├── ';
275
+ lines.push(`${linePrefix}${tag.name} (${tag.noteCount})`);
276
+ if (tag.children.length > 0) {
277
+ lines.push(...formatTagTree(tag.children, [...isLast, isLastItem]));
278
+ }
279
+ }
280
+ return lines;
281
+ }
282
+ server.registerTool('bear-list-tags', {
283
+ title: 'List Bear Tags',
284
+ description: 'List all tags in your Bear library as a hierarchical tree. Shows tag names with note counts. Useful for understanding your tag structure and finding tags to apply to untagged notes.',
285
+ inputSchema: {},
286
+ annotations: {
287
+ readOnlyHint: true,
288
+ idempotentHint: true,
289
+ openWorldHint: false,
290
+ },
291
+ }, async () => {
292
+ logger.info('bear-list-tags called');
293
+ try {
294
+ const { tags, totalCount } = listTags();
295
+ if (totalCount === 0) {
296
+ return createToolResponse('No tags found in your Bear library.');
297
+ }
298
+ // Format root tags with their children as trees
299
+ const lines = [];
300
+ for (const rootTag of tags) {
301
+ lines.push(`${rootTag.name} (${rootTag.noteCount})`);
302
+ if (rootTag.children.length > 0) {
303
+ lines.push(...formatTagTree(rootTag.children));
304
+ }
305
+ }
306
+ const header = `Found ${totalCount} tag${totalCount === 1 ? '' : 's'}:\n`;
307
+ return createToolResponse(header + '\n' + lines.join('\n'));
308
+ }
309
+ catch (error) {
310
+ logger.error(`bear-list-tags failed: ${error}`);
311
+ throw error;
312
+ }
313
+ });
314
+ server.registerTool('bear-find-untagged-notes', {
315
+ title: 'Find Untagged Notes',
316
+ description: 'Find notes in your Bear library that have no tags. Useful for organizing and categorizing notes.',
317
+ inputSchema: {
318
+ limit: z.number().optional().describe('Maximum number of results (default: 50)'),
319
+ },
320
+ annotations: {
321
+ readOnlyHint: true,
322
+ idempotentHint: true,
323
+ openWorldHint: false,
324
+ },
325
+ }, async ({ limit }) => {
326
+ logger.info(`bear-find-untagged-notes called with limit: ${limit || 'default'}`);
327
+ try {
328
+ const notes = findUntaggedNotes(limit);
329
+ if (notes.length === 0) {
330
+ return createToolResponse('No untagged notes found. All your notes have tags!');
331
+ }
332
+ const lines = [`Found ${notes.length} untagged note${notes.length === 1 ? '' : 's'}:`, ''];
333
+ notes.forEach((note, index) => {
334
+ const modifiedDate = new Date(note.modification_date).toLocaleDateString();
335
+ lines.push(`${index + 1}. **${note.title}**`);
336
+ lines.push(` Modified: ${modifiedDate}`);
337
+ lines.push(` ID: ${note.identifier}`);
338
+ lines.push('');
339
+ });
340
+ lines.push('You can also use bear-list-tags to see available tags.');
341
+ return createToolResponse(lines.join('\n'));
342
+ }
343
+ catch (error) {
344
+ logger.error(`bear-find-untagged-notes failed: ${error}`);
345
+ throw error;
346
+ }
347
+ });
348
+ server.registerTool('bear-add-tag', {
349
+ title: 'Add Tags to Note',
350
+ description: 'Add one or more tags to an existing Bear note. Tags are added at the beginning of the note. Use bear-list-tags to see available tags.',
351
+ inputSchema: {
352
+ id: z
353
+ .string()
354
+ .describe('Note identifier (ID) from bear-search-notes or bear-find-untagged-notes'),
355
+ tags: z
356
+ .array(z.string())
357
+ .describe('Tag names without # symbol (e.g., ["career", "career/meetings"])'),
358
+ },
359
+ annotations: {
360
+ readOnlyHint: false,
361
+ destructiveHint: false,
362
+ idempotentHint: false,
363
+ openWorldHint: true,
364
+ },
365
+ }, async ({ id, tags }) => {
366
+ logger.info(`bear-add-tag called with id: ${id}, tags: [${tags.join(', ')}]`);
367
+ if (!id || !id.trim()) {
368
+ throw new Error(ERROR_MESSAGES.MISSING_NOTE_ID);
369
+ }
370
+ if (!tags || tags.length === 0) {
371
+ throw new Error('At least one tag is required');
372
+ }
373
+ try {
374
+ const existingNote = getNoteContent(id.trim());
375
+ if (!existingNote) {
376
+ return createToolResponse(`Note with ID '${id}' not found. The note may have been deleted, archived, or the ID may be incorrect.
377
+
378
+ Use bear-search-notes to find the correct note identifier.`);
379
+ }
380
+ const tagsString = tags.join(',');
381
+ const url = buildBearUrl('add-text', {
382
+ id: id.trim(),
383
+ tags: tagsString,
384
+ mode: 'prepend',
385
+ open_note: 'no',
386
+ show_window: 'no',
387
+ new_window: 'no',
388
+ });
389
+ await executeBearXCallbackApi(url);
390
+ const tagList = tags.map((t) => `#${t}`).join(', ');
391
+ return createToolResponse(`Tags added successfully!
392
+
393
+ Note: "${existingNote.title}"
394
+ Tags: ${tagList}
395
+
396
+ The tags have been added to the beginning of the note.`);
397
+ }
398
+ catch (error) {
399
+ logger.error(`bear-add-tag failed: ${error}`);
400
+ throw error;
401
+ }
402
+ });
403
+ async function main() {
404
+ logger.info(`Bear Notes MCP Server initializing... Version: ${APP_VERSION}`);
405
+ logger.debug(`Debug logs enabled: ${logger.debug.enabled}`);
406
+ logger.debug(`Node.js version: ${process.version}`);
407
+ logger.debug(`App version: ${APP_VERSION}`);
408
+ // Handle process errors
409
+ process.on('uncaughtException', (error) => {
410
+ logger.error('Uncaught exception:', error);
411
+ });
412
+ process.on('unhandledRejection', (reason, promise) => {
413
+ logger.error('Unhandled rejection at:', promise, 'reason:', reason);
414
+ });
415
+ const transport = new StdioServerTransport();
416
+ await server.connect(transport);
417
+ logger.info('Bear Notes MCP Server connected and ready');
418
+ }
419
+ main().catch((error) => {
420
+ logger.error('Server startup failed:', error);
421
+ process.exit(1);
422
+ });
package/dist/notes.js ADDED
@@ -0,0 +1,224 @@
1
+ import { DEFAULT_SEARCH_LIMIT } from './config.js';
2
+ import { convertCoreDataTimestamp, convertDateToCoreDataTimestamp, logAndThrow, logger, parseDateString, } from './utils.js';
3
+ import { openBearDatabase } from './database.js';
4
+ function formatBearNote(row) {
5
+ const title = row.title || 'Untitled';
6
+ const identifier = row.identifier;
7
+ const modificationDate = row.modificationDate;
8
+ const creationDate = row.creationDate;
9
+ const pinned = row.pinned;
10
+ const text = row.text;
11
+ if (!identifier) {
12
+ logAndThrow('Database error: Note identifier is missing from database row');
13
+ }
14
+ if (typeof modificationDate !== 'number' || typeof creationDate !== 'number') {
15
+ logAndThrow('Database error: Note date fields are invalid in database row');
16
+ }
17
+ const modification_date = convertCoreDataTimestamp(modificationDate);
18
+ const creation_date = convertCoreDataTimestamp(creationDate);
19
+ // Bear stores pinned as integer; API expects string literal (only needed when pinned is queried)
20
+ const pin = pinned ? 'yes' : 'no';
21
+ return {
22
+ title,
23
+ identifier,
24
+ modification_date,
25
+ creation_date,
26
+ pin,
27
+ ...(text !== undefined && { text }),
28
+ };
29
+ }
30
+ /**
31
+ * Retrieves a Bear note with its full content from the database.
32
+ *
33
+ * @param identifier - The unique identifier of the Bear note
34
+ * @returns The note with content, or null if not found
35
+ * @throws Error if database access fails or identifier is invalid
36
+ * Note: Always includes OCR'd text from attached images and PDFs with clear labeling
37
+ */
38
+ export function getNoteContent(identifier) {
39
+ logger.info(`getNoteContent called with identifier: ${identifier}, includeFiles: always`);
40
+ if (!identifier || typeof identifier !== 'string' || !identifier.trim()) {
41
+ logAndThrow('Database error: Invalid note identifier provided');
42
+ }
43
+ const db = openBearDatabase();
44
+ try {
45
+ logger.debug(`Fetching the note content from the database, note identifier: ${identifier}`);
46
+ // Query with file content - always includes OCR'd text from attached files with clear labeling
47
+ const query = `
48
+ SELECT note.ZTITLE as title,
49
+ note.ZUNIQUEIDENTIFIER as identifier,
50
+ note.ZCREATIONDATE as creationDate,
51
+ note.ZMODIFICATIONDATE as modificationDate,
52
+ note.ZPINNED as pinned,
53
+ note.ZTEXT as text,
54
+ f.ZFILENAME as filename,
55
+ f.ZSEARCHTEXT as fileContent
56
+ FROM ZSFNOTE note
57
+ LEFT JOIN ZSFNOTEFILE f ON f.ZNOTE = note.Z_PK
58
+ WHERE note.ZUNIQUEIDENTIFIER = ?
59
+ AND note.ZARCHIVED = 0
60
+ AND note.ZTRASHED = 0
61
+ AND note.ZENCRYPTED = 0
62
+ `;
63
+ const stmt = db.prepare(query);
64
+ const rows = stmt.all(identifier);
65
+ if (!rows || rows.length === 0) {
66
+ logger.info(`Note not found for identifier: ${identifier}`);
67
+ return null;
68
+ }
69
+ // Process multiple rows (note + files) into single note object
70
+ const firstRow = rows[0];
71
+ const formattedNote = formatBearNote(firstRow);
72
+ // Collect file content from all rows with clear source labeling
73
+ const fileContents = [];
74
+ for (const row of rows) {
75
+ const rowData = row;
76
+ const filename = rowData.filename;
77
+ const fileContent = rowData.fileContent;
78
+ if (filename && fileContent && fileContent.trim()) {
79
+ fileContents.push(`##${filename}\n\n${fileContent.trim()}`);
80
+ }
81
+ }
82
+ // Always append file content section, even if empty, to show structure
83
+ const originalText = formattedNote.text || '';
84
+ const filesSectionHeader = '\n\n---\n\n#Attached Files\n\n';
85
+ if (fileContents.length > 0) {
86
+ const fileSection = `${filesSectionHeader}${fileContents.join('\n\n---\n\n')}`;
87
+ formattedNote.text = originalText + fileSection;
88
+ }
89
+ else {
90
+ // Add a note that no files are attached for clarity
91
+ formattedNote.text = originalText + `${filesSectionHeader}*No files attached to this note.*`;
92
+ }
93
+ logger.info(`Retrieved note content with ${fileContents.length} attached files for: ${formattedNote.title}`);
94
+ return formattedNote;
95
+ }
96
+ catch (error) {
97
+ logger.error(`SQLite query failed: ${error}`);
98
+ logAndThrow(`Database error: Failed to retrieve note content: ${error instanceof Error ? error.message : String(error)}`);
99
+ }
100
+ finally {
101
+ try {
102
+ db.close();
103
+ logger.debug('Database connection closed');
104
+ }
105
+ catch (closeError) {
106
+ logger.error(`Failed to close database connection: ${closeError}`);
107
+ }
108
+ }
109
+ return null;
110
+ }
111
+ /**
112
+ * Searches Bear notes by content or tags with optional filtering.
113
+ * Returns a list of notes without full content for performance.
114
+ *
115
+ * @param searchTerm - Text to search for in note titles and content (optional)
116
+ * @param tag - Tag to filter notes by (optional)
117
+ * @param limit - Maximum number of results to return (default from config)
118
+ * @param dateFilter - Date range filters for creation and modification dates (optional)
119
+ * @returns Array of matching notes without full text content
120
+ * @throws Error if database access fails or no search criteria provided
121
+ * Note: Always searches within text extracted from attached images and PDF files via OCR for comprehensive results
122
+ */
123
+ export function searchNotes(searchTerm, tag, limit, dateFilter) {
124
+ logger.info(`searchNotes called with term: "${searchTerm || 'none'}", tag: "${tag || 'none'}", limit: ${limit || DEFAULT_SEARCH_LIMIT}, dateFilter: ${dateFilter ? JSON.stringify(dateFilter) : 'none'}, includeFiles: always`);
125
+ // Validate search parameters - at least one must be provided
126
+ const hasSearchTerm = searchTerm && typeof searchTerm === 'string' && searchTerm.trim();
127
+ const hasTag = tag && typeof tag === 'string' && tag.trim();
128
+ const hasDateFilter = dateFilter && Object.keys(dateFilter).length > 0;
129
+ if (!hasSearchTerm && !hasTag && !hasDateFilter) {
130
+ logAndThrow('Search error: Please provide a search term, tag, or date filter to search for notes');
131
+ }
132
+ const db = openBearDatabase();
133
+ const queryLimit = limit || DEFAULT_SEARCH_LIMIT;
134
+ try {
135
+ let query;
136
+ const queryParams = [];
137
+ // Query with file search - uses LEFT JOIN to include OCR'd content for comprehensive search
138
+ query = `
139
+ SELECT DISTINCT note.ZTITLE as title,
140
+ note.ZUNIQUEIDENTIFIER as identifier,
141
+ note.ZCREATIONDATE as creationDate,
142
+ note.ZMODIFICATIONDATE as modificationDate
143
+ FROM ZSFNOTE note
144
+ LEFT JOIN ZSFNOTEFILE f ON f.ZNOTE = note.Z_PK
145
+ WHERE note.ZARCHIVED = 0
146
+ AND note.ZTRASHED = 0
147
+ AND note.ZENCRYPTED = 0`;
148
+ // Add search term filtering
149
+ if (hasSearchTerm) {
150
+ const searchPattern = `%${searchTerm.trim()}%`;
151
+ // Search in note title, text, and file OCR content
152
+ query += ' AND (note.ZTITLE LIKE ? OR note.ZTEXT LIKE ? OR f.ZSEARCHTEXT LIKE ?)';
153
+ queryParams.push(searchPattern, searchPattern, searchPattern);
154
+ }
155
+ // Add tag filtering
156
+ if (hasTag) {
157
+ const tagPattern = `%#${tag.trim()}%`;
158
+ query += ' AND note.ZTEXT LIKE ?';
159
+ queryParams.push(tagPattern);
160
+ }
161
+ // Add date filtering
162
+ if (hasDateFilter && dateFilter) {
163
+ if (dateFilter.createdAfter) {
164
+ const afterDate = parseDateString(dateFilter.createdAfter);
165
+ // Set to start of day (00:00:00) to include notes from the entire specified day onwards
166
+ afterDate.setHours(0, 0, 0, 0);
167
+ const timestamp = convertDateToCoreDataTimestamp(afterDate);
168
+ query += ' AND note.ZCREATIONDATE >= ?';
169
+ queryParams.push(timestamp);
170
+ }
171
+ if (dateFilter.createdBefore) {
172
+ const beforeDate = parseDateString(dateFilter.createdBefore);
173
+ // Set to end of day (23:59:59.999) to include notes through the entire specified day
174
+ beforeDate.setHours(23, 59, 59, 999);
175
+ const timestamp = convertDateToCoreDataTimestamp(beforeDate);
176
+ query += ' AND note.ZCREATIONDATE <= ?';
177
+ queryParams.push(timestamp);
178
+ }
179
+ if (dateFilter.modifiedAfter) {
180
+ const afterDate = parseDateString(dateFilter.modifiedAfter);
181
+ // Set to start of day (00:00:00) to include notes from the entire specified day onwards
182
+ afterDate.setHours(0, 0, 0, 0);
183
+ const timestamp = convertDateToCoreDataTimestamp(afterDate);
184
+ query += ' AND note.ZMODIFICATIONDATE >= ?';
185
+ queryParams.push(timestamp);
186
+ }
187
+ if (dateFilter.modifiedBefore) {
188
+ const beforeDate = parseDateString(dateFilter.modifiedBefore);
189
+ // Set to end of day (23:59:59.999) to include notes through the entire specified day
190
+ beforeDate.setHours(23, 59, 59, 999);
191
+ const timestamp = convertDateToCoreDataTimestamp(beforeDate);
192
+ query += ' AND note.ZMODIFICATIONDATE <= ?';
193
+ queryParams.push(timestamp);
194
+ }
195
+ }
196
+ // Add ordering and limit
197
+ query += ' ORDER BY note.ZMODIFICATIONDATE DESC LIMIT ?';
198
+ queryParams.push(queryLimit);
199
+ logger.debug(`Executing search query with ${queryParams.length} parameters`);
200
+ // Use parameter binding to prevent SQL injection attacks
201
+ const stmt = db.prepare(query);
202
+ const rows = stmt.all(...queryParams);
203
+ if (!rows || rows.length === 0) {
204
+ logger.info('No notes found matching search criteria');
205
+ return [];
206
+ }
207
+ const notes = rows.map((row) => formatBearNote(row));
208
+ logger.info(`Found ${notes.length} notes matching search criteria`);
209
+ return notes;
210
+ }
211
+ catch (error) {
212
+ logAndThrow(`SQLite search query failed: ${error instanceof Error ? error.message : String(error)}`);
213
+ }
214
+ finally {
215
+ try {
216
+ db.close();
217
+ logger.debug('Database connection closed');
218
+ }
219
+ catch (closeError) {
220
+ logger.error(`Failed to close database connection: ${closeError}`);
221
+ }
222
+ }
223
+ return [];
224
+ }
package/dist/tags.js ADDED
@@ -0,0 +1,170 @@
1
+ import { convertCoreDataTimestamp, logAndThrow, logger } from './utils.js';
2
+ import { openBearDatabase } from './database.js';
3
+ /**
4
+ * Decodes and normalizes Bear tag names.
5
+ * - Replaces '+' with spaces (Bear's URL encoding)
6
+ * - Converts to lowercase (matches Bear UI behavior)
7
+ * - Trims whitespace
8
+ */
9
+ function decodeTagName(encodedName) {
10
+ return encodedName.replace(/\+/g, ' ').trim().toLowerCase();
11
+ }
12
+ /**
13
+ * Extracts the display name (leaf) from a full tag path.
14
+ * For "career/content/blog" returns "blog", for "career" returns "career".
15
+ */
16
+ function getTagDisplayName(fullPath) {
17
+ const parts = fullPath.split('/');
18
+ return parts[parts.length - 1];
19
+ }
20
+ /**
21
+ * Builds a hierarchical tree from a flat list of tags.
22
+ * Tags with paths like "career/content" become children of "career".
23
+ * Tags with 0 notes are excluded (matches Bear UI behavior).
24
+ */
25
+ function buildTagHierarchy(flatTags) {
26
+ // Filter out tags with no notes (hidden in Bear UI)
27
+ const activeTags = flatTags.filter((t) => t.noteCount > 0);
28
+ const tagMap = new Map();
29
+ // Two-pass approach: first create nodes, then link parent-child relationships
30
+ for (const tag of activeTags) {
31
+ tagMap.set(tag.name, {
32
+ name: tag.name,
33
+ displayName: tag.displayName,
34
+ noteCount: tag.noteCount,
35
+ children: [],
36
+ });
37
+ }
38
+ const roots = [];
39
+ // Build parent-child relationships
40
+ for (const tag of activeTags) {
41
+ const tagNode = tagMap.get(tag.name);
42
+ if (tag.isRoot) {
43
+ roots.push(tagNode);
44
+ }
45
+ else {
46
+ // Subtags use path notation (e.g., "career/content"), so extract parent path
47
+ const lastSlash = tag.name.lastIndexOf('/');
48
+ if (lastSlash > 0) {
49
+ const parentName = tag.name.substring(0, lastSlash);
50
+ const parent = tagMap.get(parentName);
51
+ if (parent) {
52
+ parent.children.push(tagNode);
53
+ }
54
+ else {
55
+ // Orphan subtag - parent has 0 notes or doesn't exist, treat as root
56
+ roots.push(tagNode);
57
+ }
58
+ }
59
+ }
60
+ }
61
+ // Sort children alphabetically at each level
62
+ const sortChildren = (tags) => {
63
+ tags.sort((a, b) => a.displayName.localeCompare(b.displayName));
64
+ for (const tag of tags) {
65
+ sortChildren(tag.children);
66
+ }
67
+ };
68
+ sortChildren(roots);
69
+ return roots;
70
+ }
71
+ /**
72
+ * Retrieves all tags from Bear database as a hierarchical tree.
73
+ * Each tag includes note count and nested children.
74
+ *
75
+ * @returns Object with tags array (tree structure) and total count
76
+ */
77
+ export function listTags() {
78
+ logger.info('listTags called');
79
+ const db = openBearDatabase();
80
+ try {
81
+ const query = `
82
+ SELECT t.ZTITLE as name,
83
+ t.ZISROOT as isRoot,
84
+ COUNT(nt.Z_5NOTES) as noteCount
85
+ FROM ZSFNOTETAG t
86
+ LEFT JOIN Z_5TAGS nt ON nt.Z_13TAGS = t.Z_PK
87
+ GROUP BY t.Z_PK
88
+ ORDER BY t.ZTITLE
89
+ `;
90
+ const stmt = db.prepare(query);
91
+ const rows = stmt.all();
92
+ if (!rows || rows.length === 0) {
93
+ logger.info('No tags found in database');
94
+ return { tags: [], totalCount: 0 };
95
+ }
96
+ // Transform rows: decode names and extract display names
97
+ const flatTags = rows.map((row) => {
98
+ const decodedName = decodeTagName(row.name);
99
+ return {
100
+ name: decodedName,
101
+ displayName: getTagDisplayName(decodedName),
102
+ noteCount: row.noteCount,
103
+ isRoot: row.isRoot === 1,
104
+ };
105
+ });
106
+ const hierarchy = buildTagHierarchy(flatTags);
107
+ logger.info(`Retrieved ${rows.length} tags, ${hierarchy.length} root tags`);
108
+ return { tags: hierarchy, totalCount: rows.length };
109
+ }
110
+ catch (error) {
111
+ logAndThrow(`Database error: Failed to retrieve tags: ${error instanceof Error ? error.message : String(error)}`);
112
+ }
113
+ finally {
114
+ try {
115
+ db.close();
116
+ logger.debug('Database connection closed');
117
+ }
118
+ catch (closeError) {
119
+ logger.error(`Failed to close database connection: ${closeError}`);
120
+ }
121
+ }
122
+ return { tags: [], totalCount: 0 };
123
+ }
124
+ /**
125
+ * Finds notes that have no tags assigned.
126
+ *
127
+ * @param limit - Maximum number of results (default: 50)
128
+ * @returns Array of untagged notes
129
+ */
130
+ export function findUntaggedNotes(limit = 50) {
131
+ logger.info(`findUntaggedNotes called with limit: ${limit}`);
132
+ const db = openBearDatabase();
133
+ try {
134
+ const query = `
135
+ SELECT ZTITLE as title,
136
+ ZUNIQUEIDENTIFIER as identifier,
137
+ ZCREATIONDATE as creationDate,
138
+ ZMODIFICATIONDATE as modificationDate
139
+ FROM ZSFNOTE
140
+ WHERE ZARCHIVED = 0 AND ZTRASHED = 0 AND ZENCRYPTED = 0
141
+ AND Z_PK NOT IN (SELECT Z_5NOTES FROM Z_5TAGS)
142
+ ORDER BY ZMODIFICATIONDATE DESC
143
+ LIMIT ?
144
+ `;
145
+ const stmt = db.prepare(query);
146
+ const rows = stmt.all(limit);
147
+ const notes = rows.map((row) => ({
148
+ title: row.title || 'Untitled',
149
+ identifier: row.identifier,
150
+ creation_date: convertCoreDataTimestamp(row.creationDate),
151
+ modification_date: convertCoreDataTimestamp(row.modificationDate),
152
+ pin: 'no',
153
+ }));
154
+ logger.info(`Found ${notes.length} untagged notes`);
155
+ return notes;
156
+ }
157
+ catch (error) {
158
+ logAndThrow(`Database error: Failed to find untagged notes: ${error instanceof Error ? error.message : String(error)}`);
159
+ }
160
+ finally {
161
+ try {
162
+ db.close();
163
+ logger.debug('Database connection closed');
164
+ }
165
+ catch (closeError) {
166
+ logger.error(`Failed to close database connection: ${closeError}`);
167
+ }
168
+ }
169
+ return [];
170
+ }
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/dist/utils.js ADDED
@@ -0,0 +1,184 @@
1
+ import createDebug from 'debug';
2
+ import { CORE_DATA_EPOCH_OFFSET, ERROR_MESSAGES } from './config.js';
3
+ import { getNoteContent } from './notes.js';
4
+ import { buildBearUrl, executeBearXCallbackApi } from './bear-urls.js';
5
+ export const logger = {
6
+ debug: createDebug('bear-notes-mcp:debug'),
7
+ info: createDebug('bear-notes-mcp:info'),
8
+ error: createDebug('bear-notes-mcp:error'),
9
+ };
10
+ // Convert UI_DEBUG_TOGGLE boolean set from UI to DEBUG string for debug package
11
+ // MCPB has no way to make this in one step with manifest.json
12
+ if (process.env.UI_DEBUG_TOGGLE === 'true') {
13
+ process.env.DEBUG = 'bear-notes-mcp:*';
14
+ logger.debug.enabled = true;
15
+ }
16
+ // Always enable error and info logs
17
+ logger.error.enabled = true;
18
+ logger.info.enabled = true;
19
+ /**
20
+ * Logs an error message and throws an Error to halt execution.
21
+ * Centralizes error handling to ensure consistent logging before failures.
22
+ *
23
+ * @param message - The error message to log and throw
24
+ * @throws Always throws Error with the provided message
25
+ */
26
+ export function logAndThrow(message) {
27
+ logger.error(message);
28
+ throw new Error(message);
29
+ }
30
+ /**
31
+ * Cleans base64 string by removing whitespace/newlines added by base64 command.
32
+ * URLSearchParams in buildBearUrl will handle URL encoding of special characters.
33
+ *
34
+ * @param base64String - Raw base64 string (may contain whitespace/newlines)
35
+ * @returns Cleaned base64 string without whitespace
36
+ */
37
+ export function cleanBase64(base64String) {
38
+ // Remove all whitespace/newlines from base64 (base64 command adds line breaks)
39
+ return base64String.trim().replace(/\s+/g, '');
40
+ }
41
+ /**
42
+ * Converts Bear's Core Data timestamp to ISO string format.
43
+ * Bear stores timestamps in seconds since Core Data epoch (2001-01-01).
44
+ *
45
+ * @param coreDataTimestamp - Timestamp in seconds since Core Data epoch
46
+ * @returns ISO string representation of the timestamp
47
+ */
48
+ export function convertCoreDataTimestamp(coreDataTimestamp) {
49
+ const unixTimestamp = coreDataTimestamp + CORE_DATA_EPOCH_OFFSET;
50
+ return new Date(unixTimestamp * 1000).toISOString();
51
+ }
52
+ /**
53
+ * Converts a JavaScript Date object to Bear's Core Data timestamp format.
54
+ * Core Data timestamps are in seconds since 2001-01-01 00:00:00 UTC.
55
+ *
56
+ * @param date - JavaScript Date object
57
+ * @returns Core Data timestamp in seconds
58
+ */
59
+ export function convertDateToCoreDataTimestamp(date) {
60
+ const unixTimestamp = Math.floor(date.getTime() / 1000);
61
+ return unixTimestamp - CORE_DATA_EPOCH_OFFSET;
62
+ }
63
+ /**
64
+ * Parses a date string and returns a JavaScript Date object.
65
+ * Supports relative dates ("today", "yesterday", "last week", "last month") and ISO date strings.
66
+ *
67
+ * @param dateString - Date string to parse (e.g., "today", "2024-01-15", "last week")
68
+ * @returns Parsed Date object
69
+ * @throws Error if the date string is invalid
70
+ */
71
+ export function parseDateString(dateString) {
72
+ const lowerDateString = dateString.trim().toLowerCase();
73
+ const now = new Date();
74
+ // Handle relative dates to provide user-friendly natural language date input
75
+ switch (lowerDateString) {
76
+ case 'today': {
77
+ const today = new Date(now);
78
+ today.setHours(0, 0, 0, 0);
79
+ return today;
80
+ }
81
+ case 'yesterday': {
82
+ const yesterday = new Date(now);
83
+ yesterday.setDate(yesterday.getDate() - 1);
84
+ yesterday.setHours(0, 0, 0, 0);
85
+ return yesterday;
86
+ }
87
+ case 'last week':
88
+ case 'week ago': {
89
+ const lastWeek = new Date(now);
90
+ lastWeek.setDate(lastWeek.getDate() - 7);
91
+ lastWeek.setHours(0, 0, 0, 0);
92
+ return lastWeek;
93
+ }
94
+ case 'last month':
95
+ case 'month ago':
96
+ case 'start of last month': {
97
+ // Calculate the first day of last month; month arithmetic handles year transitions correctly via JavaScript Date constructor
98
+ const lastMonth = new Date(now.getFullYear(), now.getMonth() - 1, 1);
99
+ lastMonth.setHours(0, 0, 0, 0);
100
+ return lastMonth;
101
+ }
102
+ case 'end of last month': {
103
+ // Calculate the last day of last month; day 0 of current month equals last day of previous month
104
+ const endOfLastMonth = new Date(now.getFullYear(), now.getMonth(), 0);
105
+ endOfLastMonth.setHours(23, 59, 59, 999);
106
+ return endOfLastMonth;
107
+ }
108
+ default: {
109
+ // Try parsing as ISO date or other standard formats as fallback for user-provided explicit dates
110
+ const parsed = new Date(dateString);
111
+ if (isNaN(parsed.getTime())) {
112
+ logAndThrow(`Invalid date format: "${dateString}". Use ISO format (YYYY-MM-DD) or relative dates (today, yesterday, last week, last month, start of last month, end of last month).`);
113
+ }
114
+ return parsed;
115
+ }
116
+ }
117
+ }
118
+ /**
119
+ * Creates a standardized MCP tool response with consistent formatting.
120
+ * Centralizes response structure to follow DRY principles.
121
+ *
122
+ * @param text - The response text content
123
+ * @returns Formatted CallToolResult for MCP tools
124
+ */
125
+ export function createToolResponse(text) {
126
+ return {
127
+ content: [
128
+ {
129
+ type: 'text',
130
+ text,
131
+ annotations: { audience: ['user', 'assistant'] },
132
+ },
133
+ ],
134
+ };
135
+ }
136
+ /**
137
+ * Shared handler for adding text to Bear notes (append or prepend).
138
+ * Consolidates common validation, execution, and response logic.
139
+ *
140
+ * @param mode - Whether to append or prepend text
141
+ * @param params - Note ID, text content, and optional header
142
+ * @returns Formatted response indicating success or failure
143
+ */
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'}`);
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
+ try {
154
+ const existingNote = getNoteContent(id.trim());
155
+ if (!existingNote) {
156
+ return createToolResponse(`Note with ID '${id}' not found. The note may have been deleted, archived, or the ID may be incorrect.
157
+
158
+ Use bear-search-notes to find the correct note identifier.`);
159
+ }
160
+ // Strip markdown header syntax from header parameter for Bear API
161
+ const cleanHeader = header?.trim().replace(/^#+\s*/, '');
162
+ const url = buildBearUrl('add-text', {
163
+ id: id.trim(),
164
+ text: text.trim(),
165
+ header: cleanHeader,
166
+ mode,
167
+ });
168
+ logger.debug(`Executing Bear URL: ${url}`);
169
+ await executeBearXCallbackApi(url);
170
+ 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()}`);
174
+ }
175
+ responseLines.push(`Note ID: ${id.trim()}`);
176
+ return createToolResponse(`${responseLines.join('\n')}
177
+
178
+ The text has been added to your Bear note.`);
179
+ }
180
+ catch (error) {
181
+ logger.error(`bear-add-text-${mode} failed: ${error}`);
182
+ throw error;
183
+ }
184
+ }
package/package.json ADDED
@@ -0,0 +1,77 @@
1
+ {
2
+ "name": "bear-notes-mcp",
3
+ "version": "2.1.0-rc.1",
4
+ "description": "Bear Notes MCP server with TypeScript and native SQLite",
5
+ "type": "module",
6
+ "main": "dist/main.js",
7
+ "scripts": {
8
+ "build": "tsc -p tsconfig.build.json",
9
+ "dev": "tsx src/main.ts --debug",
10
+ "start": "node --experimental-sqlite dist/main.js",
11
+ "lint": "eslint src --fix",
12
+ "lint:check": "eslint src",
13
+ "format": "prettier --write src",
14
+ "format:check": "prettier --check src",
15
+ "check": "npm run lint:check && npm run format:check",
16
+ "fix": "npm run lint && npm run format",
17
+ "prepublishOnly": "npm run build && mv docs/NPM.md README.md"
18
+ },
19
+ "dependencies": {
20
+ "@modelcontextprotocol/sdk": "^1.25.1",
21
+ "debug": "^4.4.3",
22
+ "zod": "^3.25.76",
23
+ "zod-to-json-schema": "^3.24.6"
24
+ },
25
+ "devDependencies": {
26
+ "@anthropic-ai/mcpb": "^2.1.2",
27
+ "@types/debug": "^4.1.12",
28
+ "@types/node": "^24.10.4",
29
+ "@typescript-eslint/eslint-plugin": "^8.46.3",
30
+ "@typescript-eslint/parser": "^8.46.3",
31
+ "eslint": "^9.39.2",
32
+ "eslint-plugin-import": "^2.32.0",
33
+ "prettier": "^3.7.4",
34
+ "tsx": "^4.21.0",
35
+ "typescript": "^5.9.3"
36
+ },
37
+ "engines": {
38
+ "node": ">=22.5.0"
39
+ },
40
+ "overrides": {
41
+ "body-parser": "2.2.1"
42
+ },
43
+ "keywords": [
44
+ "bear app",
45
+ "bear notes",
46
+ "markdown notes",
47
+ "notes management",
48
+ "productivity",
49
+ "typescript",
50
+ "mcp"
51
+ ],
52
+ "author": {
53
+ "name": "Serhii Vasylenko",
54
+ "email": "serhii@vasylenko.info",
55
+ "url": "https://devdosvid.blog"
56
+ },
57
+ "license": "MIT",
58
+ "repository": {
59
+ "type": "git",
60
+ "url": "git+https://github.com/vasylenko/claude-desktop-extension-bear-notes.git"
61
+ },
62
+ "bugs": {
63
+ "url": "https://github.com/vasylenko/claude-desktop-extension-bear-notes/issues"
64
+ },
65
+ "homepage": "https://github.com/vasylenko/claude-desktop-extension-bear-notes#readme",
66
+ "bin": {
67
+ "bear-notes-mcp": "dist/main.js"
68
+ },
69
+ "files": [
70
+ "dist",
71
+ "README.md",
72
+ "LICENSE.md"
73
+ ],
74
+ "publishConfig": {
75
+ "access": "public"
76
+ }
77
+ }