@hasna/contacts 0.1.0 → 0.2.1

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 (38) hide show
  1. package/dashboard/dist/assets/index-0l6aQb1t.css +1 -0
  2. package/dashboard/dist/assets/index-opnZdkVD.js +229 -0
  3. package/dashboard/dist/index.html +2 -2
  4. package/dist/cli/index.js +531 -17
  5. package/dist/db/companies.d.ts.map +1 -1
  6. package/dist/db/companies.test.d.ts +2 -0
  7. package/dist/db/companies.test.d.ts.map +1 -0
  8. package/dist/db/contacts.d.ts +1 -0
  9. package/dist/db/contacts.d.ts.map +1 -1
  10. package/dist/db/contacts.test.d.ts +2 -0
  11. package/dist/db/contacts.test.d.ts.map +1 -0
  12. package/dist/db/database.d.ts +1 -0
  13. package/dist/db/database.d.ts.map +1 -1
  14. package/dist/db/groups.d.ts +12 -0
  15. package/dist/db/groups.d.ts.map +1 -0
  16. package/dist/db/relationships.test.d.ts +2 -0
  17. package/dist/db/relationships.test.d.ts.map +1 -0
  18. package/dist/db/tags.d.ts.map +1 -1
  19. package/dist/db/tags.test.d.ts +2 -0
  20. package/dist/db/tags.test.d.ts.map +1 -0
  21. package/dist/index.d.ts +3 -2
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +102 -5
  24. package/dist/lib/config.d.ts +7 -0
  25. package/dist/lib/config.d.ts.map +1 -0
  26. package/dist/lib/dedup.d.ts +10 -0
  27. package/dist/lib/dedup.d.ts.map +1 -0
  28. package/dist/lib/import.test.d.ts +2 -0
  29. package/dist/lib/import.test.d.ts.map +1 -0
  30. package/dist/mcp/index.js +567 -76
  31. package/dist/mcp/mcp.test.d.ts +2 -0
  32. package/dist/mcp/mcp.test.d.ts.map +1 -0
  33. package/dist/server/index.js +38 -3
  34. package/dist/types/index.d.ts +25 -0
  35. package/dist/types/index.d.ts.map +1 -1
  36. package/package.json +1 -1
  37. package/dashboard/dist/assets/index-B4ndI7Qt.js +0 -49
  38. package/dashboard/dist/assets/index-C5bn2HWO.css +0 -1
package/dist/mcp/index.js CHANGED
@@ -181,6 +181,25 @@ var MIGRATIONS = [
181
181
  END;
182
182
 
183
183
  CREATE TABLE IF NOT EXISTS _migrations (version INTEGER PRIMARY KEY);
184
+ `,
185
+ `
186
+ ALTER TABLE contacts ADD COLUMN last_contacted_at TEXT;
187
+ ALTER TABLE contacts ADD COLUMN website TEXT;
188
+ ALTER TABLE contacts ADD COLUMN preferred_contact_method TEXT;
189
+
190
+ CREATE TABLE IF NOT EXISTS groups (
191
+ id TEXT PRIMARY KEY,
192
+ name TEXT NOT NULL UNIQUE,
193
+ description TEXT,
194
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
195
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
196
+ );
197
+
198
+ CREATE TABLE IF NOT EXISTS contact_groups (
199
+ contact_id TEXT NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
200
+ group_id TEXT NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
201
+ PRIMARY KEY (contact_id, group_id)
202
+ );
184
203
  `
185
204
  ];
186
205
  var _db = null;
@@ -263,7 +282,8 @@ function rowToContact(row) {
263
282
  return {
264
283
  ...row,
265
284
  source: row.source,
266
- custom_fields: JSON.parse(row.custom_fields || "{}")
285
+ custom_fields: JSON.parse(row.custom_fields || "{}"),
286
+ preferred_contact_method: row.preferred_contact_method ?? null
267
287
  };
268
288
  }
269
289
  function rowToEmail(row) {
@@ -344,8 +364,8 @@ function createContact(input, db) {
344
364
  const firstName = input.first_name ?? "";
345
365
  const lastName = input.last_name ?? "";
346
366
  const displayName = input.display_name ?? (firstName || lastName ? `${firstName} ${lastName}`.trim() : "Unnamed Contact");
347
- d.run(`INSERT INTO contacts (id, first_name, last_name, display_name, nickname, avatar_url, notes, birthday, company_id, job_title, source, custom_fields, created_at, updated_at)
348
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
367
+ d.run(`INSERT INTO contacts (id, first_name, last_name, display_name, nickname, avatar_url, notes, birthday, company_id, job_title, source, custom_fields, last_contacted_at, website, preferred_contact_method, created_at, updated_at)
368
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
349
369
  id,
350
370
  firstName,
351
371
  lastName,
@@ -358,6 +378,9 @@ function createContact(input, db) {
358
378
  input.job_title ?? null,
359
379
  input.source ?? "manual",
360
380
  JSON.stringify(input.custom_fields ?? {}),
381
+ input.last_contacted_at ?? null,
382
+ input.website ?? null,
383
+ input.preferred_contact_method ?? null,
361
384
  timestamp,
362
385
  timestamp
363
386
  ]);
@@ -469,6 +492,18 @@ function updateContact(id, input, db) {
469
492
  setClauses.push("custom_fields = ?");
470
493
  params.push(JSON.stringify(input.custom_fields));
471
494
  }
495
+ if (input.last_contacted_at !== undefined) {
496
+ setClauses.push("last_contacted_at = ?");
497
+ params.push(input.last_contacted_at);
498
+ }
499
+ if (input.website !== undefined) {
500
+ setClauses.push("website = ?");
501
+ params.push(input.website);
502
+ }
503
+ if (input.preferred_contact_method !== undefined) {
504
+ setClauses.push("preferred_contact_method = ?");
505
+ params.push(input.preferred_contact_method);
506
+ }
472
507
  params.push(id);
473
508
  d.run(`UPDATE contacts SET ${setClauses.join(", ")} WHERE id = ?`, params);
474
509
  logActivity(d, { contact_id: id, action: "contact.updated", details: `Updated contact: ${existing.display_name}` });
@@ -571,6 +606,96 @@ function mergeContacts(keepId, mergeId, db) {
571
606
  return loadContactDetails(d, rowToContact(finalRow));
572
607
  }
573
608
 
609
+ // src/db/groups.ts
610
+ function createGroup(db, input) {
611
+ const id = uuid();
612
+ db.query(`INSERT INTO groups(id, name, description, created_at, updated_at) VALUES(?,?,?,?,?)`).run(id, input.name, input.description ?? null, now(), now());
613
+ return getGroup(db, id);
614
+ }
615
+ function getGroup(db, id) {
616
+ return db.query(`SELECT * FROM groups WHERE id = ?`).get(id);
617
+ }
618
+ function listGroups(db) {
619
+ return db.query(`SELECT g.*, COUNT(cg.contact_id) as member_count FROM groups g LEFT JOIN contact_groups cg ON g.id = cg.group_id GROUP BY g.id ORDER BY g.name`).all();
620
+ }
621
+ function updateGroup(db, id, input) {
622
+ const fields = [];
623
+ const vals = [];
624
+ if (input.name !== undefined) {
625
+ fields.push("name = ?");
626
+ vals.push(input.name);
627
+ }
628
+ if (input.description !== undefined) {
629
+ fields.push("description = ?");
630
+ vals.push(input.description ?? null);
631
+ }
632
+ fields.push("updated_at = ?");
633
+ vals.push(now());
634
+ vals.push(id);
635
+ db.query(`UPDATE groups SET ${fields.join(", ")} WHERE id = ?`).run(...vals);
636
+ return getGroup(db, id);
637
+ }
638
+ function deleteGroup(db, id) {
639
+ db.query(`DELETE FROM groups WHERE id = ?`).run(id);
640
+ }
641
+ function addContactToGroup(db, contactId, groupId) {
642
+ db.query(`INSERT OR IGNORE INTO contact_groups(contact_id, group_id) VALUES(?,?)`).run(contactId, groupId);
643
+ }
644
+ function removeContactFromGroup(db, contactId, groupId) {
645
+ db.query(`DELETE FROM contact_groups WHERE contact_id = ? AND group_id = ?`).run(contactId, groupId);
646
+ }
647
+ function listContactsInGroup(db, groupId) {
648
+ const rows = db.query(`SELECT contact_id FROM contact_groups WHERE group_id = ?`).all(groupId);
649
+ return rows.map((r) => r.contact_id);
650
+ }
651
+ function listGroupsForContact(db, contactId) {
652
+ return db.query(`SELECT g.* FROM groups g JOIN contact_groups cg ON g.id = cg.group_id WHERE cg.contact_id = ? ORDER BY g.name`).all(contactId);
653
+ }
654
+
655
+ // src/db/tags.ts
656
+ function rowToTag2(row) {
657
+ return { ...row };
658
+ }
659
+ function createTag(input, db) {
660
+ const d = db || getDatabase();
661
+ const existing = d.query(`SELECT id FROM tags WHERE name = ?`).get(input.name);
662
+ if (existing)
663
+ throw new DuplicateTagNameError(input.name);
664
+ const id = uuid();
665
+ d.run(`INSERT INTO tags (id, name, color, description) VALUES (?, ?, ?, ?)`, [id, input.name, input.color ?? "#6366f1", input.description ?? null]);
666
+ return rowToTag2(d.query(`SELECT * FROM tags WHERE id = ?`).get(id));
667
+ }
668
+ function getTagByName(name, db) {
669
+ const d = db || getDatabase();
670
+ const row = d.query(`SELECT * FROM tags WHERE name = ?`).get(name);
671
+ return row ? rowToTag2(row) : null;
672
+ }
673
+ function listTags(db) {
674
+ const d = db || getDatabase();
675
+ return d.query(`SELECT * FROM tags ORDER BY name ASC`).all().map(rowToTag2);
676
+ }
677
+ function deleteTag(id, db) {
678
+ const d = db || getDatabase();
679
+ const row = d.query(`SELECT id FROM tags WHERE id = ?`).get(id);
680
+ if (!row)
681
+ throw new TagNotFoundError(id);
682
+ d.run(`DELETE FROM tags WHERE id = ?`, [id]);
683
+ }
684
+ function addTagToContact(contactId, tagId, db) {
685
+ const d = db || getDatabase();
686
+ const contact = d.query(`SELECT id FROM contacts WHERE id = ?`).get(contactId);
687
+ if (!contact)
688
+ throw new ContactNotFoundError(contactId);
689
+ const tag = d.query(`SELECT id FROM tags WHERE id = ?`).get(tagId);
690
+ if (!tag)
691
+ throw new TagNotFoundError(tagId);
692
+ d.run(`INSERT OR IGNORE INTO contact_tags (contact_id, tag_id) VALUES (?, ?)`, [contactId, tagId]);
693
+ }
694
+ function removeTagFromContact(contactId, tagId, db) {
695
+ const d = db || getDatabase();
696
+ d.run(`DELETE FROM contact_tags WHERE contact_id = ? AND tag_id = ?`, [contactId, tagId]);
697
+ }
698
+
574
699
  // src/db/companies.ts
575
700
  function rowToCompany2(row) {
576
701
  return {
@@ -773,45 +898,6 @@ function searchCompanies(query, db) {
773
898
  return rows.map((row) => loadCompanyDetails(d, rowToCompany2(row)));
774
899
  }
775
900
 
776
- // src/db/tags.ts
777
- function rowToTag2(row) {
778
- return { ...row };
779
- }
780
- function createTag(input, db) {
781
- const d = db || getDatabase();
782
- const existing = d.query(`SELECT id FROM tags WHERE name = ?`).get(input.name);
783
- if (existing)
784
- throw new DuplicateTagNameError(input.name);
785
- const id = uuid();
786
- d.run(`INSERT INTO tags (id, name, color, description) VALUES (?, ?, ?, ?)`, [id, input.name, input.color ?? "#6366f1", input.description ?? null]);
787
- return rowToTag2(d.query(`SELECT * FROM tags WHERE id = ?`).get(id));
788
- }
789
- function listTags(db) {
790
- const d = db || getDatabase();
791
- return d.query(`SELECT * FROM tags ORDER BY name ASC`).all().map(rowToTag2);
792
- }
793
- function deleteTag(id, db) {
794
- const d = db || getDatabase();
795
- const row = d.query(`SELECT id FROM tags WHERE id = ?`).get(id);
796
- if (!row)
797
- throw new TagNotFoundError(id);
798
- d.run(`DELETE FROM tags WHERE id = ?`, [id]);
799
- }
800
- function addTagToContact(contactId, tagId, db) {
801
- const d = db || getDatabase();
802
- const contact = d.query(`SELECT id FROM contacts WHERE id = ?`).get(contactId);
803
- if (!contact)
804
- throw new ContactNotFoundError(contactId);
805
- const tag = d.query(`SELECT id FROM tags WHERE id = ?`).get(tagId);
806
- if (!tag)
807
- throw new TagNotFoundError(tagId);
808
- d.run(`INSERT OR IGNORE INTO contact_tags (contact_id, tag_id) VALUES (?, ?)`, [contactId, tagId]);
809
- }
810
- function removeTagFromContact(contactId, tagId, db) {
811
- const d = db || getDatabase();
812
- d.run(`DELETE FROM contact_tags WHERE contact_id = ? AND tag_id = ?`, [contactId, tagId]);
813
- }
814
-
815
901
  // src/db/relationships.ts
816
902
  function rowToRelationship(row) {
817
903
  return {
@@ -1304,18 +1390,21 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
1304
1390
  tools: [
1305
1391
  {
1306
1392
  name: "create_contact",
1307
- description: "Create a new contact",
1393
+ description: "Create a new contact. Provide at minimum display_name or first_name+last_name. Emails, phones, addresses, and social_profiles are arrays of objects. relationship_type values: colleague|friend|family|reports_to|mentor|investor|partner|client|vendor|other. source values: manual|import|linkedin|github|twitter|email|calendar|crm|other.",
1308
1394
  inputSchema: {
1309
1395
  type: "object",
1310
1396
  properties: {
1311
1397
  first_name: { type: "string" },
1312
1398
  last_name: { type: "string" },
1313
- display_name: { type: "string", description: "Display name" },
1399
+ display_name: { type: "string", description: "Display name (auto-generated from first+last if omitted)" },
1314
1400
  nickname: { type: "string" },
1315
1401
  job_title: { type: "string" },
1316
1402
  company_id: { type: "string" },
1317
1403
  notes: { type: "string" },
1318
1404
  birthday: { type: "string", description: "YYYY-MM-DD" },
1405
+ website: { type: "string", description: "Personal or professional website URL" },
1406
+ last_contacted_at: { type: "string", description: "ISO 8601 datetime of last contact" },
1407
+ preferred_contact_method: { type: "string", enum: ["email", "phone", "telegram", "whatsapp", "linkedin", "twitter", "other"] },
1319
1408
  emails: {
1320
1409
  type: "array",
1321
1410
  items: {
@@ -1369,13 +1458,13 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
1369
1458
  }
1370
1459
  },
1371
1460
  tag_ids: { type: "array", items: { type: "string" }, description: "Tag IDs to assign" },
1372
- source: { type: "string" }
1461
+ source: { type: "string", enum: ["manual", "import", "linkedin", "github", "twitter", "email", "calendar", "crm", "other"] }
1373
1462
  }
1374
1463
  }
1375
1464
  },
1376
1465
  {
1377
1466
  name: "get_contact",
1378
- description: "Get a contact by ID",
1467
+ description: "Get a contact by ID, returning all details including emails, phones, addresses, social profiles, tags, and company.",
1379
1468
  inputSchema: {
1380
1469
  type: "object",
1381
1470
  properties: { id: { type: "string" } },
@@ -1384,7 +1473,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
1384
1473
  },
1385
1474
  {
1386
1475
  name: "update_contact",
1387
- description: "Update an existing contact",
1476
+ description: "Update an existing contact's fields. Only provided fields are changed; omitted fields remain unchanged. Supports all contact fields including last_contacted_at, website, preferred_contact_method.",
1388
1477
  inputSchema: {
1389
1478
  type: "object",
1390
1479
  properties: {
@@ -1396,14 +1485,18 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
1396
1485
  job_title: { type: "string" },
1397
1486
  company_id: { type: "string" },
1398
1487
  notes: { type: "string" },
1399
- birthday: { type: "string" }
1488
+ birthday: { type: "string", description: "YYYY-MM-DD" },
1489
+ website: { type: "string" },
1490
+ last_contacted_at: { type: "string", description: "ISO 8601 datetime of last contact" },
1491
+ preferred_contact_method: { type: "string", enum: ["email", "phone", "telegram", "whatsapp", "linkedin", "twitter", "other"] },
1492
+ source: { type: "string", enum: ["manual", "import", "linkedin", "github", "twitter", "email", "calendar", "crm", "other"] }
1400
1493
  },
1401
1494
  required: ["id"]
1402
1495
  }
1403
1496
  },
1404
1497
  {
1405
1498
  name: "delete_contact",
1406
- description: "Delete a contact by ID",
1499
+ description: "Permanently delete a contact by ID. All associated emails, phones, addresses, tags, and relationships are also deleted.",
1407
1500
  inputSchema: {
1408
1501
  type: "object",
1409
1502
  properties: { id: { type: "string" } },
@@ -1412,7 +1505,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
1412
1505
  },
1413
1506
  {
1414
1507
  name: "list_contacts",
1415
- description: "List contacts with optional filters",
1508
+ description: "List contacts with optional filters. Supports filtering by company, tag, or source. Returns paginated results with total count.",
1416
1509
  inputSchema: {
1417
1510
  type: "object",
1418
1511
  properties: {
@@ -1427,7 +1520,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
1427
1520
  },
1428
1521
  {
1429
1522
  name: "search_contacts",
1430
- description: "Full-text search across contacts",
1523
+ description: "Full-text search across contacts by name, nickname, notes, job title, and more. Also searches email addresses and phone numbers. Returns up to 50 results.",
1431
1524
  inputSchema: {
1432
1525
  type: "object",
1433
1526
  properties: { query: { type: "string" } },
@@ -1436,15 +1529,15 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
1436
1529
  },
1437
1530
  {
1438
1531
  name: "create_company",
1439
- description: "Create a new company",
1532
+ description: "Create a new company/organization. Attach emails, phones, addresses, and social_profiles as arrays. Tag with tag_ids.",
1440
1533
  inputSchema: {
1441
1534
  type: "object",
1442
1535
  properties: {
1443
1536
  name: { type: "string" },
1444
- domain: { type: "string" },
1537
+ domain: { type: "string", description: "Primary domain, e.g. acme.com" },
1445
1538
  description: { type: "string" },
1446
1539
  industry: { type: "string" },
1447
- size: { type: "string" },
1540
+ size: { type: "string", description: "e.g. '1-10', '11-50', '51-200', '201-500', '500+'" },
1448
1541
  founded_year: { type: "number" },
1449
1542
  notes: { type: "string" },
1450
1543
  emails: { type: "array", items: { type: "object" } },
@@ -1458,7 +1551,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
1458
1551
  },
1459
1552
  {
1460
1553
  name: "get_company",
1461
- description: "Get a company by ID",
1554
+ description: "Get a company by ID, including all emails, phones, addresses, social profiles, tags, and employee count.",
1462
1555
  inputSchema: {
1463
1556
  type: "object",
1464
1557
  properties: { id: { type: "string" } },
@@ -1467,7 +1560,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
1467
1560
  },
1468
1561
  {
1469
1562
  name: "update_company",
1470
- description: "Update an existing company",
1563
+ description: "Update an existing company's fields. Only provided fields are changed; omitted fields remain unchanged.",
1471
1564
  inputSchema: {
1472
1565
  type: "object",
1473
1566
  properties: {
@@ -1485,7 +1578,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
1485
1578
  },
1486
1579
  {
1487
1580
  name: "delete_company",
1488
- description: "Delete a company by ID",
1581
+ description: "Permanently delete a company by ID. Contacts that belong to this company will have their company_id cleared.",
1489
1582
  inputSchema: {
1490
1583
  type: "object",
1491
1584
  properties: { id: { type: "string" } },
@@ -1494,7 +1587,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
1494
1587
  },
1495
1588
  {
1496
1589
  name: "list_companies",
1497
- description: "List companies with optional filters",
1590
+ description: "List companies with optional filters by industry or tag. Returns paginated results with total count.",
1498
1591
  inputSchema: {
1499
1592
  type: "object",
1500
1593
  properties: {
@@ -1507,7 +1600,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
1507
1600
  },
1508
1601
  {
1509
1602
  name: "search_companies",
1510
- description: "Search companies by name or domain",
1603
+ description: "Search companies by name or domain using full-text search. Returns up to 50 matches.",
1511
1604
  inputSchema: {
1512
1605
  type: "object",
1513
1606
  properties: { query: { type: "string" } },
@@ -1516,12 +1609,12 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
1516
1609
  },
1517
1610
  {
1518
1611
  name: "create_tag",
1519
- description: "Create a new tag",
1612
+ description: "Create a new tag for categorizing contacts and companies. Tags are shared across contacts and companies.",
1520
1613
  inputSchema: {
1521
1614
  type: "object",
1522
1615
  properties: {
1523
1616
  name: { type: "string" },
1524
- color: { type: "string", description: "Hex color (e.g. #FF5733)" },
1617
+ color: { type: "string", description: "Hex color (e.g. #FF5733). Defaults to indigo." },
1525
1618
  description: { type: "string" }
1526
1619
  },
1527
1620
  required: ["name"]
@@ -1529,12 +1622,12 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
1529
1622
  },
1530
1623
  {
1531
1624
  name: "list_tags",
1532
- description: "List all tags",
1625
+ description: "List all available tags with their colors and descriptions.",
1533
1626
  inputSchema: { type: "object", properties: {} }
1534
1627
  },
1535
1628
  {
1536
1629
  name: "delete_tag",
1537
- description: "Delete a tag by ID",
1630
+ description: "Delete a tag by ID. The tag will be removed from all contacts and companies it was applied to.",
1538
1631
  inputSchema: {
1539
1632
  type: "object",
1540
1633
  properties: { id: { type: "string" } },
@@ -1543,7 +1636,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
1543
1636
  },
1544
1637
  {
1545
1638
  name: "add_tag_to_contact",
1546
- description: "Add a tag to a contact",
1639
+ description: "Apply an existing tag to a contact. Use list_tags to find tag IDs.",
1547
1640
  inputSchema: {
1548
1641
  type: "object",
1549
1642
  properties: {
@@ -1555,7 +1648,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
1555
1648
  },
1556
1649
  {
1557
1650
  name: "remove_tag_from_contact",
1558
- description: "Remove a tag from a contact",
1651
+ description: "Remove a tag from a contact. The tag itself is not deleted.",
1559
1652
  inputSchema: {
1560
1653
  type: "object",
1561
1654
  properties: {
@@ -1567,7 +1660,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
1567
1660
  },
1568
1661
  {
1569
1662
  name: "add_relationship",
1570
- description: "Add a relationship between two contacts",
1663
+ description: "Link two contacts with a typed relationship. relationship_type: colleague (work peer), friend (personal), family (relative), reports_to (A reports to B), mentor (A mentors B), investor (A invests in B), partner (business partner), client, vendor, other.",
1571
1664
  inputSchema: {
1572
1665
  type: "object",
1573
1666
  properties: {
@@ -1584,7 +1677,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
1584
1677
  },
1585
1678
  {
1586
1679
  name: "list_relationships",
1587
- description: "List all relationships for a contact",
1680
+ description: "List all relationships for a contact, showing both directions (where contact is A or B).",
1588
1681
  inputSchema: {
1589
1682
  type: "object",
1590
1683
  properties: { contact_id: { type: "string" } },
@@ -1593,7 +1686,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
1593
1686
  },
1594
1687
  {
1595
1688
  name: "delete_relationship",
1596
- description: "Delete a relationship by ID",
1689
+ description: "Delete a relationship by its ID. Use list_relationships to find relationship IDs.",
1597
1690
  inputSchema: {
1598
1691
  type: "object",
1599
1692
  properties: { id: { type: "string" } },
@@ -1602,7 +1695,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
1602
1695
  },
1603
1696
  {
1604
1697
  name: "merge_contacts",
1605
- description: "Merge two contacts \u2014 keeps one, removes the other, merging all data",
1698
+ description: "Merge two contacts into one \u2014 all emails, phones, addresses, tags, and relationships from merge_id are moved to keep_id, then merge_id is deleted. Fields missing in keep_id are filled from merge_id.",
1606
1699
  inputSchema: {
1607
1700
  type: "object",
1608
1701
  properties: {
@@ -1614,32 +1707,226 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
1614
1707
  },
1615
1708
  {
1616
1709
  name: "import_contacts",
1617
- description: "Import contacts from CSV, vCard, or JSON format",
1710
+ description: "Import contacts from CSV (Google Contacts format), vCard (.vcf, v3/v4), or JSON array. Pass raw file contents as the data string. format: 'csv'|'vcf'|'json'.",
1618
1711
  inputSchema: {
1619
1712
  type: "object",
1620
1713
  properties: {
1621
1714
  format: { type: "string", enum: ["json", "csv", "vcf"] },
1622
- data: { type: "string" }
1715
+ data: { type: "string", description: "Raw file contents (CSV text, vCard text, or JSON array string)" }
1623
1716
  },
1624
1717
  required: ["format", "data"]
1625
1718
  }
1626
1719
  },
1627
1720
  {
1628
1721
  name: "export_contacts",
1629
- description: "Export contacts to CSV, vCard, or JSON format",
1722
+ description: "Export contacts to CSV, vCard (.vcf), or JSON format. Optionally specify contact_ids to export a subset; omit to export all contacts.",
1630
1723
  inputSchema: {
1631
1724
  type: "object",
1632
1725
  properties: {
1633
1726
  format: { type: "string", enum: ["json", "csv", "vcf"] },
1634
- contact_ids: { type: "array", items: { type: "string" } }
1727
+ contact_ids: { type: "array", items: { type: "string" }, description: "Specific contact IDs to export (omit for all)" }
1635
1728
  },
1636
1729
  required: ["format"]
1637
1730
  }
1638
1731
  },
1639
1732
  {
1640
1733
  name: "get_stats",
1641
- description: "Get database statistics (counts of contacts, companies, tags)",
1734
+ description: "Get database statistics: total counts of contacts, companies, tags, and groups.",
1642
1735
  inputSchema: { type: "object", properties: {} }
1736
+ },
1737
+ {
1738
+ name: "log_interaction",
1739
+ description: "Record a contact interaction \u2014 sets last_contacted_at to now (or a provided date) and optionally appends a timestamped note. Use this to track when you last spoke with someone.",
1740
+ inputSchema: {
1741
+ type: "object",
1742
+ properties: {
1743
+ contact_id: { type: "string" },
1744
+ note: { type: "string", description: "Optional note about the interaction to append to the contact's notes" },
1745
+ date: { type: "string", description: "ISO 8601 datetime (defaults to now)" }
1746
+ },
1747
+ required: ["contact_id"]
1748
+ }
1749
+ },
1750
+ {
1751
+ name: "find_or_create_contact",
1752
+ description: "Find an existing contact by email or name, or create a new one if not found. Returns { contact, found: boolean, created: boolean }. The #1 tool for agent workflows \u2014 avoids duplicate creation. Searches by email first (exact match), then by display_name.",
1753
+ inputSchema: {
1754
+ type: "object",
1755
+ properties: {
1756
+ first_name: { type: "string" },
1757
+ last_name: { type: "string" },
1758
+ display_name: { type: "string" },
1759
+ nickname: { type: "string" },
1760
+ job_title: { type: "string" },
1761
+ company_id: { type: "string" },
1762
+ notes: { type: "string" },
1763
+ birthday: { type: "string" },
1764
+ website: { type: "string" },
1765
+ last_contacted_at: { type: "string" },
1766
+ preferred_contact_method: { type: "string", enum: ["email", "phone", "telegram", "whatsapp", "linkedin", "twitter", "other"] },
1767
+ emails: { type: "array", items: { type: "object", properties: { address: { type: "string" }, type: { type: "string" }, is_primary: { type: "boolean" } }, required: ["address"] } },
1768
+ phones: { type: "array", items: { type: "object" } },
1769
+ addresses: { type: "array", items: { type: "object" } },
1770
+ social_profiles: { type: "array", items: { type: "object" } },
1771
+ tag_ids: { type: "array", items: { type: "string" } },
1772
+ source: { type: "string" }
1773
+ }
1774
+ }
1775
+ },
1776
+ {
1777
+ name: "upsert_contact",
1778
+ description: "Update a contact if one with matching email exists, otherwise create a new one. Returns { contact, action: 'created'|'updated' }. Ideal for syncing data from external sources. Requires email in the emails array or as a top-level email field.",
1779
+ inputSchema: {
1780
+ type: "object",
1781
+ properties: {
1782
+ first_name: { type: "string" },
1783
+ last_name: { type: "string" },
1784
+ display_name: { type: "string" },
1785
+ nickname: { type: "string" },
1786
+ job_title: { type: "string" },
1787
+ company_id: { type: "string" },
1788
+ notes: { type: "string" },
1789
+ birthday: { type: "string" },
1790
+ website: { type: "string" },
1791
+ last_contacted_at: { type: "string" },
1792
+ preferred_contact_method: { type: "string", enum: ["email", "phone", "telegram", "whatsapp", "linkedin", "twitter", "other"] },
1793
+ email: { type: "string", description: "Primary email address (alternative to emails array)" },
1794
+ emails: { type: "array", items: { type: "object", properties: { address: { type: "string" }, type: { type: "string" }, is_primary: { type: "boolean" } }, required: ["address"] } },
1795
+ phones: { type: "array", items: { type: "object" } },
1796
+ addresses: { type: "array", items: { type: "object" } },
1797
+ social_profiles: { type: "array", items: { type: "object" } },
1798
+ tag_ids: { type: "array", items: { type: "string" } },
1799
+ source: { type: "string" }
1800
+ }
1801
+ }
1802
+ },
1803
+ {
1804
+ name: "add_note",
1805
+ description: "Append a timestamped note to a contact's notes field. Fast and ergonomic \u2014 no need for full update_contact. Note is appended as '\\n\\n[YYYY-MM-DD] text' to existing notes.",
1806
+ inputSchema: {
1807
+ type: "object",
1808
+ properties: {
1809
+ contact_id: { type: "string" },
1810
+ note: { type: "string" }
1811
+ },
1812
+ required: ["contact_id", "note"]
1813
+ }
1814
+ },
1815
+ {
1816
+ name: "list_contacts_by_company",
1817
+ description: "List all contacts belonging to a specific company. Equivalent to list_contacts with company_id filter but more ergonomic.",
1818
+ inputSchema: {
1819
+ type: "object",
1820
+ properties: {
1821
+ company_id: { type: "string" },
1822
+ limit: { type: "number", description: "Max results (default 50)" },
1823
+ offset: { type: "number" }
1824
+ },
1825
+ required: ["company_id"]
1826
+ }
1827
+ },
1828
+ {
1829
+ name: "list_contacts_by_tag",
1830
+ description: "List all contacts with a specific tag. Accepts either a tag ID (UUID) or a tag name string.",
1831
+ inputSchema: {
1832
+ type: "object",
1833
+ properties: {
1834
+ tag: { type: "string", description: "Tag name or tag ID (UUID)" },
1835
+ limit: { type: "number", description: "Max results (default 50)" },
1836
+ offset: { type: "number" }
1837
+ },
1838
+ required: ["tag"]
1839
+ }
1840
+ },
1841
+ {
1842
+ name: "create_group",
1843
+ description: "Create a new group for organizing contacts. Groups are named collections that can hold multiple contacts.",
1844
+ inputSchema: {
1845
+ type: "object",
1846
+ properties: {
1847
+ name: { type: "string" },
1848
+ description: { type: "string" }
1849
+ },
1850
+ required: ["name"]
1851
+ }
1852
+ },
1853
+ {
1854
+ name: "list_groups",
1855
+ description: "List all groups with their member counts.",
1856
+ inputSchema: { type: "object", properties: {} }
1857
+ },
1858
+ {
1859
+ name: "get_group",
1860
+ description: "Get a group by ID.",
1861
+ inputSchema: {
1862
+ type: "object",
1863
+ properties: { id: { type: "string" } },
1864
+ required: ["id"]
1865
+ }
1866
+ },
1867
+ {
1868
+ name: "update_group",
1869
+ description: "Update a group's name or description.",
1870
+ inputSchema: {
1871
+ type: "object",
1872
+ properties: {
1873
+ id: { type: "string" },
1874
+ name: { type: "string" },
1875
+ description: { type: "string" }
1876
+ },
1877
+ required: ["id"]
1878
+ }
1879
+ },
1880
+ {
1881
+ name: "delete_group",
1882
+ description: "Delete a group by ID. Contacts in the group are not deleted \u2014 only the group itself.",
1883
+ inputSchema: {
1884
+ type: "object",
1885
+ properties: { id: { type: "string" } },
1886
+ required: ["id"]
1887
+ }
1888
+ },
1889
+ {
1890
+ name: "add_contact_to_group",
1891
+ description: "Add a contact to a group.",
1892
+ inputSchema: {
1893
+ type: "object",
1894
+ properties: {
1895
+ contact_id: { type: "string" },
1896
+ group_id: { type: "string" }
1897
+ },
1898
+ required: ["contact_id", "group_id"]
1899
+ }
1900
+ },
1901
+ {
1902
+ name: "remove_contact_from_group",
1903
+ description: "Remove a contact from a group.",
1904
+ inputSchema: {
1905
+ type: "object",
1906
+ properties: {
1907
+ contact_id: { type: "string" },
1908
+ group_id: { type: "string" }
1909
+ },
1910
+ required: ["contact_id", "group_id"]
1911
+ }
1912
+ },
1913
+ {
1914
+ name: "list_contacts_in_group",
1915
+ description: "List all contact IDs in a group.",
1916
+ inputSchema: {
1917
+ type: "object",
1918
+ properties: { group_id: { type: "string" } },
1919
+ required: ["group_id"]
1920
+ }
1921
+ },
1922
+ {
1923
+ name: "list_groups_for_contact",
1924
+ description: "List all groups that a contact belongs to.",
1925
+ inputSchema: {
1926
+ type: "object",
1927
+ properties: { contact_id: { type: "string" } },
1928
+ required: ["contact_id"]
1929
+ }
1643
1930
  }
1644
1931
  ]
1645
1932
  }));
@@ -1658,6 +1945,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1658
1945
  company_id: a.company_id,
1659
1946
  notes: a.notes,
1660
1947
  birthday: a.birthday,
1948
+ website: a.website,
1949
+ last_contacted_at: a.last_contacted_at,
1950
+ preferred_contact_method: a.preferred_contact_method,
1661
1951
  emails: a.emails,
1662
1952
  phones: a.phones,
1663
1953
  addresses: a.addresses,
@@ -1682,7 +1972,11 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1682
1972
  job_title: rest.job_title,
1683
1973
  company_id: rest.company_id,
1684
1974
  notes: rest.notes,
1685
- birthday: rest.birthday
1975
+ birthday: rest.birthday,
1976
+ website: rest.website,
1977
+ last_contacted_at: rest.last_contacted_at,
1978
+ preferred_contact_method: rest.preferred_contact_method,
1979
+ source: rest.source
1686
1980
  };
1687
1981
  const contact = updateContact(id, input);
1688
1982
  return { content: [{ type: "text", text: JSON.stringify(contact, null, 2) }] };
@@ -1847,13 +2141,210 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1847
2141
  const contactCount = db.prepare("SELECT COUNT(*) as count FROM contacts").get().count;
1848
2142
  const companyCount = db.prepare("SELECT COUNT(*) as count FROM companies").get().count;
1849
2143
  const tagCount = db.prepare("SELECT COUNT(*) as count FROM tags").get().count;
2144
+ const groupCount = db.prepare("SELECT COUNT(*) as count FROM groups").get().count;
1850
2145
  return {
1851
2146
  content: [{
1852
2147
  type: "text",
1853
- text: JSON.stringify({ contacts: contactCount, companies: companyCount, tags: tagCount }, null, 2)
2148
+ text: JSON.stringify({ contacts: contactCount, companies: companyCount, tags: tagCount, groups: groupCount }, null, 2)
1854
2149
  }]
1855
2150
  };
1856
2151
  }
2152
+ case "log_interaction": {
2153
+ const contactId = a.contact_id;
2154
+ const interactionDate = a.date ?? new Date().toISOString();
2155
+ const note = a.note;
2156
+ const existing = getContact(contactId);
2157
+ const updateInput = { last_contacted_at: interactionDate };
2158
+ if (note) {
2159
+ const dateStr = interactionDate.slice(0, 10);
2160
+ const existingNotes = existing.notes ?? "";
2161
+ updateInput.notes = existingNotes ? `${existingNotes}
2162
+
2163
+ [${dateStr}] ${note}` : `[${dateStr}] ${note}`;
2164
+ }
2165
+ const contact = updateContact(contactId, updateInput);
2166
+ return { content: [{ type: "text", text: JSON.stringify(contact, null, 2) }] };
2167
+ }
2168
+ case "find_or_create_contact": {
2169
+ const db = getDatabase();
2170
+ const emailAddresses = a.emails?.map((e) => e.address) ?? [];
2171
+ let found = null;
2172
+ for (const addr of emailAddresses) {
2173
+ const emailRow = db.prepare(`SELECT contact_id FROM emails WHERE address = ? AND contact_id IS NOT NULL LIMIT 1`).get(addr);
2174
+ if (emailRow) {
2175
+ found = getContact(emailRow.contact_id);
2176
+ break;
2177
+ }
2178
+ }
2179
+ if (!found && a.display_name) {
2180
+ const results = searchContacts(a.display_name);
2181
+ if (results.length > 0)
2182
+ found = results[0];
2183
+ }
2184
+ if (found) {
2185
+ return { content: [{ type: "text", text: JSON.stringify({ contact: found, found: true, created: false }, null, 2) }] };
2186
+ }
2187
+ const input = {
2188
+ first_name: a.first_name,
2189
+ last_name: a.last_name,
2190
+ display_name: a.display_name,
2191
+ nickname: a.nickname,
2192
+ job_title: a.job_title,
2193
+ company_id: a.company_id,
2194
+ notes: a.notes,
2195
+ birthday: a.birthday,
2196
+ website: a.website,
2197
+ last_contacted_at: a.last_contacted_at,
2198
+ preferred_contact_method: a.preferred_contact_method,
2199
+ emails: a.emails,
2200
+ phones: a.phones,
2201
+ addresses: a.addresses,
2202
+ social_profiles: a.social_profiles,
2203
+ tag_ids: a.tag_ids,
2204
+ source: a.source
2205
+ };
2206
+ const contact = createContact(input);
2207
+ return { content: [{ type: "text", text: JSON.stringify({ contact, found: false, created: true }, null, 2) }] };
2208
+ }
2209
+ case "upsert_contact": {
2210
+ const db = getDatabase();
2211
+ const upsertEmails = a.emails?.map((e) => e.address) ?? [];
2212
+ if (a.email)
2213
+ upsertEmails.unshift(a.email);
2214
+ let existingContact = null;
2215
+ for (const addr of upsertEmails) {
2216
+ const emailRow = db.prepare(`SELECT contact_id FROM emails WHERE address = ? AND contact_id IS NOT NULL LIMIT 1`).get(addr);
2217
+ if (emailRow) {
2218
+ existingContact = getContact(emailRow.contact_id);
2219
+ break;
2220
+ }
2221
+ }
2222
+ if (existingContact) {
2223
+ const updateInput = {
2224
+ first_name: a.first_name,
2225
+ last_name: a.last_name,
2226
+ display_name: a.display_name,
2227
+ nickname: a.nickname,
2228
+ job_title: a.job_title,
2229
+ company_id: a.company_id,
2230
+ notes: a.notes,
2231
+ birthday: a.birthday,
2232
+ website: a.website,
2233
+ last_contacted_at: a.last_contacted_at,
2234
+ preferred_contact_method: a.preferred_contact_method,
2235
+ source: a.source
2236
+ };
2237
+ const updated = updateContact(existingContact.id, updateInput);
2238
+ return { content: [{ type: "text", text: JSON.stringify({ contact: updated, action: "updated" }, null, 2) }] };
2239
+ }
2240
+ const createInput = {
2241
+ first_name: a.first_name,
2242
+ last_name: a.last_name,
2243
+ display_name: a.display_name,
2244
+ nickname: a.nickname,
2245
+ job_title: a.job_title,
2246
+ company_id: a.company_id,
2247
+ notes: a.notes,
2248
+ birthday: a.birthday,
2249
+ website: a.website,
2250
+ last_contacted_at: a.last_contacted_at,
2251
+ preferred_contact_method: a.preferred_contact_method,
2252
+ emails: a.email ? [{ address: a.email, is_primary: true }, ...a.emails ?? []] : a.emails,
2253
+ phones: a.phones,
2254
+ addresses: a.addresses,
2255
+ social_profiles: a.social_profiles,
2256
+ tag_ids: a.tag_ids,
2257
+ source: a.source
2258
+ };
2259
+ const created = createContact(createInput);
2260
+ return { content: [{ type: "text", text: JSON.stringify({ contact: created, action: "created" }, null, 2) }] };
2261
+ }
2262
+ case "add_note": {
2263
+ const contactId = a.contact_id;
2264
+ const note = a.note;
2265
+ const existing = getContact(contactId);
2266
+ const dateStr = new Date().toISOString().slice(0, 10);
2267
+ const existingNotes = existing.notes ?? "";
2268
+ const updatedNotes = existingNotes ? `${existingNotes}
2269
+
2270
+ [${dateStr}] ${note}` : `[${dateStr}] ${note}`;
2271
+ const contact = updateContact(contactId, { notes: updatedNotes });
2272
+ return { content: [{ type: "text", text: JSON.stringify(contact, null, 2) }] };
2273
+ }
2274
+ case "list_contacts_by_company": {
2275
+ const result = listContacts({
2276
+ company_id: a.company_id,
2277
+ limit: a.limit,
2278
+ offset: a.offset
2279
+ });
2280
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
2281
+ }
2282
+ case "list_contacts_by_tag": {
2283
+ const tagInput = a.tag;
2284
+ const db = getDatabase();
2285
+ const isUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(tagInput);
2286
+ let tagId = isUuid ? tagInput : null;
2287
+ if (!tagId) {
2288
+ const tag = getTagByName(tagInput, db);
2289
+ if (!tag)
2290
+ return { content: [{ type: "text", text: `Tag not found: ${tagInput}` }], isError: true };
2291
+ tagId = tag.id;
2292
+ }
2293
+ const result = listContacts({
2294
+ tag_id: tagId,
2295
+ limit: a.limit,
2296
+ offset: a.offset
2297
+ });
2298
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
2299
+ }
2300
+ case "create_group": {
2301
+ const db = getDatabase();
2302
+ const group = createGroup(db, { name: a.name, description: a.description });
2303
+ return { content: [{ type: "text", text: JSON.stringify(group, null, 2) }] };
2304
+ }
2305
+ case "list_groups": {
2306
+ const db = getDatabase();
2307
+ const groups = listGroups(db);
2308
+ return { content: [{ type: "text", text: JSON.stringify(groups, null, 2) }] };
2309
+ }
2310
+ case "get_group": {
2311
+ const db = getDatabase();
2312
+ const group = getGroup(db, a.id);
2313
+ if (!group)
2314
+ return { content: [{ type: "text", text: `Group not found: ${a.id}` }], isError: true };
2315
+ return { content: [{ type: "text", text: JSON.stringify(group, null, 2) }] };
2316
+ }
2317
+ case "update_group": {
2318
+ const db = getDatabase();
2319
+ const { id: groupId, ...groupRest } = a;
2320
+ const group = updateGroup(db, groupId, { name: groupRest.name, description: groupRest.description });
2321
+ return { content: [{ type: "text", text: JSON.stringify(group, null, 2) }] };
2322
+ }
2323
+ case "delete_group": {
2324
+ const db = getDatabase();
2325
+ deleteGroup(db, a.id);
2326
+ return { content: [{ type: "text", text: `Group ${a.id} deleted successfully` }] };
2327
+ }
2328
+ case "add_contact_to_group": {
2329
+ const db = getDatabase();
2330
+ addContactToGroup(db, a.contact_id, a.group_id);
2331
+ return { content: [{ type: "text", text: `Contact ${a.contact_id} added to group ${a.group_id}` }] };
2332
+ }
2333
+ case "remove_contact_from_group": {
2334
+ const db = getDatabase();
2335
+ removeContactFromGroup(db, a.contact_id, a.group_id);
2336
+ return { content: [{ type: "text", text: `Contact ${a.contact_id} removed from group ${a.group_id}` }] };
2337
+ }
2338
+ case "list_contacts_in_group": {
2339
+ const db = getDatabase();
2340
+ const contactIds = listContactsInGroup(db, a.group_id);
2341
+ return { content: [{ type: "text", text: JSON.stringify(contactIds, null, 2) }] };
2342
+ }
2343
+ case "list_groups_for_contact": {
2344
+ const db = getDatabase();
2345
+ const groups = listGroupsForContact(db, a.contact_id);
2346
+ return { content: [{ type: "text", text: JSON.stringify(groups, null, 2) }] };
2347
+ }
1857
2348
  default:
1858
2349
  return { content: [{ type: "text", text: `Unknown tool: ${name}` }], isError: true };
1859
2350
  }