keep-mcp 2.2.2__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.2 → keep_mcp-2.3.0}/PKG-INFO +1 -1
  2. {keep_mcp-2.2.2 → keep_mcp-2.3.0}/pyproject.toml +1 -1
  3. {keep_mcp-2.2.2 → keep_mcp-2.3.0}/scripts/smoke_test.py +24 -25
  4. {keep_mcp-2.2.2 → keep_mcp-2.3.0}/src/server/cli.py +62 -59
  5. {keep_mcp-2.2.2 → keep_mcp-2.3.0}/tests/test_checklist_validation.py +3 -4
  6. {keep_mcp-2.2.2 → keep_mcp-2.3.0}/tests/test_cli.py +37 -39
  7. {keep_mcp-2.2.2 → keep_mcp-2.3.0}/tests/test_keep_client.py +1 -2
  8. {keep_mcp-2.2.2 → keep_mcp-2.3.0}/.agents/skills/keep-mcp-evidence/SKILL.md +0 -0
  9. {keep_mcp-2.2.2 → keep_mcp-2.3.0}/.agents/skills/keep-mcp-evidence/references/cli-runs.md +0 -0
  10. {keep_mcp-2.2.2 → keep_mcp-2.3.0}/.agents/skills/keep-mcp-evidence/references/evidence-template.md +0 -0
  11. {keep_mcp-2.2.2 → keep_mcp-2.3.0}/.agents/skills/keep-mcp-evidence/references/scenarios.md +0 -0
  12. {keep_mcp-2.2.2 → keep_mcp-2.3.0}/.example.env +0 -0
  13. {keep_mcp-2.2.2 → keep_mcp-2.3.0}/.github/pull_request_template.md +0 -0
  14. {keep_mcp-2.2.2 → keep_mcp-2.3.0}/.github/workflows/ci.yml +0 -0
  15. {keep_mcp-2.2.2 → keep_mcp-2.3.0}/.github/workflows/release.yml +0 -0
  16. {keep_mcp-2.2.2 → keep_mcp-2.3.0}/.gitignore +0 -0
  17. {keep_mcp-2.2.2 → keep_mcp-2.3.0}/.python-version +0 -0
  18. {keep_mcp-2.2.2 → keep_mcp-2.3.0}/CONTRIBUTING.md +0 -0
  19. {keep_mcp-2.2.2 → keep_mcp-2.3.0}/LICENSE +0 -0
  20. {keep_mcp-2.2.2 → keep_mcp-2.3.0}/Makefile +0 -0
  21. {keep_mcp-2.2.2 → keep_mcp-2.3.0}/README.md +0 -0
  22. {keep_mcp-2.2.2 → keep_mcp-2.3.0}/src/server/__init__.py +0 -0
  23. {keep_mcp-2.2.2 → keep_mcp-2.3.0}/src/server/__main__.py +0 -0
  24. {keep_mcp-2.2.2 → keep_mcp-2.3.0}/src/server/keep_api.py +0 -0
  25. {keep_mcp-2.2.2 → keep_mcp-2.3.0}/tests/conftest.py +0 -0
  26. {keep_mcp-2.2.2 → keep_mcp-2.3.0}/tests/test_keep_api.py +0 -0
  27. {keep_mcp-2.2.2 → 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.2
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.2"
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
@@ -14,6 +13,7 @@ from typing import Any
14
13
  import gkeepapi
15
14
  from mcp.server import MCPServer
16
15
  from mcp.server.mcpserver.exceptions import ToolError
16
+ from mcp.types import ToolAnnotations
17
17
 
18
18
  from .keep_api import (
19
19
  KEEP_MCP_LABEL,
@@ -30,6 +30,9 @@ from .keep_api import (
30
30
 
31
31
  mcp = MCPServer("keep")
32
32
 
33
+ READ_ONLY = ToolAnnotations(readOnlyHint=True)
34
+ ADDITIVE = ToolAnnotations(destructiveHint=False)
35
+
33
36
 
34
37
  def _get_note_or_raise(note_id: str):
35
38
  keep = get_client()
@@ -106,7 +109,7 @@ def _build_time_filter(
106
109
  return within
107
110
 
108
111
 
109
- @mcp.tool()
112
+ @mcp.tool(annotations=READ_ONLY)
110
113
  @keep_operation
111
114
  def find(
112
115
  query: str = "",
@@ -121,7 +124,7 @@ def find(
121
124
  updated_after: str | None = None,
122
125
  updated_before: str | None = None,
123
126
  limit: int | None = None,
124
- ) -> str:
127
+ ) -> list[dict[str, Any]]:
125
128
  """Find notes using text and optional filters.
126
129
 
127
130
  query matches title and text, case-insensitively unless case_sensitive
@@ -158,20 +161,20 @@ def find(
158
161
  notes = islice(notes, max(limit, 0))
159
162
 
160
163
  notes_data = [serialize_note(note) for note in notes]
161
- return json.dumps(notes_data)
164
+ return notes_data
162
165
 
163
166
 
164
- @mcp.tool()
167
+ @mcp.tool(annotations=READ_ONLY)
165
168
  @keep_operation
166
- def get_note(note_id: str) -> str:
169
+ def get_note(note_id: str) -> dict[str, Any]:
167
170
  """Get a note by ID."""
168
171
  _, note = _get_note_or_raise(note_id)
169
- return json.dumps(serialize_note(note))
172
+ return serialize_note(note)
170
173
 
171
174
 
172
- @mcp.tool()
175
+ @mcp.tool(annotations=ADDITIVE)
173
176
  @keep_operation
174
- 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]:
175
178
  """Create a new note with title and text."""
176
179
  keep = get_client()
177
180
  note = keep.createNote(title=title, text=text)
@@ -183,12 +186,12 @@ def create_note(title: str | None = None, text: str | None = None) -> str:
183
186
  note.labels.add(label)
184
187
  keep.sync()
185
188
 
186
- return json.dumps(serialize_note(note))
189
+ return serialize_note(note)
187
190
 
188
191
 
189
- @mcp.tool()
192
+ @mcp.tool(annotations=ADDITIVE)
190
193
  @keep_operation
191
- 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]:
192
195
  """
193
196
  Create a new checklist note.
194
197
 
@@ -213,12 +216,12 @@ def create_list(title: str | None = None, items: list[dict[str, Any]] | None = N
213
216
  note.labels.add(label)
214
217
 
215
218
  keep.sync()
216
- return json.dumps(serialize_note(note))
219
+ return serialize_note(note)
217
220
 
218
221
 
219
- @mcp.tool()
222
+ @mcp.tool(annotations=ADDITIVE)
220
223
  @keep_operation
221
- 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]:
222
225
  """Add an item to a checklist note."""
223
226
  keep, note = _get_note_or_raise(note_id)
224
227
  _ensure_modifiable(note)
@@ -228,12 +231,12 @@ def add_list_item(note_id: str, text: str, checked: bool = False) -> str:
228
231
 
229
232
  item = note.add(text=text, checked=checked)
230
233
  keep.sync()
231
- return json.dumps({"note_id": note.id, "item_id": item.id})
234
+ return {"note_id": note.id, "item_id": item.id}
232
235
 
233
236
 
234
237
  @mcp.tool()
235
238
  @keep_operation
236
- 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]:
237
240
  """Update checklist item text and/or checked state."""
238
241
  keep, note = _get_note_or_raise(note_id)
239
242
  _ensure_modifiable(note)
@@ -251,12 +254,12 @@ def update_list_item(note_id: str, item_id: str, text: str | None = None, checke
251
254
  item.checked = checked
252
255
 
253
256
  keep.sync()
254
- return json.dumps(serialize_note(note))
257
+ return serialize_note(note)
255
258
 
256
259
 
257
260
  @mcp.tool()
258
261
  @keep_operation
259
- 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]:
260
263
  """Delete a checklist item."""
261
264
  keep, note = _get_note_or_raise(note_id)
262
265
  _ensure_modifiable(note)
@@ -270,12 +273,12 @@ def delete_list_item(note_id: str, item_id: str) -> str:
270
273
 
271
274
  item.delete()
272
275
  keep.sync()
273
- return json.dumps({"message": f"List item {item_id} marked for deletion"})
276
+ return {"message": f"List item {item_id} marked for deletion"}
274
277
 
275
278
 
276
279
  @mcp.tool()
277
280
  @keep_operation
278
- 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]:
279
282
  """Update a note's title and text, or a checklist's title. Use item tools for checklist text."""
280
283
  keep, note = _get_note_or_raise(note_id)
281
284
  _ensure_modifiable(note)
@@ -289,12 +292,12 @@ def update_note(note_id: str, title: str | None = None, text: str | None = None)
289
292
  note.text = text
290
293
 
291
294
  keep.sync()
292
- return json.dumps(serialize_note(note))
295
+ return serialize_note(note)
293
296
 
294
297
 
295
298
  @mcp.tool()
296
299
  @keep_operation
297
- def set_note_color(note_id: str, color: str) -> str:
300
+ def set_note_color(note_id: str, color: str) -> dict[str, Any]:
298
301
  """Set a note color. Valid values: DEFAULT (white), RED, ORANGE, YELLOW, GREEN, TEAL, BLUE, CERULEAN (dark blue), PURPLE, PINK, BROWN, GRAY."""
299
302
  keep, note = _get_note_or_raise(note_id)
300
303
  _ensure_modifiable(note)
@@ -305,48 +308,48 @@ def set_note_color(note_id: str, color: str) -> str:
305
308
  raise ToolError(f"Invalid color '{color}'") from exc
306
309
 
307
310
  keep.sync()
308
- return json.dumps(serialize_note(note))
311
+ return serialize_note(note)
309
312
 
310
313
 
311
314
  @mcp.tool()
312
315
  @keep_operation
313
- def pin_note(note_id: str, pinned: bool = True) -> str:
316
+ def pin_note(note_id: str, pinned: bool = True) -> dict[str, Any]:
314
317
  """Pin or unpin a note."""
315
318
  keep, note = _get_note_or_raise(note_id)
316
319
  _ensure_modifiable(note)
317
320
 
318
321
  note.pinned = pinned
319
322
  keep.sync()
320
- return json.dumps(serialize_note(note))
323
+ return serialize_note(note)
321
324
 
322
325
 
323
326
  @mcp.tool()
324
327
  @keep_operation
325
- def archive_note(note_id: str, archived: bool = True) -> str:
328
+ def archive_note(note_id: str, archived: bool = True) -> dict[str, Any]:
326
329
  """Archive or unarchive a note."""
327
330
  keep, note = _get_note_or_raise(note_id)
328
331
  _ensure_modifiable(note)
329
332
 
330
333
  note.archived = archived
331
334
  keep.sync()
332
- return json.dumps(serialize_note(note))
335
+ return serialize_note(note)
333
336
 
334
337
 
335
338
  @mcp.tool()
336
339
  @keep_operation
337
- def trash_note(note_id: str) -> str:
340
+ def trash_note(note_id: str) -> dict[str, Any]:
338
341
  """Move a note to trash."""
339
342
  keep, note = _get_note_or_raise(note_id)
340
343
  _ensure_modifiable(note)
341
344
 
342
345
  note.trash()
343
346
  keep.sync()
344
- return json.dumps(serialize_note(note))
347
+ return serialize_note(note)
345
348
 
346
349
 
347
350
  @mcp.tool()
348
351
  @keep_operation
349
- def restore_note(note_id: str) -> str:
352
+ def restore_note(note_id: str) -> dict[str, Any]:
350
353
  """Restore a trashed/deleted note."""
351
354
  keep, note = _get_note_or_raise(note_id)
352
355
  _ensure_modifiable(note)
@@ -354,42 +357,42 @@ def restore_note(note_id: str) -> str:
354
357
  note.untrash()
355
358
  note.undelete()
356
359
  keep.sync()
357
- return json.dumps(serialize_note(note))
360
+ return serialize_note(note)
358
361
 
359
362
 
360
363
  @mcp.tool()
361
364
  @keep_operation
362
- def delete_note(note_id: str) -> str:
365
+ def delete_note(note_id: str) -> dict[str, Any]:
363
366
  """Delete a note (mark for deletion)."""
364
367
  keep, note = _get_note_or_raise(note_id)
365
368
  _ensure_modifiable(note)
366
369
 
367
370
  note.delete()
368
371
  keep.sync()
369
- return json.dumps({"message": f"Note {note_id} marked for deletion"})
372
+ return {"message": f"Note {note_id} marked for deletion"}
370
373
 
371
374
 
372
- @mcp.tool()
375
+ @mcp.tool(annotations=READ_ONLY)
373
376
  @keep_operation
374
- def list_labels() -> str:
377
+ def list_labels() -> list[dict[str, Any]]:
375
378
  """List all labels."""
376
379
  keep = get_client()
377
- return json.dumps([serialize_label(label) for label in keep.labels()])
380
+ return [serialize_label(label) for label in keep.labels()]
378
381
 
379
382
 
380
- @mcp.tool()
383
+ @mcp.tool(annotations=ADDITIVE)
381
384
  @keep_operation
382
- def create_label(name: str) -> str:
385
+ def create_label(name: str) -> dict[str, Any]:
383
386
  """Create a label."""
384
387
  keep = get_client()
385
388
  label = keep.createLabel(name)
386
389
  keep.sync()
387
- return json.dumps(serialize_label(label))
390
+ return serialize_label(label)
388
391
 
389
392
 
390
393
  @mcp.tool()
391
394
  @keep_operation
392
- def delete_label(label_id: str) -> str:
395
+ def delete_label(label_id: str) -> dict[str, Any]:
393
396
  """Delete a label by ID."""
394
397
  keep = get_client()
395
398
  label = keep.getLabel(label_id)
@@ -413,12 +416,12 @@ def delete_label(label_id: str) -> str:
413
416
  )
414
417
  keep.deleteLabel(label_id)
415
418
  keep.sync()
416
- return json.dumps({"message": f"Label {label_id} marked for deletion"})
419
+ return {"message": f"Label {label_id} marked for deletion"}
417
420
 
418
421
 
419
- @mcp.tool()
422
+ @mcp.tool(annotations=ADDITIVE)
420
423
  @keep_operation
421
- 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]:
422
425
  """Add a label to a note."""
