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 +21 -0
- package/README.md +109 -0
- package/dist/bear-urls.js +87 -0
- package/dist/config.js +11 -0
- package/dist/database.js +40 -0
- package/dist/main.js +422 -0
- package/dist/notes.js +224 -0
- package/dist/tags.js +170 -0
- package/dist/types.js +1 -0
- package/dist/utils.js +184 -0
- package/package.json +77 -0
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
|
+
};
|
package/dist/database.js
ADDED
|
@@ -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
|
+
}
|