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