423
426
  keep, note = _get_note_or_raise(note_id)
424
427
  _ensure_modifiable(note)
@@ -429,12 +432,12 @@ def add_label_to_note(note_id: str, label_id: str) -> str:
429
432
 
430
433
  note.labels.add(label)
431
434
  keep.sync()
432
- return json.dumps(serialize_note(note))
435
+ return serialize_note(note)
433
436
 
434
437
 
435
438
  @mcp.tool()
436
439
  @keep_operation
437
- 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]:
438
441
  """Remove a label from a note."""
439
442
  keep, note = _get_note_or_raise(note_id)
440
443
  _ensure_modifiable(note)
@@ -450,44 +453,44 @@ def remove_label_from_note(note_id: str, label_id: str) -> str:
450
453
 
451
454
  note.labels.remove(label)
452
455
  keep.sync()
453
- return json.dumps(serialize_note(note))
456
+ return serialize_note(note)
454
457
 
455
458
 
456
- @mcp.tool()
459
+ @mcp.tool(annotations=READ_ONLY)
457
460
  @keep_operation
458
- def list_note_collaborators(note_id: str) -> str:
461
+ def list_note_collaborators(note_id: str) -> list[str]:
459
462
  """List collaborator emails for a note."""
460
463
  _, note = _get_note_or_raise(note_id)
