Bear MCP Server

by jkawamoto
Verified
# server.py # # Copyright (c) 2025 Junpei Kawamoto # # This software is released under the MIT License. # # http://opensource.org/licenses/mit-license.php import asyncio import json import logging import webbrowser from asyncio import Queue, Future, QueueEmpty from contextlib import asynccontextmanager from copy import deepcopy from dataclasses import dataclass from functools import partial from http import HTTPStatus from typing import cast, AsyncIterator from urllib.parse import urlencode, quote, unquote_plus from fastapi import FastAPI, Request from mcp.server import FastMCP from mcp.server.fastmcp import Context from pydantic import Field from starlette.datastructures import QueryParams from uvicorn import Config, Server from uvicorn.config import LOGGING_CONFIG BASE_URL = "bear://x-callback-url" LOGGER = logging.getLogger(__name__) @dataclass class ErrorResponse(Exception): errorCode: int errorMessage: str def __str__(self) -> str: return self.errorMessage def register_callback(api: FastAPI, path: str) -> Queue[Future[QueryParams]]: queue = Queue[Future[QueryParams]]() @api.get(f"/{path}/success", status_code=HTTPStatus.NO_CONTENT, include_in_schema=False) def success(request: Request) -> None: try: future = queue.get_nowait() future.set_result(request.query_params) except QueueEmpty: pass @api.get(f"/{path}/error", status_code=HTTPStatus.NO_CONTENT, include_in_schema=False) def error(request: Request) -> None: try: future = queue.get_nowait() q = request.query_params future.set_exception( ErrorResponse( errorCode=int(q.get("error-Code") or "0"), errorMessage=q.get("errorMessage") or "", ) ) except QueueEmpty: pass return queue @dataclass class AppContext: open_note_results: Queue[Future[QueryParams]] create_results: Queue[Future[QueryParams]] tags_results: Queue[Future[QueryParams]] open_tag_results: Queue[Future[QueryParams]] todo_results: Queue[Future[QueryParams]] today_results: Queue[Future[QueryParams]] search_results: Queue[Future[QueryParams]] grab_url_results: Queue[Future[QueryParams]] @asynccontextmanager async def app_lifespan(_server: FastMCP, callback_host: str, callback_port: int) -> AsyncIterator[AppContext]: callback = FastAPI() log_config = deepcopy(LOGGING_CONFIG) log_config["handlers"]["access"]["stream"] = "ext://sys.stderr" server = Server( Config( app=callback, host=callback_host, port=callback_port, log_level="warning", log_config=log_config, ) ) LOGGER.info(f"Starting callback server on {callback_host}:{callback_port}") server_task = asyncio.create_task(server.serve()) try: yield AppContext( open_note_results=register_callback(callback, "open-note"), create_results=register_callback(callback, "create"), tags_results=register_callback(callback, "tags"), open_tag_results=register_callback(callback, "open-tag"), todo_results=register_callback(callback, "todo"), today_results=register_callback(callback, "today"), search_results=register_callback(callback, "search"), grab_url_results=register_callback(callback, "grab-url"), ) finally: LOGGER.info("Stopping callback server") server.should_exit = True await server_task def create_server(token: str, callback_host: str, callback_port: int) -> FastMCP: mcp = FastMCP("Bear", lifespan=partial(app_lifespan, callback_host=callback_host, callback_port=callback_port)) @mcp.tool() async def open_note( ctx: Context, id: str | None = Field(description="note unique identifier", default=None), title: str | None = Field(description="note title", default=None), ) -> str: """Open a note identified by its title or id and return its content.""" app_ctx: AppContext = ctx.request_context.lifespan_context # type: ignore future = Future[QueryParams]() await app_ctx.open_note_results.put(future) params = { "new_window": "no", "float": "no", "show_window": "no", "open_note": "no", "selected": "no", "pin": "no", "edit": "no", "x-success": f"http://{callback_host}:{callback_port}/open-note/success", "x-error": f"http://{callback_host}:{callback_port}/open-note/error", } if id is not None: params["id"] = id if title is not None: params["title"] = title webbrowser.open(f"{BASE_URL}/open-note?{urlencode(params, quote_via=quote)}") res = await future return unquote_plus(res.get("note") or "") @mcp.tool() async def create( ctx: Context, title: str | None = Field(description="note title", default=None), text: str | None = Field(description="note body", default=None), tags: list[str] | None = Field(description="list of tags", default=None), timestamp: bool = Field(description="prepend the current date and time to the text", default=False), ) -> str: """Create a new note and return its unique identifier. Empty notes are not allowed.""" app_ctx: AppContext = ctx.request_context.lifespan_context # type: ignore future = Future[QueryParams]() await app_ctx.create_results.put(future) params = { "open_note": "no", "new_window": "no", "float": "no", "show_window": "no", "x-success": f"http://{callback_host}:{callback_port}/create/success", "x-error": f"http://{callback_host}:{callback_port}/create/error", } if title is not None: params["title"] = title if text is not None: params["text"] = text if tags is not None: params["tags"] = ",".join(tags) if timestamp: params["timestamp"] = "yes" webbrowser.open(f"{BASE_URL}/create?{urlencode(params, quote_via=quote)}") res = await future return res.get("identifier") or "" @mcp.tool() async def tags( ctx: Context, ) -> list[str]: """Return all the tags currently displayed in Bear’s sidebar.""" app_ctx: AppContext = ctx.request_context.lifespan_context # type: ignore future = Future[QueryParams]() await app_ctx.tags_results.put(future) params = { "token": token, "x-success": f"http://{callback_host}:{callback_port}/tags/success", "x-error": f"http://{callback_host}:{callback_port}/tags/error", } webbrowser.open(f"{BASE_URL}/tags?{urlencode(params, quote_via=quote)}") res = await future notes = cast(list[dict], json.loads(res.get("tags") or "[]")) return [note["name"] for note in notes if "name" in note] @mcp.tool() async def open_tag( ctx: Context, name: str = Field(description="tag name or a list of tags divided by comma"), ) -> list[str]: """Show all the notes which have a selected tag in bear.""" app_ctx: AppContext = ctx.request_context.lifespan_context # type: ignore future = Future[QueryParams]() await app_ctx.open_tag_results.put(future) params = { "name": name, "token": token, "x-success": f"http://{callback_host}:{callback_port}/open-tag/success", "x-error": f"http://{callback_host}:{callback_port}/open-tag/error", } webbrowser.open(f"{BASE_URL}/open-tag?{urlencode(params, quote_via=quote)}") res = await future notes = cast(list[dict], json.loads(res.get("notes") or "[]")) return [f"{note.get('title')} (ID: {note.get('identifier')})" for note in notes] @mcp.tool() async def todo( ctx: Context, search: str | None = Field(description="string to search", default=None), ) -> list[str]: """Select the Todo sidebar item.""" app_ctx: AppContext = ctx.request_context.lifespan_context # type: ignore future = Future[QueryParams]() await app_ctx.todo_results.put(future) params = { "show_window": "no", "token": token, "x-success": f"http://{callback_host}:{callback_port}/todo/success", "x-error": f"http://{callback_host}:{callback_port}/todo/error", } if search is not None: params["search"] = search webbrowser.open(f"{BASE_URL}/todo?{urlencode(params, quote_via=quote)}") res = await future notes = cast(list[dict], json.loads(res.get("notes") or "[]")) return [f"{note.get('title')} (ID: {note.get('identifier')})" for note in notes] @mcp.tool() async def today( ctx: Context, search: str | None = Field(description="string to search", default=None), ) -> list[str]: """Select the Today sidebar item.""" app_ctx: AppContext = ctx.request_context.lifespan_context # type: ignore future = Future[QueryParams]() await app_ctx.today_results.put(future) params = { "show_window": "no", "token": token, "x-success": f"http://{callback_host}:{callback_port}/today/success", "x-error": f"http://{callback_host}:{callback_port}/today/error", } if search is not None: params["search"] = search webbrowser.open(f"{BASE_URL}/today?{urlencode(params, quote_via=quote)}") res = await future notes = cast(list[dict], json.loads(res.get("notes") or "[]")) return [f"{note.get('title')} (ID: {note.get('identifier')})" for note in notes] @mcp.tool() async def search( ctx: Context, term: str | None = Field(description="string to search", default=None), tag: str | None = Field(description="tag to search into", default=None), ) -> list[str]: """Show search results in Bear for all notes or for a specific tag.""" app_ctx: AppContext = ctx.request_context.lifespan_context # type: ignore future = Future[QueryParams]() await app_ctx.search_results.put(future) params = { "show_window": "no", "token": token, "x-success": f"http://{callback_host}:{callback_port}/search/success", "x-error": f"http://{callback_host}:{callback_port}/search/error", } if term is not None: params["term"] = term if tag is not None: params["tag"] = tag webbrowser.open(f"{BASE_URL}/search?{urlencode(params, quote_via=quote)}") res = await future notes = cast(list[dict], json.loads(res.get("notes") or "[]")) return [f"{note.get('title')} (ID: {note.get('identifier')})" for note in notes] @mcp.tool() async def grab_url( ctx: Context, url: str = Field(description="url to grab"), tags: list[str] | None = Field( description="list of tags. If tags are specified in the Bear’s web content preferences, this parameter is ignored.", default=None, ), ) -> str: """Create a new note with the content of a web page and return its unique identifier.""" app_ctx: AppContext = ctx.request_context.lifespan_context # type: ignore future = Future[QueryParams]() await app_ctx.grab_url_results.put(future) params = { "url": url, "x-success": f"http://{callback_host}:{callback_port}/grab-url/success", "x-error": f"http://{callback_host}:{callback_port}/grab-url/error", } if tags is not None: params["tags"] = ",".join(tags) webbrowser.open(f"{BASE_URL}/grab-url?{urlencode(params, quote_via=quote)}") res = await future return res.get("identifier") or "" return mcp