keep-mcp 2.2.1__tar.gz → 2.3.0__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.3.0}/PKG-INFO +1 -1
  2. {keep_mcp-2.2.1 → keep_mcp-2.3.0}/pyproject.toml +1 -1
  3. {keep_mcp-2.2.1 → keep_mcp-2.3.0}/scripts/smoke_test.py +24 -25
  4. {keep_mcp-2.2.1 → keep_mcp-2.3.0}/src/server/cli.py +82 -78
  5. {keep_mcp-2.2.1 → keep_mcp-2.3.0}/src/server/keep_api.py +6 -3
  6. {keep_mcp-2.2.1 → keep_mcp-2.3.0}/tests/test_checklist_validation.py +6 -6
  7. {keep_mcp-2.2.1 → keep_mcp-2.3.0}/tests/test_cli.py +60 -55
  8. {keep_mcp-2.2.1 → keep_mcp-2.3.0}/tests/test_keep_client.py +16 -7
  9. {keep_mcp-2.2.1 → keep_mcp-2.3.0}/.agents/skills/keep-mcp-evidence/SKILL.md +0 -0
  10. {keep_mcp-2.2.1 → keep_mcp-2.3.0}/.agents/skills/keep-mcp-evidence/references/cli-runs.md +0 -0
  11. {keep_mcp-2.2.1 → keep_mcp-2.3.0}/.agents/skills/keep-mcp-evidence/references/evidence-template.md +0 -0
  12. {keep_mcp-2.2.1 → keep_mcp-2.3.0}/.agents/skills/keep-mcp-evidence/references/scenarios.md +0 -0
  13. {keep_mcp-2.2.1 → keep_mcp-2.3.0}/.example.env +0 -0
  14. {keep_mcp-2.2.1 → keep_mcp-2.3.0}/.github/pull_request_template.md +0 -0
  15. {keep_mcp-2.2.1 → keep_mcp-2.3.0}/.github/workflows/ci.yml +0 -0
  16. {keep_mcp-2.2.1 → keep_mcp-2.3.0}/.github/workflows/release.yml +0 -0
  17. {keep_mcp-2.2.1 → keep_mcp-2.3.0}/.gitignore +0 -0
  18. {keep_mcp-2.2.1 → keep_mcp-2.3.0}/.python-version +0 -0
  19. {keep_mcp-2.2.1 → keep_mcp-2.3.0}/CONTRIBUTING.md +0 -0
  20. {keep_mcp-2.2.1 → keep_mcp-2.3.0}/LICENSE +0 -0
  21. {keep_mcp-2.2.1 → keep_mcp-2.3.0}/Makefile +0 -0
  22. {keep_mcp-2.2.1 → keep_mcp-2.3.0}/README.md +0 -0
  23. {keep_mcp-2.2.1 → keep_mcp-2.3.0}/src/server/__init__.py +0 -0
  24. {keep_mcp-2.2.1 → keep_mcp-2.3.0}/src/server/__main__.py +0 -0
  25. {keep_mcp-2.2.1 → keep_mcp-2.3.0}/tests/conftest.py +0 -0
  26. {keep_mcp-2.2.1 → keep_mcp-2.3.0}/tests/test_keep_api.py +0 -0
  27. {keep_mcp-2.2.1 → keep_mcp-2.3.0}/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.3.0
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.3.0"
4
4
  description = "MCP Server for Google Keep"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -17,7 +17,6 @@ This script performs a lifecycle against Google Keep:
17
17
  It is intended for manual verification, not CI.