461
- return json.dumps(list(note.collaborators.all()))
464
+ return list(note.collaborators.all())
462
465
 
463
466
 
464
467
  @mcp.tool()
465
468
  @keep_operation
466
- def add_note_collaborator(note_id: str, email: str) -> str:
469
+ def add_note_collaborator(note_id: str, email: str) -> dict[str, Any]:
467
470
  """Add a collaborator email to a note."""
468
471
  keep, note = _get_note_or_raise(note_id)
469
472
  _ensure_modifiable(note)
470
473
 
471
474
  note.collaborators.add(email)
472
475
  keep.sync()
473
- return json.dumps(serialize_note(note))
476
+ return serialize_note(note)
474
477
 
475
478
 
476
479
  @mcp.tool()
477
480
  @keep_operation
478
- def remove_note_collaborator(note_id: str, email: str) -> str:
481
+ def remove_note_collaborator(note_id: str, email: str) -> dict[str, Any]:
479
482
  """Remove a collaborator email from a note."""
480
483
  keep, note = _get_note_or_raise(note_id)
481
484
  _ensure_modifiable(note)
482
485
 
483
486
  note.collaborators.remove(email)
484
487
  keep.sync()
485
- return json.dumps(serialize_note(note))
488
+ return serialize_note(note)
486
489
 
