keepsake-mcp 1.10.0 → 1.12.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 (3) hide show
  1. package/README.md +12 -5
  2. package/build/tools.js +147 -13
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -114,17 +114,17 @@ rather than in the chat, which disappears.
114
114
  |--------|-----------|-------------|
115
115
  | `review_note` | `note_id` | Act as the editor of a note: read it, judge form and substance, leave anchored remarks in the margin, never rewrite the text |
116
116
 
117
- ## Available tools (73)
117
+ ## Available tools (83)
118
118
 
119
119
  ### Contacts
120
120
  | Tool | Description |
121
121
  |------|-------------|
122
- | `list_contacts` | List all contacts with pagination and sorting |
122
+ | `list_contacts` | List contacts with pagination, sorting, field selection and filters (linked company, has_company, updated_since) |
123
123
  | `get_contact` | Get a contact with recent interactions, tags, and stats |
124
- | `create_contact` | Create a new contact |
124
+ | `create_contact` | Create a new contact (`company` links it to a company record, created if missing) |
125
125
  | `update_contact` | Update contact fields |
126
126
  | `delete_contact` | Permanently delete a contact |
127
- | `search_contacts` | Accent-insensitive search by name, email, company |
127
+ | `search_contacts` | Accent-insensitive search by name, email, notes, phone and linked company names |
128
128
  | `get_contact_timeline` | Unified chronological feed of all items for a contact |
129
129
 
130
130
  ### Companies
@@ -136,6 +136,9 @@ rather than in the chat, which disappears.
136
136
  | `update_company` | Update company fields |
137
137
  | `delete_company` | Soft-delete (or permanent delete) a company |
138
138
  | `search_companies` | Accent-insensitive company search |
139
+ | `link_contact_company` | Link a contact to a company (optional role) |
140
+ | `unlink_contact_company` | Remove a contact–company link |
141
+ | `merge_companies` | Merge a duplicate company into another (contacts, entries, tags, details, notes) |
139
142
 
140
143
  ### Entries (Interactions)
141
144
  | Tool | Description |
@@ -149,6 +152,7 @@ rather than in the chat, which disappears.
149
152
  | Tool | Description |
150
153
  |------|-------------|
151
154
  | `list_tasks` | List tasks with status/date filters |
155
+ | `get_task` | Get a task with its tags, contacts, companies and linked notes |
152
156
  | `create_task` | Create a task — supports `#tag#` and `[[tag]]` syntax |
153
157
  | `update_task` | Update task fields |
154
158
  | `delete_task` | Delete a task |
@@ -217,13 +221,16 @@ Material kept *alongside* a note without entering its text — an idea, a refere
217
221
  | `update_task_header` | Update a task header (name, description, collapsed) |
218
222
  | `delete_task_header` | Permanently delete a task header |
219
223
 
220
- ### Contact Links
224
+ ### Contact & company links
221
225
  | Tool | Description |
222
226
  |------|-------------|
227
+ | `link_note_company` / `unlink_note_company` | Link / unlink a company to a note |
223
228
  | `link_note_contact` | Link a contact to a note |
224
229
  | `unlink_note_contact` | Remove a contact link from a note |
230
+ | `link_entry_company` / `unlink_entry_company` | Add / remove a company as participant of an entry |
225
231
  | `link_entry_contact` | Link a contact to an entry |
226
232
  | `unlink_entry_contact` | Remove a contact link from an entry |
233
+ | `link_task_company` / `unlink_task_company` | Link / unlink a company to a task |
227
234
  | `link_task_contact` | Link a contact to a task |
228
235
  | `unlink_task_contact` | Remove a contact link from a task |
229
236
  | `link_task_note` | Link a note to a task (non-destructive, the note survives) |
