keep-mcp 2.2.0__tar.gz → 2.2.2__tar.gz

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.
Files changed (27) hide show
  1. {keep_mcp-2.2.0 → keep_mcp-2.2.2}/PKG-INFO +2 -2
  2. {keep_mcp-2.2.0 → keep_mcp-2.2.2}/pyproject.toml +2 -2
  3. {keep_mcp-2.2.0 → keep_mcp-2.2.2}/src/server/cli.py +21 -24
  4. {keep_mcp-2.2.0 → keep_mcp-2.2.2}/src/server/keep_api.py +6 -3
  5. {keep_mcp-2.2.0 → keep_mcp-2.2.2}/tests/test_checklist_validation.py +3 -2
  6. {keep_mcp-2.2.0 → keep_mcp-2.2.2}/tests/test_cli.py +23 -16
  7. {keep_mcp-2.2.0 → keep_mcp-2.2.2}/tests/test_keep_client.py +15 -5
  8. {keep_mcp-2.2.0 → keep_mcp-2.2.2}/.agents/skills/keep-mcp-evidence/SKILL.md +0 -0
  9. {keep_mcp-2.2.0 → keep_mcp-2.2.2}/.agents/skills/keep-mcp-evidence/references/cli-runs.md +0 -0
  10. {keep_mcp-2.2.0 → keep_mcp-2.2.2}/.agents/skills/keep-mcp-evidence/references/evidence-template.md +0 -0
  11. {keep_mcp-2.2.0 → keep_mcp-2.2.2}/.agents/skills/keep-mcp-evidence/references/scenarios.md +0 -0
  12. {keep_mcp-2.2.0 → keep_mcp-2.2.2}/.example.env +0 -0
  13. {keep_mcp-2.2.0 → keep_mcp-2.2.2}/.github/pull_request_template.md +0 -0
  14. {keep_mcp-2.2.0 → keep_mcp-2.2.2}/.github/workflows/ci.yml +0 -0
  15. {keep_mcp-2.2.0 → keep_mcp-2.2.2}/.github/workflows/release.yml +0 -0
  16. {keep_mcp-2.2.0 → keep_mcp-2.2.2}/.gitignore +0 -0
  17. {keep_mcp-2.2.0 → keep_mcp-2.2.2}/.python-version +0 -0
  18. {keep_mcp-2.2.0 → keep_mcp-2.2.2}/CONTRIBUTING.md +0 -0
  19. {keep_mcp-2.2.0 → keep_mcp-2.2.2}/LICENSE +0 -0
  20. {keep_mcp-2.2.0 → keep_mcp-2.2.2}/Makefile +0 -0
  21. {keep_mcp-2.2.0 → keep_mcp-2.2.2}/README.md +0 -0
  22. {keep_mcp-2.2.0 → keep_mcp-2.2.2}/scripts/smoke_test.py +0 -0
  23. {keep_mcp-2.2.0 → keep_mcp-2.2.2}/src/server/__init__.py +0 -0
  24. {keep_mcp-2.2.0 → keep_mcp-2.2.2}/src/server/__main__.py +0 -0
  25. {keep_mcp-2.2.0 → keep_mcp-2.2.2}/tests/conftest.py +0 -0
  26. {keep_mcp-2.2.0 → keep_mcp-2.2.2}/tests/test_keep_api.py +0 -0
  27. {keep_mcp-2.2.0 → keep_mcp-2.2.2}/tests/test_main_module.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: keep-mcp
3
- Version: 2.2.0
3
+ Version: 2.2.2
4
4
  Summary: MCP Server for Google Keep
5
5
  Project-URL: Homepage, https://github.com/feuerdev/keep-mcp
6
6
  Project-URL: Repository, https://github.com/feuerdev/keep-mcp
@@ -36,7 +36,7 @@ Classifier: Programming Language :: Python :: 3.10
36
36
  Classifier: Topic :: Utilities
37
37
  Requires-Python: >=3.10
38
38
  Requires-Dist: gkeepapi>=0.16.0
39
- Requires-Dist: mcp[cli]<3,>=1.2
39
+ Requires-Dist: mcp[cli]<3,>=2.0
40
40
  Requires-Dist: python-dotenv>=1.0