487
490
 
488
- @mcp.tool()
491
+ @mcp.tool(annotations=READ_ONLY)
489
492
  @keep_operation
490
- def list_note_media(note_id: str) -> str:
493
+ def list_note_media(note_id: str) -> list[dict[str, Any]]:
491
494
  """List note media blobs and direct media links when available."""
492
495
  keep, note = _get_note_or_raise(note_id)
493
496
 
@@ -501,12 +504,12 @@ def list_note_media(note_id: str) -> str:
501
504
  }
502
505
  )
503
506
 
504
- return json.dumps(media)
507
+ return media
505
508
 
506
509
 
507
510
  @mcp.tool()
508
511
  @keep_operation
509
- 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]]:
510
513
  """Download a note's media (images, drawings, audio) to a local directory.
511
514
 
512
515
  The links from list_note_media require Google authentication and answer
@@ -540,7 +543,7 @@ def download_media(note_id: str, dest_dir: str, blob_id: str | None = None) -> s
540
543
  }
541
544
  )
542
545
 
543
- return json.dumps(saved)
546
+ return saved
544
547
 
545
548
 
546
549
  def main():
@@ -1,6 +1,5 @@
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
@@ -28,11 +27,11 @@ def test_create_list_rejects_non_boolean_checked_without_creating_note(offline_k
28
27
 
29
28
 
30
29
  def test_create_list_preserves_boolean_checked_and_default(offline_keep):
31
- result = json.loads(cli.create_list("Tasks", [
30
+ result = cli.create_list("Tasks", [
32
31
  {"text": "Unchecked", "checked": False},
33
32
  {"text": "Checked", "checked": True},
34
33
  {"text": "Default"},
35
- ]))
34
+ ])
36
35
  assert {item["text"]: item["checked"] for item in result["items"]} == {
37
36
  "Unchecked": False, "Checked": True, "Default": False,
38
37
  }
@@ -51,6 +50,6 @@ def test_update_list_text_does_not_partially_change_title(offline_keep):
51
50
  def test_update_list_title_remains_supported(offline_keep):
52
51
  note = offline_keep.createList("Original", [("Buy milk", False)])
53
52
  note.labels.add(offline_keep.createLabel("keep-mcp"))
54
- result = json.loads(cli.update_note(note.id, title="Changed"))
53
+ result = cli.update_note(note.id, title="Changed")
55
54
  assert result["title"] == "Changed"
56
55
  assert result["items"][0]["text"] == "Buy milk"
@@ -1,4 +1,3 @@
1
- import json
2
1
  import re
3
2
  from datetime import datetime, timezone
4
3
  from types import SimpleNamespace
@@ -196,8 +195,7 @@ def keep(monkeypatch):
196
195
 
197
196
 
198
197
  def test_find_forwards_filters(keep):
199
- result = json.loads(
200
- cli.find(
198
+ result = cli.find(
201
199
  query="q",
202
200
  labels=["l1"],
203
201
  colors=["red"],
@@ -205,7 +203,6 @@ def test_find_forwards_filters(keep):
205
203
  archived=False,
206
204
  trashed=False,
207
205
  )
208
- )
209
206
  assert keep.last_find_kwargs["query"].pattern == "q"
210
207
  assert keep.last_find_kwargs["labels"] == ["l1"]
211
208
  assert [color.value for color in keep.last_find_kwargs["colors"]] == ["red"]
@@ -260,9 +257,9 @@ def test_find_invalid_date_raises(keep):
260
257
 
261
258
 
262
259
  def test_find_limit_caps_results(keep):
263
- assert len(json.loads(cli.find())) == 2
264
- assert len(json.loads(cli.find(limit=1))) == 1
265
- 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) == []
266
263
 
267
264
 
268
265
  def test_find_invalid_color_raises(keep, monkeypatch):
@@ -275,39 +272,37 @@ def test_find_invalid_color_raises(keep, monkeypatch):
275
272
 
276
273
 
277
274
  def test_get_note(keep):
278
- data = json.loads(cli.get_note("n1"))
275
+ data = cli.get_note("n1")
279
276
  assert data["id"] == "n1"
280
277
 
281
278
 
282
279
  def test_create_note_labels_and_sync(keep):
283
- data = json.loads(cli.create_note("t", "body"))
280
+ data = cli.create_note("t", "body")
284
281
  assert data["id"] == "created"
285
282
  assert keep.sync_calls == 1
286
283
 
287
284
 
288
285
  def test_create_note_creates_label_when_missing(keep):
289
286
  keep._labels = {}
290
- data = json.loads(cli.create_note("t", "body"))
287
+ data = cli.create_note("t", "body")
291
288
  assert data["labels"][0]["name"] == "keep-mcp"
292
289
 
293
290
 
294
291
  def test_create_list_variants(keep):
295
- data = json.loads(cli.create_list("list"))
292
+ data = cli.create_list("list")
296
293
  assert data["id"] == "created_list"
297
- data_with_items = json.loads(
298
- cli.create_list("list", items=[{"text": "a", "checked": True}])
299
- )
294
+ data_with_items = cli.create_list("list", items=[{"text": "a", "checked": True}])
300
295
  assert data_with_items["items"][0]["checked"] is True
301
296
 
302
297
 
303
298
  def test_create_list_creates_label_when_missing(keep):
304
299
  keep._labels = {}
305
- data = json.loads(cli.create_list("list"))
300
+ data = cli.create_list("list")
306
301
  assert data["labels"][0]["name"] == "keep-mcp"
307
302
 
308
303
 
309
304
  def test_update_note_updates_fields(keep):
310
- data = json.loads(cli.update_note("n1", title="new", text="changed"))
305
+ data = cli.update_note("n1", title="new", text="changed")
311
306
  assert data["title"] == "new"
312
307
  assert data["text"] == "changed"
313
308
 
@@ -317,17 +312,20 @@ def test_anticipated_errors_reach_the_client(keep):
317
312
  anyio.run(cli.mcp.call_tool, "get_note", {"note_id": "missing"})
318
313
 
319
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
+
320
320
  def test_update_note_not_found_raises(keep):
321
321
  with pytest.raises(ToolError, match="not found"):
322
322
  cli.update_note("missing", title="x")
323
323
 
324
324
 
325
325
  def test_list_item_roundtrip(keep):
326
- add = json.loads(cli.add_list_item("list1", "task", checked=True))
326
+ add = cli.add_list_item("list1", "task", checked=True)
327
327
  item_id = add["item_id"]
328
- updated = json.loads(
329
- cli.update_list_item("list1", item_id, text="task2", checked=False)
330
- )
328
+ updated = cli.update_list_item("list1", item_id, text="task2", checked=False)
331
329
  assert any(item["id"] == item_id for item in updated["items"])
332
330
 
333
331
 
@@ -340,8 +338,8 @@ def test_delete_list_item_paths(keep):
340
338
  with pytest.raises(ToolError, match="not found"):
341
339
  cli.delete_list_item("list1", "missing")
342
340
 
343
- new_item_id = json.loads(cli.add_list_item("list1", "task"))["item_id"]
344
- 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)
345
343
  assert "marked for deletion" in data["message"]
346
344
 
347
345
 
@@ -364,25 +362,25 @@ def test_set_note_color_validates(keep, monkeypatch):
364
362
 
365
363
 
366
364
  def test_note_state_transitions(keep):
367
- assert json.loads(cli.set_note_color("n1", "red"))["color"] == "red"
368
- assert json.loads(cli.pin_note("n1", True))["pinned"] is True
369
- assert json.loads(cli.archive_note("n1", True))["archived"] is True
370
- assert json.loads(cli.trash_note("n1"))["trashed"] is True
371
- 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
372
370
 
373
371
 
374
372
  def test_delete_note_marks_deleted(keep):
375
- msg = json.loads(cli.delete_note("n1"))
373
+ msg = cli.delete_note("n1")
376
374
  assert "marked for deletion" in msg["message"]
377
375
 
378
376
 
379
377
  def test_label_crud_and_missing_label_errors(keep):
380
- labels = json.loads(cli.list_labels())
378
+ labels = cli.list_labels()
381
379
  assert labels
382
380
 
383
- created = json.loads(cli.create_label("other"))
381
+ created = cli.create_label("other")
384
382
  assert created["name"] == "other"
385
- message = json.loads(cli.delete_label(created["id"]))
383
+ message = cli.delete_label(created["id"])
386
384
  assert "marked for deletion" in message["message"]
387
385
 
388
386
  with pytest.raises(ToolError, match="Label with ID bad not found"):
@@ -409,36 +407,36 @@ def test_delete_label_safe_mode_guards(keep, monkeypatch):
409
407
 
410
408
  # UNSAFE_MODE bypasses both guards.
411
409
  monkeypatch.setenv("UNSAFE_MODE", "true")
412
- msg = json.loads(cli.delete_label("shared"))
410
+ msg = cli.delete_label("shared")
413
411
  assert "marked for deletion" in msg["message"]
414
412
 
415
413
 
416
414
  def test_label_add_remove(keep):
417
415
  keep._labels["l2"] = DummyLabel("l2", "other")
418
- add = json.loads(cli.add_label_to_note("n1", "l2"))
416
+ add = cli.add_label_to_note("n1", "l2")
419
417
  assert any(label["id"] == "l2" for label in add["labels"])
420
- remove = json.loads(cli.remove_label_from_note("n1", "l2"))
418
+ remove = cli.remove_label_from_note("n1", "l2")
421
419
  assert all(label["id"] != "l2" for label in remove["labels"])
422
420
 
423
421
 
424
422
  def test_collaborator_list_add_remove(keep):
425
- before = json.loads(cli.list_note_collaborators("n1"))
423
+ before = cli.list_note_collaborators("n1")
426
424
  assert before == []
427
425
 
428
- data = json.loads(cli.add_note_collaborator("n1", "user@example.com"))
426
+ data = cli.add_note_collaborator("n1", "user@example.com")
429
427
  assert "user@example.com" in data["collaborators"]
430
- after = json.loads(cli.remove_note_collaborator("n1", "user@example.com"))
428
+ after = cli.remove_note_collaborator("n1", "user@example.com")
431
429
  assert "user@example.com" not in after["collaborators"]
432
430
 
433
431
 
434
432
  def test_list_note_media(keep):
435
- media = json.loads(cli.list_note_media("n1"))
433
+ media = cli.list_note_media("n1")
436
434
  assert media[0]["media_link"].startswith("https://media/")
437
435
 
438
436
 
439
437
  def test_download_media_writes_files(keep, monkeypatch, tmp_path):
440
438
  monkeypatch.setattr(cli, "fetch_blob_bytes", lambda k, b: (b"png-bytes", "image/png"))
441
- saved = json.loads(cli.download_media("n1", str(tmp_path)))
439
+ saved = cli.download_media("n1", str(tmp_path))
442
440
  assert saved[0]["blob_id"] == "b1"
443
441
  assert saved[0]["type"] == "IMAGE"
444
442
  assert saved[0]["path"].endswith("b1.png")
@@ -154,7 +154,6 @@ def test_tools_do_not_overlap_access_to_shared_state(monkeypatch):
154
154
 
155
155
  @pytest.mark.parametrize("remote_committed", [False, True])
156
156
  def test_failed_write_does_not_leave_phantom_state_for_next_read(monkeypatch, remote_committed):
157
- import json
158
157
 
159
158
  import gkeepapi
160
159
  import requests
@@ -196,5 +195,5 @@ def test_failed_write_does_not_leave_phantom_state_for_next_read(monkeypatch, re
196
195
  cli.update_note(note.id, title='Uncommitted title')
197
196
 
198
197
  assert any(node.get('title') == 'Uncommitted title' for node in requests_sent[1])
199
- result = json.loads(cli.get_note(note.id))
198
+ result = cli.get_note(note.id)
200
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