package/build/tools.js CHANGED
@@ -109,17 +109,24 @@ export function registerAllTools(server, fetchApi) {
109
109
  // CONTACTS
110
110
  // ===========================================================================
111
111
  server.registerTool("list_contacts", {
112
- description: "List all contacts in the user's Keepsake CRM. Supports pagination, sorting, and optional last_interaction_date enrichment.",
112
+ description: "List contacts in the user's Keepsake CRM. Supports pagination, sorting, field selection (use `fields` to skip long notes when scanning many contacts), filters on linked companies and on last update, and optional last_interaction_date enrichment. Each contact's companies are company records, returned in `companies`.",
113
113
  inputSchema: {
114
- limit: z.number().int().positive().optional().describe("Max results (default 20)"),
114
+ limit: z.number().int().positive().optional().describe("Max results (default 20, max 100)"),
115
115
  offset: z.number().int().nonnegative().optional().describe("Pagination offset"),
116
- sort: z.string().optional().describe("Sort field: last_name, first_name, created_at"),
116
+ sort: z.string().optional().describe("Sort field: last_name, first_name, created_at, updated_at"),
117
117
  order: z.enum(["asc", "desc"]).optional().describe("Sort order"),
118
118
  include_last_interaction: z.boolean().optional().describe("Include last_interaction_date for each contact (default: false)"),
119
+ fields: z
120
+ .array(z.string())
121
+ .optional()
122
+ .describe("Only return these fields (id is always included). Columns: first_name, last_name, email, phone, job_title, address, birth_day, birth_month, birth_year, notes, created_at, updated_at. Computed: companies, last_interaction_date. Omit for everything."),
123
+ company: z.string().optional().describe("Only contacts linked to this company: a company UUID, or part of its name (case and accents ignored)"),
124
+ has_company: z.boolean().optional().describe("true: only contacts linked to at least one company; false: only contacts without any company"),
125
+ updated_since: z.string().optional().describe("Only contacts updated after this ISO date or timestamp"),
119
126
  },
120
127
  annotations: { title: "List contacts", readOnlyHint: true, openWorldHint: false },
121
- }, async ({ limit, offset, sort, order, include_last_interaction }) => {
122
- return toContent(await fetchApi(`/contacts${qs({ limit, offset, sort, order, include_last_interaction })}`));
128
+ }, async ({ limit, offset, sort, order, include_last_interaction, fields, company, has_company, updated_since }) => {
129
+ return toContent(await fetchApi(`/contacts${qs({ limit, offset, sort, order, include_last_interaction, fields: fields?.join(","), company, has_company, updated_since })}`));
123
130
  });
124
131
  server.registerTool("get_contact", {
125
132
  description: "Get a single contact by ID, including recent entries (interactions), tags, last_interaction_date, and total_entries count.",
@@ -138,7 +145,7 @@ export function registerAllTools(server, fetchApi) {
138
145
  last_name: z.string().optional().describe("Last name (optional)"),
139
146
  email: z.string().optional().describe("Email address"),
140
147
  phone: z.string().optional().describe("Phone number"),
141
- company: z.string().optional().describe("Company name"),
148
+ company: z.string().optional().describe("Company name. Links the contact to that company record (matched by name ignoring case and accents, created if missing). Adds a link, never removes one. The contact's companies are returned in `companies`."),
142
149
  birthday: z.string().optional().describe("Birthday as ISO date string (YYYY-MM-DD), e.g. '1980-02-14'"),
143
150
  notes: z.string().optional().describe("Notes about the contact"),
144
151
  },
@@ -154,7 +161,7 @@ export function registerAllTools(server, fetchApi) {
154
161
  last_name: z.string().optional().describe("Last name"),
155
162
  email: z.string().optional().describe("Email address"),
156
163
  phone: z.string().optional().describe("Phone number"),
157
- company: z.string().optional().describe("Company name"),
164
+ company: z.string().optional().describe("Company name. Links the contact to that company record (matched by name ignoring case and accents, created if missing). Adds a link, never removes one. The contact's companies are returned in `companies`."),
158
165
  birthday: z.string().nullable().optional().describe("Birthday as ISO date string (YYYY-MM-DD), e.g. '1980-02-14'. Set to null to clear."),
159
166
  notes: z.string().optional().describe("Notes about the contact"),
160
167
  },
@@ -244,6 +251,37 @@ export function registerAllTools(server, fetchApi) {
244
251
  const query = permanent ? "?permanent=true" : "";
245
252
  return toContent(await fetchApi(`/companies/${id}${query}`, "DELETE"));
246
253
  });
254
+ server.registerTool("link_contact_company", {
255
+ description: "Link a contact to a company record (idempotent: linking twice keeps one link). A contact can belong to several companies. Optionally set the contact's role there. Shortcut when you only know the company's name: create_contact / update_contact with `company`.",
256
+ inputSchema: {
257
+ company_id: z.string().uuid().describe("Company UUID"),
258
+ contact_id: z.string().uuid().describe("Contact UUID"),
259
+ role: z.string().optional().describe("Role or job at this company (e.g. 'Training advisor')"),
260
+ },
261
+ annotations: { title: "Link contact to company", destructiveHint: false, idempotentHint: true, openWorldHint: false },
262
+ }, async ({ company_id, contact_id, role }) => {
263
+ return toContent(await fetchApi(`/companies/${company_id}/contacts`, "POST", { contact_id, ...(role !== undefined && { role }) }));
264
+ });
265
+ server.registerTool("unlink_contact_company", {
266
+ description: "Remove the link between a contact and a company. Neither the contact nor the company is deleted.",
267
+ inputSchema: {
268
+ company_id: z.string().uuid().describe("Company UUID"),
269
+ contact_id: z.string().uuid().describe("Contact UUID"),
270
+ },
271
+ annotations: { title: "Unlink contact from company", destructiveHint: true, idempotentHint: true, openWorldHint: false },
272
+ }, async ({ company_id, contact_id }) => {
273
+ return toContent(await fetchApi(`/companies/${company_id}/contacts${qs({ contact_id })}`, "DELETE"));
274
+ });
275
+ server.registerTool("merge_companies", {
276
+ description: "Merge a duplicate company INTO another one: its contacts, entries and tags move to the target (no duplicates), the target's empty details (website, email, phone, address) are filled from it, its notes are appended to the target's, then it is deleted. Use it when two records describe the same organization (e.g. 'CNCP' and 'Cncp'). Confirm with your user which record to keep.",
277
+ inputSchema: {
278
+ source_id: z.string().uuid().describe("UUID of the company to merge away (it will be deleted)"),
279
+ target_id: z.string().uuid().describe("UUID of the company to keep"),
280
+ },
281
+ annotations: { title: "Merge companies", destructiveHint: true, idempotentHint: false, openWorldHint: false },
282
+ }, async ({ source_id, target_id }) => {
283
+ return toContent(await fetchApi(`/companies/${source_id}/merge`, "POST", { target_id }));
284
+ });
247
285
  server.registerTool("search_companies", {
248
286
  description: "Search companies by name, email, website, or address. Search is accent-insensitive.",
249
287
  inputSchema: {
@@ -264,14 +302,15 @@ export function registerAllTools(server, fetchApi) {
264
302
  .optional()
265
303
  .describe("Filter by entry type"),
266
304
  contact_id: z.string().uuid().optional().describe("Filter by associated contact ID"),
305
+ company_id: z.string().uuid().optional().describe("Only entries where this company is a participant"),
267
306
  from: z.string().optional().describe("Start date (YYYY-MM-DD)"),
268
307
  to: z.string().optional().describe("End date (YYYY-MM-DD)"),
269
308
  limit: z.number().int().positive().optional().describe("Max results (default 20)"),
270
309
  offset: z.number().int().nonnegative().optional().describe("Pagination offset"),
271
310
  },
272
311
  annotations: { title: "List entries", readOnlyHint: true, openWorldHint: false },
273
- }, async ({ type, contact_id, from, to, limit, offset }) => {
274
- return toContent(await fetchApi(`/entries${qs({ type, contact_id, from, to, limit, offset })}`));
312
+ }, async ({ type, contact_id, company_id, from, to, limit, offset }) => {
313
+ return toContent(await fetchApi(`/entries${qs({ type, contact_id, company_id, from, to, limit, offset })}`));
275
314
  });
276
315
  server.registerTool("create_entry", {
277
316
  description: "An ENTRY is a dated interaction log tied to contacts. Records something that happened (call, meeting, email…) on a specific date.\n\nCreate a new interaction entry. Content supports #tag# and [[tag]] syntax for automatic tag linking.",
@@ -285,6 +324,10 @@ export function registerAllTools(server, fetchApi) {
285
324
  .array(z.string().uuid())
286
325
  .optional()
287
326
  .describe("Array of contact UUIDs to associate"),
327
+ company_ids: z
328
+ .array(z.string().uuid())
329
+ .optional()
330
+ .describe("Array of company UUIDs to associate (companies are separate from contacts)"),
288
331
  tag_ids: z
289
332
  .array(z.string().uuid())
290
333
  .optional()
@@ -308,6 +351,10 @@ export function registerAllTools(server, fetchApi) {
308
351
  .array(z.string().uuid())
309
352
  .optional()
310
353
  .describe("Replace associated contacts"),
354
+ company_ids: z
355
+ .array(z.string().uuid())
356
+ .optional()
357
+ .describe("Replace associated companies (send [] to unlink all). Contacts are unaffected."),
311
358
  tag_ids: z
312
359
  .array(z.string().uuid())
313
360
  .optional()
@@ -338,12 +385,22 @@ export function registerAllTools(server, fetchApi) {
338
385
  .optional()
339
386
  .describe("Filter by date type: specific (has a due date), asap (do as soon as possible), one_day (someday/no rush)"),
340
387
  date: z.string().optional().describe("Filter by specific date (YYYY-MM-DD)"),
388
+ company_id: z.string().uuid().optional().describe("Only tasks linked directly to this company"),
341
389
  limit: z.number().int().positive().optional().describe("Max results (default 20)"),
342
390
  offset: z.number().int().nonnegative().optional().describe("Pagination offset"),
343
391
  },
344
392
  annotations: { title: "List tasks", readOnlyHint: true, openWorldHint: false },
345
- }, async ({ status, date_type, date, limit, offset }) => {
346
- return toContent(await fetchApi(`/tasks${qs({ status, date_type, date, limit, offset })}`));
393
+ }, async ({ status, date_type, date, company_id, limit, offset }) => {
394
+ return toContent(await fetchApi(`/tasks${qs({ status, date_type, date, company_id, limit, offset })}`));
395
+ });
396
+ server.registerTool("get_task", {
397
+ description: "Get a single task by ID, with its tags, linked contacts (contact_ids), linked companies (company_ids) and linked notes (note_ids). Use it when you already know which task you need instead of listing everything.",
398
+ inputSchema: {
399
+ id: z.string().uuid().describe("Task UUID"),
400
+ },
401
+ annotations: { title: "Get task", readOnlyHint: true, openWorldHint: false },
402
+ }, async ({ id }) => {
403
+ return toContent(await fetchApi(`/tasks/${id}`));
347
404
  });
348
405
  server.registerTool("create_task", {
349
406
  description: "A TASK is an action item to accomplish. Can have due date, recurrence, priority, linked to contacts and tags.\n\nCreate a new task. Title supports #tag# and [[tag]] for automatic tag linking.",
@@ -382,6 +439,10 @@ export function registerAllTools(server, fetchApi) {
382
439
  .array(z.string().uuid())
383
440
  .optional()
384
441
  .describe("Array of contact UUIDs to associate"),
442
+ company_ids: z
443
+ .array(z.string().uuid())
444
+ .optional()
445
+ .describe("Array of company UUIDs to associate (companies are separate from contacts)"),
385
446
  tag_ids: z
386
447
  .array(z.string().uuid())
387
448
  .optional()
@@ -421,6 +482,10 @@ export function registerAllTools(server, fetchApi) {
421
482
  .array(z.string().uuid())
422
483
  .optional()
423
484
  .describe("Replace associated contacts"),
485
+ company_ids: z
486
+ .array(z.string().uuid())
487
+ .optional()
488
+ .describe("Replace associated companies (send [] to unlink all). Contacts are unaffected."),
424
489
  tag_ids: z
425
490
  .array(z.string().uuid())
426
491
  .optional()
@@ -482,12 +547,13 @@ export function registerAllTools(server, fetchApi) {
482
547
  date: z.string().optional().describe("Only notes linked to this day (YYYY-MM-DD)"),
483
548
  date_from: z.string().optional().describe("Only notes linked to a day on or after this date (YYYY-MM-DD)"),
484
549
  date_to: z.string().optional().describe("Only notes linked to a day on or before this date (YYYY-MM-DD)"),
550
+ company_id: z.string().uuid().optional().describe("Only notes linked directly to this company"),
485
551
  limit: z.number().int().positive().optional().describe("Max results (default 20)"),
486
552
  offset: z.number().int().nonnegative().optional().describe("Pagination offset"),
487
553
  },
488
554
  annotations: { title: "List notes", readOnlyHint: true, openWorldHint: false },
489
- }, async ({ pinned, archived, date, date_from, date_to, limit, offset }) => {
490
- return toContent(await fetchApi(`/notes${qs({ pinned, archived, date, date_from, date_to, limit, offset })}`));
555
+ }, async ({ pinned, archived, date, date_from, date_to, company_id, limit, offset }) => {
556
+ return toContent(await fetchApi(`/notes${qs({ pinned, archived, date, date_from, date_to, company_id, limit, offset })}`));
491
557
  });
492
558
  server.registerTool("get_note", {
493
559
  description: "Get a single note by ID, including its tags, linked contacts, linked tasks, linked notes and the days it is linked to (`dates`). Use this when the user points you at one specific note (e.g. gives you its URL — the UUID is the last path segment) instead of listing everything.",
@@ -507,6 +573,10 @@ export function registerAllTools(server, fetchApi) {
507
573
  .array(z.string().uuid())
508
574
  .optional()
509
575
  .describe("Array of contact UUIDs to associate"),
576
+ company_ids: z
577
+ .array(z.string().uuid())
578
+ .optional()
579
+ .describe("Array of company UUIDs to associate (companies are separate from contacts)"),
510
580
  dates: z
511
581
  .array(z.string())
512
582
  .optional()
@@ -525,6 +595,10 @@ export function registerAllTools(server, fetchApi) {
525
595
  .array(z.string().uuid())
526
596
  .optional()
527
597
  .describe("Replace associated contacts"),
598
+ company_ids: z
599
+ .array(z.string().uuid())
600
+ .optional()
601
+ .describe("Replace associated companies (send [] to unlink all). Contacts are unaffected."),
528
602
  tag_ids: z
529
603
  .array(z.string().uuid())
530
604
  .optional()
@@ -901,6 +975,66 @@ export function registerAllTools(server, fetchApi) {
901
975
  }, async ({ task_id, contact_id }) => {
902
976
  return toContent(await fetchApi(`/tasks/${task_id}/contacts/${contact_id}`, "DELETE"));
903
977
  });
978
+ server.registerTool("link_entry_company", {
979
+ description: "Link a company to an entry (the company becomes a participant of that interaction). Idempotent. Companies are separate records from contacts: use this (or company_ids) for an organization, link_entry_contact for a person.",
980
+ inputSchema: {
981
+ entry_id: z.string().uuid().describe("Entry UUID"),
982
+ company_id: z.string().uuid().describe("Company UUID"),
983
+ },
984
+ annotations: { title: "Link entry company", destructiveHint: false, idempotentHint: true, openWorldHint: false },
985
+ }, async ({ entry_id, company_id }) => {
986
+ return toContent(await fetchApi(`/entries/${entry_id}/companies/${company_id}`, "POST"));
987
+ });
988
+ server.registerTool("unlink_entry_company", {
989
+ description: "Remove the link between a company and an entry.",
990
+ inputSchema: {
991
+ entry_id: z.string().uuid().describe("Entry UUID"),
992
+ company_id: z.string().uuid().describe("Company UUID"),
993
+ },
994
+ annotations: { title: "Unlink entry company", destructiveHint: true, idempotentHint: true, openWorldHint: false },
995
+ }, async ({ entry_id, company_id }) => {
996
+ return toContent(await fetchApi(`/entries/${entry_id}/companies/${company_id}`, "DELETE"));
997
+ });
998
+ server.registerTool("link_task_company", {
999
+ description: "Link a company to a task. Idempotent. Companies are separate records from contacts: use this (or company_ids) for an organization, link_task_contact for a person.",
1000
+ inputSchema: {
1001
+ task_id: z.string().uuid().describe("Task UUID"),
1002
+ company_id: z.string().uuid().describe("Company UUID"),
1003
+ },
1004
+ annotations: { title: "Link task company", destructiveHint: false, idempotentHint: true, openWorldHint: false },
1005
+ }, async ({ task_id, company_id }) => {
1006
+ return toContent(await fetchApi(`/tasks/${task_id}/companies/${company_id}`, "POST"));
1007
+ });
1008
+ server.registerTool("unlink_task_company", {
1009
+ description: "Remove the link between a company and a task.",
1010
+ inputSchema: {
1011
+ task_id: z.string().uuid().describe("Task UUID"),
1012
+ company_id: z.string().uuid().describe("Company UUID"),
1013
+ },
1014
+ annotations: { title: "Unlink task company", destructiveHint: true, idempotentHint: true, openWorldHint: false },
1015
+ }, async ({ task_id, company_id }) => {
1016
+ return toContent(await fetchApi(`/tasks/${task_id}/companies/${company_id}`, "DELETE"));
1017
+ });
1018
+ server.registerTool("link_note_company", {
1019
+ description: "Link a company to a note. Idempotent. Companies are separate records from contacts: use this (or company_ids) for an organization, link_note_contact for a person.",
1020
+ inputSchema: {
1021
+ note_id: z.string().uuid().describe("Note UUID"),
1022
+ company_id: z.string().uuid().describe("Company UUID"),
1023
+ },
1024
+ annotations: { title: "Link note company", destructiveHint: false, idempotentHint: true, openWorldHint: false },
1025
+ }, async ({ note_id, company_id }) => {
1026
+ return toContent(await fetchApi(`/notes/${note_id}/companies/${company_id}`, "POST"));
1027
+ });
1028
+ server.registerTool("unlink_note_company", {
1029
+ description: "Remove the link between a company and a note.",
1030
+ inputSchema: {
1031
+ note_id: z.string().uuid().describe("Note UUID"),
1032
+ company_id: z.string().uuid().describe("Company UUID"),
1033
+ },
1034
+ annotations: { title: "Unlink note company", destructiveHint: true, idempotentHint: true, openWorldHint: false },
1035
+ }, async ({ note_id, company_id }) => {
1036
+ return toContent(await fetchApi(`/notes/${note_id}/companies/${company_id}`, "DELETE"));
1037
+ });
904
1038
  server.registerTool("link_task_note", {
905
1039
  description: "Link an existing note to a task (non-destructive, N-N). The note stays in the inbox/notes list and the task keeps a live link back to it — use this instead of deleting a note after creating a task from it. The note shows its linked tasks; the task shows the source note.",
906
1040
  inputSchema: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "keepsake-mcp",
3
- "version": "1.10.0",
3
+ "version": "1.12.0",
4
4
  "description": "MCP server for Keepsake personal CRM \u2014 connect your AI agent to your contacts, tasks, notes, and more",
5
5
  "type": "module",
6
6
  "bin": {