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