Skip to content

Commit d0afbaf

Browse files
authored
Merge pull request #36 from jkawamoto/add-file
Implement `/add-file` endpoint with tests
2 parents 3e22297 + c52480b commit d0afbaf

File tree

4 files changed

+170
-2
lines changed

4 files changed

+170
-2
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ Refer to Bear's [X-callback-url Scheme documentation](https://bear.app/faq/x-cal
8282
- [x] /open-note
8383
- [x] /create
8484
- [x] /add-text (partially, via the replace_note method)
85-
- [ ] /add-file
85+
- [x] /add-file
8686
- [x] /tags
8787
- [x] /open-tag
8888
- [x] /rename-tag

src/mcp_bear/__init__.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
#
77
# http://opensource.org/licenses/mit-license.php
88
import asyncio
9+
import base64
910
import json
1011
import logging
1112
import webbrowser
@@ -19,6 +20,7 @@
1920
from typing import cast, AsyncIterator, Final, Any, Mapping
2021
from urllib.parse import urlencode, quote
2122

23+
import requests
2224
from fastapi import FastAPI, Request, HTTPException
2325
from mcp.server import FastMCP
2426
from mcp.server.fastmcp import Context
@@ -246,6 +248,55 @@ async def replace_note(
246248
finally:
247249
del ctx.request_context.lifespan_context.futures[req_id]
248250

251+
@mcp.tool()
252+
async def add_file(
253+
ctx: Context[Any, AppContext],
254+
id: str | None = Field(description="note unique identifier", default=None),
255+
title: str | None = Field(description="note title", default=None),
256+
file: str = Field(description="base64 representation of a file or a URL to a file to add to the note"),
257+
header: str | None = Field(
258+
description="if specified add the file to the corresponding header inside the note", default=None
259+
),
260+
filename: str = Field(description="file name with extension"),
261+
mode: str | None = Field(description="adding mode (prepend, append)", default=None),
262+
) -> None:
263+
"""Append or prepend a file to a note identified by its title or id."""
264+
req_id = ctx.request_id
265+
266+
if file.startswith("http://") or file.startswith("https://"):
267+
res = requests.get(file)
268+
res.raise_for_status()
269+
file = base64.b64encode(res.content).decode("ascii")
270+
271+
params = {
272+
"selected": "no",
273+
"file": file,
274+
"filename": filename,
275+
"open_note": "no",
276+
"new_window": "no",
277+
"show_window": "no",
278+
"edit": "no",
279+
"x-success": f"xfwder://{uds.stem}/{req_id}/success",
280+
"x-error": f"xfwder://{uds.stem}/{req_id}/error",
281+
}
282+
if id is not None:
283+
params["id"] = id
284+
if title is not None:
285+
params["title"] = title
286+
if header is not None:
287+
params["header"] = header
288+
if mode is not None:
289+
params["mode"] = mode
290+
291+
future = Future[QueryParams]()
292+
ctx.request_context.lifespan_context.futures[req_id] = future
293+
try:
294+
webbrowser.open(f"{BASE_URL}/add-file?{urlencode(sorted(params.items()), quote_via=quote)}")
295+
await future
296+
297+
finally:
298+
del ctx.request_context.lifespan_context.futures[req_id]
299+
249300
@mcp.tool()
250301
async def tags(
251302
ctx: Context[Any, AppContext],

tests/test_mcp.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ async def test_list_tools(mcp_client_session: ClientSession) -> None:
2929

3030
assert "open_note" in tools
3131
assert "create" in tools
32+
assert "replace_note" in tools
33+
assert "add_file" in tools
3234
assert "tags" in tools
3335
assert "open_tag" in tools
3436
assert "rename_tag" in tools
@@ -41,4 +43,3 @@ async def test_list_tools(mcp_client_session: ClientSession) -> None:
4143
assert "locked" in tools
4244
assert "search" in tools
4345
assert "grab_url" in tools
44-
assert "replace_note" in tools

tests/test_server.py

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,16 @@ def side_effect(url, _new=0, _autoraise=True) -> bool:
9797
yield mock_open
9898

9999

100+
@pytest.fixture
101+
def mock_requests_get() -> Generator[MagicMock, None, None]:
102+
with patch("requests.get") as mock_get:
103+
mock_response = MagicMock()
104+
mock_response.status_code = 200
105+
mock_response.content = b"mocked http request"
106+
mock_get.return_value = mock_response
107+
yield mock_get
108+
109+
100110
@pytest.mark.anyio
101111
@pytest.mark.parametrize(
102112
"arguments", [{"id": "1234567890"}, {"title": "test note"}, {"id": "1234567890", "title": "test note"}]
@@ -268,6 +278,112 @@ async def test_replace_note_failed(
268278
assert len(ctx.request_context.lifespan_context.futures) == 0
269279

270280

281+
@pytest.mark.anyio
282+
@pytest.mark.parametrize(
283+
"arguments,expect_req_params",
284+
[
285+
(
286+
{"id": "123456", "file": "dGVzdA==", "filename": "test.txt"},
287+
{"id": "123456", "file": "dGVzdA==", "filename": "test.txt"},
288+
),
289+
(
290+
{"title": "sample note", "file": "dGVzdA==", "filename": "test.txt"},
291+
{"title": "sample note", "file": "dGVzdA==", "filename": "test.txt"},
292+
),
293+
(
294+
{"id": "123456", "file": "dGVzdA==", "filename": "test.txt", "header": "supplement"},
295+
{"id": "123456", "file": "dGVzdA==", "filename": "test.txt", "header": "supplement"},
296+
),
297+
(
298+
{"id": "123456", "file": "dGVzdA==", "filename": "test.txt", "mode": "prepend"},
299+
{"id": "123456", "file": "dGVzdA==", "filename": "test.txt", "mode": "prepend"},
300+
),
301+
],
302+
)
303+
async def test_add_file(
304+
temp_socket: Path,
305+
mcp_server: Tuple[FastMCP, Context],
306+
mock_webbrowser: MagicMock,
307+
arguments: dict,
308+
expect_req_params: dict,
309+
) -> None:
310+
s, ctx = mcp_server
311+
mock_webbrowser.stubbed_queries = {}
312+
313+
await s._tool_manager.call_tool("add_file", arguments=arguments, context=ctx)
314+
assert len(ctx.request_context.lifespan_context.futures) == 0
315+
316+
req_params = {
317+
"selected": "no",
318+
"open_note": "no",
319+
"new_window": "no",
320+
"show_window": "no",
321+
"edit": "no",
322+
"x-success": f"xfwder://{temp_socket.stem}/{ctx.request_id}/success",
323+
"x-error": f"xfwder://{temp_socket.stem}/{ctx.request_id}/error",
324+
}
325+
req_params.update(expect_req_params)
326+
mock_webbrowser.assert_called_once_with(
327+
f"{BASE_URL}/add-file?{urlencode(sorted(req_params.items()), quote_via=quote)}"
328+
)
329+
330+
331+
@pytest.mark.anyio
332+
@pytest.mark.parametrize(
333+
"arguments,expect_req_params",
334+
[
335+
(
336+
{"id": "123456", "file": "http://example.com", "filename": "test.txt"},
337+
{"id": "123456", "file": "bW9ja2VkIGh0dHAgcmVxdWVzdA==", "filename": "test.txt"},
338+
),
339+
(
340+
{"title": "sample note", "file": "https://example.com", "filename": "test.txt"},
341+
{"title": "sample note", "file": "bW9ja2VkIGh0dHAgcmVxdWVzdA==", "filename": "test.txt"},
342+
),
343+
],
344+
)
345+
async def test_add_file_http_request(
346+
temp_socket: Path,
347+
mcp_server: Tuple[FastMCP, Context],
348+
mock_webbrowser: MagicMock,
349+
mock_requests_get: MagicMock,
350+
arguments: dict,
351+
expect_req_params: dict,
352+
) -> None:
353+
s, ctx = mcp_server
354+
mock_webbrowser.stubbed_queries = {}
355+
356+
await s._tool_manager.call_tool("add_file", arguments=arguments, context=ctx)
357+
assert len(ctx.request_context.lifespan_context.futures) == 0
358+
359+
req_params = {
360+
"selected": "no",
361+
"open_note": "no",
362+
"new_window": "no",
363+
"show_window": "no",
364+
"edit": "no",
365+
"x-success": f"xfwder://{temp_socket.stem}/{ctx.request_id}/success",
366+
"x-error": f"xfwder://{temp_socket.stem}/{ctx.request_id}/error",
367+
}
368+
req_params.update(expect_req_params)
369+
mock_webbrowser.assert_called_once_with(
370+
f"{BASE_URL}/add-file?{urlencode(sorted(req_params.items()), quote_via=quote)}"
371+
)
372+
mock_requests_get.assert_called_once_with(arguments["file"])
373+
374+
375+
@pytest.mark.anyio
376+
async def test_add_file_failed(
377+
mcp_server: Tuple[FastMCP, Context[Any, AppContext]], mock_webbrowser_error: MagicMock
378+
) -> None:
379+
s, ctx = mcp_server
380+
with pytest.raises(ToolError) as excinfo:
381+
await s._tool_manager.call_tool("add_file", arguments={"file": "dGVzdA==", "filename": "test.txt"}, context=ctx)
382+
383+
assert "test error message" in str(excinfo.value)
384+
assert len(ctx.request_context.lifespan_context.futures) == 0
385+
386+
271387
@pytest.mark.anyio
272388
async def test_tags(
273389
temp_socket: Path,

0 commit comments

Comments
 (0)