keepsake-mcp 1.0.0

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.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +180 -0
  3. package/build/index.js +629 -0
  4. package/package.json +37 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Nicolas
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,180 @@
1
+ # keepsake-mcp
2
+
3
+ MCP server for [Keepsake](https://keepsake.place) — the personal CRM that helps you nurture your relationships.
4
+
5
+ Connect your AI assistant (Claude, Cursor, or any MCP-compatible client) to your Keepsake data: contacts, interactions, tasks, notes, daily journal, companies, and tags.
6
+
7
+ ## Why
8
+
9
+ Your AI assistant becomes a personal relationship manager. Ask it to:
10
+
11
+ - "Who did I last talk to at Acme Corp?"
12
+ - "Add a note that I ran into Sarah at the conference"
13
+ - "What tasks are overdue?"
14
+ - "Show me everything related to the #house-project tag"
15
+ - "Create a follow-up task for my meeting with John next week"
16
+
17
+ ## Quick start
18
+
19
+ ### 1. Get your API key
20
+
21
+ Sign up at [keepsake.place](https://keepsake.place), then go to **Account > API Keys** to generate one.
22
+
23
+ ### 2. Configure your MCP client
24
+
25
+ #### Claude Desktop
26
+
27
+ Add to `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) or `%APPDATA%\Claude\claude_desktop_config.json` (Windows):
28
+
29
+ ```json
30
+ {
31
+ "mcpServers": {
32
+ "keepsake": {
33
+ "command": "npx",
34
+ "args": ["-y", "keepsake-mcp"],
35
+ "env": {
36
+ "KEEPSAKE_API_KEY": "ksk_YOUR_API_KEY"
37
+ }
38
+ }
39
+ }
40
+ }
41
+ ```
42
+
43
+ #### Claude Code
44
+
45
+ ```bash
46
+ claude mcp add keepsake -- npx -y keepsake-mcp
47
+ ```
48
+
49
+ Then set `KEEPSAKE_API_KEY` in your environment.
50
+
51
+ #### Cursor
52
+
53
+ Add to `.cursor/mcp.json` in your project:
54
+
55
+ ```json
56
+ {
57
+ "mcpServers": {
58
+ "keepsake": {
59
+ "command": "npx",
60
+ "args": ["-y", "keepsake-mcp"],
61
+ "env": {
62
+ "KEEPSAKE_API_KEY": "ksk_YOUR_API_KEY"
63
+ }
64
+ }
65
+ }
66
+ }
67
+ ```
68
+
69
+ ## Available tools (42)
70
+
71
+ ### Contacts
72
+ | Tool | Description |
73
+ |------|-------------|
74
+ | `list_contacts` | List all contacts with pagination and sorting |
75
+ | `get_contact` | Get a contact with recent interactions, tags, and stats |
76
+ | `create_contact` | Create a new contact |
77
+ | `update_contact` | Update contact fields |
78
+ | `delete_contact` | Permanently delete a contact |
79
+ | `search_contacts` | Accent-insensitive search by name, email, company |
80
+ | `get_contact_timeline` | Unified chronological feed of all items for a contact |
81
+
82
+ ### Companies
83
+ | Tool | Description |
84
+ |------|-------------|
85
+ | `list_companies` | List all companies |
86
+ | `get_company` | Get company with linked contacts and tags |
87
+ | `create_company` | Create a new company |
88
+ | `update_company` | Update company fields |
89
+ | `delete_company` | Soft-delete (or permanent delete) a company |
90
+ | `search_companies` | Accent-insensitive company search |
91
+
92
+ ### Entries (Interactions)
93
+ | Tool | Description |
94
+ |------|-------------|
95
+ | `list_entries` | List interactions (calls, emails, meetings, etc.) |
96
+ | `create_entry` | Log a new interaction — supports `#tag#` and `[[tag]]` syntax |
97
+ | `update_entry` | Update an interaction |
98
+ | `delete_entry` | Delete an interaction |
99
+
100
+ ### Tasks
101
+ | Tool | Description |
102
+ |------|-------------|
103
+ | `list_tasks` | List tasks with status/date filters |
104
+ | `create_task` | Create a task — supports `#tag#` and `[[tag]]` syntax |
105
+ | `update_task` | Update task fields |
106
+ | `delete_task` | Delete a task |
107
+ | `complete_task` | Mark as completed (auto-creates next occurrence for recurring tasks) |
108
+ | `uncomplete_task` | Mark as pending again |
109
+ | `snooze_task` | Reschedule to a new date |
110
+ | `get_tasks_today` | Today's tasks: overdue + due today + ASAP |
111
+ | `get_tasks_overdue` | Only overdue tasks |
112
+
113
+ ### Quick Notes
114
+ | Tool | Description |
115
+ |------|-------------|
116
+ | `list_notes` | List notes (filter by pinned/archived) |
117
+ | `create_note` | Create a note — supports `#tag#` and `[[tag]]` syntax |
118
+ | `update_note` | Update note content |
119
+ | `delete_note` | Soft-delete (or permanent) |
120
+ | `pin_note` | Pin to top |
121
+ | `archive_note` | Archive a note |
122
+ | `restore_note` | Restore a deleted/archived note |
123
+
124
+ ### Daily Journal
125
+ | Tool | Description |
126
+ |------|-------------|
127
+ | `list_days` | List journal entries by date range |
128
+ | `get_day` | Get a specific day's journal |
129
+ | `update_day` | Create or update a day's journal (upsert) |
130
+
131
+ ### Tags
132
+ | Tool | Description |
133
+ |------|-------------|
134
+ | `list_tags` | List all tags |
135
+ | `get_tag_items` | Get everything linked to a tag |
136
+ | `link_tag` | Link any entity to a tag |
137
+ | `unlink_tag` | Remove a tag link |
138
+
139
+ ### Utilities
140
+ | Tool | Description |
141
+ |------|-------------|
142
+ | `search` | Global search across all data types |
143
+ | `get_changelog` | Items modified since a timestamp (for sync) |
144
+ | `get_agent_instructions` | Best practices for AI agents |
145
+
146
+ ## Tool annotations
147
+
148
+ All tools include MCP safety annotations:
149
+
150
+ - **Read-only tools** (`list_*`, `get_*`, `search_*`): marked `readOnlyHint: true`
151
+ - **Create tools**: marked `destructiveHint: false`
152
+ - **Update tools**: marked `destructiveHint: false, idempotentHint: true`
153
+ - **Delete tools**: marked `destructiveHint: true, idempotentHint: true`
154
+
155
+ ## Environment variables
156
+
157
+ | Variable | Required | Description |
158
+ |----------|----------|-------------|
159
+ | `KEEPSAKE_API_KEY` | Yes | Your API key (starts with `ksk_`) |
160
+ | `KEEPSAKE_API_URL` | No | Custom API URL (default: `https://app.keepsake.place/api/v1`) |
161
+
162
+ ## Rate limits
163
+
164
+ 60 requests per minute per API key. Rate limit headers are included in responses.
165
+
166
+ ## API documentation
167
+
168
+ Full REST API docs: [keepsake.place/api](https://keepsake.place/en/api)
169
+
170
+ ## Privacy
171
+
172
+ Keepsake MCP server only communicates with the Keepsake API (`app.keepsake.place`). It does not send data to any third-party service. Your data stays between your MCP client and your Keepsake account.
173
+
174
+ All API calls are authenticated with your personal API key and scoped to your account via Row Level Security. No other user's data is accessible.
175
+
176
+ See our privacy policy at [keepsake.place/privacy](https://keepsake.place/en/privacy).
177
+
178
+ ## License
179
+
180
+ MIT
package/build/index.js ADDED
@@ -0,0 +1,629 @@
1
+ #!/usr/bin/env node
2
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import { z } from "zod";
5
+ // ---------------------------------------------------------------------------
6
+ // Configuration
7
+ // ---------------------------------------------------------------------------
8
+ const API_BASE = process.env.KEEPSAKE_API_URL || "https://app.keepsake.place/api/v1";
9
+ const API_KEY = process.env.KEEPSAKE_API_KEY || "";
10
+ if (!API_KEY) {
11
+ console.error("Error: KEEPSAKE_API_KEY environment variable is required.\n" +
12
+ "Generate one at https://app.keepsake.place/account");
13
+ process.exit(1);
14
+ }
15
+ async function fetchApi(path, method = "GET", body) {
16
+ const url = `${API_BASE}${path}`;
17
+ const headers = {
18
+ Authorization: `Bearer ${API_KEY}`,
19
+ "Content-Type": "application/json",
20
+ };
21
+ const init = { method, headers };
22
+ if (body && method !== "GET") {
23
+ init.body = JSON.stringify(body);
24
+ }
25
+ try {
26
+ const res = await fetch(url, init);
27
+ const json = (await res.json());
28
+ return json;
29
+ }
30
+ catch (err) {
31
+ return { error: { code: "NETWORK_ERROR", message: String(err) } };
32
+ }
33
+ }
34
+ /** Format an API result as text content for the MCP response. */
35
+ function toContent(result) {
36
+ if (result.error) {
37
+ const msg = typeof result.error === "string"
38
+ ? result.error
39
+ : result.error.message || JSON.stringify(result.error);
40
+ return { content: [{ type: "text", text: `Error: ${msg}` }] };
41
+ }
42
+ return {
43
+ content: [{ type: "text", text: JSON.stringify(result.data ?? result, null, 2) }],
44
+ };
45
+ }
46
+ /** Build query string from optional params, skipping undefined values. */
47
+ function qs(params) {
48
+ const parts = [];
49
+ for (const [k, v] of Object.entries(params)) {
50
+ if (v !== undefined && v !== null && v !== "") {
51
+ parts.push(`${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`);
52
+ }
53
+ }
54
+ return parts.length ? `?${parts.join("&")}` : "";
55
+ }
56
+ // ---------------------------------------------------------------------------
57
+ // MCP Server
58
+ // ---------------------------------------------------------------------------
59
+ const server = new McpServer({
60
+ name: "keepsake",
61
+ version: "1.0.0",
62
+ });
63
+ // ===========================================================================
64
+ // CONTACTS
65
+ // ===========================================================================
66
+ server.registerTool("list_contacts", {
67
+ description: "List all contacts in the user's Keepsake CRM. Supports pagination, sorting, and optional last_interaction_date enrichment.",
68
+ inputSchema: {
69
+ limit: z.number().int().positive().optional().describe("Max results (default 20)"),
70
+ offset: z.number().int().nonnegative().optional().describe("Pagination offset"),
71
+ sort: z.string().optional().describe("Sort field: last_name, first_name, created_at"),
72
+ order: z.enum(["asc", "desc"]).optional().describe("Sort order"),
73
+ include_last_interaction: z.boolean().optional().describe("Include last_interaction_date for each contact (default: false)"),
74
+ },
75
+ annotations: { title: "List contacts", readOnlyHint: true, openWorldHint: false },
76
+ }, async ({ limit, offset, sort, order, include_last_interaction }) => {
77
+ return toContent(await fetchApi(`/contacts${qs({ limit, offset, sort, order, include_last_interaction })}`));
78
+ });
79
+ server.registerTool("get_contact", {
80
+ description: "Get a single contact by ID, including recent entries (interactions), tags, last_interaction_date, and total_entries count.",
81
+ inputSchema: {
82
+ id: z.string().uuid().describe("Contact UUID"),
83
+ entries_limit: z.number().int().optional().describe("Max entries to return (default 10, -1 for all)"),
84
+ },
85
+ annotations: { title: "Get contact", readOnlyHint: true, openWorldHint: false },
86
+ }, async ({ id, entries_limit }) => {
87
+ return toContent(await fetchApi(`/contacts/${id}${qs({ entries_limit })}`));
88
+ });
89
+ server.registerTool("create_contact", {
90
+ description: "Create a new contact. first_name and last_name are required.",
91
+ inputSchema: {
92
+ first_name: z.string().describe("First name"),
93
+ last_name: z.string().describe("Last name"),
94
+ email: z.string().optional().describe("Email address"),
95
+ phone: z.string().optional().describe("Phone number"),
96
+ company: z.string().optional().describe("Company name"),
97
+ notes: z.string().optional().describe("Notes about the contact"),
98
+ },
99
+ annotations: { title: "Create contact", destructiveHint: false, idempotentHint: false, openWorldHint: false },
100
+ }, async (params) => {
101
+ return toContent(await fetchApi("/contacts", "POST", params));
102
+ });
103
+ server.registerTool("update_contact", {
104
+ description: "Update an existing contact. Only send the fields you want to change.",
105
+ inputSchema: {
106
+ id: z.string().uuid().describe("Contact UUID"),
107
+ first_name: z.string().optional().describe("First name"),
108
+ last_name: z.string().optional().describe("Last name"),
109
+ email: z.string().optional().describe("Email address"),
110
+ phone: z.string().optional().describe("Phone number"),
111
+ company: z.string().optional().describe("Company name"),
112
+ notes: z.string().optional().describe("Notes about the contact"),
113
+ },
114
+ annotations: { title: "Update contact", destructiveHint: false, idempotentHint: true, openWorldHint: false },
115
+ }, async ({ id, ...body }) => {
116
+ return toContent(await fetchApi(`/contacts/${id}`, "PATCH", body));
117
+ });
118
+ server.registerTool("delete_contact", {
119
+ description: "Permanently delete a contact and all associated data.",
120
+ inputSchema: {
121
+ id: z.string().uuid().describe("Contact UUID"),
122
+ },
123
+ annotations: { title: "Delete contact", destructiveHint: true, idempotentHint: true, openWorldHint: false },
124
+ }, async ({ id }) => {
125
+ return toContent(await fetchApi(`/contacts/${id}`, "DELETE"));
126
+ });
127
+ server.registerTool("search_contacts", {
128
+ description: "Search contacts by name, email, company, etc. Search is accent-insensitive.",
129
+ inputSchema: {
130
+ q: z.string().describe("Search query"),
131
+ },
132
+ annotations: { title: "Search contacts", readOnlyHint: true, openWorldHint: false },
133
+ }, async ({ q }) => {
134
+ return toContent(await fetchApi(`/contacts/search${qs({ q })}`));
135
+ });
136
+ // ===========================================================================
137
+ // COMPANIES
138
+ // ===========================================================================
139
+ server.registerTool("list_companies", {
140
+ description: "List all companies/organizations in the user's Keepsake CRM. Supports pagination and sorting.",
141
+ inputSchema: {
142
+ limit: z.number().int().positive().optional().describe("Max results (default 20)"),
143
+ offset: z.number().int().nonnegative().optional().describe("Pagination offset"),
144
+ sort: z.string().optional().describe("Sort field: name, created_at, updated_at"),
145
+ order: z.enum(["asc", "desc"]).optional().describe("Sort order"),
146
+ },
147
+ annotations: { title: "List companies", readOnlyHint: true, openWorldHint: false },
148
+ }, async ({ limit, offset, sort, order }) => {
149
+ return toContent(await fetchApi(`/companies${qs({ limit, offset, sort, order })}`));
150
+ });
151
+ server.registerTool("get_company", {
152
+ description: "Get a single company by ID, including linked contacts (with roles) and tags.",
153
+ inputSchema: {
154
+ id: z.string().uuid().describe("Company UUID"),
155
+ },
156
+ annotations: { title: "Get company", readOnlyHint: true, openWorldHint: false },
157
+ }, async ({ id }) => {
158
+ return toContent(await fetchApi(`/companies/${id}`));
159
+ });
160
+ server.registerTool("create_company", {
161
+ description: "Create a new company/organization. Only 'name' is required.",
162
+ inputSchema: {
163
+ name: z.string().describe("Company name"),
164
+ website: z.string().optional().describe("Website URL"),
165
+ email: z.string().optional().describe("Email address"),
166
+ phone: z.string().optional().describe("Phone number"),
167
+ address: z.string().optional().describe("Address"),
168
+ notes: z.string().optional().describe("Notes about the company"),
169
+ },
170
+ annotations: { title: "Create company", destructiveHint: false, idempotentHint: false, openWorldHint: false },
171
+ }, async (params) => {
172
+ return toContent(await fetchApi("/companies", "POST", params));
173
+ });
174
+ server.registerTool("update_company", {
175
+ description: "Update an existing company. Only send the fields you want to change.",
176
+ inputSchema: {
177
+ id: z.string().uuid().describe("Company UUID"),
178
+ name: z.string().optional().describe("Company name"),
179
+ website: z.string().optional().describe("Website URL"),
180
+ email: z.string().optional().describe("Email address"),
181
+ phone: z.string().optional().describe("Phone number"),
182
+ address: z.string().optional().describe("Address"),
183
+ notes: z.string().optional().describe("Notes about the company"),
184
+ },
185
+ annotations: { title: "Update company", destructiveHint: false, idempotentHint: true, openWorldHint: false },
186
+ }, async ({ id, ...body }) => {
187
+ return toContent(await fetchApi(`/companies/${id}`, "PATCH", body));
188
+ });
189
+ server.registerTool("delete_company", {
190
+ description: "Soft-delete a company. Use permanent=true for hard delete.",
191
+ inputSchema: {
192
+ id: z.string().uuid().describe("Company UUID"),
193
+ permanent: z.boolean().optional().describe("Hard delete (default: false, soft delete)"),
194
+ },
195
+ annotations: { title: "Delete company", destructiveHint: true, idempotentHint: true, openWorldHint: false },
196
+ }, async ({ id, permanent }) => {
197
+ const query = permanent ? "?permanent=true" : "";
198
+ return toContent(await fetchApi(`/companies/${id}${query}`, "DELETE"));
199
+ });
200
+ server.registerTool("search_companies", {
201
+ description: "Search companies by name, email, website, or address. Search is accent-insensitive.",
202
+ inputSchema: {
203
+ q: z.string().describe("Search query"),
204
+ },
205
+ annotations: { title: "Search companies", readOnlyHint: true, openWorldHint: false },
206
+ }, async ({ q }) => {
207
+ return toContent(await fetchApi(`/companies/search${qs({ q })}`));
208
+ });
209
+ // ===========================================================================
210
+ // ENTRIES (Interactions)
211
+ // ===========================================================================
212
+ server.registerTool("list_entries", {
213
+ description: "List interaction entries (calls, emails, meetings, events, etc.). Supports filtering by type, contact, and date range.",
214
+ inputSchema: {
215
+ type: z
216
+ .enum(["call", "email", "meeting", "event", "gift", "letter", "message", "other"])
217
+ .optional()
218
+ .describe("Filter by entry type"),
219
+ contact_id: z.string().uuid().optional().describe("Filter by associated contact ID"),
220
+ from: z.string().optional().describe("Start date (YYYY-MM-DD)"),
221
+ to: z.string().optional().describe("End date (YYYY-MM-DD)"),
222
+ limit: z.number().int().positive().optional().describe("Max results (default 20)"),
223
+ offset: z.number().int().nonnegative().optional().describe("Pagination offset"),
224
+ },
225
+ annotations: { title: "List entries", readOnlyHint: true, openWorldHint: false },
226
+ }, async ({ type, contact_id, from, to, limit, offset }) => {
227
+ return toContent(await fetchApi(`/entries${qs({ type, contact_id, from, to, limit, offset })}`));
228
+ });
229
+ server.registerTool("create_entry", {
230
+ description: "Create a new interaction entry. Content supports #tag# and [[tag]] syntax for automatic tag linking.",
231
+ inputSchema: {
232
+ type: z
233
+ .enum(["call", "email", "meeting", "event", "gift", "letter", "message", "other"])
234
+ .describe("Entry type"),
235
+ date: z.string().describe("Date (YYYY-MM-DD)"),
236
+ content: z.string().optional().describe("Entry content (supports #tag# and [[tag]])"),
237
+ contact_ids: z
238
+ .array(z.string().uuid())
239
+ .optional()
240
+ .describe("Array of contact UUIDs to associate"),
241
+ },
242
+ annotations: { title: "Create entry", destructiveHint: false, idempotentHint: false, openWorldHint: false },
243
+ }, async (params) => {
244
+ return toContent(await fetchApi("/entries", "POST", params));
245
+ });
246
+ server.registerTool("update_entry", {
247
+ description: "Update an existing entry. Only send fields you want to change.",
248
+ inputSchema: {
249
+ id: z.string().uuid().describe("Entry UUID"),
250
+ type: z
251
+ .enum(["call", "email", "meeting", "event", "gift", "letter", "message", "other"])
252
+ .optional()
253
+ .describe("Entry type"),
254
+ date: z.string().optional().describe("Date (YYYY-MM-DD)"),
255
+ content: z.string().optional().describe("Entry content (supports #tag# and [[tag]])"),
256
+ contact_ids: z
257
+ .array(z.string().uuid())
258
+ .optional()
259
+ .describe("Replace associated contacts"),
260
+ },
261
+ annotations: { title: "Update entry", destructiveHint: false, idempotentHint: true, openWorldHint: false },
262
+ }, async ({ id, ...body }) => {
263
+ return toContent(await fetchApi(`/entries/${id}`, "PATCH", body));
264
+ });
265
+ server.registerTool("delete_entry", {
266
+ description: "Delete an interaction entry.",
267
+ inputSchema: {
268
+ id: z.string().uuid().describe("Entry UUID"),
269
+ },
270
+ annotations: { title: "Delete entry", destructiveHint: true, idempotentHint: true, openWorldHint: false },
271
+ }, async ({ id }) => {
272
+ return toContent(await fetchApi(`/entries/${id}`, "DELETE"));
273
+ });
274
+ // ===========================================================================
275
+ // TASKS
276
+ // ===========================================================================
277
+ server.registerTool("list_tasks", {
278
+ description: "List tasks. Filter by status (pending/completed), date_type, or specific date.",
279
+ inputSchema: {
280
+ status: z.enum(["pending", "completed"]).optional().describe("Filter by status"),
281
+ date_type: z
282
+ .enum(["specific", "week", "month", "quarter", "unspecified"])
283
+ .optional()
284
+ .describe("Filter by date type"),
285
+ date: z.string().optional().describe("Filter by specific date (YYYY-MM-DD)"),
286
+ limit: z.number().int().positive().optional().describe("Max results (default 20)"),
287
+ offset: z.number().int().nonnegative().optional().describe("Pagination offset"),
288
+ },
289
+ annotations: { title: "List tasks", readOnlyHint: true, openWorldHint: false },
290
+ }, async ({ status, date_type, date, limit, offset }) => {
291
+ return toContent(await fetchApi(`/tasks${qs({ status, date_type, date, limit, offset })}`));
292
+ });
293
+ server.registerTool("create_task", {
294
+ description: "Create a new task. Title supports #tag# and [[tag]] for automatic tag linking.",
295
+ inputSchema: {
296
+ title: z.string().describe("Task title (supports #tag# and [[tag]])"),
297
+ description: z.string().optional().describe("Task description"),
298
+ date: z.string().optional().describe("Due date (YYYY-MM-DD)"),
299
+ date_type: z
300
+ .enum(["specific", "week", "month", "quarter", "unspecified"])
301
+ .optional()
302
+ .describe("Date type (default: specific)"),
303
+ priority: z.enum(["low", "medium", "high"]).optional().describe("Priority level"),
304
+ recurrence_type: z
305
+ .enum(["daily", "weekly", "monthly", "yearly"])
306
+ .optional()
307
+ .describe("Recurrence pattern"),
308
+ recurrence_interval: z
309
+ .number()
310
+ .int()
311
+ .positive()
312
+ .optional()
313
+ .describe("Recurrence interval (e.g., every N days)"),
314
+ contact_id: z.string().uuid().optional().describe("Associated contact UUID"),
315
+ },
316
+ annotations: { title: "Create task", destructiveHint: false, idempotentHint: false, openWorldHint: false },
317
+ }, async (params) => {
318
+ return toContent(await fetchApi("/tasks", "POST", params));
319
+ });
320
+ server.registerTool("update_task", {
321
+ description: "Update an existing task. Only send fields you want to change.",
322
+ inputSchema: {
323
+ id: z.string().uuid().describe("Task UUID"),
324
+ title: z.string().optional().describe("Task title"),
325
+ description: z.string().optional().describe("Task description"),
326
+ date: z.string().optional().describe("Due date (YYYY-MM-DD)"),
327
+ date_type: z
328
+ .enum(["specific", "week", "month", "quarter", "unspecified"])
329
+ .optional()
330
+ .describe("Date type"),
331
+ priority: z.enum(["low", "medium", "high"]).optional().describe("Priority level"),
332
+ },
333
+ annotations: { title: "Update task", destructiveHint: false, idempotentHint: true, openWorldHint: false },
334
+ }, async ({ id, ...body }) => {
335
+ return toContent(await fetchApi(`/tasks/${id}`, "PATCH", body));
336
+ });
337
+ server.registerTool("delete_task", {
338
+ description: "Delete a task.",
339
+ inputSchema: {
340
+ id: z.string().uuid().describe("Task UUID"),
341
+ },
342
+ annotations: { title: "Delete task", destructiveHint: true, idempotentHint: true, openWorldHint: false },
343
+ }, async ({ id }) => {
344
+ return toContent(await fetchApi(`/tasks/${id}`, "DELETE"));
345
+ });
346
+ server.registerTool("complete_task", {
347
+ description: "Mark a task as completed. If the task is recurring, this automatically creates the next occurrence.",
348
+ inputSchema: {
349
+ id: z.string().uuid().describe("Task UUID"),
350
+ },
351
+ annotations: { title: "Complete task", destructiveHint: false, idempotentHint: true, openWorldHint: false },
352
+ }, async ({ id }) => {
353
+ return toContent(await fetchApi(`/tasks/${id}/complete`, "POST"));
354
+ });
355
+ server.registerTool("uncomplete_task", {
356
+ description: "Mark a completed task as pending again.",
357
+ inputSchema: {
358
+ id: z.string().uuid().describe("Task UUID"),
359
+ },
360
+ annotations: { title: "Uncomplete task", destructiveHint: false, idempotentHint: true, openWorldHint: false },
361
+ }, async ({ id }) => {
362
+ return toContent(await fetchApi(`/tasks/${id}/uncomplete`, "POST"));
363
+ });
364
+ server.registerTool("snooze_task", {
365
+ description: "Reschedule a task to a new date.",
366
+ inputSchema: {
367
+ id: z.string().uuid().describe("Task UUID"),
368
+ date: z.string().describe("New date (YYYY-MM-DD)"),
369
+ date_type: z
370
+ .enum(["specific", "week", "month", "quarter", "unspecified"])
371
+ .optional()
372
+ .describe("New date type (default: specific)"),
373
+ },
374
+ annotations: { title: "Snooze task", destructiveHint: false, idempotentHint: true, openWorldHint: false },
375
+ }, async ({ id, ...body }) => {
376
+ return toContent(await fetchApi(`/tasks/${id}/snooze`, "POST", body));
377
+ });
378
+ // ===========================================================================
379
+ // QUICK NOTES
380
+ // ===========================================================================
381
+ server.registerTool("list_notes", {
382
+ description: "List quick notes. Filter by pinned status or archived status.",
383
+ inputSchema: {
384
+ pinned: z.boolean().optional().describe("Filter pinned notes only"),
385
+ archived: z.boolean().optional().describe("Filter archived notes"),
386
+ limit: z.number().int().positive().optional().describe("Max results (default 20)"),
387
+ offset: z.number().int().nonnegative().optional().describe("Pagination offset"),
388
+ },
389
+ annotations: { title: "List notes", readOnlyHint: true, openWorldHint: false },
390
+ }, async ({ pinned, archived, limit, offset }) => {
391
+ return toContent(await fetchApi(`/notes${qs({ pinned, archived, limit, offset })}`));
392
+ });
393
+ server.registerTool("create_note", {
394
+ description: "Create a new quick note. Content supports #tag# and [[tag]] for automatic tag linking.",
395
+ inputSchema: {
396
+ content: z.string().describe("Note content (supports #tag# and [[tag]])"),
397
+ is_pinned: z.boolean().optional().describe("Pin the note (default: false)"),
398
+ contact_ids: z
399
+ .array(z.string().uuid())
400
+ .optional()
401
+ .describe("Array of contact UUIDs to associate"),
402
+ },
403
+ annotations: { title: "Create note", destructiveHint: false, idempotentHint: false, openWorldHint: false },
404
+ }, async (params) => {
405
+ return toContent(await fetchApi("/notes", "POST", params));
406
+ });
407
+ server.registerTool("update_note", {
408
+ description: "Update an existing quick note.",
409
+ inputSchema: {
410
+ id: z.string().uuid().describe("Note UUID"),
411
+ content: z.string().optional().describe("Updated content"),
412
+ },
413
+ annotations: { title: "Update note", destructiveHint: false, idempotentHint: true, openWorldHint: false },
414
+ }, async ({ id, ...body }) => {
415
+ return toContent(await fetchApi(`/notes/${id}`, "PATCH", body));
416
+ });
417
+ server.registerTool("delete_note", {
418
+ description: "Soft-delete a quick note. Use permanent=true for hard delete.",
419
+ inputSchema: {
420
+ id: z.string().uuid().describe("Note UUID"),
421
+ permanent: z.boolean().optional().describe("Hard delete (default: false, soft delete)"),
422
+ },
423
+ annotations: { title: "Delete note", destructiveHint: true, idempotentHint: true, openWorldHint: false },
424
+ }, async ({ id, permanent }) => {
425
+ const query = permanent ? "?permanent=true" : "";
426
+ return toContent(await fetchApi(`/notes/${id}${query}`, "DELETE"));
427
+ });
428
+ server.registerTool("pin_note", {
429
+ description: "Pin a quick note so it appears at the top of the list.",
430
+ inputSchema: {
431
+ id: z.string().uuid().describe("Note UUID"),
432
+ },
433
+ annotations: { title: "Pin note", destructiveHint: false, idempotentHint: true, openWorldHint: false },
434
+ }, async ({ id }) => {
435
+ return toContent(await fetchApi(`/notes/${id}/pin`, "POST"));
436
+ });
437
+ server.registerTool("archive_note", {
438
+ description: "Archive a quick note.",
439
+ inputSchema: {
440
+ id: z.string().uuid().describe("Note UUID"),
441
+ },
442
+ annotations: { title: "Archive note", destructiveHint: false, idempotentHint: true, openWorldHint: false },
443
+ }, async ({ id }) => {
444
+ return toContent(await fetchApi(`/notes/${id}/archive`, "POST"));
445
+ });
446
+ server.registerTool("restore_note", {
447
+ description: "Restore a deleted or archived quick note.",
448
+ inputSchema: {
449
+ id: z.string().uuid().describe("Note UUID"),
450
+ },
451
+ annotations: { title: "Restore note", destructiveHint: false, idempotentHint: true, openWorldHint: false },
452
+ }, async ({ id }) => {
453
+ return toContent(await fetchApi(`/notes/${id}/restore`, "POST"));
454
+ });
455
+ // ===========================================================================
456
+ // DAYS (Daily Summaries)
457
+ // ===========================================================================
458
+ server.registerTool("list_days", {
459
+ description: "List daily journal summaries. Filter by date range.",
460
+ inputSchema: {
461
+ from: z.string().optional().describe("Start date (YYYY-MM-DD)"),
462
+ to: z.string().optional().describe("End date (YYYY-MM-DD)"),
463
+ limit: z.number().int().positive().optional().describe("Max results (default 20)"),
464
+ offset: z.number().int().nonnegative().optional().describe("Pagination offset"),
465
+ },
466
+ annotations: { title: "List days", readOnlyHint: true, openWorldHint: false },
467
+ }, async ({ from, to, limit, offset }) => {
468
+ return toContent(await fetchApi(`/days${qs({ from, to, limit, offset })}`));
469
+ });
470
+ server.registerTool("get_day", {
471
+ description: "Get a specific day's journal summary by date.",
472
+ inputSchema: {
473
+ date: z.string().describe("Date (YYYY-MM-DD)"),
474
+ },
475
+ annotations: { title: "Get day", readOnlyHint: true, openWorldHint: false },
476
+ }, async ({ date }) => {
477
+ return toContent(await fetchApi(`/days/${date}`));
478
+ });
479
+ server.registerTool("update_day", {
480
+ description: "Create or update a daily journal summary. If a day entry already exists for this date, it will be updated (upsert).",
481
+ inputSchema: {
482
+ date: z.string().describe("Date (YYYY-MM-DD)"),
483
+ note: z.string().describe("Journal content for the day"),
484
+ },
485
+ annotations: { title: "Update day", destructiveHint: false, idempotentHint: true, openWorldHint: false },
486
+ }, async (params) => {
487
+ return toContent(await fetchApi("/days", "POST", params));
488
+ });
489
+ // ===========================================================================
490
+ // TAGS
491
+ // ===========================================================================
492
+ server.registerTool("list_tags", {
493
+ description: "List all tags. Tags organize contacts, entries, tasks, notes, and companies.",
494
+ inputSchema: {
495
+ limit: z.number().int().positive().optional().describe("Max results (default 50)"),
496
+ offset: z.number().int().nonnegative().optional().describe("Pagination offset"),
497
+ },
498
+ annotations: { title: "List tags", readOnlyHint: true, openWorldHint: false },
499
+ }, async ({ limit, offset }) => {
500
+ return toContent(await fetchApi(`/tags${qs({ limit, offset })}`));
501
+ });
502
+ server.registerTool("get_tag_items", {
503
+ description: "Get all items linked to a specific tag: contacts, entries, tasks, notes, and companies with counts.",
504
+ inputSchema: {
505
+ id: z.string().uuid().describe("Tag UUID"),
506
+ },
507
+ annotations: { title: "Get tag items", readOnlyHint: true, openWorldHint: false },
508
+ }, async ({ id }) => {
509
+ return toContent(await fetchApi(`/tags/${id}/items`));
510
+ });
511
+ server.registerTool("link_tag", {
512
+ description: "Link an entity (contact, entry, task, note, or company) to a tag.",
513
+ inputSchema: {
514
+ id: z.string().uuid().describe("Tag UUID"),
515
+ entity_type: z
516
+ .enum(["contact", "entry", "task", "note", "company"])
517
+ .describe("Type of entity to link"),
518
+ entity_id: z.string().uuid().describe("UUID of the entity to link"),
519
+ },
520
+ annotations: { title: "Link tag", destructiveHint: false, idempotentHint: true, openWorldHint: false },
521
+ }, async ({ id, ...body }) => {
522
+ return toContent(await fetchApi(`/tags/${id}/link`, "POST", body));
523
+ });
524
+ server.registerTool("unlink_tag", {
525
+ description: "Remove the link between an entity and a tag.",
526
+ inputSchema: {
527
+ id: z.string().uuid().describe("Tag UUID"),
528
+ entity_type: z
529
+ .enum(["contact", "entry", "task", "note", "company"])
530
+ .describe("Type of entity to unlink"),
531
+ entity_id: z.string().uuid().describe("UUID of the entity to unlink"),
532
+ },
533
+ annotations: { title: "Unlink tag", destructiveHint: true, idempotentHint: true, openWorldHint: false },
534
+ }, async ({ id, ...body }) => {
535
+ return toContent(await fetchApi(`/tags/${id}/unlink`, "POST", body));
536
+ });
537
+ // ===========================================================================
538
+ // CONTACT TIMELINE
539
+ // ===========================================================================
540
+ server.registerTool("get_contact_timeline", {
541
+ description: "Get a unified, chronological feed of ALL items related to a contact — entries, tasks, and notes — sorted by date (most recent first). Much more efficient than fetching entries, tasks, and notes separately.",
542
+ inputSchema: {
543
+ id: z.string().uuid().describe("Contact UUID"),
544
+ type: z
545
+ .enum(["all", "entries", "tasks", "notes"])
546
+ .optional()
547
+ .describe("Filter by item type (default: all)"),
548
+ from: z.string().optional().describe("Start date filter (YYYY-MM-DD)"),
549
+ to: z.string().optional().describe("End date filter (YYYY-MM-DD)"),
550
+ limit: z.number().int().positive().optional().describe("Max results (default 20)"),
551
+ offset: z.number().int().nonnegative().optional().describe("Pagination offset"),
552
+ },
553
+ annotations: { title: "Get contact timeline", readOnlyHint: true, openWorldHint: false },
554
+ }, async ({ id, type, from, to, limit, offset }) => {
555
+ return toContent(await fetchApi(`/contacts/${id}/timeline${qs({ type, from, to, limit, offset })}`));
556
+ });
557
+ // ===========================================================================
558
+ // SMART TASK VIEWS
559
+ // ===========================================================================
560
+ server.registerTool("get_tasks_today", {
561
+ description: "Get all tasks for today: overdue tasks + tasks due today + ASAP tasks. Each task has a 'category' field ('overdue', 'today', or 'asap'). Includes counts per category.",
562
+ inputSchema: {},
563
+ annotations: { title: "Get today's tasks", readOnlyHint: true, openWorldHint: false },
564
+ }, async () => {
565
+ return toContent(await fetchApi("/tasks/today"));
566
+ });
567
+ server.registerTool("get_tasks_overdue", {
568
+ description: "Get only overdue tasks (pending tasks with a due date before today). Sorted by date ascending (oldest first).",
569
+ inputSchema: {},
570
+ annotations: { title: "Get overdue tasks", readOnlyHint: true, openWorldHint: false },
571
+ }, async () => {
572
+ return toContent(await fetchApi("/tasks/overdue"));
573
+ });
574
+ // ===========================================================================
575
+ // CHANGELOG
576
+ // ===========================================================================
577
+ server.registerTool("get_changelog", {
578
+ description: "Get all items modified since a given timestamp, across all entity types. Perfect for 'heartbeat' checks to see what changed since your last visit. Returns server_time to use as 'since' for the next call.",
579
+ inputSchema: {
580
+ since: z.string().describe("ISO timestamp — only items modified after this time are returned (e.g. 2026-02-11T10:00:00Z)"),
581
+ type: z
582
+ .enum(["all", "contacts", "entries", "tasks", "notes", "days", "companies"])
583
+ .optional()
584
+ .describe("Filter by entity type (default: all)"),
585
+ limit: z.number().int().positive().optional().describe("Max items per entity type (default 50, max 100)"),
586
+ },
587
+ annotations: { title: "Get changelog", readOnlyHint: true, openWorldHint: false },
588
+ }, async ({ since, type, limit }) => {
589
+ return toContent(await fetchApi(`/changelog${qs({ since, type, limit })}`));
590
+ });
591
+ // ===========================================================================
592
+ // SEARCH
593
+ // ===========================================================================
594
+ server.registerTool("search", {
595
+ description: "Search across all Keepsake data — contacts, entries, tasks, notes, and companies. Search is accent-insensitive (e.g., 'berenice' finds 'Bérénice').",
596
+ inputSchema: {
597
+ q: z.string().describe("Search query"),
598
+ type: z
599
+ .enum(["all", "contacts", "entries", "tasks", "notes", "companies"])
600
+ .optional()
601
+ .describe("Limit search to a specific entity type (default: all)"),
602
+ limit: z.number().int().positive().optional().describe("Max results per type (default 10)"),
603
+ },
604
+ annotations: { title: "Search", readOnlyHint: true, openWorldHint: false },
605
+ }, async ({ q, type, limit }) => {
606
+ return toContent(await fetchApi(`/search${qs({ q, type, limit })}`));
607
+ });
608
+ // ---------------------------------------------------------------------------
609
+ // Agent instructions
610
+ // ---------------------------------------------------------------------------
611
+ server.registerTool("get_agent_instructions", {
612
+ description: "Get best practices and instructions for being an effective Keepsake AI agent. Call this at the start of each session to refresh your instructions.",
613
+ inputSchema: {},
614
+ annotations: { title: "Get agent instructions", readOnlyHint: true, openWorldHint: false },
615
+ }, async () => {
616
+ return toContent(await fetchApi("/agent/instructions"));
617
+ });
618
+ // ===========================================================================
619
+ // Start server
620
+ // ===========================================================================
621
+ async function main() {
622
+ const transport = new StdioServerTransport();
623
+ await server.connect(transport);
624
+ console.error("Keepsake MCP server running on stdio");
625
+ }
626
+ main().catch((err) => {
627
+ console.error("Fatal error:", err);
628
+ process.exit(1);
629
+ });
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "keepsake-mcp",
3
+ "version": "1.0.0",
4
+ "description": "MCP server for Keepsake personal CRM — connect your AI agent to your contacts, tasks, notes, and more",
5
+ "type": "module",
6
+ "bin": {
7
+ "keepsake-mcp": "./build/index.js"
8
+ },
9
+ "main": "./build/index.js",
10
+ "scripts": {
11
+ "build": "tsc && chmod 755 build/index.js",
12
+ "dev": "tsc --watch"
13
+ },
14
+ "files": [
15
+ "build"
16
+ ],
17
+ "keywords": [
18
+ "mcp",
19
+ "model-context-protocol",
20
+ "keepsake",
21
+ "crm",
22
+ "ai-agent",
23
+ "contacts",
24
+ "tasks",
25
+ "notes"
26
+ ],
27
+ "license": "MIT",
28
+ "mcpName": "io.github.nicolascroce/keepsake",
29
+ "dependencies": {
30
+ "@modelcontextprotocol/sdk": "^1.12.1",
31
+ "zod": "^3.24.4"
32
+ },
33
+ "devDependencies": {
34
+ "@types/node": "^22.0.0",
35
+ "typescript": "^5.8.0"
36
+ }
37
+ }