keep-mcp 2.2.1__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.1 → keep_mcp-2.2.2}/PKG-INFO +1 -1
  2. {keep_mcp-2.2.1 → keep_mcp-2.2.2}/pyproject.toml +1 -1
  3. {keep_mcp-2.2.1 → keep_mcp-2.2.2}/src/server/cli.py +20 -19
  4. {keep_mcp-2.2.1 → keep_mcp-2.2.2}/src/server/keep_api.py +6 -3
  5. {keep_mcp-2.2.1 → keep_mcp-2.2.2}/tests/test_checklist_validation.py +3 -2
  6. {keep_mcp-2.2.1 → keep_mcp-2.2.2}/tests/test_cli.py +23 -16
  7. {keep_mcp-2.2.1 → keep_mcp-2.2.2}/tests/test_keep_client.py +15 -5
  8. {keep_mcp-2.2.1 → keep_mcp-2.2.2}/.agents/skills/keep-mcp-evidence/SKILL.md +0 -0
  9. {keep_mcp-2.2.1 → keep_mcp-2.2.2}/.agents/skills/keep-mcp-evidence/references/cli-runs.md +0 -0
  10. {keep_mcp-2.2.1 → keep_mcp-2.2.2}/.agents/skills/keep-mcp-evidence/references/evidence-template.md +0 -0
  11. {keep_mcp-2.2.1 → keep_mcp-2.2.2}/.agents/skills/keep-mcp-evidence/references/scenarios.md +0 -0
  12. {keep_mcp-2.2.1 → keep_mcp-2.2.2}/.example.env +0 -0
  13. {keep_mcp-2.2.1 → keep_mcp-2.2.2}/.github/pull_request_template.md +0 -0
  14. {keep_mcp-2.2.1 → keep_mcp-2.2.2}/.github/workflows/ci.yml +0 -0
  15. {keep_mcp-2.2.1 → keep_mcp-2.2.2}/.github/workflows/release.yml +0 -0
  16. {keep_mcp-2.2.1 → keep_mcp-2.2.2}/.gitignore +0 -0
  17. {keep_mcp-2.2.1 → keep_mcp-2.2.2}/.python-version +0 -0
  18. {keep_mcp-2.2.1 → keep_mcp-2.2.2}/CONTRIBUTING.md +0 -0
  19. {keep_mcp-2.2.1 → keep_mcp-2.2.2}/LICENSE +0 -0
  20. {keep_mcp-2.2.1 → keep_mcp-2.2.2}/Makefile +0 -0
  21. {keep_mcp-2.2.1 → keep_mcp-2.2.2}/README.md +0 -0
  22. {keep_mcp-2.2.1 → keep_mcp-2.2.2}/scripts/smoke_test.py +0 -0
  23. {keep_mcp-2.2.1 → keep_mcp-2.2.2}/src/server/__init__.py +0 -0
  24. {keep_mcp-2.2.1 → keep_mcp-2.2.2}/src/server/__main__.py +0 -0
  25. {keep_mcp-2.2.1 → keep_mcp-2.2.2}/tests/conftest.py +0 -0
  26. {keep_mcp-2.2.1 → keep_mcp-2.2.2}/tests/test_keep_api.py +0 -0
  27. {keep_mcp-2.2.1 → 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.1
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "keep-mcp"
3
- version = "2.2.1"
3
+ version = "2.2.2"
4
4
  description = "MCP Server for Google Keep"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -13,6 +13,7 @@ from typing import Any
13
13
 
14
14
  import gkeepapi
15
15
  from mcp.server import MCPServer
16
+ from mcp.server.mcpserver.exceptions import ToolError
16
17
 
17
18
  from .keep_api import (
18
19
  KEEP_MCP_LABEL,
@@ -34,13 +35,13 @@ def _get_note_or_raise(note_id: str):
34
35
  keep = get_client()
35
36
  note = keep.get(note_id)
36
37
  if not note:
37
- raise ValueError(f"Note with ID {note_id} not found")
38
+ raise ToolError(f"Note with ID {note_id} not found")
38
39
  return keep, note
39
40
 
40
41
 
41
42
  def _ensure_modifiable(note):
42
43
  if not can_modify_note(note):
43
- raise ValueError(
44
+ raise ToolError(
44
45
  f"Note with ID {note.id} cannot be modified "
45
46
  "(missing keep-mcp label and UNSAFE_MODE is not enabled)"
46
47
  )
@@ -55,7 +56,7 @@ def _normalize_colors(colors: list[str] | None):
55
56
  try:
56
57
  normalized_colors.append(gkeepapi.node.ColorValue(color))
57
58
  except ValueError as exc:
58
- raise ValueError(f"Invalid color '{color}'") from exc
59
+ raise ToolError(f"Invalid color '{color}'") from exc
59
60
 
60
61
  return normalized_colors
61
62
 
@@ -66,7 +67,7 @@ def _parse_utc(value: str | None, param: str) -> datetime | None:
66
67
  try:
67
68
  parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
68
69
  except ValueError as exc:
69
- raise ValueError(
70
+ raise ToolError(
70
71
  f"Invalid {param} '{value}': expected ISO 8601, "
71
72
  "e.g. 2026-07-29 or 2026-07-29T12:00:00Z"
72
73
  ) from exc
@@ -199,7 +200,7 @@ def create_list(title: str | None = None, items: list[dict[str, Any]] | None = N
199
200
  if items:
200
201
  for item in items:
201
202
  if not isinstance(item.get("checked", False), bool):
202
- raise TypeError("Each item's checked field must be a boolean")
203
+ raise ToolError("Each item's checked field must be a boolean")
203
204
  formatted_items = [
204
205
  (item.get("text", ""), item.get("checked", False)) for item in items
205
206
  ]
@@ -223,7 +224,7 @@ def add_list_item(note_id: str, text: str, checked: bool = False) -> str:
223
224
  _ensure_modifiable(note)
224
225
 
225
226
  if not isinstance(note, gkeepapi.node.List):
226
- 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")
227
228
 
228
229
  item = note.add(text=text, checked=checked)
229
230
  keep.sync()
@@ -238,11 +239,11 @@ def update_list_item(note_id: str, item_id: str, text: str | None = None, checke
238
239
  _ensure_modifiable(note)
239
240
 
240
241
  if not isinstance(note, gkeepapi.node.List):
241
- 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")
242
243
 
243
244
  item = note.get(item_id)
244
245
  if not item:
245
- raise ValueError(f"List item with ID {item_id} not found")
246
+ raise ToolError(f"List item with ID {item_id} not found")
246
247
 
247
248
  if text is not None:
248
249
  item.text = text
@@ -261,11 +262,11 @@ def delete_list_item(note_id: str, item_id: str) -> str:
261
262
  _ensure_modifiable(note)
262
263
 
263
264
  if not isinstance(note, gkeepapi.node.List):
264
- 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")
265
266
 
266
267
  item = note.get(item_id)
267
268
  if not item:
268
- raise ValueError(f"List item with ID {item_id} not found")
269
+ raise ToolError(f"List item with ID {item_id} not found")
269
270
 
270
271
  item.delete()
271
272
  keep.sync()
@@ -280,7 +281,7 @@ def update_note(note_id: str, title: str | None = None, text: str | None = None)
280
281
  _ensure_modifiable(note)
281
282
 
282
283
  if text is not None and isinstance(note, gkeepapi.node.List):
283
- raise ValueError("Cannot replace checklist text: use the checklist item tools")
284
+ raise ToolError("Cannot replace checklist text: use the checklist item tools")
284
285
 
285
286
  if title is not None:
286
287
  note.title = title
@@ -301,7 +302,7 @@ def set_note_color(note_id: str, color: str) -> str:
301
302
  try:
302
303
  note.color = gkeepapi.node.ColorValue(color)
303
304
  except ValueError as exc:
304
- raise ValueError(f"Invalid color '{color}'") from exc
305
+ raise ToolError(f"Invalid color '{color}'") from exc
305
306
 
306
307
  keep.sync()
307
308
  return json.dumps(serialize_note(note))
@@ -393,10 +394,10 @@ def delete_label(label_id: str) -> str:
393
394
  keep = get_client()
394
395
  label = keep.getLabel(label_id)
395
396
  if not label:
396
- raise ValueError(f"Label with ID {label_id} not found")
397
+ raise ToolError(f"Label with ID {label_id} not found")
397
398
  if not is_unsafe_mode():
398
399
  if label.name == KEEP_MCP_LABEL:
399
- raise ValueError(
400
+ raise ToolError(
400
401
  f"Cannot delete the '{KEEP_MCP_LABEL}' label in safe mode: all notes managed "
401
402
  "by this server would become permanently unmodifiable. Set UNSAFE_MODE=true to override."
402
403
  )
@@ -405,7 +406,7 @@ def delete_label(label_id: str) -> str:
405
406
  if any(lb.id == label_id for lb in n.labels.all()) and not has_keep_mcp_label(n)
406
407
  ]
407
408
  if unmanaged:
408
- raise ValueError(
409
+ raise ToolError(
409
410
  f"Cannot delete label '{label.name}' in safe mode: it is attached to "
410
411
  f"{len(unmanaged)} unmanaged note(s). Deleting it would silently modify "
411
412
  "those notes. Set UNSAFE_MODE=true to override."
@@ -424,7 +425,7 @@ def add_label_to_note(note_id: str, label_id: str) -> str:
424
425
 
425
426
  label = keep.getLabel(label_id)
426
427
  if not label:
427
- raise ValueError(f"Label with ID {label_id} not found")
428
+ raise ToolError(f"Label with ID {label_id} not found")
428
429
 
429
430
  note.labels.add(label)
430
431
  keep.sync()
@@ -440,9 +441,9 @@ def remove_label_from_note(note_id: str, label_id: str) -> str:
440
441
 
441
442
  label = keep.getLabel(label_id)
442
443
  if not label:
443
- raise ValueError(f"Label with ID {label_id} not found")
444
+ raise ToolError(f"Label with ID {label_id} not found")
444
445
  if label.name == KEEP_MCP_LABEL and not is_unsafe_mode():
445
- raise ValueError(
446
+ raise ToolError(
446
447
  f"Cannot remove the '{KEEP_MCP_LABEL}' label in safe mode: the note would "
447
448
  "become permanently unmodifiable. Set UNSAFE_MODE=true to override."
448
449
  )
@@ -519,7 +520,7 @@ def download_media(note_id: str, dest_dir: str, blob_id: str | None = None) -> s
519
520
 
520
521
  blobs = [blob for blob in note.blobs if blob_id is None or blob.id == blob_id]
521
522
  if blob_id is not None and not blobs:
522
- 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}")
523
524
 
524
525
  os.makedirs(dest_dir, exist_ok=True)
525
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