keep-agent-mem 1.0.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_agent_mem-1.0.0/.example.env +3 -0
- keep_agent_mem-1.0.0/.github/workflows/ci.yml +41 -0
- keep_agent_mem-1.0.0/.github/workflows/release.yml +202 -0
- keep_agent_mem-1.0.0/.gitignore +54 -0
- keep_agent_mem-1.0.0/.python-version +1 -0
- keep_agent_mem-1.0.0/PKG-INFO +82 -0
- keep_agent_mem-1.0.0/README.md +61 -0
- keep_agent_mem-1.0.0/pyproject.toml +49 -0
- keep_agent_mem-1.0.0/scripts/smoke_test.py +52 -0
- keep_agent_mem-1.0.0/src/server/__init__.py +0 -0
- keep_agent_mem-1.0.0/src/server/__main__.py +4 -0
- keep_agent_mem-1.0.0/src/server/cli.py +136 -0
- keep_agent_mem-1.0.0/src/server/keep_api.py +103 -0
- keep_agent_mem-1.0.0/tests/conftest.py +4 -0
- keep_agent_mem-1.0.0/tests/test_cli.py +260 -0
- keep_agent_mem-1.0.0/tests/test_keep_api.py +75 -0
- keep_agent_mem-1.0.0/tests/test_keep_client.py +41 -0
- keep_agent_mem-1.0.0/tests/test_main_module.py +14 -0
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
pull_request:
|
|
5
|
+
push:
|
|
6
|
+
branches:
|
|
7
|
+
- main
|
|
8
|
+
- master
|
|
9
|
+
|
|
10
|
+
jobs:
|
|
11
|
+
test:
|
|
12
|
+
runs-on: ubuntu-latest
|
|
13
|
+
strategy:
|
|
14
|
+
fail-fast: false
|
|
15
|
+
matrix:
|
|
16
|
+
python-version: ["3.10", "3.11", "3.12"]
|
|
17
|
+
|
|
18
|
+
steps:
|
|
19
|
+
- name: Checkout
|
|
20
|
+
uses: actions/checkout@v5
|
|
21
|
+
|
|
22
|
+
- name: Setup Python
|
|
23
|
+
uses: actions/setup-python@v5
|
|
24
|
+
with:
|
|
25
|
+
python-version: ${{ matrix.python-version }}
|
|
26
|
+
|
|
27
|
+
- name: Install uv
|
|
28
|
+
uses: astral-sh/setup-uv@v8.2.0
|
|
29
|
+
|
|
30
|
+
- name: Install package and test dependencies
|
|
31
|
+
run: |
|
|
32
|
+
uv pip install --system -e . pytest pytest-asyncio pytest-cov ruff python-dotenv
|
|
33
|
+
|
|
34
|
+
- name: Lint
|
|
35
|
+
run: ruff check .
|
|
36
|
+
|
|
37
|
+
- name: Run tests with coverage
|
|
38
|
+
run: pytest -q --cov=src/server --cov-report=term-missing --cov-fail-under=70
|
|
39
|
+
|
|
40
|
+
- name: Bytecode sanity
|
|
41
|
+
run: python -m compileall src
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
name: Release
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches:
|
|
6
|
+
- main
|
|
7
|
+
|
|
8
|
+
permissions:
|
|
9
|
+
contents: write
|
|
10
|
+
|
|
11
|
+
concurrency:
|
|
12
|
+
group: release-${{ github.ref }}
|
|
13
|
+
cancel-in-progress: false
|
|
14
|
+
|
|
15
|
+
jobs:
|
|
16
|
+
publish:
|
|
17
|
+
runs-on: ubuntu-latest
|
|
18
|
+
|
|
19
|
+
steps:
|
|
20
|
+
- name: Checkout
|
|
21
|
+
uses: actions/checkout@v5
|
|
22
|
+
with:
|
|
23
|
+
fetch-depth: 0
|
|
24
|
+
|
|
25
|
+
- name: Setup Python
|
|
26
|
+
uses: actions/setup-python@v5
|
|
27
|
+
with:
|
|
28
|
+
python-version: "3.11"
|
|
29
|
+
|
|
30
|
+
- name: Compute semantic release version from commits
|
|
31
|
+
id: meta
|
|
32
|
+
run: |
|
|
33
|
+
python - <<'PY' >> "$GITHUB_OUTPUT"
|
|
34
|
+
import os
|
|
35
|
+
import pathlib
|
|
36
|
+
import re
|
|
37
|
+
import subprocess
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def run(*args: str) -> str:
|
|
41
|
+
return subprocess.check_output(args, text=True).strip()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def parse_tuple(version: str) -> tuple[int, int, int]:
|
|
45
|
+
match = re.fullmatch(r"(\d+)\.(\d+)\.(\d+)", version)
|
|
46
|
+
if not match:
|
|
47
|
+
raise SystemExit(f"Unsupported version format: {version}")
|
|
48
|
+
return tuple(int(part) for part in match.groups())
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def parse_semver_tag(tag: str) -> tuple[int, int, int] | None:
|
|
52
|
+
match = re.fullmatch(r"v(\d+)\.(\d+)\.(\d+)", tag)
|
|
53
|
+
if not match:
|
|
54
|
+
return None
|
|
55
|
+
return tuple(int(part) for part in match.groups())
|
|
56
|
+
|
|
57
|
+
text = pathlib.Path("pyproject.toml").read_text()
|
|
58
|
+
match = re.search(r'^version\s*=\s*"([^"]+)"', text, re.MULTILINE)
|
|
59
|
+
if not match:
|
|
60
|
+
raise SystemExit("Could not find project version in pyproject.toml")
|
|
61
|
+
pyproject_version = match.group(1)
|
|
62
|
+
|
|
63
|
+
all_tags = run("git", "tag", "--list", "v*.*.*").splitlines()
|
|
64
|
+
parsed_tags = []
|
|
65
|
+
for tag in all_tags:
|
|
66
|
+
parsed = parse_semver_tag(tag)
|
|
67
|
+
if parsed is not None:
|
|
68
|
+
parsed_tags.append((parsed, tag))
|
|
69
|
+
parsed_tags.sort(key=lambda item: item[0])
|
|
70
|
+
|
|
71
|
+
latest_tag = parsed_tags[-1][1] if parsed_tags else ""
|
|
72
|
+
base_version = parsed_tags[-1][0] if parsed_tags else parse_tuple(pyproject_version)
|
|
73
|
+
|
|
74
|
+
log_range = f"{latest_tag}..HEAD" if latest_tag else "HEAD"
|
|
75
|
+
raw = run("git", "log", log_range, "--pretty=%s%n%b<<END>>")
|
|
76
|
+
chunks = [chunk.strip() for chunk in raw.split("<<END>>") if chunk.strip()]
|
|
77
|
+
|
|
78
|
+
# Conventional commits -> version bump mapping:
|
|
79
|
+
# major: *!: or BREAKING CHANGE
|
|
80
|
+
# minor: feat
|
|
81
|
+
# patch: fix, perf, revert
|
|
82
|
+
bump_order = {"patch": 1, "minor": 2, "major": 3}
|
|
83
|
+
bump = None
|
|
84
|
+
|
|
85
|
+
for chunk in chunks:
|
|
86
|
+
lines = chunk.splitlines()
|
|
87
|
+
subject = lines[0].strip() if lines else ""
|
|
88
|
+
body = "\n".join(lines[1:]) if len(lines) > 1 else ""
|
|
89
|
+
|
|
90
|
+
if "BREAKING CHANGE" in chunk or re.match(r"^[a-z]+(?:\([^)]+\))?!:", subject):
|
|
91
|
+
current = "major"
|
|
92
|
+
elif re.match(r"^feat(?:\([^)]+\))?:", subject):
|
|
93
|
+
current = "minor"
|
|
94
|
+
elif re.match(r"^(fix|perf|revert)(?:\([^)]+\))?:", subject):
|
|
95
|
+
current = "patch"
|
|
96
|
+
else:
|
|
97
|
+
current = None
|
|
98
|
+
|
|
99
|
+
if current and (bump is None or bump_order[current] > bump_order[bump]):
|
|
100
|
+
bump = current
|
|
101
|
+
|
|
102
|
+
if bump is None:
|
|
103
|
+
print("release_required=false")
|
|
104
|
+
print("reason=no_semantic_release_commits")
|
|
105
|
+
print(f"previous_tag={latest_tag}")
|
|
106
|
+
raise SystemExit(0)
|
|
107
|
+
|
|
108
|
+
major, minor, patch = base_version
|
|
109
|
+
if bump == "major":
|
|
110
|
+
next_version = f"{major + 1}.0.0"
|
|
111
|
+
elif bump == "minor":
|
|
112
|
+
next_version = f"{major}.{minor + 1}.0"
|
|
113
|
+
else:
|
|
114
|
+
next_version = f"{major}.{minor}.{patch + 1}"
|
|
115
|
+
|
|
116
|
+
print("release_required=true")
|
|
117
|
+
print(f"bump={bump}")
|
|
118
|
+
print(f"previous_tag={latest_tag}")
|
|
119
|
+
print(f"version={next_version}")
|
|
120
|
+
print(f"tag=v{next_version}")
|
|
121
|
+
PY
|
|
122
|
+
|
|
123
|
+
- name: Skip when no releasable semantic commits
|
|
124
|
+
if: steps.meta.outputs.release_required != 'true'
|
|
125
|
+
run: |
|
|
126
|
+
echo "No release created."
|
|
127
|
+
echo "Reason: ${{ steps.meta.outputs.reason }}"
|
|
128
|
+
echo "Only these commit types trigger a release: feat, fix, perf, revert, and BREAKING CHANGE."
|
|
129
|
+
|
|
130
|
+
- name: Check whether computed tag already exists
|
|
131
|
+
id: tag_check
|
|
132
|
+
if: steps.meta.outputs.release_required == 'true'
|
|
133
|
+
run: |
|
|
134
|
+
if git show-ref --tags --verify --quiet "refs/tags/${{ steps.meta.outputs.tag }}"; then
|
|
135
|
+
echo "exists=true" >> "$GITHUB_OUTPUT"
|
|
136
|
+
else
|
|
137
|
+
echo "exists=false" >> "$GITHUB_OUTPUT"
|
|
138
|
+
fi
|
|
139
|
+
|
|
140
|
+
- name: Skip publish when release tag exists
|
|
141
|
+
if: steps.meta.outputs.release_required == 'true' && steps.tag_check.outputs.exists == 'true'
|
|
142
|
+
run: echo "Tag ${{ steps.meta.outputs.tag }} already exists. Skipping publish."
|
|
143
|
+
|
|
144
|
+
- name: Install uv
|
|
145
|
+
if: steps.meta.outputs.release_required == 'true' && steps.tag_check.outputs.exists != 'true'
|
|
146
|
+
uses: astral-sh/setup-uv@v8.2.0
|
|
147
|
+
|
|
148
|
+
- name: Install test and build dependencies
|
|
149
|
+
if: steps.meta.outputs.release_required == 'true' && steps.tag_check.outputs.exists != 'true'
|
|
150
|
+
run: |
|
|
151
|
+
uv pip install --system -e . pytest pytest-asyncio ruff
|
|
152
|
+
|
|
153
|
+
- name: Lint
|
|
154
|
+
if: steps.meta.outputs.release_required == 'true' && steps.tag_check.outputs.exists != 'true'
|
|
155
|
+
run: ruff check .
|
|
156
|
+
|
|
157
|
+
- name: Unit tests
|
|
158
|
+
if: steps.meta.outputs.release_required == 'true' && steps.tag_check.outputs.exists != 'true'
|
|
159
|
+
run: pytest -q
|
|
160
|
+
|
|
161
|
+
- name: Write computed version to pyproject.toml
|
|
162
|
+
if: steps.meta.outputs.release_required == 'true' && steps.tag_check.outputs.exists != 'true'
|
|
163
|
+
run: |
|
|
164
|
+
python - <<'PY'
|
|
165
|
+
import pathlib
|
|
166
|
+
import re
|
|
167
|
+
|
|
168
|
+
version = "${{ steps.meta.outputs.version }}"
|
|
169
|
+
path = pathlib.Path("pyproject.toml")
|
|
170
|
+
text = path.read_text()
|
|
171
|
+
updated, count = re.subn(
|
|
172
|
+
r'^version\s*=\s*"[^"]+"',
|
|
173
|
+
f'version = "{version}"',
|
|
174
|
+
text,
|
|
175
|
+
count=1,
|
|
176
|
+
flags=re.MULTILINE,
|
|
177
|
+
)
|
|
178
|
+
if count != 1:
|
|
179
|
+
raise SystemExit("Failed to update version in pyproject.toml")
|
|
180
|
+
path.write_text(updated)
|
|
181
|
+
print(f"Updated pyproject version to {version}")
|
|
182
|
+
PY
|
|
183
|
+
|
|
184
|
+
- name: Build distribution artifacts
|
|
185
|
+
if: steps.meta.outputs.release_required == 'true' && steps.tag_check.outputs.exists != 'true'
|
|
186
|
+
run: uv build
|
|
187
|
+
|
|
188
|
+
- name: Publish package to PyPI
|
|
189
|
+
if: steps.meta.outputs.release_required == 'true' && steps.tag_check.outputs.exists != 'true'
|
|
190
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
191
|
+
with:
|
|
192
|
+
password: ${{ secrets.PYPI_API_TOKEN }}
|
|
193
|
+
|
|
194
|
+
- name: Create GitHub release
|
|
195
|
+
if: steps.meta.outputs.release_required == 'true' && steps.tag_check.outputs.exists != 'true'
|
|
196
|
+
uses: softprops/action-gh-release@v3
|
|
197
|
+
with:
|
|
198
|
+
tag_name: ${{ steps.meta.outputs.tag }}
|
|
199
|
+
target_commitish: ${{ github.sha }}
|
|
200
|
+
name: ${{ steps.meta.outputs.tag }}
|
|
201
|
+
generate_release_notes: true
|
|
202
|
+
files: dist/*
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*$py.class
|
|
5
|
+
*.so
|
|
6
|
+
.Python
|
|
7
|
+
build/
|
|
8
|
+
develop-eggs/
|
|
9
|
+
dist/
|
|
10
|
+
downloads/
|
|
11
|
+
eggs/
|
|
12
|
+
.eggs/
|
|
13
|
+
lib/
|
|
14
|
+
lib64/
|
|
15
|
+
parts/
|
|
16
|
+
sdist/
|
|
17
|
+
var/
|
|
18
|
+
wheels/
|
|
19
|
+
*.egg-info/
|
|
20
|
+
.installed.cfg
|
|
21
|
+
*.egg
|
|
22
|
+
|
|
23
|
+
# Virtual Environment
|
|
24
|
+
.venv/
|
|
25
|
+
venv/
|
|
26
|
+
ENV/
|
|
27
|
+
|
|
28
|
+
# Environment variables
|
|
29
|
+
.env
|
|
30
|
+
.env.local
|
|
31
|
+
.env.development.local
|
|
32
|
+
.env.test.local
|
|
33
|
+
.env.production.local
|
|
34
|
+
|
|
35
|
+
# IDE specific files
|
|
36
|
+
.idea/
|
|
37
|
+
.vscode/
|
|
38
|
+
*.swp
|
|
39
|
+
*.swo
|
|
40
|
+
|
|
41
|
+
# OS specific files
|
|
42
|
+
.DS_Store
|
|
43
|
+
Thumbs.db
|
|
44
|
+
|
|
45
|
+
# Project specific
|
|
46
|
+
.cursor/
|
|
47
|
+
uv.lock
|
|
48
|
+
API_DOCS.md
|
|
49
|
+
progress
|
|
50
|
+
|
|
51
|
+
# Test artifacts
|
|
52
|
+
.coverage
|
|
53
|
+
htmlcov/
|
|
54
|
+
.pytest_cache/
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
3.10
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: keep-agent-mem
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Agent Memory via Google Keep MCP
|
|
5
|
+
Project-URL: Homepage, https://github.com/anand-92/keep-agent-mem
|
|
6
|
+
Project-URL: Repository, https://github.com/anand-92/keep-agent-mem
|
|
7
|
+
Author-email: Nik Anand <mntechsurvey@gmail.com>
|
|
8
|
+
License: MIT
|
|
9
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
10
|
+
Classifier: Environment :: Console
|
|
11
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Topic :: Utilities
|
|
16
|
+
Requires-Python: >=3.10
|
|
17
|
+
Requires-Dist: fastmcp==3.4.2
|
|
18
|
+
Requires-Dist: gkeepapi>=0.16.0
|
|
19
|
+
Requires-Dist: python-dotenv>=1.2.2
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
22
|
+
# keep-agent-mem
|
|
23
|
+
|
|
24
|
+
MCP server for Google Keep that serves as cross-device memory for your agents.
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
## How to use
|
|
28
|
+
|
|
29
|
+
1. Add the MCP server to your MCP servers:
|
|
30
|
+
|
|
31
|
+
### `config.toml` clients (Claude, Droid, Cursor)
|
|
32
|
+
|
|
33
|
+
```json
|
|
34
|
+
"mcpServers": {
|
|
35
|
+
"keep-agent-mem": {
|
|
36
|
+
"command": "uvx",
|
|
37
|
+
"args": [
|
|
38
|
+
"keep-agent-mem"
|
|
39
|
+
],
|
|
40
|
+
"env": {
|
|
41
|
+
"GOOGLE_EMAIL": "Your Google Email",
|
|
42
|
+
"GOOGLE_MASTER_TOKEN": "Your Google Master Token - see README.md"
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
### `config.toml` clients (Codex, Goose, etc.)
|
|
49
|
+
|
|
50
|
+
```toml
|
|
51
|
+
[mcp_servers.keep_agent_mem]
|
|
52
|
+
command = "uvx"
|
|
53
|
+
args = ["keep-agent-mem"]
|
|
54
|
+
|
|
55
|
+
[mcp_servers.keep_agent_mem.env]
|
|
56
|
+
GOOGLE_EMAIL = "you@example.com"
|
|
57
|
+
GOOGLE_MASTER_TOKEN = "your-master-token"
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
2. Add your credentials:
|
|
61
|
+
* `GOOGLE_EMAIL`: Your Google account email address
|
|
62
|
+
* `GOOGLE_MASTER_TOKEN`: Your Google account master token
|
|
63
|
+
|
|
64
|
+
Check https://gkeepapi.readthedocs.io/en/latest/#obtaining-a-master-token and https://github.com/simon-weber/gpsoauth?tab=readme-ov-file#alternative-flow for more information.
|
|
65
|
+
|
|
66
|
+
## Tools
|
|
67
|
+
|
|
68
|
+
### Query and read tools
|
|
69
|
+
* `find`: Search notes with optional filters for labels, colors, pinned, archived, and trashed
|
|
70
|
+
* `get`: Get a single note by ID
|
|
71
|
+
|
|
72
|
+
### Creation, update, and deletion tools
|
|
73
|
+
* `create`: Create a new note with a title, text, and an associated label
|
|
74
|
+
* `update`: Update a note's title and text
|
|
75
|
+
* `delete`: Delete a note by ID
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
## Troubleshooting
|
|
81
|
+
|
|
82
|
+
* If you get "DeviceManagementRequiredOrSyncDisabled" check https://admin.google.com/ac/devices/settings/general and turn "Turn off mobile management (Unmanaged)"
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# keep-agent-mem
|
|
2
|
+
|
|
3
|
+
MCP server for Google Keep that serves as cross-device memory for your agents.
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
## How to use
|
|
7
|
+
|
|
8
|
+
1. Add the MCP server to your MCP servers:
|
|
9
|
+
|
|
10
|
+
### `config.toml` clients (Claude, Droid, Cursor)
|
|
11
|
+
|
|
12
|
+
```json
|
|
13
|
+
"mcpServers": {
|
|
14
|
+
"keep-agent-mem": {
|
|
15
|
+
"command": "uvx",
|
|
16
|
+
"args": [
|
|
17
|
+
"keep-agent-mem"
|
|
18
|
+
],
|
|
19
|
+
"env": {
|
|
20
|
+
"GOOGLE_EMAIL": "Your Google Email",
|
|
21
|
+
"GOOGLE_MASTER_TOKEN": "Your Google Master Token - see README.md"
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
### `config.toml` clients (Codex, Goose, etc.)
|
|
28
|
+
|
|
29
|
+
```toml
|
|
30
|
+
[mcp_servers.keep_agent_mem]
|
|
31
|
+
command = "uvx"
|
|
32
|
+
args = ["keep-agent-mem"]
|
|
33
|
+
|
|
34
|
+
[mcp_servers.keep_agent_mem.env]
|
|
35
|
+
GOOGLE_EMAIL = "you@example.com"
|
|
36
|
+
GOOGLE_MASTER_TOKEN = "your-master-token"
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
2. Add your credentials:
|
|
40
|
+
* `GOOGLE_EMAIL`: Your Google account email address
|
|
41
|
+
* `GOOGLE_MASTER_TOKEN`: Your Google account master token
|
|
42
|
+
|
|
43
|
+
Check https://gkeepapi.readthedocs.io/en/latest/#obtaining-a-master-token and https://github.com/simon-weber/gpsoauth?tab=readme-ov-file#alternative-flow for more information.
|
|
44
|
+
|
|
45
|
+
## Tools
|
|
46
|
+
|
|
47
|
+
### Query and read tools
|
|
48
|
+
* `find`: Search notes with optional filters for labels, colors, pinned, archived, and trashed
|
|
49
|
+
* `get`: Get a single note by ID
|
|
50
|
+
|
|
51
|
+
### Creation, update, and deletion tools
|
|
52
|
+
* `create`: Create a new note with a title, text, and an associated label
|
|
53
|
+
* `update`: Update a note's title and text
|
|
54
|
+
* `delete`: Delete a note by ID
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
## Troubleshooting
|
|
60
|
+
|
|
61
|
+
* If you get "DeviceManagementRequiredOrSyncDisabled" check https://admin.google.com/ac/devices/settings/general and turn "Turn off mobile management (Unmanaged)"
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "keep-agent-mem"
|
|
3
|
+
version = "1.0.0"
|
|
4
|
+
description = "Agent Memory via Google Keep MCP"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.10"
|
|
7
|
+
dependencies = [
|
|
8
|
+
"fastmcp==3.4.2",
|
|
9
|
+
"gkeepapi>=0.16.0",
|
|
10
|
+
"python-dotenv>=1.2.2",
|
|
11
|
+
]
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "Nik Anand", email = "mntechsurvey@gmail.com" }
|
|
14
|
+
]
|
|
15
|
+
license = { text = "MIT" }
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 5 - Production/Stable",
|
|
18
|
+
"Environment :: Console",
|
|
19
|
+
"Intended Audience :: End Users/Desktop",
|
|
20
|
+
"License :: OSI Approved :: MIT License",
|
|
21
|
+
"Programming Language :: Python :: 3",
|
|
22
|
+
"Programming Language :: Python :: 3.10",
|
|
23
|
+
"Topic :: Utilities",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
[project.urls]
|
|
27
|
+
Homepage = "https://github.com/anand-92/keep-agent-mem"
|
|
28
|
+
Repository = "https://github.com/anand-92/keep-agent-mem"
|
|
29
|
+
|
|
30
|
+
[project.scripts]
|
|
31
|
+
keep-agent-mem = "server.cli:main"
|
|
32
|
+
|
|
33
|
+
[build-system]
|
|
34
|
+
requires = ["hatchling >= 1.26"]
|
|
35
|
+
build-backend = "hatchling.build"
|
|
36
|
+
|
|
37
|
+
[tool.hatch.build.targets.wheel]
|
|
38
|
+
packages = ["src/server"]
|
|
39
|
+
|
|
40
|
+
[dependency-groups]
|
|
41
|
+
dev = [
|
|
42
|
+
"pytest>=9.0.3",
|
|
43
|
+
"pytest-asyncio>=1.4.0",
|
|
44
|
+
"pytest-cov>=7.1.0",
|
|
45
|
+
"ruff>=0.15.16",
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
[tool.pytest.ini_options]
|
|
49
|
+
asyncio_mode = "auto"
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Basic real-account smoke test for keep-agent-mem server logic.
|
|
2
|
+
|
|
3
|
+
Usage:
|
|
4
|
+
GOOGLE_EMAIL=... GOOGLE_MASTER_TOKEN=... python scripts/smoke_test.py
|
|
5
|
+
|
|
6
|
+
This script performs a lifecycle against Google Keep:
|
|
7
|
+
- create note
|
|
8
|
+
- get note
|
|
9
|
+
- update note
|
|
10
|
+
- find note
|
|
11
|
+
|
|
12
|
+
It is intended for manual verification, not CI.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import sys
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
|
21
|
+
|
|
22
|
+
from server import cli
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def main() -> None:
|
|
26
|
+
if not os.getenv("GOOGLE_EMAIL") or not os.getenv("GOOGLE_MASTER_TOKEN"):
|
|
27
|
+
raise SystemExit("Set GOOGLE_EMAIL and GOOGLE_MASTER_TOKEN before running smoke test")
|
|
28
|
+
|
|
29
|
+
# --- Note lifecycle ---
|
|
30
|
+
print("Creating note...")
|
|
31
|
+
created = json.loads(cli.create(label="keep-agent-mem", title="keep-agent-mem smoke", text="hello"))
|
|
32
|
+
note_id = created["id"]
|
|
33
|
+
print("Created:", note_id)
|
|
34
|
+
|
|
35
|
+
print("Getting note...")
|
|
36
|
+
fetched = json.loads(cli.get(note_id))
|
|
37
|
+
assert fetched["id"] == note_id
|
|
38
|
+
|
|
39
|
+
print("Updating note...")
|
|
40
|
+
updated = json.loads(cli.update(note_id, title="keep-agent-mem smoke updated", text="world"))
|
|
41
|
+
assert updated["title"] == "keep-agent-mem smoke updated"
|
|
42
|
+
|
|
43
|
+
# --- find() ---
|
|
44
|
+
print("Testing find()...")
|
|
45
|
+
results = json.loads(cli.find(query="keep-agent-mem smoke updated"))
|
|
46
|
+
assert isinstance(results, list)
|
|
47
|
+
|
|
48
|
+
print("Smoke test finished successfully")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
if __name__ == "__main__":
|
|
52
|
+
main()
|
|
File without changes
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import gkeepapi
|
|
2
|
+
from fastmcp import FastMCP
|
|
3
|
+
|
|
4
|
+
from .keep_api import get_client, serialize_note
|
|
5
|
+
|
|
6
|
+
mcp = FastMCP("keep")
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _get_note_or_raise(note_id: str):
|
|
10
|
+
keep = get_client()
|
|
11
|
+
note = keep.get(note_id)
|
|
12
|
+
if not note:
|
|
13
|
+
raise ValueError(f"Note with ID {note_id} not found")
|
|
14
|
+
return keep, note
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _normalize_colors(colors: list[str] | None):
|
|
18
|
+
if colors is None:
|
|
19
|
+
return None
|
|
20
|
+
|
|
21
|
+
normalized_colors = []
|
|
22
|
+
for color in colors:
|
|
23
|
+
try:
|
|
24
|
+
normalized_colors.append(gkeepapi.node.ColorValue(color))
|
|
25
|
+
except ValueError as exc:
|
|
26
|
+
raise ValueError(f"Invalid color '{color}'") from exc
|
|
27
|
+
|
|
28
|
+
return normalized_colors
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@mcp.tool()
|
|
32
|
+
def find(
|
|
33
|
+
query: str = "",
|
|
34
|
+
labels: list[str] | None = None,
|
|
35
|
+
colors: list[str] | None = None,
|
|
36
|
+
pinned: bool | None = None,
|
|
37
|
+
archived: bool | None = False,
|
|
38
|
+
trashed: bool = False,
|
|
39
|
+
) -> list[dict]:
|
|
40
|
+
"""Find notes using text and optional filters.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
query: The search term or query string to search for in notes.
|
|
44
|
+
labels: A list of label IDs to filter the notes by.
|
|
45
|
+
colors: A list of ColorValue strings to filter by (e.g. DEFAULT, RED, CERULEAN).
|
|
46
|
+
pinned: Filter notes by pinned status. True for pinned, False for unpinned, None for both.
|
|
47
|
+
archived: Filter notes by archived status. Defaults to False.
|
|
48
|
+
trashed: Filter notes by trashed status. Defaults to False.
|
|
49
|
+
"""
|
|
50
|
+
keep = get_client()
|
|
51
|
+
normalized_colors = _normalize_colors(colors)
|
|
52
|
+
notes = keep.find(
|
|
53
|
+
query=query,
|
|
54
|
+
labels=labels,
|
|
55
|
+
colors=normalized_colors,
|
|
56
|
+
pinned=pinned,
|
|
57
|
+
archived=archived,
|
|
58
|
+
trashed=trashed,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
notes_data = [serialize_note(note) for note in notes]
|
|
62
|
+
return notes_data
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@mcp.tool()
|
|
66
|
+
def get(note_id: str) -> dict:
|
|
67
|
+
"""Get a note by ID.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
note_id: The ID of the note to retrieve.
|
|
71
|
+
"""
|
|
72
|
+
_, note = _get_note_or_raise(note_id)
|
|
73
|
+
return serialize_note(note)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@mcp.tool()
|
|
77
|
+
def create(label: str, title: str | None = None, text: str | None = None) -> dict:
|
|
78
|
+
"""Create a new note with a title, text, and an associated label.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
label: The label to apply to the note. If the user does not explicitly specify a label, the client (e.g. LLM agent) MUST use the name of the current project or repository as the label.
|
|
82
|
+
title: The title of the note.
|
|
83
|
+
text: The text content of the note.
|
|
84
|
+
"""
|
|
85
|
+
keep = get_client()
|
|
86
|
+
note = keep.createNote(title=title, text=text)
|
|
87
|
+
|
|
88
|
+
keep_label = keep.findLabel(label)
|
|
89
|
+
if not keep_label:
|
|
90
|
+
keep_label = keep.createLabel(label)
|
|
91
|
+
|
|
92
|
+
note.labels.add(keep_label)
|
|
93
|
+
keep.sync()
|
|
94
|
+
|
|
95
|
+
return serialize_note(note)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@mcp.tool()
|
|
99
|
+
def update(note_id: str, title: str | None = None, text: str | None = None) -> dict:
|
|
100
|
+
"""Update a note's properties.
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
note_id: The ID of the note to update.
|
|
104
|
+
title: The new title for the note, if specified.
|
|
105
|
+
text: The new body text for the note, if specified.
|
|
106
|
+
"""
|
|
107
|
+
keep, note = _get_note_or_raise(note_id)
|
|
108
|
+
|
|
109
|
+
if title is not None:
|
|
110
|
+
note.title = title
|
|
111
|
+
if text is not None:
|
|
112
|
+
note.text = text
|
|
113
|
+
|
|
114
|
+
keep.sync()
|
|
115
|
+
return serialize_note(note)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@mcp.tool()
|
|
119
|
+
def delete(note_id: str) -> dict:
|
|
120
|
+
"""Delete a note by ID.
|
|
121
|
+
|
|
122
|
+
Args:
|
|
123
|
+
note_id: The ID of the note to delete.
|
|
124
|
+
"""
|
|
125
|
+
keep, note = _get_note_or_raise(note_id)
|
|
126
|
+
note.delete()
|
|
127
|
+
keep.sync()
|
|
128
|
+
return {"message": f"Note {note_id} marked for deletion"}
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def main():
|
|
132
|
+
mcp.run(transport="stdio")
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
if __name__ == "__main__":
|
|
136
|
+
main()
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import gkeepapi
|
|
2
|
+
import os
|
|
3
|
+
import requests
|
|
4
|
+
from dotenv import load_dotenv
|
|
5
|
+
|
|
6
|
+
_keep_client = None
|
|
7
|
+
|
|
8
|
+
def get_client():
|
|
9
|
+
"""
|
|
10
|
+
Get or initialize the Google Keep client.
|
|
11
|
+
This ensures we only authenticate once and reuse the client.
|
|
12
|
+
|
|
13
|
+
Returns:
|
|
14
|
+
gkeepapi.Keep: Authenticated Keep client
|
|
15
|
+
"""
|
|
16
|
+
global _keep_client
|
|
17
|
+
|
|
18
|
+
if _keep_client is not None:
|
|
19
|
+
return _keep_client
|
|
20
|
+
|
|
21
|
+
# Load environment variables
|
|
22
|
+
load_dotenv()
|
|
23
|
+
|
|
24
|
+
# Get credentials from environment variables
|
|
25
|
+
email = os.getenv('GOOGLE_EMAIL')
|
|
26
|
+
master_token = os.getenv('GOOGLE_MASTER_TOKEN')
|
|
27
|
+
|
|
28
|
+
if not email or not master_token:
|
|
29
|
+
raise ValueError("Missing Google Keep credentials. Please set GOOGLE_EMAIL and GOOGLE_MASTER_TOKEN environment variables.")
|
|
30
|
+
|
|
31
|
+
# Initialize the Keep API
|
|
32
|
+
keep = gkeepapi.Keep()
|
|
33
|
+
|
|
34
|
+
# Authenticate
|
|
35
|
+
try:
|
|
36
|
+
keep.authenticate(email, master_token)
|
|
37
|
+
except requests.exceptions.JSONDecodeError as exc:
|
|
38
|
+
raise RuntimeError(
|
|
39
|
+
"Google Keep API returned a non-JSON response during authentication. "
|
|
40
|
+
"This usually means the unofficial Keep API (notes/v1) is inaccessible "
|
|
41
|
+
"from this environment (HTTP 403/4xx). "
|
|
42
|
+
"Check that your GOOGLE_MASTER_TOKEN is valid and that the Keep API "
|
|
43
|
+
"is reachable from this network."
|
|
44
|
+
) from exc
|
|
45
|
+
except gkeepapi.exception.LoginException as exc:
|
|
46
|
+
raise RuntimeError(
|
|
47
|
+
f"Google Keep login failed: {exc}. "
|
|
48
|
+
"Verify that GOOGLE_EMAIL and GOOGLE_MASTER_TOKEN are correct."
|
|
49
|
+
) from exc
|
|
50
|
+
|
|
51
|
+
# Store the client for reuse
|
|
52
|
+
_keep_client = keep
|
|
53
|
+
|
|
54
|
+
return keep
|
|
55
|
+
|
|
56
|
+
def serialize_label(label):
|
|
57
|
+
return {'id': label.id, 'name': label.name}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def serialize_list_item(item):
|
|
61
|
+
return {
|
|
62
|
+
'id': item.id,
|
|
63
|
+
'text': item.text,
|
|
64
|
+
'checked': item.checked,
|
|
65
|
+
'parent_item_id': item.parent_item.id if item.parent_item else None,
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def serialize_note(note):
|
|
70
|
+
"""
|
|
71
|
+
Serialize a Google Keep note into a dictionary.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
note: A Google Keep note object
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
dict: A dictionary containing the note's id, title, text, pinned status, color and labels
|
|
78
|
+
"""
|
|
79
|
+
payload = {
|
|
80
|
+
'id': note.id,
|
|
81
|
+
'title': note.title,
|
|
82
|
+
'text': note.text,
|
|
83
|
+
'type': note.type.value,
|
|
84
|
+
'pinned': note.pinned,
|
|
85
|
+
'archived': note.archived,
|
|
86
|
+
'trashed': note.trashed,
|
|
87
|
+
'color': note.color.value if note.color else None,
|
|
88
|
+
'labels': [serialize_label(label) for label in note.labels.all()],
|
|
89
|
+
'collaborators': list(note.collaborators.all()),
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if hasattr(note, 'items'):
|
|
93
|
+
payload['items'] = [serialize_list_item(item) for item in note.items]
|
|
94
|
+
|
|
95
|
+
payload['media'] = [
|
|
96
|
+
{
|
|
97
|
+
'blob_id': blob.id,
|
|
98
|
+
'type': blob.blob.type.value if blob.blob and blob.blob.type else None,
|
|
99
|
+
}
|
|
100
|
+
for blob in note.blobs
|
|
101
|
+
]
|
|
102
|
+
|
|
103
|
+
return payload
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
from fastmcp.client import Client
|
|
3
|
+
|
|
4
|
+
from server import cli
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class DummyLabel:
|
|
8
|
+
def __init__(self, label_id="l1", name="keep-agent-mem"):
|
|
9
|
+
self.id = label_id
|
|
10
|
+
self.name = name
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class DummyLabels:
|
|
14
|
+
def __init__(self):
|
|
15
|
+
self._labels = []
|
|
16
|
+
|
|
17
|
+
def add(self, label):
|
|
18
|
+
self._labels.append(label)
|
|
19
|
+
|
|
20
|
+
def remove(self, label):
|
|
21
|
+
self._labels = [existing for existing in self._labels if existing.id != label.id]
|
|
22
|
+
|
|
23
|
+
def all(self):
|
|
24
|
+
return self._labels
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class DummyCollaborators:
|
|
28
|
+
def __init__(self):
|
|
29
|
+
self._emails = []
|
|
30
|
+
|
|
31
|
+
def all(self):
|
|
32
|
+
return list(self._emails)
|
|
33
|
+
|
|
34
|
+
def add(self, email):
|
|
35
|
+
self._emails.append(email)
|
|
36
|
+
|
|
37
|
+
def remove(self, email):
|
|
38
|
+
self._emails = [value for value in self._emails if value != email]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class DummyBlobType:
|
|
42
|
+
def __init__(self, value="IMAGE"):
|
|
43
|
+
self.value = value
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class DummyBlobInner:
|
|
47
|
+
def __init__(self):
|
|
48
|
+
self.type = DummyBlobType("IMAGE")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class DummyBlob:
|
|
52
|
+
def __init__(self, blob_id="b1"):
|
|
53
|
+
self.id = blob_id
|
|
54
|
+
self.blob = DummyBlobInner()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class DummyNote:
|
|
58
|
+
def __init__(self, note_id="n1"):
|
|
59
|
+
self.id = note_id
|
|
60
|
+
self.title = "title"
|
|
61
|
+
self.text = "text"
|
|
62
|
+
self.pinned = False
|
|
63
|
+
self.archived = False
|
|
64
|
+
self.trashed = False
|
|
65
|
+
self.type = type("T", (), {"value": "NOTE"})()
|
|
66
|
+
self.color = type("C", (), {"value": "white"})()
|
|
67
|
+
self.labels = DummyLabels()
|
|
68
|
+
self.collaborators = DummyCollaborators()
|
|
69
|
+
self.blobs = [DummyBlob()]
|
|
70
|
+
self.deleted = False
|
|
71
|
+
|
|
72
|
+
def delete(self):
|
|
73
|
+
self.deleted = True
|
|
74
|
+
|
|
75
|
+
def trash(self):
|
|
76
|
+
self.trashed = True
|
|
77
|
+
|
|
78
|
+
def untrash(self):
|
|
79
|
+
self.trashed = False
|
|
80
|
+
|
|
81
|
+
def undelete(self):
|
|
82
|
+
self.deleted = False
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class DummyKeep:
|
|
86
|
+
def __init__(self):
|
|
87
|
+
self.notes = {}
|
|
88
|
+
self._labels = {"l1": DummyLabel("l1", "keep-agent-mem")}
|
|
89
|
+
self.sync_calls = 0
|
|
90
|
+
|
|
91
|
+
def sync(self):
|
|
92
|
+
self.sync_calls += 1
|
|
93
|
+
|
|
94
|
+
def find(self, **kwargs):
|
|
95
|
+
self.last_find_kwargs = kwargs
|
|
96
|
+
return list(self.notes.values())
|
|
97
|
+
|
|
98
|
+
def get(self, note_id):
|
|
99
|
+
return self.notes.get(note_id)
|
|
100
|
+
|
|
101
|
+
def createNote(self, title=None, text=None):
|
|
102
|
+
note = DummyNote("created")
|
|
103
|
+
note.title = title
|
|
104
|
+
note.text = text
|
|
105
|
+
self.notes[note.id] = note
|
|
106
|
+
return note
|
|
107
|
+
|
|
108
|
+
def findLabel(self, name):
|
|
109
|
+
for label in self._labels.values():
|
|
110
|
+
if label.name == name:
|
|
111
|
+
return label
|
|
112
|
+
return None
|
|
113
|
+
|
|
114
|
+
def createLabel(self, name):
|
|
115
|
+
label = DummyLabel("new", name)
|
|
116
|
+
self._labels[label.id] = label
|
|
117
|
+
return label
|
|
118
|
+
|
|
119
|
+
def labels(self):
|
|
120
|
+
return list(self._labels.values())
|
|
121
|
+
|
|
122
|
+
def getLabel(self, label_id):
|
|
123
|
+
return self._labels.get(label_id)
|
|
124
|
+
|
|
125
|
+
def all(self):
|
|
126
|
+
return list(self.notes.values())
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@pytest.fixture()
|
|
130
|
+
def keep(monkeypatch):
|
|
131
|
+
keep = DummyKeep()
|
|
132
|
+
keep.notes["n1"] = DummyNote("n1")
|
|
133
|
+
keep.notes["n1"].labels.add(DummyLabel("l1", "keep-agent-mem"))
|
|
134
|
+
|
|
135
|
+
monkeypatch.setattr(cli, "get_client", lambda: keep)
|
|
136
|
+
monkeypatch.setattr(
|
|
137
|
+
cli.gkeepapi.node,
|
|
138
|
+
"ColorValue",
|
|
139
|
+
lambda color: type("Color", (), {"value": color})(),
|
|
140
|
+
)
|
|
141
|
+
return keep
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def test_find_forwards_filters(keep):
|
|
147
|
+
result = cli.find(
|
|
148
|
+
query="q",
|
|
149
|
+
labels=["l1"],
|
|
150
|
+
colors=["red"],
|
|
151
|
+
pinned=True,
|
|
152
|
+
archived=False,
|
|
153
|
+
trashed=False,
|
|
154
|
+
)
|
|
155
|
+
assert keep.last_find_kwargs["query"] == "q"
|
|
156
|
+
assert keep.last_find_kwargs["labels"] == ["l1"]
|
|
157
|
+
assert [color.value for color in keep.last_find_kwargs["colors"]] == ["red"]
|
|
158
|
+
assert isinstance(result, list)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def test_find_without_colors_passes_none(keep):
|
|
162
|
+
cli.find(query="q")
|
|
163
|
+
assert keep.last_find_kwargs["colors"] is None
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def test_find_invalid_color_raises(keep, monkeypatch):
|
|
167
|
+
def bad_color(_):
|
|
168
|
+
raise ValueError("bad")
|
|
169
|
+
|
|
170
|
+
monkeypatch.setattr(cli.gkeepapi.node, "ColorValue", bad_color)
|
|
171
|
+
with pytest.raises(ValueError, match="Invalid color 'invalid'"):
|
|
172
|
+
cli.find(colors=["invalid"])
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def test_get(keep):
|
|
176
|
+
data = cli.get("n1")
|
|
177
|
+
assert data["id"] == "n1"
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def test_create_labels_and_sync(keep):
|
|
181
|
+
data = cli.create(label="keep-agent-mem", title="t", text="body")
|
|
182
|
+
assert data["id"] == "created"
|
|
183
|
+
assert keep.sync_calls == 1
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def test_create_creates_label_when_missing(keep):
|
|
187
|
+
keep._labels = {}
|
|
188
|
+
data = cli.create(label="my-custom-label", title="t", text="body")
|
|
189
|
+
assert data["labels"][0]["name"] == "my-custom-label"
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def test_update_updates_fields(keep):
|
|
193
|
+
data = cli.update("n1", title="new", text="changed")
|
|
194
|
+
assert data["title"] == "new"
|
|
195
|
+
assert data["text"] == "changed"
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def test_update_not_found_raises(keep):
|
|
199
|
+
with pytest.raises(ValueError, match="not found"):
|
|
200
|
+
cli.update("missing", title="x")
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def test_delete(keep):
|
|
204
|
+
data = cli.delete("n1")
|
|
205
|
+
assert data["message"] == "Note n1 marked for deletion"
|
|
206
|
+
assert keep.notes["n1"].deleted is True
|
|
207
|
+
assert keep.sync_calls == 1
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def test_delete_not_found_raises(keep):
|
|
211
|
+
with pytest.raises(ValueError, match="not found"):
|
|
212
|
+
cli.delete("missing")
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def test_main_runs_stdio_transport(monkeypatch):
|
|
216
|
+
captured = {}
|
|
217
|
+
|
|
218
|
+
def fake_run(*args, **kwargs):
|
|
219
|
+
captured["transport"] = kwargs.get("transport", "stdio")
|
|
220
|
+
|
|
221
|
+
monkeypatch.setattr(cli.mcp, "run", fake_run)
|
|
222
|
+
cli.main()
|
|
223
|
+
assert captured["transport"] == "stdio"
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
@pytest.fixture
|
|
227
|
+
async def main_mcp_client(keep):
|
|
228
|
+
async with Client(cli.mcp) as client:
|
|
229
|
+
yield client
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
async def test_integration_list_tools(main_mcp_client):
|
|
233
|
+
tools = await main_mcp_client.list_tools()
|
|
234
|
+
tool_names = [tool.name for tool in tools]
|
|
235
|
+
assert "find" in tool_names
|
|
236
|
+
assert "get" in tool_names
|
|
237
|
+
assert "create" in tool_names
|
|
238
|
+
assert "update" in tool_names
|
|
239
|
+
assert "delete" in tool_names
|
|
240
|
+
|
|
241
|
+
# Assert schemas are populated
|
|
242
|
+
find_tool = next(t for t in tools if t.name == "find")
|
|
243
|
+
assert "query" in find_tool.inputSchema["properties"]
|
|
244
|
+
assert "labels" in find_tool.inputSchema["properties"]
|
|
245
|
+
assert "colors" in find_tool.inputSchema["properties"]
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
async def test_integration_create_note(main_mcp_client, keep):
|
|
249
|
+
result = await main_mcp_client.call_tool(
|
|
250
|
+
name="create",
|
|
251
|
+
arguments={
|
|
252
|
+
"label": "keep-agent-mem",
|
|
253
|
+
"title": "Integration test note",
|
|
254
|
+
"text": "Hello world from integration test",
|
|
255
|
+
},
|
|
256
|
+
)
|
|
257
|
+
assert result.structured_content is not None
|
|
258
|
+
assert result.structured_content["title"] == "Integration test note"
|
|
259
|
+
assert result.structured_content["text"] == "Hello world from integration test"
|
|
260
|
+
assert keep.sync_calls == 1
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
from types import SimpleNamespace
|
|
2
|
+
|
|
3
|
+
from server.keep_api import serialize_note
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class DummyLabels:
|
|
7
|
+
def __init__(self, labels):
|
|
8
|
+
self._labels = labels
|
|
9
|
+
|
|
10
|
+
def all(self):
|
|
11
|
+
return self._labels
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class DummyCollaborators:
|
|
15
|
+
def __init__(self, emails):
|
|
16
|
+
self._emails = emails
|
|
17
|
+
|
|
18
|
+
def all(self):
|
|
19
|
+
return self._emails
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class DummyBlobType:
|
|
23
|
+
def __init__(self, value):
|
|
24
|
+
self.value = value
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class DummyBlobNode:
|
|
28
|
+
def __init__(self, blob_id, blob_type):
|
|
29
|
+
self.id = blob_id
|
|
30
|
+
self.blob = SimpleNamespace(type=DummyBlobType(blob_type))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class DummyNote:
|
|
34
|
+
def __init__(self):
|
|
35
|
+
self.id = "n1"
|
|
36
|
+
self.title = "title"
|
|
37
|
+
self.text = "text"
|
|
38
|
+
self.type = SimpleNamespace(value="NOTE")
|
|
39
|
+
self.pinned = False
|
|
40
|
+
self.archived = False
|
|
41
|
+
self.trashed = False
|
|
42
|
+
self.color = SimpleNamespace(value="white")
|
|
43
|
+
self.labels = DummyLabels([SimpleNamespace(id="l1", name="keep-agent-mem")])
|
|
44
|
+
self.collaborators = DummyCollaborators(["alice@example.com"])
|
|
45
|
+
self.blobs = [DummyBlobNode("b1", "IMAGE")]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class DummyListNote(DummyNote):
|
|
49
|
+
def __init__(self):
|
|
50
|
+
super().__init__()
|
|
51
|
+
self.items = [
|
|
52
|
+
SimpleNamespace(
|
|
53
|
+
id="i1",
|
|
54
|
+
text="item",
|
|
55
|
+
checked=False,
|
|
56
|
+
parent_item=None,
|
|
57
|
+
)
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_serialize_note_for_note_type():
|
|
62
|
+
data = serialize_note(DummyNote())
|
|
63
|
+
assert data["id"] == "n1"
|
|
64
|
+
assert data["labels"][0]["name"] == "keep-agent-mem"
|
|
65
|
+
assert data["collaborators"] == ["alice@example.com"]
|
|
66
|
+
assert data["media"][0]["type"] == "IMAGE"
|
|
67
|
+
assert "items" not in data
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_serialize_note_for_list_type():
|
|
71
|
+
data = serialize_note(DummyListNote())
|
|
72
|
+
assert data["items"][0]["id"] == "i1"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from server import keep_api
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class DummyKeep:
|
|
5
|
+
def __init__(self):
|
|
6
|
+
self.auth_calls = []
|
|
7
|
+
|
|
8
|
+
def authenticate(self, email, token):
|
|
9
|
+
self.auth_calls.append((email, token))
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def test_get_client_authenticates_and_caches(monkeypatch):
|
|
13
|
+
keep_api._keep_client = None
|
|
14
|
+
created = DummyKeep()
|
|
15
|
+
|
|
16
|
+
monkeypatch.setattr(keep_api, "load_dotenv", lambda: None)
|
|
17
|
+
monkeypatch.setattr(keep_api.os, "getenv", lambda key: {
|
|
18
|
+
"GOOGLE_EMAIL": "user@example.com",
|
|
19
|
+
"GOOGLE_MASTER_TOKEN": "token",
|
|
20
|
+
}.get(key))
|
|
21
|
+
monkeypatch.setattr(keep_api.gkeepapi, "Keep", lambda: created)
|
|
22
|
+
|
|
23
|
+
first = keep_api.get_client()
|
|
24
|
+
second = keep_api.get_client()
|
|
25
|
+
|
|
26
|
+
assert first is created
|
|
27
|
+
assert second is created
|
|
28
|
+
assert created.auth_calls == [("user@example.com", "token")]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_get_client_raises_when_missing_credentials(monkeypatch):
|
|
32
|
+
keep_api._keep_client = None
|
|
33
|
+
monkeypatch.setattr(keep_api, "load_dotenv", lambda: None)
|
|
34
|
+
monkeypatch.setattr(keep_api.os, "getenv", lambda _key: None)
|
|
35
|
+
|
|
36
|
+
try:
|
|
37
|
+
keep_api.get_client()
|
|
38
|
+
except ValueError as exc:
|
|
39
|
+
assert "Missing Google Keep credentials" in str(exc)
|
|
40
|
+
else:
|
|
41
|
+
raise AssertionError("Expected ValueError for missing credentials")
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import runpy
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def test_main_module_calls_cli_main(monkeypatch):
|
|
5
|
+
called = {"value": False}
|
|
6
|
+
|
|
7
|
+
def fake_main():
|
|
8
|
+
called["value"] = True
|
|
9
|
+
|
|
10
|
+
import server.cli
|
|
11
|
+
|
|
12
|
+
monkeypatch.setattr(server.cli, "main", fake_main)
|
|
13
|
+
runpy.run_module("server.__main__", run_name="__main__")
|
|
14
|
+
assert called["value"] is True
|