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.
@@ -0,0 +1,3 @@
1
+ # Google Keep API Credentials
2
+ GOOGLE_EMAIL=your-email@gmail.com
3
+ GOOGLE_MASTER_TOKEN=your-master-token-see-the-readme-on-how-to-get-it
@@ -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,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
@@ -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,4 @@
1
+ import sys
2
+ from pathlib import Path
3
+
4
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
@@ -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