keepsake-mcp 1.10.0 → 1.11.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.
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 (76)
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 |
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: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "keepsake-mcp",
3
- "version": "1.10.0",
3
+ "version": "1.11.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": {