18
18
  """
19
19
 
20
- import json
21
20
  import os
22
21
  import sys
23
22
  from pathlib import Path
@@ -33,93 +32,93 @@ def main() -> None:
33
32
 
34
33
  # --- Note lifecycle ---
35
34
  print("Creating note...")
36
- created = json.loads(cli.create_note(title="keep-mcp smoke", text="hello"))
35
+ created = cli.create_note(title="keep-mcp smoke", text="hello")
37
36
  note_id = created["id"]
38
37
  print("Created:", note_id)
39
38
 
40
39
  print("Updating note...")
41
- updated = json.loads(cli.update_note(note_id, title="keep-mcp smoke updated", text="world"))
40
+ updated = cli.update_note(note_id, title="keep-mcp smoke updated", text="world")
42
41
  assert updated["title"] == "keep-mcp smoke updated"
43
42
 
44
43
  print("Setting color...")
45
- colored = json.loads(cli.set_note_color(note_id, "RED"))
44
+ colored = cli.set_note_color(note_id, "RED")
46
45
  assert colored["color"] == "RED"
47
46
  # Reset to default
48
- json.loads(cli.set_note_color(note_id, "DEFAULT"))
47
+ cli.set_note_color(note_id, "DEFAULT")
49
48
 
50
49
  print("Pin/unpin...")
51
- assert json.loads(cli.pin_note(note_id, True))["pinned"] is True
52
- assert json.loads(cli.pin_note(note_id, False))["pinned"] is False
50
+ assert cli.pin_note(note_id, True)["pinned"] is True
51
+ assert cli.pin_note(note_id, False)["pinned"] is False
53
52
 
54
53
  print("Archive/unarchive...")
55
- assert json.loads(cli.archive_note(note_id, True))["archived"] is True
56
- assert json.loads(cli.archive_note(note_id, False))["archived"] is False
54
+ assert cli.archive_note(note_id, True)["archived"] is True
55
+ assert cli.archive_note(note_id, False)["archived"] is False
57
56
 
58
57
  print("Trash/restore...")
59
- assert json.loads(cli.trash_note(note_id))["trashed"] is True
60
- restored = json.loads(cli.restore_note(note_id))
58
+ assert cli.trash_note(note_id)["trashed"] is True
59
+ restored = cli.restore_note(note_id)
61
60
  assert restored["trashed"] is False
62
61
 
63
62
  print("Deleting note...")
64
- delete_msg = json.loads(cli.delete_note(note_id))
63
+ delete_msg = cli.delete_note(note_id)
65
64
  assert "marked for deletion" in delete_msg["message"]
66
65
 
67
66
  # --- Checklist lifecycle ---
68
67
  print("Creating list...")
69
- lst = json.loads(cli.create_list("keep-mcp smoke list", items=[{"text": "item1", "checked": False}]))
68
+ lst = cli.create_list("keep-mcp smoke list", items=[{"text": "item1", "checked": False}])
70
69
  list_id = lst["id"]
71
70
  print("List created:", list_id)
72
71
  assert lst["items"][0]["text"] == "item1"
73
72
 
74
73
  print("Adding list item...")
75
- added = json.loads(cli.add_list_item(list_id, "item2", checked=False))
74
+ added = cli.add_list_item(list_id, "item2", checked=False)
76
75
  item_id = added["item_id"]
77
76
 
78
77
  print("Updating list item...")
79
- updated_list = json.loads(cli.update_list_item(list_id, item_id, text="item2 edited", checked=True))
78
+ updated_list = cli.update_list_item(list_id, item_id, text="item2 edited", checked=True)
80
79
  edited = next(i for i in updated_list["items"] if i["id"] == item_id)
81
80
  assert edited["text"] == "item2 edited"
82
81
  assert edited["checked"] is True
83
82
 
84
83
  print("Deleting list item...")
85
- del_item = json.loads(cli.delete_list_item(list_id, item_id))
84
+ del_item = cli.delete_list_item(list_id, item_id)
86
85
  assert "marked for deletion" in del_item["message"]
87
86
 
88
87
  print("Deleting list note...")
89
- json.loads(cli.delete_note(list_id))
88
+ cli.delete_note(list_id)
90
89
 
91
90
  # --- Label lifecycle ---
92
91
  print("Creating label...")
93
- label = json.loads(cli.create_label("keep-mcp-smoke-label"))
92
+ label = cli.create_label("keep-mcp-smoke-label")
94
93
  label_id = label["id"]
95
94
  print("Label created:", label_id)
96
95
 
97
96
  print("Listing labels (should include new label)...")
98
- labels = json.loads(cli.list_labels())
97
+ labels = cli.list_labels()
99
98
  assert any(lb["id"] == label_id for lb in labels)
100
99
 
101
100
  print("Creating note for label association...")
102
- note_for_label = json.loads(cli.create_note(title="label test"))
101
+ note_for_label = cli.create_note(title="label test")
103
102
  nfl_id = note_for_label["id"]
104
103
 
105
104
  print("Adding label to note...")
106
- labeled = json.loads(cli.add_label_to_note(nfl_id, label_id))
105
+ labeled = cli.add_label_to_note(nfl_id, label_id)
107
106
  assert any(lb["id"] == label_id for lb in labeled["labels"])
108
107
 
109
108
  print("Removing label from note...")
110
- unlabeled = json.loads(cli.remove_label_from_note(nfl_id, label_id))
109
+ unlabeled = cli.remove_label_from_note(nfl_id, label_id)
111
110
  assert all(lb["id"] != label_id for lb in unlabeled["labels"])
112
111
 
113
112
  print("Deleting label...")
114
- del_label = json.loads(cli.delete_label(label_id))
113
+ del_label = cli.delete_label(label_id)
115
114
  assert "marked for deletion" in del_label["message"]
116
115
 
117
116
  print("Cleaning up label test note...")
118
- json.loads(cli.delete_note(nfl_id))
117
+ cli.delete_note(nfl_id)
119
118
 
120
119
  # --- find() ---
121
120
  print("Testing find()...")
122
- results = json.loads(cli.find(query="keep-mcp"))
121
+ results = cli.find(query="keep-mcp")
123
122
  assert isinstance(results, list)
124
123
 
125
124
  print("Smoke test finished successfully")
@@ -4,7 +4,6 @@ Provides tools for interacting with Google Keep notes through MCP.
4
4
  """
5
5
 
6
6
  import argparse
7
- import json
8
7
  import os
9
8
  import re
10
9
  from datetime import datetime, timezone
@@ -13,6 +12,8 @@ from typing import Any
13
12
 
14
13
  import gkeepapi
15
14
  from mcp.server import MCPServer
15
+ from mcp.server.mcpserver.exceptions import ToolError
16
+ from mcp.types import ToolAnnotations
16
17
 
