keep-mcp 2.2.2__tar.gz → 2.4.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.4.0}/PKG-INFO +1 -1
  2. {keep_mcp-2.2.2 → keep_mcp-2.4.0}/pyproject.toml +1 -1
  3. {keep_mcp-2.2.2 → keep_mcp-2.4.0}/scripts/smoke_test.py +24 -25
  4. {keep_mcp-2.2.2 → keep_mcp-2.4.0}/src/server/cli.py +81 -93
  5. {keep_mcp-2.2.2 → keep_mcp-2.4.0}/tests/test_checklist_validation.py +6 -6
  6. {keep_mcp-2.2.2 → keep_mcp-2.4.0}/tests/test_cli.py +43 -53
  7. {keep_mcp-2.2.2 → keep_mcp-2.4.0}/tests/test_keep_client.py +1 -2
  8. {keep_mcp-2.2.2 → keep_mcp-2.4.0}/.agents/skills/keep-mcp-evidence/SKILL.md +0 -0
  9. {keep_mcp-2.2.2 → keep_mcp-2.4.0}/.agents/skills/keep-mcp-evidence/references/cli-runs.md +0 -0
  10. {keep_mcp-2.2.2 → keep_mcp-2.4.0}/.agents/skills/keep-mcp-evidence/references/evidence-template.md +0 -0
  11. {keep_mcp-2.2.2 → keep_mcp-2.4.0}/.agents/skills/keep-mcp-evidence/references/scenarios.md +0 -0
  12. {keep_mcp-2.2.2 → keep_mcp-2.4.0}/.example.env +0 -0
  13. {keep_mcp-2.2.2 → keep_mcp-2.4.0}/.github/pull_request_template.md +0 -0
  14. {keep_mcp-2.2.2 → keep_mcp-2.4.0}/.github/workflows/ci.yml +0 -0
  15. {keep_mcp-2.2.2 → keep_mcp-2.4.0}/.github/workflows/release.yml +0 -0
  16. {keep_mcp-2.2.2 → keep_mcp-2.4.0}/.gitignore +0 -0
  17. {keep_mcp-2.2.2 → keep_mcp-2.4.0}/.python-version +0 -0
  18. {keep_mcp-2.2.2 → keep_mcp-2.4.0}/CONTRIBUTING.md +0 -0
  19. {keep_mcp-2.2.2 → keep_mcp-2.4.0}/LICENSE +0 -0
  20. {keep_mcp-2.2.2 → keep_mcp-2.4.0}/Makefile +0 -0
  21. {keep_mcp-2.2.2 → keep_mcp-2.4.0}/README.md +0 -0
  22. {keep_mcp-2.2.2 → keep_mcp-2.4.0}/src/server/__init__.py +0 -0
  23. {keep_mcp-2.2.2 → keep_mcp-2.4.0}/src/server/__main__.py +0 -0
  24. {keep_mcp-2.2.2 → keep_mcp-2.4.0}/src/server/keep_api.py +0 -0
  25. {keep_mcp-2.2.2 → keep_mcp-2.4.0}/tests/conftest.py +0 -0
  26. {keep_mcp-2.2.2 → keep_mcp-2.4.0}/tests/test_keep_api.py +0 -0
  27. {keep_mcp-2.2.2 → keep_mcp-2.4.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.4.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.4.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,16 +4,18 @@ 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
11
10
  from itertools import islice
12
- from typing import Any
11
+ from typing import Any, Literal
13
12
 
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
+ from pydantic import StrictBool
18
+ from typing_extensions import TypedDict
17
19
 
18
20
  from .keep_api import (
19
21
  KEEP_MCP_LABEL,
@@ -30,6 +32,19 @@ from .keep_api import (
30
32
 
31
33
  mcp = MCPServer("keep")
32
34
 
35
+ READ_ONLY = ToolAnnotations(readOnlyHint=True)
36
+ ADDITIVE = ToolAnnotations(destructiveHint=False)
37
+
38
+ Color = Literal[
39
+ "DEFAULT", "RED", "ORANGE", "YELLOW", "GREEN", "TEAL",
40
+ "BLUE", "CERULEAN", "PURPLE", "PINK", "BROWN", "GRAY",
41
+ ]
42
+
43
+
44
+ class ChecklistItem(TypedDict, total=False):
45
+ text: str
46
+ checked: StrictBool
47
+
33
48
 
34
49
  def _get_note_or_raise(note_id: str):
35
50
  keep = get_client()
@@ -47,20 +62,6 @@ def _ensure_modifiable(note):
47
62
  )
48
63
 
49
64
 
50
- def _normalize_colors(colors: list[str] | None):
51
- if colors is None:
52
- return None
53
-
54
- normalized_colors = []
55
- for color in colors:
56
- try:
57
- normalized_colors.append(gkeepapi.node.ColorValue(color))
58
- except ValueError as exc:
59
- raise ToolError(f"Invalid color '{color}'") from exc
60
-
61
- return normalized_colors
62
-
63
-
64
65
  def _parse_utc(value: str | None, param: str) -> datetime | None:
65
66
  if value is None:
66
67
  return None
@@ -106,12 +107,12 @@ def _build_time_filter(
106
107
  return within
107
108
 
108
109
 
109
- @mcp.tool()
110
+ @mcp.tool(annotations=READ_ONLY)
110
111
  @keep_operation
111
112
  def find(
112
113
  query: str = "",
113
114
  labels: list[str] | None = None,
114
- colors: list[str] | None = None,
115
+ colors: list[Color] | None = None,
115
116
  pinned: bool | None = None,
116
117
  archived: bool | None = False,
117
118
  trashed: bool = False,
@@ -121,18 +122,16 @@ def find(
121
122
  updated_after: str | None = None,
122
123
  updated_before: str | None = None,
123
124
  limit: int | None = None,
124
- ) -> str:
125
+ ) -> list[dict[str, Any]]:
125
126
  """Find notes using text and optional filters.
126
127
 
127
128
  query matches title and text, case-insensitively unless case_sensitive
128
- is true. labels should be label IDs. colors should be ColorValue strings
129
- (e.g. DEFAULT, RED, CERULEAN). The date bounds accept ISO 8601 dates or
129
+ is true. labels should be label IDs. The date bounds accept ISO 8601 dates or
130
130
  datetimes, interpreted as UTC when no timezone is given (e.g. 2026-07-29
131
131
  or 2026-07-29T12:00:00Z); after-bounds are inclusive, before-bounds
132
132
  exclusive. limit caps the number of returned notes.
133
133
  """
134
134
  keep = get_client()
135
- normalized_colors = _normalize_colors(colors)
136
135
 
137
136
  search_query: str | re.Pattern = query
138
137
  if query and not case_sensitive:
@@ -149,7 +148,7 @@ def find(
149
148
  query=search_query,
150
149
  func=time_filter,
151
150
  labels=labels,
152
- colors=normalized_colors,
151
+ colors=None if colors is None else [gkeepapi.node.ColorValue(color) for color in colors],
153
152
  pinned=pinned,
154
153
  archived=archived,
155
154
  trashed=trashed,
@@ -158,20 +157,20 @@ def find(
158
157
  notes = islice(notes, max(limit, 0))
159
158
 
160
159
  notes_data = [serialize_note(note) for note in notes]
161
- return json.dumps(notes_data)
160
+ return notes_data
162
161
 
163
162
 
164
- @mcp.tool()
163
+ @mcp.tool(annotations=READ_ONLY)
165
164
  @keep_operation
166
- def get_note(note_id: str) -> str:
165
+ def get_note(note_id: str) -> dict[str, Any]:
167
166
  """Get a note by ID."""
168
167
  _, note = _get_note_or_raise(note_id)
169
- return json.dumps(serialize_note(note))
168
+ return serialize_note(note)
170
169
 
171
170
 
172
- @mcp.tool()
171
+ @mcp.tool(annotations=ADDITIVE)
173
172
  @keep_operation
174
- def create_note(title: str | None = None, text: str | None = None) -> str:
173
+ def create_note(title: str | None = None, text: str | None = None) -> dict[str, Any]:
175
174
  """Create a new note with title and text."""
176
175
  keep = get_client()
177
176
  note = keep.createNote(title=title, text=text)
@@ -183,24 +182,16 @@ def create_note(title: str | None = None, text: str | None = None) -> str:
183
182
  note.labels.add(label)
184
183
  keep.sync()
185
184
 
186
- return json.dumps(serialize_note(note))
185
+ return serialize_note(note)
187
186
 
188
187
 
189
- @mcp.tool()
188
+ @mcp.tool(annotations=ADDITIVE)
190
189
  @keep_operation
191
- def create_list(title: str | None = None, items: list[dict[str, Any]] | None = None) -> str:
192
- """
193
- Create a new checklist note.
194
-
195
- items should be objects like: {"text": "task", "checked": false}.
196
- checked must be a boolean when supplied and defaults to false.
197
- """
190
+ def create_list(title: str | None = None, items: list[ChecklistItem] | None = None) -> dict[str, Any]:
191
+ """Create a new checklist note. An item's checked defaults to false."""
198
192
  keep = get_client()
199
193
  formatted_items = None
200
194
  if items:
201
- for item in items:
202
- if not isinstance(item.get("checked", False), bool):
203
- raise ToolError("Each item's checked field must be a boolean")
204
195
  formatted_items = [
205
196
  (item.get("text", ""), item.get("checked", False)) for item in items
206
197
  ]
@@ -213,12 +204,12 @@ def create_list(title: str | None = None, items: list[dict[str, Any]] | None = N
213
204
  note.labels.add(label)
214
205
 
215
206
  keep.sync()
216
- return json.dumps(serialize_note(note))
207
+ return serialize_note(note)
217
208
 
218
209
 
219
- @mcp.tool()
210
+ @mcp.tool(annotations=ADDITIVE)
220
211
  @keep_operation
221
- def add_list_item(note_id: str, text: str, checked: bool = False) -> str:
212
+ def add_list_item(note_id: str, text: str, checked: bool = False) -> dict[str, Any]:
222
213
  """Add an item to a checklist note."""
223
214
  keep, note = _get_note_or_raise(note_id)
224
215
  _ensure_modifiable(note)
@@ -228,12 +219,12 @@ def add_list_item(note_id: str, text: str, checked: bool = False) -> str:
228
219
 
229
220
  item = note.add(text=text, checked=checked)
230
221
  keep.sync()
231
- return json.dumps({"note_id": note.id, "item_id": item.id})
222
+ return {"note_id": note.id, "item_id": item.id}
232
223
 
233
224
 
234
225
  @mcp.tool()
235
226
  @keep_operation
236
- def update_list_item(note_id: str, item_id: str, text: str | None = None, checked: bool | None = None) -> str:
227
+ def update_list_item(note_id: str, item_id: str, text: str | None = None, checked: bool | None = None) -> dict[str, Any]:
237
228
  """Update checklist item text and/or checked state."""
238
229
  keep, note = _get_note_or_raise(note_id)
239
230
  _ensure_modifiable(note)
@@ -251,12 +242,12 @@ def update_list_item(note_id: str, item_id: str, text: str | None = None, checke
251
242
  item.checked = checked
252
243
 
253
244
  keep.sync()
254
- return json.dumps(serialize_note(note))
245
+ return serialize_note(note)
255
246
 
256
247
 
257
248
  @mcp.tool()
258
249
  @keep_operation
259
- def delete_list_item(note_id: str, item_id: str) -> str:
250
+ def delete_list_item(note_id: str, item_id: str) -> dict[str, Any]:
260
251
  """Delete a checklist item."""
261
252
  keep, note = _get_note_or_raise(note_id)
262
253
  _ensure_modifiable(note)
@@ -270,12 +261,12 @@ def delete_list_item(note_id: str, item_id: str) -> str:
270
261
 
271
262
  item.delete()
272
263
  keep.sync()
273
- return json.dumps({"message": f"List item {item_id} marked for deletion"})
264
+ return {"message": f"List item {item_id} marked for deletion"}
274
265
 
275
266
 
276
267
  @mcp.tool()
277
268
  @keep_operation
278
- def update_note(note_id: str, title: str | None = None, text: str | None = None) -> str:
269
+ def update_note(note_id: str, title: str | None = None, text: str | None = None) -> dict[str, Any]:
279
270
  """Update a note's title and text, or a checklist's title. Use item tools for checklist text."""
280
271
  keep, note = _get_note_or_raise(note_id)
281
272
  _ensure_modifiable(note)
@@ -289,64 +280,61 @@ def update_note(note_id: str, title: str | None = None, text: str | None = None)
289
280
  note.text = text
290
281
 
291
282
  keep.sync()
292
- return json.dumps(serialize_note(note))
283
+ return serialize_note(note)
293
284
 
294
285
 
295
286
  @mcp.tool()
296
287
  @keep_operation
297
- def set_note_color(note_id: str, color: str) -> str:
298
- """Set a note color. Valid values: DEFAULT (white), RED, ORANGE, YELLOW, GREEN, TEAL, BLUE, CERULEAN (dark blue), PURPLE, PINK, BROWN, GRAY."""
288
+ def set_note_color(note_id: str, color: Color) -> dict[str, Any]:
289
+ """Set a note color. DEFAULT is white, CERULEAN is dark blue."""
299
290
  keep, note = _get_note_or_raise(note_id)
300
291
  _ensure_modifiable(note)
301
292
 
302
- try:
303
- note.color = gkeepapi.node.ColorValue(color)
304
- except ValueError as exc:
305
- raise ToolError(f"Invalid color '{color}'") from exc
293
+ note.color = gkeepapi.node.ColorValue(color)
306
294
 
307
295
  keep.sync()
308
- return json.dumps(serialize_note(note))
296
+ return serialize_note(note)
309
297
 
310
298
 
311
299
  @mcp.tool()
312
300
  @keep_operation
313
- def pin_note(note_id: str, pinned: bool = True) -> str:
301
+ def pin_note(note_id: str, pinned: bool = True) -> dict[str, Any]:
314
302
  """Pin or unpin a note."""
315
303
  keep, note = _get_note_or_raise(note_id)
316
304
  _ensure_modifiable(note)
317
305
 
318
306
  note.pinned = pinned
319
307
  keep.sync()
320
- return json.dumps(serialize_note(note))
308
+ return serialize_note(note)
321
309
 
322
310
 
323
311
  @mcp.tool()
324
312
  @keep_operation
325
- def archive_note(note_id: str, archived: bool = True) -> str:
313
+ def archive_note(note_id: str, archived: bool = True) -> dict[str, Any]:
326
314
  """Archive or unarchive a note."""
327
315
  keep, note = _get_note_or_raise(note_id)
328
316
  _ensure_modifiable(note)
329
317
 
330
318
  note.archived = archived
331
319
  keep.sync()
332
- return json.dumps(serialize_note(note))
320
+ return serialize_note(note)
333
321
 
334
322
 
335
323
  @mcp.tool()
336
324
  @keep_operation
337
- def trash_note(note_id: str) -> str:
325
+ def trash_note(note_id: str) -> dict[str, Any]:
338
326
  """Move a note to trash."""
339
327
  keep, note = _get_note_or_raise(note_id)
340
328
  _ensure_modifiable(note)
341
329
 
342
330
  note.trash()
343
331
  keep.sync()
344
- return json.dumps(serialize_note(note))
332
+ return serialize_note(note)
345
333
 
346
334
 
347
335
  @mcp.tool()
348
336
  @keep_operation
349
- def restore_note(note_id: str) -> str:
337
+ def restore_note(note_id: str) -> dict[str, Any]:
350
338
  """Restore a trashed/deleted note."""
351
339
  keep, note = _get_note_or_raise(note_id)
352
340
  _ensure_modifiable(note)
@@ -354,42 +342,42 @@ def restore_note(note_id: str) -> str:
354
342
  note.untrash()
355
343
  note.undelete()
356
344
  keep.sync()
357
- return json.dumps(serialize_note(note))
345
+ return serialize_note(note)
358
346
 
359
347
 
360
348
  @mcp.tool()
361
349
  @keep_operation
362
- def delete_note(note_id: str) -> str:
350
+ def delete_note(note_id: str) -> dict[str, Any]:
363
351
  """Delete a note (mark for deletion)."""
364
352
  keep, note = _get_note_or_raise(note_id)
365
353
  _ensure_modifiable(note)
366
354
 
367
355
  note.delete()
368
356
  keep.sync()
369
- return json.dumps({"message": f"Note {note_id} marked for deletion"})
357
+ return {"message": f"Note {note_id} marked for deletion"}
370
358
 
371
359
 
372
- @mcp.tool()
360
+ @mcp.tool(annotations=READ_ONLY)
373
361
  @keep_operation
374
- def list_labels() -> str:
362
+ def list_labels() -> list[dict[str, Any]]:
375
363
  """List all labels."""
376
364
  keep = get_client()
377
- return json.dumps([serialize_label(label) for label in keep.labels()])
365
+ return [serialize_label(label) for label in keep.labels()]
378
366
 
379
367
 
380
- @mcp.tool()
368
+ @mcp.tool(annotations=ADDITIVE)
381
369
  @keep_operation
382
- def create_label(name: str) -> str:
370
+ def create_label(name: str) -> dict[str, Any]:
383
371
  """Create a label."""
384
372
  keep = get_client()
385
373
  label = keep.createLabel(name)
386
374
  keep.sync()
387
- return json.dumps(serialize_label(label))
375
+ return serialize_label(label)
388
376
 
389
377
 
390
378
  @mcp.tool()
391
379
  @keep_operation
392
- def delete_label(label_id: str) -> str:
380
+ def delete_label(label_id: str) -> dict[str, Any]:
393
381
  """Delete a label by ID."""
394
382
  keep = get_client()
395
383
  label = keep.getLabel(label_id)
@@ -413,12 +401,12 @@ def delete_label(label_id: str) -> str:
413
401
  )
414
402
  keep.deleteLabel(label_id)
415
403
  keep.sync()
416
- return json.dumps({"message": f"Label {label_id} marked for deletion"})
404
+ return {"message": f"Label {label_id} marked for deletion"}
417
405
 
418
406
 
419
- @mcp.tool()
407
+ @mcp.tool(annotations=ADDITIVE)
420
408
  @keep_operation
421
- def add_label_to_note(note_id: str, label_id: str) -> str:
409
+ def add_label_to_note(note_id: str, label_id: str) -> dict[str, Any]:
422
410
  """Add a label to a note."""
423
411
  keep, note = _get_note_or_raise(note_id)
424
412
  _ensure_modifiable(note)
@@ -429,12 +417,12 @@ def add_label_to_note(note_id: str, label_id: str) -> str:
429
417
 
430
418
  note.labels.add(label)
431
419
  keep.sync()
432
- return json.dumps(serialize_note(note))
420
+ return serialize_note(note)
433
421
 
434
422
 
435
423
  @mcp.tool()
436
424
  @keep_operation
437
- def remove_label_from_note(note_id: str, label_id: str) -> str:
425
+ def remove_label_from_note(note_id: str, label_id: str) -> dict[str, Any]:
438
426
  """Remove a label from a note."""
439
427
  keep, note = _get_note_or_raise(note_id)
440
428
  _ensure_modifiable(note)
@@ -450,44 +438,44 @@ def remove_label_from_note(note_id: str, label_id: str) -> str:
450
438
 
451
439
  note.labels.remove(label)
452
440
  keep.sync()
453
- return json.dumps(serialize_note(note))
441
+ return serialize_note(note)
454
442
 
455
443
 
456
- @mcp.tool()
444
+ @mcp.tool(annotations=READ_ONLY)
457
445
  @keep_operation
458
- def list_note_collaborators(note_id: str) -> str:
446
+ def list_note_collaborators(note_id: str) -> list[str]:
459
447
  """List collaborator emails for a note."""
460
448
  _, note = _get_note_or_raise(note_id)
461
- return json.dumps(list(note.collaborators.all()))
449
+ return list(note.collaborators.all())
462
450
 
463
451
 
464
452
  @mcp.tool()
465
453
  @keep_operation
466
- def add_note_collaborator(note_id: str, email: str) -> str:
454
+ def add_note_collaborator(note_id: str, email: str) -> dict[str, Any]:
467
455
  """Add a collaborator email to a note."""
468
456
  keep, note = _get_note_or_raise(note_id)
469
457
  _ensure_modifiable(note)
470
458
 
471
459
  note.collaborators.add(email)
472
460
  keep.sync()
473
- return json.dumps(serialize_note(note))
461
+ return serialize_note(note)
474
462
 
475
463
 
476
464
  @mcp.tool()
477
465
  @keep_operation
478
- def remove_note_collaborator(note_id: str, email: str) -> str:
466
+ def remove_note_collaborator(note_id: str, email: str) -> dict[str, Any]:
479
467
  """Remove a collaborator email from a note."""
480
468
  keep, note = _get_note_or_raise(note_id)
481
469
  _ensure_modifiable(note)
482
470
 
483
471
  note.collaborators.remove(email)
484
472
  keep.sync()
485
- return json.dumps(serialize_note(note))
473
+ return serialize_note(note)
486
474
 
487
475
 
488
- @mcp.tool()
476
+ @mcp.tool(annotations=READ_ONLY)
489
477
  @keep_operation
490
- def list_note_media(note_id: str) -> str:
478
+ def list_note_media(note_id: str) -> list[dict[str, Any]]:
491
479
  """List note media blobs and direct media links when available."""
492
480
  keep, note = _get_note_or_raise(note_id)
493
481
 
@@ -501,12 +489,12 @@ def list_note_media(note_id: str) -> str:
501
489
  }
502
490
  )
503
491
 
504
- return json.dumps(media)
492
+ return media
505
493
 
506
494
 
507
495
  @mcp.tool()
508
496
  @keep_operation
509
- def download_media(note_id: str, dest_dir: str, blob_id: str | None = None) -> str:
497
+ def download_media(note_id: str, dest_dir: str, blob_id: str | None = None) -> list[dict[str, Any]]:
510
498
  """Download a note's media (images, drawings, audio) to a local directory.
511
499
 
512
500
  The links from list_note_media require Google authentication and answer
@@ -540,7 +528,7 @@ def download_media(note_id: str, dest_dir: str, blob_id: str | None = None) -> s
540
528
  }
541
529
  )
542
530
 
543
- return json.dumps(saved)
531
+ return saved
544
532
 
545
533
 
546
534
  def main():
@@ -1,8 +1,8 @@
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
 
5
+ import anyio
6
6
  import gkeepapi
7
7
  import pytest
8
8
  from mcp.server.mcpserver.exceptions import ToolError
@@ -21,18 +21,18 @@ def offline_keep(monkeypatch):
21
21
 
22
22
  @pytest.mark.parametrize("checked", ["false", "true", 0, 1, None])
23
23
  def test_create_list_rejects_non_boolean_checked_without_creating_note(offline_keep, checked):
24
- with pytest.raises(ToolError, match="checked.*boolean"):
25
- cli.create_list("Tasks", [{"text": "Buy milk", "checked": checked}])
24
+ with pytest.raises(ToolError, match="checked"):
25
+ anyio.run(cli.mcp.call_tool, "create_list", {"title": "Tasks", "items": [{"text": "Buy milk", "checked": checked}]})
26
26
  assert list(offline_keep.all()) == []
27
27
  offline_keep.sync.assert_not_called()
28
28
 
29
29
 
30
30
  def test_create_list_preserves_boolean_checked_and_default(offline_keep):
31
- result = json.loads(cli.create_list("Tasks", [
31
+ result = cli.create_list("Tasks", [
32
32
  {"text": "Unchecked", "checked": False},
33
33
  {"text": "Checked", "checked": True},
34
34
  {"text": "Default"},
35
- ]))
35
+ ])
36
36
  assert {item["text"]: item["checked"] for item in result["items"]} == {
37
37
  "Unchecked": False, "Checked": True, "Default": False,
38
38
  }
@@ -51,6 +51,6 @@ def test_update_list_text_does_not_partially_change_title(offline_keep):
51
51
  def test_update_list_title_remains_supported(offline_keep):
52
52
  note = offline_keep.createList("Original", [("Buy milk", False)])
53
53
  note.labels.add(offline_keep.createLabel("keep-mcp"))
54
- result = json.loads(cli.update_note(note.id, title="Changed"))
54
+ result = cli.update_note(note.id, title="Changed")
55
55
  assert result["title"] == "Changed"
56
56
  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,54 +257,48 @@ 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)) == []
266
-
260
+ assert len(cli.find()) == 2
261
+ assert len(cli.find(limit=1)) == 1
262
+ assert cli.find(limit=0) == []
267
263
 
268
- def test_find_invalid_color_raises(keep, monkeypatch):
269
- def bad_color(_):
270
- raise ValueError("bad")
271
264
 
272
- monkeypatch.setattr(cli.gkeepapi.node, "ColorValue", bad_color)
273
- with pytest.raises(ToolError, match="Invalid color 'invalid'"):
274
- cli.find(colors=["invalid"])
265
+ def test_find_invalid_color_raises(keep):
266
+ with pytest.raises(ToolError, match="colors"):
267
+ anyio.run(cli.mcp.call_tool, "find", {"colors": ["invalid"]})
275
268
 
276
269
 
277
270
  def test_get_note(keep):
278
- data = json.loads(cli.get_note("n1"))
271
+ data = cli.get_note("n1")
279
272
  assert data["id"] == "n1"
280
273
 
281
274
 
282
275
  def test_create_note_labels_and_sync(keep):
283
- data = json.loads(cli.create_note("t", "body"))
276
+ data = cli.create_note("t", "body")
284
277
  assert data["id"] == "created"
285
278
  assert keep.sync_calls == 1
286
279
 
287
280
 
288
281
  def test_create_note_creates_label_when_missing(keep):
289
282
  keep._labels = {}
290
- data = json.loads(cli.create_note("t", "body"))
283
+ data = cli.create_note("t", "body")
291
284
  assert data["labels"][0]["name"] == "keep-mcp"
292
285
 
293
286
 
294
287
  def test_create_list_variants(keep):
295
- data = json.loads(cli.create_list("list"))
288
+ data = cli.create_list("list")
296
289
  assert data["id"] == "created_list"
297
- data_with_items = json.loads(
298
- cli.create_list("list", items=[{"text": "a", "checked": True}])
299
- )
290
+ data_with_items = cli.create_list("list", items=[{"text": "a", "checked": True}])
300
291
  assert data_with_items["items"][0]["checked"] is True
301
292
 
302
293
 
303
294
  def test_create_list_creates_label_when_missing(keep):
304
295
  keep._labels = {}
305
- data = json.loads(cli.create_list("list"))
296
+ data = cli.create_list("list")
306
297
  assert data["labels"][0]["name"] == "keep-mcp"
307
298
 
308
299
 
309
300
  def test_update_note_updates_fields(keep):
310
- data = json.loads(cli.update_note("n1", title="new", text="changed"))
301
+ data = cli.update_note("n1", title="new", text="changed")
311
302
  assert data["title"] == "new"
312
303
  assert data["text"] == "changed"
313
304
 
@@ -317,17 +308,20 @@ def test_anticipated_errors_reach_the_client(keep):
317
308
  anyio.run(cli.mcp.call_tool, "get_note", {"note_id": "missing"})
318
309
 
319
310
 
311
+ def test_tools_return_structured_content(keep):
312
+ result = anyio.run(cli.mcp.call_tool, "get_note", {"note_id": "n1"})
313
+ assert result.structured_content["id"] == "n1"
314
+
315
+
320
316
  def test_update_note_not_found_raises(keep):
321
317
  with pytest.raises(ToolError, match="not found"):
322
318
  cli.update_note("missing", title="x")
323
319
 
324
320
 
325
321
  def test_list_item_roundtrip(keep):
326
- add = json.loads(cli.add_list_item("list1", "task", checked=True))
322
+ add = cli.add_list_item("list1", "task", checked=True)
327
323
  item_id = add["item_id"]
328
- updated = json.loads(
329
- cli.update_list_item("list1", item_id, text="task2", checked=False)
330
- )
324
+ updated = cli.update_list_item("list1", item_id, text="task2", checked=False)
331
325
  assert any(item["id"] == item_id for item in updated["items"])
332
326
 
333
327
 
@@ -340,8 +334,8 @@ def test_delete_list_item_paths(keep):
340
334
  with pytest.raises(ToolError, match="not found"):
341
335
  cli.delete_list_item("list1", "missing")
342
336
 
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))
337
+ new_item_id = cli.add_list_item("list1", "task")["item_id"]
338
+ data = cli.delete_list_item("list1", new_item_id)
345
339
  assert "marked for deletion" in data["message"]
346
340
 
347
341
 
@@ -354,35 +348,31 @@ def test_list_item_requires_list_type(keep):
354
348
  cli.delete_list_item("n1", "i1")
355
349
 
356
350
 
357
- def test_set_note_color_validates(keep, monkeypatch):
358
- def bad_color(_):
359
- raise ValueError("bad")
360
-
361
- monkeypatch.setattr(cli.gkeepapi.node, "ColorValue", bad_color)
362
- with pytest.raises(ToolError, match="Invalid color"):
363
- cli.set_note_color("n1", "invalid")
351
+ def test_set_note_color_validates(keep):
352
+ with pytest.raises(ToolError, match="color"):
353
+ anyio.run(cli.mcp.call_tool, "set_note_color", {"note_id": "n1", "color": "invalid"})
364
354
 
365
355
 
366
356
  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
357
+ assert cli.set_note_color("n1", "red")["color"] == "red"
358
+ assert cli.pin_note("n1", True)["pinned"] is True
359
+ assert cli.archive_note("n1", True)["archived"] is True
360
+ assert cli.trash_note("n1")["trashed"] is True
361
+ assert cli.restore_note("n1")["trashed"] is False
372
362
 
373
363
 
374
364
  def test_delete_note_marks_deleted(keep):
375
- msg = json.loads(cli.delete_note("n1"))
365
+ msg = cli.delete_note("n1")
376
366
  assert "marked for deletion" in msg["message"]
377
367
 
378
368
 
379
369
  def test_label_crud_and_missing_label_errors(keep):
380
- labels = json.loads(cli.list_labels())
370
+ labels = cli.list_labels()
381
371
  assert labels
382
372
 
383
- created = json.loads(cli.create_label("other"))
373
+ created = cli.create_label("other")
384
374
  assert created["name"] == "other"
385
- message = json.loads(cli.delete_label(created["id"]))
375
+ message = cli.delete_label(created["id"])
386
376
  assert "marked for deletion" in message["message"]
387
377
 
388
378
  with pytest.raises(ToolError, match="Label with ID bad not found"):
@@ -409,36 +399,36 @@ def test_delete_label_safe_mode_guards(keep, monkeypatch):
409
399
 
410
400
  # UNSAFE_MODE bypasses both guards.
411
401
  monkeypatch.setenv("UNSAFE_MODE", "true")
412
- msg = json.loads(cli.delete_label("shared"))
402
+ msg = cli.delete_label("shared")
413
403
  assert "marked for deletion" in msg["message"]
414
404
 
415
405
 
416
406
  def test_label_add_remove(keep):
417
407
  keep._labels["l2"] = DummyLabel("l2", "other")
418
- add = json.loads(cli.add_label_to_note("n1", "l2"))
408
+ add = cli.add_label_to_note("n1", "l2")
419
409
  assert any(label["id"] == "l2" for label in add["labels"])
420
- remove = json.loads(cli.remove_label_from_note("n1", "l2"))
410
+ remove = cli.remove_label_from_note("n1", "l2")
421
411
  assert all(label["id"] != "l2" for label in remove["labels"])
422
412
 
423
413
 
424
414
  def test_collaborator_list_add_remove(keep):
425
- before = json.loads(cli.list_note_collaborators("n1"))
415
+ before = cli.list_note_collaborators("n1")
426
416
  assert before == []
427
417
 
428
- data = json.loads(cli.add_note_collaborator("n1", "user@example.com"))
418
+ data = cli.add_note_collaborator("n1", "user@example.com")
429
419
  assert "user@example.com" in data["collaborators"]
430
- after = json.loads(cli.remove_note_collaborator("n1", "user@example.com"))
420
+ after = cli.remove_note_collaborator("n1", "user@example.com")
431
421
  assert "user@example.com" not in after["collaborators"]
432
422
 
433
423
 
434
424
  def test_list_note_media(keep):
435
- media = json.loads(cli.list_note_media("n1"))
425
+ media = cli.list_note_media("n1")
436
426
  assert media[0]["media_link"].startswith("https://media/")
437
427
 
438
428
 
439
429
  def test_download_media_writes_files(keep, monkeypatch, tmp_path):
440
430
  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)))
431
+ saved = cli.download_media("n1", str(tmp_path))
442
432
  assert saved[0]["blob_id"] == "b1"
443
433
  assert saved[0]["type"] == "IMAGE"
444
434
  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