keep-mcp 2.2.0__tar.gz → 2.2.2__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.0 → keep_mcp-2.2.2}/PKG-INFO +2 -2
- {keep_mcp-2.2.0 → keep_mcp-2.2.2}/pyproject.toml +2 -2
- {keep_mcp-2.2.0 → keep_mcp-2.2.2}/src/server/cli.py +21 -24
- {keep_mcp-2.2.0 → keep_mcp-2.2.2}/src/server/keep_api.py +6 -3
- {keep_mcp-2.2.0 → keep_mcp-2.2.2}/tests/test_checklist_validation.py +3 -2
- {keep_mcp-2.2.0 → keep_mcp-2.2.2}/tests/test_cli.py +23 -16
- {keep_mcp-2.2.0 → keep_mcp-2.2.2}/tests/test_keep_client.py +15 -5
- {keep_mcp-2.2.0 → keep_mcp-2.2.2}/.agents/skills/keep-mcp-evidence/SKILL.md +0 -0
- {keep_mcp-2.2.0 → keep_mcp-2.2.2}/.agents/skills/keep-mcp-evidence/references/cli-runs.md +0 -0
- {keep_mcp-2.2.0 → keep_mcp-2.2.2}/.agents/skills/keep-mcp-evidence/references/evidence-template.md +0 -0
- {keep_mcp-2.2.0 → keep_mcp-2.2.2}/.agents/skills/keep-mcp-evidence/references/scenarios.md +0 -0
- {keep_mcp-2.2.0 → keep_mcp-2.2.2}/.example.env +0 -0
- {keep_mcp-2.2.0 → keep_mcp-2.2.2}/.github/pull_request_template.md +0 -0
- {keep_mcp-2.2.0 → keep_mcp-2.2.2}/.github/workflows/ci.yml +0 -0
- {keep_mcp-2.2.0 → keep_mcp-2.2.2}/.github/workflows/release.yml +0 -0
- {keep_mcp-2.2.0 → keep_mcp-2.2.2}/.gitignore +0 -0
- {keep_mcp-2.2.0 → keep_mcp-2.2.2}/.python-version +0 -0
- {keep_mcp-2.2.0 → keep_mcp-2.2.2}/CONTRIBUTING.md +0 -0
- {keep_mcp-2.2.0 → keep_mcp-2.2.2}/LICENSE +0 -0
- {keep_mcp-2.2.0 → keep_mcp-2.2.2}/Makefile +0 -0
- {keep_mcp-2.2.0 → keep_mcp-2.2.2}/README.md +0 -0
- {keep_mcp-2.2.0 → keep_mcp-2.2.2}/scripts/smoke_test.py +0 -0
- {keep_mcp-2.2.0 → keep_mcp-2.2.2}/src/server/__init__.py +0 -0
- {keep_mcp-2.2.0 → keep_mcp-2.2.2}/src/server/__main__.py +0 -0
- {keep_mcp-2.2.0 → keep_mcp-2.2.2}/tests/conftest.py +0 -0
- {keep_mcp-2.2.0 → keep_mcp-2.2.2}/tests/test_keep_api.py +0 -0
- {keep_mcp-2.2.0 → keep_mcp-2.2.2}/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.
|
|
3
|
+
Version: 2.2.2
|
|
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
|
|
@@ -36,7 +36,7 @@ Classifier: Programming Language :: Python :: 3.10
|
|
|
36
36
|
Classifier: Topic :: Utilities
|
|
37
37
|
Requires-Python: >=3.10
|
|
38
38
|
Requires-Dist: gkeepapi>=0.16.0
|
|
39
|
-
Requires-Dist: mcp[cli]<3,>=
|
|
39
|
+
Requires-Dist: mcp[cli]<3,>=2.0
|
|
40
40
|
Requires-Dist: python-dotenv>=1.0
|
|
41
41
|
Requires-Dist: requests>=2.28
|
|
42
42
|
Description-Content-Type: text/markdown
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "keep-mcp"
|
|
3
|
-
version = "2.2.
|
|
3
|
+
version = "2.2.2"
|
|
4
4
|
description = "MCP Server for Google Keep"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.10"
|
|
7
7
|
dependencies = [
|
|
8
8
|
"gkeepapi>=0.16.0",
|
|
9
|
-
"mcp[cli]>=
|
|
9
|
+
"mcp[cli]>=2.0,<3",
|
|
10
10
|
"python-dotenv>=1.0",
|
|
11
11
|
"requests>=2.28",
|
|
12
12
|
]
|
|
@@ -12,11 +12,8 @@ from itertools import islice
|
|
|
12
12
|
from typing import Any
|
|
13
13
|
|
|
14
14
|
import gkeepapi
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
from mcp.server import MCPServer
|
|
18
|
-
except ImportError: # MCP SDK 1.x: FastMCP became MCPServer in 2.0
|
|
19
|
-
from mcp.server.fastmcp import FastMCP as MCPServer
|
|
15
|
+
from mcp.server import MCPServer
|
|
16
|
+
from mcp.server.mcpserver.exceptions import ToolError
|
|
20
17
|
|
|
21
18
|
from .keep_api import (
|
|
22
19
|
KEEP_MCP_LABEL,
|
|
@@ -38,13 +35,13 @@ def _get_note_or_raise(note_id: str):
|
|
|
38
35
|
keep = get_client()
|
|
39
36
|
note = keep.get(note_id)
|
|
40
37
|
if not note:
|
|
41
|
-
raise
|
|
38
|
+
raise ToolError(f"Note with ID {note_id} not found")
|
|
42
39
|
return keep, note
|
|
43
40
|
|
|
44
41
|
|
|
45
42
|
def _ensure_modifiable(note):
|
|
46
43
|
if not can_modify_note(note):
|
|
47
|
-
raise
|
|
44
|
+
raise ToolError(
|
|
48
45
|
f"Note with ID {note.id} cannot be modified "
|
|
49
46
|
"(missing keep-mcp label and UNSAFE_MODE is not enabled)"
|
|
50
47
|
)
|
|
@@ -59,7 +56,7 @@ def _normalize_colors(colors: list[str] | None):
|
|
|
59
56
|
try:
|
|
60
57
|
normalized_colors.append(gkeepapi.node.ColorValue(color))
|
|
61
58
|
except ValueError as exc:
|
|
62
|
-
raise
|
|
59
|
+
raise ToolError(f"Invalid color '{color}'") from exc
|
|
63
60
|
|
|
64
61
|
return normalized_colors
|
|
65
62
|
|
|
@@ -70,7 +67,7 @@ def _parse_utc(value: str | None, param: str) -> datetime | None:
|
|
|
70
67
|
try:
|
|
71
68
|
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
72
69
|
except ValueError as exc:
|
|
73
|
-
raise
|
|
70
|
+
raise ToolError(
|
|
74
71
|
f"Invalid {param} '{value}': expected ISO 8601, "
|
|
75
72
|
"e.g. 2026-07-29 or 2026-07-29T12:00:00Z"
|
|
76
73
|
) from exc
|
|
@@ -203,7 +200,7 @@ def create_list(title: str | None = None, items: list[dict[str, Any]] | None = N
|
|
|
203
200
|
if items:
|
|
204
201
|
for item in items:
|
|
205
202
|
if not isinstance(item.get("checked", False), bool):
|
|
206
|
-
raise
|
|
203
|
+
raise ToolError("Each item's checked field must be a boolean")
|
|
207
204
|
formatted_items = [
|
|
208
205
|
(item.get("text", ""), item.get("checked", False)) for item in items
|
|
209
206
|
]
|
|
@@ -227,7 +224,7 @@ def add_list_item(note_id: str, text: str, checked: bool = False) -> str:
|
|
|
227
224
|
_ensure_modifiable(note)
|
|
228
225
|
|
|
229
226
|
if not isinstance(note, gkeepapi.node.List):
|
|
230
|
-
raise
|
|
227
|
+
raise ToolError(f"Note with ID {note_id} is not a list")
|
|
231
228
|
|
|
232
229
|
item = note.add(text=text, checked=checked)
|
|
233
230
|
keep.sync()
|
|
@@ -242,11 +239,11 @@ def update_list_item(note_id: str, item_id: str, text: str | None = None, checke
|
|
|
242
239
|
_ensure_modifiable(note)
|
|
243
240
|
|
|
244
241
|
if not isinstance(note, gkeepapi.node.List):
|
|
245
|
-
raise
|
|
242
|
+
raise ToolError(f"Note with ID {note_id} is not a list")
|
|
246
243
|
|
|
247
244
|
item = note.get(item_id)
|
|
248
245
|
if not item:
|
|
249
|
-
raise
|
|
246
|
+
raise ToolError(f"List item with ID {item_id} not found")
|
|
250
247
|
|
|
251
248
|
if text is not None:
|
|
252
249
|
item.text = text
|
|
@@ -265,11 +262,11 @@ def delete_list_item(note_id: str, item_id: str) -> str:
|
|
|
265
262
|
_ensure_modifiable(note)
|
|
266
263
|
|
|
267
264
|
if not isinstance(note, gkeepapi.node.List):
|
|
268
|
-
raise
|
|
265
|
+
raise ToolError(f"Note with ID {note_id} is not a list")
|
|
269
266
|
|
|
270
267
|
item = note.get(item_id)
|
|
271
268
|
if not item:
|
|
272
|
-
raise
|
|
269
|
+
raise ToolError(f"List item with ID {item_id} not found")
|
|
273
270
|
|
|
274
271
|
item.delete()
|
|
275
272
|
keep.sync()
|
|
@@ -284,7 +281,7 @@ def update_note(note_id: str, title: str | None = None, text: str | None = None)
|
|
|
284
281
|
_ensure_modifiable(note)
|
|
285
282
|
|
|
286
283
|
if text is not None and isinstance(note, gkeepapi.node.List):
|
|
287
|
-
raise
|
|
284
|
+
raise ToolError("Cannot replace checklist text: use the checklist item tools")
|
|
288
285
|
|
|
289
286
|
if title is not None:
|
|
290
287
|
note.title = title
|
|
@@ -305,7 +302,7 @@ def set_note_color(note_id: str, color: str) -> str:
|
|
|
305
302
|
try:
|
|
306
303
|
note.color = gkeepapi.node.ColorValue(color)
|
|
307
304
|
except ValueError as exc:
|
|
308
|
-
raise
|
|
305
|
+
raise ToolError(f"Invalid color '{color}'") from exc
|
|
309
306
|
|
|
310
307
|
keep.sync()
|
|
311
308
|
return json.dumps(serialize_note(note))
|
|
@@ -397,10 +394,10 @@ def delete_label(label_id: str) -> str:
|
|
|
397
394
|
keep = get_client()
|
|
398
395
|
label = keep.getLabel(label_id)
|
|
399
396
|
if not label:
|
|
400
|
-
raise
|
|
397
|
+
raise ToolError(f"Label with ID {label_id} not found")
|
|
401
398
|
if not is_unsafe_mode():
|
|
402
399
|
if label.name == KEEP_MCP_LABEL:
|
|
403
|
-
raise
|
|
400
|
+
raise ToolError(
|
|
404
401
|
f"Cannot delete the '{KEEP_MCP_LABEL}' label in safe mode: all notes managed "
|
|
405
402
|
"by this server would become permanently unmodifiable. Set UNSAFE_MODE=true to override."
|
|
406
403
|
)
|
|
@@ -409,7 +406,7 @@ def delete_label(label_id: str) -> str:
|
|
|
409
406
|
if any(lb.id == label_id for lb in n.labels.all()) and not has_keep_mcp_label(n)
|
|
410
407
|
]
|
|
411
408
|
if unmanaged:
|
|
412
|
-
raise
|
|
409
|
+
raise ToolError(
|
|
413
410
|
f"Cannot delete label '{label.name}' in safe mode: it is attached to "
|
|
414
411
|
f"{len(unmanaged)} unmanaged note(s). Deleting it would silently modify "
|
|
415
412
|
"those notes. Set UNSAFE_MODE=true to override."
|
|
@@ -428,7 +425,7 @@ def add_label_to_note(note_id: str, label_id: str) -> str:
|
|
|
428
425
|
|
|
429
426
|
label = keep.getLabel(label_id)
|
|
430
427
|
if not label:
|
|
431
|
-
raise
|
|
428
|
+
raise ToolError(f"Label with ID {label_id} not found")
|
|
432
429
|
|
|
433
430
|
note.labels.add(label)
|
|
434
431
|
keep.sync()
|
|
@@ -444,9 +441,9 @@ def remove_label_from_note(note_id: str, label_id: str) -> str:
|
|
|
444
441
|
|
|
445
442
|
label = keep.getLabel(label_id)
|
|
446
443
|
if not label:
|
|
447
|
-
raise
|
|
444
|
+
raise ToolError(f"Label with ID {label_id} not found")
|
|
448
445
|
if label.name == KEEP_MCP_LABEL and not is_unsafe_mode():
|
|
449
|
-
raise
|
|
446
|
+
raise ToolError(
|
|
450
447
|
f"Cannot remove the '{KEEP_MCP_LABEL}' label in safe mode: the note would "
|
|
451
448
|
"become permanently unmodifiable. Set UNSAFE_MODE=true to override."
|
|
452
449
|
)
|
|
@@ -523,7 +520,7 @@ def download_media(note_id: str, dest_dir: str, blob_id: str | None = None) -> s
|
|
|
523
520
|
|
|
524
521
|
blobs = [blob for blob in note.blobs if blob_id is None or blob.id == blob_id]
|
|
525
522
|
if blob_id is not None and not blobs:
|
|
526
|
-
raise
|
|
523
|
+
raise ToolError(f"Blob with ID {blob_id} not found on note {note_id}")
|
|
527
524
|
|
|
528
525
|
os.makedirs(dest_dir, exist_ok=True)
|
|
529
526
|
|
|
@@ -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
|
|
@@ -5,6 +5,7 @@ from unittest.mock import Mock
|
|
|
5
5
|
|
|
6
6
|
import gkeepapi
|
|
7
7
|
import pytest
|
|
8
|
+
from mcp.server.mcpserver.exceptions import ToolError
|
|
8
9
|
|
|
9
10
|
from server import cli
|
|
10
11
|
|
|
@@ -20,7 +21,7 @@ def offline_keep(monkeypatch):
|
|
|
20
21
|
|
|
21
22
|
@pytest.mark.parametrize("checked", ["false", "true", 0, 1, None])
|
|
22
23
|
def test_create_list_rejects_non_boolean_checked_without_creating_note(offline_keep, checked):
|
|
23
|
-
with pytest.raises(
|
|
24
|
+
with pytest.raises(ToolError, match="checked.*boolean"):
|
|
24
25
|
cli.create_list("Tasks", [{"text": "Buy milk", "checked": checked}])
|
|
25
26
|
assert list(offline_keep.all()) == []
|
|
26
27
|
offline_keep.sync.assert_not_called()
|
|
@@ -40,7 +41,7 @@ def test_create_list_preserves_boolean_checked_and_default(offline_keep):
|
|
|
40
41
|
def test_update_list_text_does_not_partially_change_title(offline_keep):
|
|
41
42
|
note = offline_keep.createList("Original", [("Buy milk", False)])
|
|
42
43
|
note.labels.add(offline_keep.createLabel("keep-mcp"))
|
|
43
|
-
with pytest.raises(
|
|
44
|
+
with pytest.raises(ToolError, match="checklist.*item"):
|
|
44
45
|
cli.update_note(note.id, title="Changed", text="Replacement")
|
|
45
46
|
assert note.title == "Original"
|
|
46
47
|
assert [item.text for item in note.items] == ["Buy milk"]
|
|
@@ -3,7 +3,9 @@ import re
|
|
|
3
3
|
from datetime import datetime, timezone
|
|
4
4
|
from types import SimpleNamespace
|
|
5
5
|
|
|
6
|
+
import anyio
|
|
6
7
|
import pytest
|
|
8
|
+
from mcp.server.mcpserver.exceptions import ToolError
|
|
7
9
|
|
|
8
10
|
from server import cli
|
|
9
11
|
|
|
@@ -253,7 +255,7 @@ def test_find_date_filter_func(keep):
|
|
|
253
255
|
|
|
254
256
|
|
|
255
257
|
def test_find_invalid_date_raises(keep):
|
|
256
|
-
with pytest.raises(
|
|
258
|
+
with pytest.raises(ToolError, match="created_after"):
|
|
257
259
|
cli.find(created_after="not-a-date")
|
|
258
260
|
|
|
259
261
|
|
|
@@ -268,7 +270,7 @@ def test_find_invalid_color_raises(keep, monkeypatch):
|
|
|
268
270
|
raise ValueError("bad")
|
|
269
271
|
|
|
270
272
|
monkeypatch.setattr(cli.gkeepapi.node, "ColorValue", bad_color)
|
|
271
|
-
with pytest.raises(
|
|
273
|
+
with pytest.raises(ToolError, match="Invalid color 'invalid'"):
|
|
272
274
|
cli.find(colors=["invalid"])
|
|
273
275
|
|
|
274
276
|
|
|
@@ -310,8 +312,13 @@ def test_update_note_updates_fields(keep):
|
|
|
310
312
|
assert data["text"] == "changed"
|
|
311
313
|
|
|
312
314
|
|
|
315
|
+
def test_anticipated_errors_reach_the_client(keep):
|
|
316
|
+
with pytest.raises(ToolError, match="Note with ID missing not found"):
|
|
317
|
+
anyio.run(cli.mcp.call_tool, "get_note", {"note_id": "missing"})
|
|
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
|
|
|
@@ -325,12 +332,12 @@ def test_list_item_roundtrip(keep):
|
|
|
325
332
|
|
|
326
333
|
|
|
327
334
|
def test_update_list_item_missing_item_raises(keep):
|
|
328
|
-
with pytest.raises(
|
|
335
|
+
with pytest.raises(ToolError, match="not found"):
|
|
329
336
|
cli.update_list_item("list1", "missing", text="x")
|
|
330
337
|
|
|
331
338
|
|
|
332
339
|
def test_delete_list_item_paths(keep):
|
|
333
|
-
with pytest.raises(
|
|
340
|
+
with pytest.raises(ToolError, match="not found"):
|
|
334
341
|
cli.delete_list_item("list1", "missing")
|
|
335
342
|
|
|
336
343
|
new_item_id = json.loads(cli.add_list_item("list1", "task"))["item_id"]
|
|
@@ -339,11 +346,11 @@ def test_delete_list_item_paths(keep):
|
|
|
339
346
|
|
|
340
347
|
|
|
341
348
|
def test_list_item_requires_list_type(keep):
|
|
342
|
-
with pytest.raises(
|
|
349
|
+
with pytest.raises(ToolError, match="not a list"):
|
|
343
350
|
cli.add_list_item("n1", "x")
|
|
344
|
-
with pytest.raises(
|
|
351
|
+
with pytest.raises(ToolError, match="not a list"):
|
|
345
352
|
cli.update_list_item("n1", "i1", text="x")
|
|
346
|
-
with pytest.raises(
|
|
353
|
+
with pytest.raises(ToolError, match="not a list"):
|
|
347
354
|
cli.delete_list_item("n1", "i1")
|
|
348
355
|
|
|
349
356
|
|
|
@@ -352,7 +359,7 @@ def test_set_note_color_validates(keep, monkeypatch):
|
|
|
352
359
|
raise ValueError("bad")
|
|
353
360
|
|
|
354
361
|
monkeypatch.setattr(cli.gkeepapi.node, "ColorValue", bad_color)
|
|
355
|
-
with pytest.raises(
|
|
362
|
+
with pytest.raises(ToolError, match="Invalid color"):
|
|
356
363
|
cli.set_note_color("n1", "invalid")
|
|
357
364
|
|
|
358
365
|
|
|
@@ -378,17 +385,17 @@ def test_label_crud_and_missing_label_errors(keep):
|
|
|
378
385
|
message = json.loads(cli.delete_label(created["id"]))
|
|
379
386
|
assert "marked for deletion" in message["message"]
|
|
380
387
|
|
|
381
|
-
with pytest.raises(
|
|
388
|
+
with pytest.raises(ToolError, match="Label with ID bad not found"):
|
|
382
389
|
cli.delete_label("bad")
|
|
383
|
-
with pytest.raises(
|
|
390
|
+
with pytest.raises(ToolError, match="Label with ID bad not found"):
|
|
384
391
|
cli.add_label_to_note("n1", "bad")
|
|
385
|
-
with pytest.raises(
|
|
392
|
+
with pytest.raises(ToolError, match="Label with ID bad not found"):
|
|
386
393
|
cli.remove_label_from_note("n1", "bad")
|
|
387
394
|
|
|
388
395
|
|
|
389
396
|
def test_delete_label_safe_mode_guards(keep, monkeypatch):
|
|
390
397
|
# Deleting the keep-mcp label is blocked in safe mode.
|
|
391
|
-
with pytest.raises(
|
|
398
|
+
with pytest.raises(ToolError, match="keep-mcp"):
|
|
392
399
|
cli.delete_label("l1")
|
|
393
400
|
|
|
394
401
|
# Deleting a label that is on an unmanaged note is also blocked in safe mode.
|
|
@@ -397,7 +404,7 @@ def test_delete_label_safe_mode_guards(keep, monkeypatch):
|
|
|
397
404
|
keep._labels["shared"] = shared
|
|
398
405
|
unmanaged.labels.add(shared)
|
|
399
406
|
keep.notes["unmanaged"] = unmanaged
|
|
400
|
-
with pytest.raises(
|
|
407
|
+
with pytest.raises(ToolError, match="unmanaged"):
|
|
401
408
|
cli.delete_label("shared")
|
|
402
409
|
|
|
403
410
|
# UNSAFE_MODE bypasses both guards.
|
|
@@ -441,14 +448,14 @@ def test_download_media_writes_files(keep, monkeypatch, tmp_path):
|
|
|
441
448
|
|
|
442
449
|
|
|
443
450
|
def test_download_media_unknown_blob(keep, tmp_path):
|
|
444
|
-
with pytest.raises(
|
|
451
|
+
with pytest.raises(ToolError, match="Blob with ID nope not found"):
|
|
445
452
|
cli.download_media("n1", str(tmp_path), blob_id="nope")
|
|
446
453
|
|
|
447
454
|
|
|
448
455
|
def test_modification_guard_blocks_when_unlabeled(keep, monkeypatch):
|
|
449
456
|
keep.notes["n1"].labels = DummyLabels()
|
|
450
457
|
monkeypatch.setattr(cli, "can_modify_note", lambda _: False)
|
|
451
|
-
with pytest.raises(
|
|
458
|
+
with pytest.raises(ToolError, match="cannot be modified"):
|
|
452
459
|
cli.update_note("n1", title="x")
|
|
453
460
|
|
|
454
461
|
|
|
@@ -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):
|
|
File without changes
|
|
File without changes
|
{keep_mcp-2.2.0 → keep_mcp-2.2.2}/.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
|