41
41
  Requires-Dist: requests>=2.28
42
42
  Description-Content-Type: text/markdown
@@ -1,12 +1,12 @@
1
1
  [project]
2
2
  name = "keep-mcp"
3
- version = "2.2.0"
3
+ version = "2.2.2"
4
4
  description = "MCP Server for Google Keep"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
7
7
  dependencies = [
8
8
  "gkeepapi>=0.16.0",
9
- "mcp[cli]>=1.2,<3",
9
+ "mcp[cli]>=2.0,<3",
10
10
  "python-dotenv>=1.0",
11
11
  "requests>=2.28",
12
12
  ]
@@ -12,11 +12,8 @@ from itertools import islice
12
12
  from typing import Any
13
13
 
14
14
  import gkeepapi
15
-
16
- try:
17
- from mcp.server import MCPServer
18
- except ImportError: # MCP SDK 1.x: FastMCP became MCPServer in 2.0
19
- from mcp.server.fastmcp import FastMCP as MCPServer
15
+ from mcp.server import MCPServer
16
+ from mcp.server.mcpserver.exceptions import ToolError
20
17
 
21
18
  from .keep_api import (
22
19
  KEEP_MCP_LABEL,
@@ -38,13 +35,13 @@ def _get_note_or_raise(note_id: str):
38
35
  keep = get_client()
39
36
  note = keep.get(note_id)
40
37
  if not note:
41
- raise ValueError(f"Note with ID {note_id} not found")
38
+ raise ToolError(f"Note with ID {note_id} not found")
42
39
  return keep, note
43
40
 
44
41
 
45
42
  def _ensure_modifiable(note):
46
43
  if not can_modify_note(note):
47
- raise ValueError(
44
+ raise ToolError(
48
45
  f"Note with ID {note.id} cannot be modified "
49
46
  "(missing keep-mcp label and UNSAFE_MODE is not enabled)"
50
47
  )
@@ -59,7 +56,7 @@ def _normalize_colors(colors: list[str] | None):
59
56
  try:
60
57
  normalized_colors.append(gkeepapi.node.ColorValue(color))
61
58
  except ValueError as exc:
62
- raise ValueError(f"Invalid color '{color}'") from exc
59
+ raise ToolError(f"Invalid color '{color}'") from exc
63
60
 
64
61
  return normalized_colors
65
62
 
@@ -70,7 +67,7 @@ def _parse_utc(value: str | None, param: str) -> datetime | None:
70
67
  try:
71
68
  parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
72
69
  except ValueError as exc:
73
- raise ValueError(
70
+ raise ToolError(
74
71
  f"Invalid {param} '{value}': expected ISO 8601, "
75
72
  "e.g. 2026-07-29 or 2026-07-29T12:00:00Z"
76
73
  ) from exc
@@ -203,7 +200,7 @@ def create_list(title: str | None = None, items: list[dict[str, Any]] | None = N
203
200
  if items:
204
201
  for item in items:
205
202
  if not isinstance(item.get("checked", False), bool):
206
- raise TypeError("Each item's checked field must be a boolean")
203
+ raise ToolError("Each item's checked field must be a boolean")
207
204
  formatted_items = [
208
205
  (item.get("text", ""), item.get("checked", False)) for item in items
209
206
  ]
@@ -227,7 +224,7 @@ def add_list_item(note_id: str, text: str, checked: bool = False) -> str:
227
224
  _ensure_modifiable(note)
228
225
 
229
226
  if not isinstance(note, gkeepapi.node.List):
230
- raise TypeError(f"Note with ID {note_id} is not a list")
227
+ raise ToolError(f"Note with ID {note_id} is not a list")
231
228
 
232
229
  item = note.add(text=text, checked=checked)
233
230
  keep.sync()
@@ -242,11 +239,11 @@ def update_list_item(note_id: str, item_id: str, text: str | None = None, checke
242
239
  _ensure_modifiable(note)
243
240
 
244
241
  if not isinstance(note, gkeepapi.node.List):
245
- raise TypeError(f"Note with ID {note_id} is not a list")
242
+ raise ToolError(f"Note with ID {note_id} is not a list")
246
243
 
247
244
  item = note.get(item_id)
248
245
  if not item:
249
- raise ValueError(f"List item with ID {item_id} not found")
246
+ raise ToolError(f"List item with ID {item_id} not found")
250
247
 
251
248
  if text is not None:
252
249
  item.text = text
@@ -265,11 +262,11 @@ def delete_list_item(note_id: str, item_id: str) -> str:
265
262
  _ensure_modifiable(note)
266
263
 
267
264
  if not isinstance(note, gkeepapi.node.List):
268
- raise TypeError(f"Note with ID {note_id} is not a list")
265
+ raise ToolError(f"Note with ID {note_id} is not a list")
269
266
 
270
267
  item = note.get(item_id)
271
268
  if not item:
272
- raise ValueError(f"List item with ID {item_id} not found")
269
+ raise ToolError(f"List item with ID {item_id} not found")
273
270
 
274
271
  item.delete()
275
272
  keep.sync()
@@ -284,7 +281,7 @@ def update_note(note_id: str, title: str | None = None, text: str | None = None)
284
281
  _ensure_modifiable(note)
285
282
 
286
283
  if text is not None and isinstance(note, gkeepapi.node.List):
287
- raise ValueError("Cannot replace checklist text: use the checklist item tools")
284
+ raise ToolError("Cannot replace checklist text: use the checklist item tools")
288
285
 
289
286
  if title is not None:
290
287
  note.title = title
@@ -305,7 +302,7 @@ def set_note_color(note_id: str, color: str) -> str:
305
302
  try:
306
303
  note.color = gkeepapi.node.ColorValue(color)
307
304
  except ValueError as exc:
308
- raise ValueError(f"Invalid color '{color}'") from exc
305
+ raise ToolError(f"Invalid color '{color}'") from exc
309
306
 
310
307
  keep.sync()
311
308
  return json.dumps(serialize_note(note))
@@ -397,10 +394,10 @@ def delete_label(label_id: str) -> str:
397
394
  keep = get_client()
398
395
  label = keep.getLabel(label_id)
399
396
  if not label:
400
- raise ValueError(f"Label with ID {label_id} not found")
397
+ raise ToolError(f"Label with ID {label_id} not found")
401
398
  if not is_unsafe_mode():
402
399
  if label.name == KEEP_MCP_LABEL:
403
- raise ValueError(
400
+ raise ToolError(
404
401
  f"Cannot delete the '{KEEP_MCP_LABEL}' label in safe mode: all notes managed "
405
402
  "by this server would become permanently unmodifiable. Set UNSAFE_MODE=true to override."
406
403
  )
@@ -409,7 +406,7 @@ def delete_label(label_id: str) -> str:
409
406
  if any(lb.id == label_id for lb in n.labels.all()) and not has_keep_mcp_label(n)
410
407
  ]
411
408
  if unmanaged:
412
- raise ValueError(
409
+ raise ToolError(
413
410
  f"Cannot delete label '{label.name}' in safe mode: it is attached to "
414
411
  f"{len(unmanaged)} unmanaged note(s). Deleting it would silently modify "
415
412
  "those notes. Set UNSAFE_MODE=true to override."
@@ -428,7 +425,7 @@ def add_label_to_note(note_id: str, label_id: str) -> str:
428
425
 
429
426
  label = keep.getLabel(label_id)
430
427
  if not label:
431
- raise ValueError(f"Label with ID {label_id} not found")
428
+ raise ToolError(f"Label with ID {label_id} not found")
432
429
 
433
430
  note.labels.add(label)
434
431
  keep.sync()
@@ -444,9 +441,9 @@ def remove_label_from_note(note_id: str, label_id: str) -> str:
444
441
 
445
442
  label = keep.getLabel(label_id)
446
443
  if not label:
447
- raise ValueError(f"Label with ID {label_id} not found")
444
+ raise ToolError(f"Label with ID {label_id} not found")
448
445
  if label.name == KEEP_MCP_LABEL and not is_unsafe_mode():
449
- raise ValueError(
446
+ raise ToolError(
450
447
  f"Cannot remove the '{KEEP_MCP_LABEL}' label in safe mode: the note would "
451
448
  "become permanently unmodifiable. Set UNSAFE_MODE=true to override."
452
449
  )
@@ -523,7 +520,7 @@ def download_media(note_id: str, dest_dir: str, blob_id: str | None = None) -> s
523
520
 
524
521
  blobs = [blob for blob in note.blobs if blob_id is None or blob.id == blob_id]
525
522
  if blob_id is not None and not blobs:
526
- raise ValueError(f"Blob with ID {blob_id} not found on note {note_id}")
523
+ raise ToolError(f"Blob with ID {blob_id} not found on note {note_id}")
527
524
 
528
525
  os.makedirs(dest_dir, exist_ok=True)
529
526
 
@@ -5,6 +5,7 @@ from threading import RLock
5
5
  import gkeepapi
6
6
  import requests
7
7
  from dotenv import load_dotenv
8
+ from mcp.server.mcpserver.exceptions import ToolError
8
9
 
9
10
  KEEP_MCP_LABEL = "keep-mcp"
10
11
 
@@ -20,6 +21,8 @@ def keep_operation(func):
20
21
  with _client_lock:
21
22
  try:
22
23
  return func(*args, **kwargs)
24
+ except ToolError:
25
+ raise
23
26
  except Exception:
24
27
  # A failed operation can leave unsaved or prematurely cleaned
25
28
  # mutations in memory. Reload remote state on the next call.
@@ -51,7 +54,7 @@ def get_client():
51
54
  master_token = os.getenv('GOOGLE_MASTER_TOKEN')
52
55
 
53
56
  if not email or not master_token:
54
- raise ValueError("Missing Google Keep credentials. Please set GOOGLE_EMAIL and GOOGLE_MASTER_TOKEN environment variables.")
57
+ raise ToolError("Missing Google Keep credentials. Please set GOOGLE_EMAIL and GOOGLE_MASTER_TOKEN environment variables.")
55
58
 
56
59
  # Initialize the Keep API
57
60
  keep = gkeepapi.Keep()
@@ -60,7 +63,7 @@ def get_client():
60
63
  try:
61
64
  keep.authenticate(email, master_token)
62
65
  except requests.exceptions.JSONDecodeError as exc:
63
- raise RuntimeError(
66
+ raise ToolError(
64
67
  "Google Keep API returned a non-JSON response during authentication. "
65
68
  "This usually means the unofficial Keep API (notes/v1) is inaccessible "
66
69
  "from this environment (HTTP 403/4xx). "
@@ -68,7 +71,7 @@ def get_client():
68
71
  "is reachable from this network."
69
72
  ) from exc
70
73
  except gkeepapi.exception.LoginException as exc:
71
- raise RuntimeError(
74
+ raise ToolError(
72
75
  f"Google Keep login failed: {exc}. "
73
76
  "Verify that GOOGLE_EMAIL and GOOGLE_MASTER_TOKEN are correct."
74
77
  ) from exc
@@ -5,6 +5,7 @@ from unittest.mock import Mock
5
5
 
6
6
  import gkeepapi
7
7
  import pytest
8
+ from mcp.server.mcpserver.exceptions import ToolError
8
9
 
9
10
  from server import cli
10
11
 
@@ -20,7 +21,7 @@ def offline_keep(monkeypatch):
20
21
 
21
22
  @pytest.mark.parametrize("checked", ["false", "true", 0, 1, None])
22
23
  def test_create_list_rejects_non_boolean_checked_without_creating_note(offline_keep, checked):
23
- with pytest.raises(TypeError, match="checked.*boolean"):
24
+ with pytest.raises(ToolError, match="checked.*boolean"):
24
25
  cli.create_list("Tasks", [{"text": "Buy milk", "checked": checked}])
25
26
  assert list(offline_keep.all()) == []
26
27
  offline_keep.sync.assert_not_called()
@@ -40,7 +41,7 @@ def test_create_list_preserves_boolean_checked_and_default(offline_keep):
40
41
  def test_update_list_text_does_not_partially_change_title(offline_keep):
41
42
  note = offline_keep.createList("Original", [("Buy milk", False)])
42
43
  note.labels.add(offline_keep.createLabel("keep-mcp"))
43
- with pytest.raises(ValueError, match="checklist.*item"):
44
+ with pytest.raises(ToolError, match="checklist.*item"):
44
45
  cli.update_note(note.id, title="Changed", text="Replacement")
45
46
  assert note.title == "Original"
46
47
  assert [item.text for item in note.items] == ["Buy milk"]
@@ -3,7 +3,9 @@ import re
3
3
  from datetime import datetime, timezone
4
4
  from types import SimpleNamespace
5
5
 
6
+ import anyio
6
7
  import pytest
8
+ from mcp.server.mcpserver.exceptions import ToolError
7
9
 
8
10
  from server import cli
9
11
 
@@ -253,7 +255,7 @@ def test_find_date_filter_func(keep):
253
255
 
254
256
 
255
257
  def test_find_invalid_date_raises(keep):
256
- with pytest.raises(ValueError, match="created_after"):
258
+ with pytest.raises(ToolError, match="created_after"):
257
259
  cli.find(created_after="not-a-date")
258
260
 
259
261
 
@@ -268,7 +270,7 @@ def test_find_invalid_color_raises(keep, monkeypatch):
268
270
  raise ValueError("bad")
269
271
 
270
272
  monkeypatch.setattr(cli.gkeepapi.node, "ColorValue", bad_color)
271
- with pytest.raises(ValueError, match="Invalid color 'invalid'"):
273
+ with pytest.raises(ToolError, match="Invalid color 'invalid'"):
272
274
  cli.find(colors=["invalid"])
273
275
 
274
276
 
@@ -310,8 +312,13 @@ def test_update_note_updates_fields(keep):
310
312
  assert data["text"] == "changed"
311
313
 
312
314
 
315
+ def test_anticipated_errors_reach_the_client(keep):
316
+ with pytest.raises(ToolError, match="Note with ID missing not found"):
317
+ anyio.run(cli.mcp.call_tool, "get_note", {"note_id": "missing"})
318
+
319
+
313
320
  def test_update_note_not_found_raises(keep):
314
- with pytest.raises(ValueError, match="not found"):
321
+ with pytest.raises(ToolError, match="not found"):
315
322
  cli.update_note("missing", title="x")
316
323
 
317
324
 
@@ -325,12 +332,12 @@ def test_list_item_roundtrip(keep):
325
332
 
326
333
 
327
334
  def test_update_list_item_missing_item_raises(keep):
328
- with pytest.raises(ValueError, match="not found"):
335
+ with pytest.raises(ToolError, match="not found"):
329
336
  cli.update_list_item("list1", "missing", text="x")
330
337
 
331
338
 
332
339
  def test_delete_list_item_paths(keep):
333
- with pytest.raises(ValueError, match="not found"):
340
+ with pytest.raises(ToolError, match="not found"):
334
341
  cli.delete_list_item("list1", "missing")
335
342
 
336
343
  new_item_id = json.loads(cli.add_list_item("list1", "task"))["item_id"]
@@ -339,11 +346,11 @@ def test_delete_list_item_paths(keep):
339
346
 
340
347
 
341
348
  def test_list_item_requires_list_type(keep):
342
- with pytest.raises(TypeError, match="not a list"):
349
+ with pytest.raises(ToolError, match="not a list"):
343
350
  cli.add_list_item("n1", "x")
344
- with pytest.raises(TypeError, match="not a list"):
351
+ with pytest.raises(ToolError, match="not a list"):
345
352
  cli.update_list_item("n1", "i1", text="x")
346
- with pytest.raises(TypeError, match="not a list"):
353
+ with pytest.raises(ToolError, match="not a list"):
347
354
  cli.delete_list_item("n1", "i1")
348
355
 
349
356
 
@@ -352,7 +359,7 @@ def test_set_note_color_validates(keep, monkeypatch):
352
359
  raise ValueError("bad")
353
360
 
354
361
  monkeypatch.setattr(cli.gkeepapi.node, "ColorValue", bad_color)
355
- with pytest.raises(ValueError, match="Invalid color"):
362
+ with pytest.raises(ToolError, match="Invalid color"):
356
363
  cli.set_note_color("n1", "invalid")
357
364
 
358
365
 
@@ -378,17 +385,17 @@ def test_label_crud_and_missing_label_errors(keep):
378
385
  message = json.loads(cli.delete_label(created["id"]))
379
386
  assert "marked for deletion" in message["message"]
380
387
 
381
- with pytest.raises(ValueError, match="Label with ID bad not found"):
388
+ with pytest.raises(ToolError, match="Label with ID bad not found"):
382
389
  cli.delete_label("bad")
383
- with pytest.raises(ValueError, match="Label with ID bad not found"):
390
+ with pytest.raises(ToolError, match="Label with ID bad not found"):
384
391
  cli.add_label_to_note("n1", "bad")
385
- with pytest.raises(ValueError, match="Label with ID bad not found"):
392
+ with pytest.raises(ToolError, match="Label with ID bad not found"):
386
393
  cli.remove_label_from_note("n1", "bad")
387
394
 
388
395
 
389
396
  def test_delete_label_safe_mode_guards(keep, monkeypatch):
390
397
  # Deleting the keep-mcp label is blocked in safe mode.
391
- with pytest.raises(ValueError, match="keep-mcp"):
398
+ with pytest.raises(ToolError, match="keep-mcp"):
392
399
  cli.delete_label("l1")
393
400
 
394
401
  # Deleting a label that is on an unmanaged note is also blocked in safe mode.
@@ -397,7 +404,7 @@ def test_delete_label_safe_mode_guards(keep, monkeypatch):
397
404
  keep._labels["shared"] = shared
398
405
  unmanaged.labels.add(shared)
399
406
  keep.notes["unmanaged"] = unmanaged
400
- with pytest.raises(ValueError, match="unmanaged"):
407
+ with pytest.raises(ToolError, match="unmanaged"):
401
408
  cli.delete_label("shared")
402
409
 
403
410
  # UNSAFE_MODE bypasses both guards.
@@ -441,14 +448,14 @@ def test_download_media_writes_files(keep, monkeypatch, tmp_path):
441
448
 
442
449
 
443
450
  def test_download_media_unknown_blob(keep, tmp_path):
444
- with pytest.raises(ValueError, match="Blob with ID nope not found"):
451
+ with pytest.raises(ToolError, match="Blob with ID nope not found"):
445
452
  cli.download_media("n1", str(tmp_path), blob_id="nope")
446
453
 
447
454
 
448
455
  def test_modification_guard_blocks_when_unlabeled(keep, monkeypatch):
449
456
  keep.notes["n1"].labels = DummyLabels()
450
457
  monkeypatch.setattr(cli, "can_modify_note", lambda _: False)
451
- with pytest.raises(ValueError, match="cannot be modified"):
458
+ with pytest.raises(ToolError, match="cannot be modified"):
452
459
  cli.update_note("n1", title="x")
453
460
 
454
461
 
@@ -1,4 +1,5 @@
1
1
  import pytest
2
+ from mcp.server.mcpserver.exceptions import ToolError
2
3
 
3
4
  from server import keep_api
4
5
 
@@ -38,12 +39,21 @@ def test_get_client_raises_when_missing_credentials(monkeypatch):
38
39
  monkeypatch.setattr(keep_api, "load_dotenv", lambda: None)
39
40
  monkeypatch.setattr(keep_api.os, "getenv", lambda _key: None)
40
41
 
41
- try:
42
+ with pytest.raises(ToolError, match="Missing Google Keep credentials"):
42
43
  keep_api.get_client()
43
- except ValueError as exc:
44
- assert "Missing Google Keep credentials" in str(exc)
45
- else:
46
- raise AssertionError("Expected ValueError for missing credentials")
44
+
45
+
46
+ def test_tool_error_keeps_the_client(monkeypatch):
47
+ client = object()
48
+ monkeypatch.setattr(keep_api, "_keep_client", client)
49
+
50
+ @keep_api.keep_operation
51
+ def reject():
52
+ raise ToolError("rejected")
53
+
54
+ with pytest.raises(ToolError):
55
+ reject()
56
+ assert keep_api._keep_client is client
47
57
 
48
58
 
49
59
  def test_cached_client_refreshes_external_changes(monkeypatch):
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes