Bear MCP Server

by bart6114
Verified
/** * BearDB client for interacting with the Bear app's SQLite database */ import Database from 'better-sqlite3'; import { homedir } from 'os'; import { join } from 'path'; /** * Options for the BearDB client */ export interface BearDBOptions { databasePath?: string; } /** * Parameters for opening a note */ export interface OpenNoteParams { id?: string; title?: string; header?: string; exclude_trashed?: boolean; selected?: boolean; } /** * Parameters for searching notes */ export interface SearchParams { term?: string; tag?: string; } /** * Note structure from database */ interface NoteRecord { id: string; title: string; text: string; trashed: number; creation_date: number; modification_date: number; } /** * Tag structure from database */ interface TagRecord { name: string; } /** * Client for interacting with the Bear app's SQLite database */ export class BearDB { private db: Database.Database; private readonly defaultDbPath: string; /** * Creates a new BearDB client * @param options Options for the client */ constructor(options: BearDBOptions = {}) { this.defaultDbPath = join( homedir(), 'Library/Group Containers/9K33E3U3T4.net.shinyfrog.bear/Application Data/database.sqlite' ); const dbPath = options.databasePath || this.defaultDbPath; try { // Open the database in read-only mode this.db = new Database(dbPath, { readonly: true }); console.error(`Connected to Bear database at ${dbPath} in read-only mode`); } catch (error) { console.error(`Failed to connect to Bear database at ${dbPath}:`, error); throw new Error(`Failed to connect to Bear database: ${error instanceof Error ? error.message : String(error)}`); } } /** * Closes the database connection */ close(): void { if (this.db) { this.db.close(); } } /** * Gets a note by its ID or title * @param params Parameters for opening the note * @returns The note data */ getNoteByIdOrTitle(params: OpenNoteParams): { note: string; title: string; id: string; creation_date: number; modification_date: number } | null { try { let note: NoteRecord | undefined; if (params.id) { // Get note by ID note = this.db.prepare(` SELECT ZUNIQUEIDENTIFIER as id, ZTITLE as title, ZTEXT as text, ZTRASHED as trashed, ZCREATIONDATE as creation_date, ZMODIFICATIONDATE as modification_date FROM ZSFNOTE WHERE ZUNIQUEIDENTIFIER = ? `).get(params.id) as NoteRecord | undefined; } else if (params.title) { // Get note by title note = this.db.prepare(` SELECT ZUNIQUEIDENTIFIER as id, ZTITLE as title, ZTEXT as text, ZTRASHED as trashed, ZCREATIONDATE as creation_date, ZMODIFICATIONDATE as modification_date FROM ZSFNOTE WHERE ZTITLE = ? `).get(params.title) as NoteRecord | undefined; } else { throw new Error('Either id or title must be provided'); } if (!note) { return null; } // Check if note is trashed and should be excluded if (params.exclude_trashed && note.trashed) { return null; } // If header is specified, try to extract that section let noteText = note.text; if (params.header && noteText) { const headerPattern = new RegExp(`## ${params.header}\\s*\\n([\\s\\S]*?)(?=\\n## |$)`, 'i'); const match = headerPattern.exec(noteText); if (match && match[1]) { noteText = match[1].trim(); } else { // Header not found return null; } } return { note: noteText, title: note.title, id: note.id, creation_date: note.creation_date, modification_date: note.modification_date }; } catch (error) { console.error('Error getting note:', error); throw new Error(`Failed to get note: ${error instanceof Error ? error.message : String(error)}`); } } /** * Searches for notes * @param params Parameters for searching * @returns Array of matching notes */ searchNotes(params: SearchParams): Array<{ identifier: string; title: string; tags: string[]; creation_date: number; modification_date: number }> { try { let query = ` SELECT n.ZUNIQUEIDENTIFIER as identifier, n.ZTITLE as title, n.ZCREATIONDATE as creation_date, n.ZMODIFICATIONDATE as modification_date FROM ZSFNOTE n WHERE n.ZTRASHED = 0 `; const queryParams: any[] = []; // Add search term condition if provided if (params.term) { query += ` AND (n.ZTITLE LIKE ? OR n.ZTEXT LIKE ?)`; const searchTerm = `%${params.term}%`; queryParams.push(searchTerm, searchTerm); } // Add tag condition if provided if (params.tag) { query += ` AND n.Z_PK IN ( SELECT nt.Z_5NOTES FROM Z_5TAGS nt JOIN ZSFNOTETAG t ON t.Z_PK = nt.Z_13TAGS WHERE t.ZTITLE = ? ) `; queryParams.push(params.tag); } // Execute the query const notes = this.db.prepare(query).all(...queryParams) as Array<{ identifier: string; title: string; creation_date: number; modification_date: number }>; // Get tags for each note return notes.map((note) => { const tags = this.getTagsForNote(note.identifier); return { identifier: note.identifier, title: note.title, tags, creation_date: note.creation_date, modification_date: note.modification_date }; }); } catch (error) { console.error('Error searching notes:', error); throw new Error(`Failed to search notes: ${error instanceof Error ? error.message : String(error)}`); } } /** * Gets all tags * @returns Array of tags */ getTags(): Array<{ name: string }> { try { const tags = this.db.prepare(` SELECT ZTITLE as name FROM ZSFNOTETAG ORDER BY name `).all() as Array<TagRecord>; return tags; } catch (error) { console.error('Error getting tags:', error); throw new Error(`Failed to get tags: ${error instanceof Error ? error.message : String(error)}`); } } /** * Gets notes with a specific tag * @param tagName The tag name * @returns Array of notes with the tag */ getNotesByTag(tagName: string): Array<{ identifier: string; title: string; tags: string[]; creation_date: number; modification_date: number }> { try { const notes = this.db.prepare(` SELECT n.ZUNIQUEIDENTIFIER as identifier, n.ZTITLE as title, n.ZCREATIONDATE as creation_date, n.ZMODIFICATIONDATE as modification_date FROM ZSFNOTE n JOIN Z_5TAGS nt ON n.Z_PK = nt.Z_5NOTES JOIN ZSFNOTETAG t ON t.Z_PK = nt.Z_13TAGS WHERE t.ZTITLE = ? AND n.ZTRASHED = 0 ORDER BY n.ZCREATIONDATE DESC `).all(tagName) as Array<{ identifier: string; title: string; creation_date: number; modification_date: number }>; // Get tags for each note return notes.map((note) => { const tags = this.getTagsForNote(note.identifier); return { identifier: note.identifier, title: note.title, tags, creation_date: note.creation_date, modification_date: note.modification_date }; }); } catch (error) { console.error('Error getting notes by tag:', error); throw new Error(`Failed to get notes by tag: ${error instanceof Error ? error.message : String(error)}`); } } /** * Gets tags for a note * @param noteId The note ID * @returns Array of tag names */ private getTagsForNote(noteId: string): string[] { try { const tags = this.db.prepare(` SELECT t.ZTITLE as name FROM ZSFNOTETAG t JOIN Z_5TAGS nt ON t.Z_PK = nt.Z_13TAGS JOIN ZSFNOTE n ON n.Z_PK = nt.Z_5NOTES WHERE n.ZUNIQUEIDENTIFIER = ? ORDER BY name `).all(noteId) as Array<TagRecord>; return tags.map((tag) => tag.name); } catch (error) { console.error(`Error getting tags for note ${noteId}:`, error); return []; } } }