17
18
  from .keep_api import (
18
19
  KEEP_MCP_LABEL,
@@ -29,18 +30,21 @@ from .keep_api import (
29
30
 
30
31
  mcp = MCPServer("keep")
31
32
 
33
+ READ_ONLY = ToolAnnotations(readOnlyHint=True)
34
+ ADDITIVE = ToolAnnotations(destructiveHint=False)
35
+
32
36
 
33
37
  def _get_note_or_raise(note_id: str):
34
38
  keep = get_client()
35
39
  note = keep.get(note_id)
36
40
  if not note:
37
- raise ValueError(f"Note with ID {note_id} not found")
41
+ raise ToolError(f"Note with ID {note_id} not found")
38
42
  return keep, note
39
43
 
40
44
 
41
45
  def _ensure_modifiable(note):
42
46
  if not can_modify_note(note):
43
- raise ValueError(
47
+ raise ToolError(
44
48
  f"Note with ID {note.id} cannot be modified "
45
49
  "(missing keep-mcp label and UNSAFE_MODE is not enabled)"
46
50
  )
@@ -55,7 +59,7 @@ def _normalize_colors(colors: list[str] | None):
55
59
  try:
56
60
  normalized_colors.append(gkeepapi.node.ColorValue(color))
57
61
  except ValueError as exc:
58
- raise ValueError(f"Invalid color '{color}'") from exc
62
+ raise ToolError(f"Invalid color '{color}'") from exc
59
63
 
60
64
  return normalized_colors
61
65
 
@@ -66,7 +70,7 @@ def _parse_utc(value: str | None, param: str) -> datetime | None:
66
70
  try:
67
71
  parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
68
72
  except ValueError as exc:
69
- raise ValueError(
73
+ raise ToolError(
70
74
  f"Invalid {param} '{value}': expected ISO 8601, "
71
75
  "e.g. 2026-07-29 or 2026-07-29T12:00:00Z"
72
76
  ) from exc
@@ -105,7 +109,7 @@ def _build_time_filter(
105
109
  return within
106
110
 
107
111
 
108
- @mcp.tool()
112
+ @mcp.tool(annotations=READ_ONLY)
109
113
  @keep_operation
110
114
  def find(
111
115
  query: str = "",
@@ -120,7 +124,7 @@ def find(
120
124
  updated_after: str | None = None,
121
125
  updated_before: str | None = None,
122
126
  limit: int | None = None,
123
- ) -> str:
127
+ ) -> list[dict[str, Any]]:
124
128
  """Find notes using text and optional filters.
125
129
 
126
130
  query matches title and text, case-insensitively unless case_sensitive
@@ -157,20 +161,20 @@ def find(
157
161
  notes = islice(notes, max(limit, 0))
158
162
 
159
163
  notes_data = [serialize_note(note) for note in notes]
160
- return json.dumps(notes_data)
164
+ return notes_data
161
165
 
162
166
 
163
- @mcp.tool()
167
+ @mcp.tool(annotations=READ_ONLY)
164
168
  @keep_operation
165
- def get_note(note_id: str) -> str:
169
+ def get_note(note_id: str) -> dict[str, Any]:
166
170
  """Get a note by ID."""
167
171
  _, note = _get_note_or_raise(note_id)
168
- return json.dumps(serialize_note(note))
172
+ return serialize_note(note)
169
173
 
170
174
 
171
- @mcp.tool()
175
+ @mcp.tool(annotations=ADDITIVE)
172
176
  @keep_operation
173
- def create_note(title: str | None = None, text: str | None = None) -> str:
177
+ def create_note(title: str | None = None, text: str | None = None) -> dict[str, Any]:
174
178
  """Create a new note with title and text."""
175
179
  keep = get_client()
176
180
  note = keep.createNote(title=title, text=text)
@@ -182,12 +186,12 @@ def create_note(title: str | None = None, text: str | None = None) -> str:
182
186
  note.labels.add(label)
183
187
  keep.sync()
184
188
 
185
- return json.dumps(serialize_note(note))
189
+ return serialize_note(note)
186
190
 
187
191
 
188
- @mcp.tool()
192
+ @mcp.tool(annotations=ADDITIVE)
189
193
  @keep_operation
190
- def create_list(title: str | None = None, items: list[dict[str, Any]] | None = None) -> str:
194
+ def create_list(title: str | None = None, items: list[dict[str, Any]] | None = None) -> dict[str, Any]:
191
195
  """
192
196
  Create a new checklist note.
193
197
 
@@ -199,7 +203,7 @@ def create_list(title: str | None = None, items: list[dict[str, Any]] | None = N
199
203
  if items:
200
204
  for item in items:
201
205
  if not isinstance(item.get("checked", False), bool):
202
- raise TypeError("Each item's checked field must be a boolean")
206
+ raise ToolError("Each item's checked field must be a boolean")
203
207
  formatted_items = [
204
208
  (item.get("text", ""), item.get("checked", False)) for item in items
205
209
  ]
@@ -212,37 +216,37 @@ def create_list(title: str | None = None, items: list[dict[str, Any]] | None = N
212
216
  note.labels.add(label)
213
217
 
214
218
  keep.sync()
215
- return json.dumps(serialize_note(note))
219
+ return serialize_note(note)
216
220
 
217
221
 
218
- @mcp.tool()
222
+ @mcp.tool(annotations=ADDITIVE)
219
223
  @keep_operation
220
- def add_list_item(note_id: str, text: str, checked: bool = False) -> str:
224
+ def add_list_item(note_id: str, text: str, checked: bool = False) -> dict[str, Any]:
221
225
  """Add an item to a checklist note."""
222
226
  keep, note = _get_note_or_raise(note_id)
223
227
  _ensure_modifiable(note)
224
228
 
225
229
  if not isinstance(note, gkeepapi.node.List):
226
- raise TypeError(f"Note with ID {note_id} is not a list")
230
+ raise ToolError(f"Note with ID {note_id} is not a list")
227
231
 
228
232
  item = note.add(text=text, checked=checked)
229
233
  keep.sync()
230
- return json.dumps({"note_id": note.id, "item_id": item.id})
234
+ return {"note_id": note.id, "item_id": item.id}
231
235
 
232
236
 
233
237
  @mcp.tool()
234
238
  @keep_operation
235
- def update_list_item(note_id: str, item_id: str, text: str | None = None, checked: bool | None = None) -> str:
239
+ def update_list_item(note_id: str, item_id: str, text: str | None = None, checked: bool | None = None) -> dict[str, Any]:
236
240
  """Update checklist item text and/or checked state."""
237
241
  keep, note = _get_note_or_raise(note_id)
238
242
  _ensure_modifiable(note)
239
243
 
240
244
  if not isinstance(note, gkeepapi.node.List):
241
- raise TypeError(f"Note with ID {note_id} is not a list")
245
+ raise ToolError(f"Note with ID {note_id} is not a list")
242
246
 
243
247
  item = note.get(item_id)
244
248
  if not item:
245
- raise ValueError(f"List item with ID {item_id} not found")
249
+ raise ToolError(f"List item with ID {item_id} not found")
246
250
 
247
251
  if text is not None:
248
252
  item.text = text
@@ -250,37 +254,37 @@ def update_list_item(note_id: str, item_id: str, text: str | None = None, checke
250
254
  item.checked = checked
251
255
 
252
256
  keep.sync()
253
- return json.dumps(serialize_note(note))
257
+ return serialize_note(note)
254
258
 
255
259
 
256
260
  @mcp.tool()
257
261
  @keep_operation
258
- def delete_list_item(note_id: str, item_id: str) -> str:
262
+ def delete_list_item(note_id: str, item_id: str) -> dict[str, Any]:
259
263
  """Delete a checklist item."""
260
264
  keep, note = _get_note_or_raise(note_id)
261
265
  _ensure_modifiable(note)
262
266
 
263
267
  if not isinstance(note, gkeepapi.node.List):
264
- raise TypeError(f"Note with ID {note_id} is not a list")
268
+ raise ToolError(f"Note with ID {note_id} is not a list")
265
269
 
266
270
  item = note.get(item_id)
267
271
  if not item:
268
- raise ValueError(f"List item with ID {item_id} not found")
272
+ raise ToolError(f"List item with ID {item_id} not found")
269
273
 
270
274
  item.delete()
271
275
  keep.sync()
272
- return json.dumps({"message": f"List item {item_id} marked for deletion"})
276
+ return {"message": f"List item {item_id} marked for deletion"}
273
277
 
274
278
 
275
279
  @mcp.tool()
276
280
  @keep_operation
277
- def update_note(note_id: str, title: str | None = None, text: str | None = None) -> str:
281
+ def update_note(note_id: str, title: str | None = None, text: str | None = None) -> dict[str, Any]:
278
282
  """Update a note's title and text, or a checklist's title. Use item tools for checklist text."""
279
283
  keep, note = _get_note_or_raise(note_id)
280
284
  _ensure_modifiable(note)
281
285
 
282
286
  if text is not None and isinstance(note, gkeepapi.node.List):
283
- raise ValueError("Cannot replace checklist text: use the checklist item tools")
287
+ raise ToolError("Cannot replace checklist text: use the checklist item tools")
284
288
 
285
289
  if title is not None:
286
290
  note.title = title
@@ -288,12 +292,12 @@ def update_note(note_id: str, title: str | None = None, text: str | None = None)
288
292
  note.text = text
289
293
 
290
294
  keep.sync()
291
- return json.dumps(serialize_note(note))
295
+ return serialize_note(note)
292
296
 
293
297
 
294
298
  @mcp.tool()
295
299
  @keep_operation
296
- def set_note_color(note_id: str, color: str) -> str:
300
+ def set_note_color(note_id: str, color: str) -> dict[str, Any]:
297
301
  """Set a note color. Valid values: DEFAULT (white), RED, ORANGE, YELLOW, GREEN, TEAL, BLUE, CERULEAN (dark blue), PURPLE, PINK, BROWN, GRAY."""
298
302
  keep, note = _get_note_or_raise(note_id)
299
303
  _ensure_modifiable(note)
@@ -301,51 +305,51 @@ def set_note_color(note_id: str, color: str) -> str:
301
305
  try:
302
306
  note.color = gkeepapi.node.ColorValue(color)
303
307
  except ValueError as exc:
304
- raise ValueError(f"Invalid color '{color}'") from exc
308
+ raise ToolError(f"Invalid color '{color}'") from exc
305
309
 
306
310
  keep.sync()
307
- return json.dumps(serialize_note(note))
311
+ return serialize_note(note)
308
312
 
309
313
 
310
314
  @mcp.tool()
311
315
  @keep_operation
312
- def pin_note(note_id: str, pinned: bool = True) -> str:
316
+ def pin_note(note_id: str, pinned: bool = True) -> dict[str, Any]:
313
317
  """Pin or unpin a note."""
314
318
  keep, note = _get_note_or_raise(note_id)
315
319
  _ensure_modifiable(note)
316
320
 
317
321
  note.pinned = pinned
318
322
  keep.sync()
319
- return json.dumps(serialize_note(note))
323
+ return serialize_note(note)
320
324
 
321
325
 
322
326
  @mcp.tool()
323
327
  @keep_operation
324
- def archive_note(note_id: str, archived: bool = True) -> str:
328
+ def archive_note(note_id: str, archived: bool = True) -> dict[str, Any]:
325
329
  """Archive or unarchive a note."""
326
330
  keep, note = _get_note_or_raise(note_id)
327
331
  _ensure_modifiable(note)
328
332
 
329
333
  note.archived = archived
330
334
  keep.sync()
331
- return json.dumps(serialize_note(note))
335
+ return serialize_note(note)
332
336
 
333
337
 
334
338
  @mcp.tool()
335
339
  @keep_operation
336
- def trash_note(note_id: str) -> str:
340
+ def trash_note(note_id: str) -> dict[str, Any]:
337
341
  """Move a note to trash."""
338
342
  keep, note = _get_note_or_raise(note_id)
339
343
  _ensure_modifiable(note)
340
344
 
341
345
  note.trash()
342
346
  keep.sync()
343
- return json.dumps(serialize_note(note))
347
+ return serialize_note(note)
344
348
 
345
349
 
346
350
  @mcp.tool()
347
351
  @keep_operation
348
- def restore_note(note_id: str) -> str:
352
+ def restore_note(note_id: str) -> dict[str, Any]:
349
353
  """Restore a trashed/deleted note."""
350
354
  keep, note = _get_note_or_raise(note_id)
351
355
  _ensure_modifiable(note)
@@ -353,50 +357,50 @@ def restore_note(note_id: str) -> str:
353
357
  note.untrash()
354
358
  note.undelete()
355
359
  keep.sync()
356
- return json.dumps(serialize_note(note))
360
+ return serialize_note(note)
357
361
 
358
362
 
359
363
  @mcp.tool()
360
364
  @keep_operation
361
- def delete_note(note_id: str) -> str:
365
+ def delete_note(note_id: str) -> dict[str, Any]:
362
366
  """Delete a note (mark for deletion)."""
363
367
  keep, note = _get_note_or_raise(note_id)
364
368
  _ensure_modifiable(note)
365
369
 
366
370
  note.delete()
367
371
  keep.sync()
368
- return json.dumps({"message": f"Note {note_id} marked for deletion"})
372
+ return {"message": f"Note {note_id} marked for deletion"}
369
373
 
370
374
 
371
- @mcp.tool()
375
+ @mcp.tool(annotations=READ_ONLY)
372
376
  @keep_operation
373
- def list_labels() -> str:
377
+ def list_labels() -> list[dict[str, Any]]:
374
378
  """List all labels."""
375
379
  keep = get_client()
376
- return json.dumps([serialize_label(label) for label in keep.labels()])
380
+ return [serialize_label(label) for label in keep.labels()]
377
381
 
378
382
 
379
- @mcp.tool()
383
+ @mcp.tool(annotations=ADDITIVE)
380
384
  @keep_operation
381
- def create_label(name: str) -> str:
385
+ def create_label(name: str) -> dict[str, Any]:
382
386
  """Create a label."""
383
387
  keep = get_client()
384
388
  label = keep.createLabel(name)
385
389
  keep.sync()
386
- return json.dumps(serialize_label(label))
390
+ return serialize_label(label)
387
391
 
388
392
 
389
393
  @mcp.tool()
390
394
  @keep_operation
391
- def delete_label(label_id: str) -> str:
395
+ def delete_label(label_id: str) -> dict[str, Any]:
392
396
  """Delete a label by ID."""
393
397
  keep = get_client()
394
398
  label = keep.getLabel(label_id)
395
399
  if not label:
396
- raise ValueError(f"Label with ID {label_id} not found")
400
+ raise ToolError(f"Label with ID {label_id} not found")
397
401
  if not is_unsafe_mode():
398
402
  if label.name == KEEP_MCP_LABEL:
399
- raise ValueError(
403
+ raise ToolError(
400
404
  f"Cannot delete the '{KEEP_MCP_LABEL}' label in safe mode: all notes managed "
401
405
  "by this server would become permanently unmodifiable. Set UNSAFE_MODE=true to override."
402
406
  )
@@ -405,88 +409,88 @@ def delete_label(label_id: str) -> str:
405
409
  if any(lb.id == label_id for lb in n.labels.all()) and not has_keep_mcp_label(n)
406
410
  ]
407
411
  if unmanaged:
408
- raise ValueError(
412
+ raise ToolError(
409
413
  f"Cannot delete label '{label.name}' in safe mode: it is attached to "
410
414
  f"{len(unmanaged)} unmanaged note(s). Deleting it would silently modify "
411
415
  "those notes. Set UNSAFE_MODE=true to override."
412
416
  )
413
417
  keep.deleteLabel(label_id)
414
418
  keep.sync()
415
- return json.dumps({"message": f"Label {label_id} marked for deletion"})
419
+ return {"message": f"Label {label_id} marked for deletion"}
416
420
 
417
421
 
418
- @mcp.tool()
422
+ @mcp.tool(annotations=ADDITIVE)
419
423
  @keep_operation
420
- def add_label_to_note(note_id: str, label_id: str) -> str:
424
+ def add_label_to_note(note_id: str, label_id: str) -> dict[str, Any]:
421
425
  """Add a label to a note."""
422
426
  keep, note = _get_note_or_raise(note_id)
423
427
  _ensure_modifiable(note)
424
428
 
425
429
  label = keep.getLabel(label_id)
426
430
  if not label:
427
- raise ValueError(f"Label with ID {label_id} not found")
431
+ raise ToolError(f"Label with ID {label_id} not found")
428
432
 
429
433
  note.labels.add(label)
430
434
  keep.sync()
431
- return json.dumps(serialize_note(note))
435
+ return serialize_note(note)
432
436
 
433
437
 
434
438
  @mcp.tool()
435
439
  @keep_operation
436
- def remove_label_from_note(note_id: str, label_id: str) -> str:
440
+ def remove_label_from_note(note_id: str, label_id: str) -> dict[str, Any]:
437
441
  """Remove a label from a note."""
438
442
  keep, note = _get_note_or_raise(note_id)
439
443
  _ensure_modifiable(note)
440
444
 
441
445
  label = keep.getLabel(label_id)
442
446
  if not label:
443
- raise ValueError(f"Label with ID {label_id} not found")
447
+ raise ToolError(f"Label with ID {label_id} not found")
444
448
  if label.name == KEEP_MCP_LABEL and not is_unsafe_mode():
445
- raise ValueError(
449
+ raise ToolError(
446
450
  f"Cannot remove the '{KEEP_MCP_LABEL}' label in safe mode: the note would "
447
451
  "become permanently unmodifiable. Set UNSAFE_MODE=true to override."
448
452
  )
449
453
 
450
454
  note.labels.remove(label)
451
455
  keep.sync()
452
- return json.dumps(serialize_note(note))
456
+ return serialize_note(note)
453
457
 
454
458
 
455
- @mcp.tool()
459
+ @mcp.tool(annotations=READ_ONLY)
456
460
  @keep_operation
457
- def list_note_collaborators(note_id: str) -> str:
461
+ def list_note_collaborators(note_id: str) -> list[str]:
458
462
  """List collaborator emails for a note."""
459
463
  _, note = _get_note_or_raise(note_id)
460
- return json.dumps(list(note.collaborators.all()))
464
+ return list(note.collaborators.all())
461
465
 
462
466
 
463
467
  @mcp.tool()
464
468
  @keep_operation
465
- def add_note_collaborator(note_id: str, email: str) -> str:
469
+ def add_note_collaborator(note_id: str, email: str) -> dict[str, Any]:
466
470
  """Add a collaborator email to a note."""
467
471
  keep, note = _get_note_or_raise(note_id)
468
472
  _ensure_modifiable(note)
469
473
 
470
474
  note.collaborators.add(email)
471
475
  keep.sync()
472
- return json.dumps(serialize_note(note))
476
+ return serialize_note(note)
473
477
 
474
478
 
475
479
  @mcp.tool()
476
480
  @keep_operation
477
- def remove_note_collaborator(note_id: str, email: str) -> str:
481
+ def remove_note_collaborator(note_id: str, email: str) -> dict[str, Any]:
478
482
  """Remove a collaborator email from a note."""
479
483
  keep, note = _get_note_or_raise(note_id)
480
484
  _ensure_modifiable(note)
481
485
 
482
486
  note.collaborators.remove(email)
483
487
  keep.sync()
484
- return json.dumps(serialize_note(note))
488
+ return serialize_note(note)
485
489
 
486
490
 
487
- @mcp.tool()
491
+ @mcp.tool(annotations=READ_ONLY)
488
492
  @keep_operation
489
- def list_note_media(note_id: str) -> str:
493
+ def list_note_media(note_id: str) -> list[dict[str, Any]]:
490
494
  """List note media blobs and direct media links when available."""
491
495
  keep, note = _get_note_or_raise(note_id)
492
496
 
@@ -500,12 +504,12 @@ def list_note_media(note_id: str) -> str:
500
504
  }
501
505
  )
502
506
 
503
- return json.dumps(media)
507
+ return media
504
508
 
505
509
 
506
510
  @mcp.tool()
507
511
  @keep_operation
508
- def download_media(note_id: str, dest_dir: str, blob_id: str | None = None) -> str:
512
+ def download_media(note_id: str, dest_dir: str, blob_id: str | None = None) -> list[dict[str, Any]]:
509
513
  """Download a note's media (images, drawings, audio) to a local directory.
510
514
 
511
515
  The links from list_note_media require Google authentication and answer
@@ -519,7 +523,7 @@ def download_media(note_id: str, dest_dir: str, blob_id: str | None = None) -> s
519
523
 
520
524
  blobs = [blob for blob in note.blobs if blob_id is None or blob.id == blob_id]
521
525
  if blob_id is not None and not blobs:
522
- raise ValueError(f"Blob with ID {blob_id} not found on note {note_id}")
526
+ raise ToolError(f"Blob with ID {blob_id} not found on note {note_id}")
523
527
 
524
528
  os.makedirs(dest_dir, exist_ok=True)
525
529
 
@@ -539,7 +543,7 @@ def download_media(note_id: str, dest_dir: str, blob_id: str | None = None) -> s
539
543
  }
540
544
  )
541
545
 
542
- return json.dumps(saved)
546
+ return saved
543
547
 
544
548
 
545
549
  def main():
@@ -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
@@ -1,10 +1,10 @@
1
1
  """Exercise checklist validation with real gkeepapi objects, without network calls."""
2
2
 
3
- import json
4
3
  from unittest.mock import Mock
5
4
 
6
5
  import gkeepapi
7
6
  import pytest
7
+ from mcp.server.mcpserver.exceptions import ToolError
8
8
 
9
9
  from server import cli
10
10
 
@@ -20,18 +20,18 @@ def offline_keep(monkeypatch):
20
20
 
21
21
  @pytest.mark.parametrize("checked", ["false", "true", 0, 1, None])
22
22
  def test_create_list_rejects_non_boolean_checked_without_creating_note(offline_keep, checked):
23
- with pytest.raises(TypeError, match="checked.*boolean"):
23
+ with pytest.raises(ToolError, match="checked.*boolean"):
24
24
  cli.create_list("Tasks", [{"text": "Buy milk", "checked": checked}])
25
25
  assert list(offline_keep.all()) == []
26
26
  offline_keep.sync.assert_not_called()
27
27
 
28
28
 
29
29
  def test_create_list_preserves_boolean_checked_and_default(offline_keep):
30
- result = json.loads(cli.create_list("Tasks", [
30
+ result = cli.create_list("Tasks", [
31
31
  {"text": "Unchecked", "checked": False},
32
32
  {"text": "Checked", "checked": True},
33
33
  {"text": "Default"},
34
- ]))
34
+ ])
35
35
  assert {item["text"]: item["checked"] for item in result["items"]} == {
36
36
  "Unchecked": False, "Checked": True, "Default": False,
37
37
  }
@@ -40,7 +40,7 @@ def test_create_list_preserves_boolean_checked_and_default(offline_keep):
40
40
  def test_update_list_text_does_not_partially_change_title(offline_keep):
41
41
  note = offline_keep.createList("Original", [("Buy milk", False)])
42
42
  note.labels.add(offline_keep.createLabel("keep-mcp"))
43
- with pytest.raises(ValueError, match="checklist.*item"):
43
+ with pytest.raises(ToolError, match="checklist.*item"):
44
44
  cli.update_note(note.id, title="Changed", text="Replacement")
45
45
  assert note.title == "Original"
46
46
  assert [item.text for item in note.items] == ["Buy milk"]
@@ -50,6 +50,6 @@ def test_update_list_text_does_not_partially_change_title(offline_keep):
50
50
  def test_update_list_title_remains_supported(offline_keep):
51
51
  note = offline_keep.createList("Original", [("Buy milk", False)])
52
52
  note.labels.add(offline_keep.createLabel("keep-mcp"))
53
- result = json.loads(cli.update_note(note.id, title="Changed"))
53
+ result = cli.update_note(note.id, title="Changed")
54
54
  assert result["title"] == "Changed"
55
55
  assert result["items"][0]["text"] == "Buy milk"
@@ -1,9 +1,10 @@
1
- import json
2
1
  import re
3
2
  from datetime import datetime, timezone
4
3
  from types import SimpleNamespace
5
4
 
5
+ import anyio
6
6
  import pytest
7
+ from mcp.server.mcpserver.exceptions import ToolError
7
8
 
8
9
  from server import cli
9
10
 
@@ -194,8 +195,7 @@ def keep(monkeypatch):
194
195
 
195
196
 
196
197
  def test_find_forwards_filters(keep):
197
- result = json.loads(
198
- cli.find(
198
+ result = cli.find(
199
199
  query="q",
200
200
  labels=["l1"],
201
201
  colors=["red"],
@@ -203,7 +203,6 @@ def test_find_forwards_filters(keep):
203
203
  archived=False,
204
204
  trashed=False,
205
205
  )
206
- )
207
206
  assert keep.last_find_kwargs["query"].pattern == "q"
208
207
  assert keep.last_find_kwargs["labels"] == ["l1"]
209
208
  assert [color.value for color in keep.last_find_kwargs["colors"]] == ["red"]
@@ -253,14 +252,14 @@ def test_find_date_filter_func(keep):
253
252
 
254
253
 
255
254
  def test_find_invalid_date_raises(keep):
256
- with pytest.raises(ValueError, match="created_after"):
255
+ with pytest.raises(ToolError, match="created_after"):
257
256
  cli.find(created_after="not-a-date")
258
257
 
259
258
 
260
259
  def test_find_limit_caps_results(keep):
261
- assert len(json.loads(cli.find())) == 2
262
- assert len(json.loads(cli.find(limit=1))) == 1
263
- assert json.loads(cli.find(limit=0)) == []
260
+ assert len(cli.find()) == 2
261
+ assert len(cli.find(limit=1)) == 1
262
+ assert cli.find(limit=0) == []
264
263
 
265
264
 
266
265
  def test_find_invalid_color_raises(keep, monkeypatch):
@@ -268,82 +267,88 @@ def test_find_invalid_color_raises(keep, monkeypatch):
268
267
  raise ValueError("bad")
269
268
 
270
269
  monkeypatch.setattr(cli.gkeepapi.node, "ColorValue", bad_color)
271
- with pytest.raises(ValueError, match="Invalid color 'invalid'"):
270
+ with pytest.raises(ToolError, match="Invalid color 'invalid'"):
272
271
  cli.find(colors=["invalid"])
273
272
 
274
273
 
275
274
  def test_get_note(keep):
276
- data = json.loads(cli.get_note("n1"))
275
+ data = cli.get_note("n1")
277
276
  assert data["id"] == "n1"
278
277
 
279
278
 
280
279
  def test_create_note_labels_and_sync(keep):
281
- data = json.loads(cli.create_note("t", "body"))
280
+ data = cli.create_note("t", "body")
282
281
  assert data["id"] == "created"
283
282
  assert keep.sync_calls == 1
284
283
 
285
284
 
286
285
  def test_create_note_creates_label_when_missing(keep):
287
286
  keep._labels = {}
288
- data = json.loads(cli.create_note("t", "body"))
287
+ data = cli.create_note("t", "body")
289
288
  assert data["labels"][0]["name"] == "keep-mcp"
290
289
 
291
290
 
292
291
  def test_create_list_variants(keep):
293
- data = json.loads(cli.create_list("list"))
292
+ data = cli.create_list("list")
294
293
  assert data["id"] == "created_list"
295
- data_with_items = json.loads(
296
- cli.create_list("list", items=[{"text": "a", "checked": True}])
297
- )
294
+ data_with_items = cli.create_list("list", items=[{"text": "a", "checked": True}])
298
295
  assert data_with_items["items"][0]["checked"] is True
299
296
 
300
297
 
301
298
  def test_create_list_creates_label_when_missing(keep):
302
299
  keep._labels = {}
303
- data = json.loads(cli.create_list("list"))
300
+ data = cli.create_list("list")
304
301
  assert data["labels"][0]["name"] == "keep-mcp"
305
302
 
306
303
 
307
304
  def test_update_note_updates_fields(keep):
308
- data = json.loads(cli.update_note("n1", title="new", text="changed"))
305
+ data = cli.update_note("n1", title="new", text="changed")
309
306
  assert data["title"] == "new"
310
307
  assert data["text"] == "changed"
311
308
 
312
309
 
310
+ def test_anticipated_errors_reach_the_client(keep):
311
+ with pytest.raises(ToolError, match="Note with ID missing not found"):
312
+ anyio.run(cli.mcp.call_tool, "get_note", {"note_id": "missing"})
313
+
314
+
315
+ def test_tools_return_structured_content(keep):
316
+ result = anyio.run(cli.mcp.call_tool, "get_note", {"note_id": "n1"})
317
+ assert result.structured_content["id"] == "n1"
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
 
318
325
  def test_list_item_roundtrip(keep):
319
- add = json.loads(cli.add_list_item("list1", "task", checked=True))
326
+ add = cli.add_list_item("list1", "task", checked=True)
320
327
  item_id = add["item_id"]
321
- updated = json.loads(
322
- cli.update_list_item("list1", item_id, text="task2", checked=False)
323
- )
328
+ updated = cli.update_list_item("list1", item_id, text="task2", checked=False)
324
329
  assert any(item["id"] == item_id for item in updated["items"])
325
330
 
326
331
 
327
332
  def test_update_list_item_missing_item_raises(keep):
328
- with pytest.raises(ValueError, match="not found"):
333
+ with pytest.raises(ToolError, match="not found"):
329
334
  cli.update_list_item("list1", "missing", text="x")
330
335
 
331
336
 
332
337
  def test_delete_list_item_paths(keep):
333
- with pytest.raises(ValueError, match="not found"):
338
+ with pytest.raises(ToolError, match="not found"):
334
339
  cli.delete_list_item("list1", "missing")
335
340
 
336
- new_item_id = json.loads(cli.add_list_item("list1", "task"))["item_id"]
337
- data = json.loads(cli.delete_list_item("list1", new_item_id))
341
+ new_item_id = cli.add_list_item("list1", "task")["item_id"]
342
+ data = cli.delete_list_item("list1", new_item_id)
338
343
  assert "marked for deletion" in data["message"]
339
344
 
340
345
 
341
346
  def test_list_item_requires_list_type(keep):
342
- with pytest.raises(TypeError, match="not a list"):
347
+ with pytest.raises(ToolError, match="not a list"):
343
348
  cli.add_list_item("n1", "x")
344
- with pytest.raises(TypeError, match="not a list"):
349
+ with pytest.raises(ToolError, match="not a list"):
345
350
  cli.update_list_item("n1", "i1", text="x")
346
- with pytest.raises(TypeError, match="not a list"):
351
+ with pytest.raises(ToolError, match="not a list"):
347
352
  cli.delete_list_item("n1", "i1")
348
353
 
349
354
 
@@ -352,43 +357,43 @@ def test_set_note_color_validates(keep, monkeypatch):
352
357
  raise ValueError("bad")
353
358
 
354
359
  monkeypatch.setattr(cli.gkeepapi.node, "ColorValue", bad_color)
355
- with pytest.raises(ValueError, match="Invalid color"):
360
+ with pytest.raises(ToolError, match="Invalid color"):
356
361
  cli.set_note_color("n1", "invalid")
357
362
 
358
363
 
359
364
  def test_note_state_transitions(keep):
360
- assert json.loads(cli.set_note_color("n1", "red"))["color"] == "red"
361
- assert json.loads(cli.pin_note("n1", True))["pinned"] is True
362
- assert json.loads(cli.archive_note("n1", True))["archived"] is True
363
- assert json.loads(cli.trash_note("n1"))["trashed"] is True
364
- assert json.loads(cli.restore_note("n1"))["trashed"] is False
365
+ assert cli.set_note_color("n1", "red")["color"] == "red"
366
+ assert cli.pin_note("n1", True)["pinned"] is True
367
+ assert cli.archive_note("n1", True)["archived"] is True
368
+ assert cli.trash_note("n1")["trashed"] is True
369
+ assert cli.restore_note("n1")["trashed"] is False
365
370
 
366
371
 
367
372
  def test_delete_note_marks_deleted(keep):
368
- msg = json.loads(cli.delete_note("n1"))
373
+ msg = cli.delete_note("n1")
369
374
  assert "marked for deletion" in msg["message"]
370
375
 
371
376
 
372
377
  def test_label_crud_and_missing_label_errors(keep):
373
- labels = json.loads(cli.list_labels())
378
+ labels = cli.list_labels()
374
379
  assert labels
375
380
 
376
- created = json.loads(cli.create_label("other"))
381
+ created = cli.create_label("other")
377
382
  assert created["name"] == "other"
378
- message = json.loads(cli.delete_label(created["id"]))
383
+ message = cli.delete_label(created["id"])
379
384
  assert "marked for deletion" in message["message"]
380
385
 
381
- with pytest.raises(ValueError, match="Label with ID bad not found"):
386
+ with pytest.raises(ToolError, match="Label with ID bad not found"):
382
387
  cli.delete_label("bad")
383
- with pytest.raises(ValueError, match="Label with ID bad not found"):
388
+ with pytest.raises(ToolError, match="Label with ID bad not found"):
384
389
  cli.add_label_to_note("n1", "bad")
385
- with pytest.raises(ValueError, match="Label with ID bad not found"):
390
+ with pytest.raises(ToolError, match="Label with ID bad not found"):
386
391
  cli.remove_label_from_note("n1", "bad")
387
392
 
388
393
 
389
394
  def test_delete_label_safe_mode_guards(keep, monkeypatch):
390
395
  # Deleting the keep-mcp label is blocked in safe mode.
391
- with pytest.raises(ValueError, match="keep-mcp"):
396
+ with pytest.raises(ToolError, match="keep-mcp"):
392
397
  cli.delete_label("l1")
393
398
 
394
399
  # Deleting a label that is on an unmanaged note is also blocked in safe mode.
@@ -397,41 +402,41 @@ def test_delete_label_safe_mode_guards(keep, monkeypatch):
397
402
  keep._labels["shared"] = shared
398
403
  unmanaged.labels.add(shared)
399
404
  keep.notes["unmanaged"] = unmanaged
400
- with pytest.raises(ValueError, match="unmanaged"):
405
+ with pytest.raises(ToolError, match="unmanaged"):
401
406
  cli.delete_label("shared")
402
407
 
403
408
  # UNSAFE_MODE bypasses both guards.
404
409
  monkeypatch.setenv("UNSAFE_MODE", "true")
405
- msg = json.loads(cli.delete_label("shared"))
410
+ msg = cli.delete_label("shared")
406
411
  assert "marked for deletion" in msg["message"]
407
412
 
408
413
 
409
414
  def test_label_add_remove(keep):
410
415
  keep._labels["l2"] = DummyLabel("l2", "other")
411
- add = json.loads(cli.add_label_to_note("n1", "l2"))
416
+ add = cli.add_label_to_note("n1", "l2")
412
417
  assert any(label["id"] == "l2" for label in add["labels"])
413
- remove = json.loads(cli.remove_label_from_note("n1", "l2"))
418
+ remove = cli.remove_label_from_note("n1", "l2")
414
419
  assert all(label["id"] != "l2" for label in remove["labels"])
415
420
 
416
421
 
417
422
  def test_collaborator_list_add_remove(keep):
418
- before = json.loads(cli.list_note_collaborators("n1"))
423
+ before = cli.list_note_collaborators("n1")
419
424
  assert before == []
420
425
 
421
- data = json.loads(cli.add_note_collaborator("n1", "user@example.com"))
426
+ data = cli.add_note_collaborator("n1", "user@example.com")
422
427
  assert "user@example.com" in data["collaborators"]
423
- after = json.loads(cli.remove_note_collaborator("n1", "user@example.com"))
428
+ after = cli.remove_note_collaborator("n1", "user@example.com")
424
429
  assert "user@example.com" not in after["collaborators"]
425
430
 
426
431
 
427
432
  def test_list_note_media(keep):
428
- media = json.loads(cli.list_note_media("n1"))
433
+ media = cli.list_note_media("n1")
429
434
  assert media[0]["media_link"].startswith("https://media/")
430
435
 
431
436
 
432
437
  def test_download_media_writes_files(keep, monkeypatch, tmp_path):
433
438
  monkeypatch.setattr(cli, "fetch_blob_bytes", lambda k, b: (b"png-bytes", "image/png"))
434
- saved = json.loads(cli.download_media("n1", str(tmp_path)))
439
+ saved = cli.download_media("n1", str(tmp_path))
435
440
  assert saved[0]["blob_id"] == "b1"
436
441
  assert saved[0]["type"] == "IMAGE"
437
442
  assert saved[0]["path"].endswith("b1.png")
@@ -441,14 +446,14 @@ def test_download_media_writes_files(keep, monkeypatch, tmp_path):
441
446
 
442
447
 
443
448
  def test_download_media_unknown_blob(keep, tmp_path):
444
- with pytest.raises(ValueError, match="Blob with ID nope not found"):
449
+ with pytest.raises(ToolError, match="Blob with ID nope not found"):
445
450
  cli.download_media("n1", str(tmp_path), blob_id="nope")
446
451
 
447
452
 
448
453
  def test_modification_guard_blocks_when_unlabeled(keep, monkeypatch):
449
454
  keep.notes["n1"].labels = DummyLabels()
450
455
  monkeypatch.setattr(cli, "can_modify_note", lambda _: False)
451
- with pytest.raises(ValueError, match="cannot be modified"):
456
+ with pytest.raises(ToolError, match="cannot be modified"):
452
457
  cli.update_note("n1", title="x")
453
458
 
454
459
 
@@ -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):
@@ -144,7 +154,6 @@ def test_tools_do_not_overlap_access_to_shared_state(monkeypatch):
144
154
 
145
155
  @pytest.mark.parametrize("remote_committed", [False, True])
146
156
  def test_failed_write_does_not_leave_phantom_state_for_next_read(monkeypatch, remote_committed):
147
- import json
148
157
 
149
158
  import gkeepapi
150
159
  import requests
@@ -186,5 +195,5 @@ def test_failed_write_does_not_leave_phantom_state_for_next_read(monkeypatch, re
186
195
  cli.update_note(note.id, title='Uncommitted title')
187
196
 
188
197
  assert any(node.get('title') == 'Uncommitted title' for node in requests_sent[1])
189
- result = json.loads(cli.get_note(note.id))
198
+ result = cli.get_note(note.id)
190
199
  assert result['title'] == ('Uncommitted title' if remote_committed else 'Original title')
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes