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.
- {keep_mcp-2.2.2 → keep_mcp-2.3.0}/PKG-INFO +1 -1
- {keep_mcp-2.2.2 → keep_mcp-2.3.0}/pyproject.toml +1 -1
- {keep_mcp-2.2.2 → keep_mcp-2.3.0}/scripts/smoke_test.py +24 -25
- {keep_mcp-2.2.2 → keep_mcp-2.3.0}/src/server/cli.py +62 -59
- {keep_mcp-2.2.2 → keep_mcp-2.3.0}/tests/test_checklist_validation.py +3 -4
- {keep_mcp-2.2.2 → keep_mcp-2.3.0}/tests/test_cli.py +37 -39
- {keep_mcp-2.2.2 → keep_mcp-2.3.0}/tests/test_keep_client.py +1 -2
- {keep_mcp-2.2.2 → keep_mcp-2.3.0}/.agents/skills/keep-mcp-evidence/SKILL.md +0 -0
- {keep_mcp-2.2.2 → keep_mcp-2.3.0}/.agents/skills/keep-mcp-evidence/references/cli-runs.md +0 -0
- {keep_mcp-2.2.2 → keep_mcp-2.3.0}/.agents/skills/keep-mcp-evidence/references/evidence-template.md +0 -0
- {keep_mcp-2.2.2 → keep_mcp-2.3.0}/.agents/skills/keep-mcp-evidence/references/scenarios.md +0 -0
- {keep_mcp-2.2.2 → keep_mcp-2.3.0}/.example.env +0 -0
- {keep_mcp-2.2.2 → keep_mcp-2.3.0}/.github/pull_request_template.md +0 -0
- {keep_mcp-2.2.2 → keep_mcp-2.3.0}/.github/workflows/ci.yml +0 -0
- {keep_mcp-2.2.2 → keep_mcp-2.3.0}/.github/workflows/release.yml +0 -0
- {keep_mcp-2.2.2 → keep_mcp-2.3.0}/.gitignore +0 -0
- {keep_mcp-2.2.2 → keep_mcp-2.3.0}/.python-version +0 -0
- {keep_mcp-2.2.2 → keep_mcp-2.3.0}/CONTRIBUTING.md +0 -0
- {keep_mcp-2.2.2 → keep_mcp-2.3.0}/LICENSE +0 -0
- {keep_mcp-2.2.2 → keep_mcp-2.3.0}/Makefile +0 -0
- {keep_mcp-2.2.2 → keep_mcp-2.3.0}/README.md +0 -0
- {keep_mcp-2.2.2 → keep_mcp-2.3.0}/src/server/__init__.py +0 -0
- {keep_mcp-2.2.2 → keep_mcp-2.3.0}/src/server/__main__.py +0 -0
- {keep_mcp-2.2.2 → keep_mcp-2.3.0}/src/server/keep_api.py +0 -0
- {keep_mcp-2.2.2 → keep_mcp-2.3.0}/tests/conftest.py +0 -0
- {keep_mcp-2.2.2 → keep_mcp-2.3.0}/tests/test_keep_api.py +0 -0
- {keep_mcp-2.2.2 → keep_mcp-2.3.0}/tests/test_main_module.py +0 -0
|
@@ -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 =
|
|
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 =
|
|
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 =
|
|
44
|
+
colored = cli.set_note_color(note_id, "RED")
|
|
46
45
|
assert colored["color"] == "RED"
|
|
47
46
|
# Reset to default
|
|
48
|
-
|
|
47
|
+
cli.set_note_color(note_id, "DEFAULT")
|
|
49
48
|
|
|
50
49
|
print("Pin/unpin...")
|
|
51
|
-
assert
|
|
52
|
-
assert
|
|
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
|
|
56
|
-
assert
|
|
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
|
|
60
|
-
restored =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
88
|
+
cli.delete_note(list_id)
|
|
90
89
|
|
|
91
90
|
# --- Label lifecycle ---
|
|
92
91
|
print("Creating label...")
|
|
93
|
-
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
117
|
+
cli.delete_note(nfl_id)
|
|
119
118
|
|
|
120
119
|
# --- find() ---
|
|
121
120
|
print("Testing find()...")
|
|
122
|
-
results =
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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(
|
|
264
|
-
assert len(
|
|
265
|
-
assert
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
292
|
+
data = cli.create_list("list")
|
|
296
293
|
assert data["id"] == "created_list"
|
|
297
|
-
data_with_items =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
326
|
+
add = cli.add_list_item("list1", "task", checked=True)
|
|
327
327
|
item_id = add["item_id"]
|
|
328
|
-
updated =
|
|
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 =
|
|
344
|
-
data =
|
|
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
|
|
368
|
-
assert
|
|
369
|
-
assert
|
|
370
|
-
assert
|
|
371
|
-
assert
|
|
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 =
|
|
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 =
|
|
378
|
+
labels = cli.list_labels()
|
|
381
379
|
assert labels
|
|
382
380
|
|
|
383
|
-
created =
|
|
381
|
+
created = cli.create_label("other")
|
|
384
382
|
assert created["name"] == "other"
|
|
385
|
-
message =
|
|
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 =
|
|
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 =
|
|
416
|
+
add = cli.add_label_to_note("n1", "l2")
|
|
419
417
|
assert any(label["id"] == "l2" for label in add["labels"])
|
|
420
|
-
remove =
|
|
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 =
|
|
423
|
+
before = cli.list_note_collaborators("n1")
|
|
426
424
|
assert before == []
|
|
427
425
|
|
|
428
|
-
data =
|
|
426
|
+
data = cli.add_note_collaborator("n1", "user@example.com")
|
|
429
427
|
assert "user@example.com" in data["collaborators"]
|
|
430
|
-
after =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
{keep_mcp-2.2.2 → keep_mcp-2.3.0}/.agents/skills/keep-mcp-evidence/references/evidence-template.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|