@hasna/contacts 0.2.2 → 0.2.3

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/dist/mcp/index.js CHANGED
@@ -198,6 +198,20 @@ var MIGRATIONS = [
198
198
  group_id TEXT NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
199
199
  PRIMARY KEY (contact_id, group_id)
200
200
  );
201
+ `,
202
+ `
203
+ ALTER TABLE contacts ADD COLUMN status TEXT DEFAULT 'active';
204
+ ALTER TABLE contacts ADD COLUMN follow_up_at TEXT;
205
+ ALTER TABLE contacts ADD COLUMN archived INTEGER NOT NULL DEFAULT 0;
206
+ ALTER TABLE contacts ADD COLUMN project_id TEXT;
207
+ ALTER TABLE companies ADD COLUMN archived INTEGER NOT NULL DEFAULT 0;
208
+ ALTER TABLE companies ADD COLUMN project_id TEXT;
209
+
210
+ CREATE TABLE IF NOT EXISTS company_groups (
211
+ company_id TEXT NOT NULL REFERENCES companies(id) ON DELETE CASCADE,
212
+ group_id TEXT NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
213
+ PRIMARY KEY (company_id, group_id)
214
+ );
201
215
  `
202
216
  ];
203
217
  var _db = null;
@@ -269,11 +283,32 @@ class DuplicateTagNameError extends Error {
269
283
  }
270
284
 
271
285
  // src/db/activity.ts
286
+ function rowToActivity(row) {
287
+ return { ...row };
288
+ }
272
289
  function logActivity(db, input) {
273
290
  const id = uuid();
274
291
  db.run(`INSERT INTO activity_log (id, contact_id, company_id, action, details) VALUES (?, ?, ?, ?, ?)`, [id, input.contact_id ?? null, input.company_id ?? null, input.action, input.details ?? null]);
275
292
  return db.query(`SELECT * FROM activity_log WHERE id = ?`).get(id);
276
293
  }
294
+ function listActivity(opts = {}, db) {
295
+ const d = db || getDatabase();
296
+ const { limit = 50, offset = 0, contact_id, company_id } = opts;
297
+ const conditions = [];
298
+ const params = [];
299
+ if (contact_id) {
300
+ conditions.push("contact_id = ?");
301
+ params.push(contact_id);
302
+ }
303
+ if (company_id) {
304
+ conditions.push("company_id = ?");
305
+ params.push(company_id);
306
+ }
307
+ const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
308
+ const totalRow = d.query(`SELECT COUNT(*) as total FROM activity_log ${where}`).get(...params);
309
+ const rows = d.query(`SELECT * FROM activity_log ${where} ORDER BY created_at DESC LIMIT ? OFFSET ?`).all(...params, limit, offset);
310
+ return { entries: rows.map(rowToActivity), total: totalRow.total };
311
+ }
277
312
 
278
313
  // src/db/contacts.ts
279
314
  function rowToContact(row) {
@@ -281,7 +316,11 @@ function rowToContact(row) {
281
316
  ...row,
282
317
  source: row.source,
283
318
  custom_fields: JSON.parse(row.custom_fields || "{}"),
284
- preferred_contact_method: row.preferred_contact_method ?? null
319
+ preferred_contact_method: row.preferred_contact_method ?? null,
320
+ status: row.status ?? "active",
321
+ follow_up_at: row.follow_up_at ?? null,
322
+ archived: !!row.archived,
323
+ project_id: row.project_id ?? null
285
324
  };
286
325
  }
287
326
  function rowToEmail(row) {
@@ -318,7 +357,9 @@ function rowToTag(row) {
318
357
  function rowToCompany(row) {
319
358
  return {
320
359
  ...row,
321
- custom_fields: JSON.parse(row.custom_fields || "{}")
360
+ custom_fields: JSON.parse(row.custom_fields || "{}"),
361
+ archived: !!row.archived,
362
+ project_id: row.project_id ?? null
322
363
  };
323
364
  }
324
365
  function insertEmails(db, contactId, companyId, emails) {
@@ -362,8 +403,8 @@ function createContact(input, db) {
362
403
  const firstName = input.first_name ?? "";
363
404
  const lastName = input.last_name ?? "";
364
405
  const displayName = input.display_name ?? (firstName || lastName ? `${firstName} ${lastName}`.trim() : "Unnamed Contact");
365
- 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)
366
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
406
+ 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, status, follow_up_at, project_id, created_at, updated_at)
407
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
367
408
  id,
368
409
  firstName,
369
410
  lastName,
@@ -379,6 +420,9 @@ function createContact(input, db) {
379
420
  input.last_contacted_at ?? null,
380
421
  input.website ?? null,
381
422
  input.preferred_contact_method ?? null,
423
+ input.status ?? "active",
424
+ input.follow_up_at ?? null,
425
+ input.project_id ?? null,
382
426
  timestamp,
383
427
  timestamp
384
428
  ]);
@@ -413,12 +457,21 @@ function listContacts(opts = {}, db) {
413
457
  offset = 0,
414
458
  company_id,
415
459
  tag_id,
460
+ tag_ids,
416
461
  source,
462
+ status,
463
+ project_id,
464
+ archived = false,
465
+ follow_up_due,
466
+ last_contacted_after,
467
+ last_contacted_before,
417
468
  order_by = "display_name",
418
469
  order_dir = "asc"
419
470
  } = opts;
420
471
  const conditions = [];
421
472
  const params = [];
473
+ conditions.push("c.archived = ?");
474
+ params.push(archived ? 1 : 0);
422
475
  if (company_id) {
423
476
  conditions.push("c.company_id = ?");
424
477
  params.push(company_id);
@@ -427,12 +480,37 @@ function listContacts(opts = {}, db) {
427
480
  conditions.push("c.source = ?");
428
481
  params.push(source);
429
482
  }
483
+ if (status) {
484
+ conditions.push("c.status = ?");
485
+ params.push(status);
486
+ }
487
+ if (project_id) {
488
+ conditions.push("c.project_id = ?");
489
+ params.push(project_id);
490
+ }
430
491
  if (tag_id) {
431
492
  conditions.push("EXISTS (SELECT 1 FROM contact_tags ct WHERE ct.contact_id = c.id AND ct.tag_id = ?)");
432
493
  params.push(tag_id);
433
494
  }
495
+ if (tag_ids?.length) {
496
+ for (const tid of tag_ids) {
497
+ conditions.push("EXISTS (SELECT 1 FROM contact_tags ct WHERE ct.contact_id = c.id AND ct.tag_id = ?)");
498
+ params.push(tid);
499
+ }
500
+ }
501
+ if (follow_up_due) {
502
+ conditions.push("c.follow_up_at IS NOT NULL AND c.follow_up_at <= datetime('now')");
503
+ }
504
+ if (last_contacted_after) {
505
+ conditions.push("c.last_contacted_at >= ?");
506
+ params.push(last_contacted_after);
507
+ }
508
+ if (last_contacted_before) {
509
+ conditions.push("c.last_contacted_at <= ?");
510
+ params.push(last_contacted_before);
511
+ }
434
512
  const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
435
- const validOrderBy = ["display_name", "created_at", "updated_at"].includes(order_by) ? order_by : "display_name";
513
+ const validOrderBy = ["display_name", "created_at", "updated_at", "last_contacted_at", "follow_up_at"].includes(order_by) ? order_by : "display_name";
436
514
  const validOrderDir = order_dir === "desc" ? "DESC" : "ASC";
437
515
  const totalRow = d.query(`SELECT COUNT(*) as total FROM contacts c ${where}`).get(...params);
438
516
  const rows = d.query(`SELECT c.* FROM contacts c ${where} ORDER BY c.${validOrderBy} ${validOrderDir} LIMIT ? OFFSET ?`).all(...params, limit, offset);
@@ -502,8 +580,36 @@ function updateContact(id, input, db) {
502
580
  setClauses.push("preferred_contact_method = ?");
503
581
  params.push(input.preferred_contact_method);
504
582
  }
583
+ if (input.status !== undefined) {
584
+ setClauses.push("status = ?");
585
+ params.push(input.status);
586
+ }
587
+ if (input.follow_up_at !== undefined) {
588
+ setClauses.push("follow_up_at = ?");
589
+ params.push(input.follow_up_at);
590
+ }
591
+ if (input.project_id !== undefined) {
592
+ setClauses.push("project_id = ?");
593
+ params.push(input.project_id);
594
+ }
505
595
  params.push(id);
506
596
  d.run(`UPDATE contacts SET ${setClauses.join(", ")} WHERE id = ?`, params);
597
+ if (input.emails_add?.length) {
598
+ for (const e of input.emails_add) {
599
+ const exists = d.query(`SELECT id FROM emails WHERE contact_id = ? AND LOWER(address) = LOWER(?)`).get(id, e.address);
600
+ if (!exists) {
601
+ d.run(`INSERT INTO emails (id, contact_id, company_id, address, type, is_primary) VALUES (?, ?, NULL, ?, ?, ?)`, [uuid(), id, e.address, e.type ?? "work", e.is_primary ? 1 : 0]);
602
+ }
603
+ }
604
+ }
605
+ if (input.phones_add?.length) {
606
+ for (const p of input.phones_add) {
607
+ const exists = d.query(`SELECT id FROM phones WHERE contact_id = ? AND number = ?`).get(id, p.number);
608
+ if (!exists) {
609
+ d.run(`INSERT INTO phones (id, contact_id, company_id, number, country_code, type, is_primary) VALUES (?, ?, NULL, ?, ?, ?, ?)`, [uuid(), id, p.number, p.country_code ?? null, p.type ?? "mobile", p.is_primary ? 1 : 0]);
610
+ }
611
+ }
612
+ }
507
613
  logActivity(d, { contact_id: id, action: "contact.updated", details: `Updated contact: ${existing.display_name}` });
508
614
  const row = d.query(`SELECT * FROM contacts WHERE id = ?`).get(id);
509
615
  return loadContactDetails(d, rowToContact(row));
@@ -521,25 +627,31 @@ function searchContacts(query, db) {
521
627
  const ftsRows = d.query(`
522
628
  SELECT c.* FROM contacts c
523
629
  JOIN contacts_fts fts ON fts.id = c.id
524
- WHERE contacts_fts MATCH ?
630
+ WHERE contacts_fts MATCH ? AND c.archived = 0
525
631
  ORDER BY rank
526
632
  LIMIT 50
527
633
  `).all(`"${query.replace(/"/g, '""')}"*`);
528
634
  const emailRows = d.query(`
529
635
  SELECT DISTINCT c.* FROM contacts c
530
636
  JOIN emails e ON e.contact_id = c.id
531
- WHERE e.address LIKE ?
637
+ WHERE e.address LIKE ? AND c.archived = 0
532
638
  LIMIT 20
533
639
  `).all(`%${query}%`);
534
640
  const phoneRows = d.query(`
535
641
  SELECT DISTINCT c.* FROM contacts c
536
642
  JOIN phones p ON p.contact_id = c.id
537
- WHERE p.number LIKE ?
643
+ WHERE p.number LIKE ? AND c.archived = 0
644
+ LIMIT 20
645
+ `).all(`%${query}%`);
646
+ const companyRows = d.query(`
647
+ SELECT DISTINCT c.* FROM contacts c
648
+ JOIN companies co ON co.id = c.company_id
649
+ WHERE co.name LIKE ? AND c.archived = 0
538
650
  LIMIT 20
539
651
  `).all(`%${query}%`);
540
652
  const seen = new Set;
541
653
  const allRows = [];
542
- for (const row of [...ftsRows, ...emailRows, ...phoneRows]) {
654
+ for (const row of [...ftsRows, ...emailRows, ...phoneRows, ...companyRows]) {
543
655
  if (!seen.has(row.id)) {
544
656
  seen.add(row.id);
545
657
  allRows.push(row);
@@ -555,8 +667,24 @@ function mergeContacts(keepId, mergeId, db) {
555
667
  const mergeRow = d.query(`SELECT * FROM contacts WHERE id = ?`).get(mergeId);
556
668
  if (!mergeRow)
557
669
  throw new ContactNotFoundError(mergeId);
558
- d.run(`UPDATE emails SET contact_id = ? WHERE contact_id = ?`, [keepId, mergeId]);
559
- d.run(`UPDATE phones SET contact_id = ? WHERE contact_id = ?`, [keepId, mergeId]);
670
+ const mergeEmailRows = d.query(`SELECT * FROM emails WHERE contact_id = ?`).all(mergeId);
671
+ for (const e of mergeEmailRows) {
672
+ const exists = d.query(`SELECT id FROM emails WHERE contact_id = ? AND LOWER(address) = LOWER(?)`).get(keepId, e.address);
673
+ if (exists) {
674
+ d.run(`DELETE FROM emails WHERE id = ?`, [e.id]);
675
+ } else {
676
+ d.run(`UPDATE emails SET contact_id = ? WHERE id = ?`, [keepId, e.id]);
677
+ }
678
+ }
679
+ const mergePhoneRows = d.query(`SELECT * FROM phones WHERE contact_id = ?`).all(mergeId);
680
+ for (const p of mergePhoneRows) {
681
+ const exists = d.query(`SELECT id FROM phones WHERE contact_id = ? AND number = ?`).get(keepId, p.number);
682
+ if (exists) {
683
+ d.run(`DELETE FROM phones WHERE id = ?`, [p.id]);
684
+ } else {
685
+ d.run(`UPDATE phones SET contact_id = ? WHERE id = ?`, [keepId, p.id]);
686
+ }
687
+ }
560
688
  d.run(`UPDATE addresses SET contact_id = ? WHERE contact_id = ?`, [keepId, mergeId]);
561
689
  d.run(`UPDATE social_profiles SET contact_id = ? WHERE contact_id = ?`, [keepId, mergeId]);
562
690
  const mergeTags = d.query(`SELECT tag_id FROM contact_tags WHERE contact_id = ?`).all(mergeId);
@@ -603,6 +731,79 @@ function mergeContacts(keepId, mergeId, db) {
603
731
  const finalRow = d.query(`SELECT * FROM contacts WHERE id = ?`).get(keepId);
604
732
  return loadContactDetails(d, rowToContact(finalRow));
605
733
  }
734
+ function getContactByEmail(email, db) {
735
+ const d = db || getDatabase();
736
+ const emailRow = d.query(`SELECT contact_id FROM emails WHERE LOWER(address) = LOWER(?) AND contact_id IS NOT NULL LIMIT 1`).get(email);
737
+ if (!emailRow)
738
+ return null;
739
+ const row = d.query(`SELECT * FROM contacts WHERE id = ?`).get(emailRow.contact_id);
740
+ if (!row)
741
+ return null;
742
+ return loadContactDetails(d, rowToContact(row));
743
+ }
744
+ function addEmailToContact(contactId, email, db) {
745
+ const d = db || getDatabase();
746
+ const row = d.query(`SELECT * FROM contacts WHERE id = ?`).get(contactId);
747
+ if (!row)
748
+ throw new ContactNotFoundError(contactId);
749
+ const exists = d.query(`SELECT id FROM emails WHERE contact_id = ? AND LOWER(address) = LOWER(?)`).get(contactId, email.address);
750
+ if (!exists) {
751
+ d.run(`INSERT INTO emails (id, contact_id, company_id, address, type, is_primary) VALUES (?, ?, NULL, ?, ?, ?)`, [uuid(), contactId, email.address, email.type ?? "work", email.is_primary ? 1 : 0]);
752
+ }
753
+ const updated = d.query(`SELECT * FROM contacts WHERE id = ?`).get(contactId);
754
+ return loadContactDetails(d, rowToContact(updated));
755
+ }
756
+ function addPhoneToContact(contactId, phone, db) {
757
+ const d = db || getDatabase();
758
+ const row = d.query(`SELECT * FROM contacts WHERE id = ?`).get(contactId);
759
+ if (!row)
760
+ throw new ContactNotFoundError(contactId);
761
+ const exists = d.query(`SELECT id FROM phones WHERE contact_id = ? AND number = ?`).get(contactId, phone.number);
762
+ if (!exists) {
763
+ d.run(`INSERT INTO phones (id, contact_id, company_id, number, country_code, type, is_primary) VALUES (?, ?, NULL, ?, ?, ?, ?)`, [uuid(), contactId, phone.number, phone.country_code ?? null, phone.type ?? "mobile", phone.is_primary ? 1 : 0]);
764
+ }
765
+ const updated = d.query(`SELECT * FROM contacts WHERE id = ?`).get(contactId);
766
+ return loadContactDetails(d, rowToContact(updated));
767
+ }
768
+ function archiveContact(id, db) {
769
+ const d = db || getDatabase();
770
+ const row = d.query(`SELECT * FROM contacts WHERE id = ?`).get(id);
771
+ if (!row)
772
+ throw new ContactNotFoundError(id);
773
+ d.run(`UPDATE contacts SET archived = 1, updated_at = ? WHERE id = ?`, [now(), id]);
774
+ logActivity(d, { contact_id: id, action: "contact.archived", details: `Archived contact: ${row.display_name}` });
775
+ const updated = d.query(`SELECT * FROM contacts WHERE id = ?`).get(id);
776
+ return loadContactDetails(d, rowToContact(updated));
777
+ }
778
+ function unarchiveContact(id, db) {
779
+ const d = db || getDatabase();
780
+ const row = d.query(`SELECT * FROM contacts WHERE id = ?`).get(id);
781
+ if (!row)
782
+ throw new ContactNotFoundError(id);
783
+ d.run(`UPDATE contacts SET archived = 0, updated_at = ? WHERE id = ?`, [now(), id]);
784
+ logActivity(d, { contact_id: id, action: "contact.unarchived", details: `Unarchived contact: ${row.display_name}` });
785
+ const updated = d.query(`SELECT * FROM contacts WHERE id = ?`).get(id);
786
+ return loadContactDetails(d, rowToContact(updated));
787
+ }
788
+ function autoLinkContactToCompany(contactId, db) {
789
+ const d = db || getDatabase();
790
+ const row = d.query(`SELECT * FROM contacts WHERE id = ?`).get(contactId);
791
+ if (!row || row.company_id)
792
+ return null;
793
+ const emailRow = d.query(`SELECT address FROM emails WHERE contact_id = ? AND contact_id IS NOT NULL LIMIT 1`).get(contactId);
794
+ if (!emailRow)
795
+ return null;
796
+ const domain = emailRow.address.split("@")[1];
797
+ if (!domain)
798
+ return null;
799
+ const companyRow = d.query(`SELECT id FROM companies WHERE domain = ? LIMIT 1`).get(domain);
800
+ if (!companyRow)
801
+ return null;
802
+ d.run(`UPDATE contacts SET company_id = ?, updated_at = ? WHERE id = ?`, [companyRow.id, now(), contactId]);
803
+ logActivity(d, { contact_id: contactId, action: "contact.auto_linked", details: `Auto-linked to company via email domain: ${domain}` });
804
+ const updated = d.query(`SELECT * FROM contacts WHERE id = ?`).get(contactId);
805
+ return loadContactDetails(d, rowToContact(updated));
806
+ }
606
807
 
607
808
  // src/db/groups.ts
608
809
  function createGroup(db, input) {
@@ -614,7 +815,10 @@ function getGroup(db, id) {
614
815
  return db.query(`SELECT * FROM groups WHERE id = ?`).get(id);
615
816
  }
616
817
  function listGroups(db) {
617
- 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();
818
+ return db.query(`SELECT g.*,
819
+ (SELECT COUNT(*) FROM contact_groups cg WHERE cg.group_id = g.id) as member_count,
820
+ (SELECT COUNT(*) FROM company_groups cog WHERE cog.group_id = g.id) as company_count
821
+ FROM groups g ORDER BY g.name`).all();
618
822
  }
619
823
  function updateGroup(db, id, input) {
620
824
  const fields = [];
@@ -637,7 +841,11 @@ function deleteGroup(db, id) {
637
841
  db.query(`DELETE FROM groups WHERE id = ?`).run(id);
638
842
  }
639
843
  function addContactToGroup(db, contactId, groupId) {
640
- db.query(`INSERT OR IGNORE INTO contact_groups(contact_id, group_id) VALUES(?,?)`).run(contactId, groupId);
844
+ const existing = db.query(`SELECT 1 FROM contact_groups WHERE contact_id = ? AND group_id = ?`).get(contactId, groupId);
845
+ if (existing)
846
+ return { added: false, already_member: true };
847
+ db.query(`INSERT INTO contact_groups(contact_id, group_id) VALUES(?,?)`).run(contactId, groupId);
848
+ return { added: true, already_member: false };
641
849
  }
642
850
  function removeContactFromGroup(db, contactId, groupId) {
643
851
  db.query(`DELETE FROM contact_groups WHERE contact_id = ? AND group_id = ?`).run(contactId, groupId);
@@ -649,6 +857,23 @@ function listContactsInGroup(db, groupId) {
649
857
  function listGroupsForContact(db, contactId) {
650
858
  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);
651
859
  }
860
+ function addCompanyToGroup(db, companyId, groupId) {
861
+ const existing = db.query(`SELECT 1 FROM company_groups WHERE company_id = ? AND group_id = ?`).get(companyId, groupId);
862
+ if (existing)
863
+ return { added: false, already_member: true };
864
+ db.query(`INSERT INTO company_groups(company_id, group_id) VALUES(?,?)`).run(companyId, groupId);
865
+ return { added: true, already_member: false };
866
+ }
867
+ function removeCompanyFromGroup(db, companyId, groupId) {
868
+ db.query(`DELETE FROM company_groups WHERE company_id = ? AND group_id = ?`).run(companyId, groupId);
869
+ }
870
+ function listCompaniesInGroup(db, groupId) {
871
+ const rows = db.query(`SELECT company_id FROM company_groups WHERE group_id = ?`).all(groupId);
872
+ return rows.map((r) => r.company_id);
873
+ }
874
+ function listGroupsForCompany(db, companyId) {
875
+ return db.query(`SELECT g.* FROM groups g JOIN company_groups cog ON g.id = cog.group_id WHERE cog.company_id = ? ORDER BY g.name`).all(companyId);
876
+ }
652
877
 
653
878
  // src/db/tags.ts
654
879
  function rowToTag2(row) {
@@ -693,12 +918,28 @@ function removeTagFromContact(contactId, tagId, db) {
693
918
  const d = db || getDatabase();
694
919
  d.run(`DELETE FROM contact_tags WHERE contact_id = ? AND tag_id = ?`, [contactId, tagId]);
695
920
  }
921
+ function addTagToCompany(companyId, tagId, db) {
922
+ const d = db || getDatabase();
923
+ const company = d.query(`SELECT id FROM companies WHERE id = ?`).get(companyId);
924
+ if (!company)
925
+ throw new CompanyNotFoundError(companyId);
926
+ const tag = d.query(`SELECT id FROM tags WHERE id = ?`).get(tagId);
927
+ if (!tag)
928
+ throw new TagNotFoundError(tagId);
929
+ d.run(`INSERT OR IGNORE INTO company_tags (company_id, tag_id) VALUES (?, ?)`, [companyId, tagId]);
930
+ }
931
+ function removeTagFromCompany(companyId, tagId, db) {
932
+ const d = db || getDatabase();
933
+ d.run(`DELETE FROM company_tags WHERE company_id = ? AND tag_id = ?`, [companyId, tagId]);
934
+ }
696
935
 
697
936
  // src/db/companies.ts
698
937
  function rowToCompany2(row) {
699
938
  return {
700
939
  ...row,
701
- custom_fields: JSON.parse(row.custom_fields || "{}")
940
+ custom_fields: JSON.parse(row.custom_fields || "{}"),
941
+ archived: !!row.archived,
942
+ project_id: row.project_id ?? null
702
943
  };
703
944
  }
704
945
  function insertEmails2(db, companyId, emails) {
@@ -808,15 +1049,23 @@ function listCompanies(opts = {}, db) {
808
1049
  offset = 0,
809
1050
  industry,
810
1051
  tag_id,
1052
+ project_id,
1053
+ archived = false,
811
1054
  order_by = "name",
812
1055
  order_dir = "asc"
813
1056
  } = opts;
814
1057
  const conditions = [];
815
1058
  const params = [];
1059
+ conditions.push("co.archived = ?");
1060
+ params.push(archived ? 1 : 0);
816
1061
  if (industry) {
817
1062
  conditions.push("co.industry = ?");
818
1063
  params.push(industry);
819
1064
  }
1065
+ if (project_id) {
1066
+ conditions.push("co.project_id = ?");
1067
+ params.push(project_id);
1068
+ }
820
1069
  if (tag_id) {
821
1070
  conditions.push("EXISTS (SELECT 1 FROM company_tags ct WHERE ct.company_id = co.id AND ct.tag_id = ?)");
822
1071
  params.push(tag_id);
@@ -872,6 +1121,10 @@ function updateCompany(id, input, db) {
872
1121
  setClauses.push("custom_fields = ?");
873
1122
  params.push(JSON.stringify(input.custom_fields));
874
1123
  }
1124
+ if ("project_id" in input && input.project_id !== undefined) {
1125
+ setClauses.push("project_id = ?");
1126
+ params.push(input.project_id);
1127
+ }
875
1128
  params.push(id);
876
1129
  d.run(`UPDATE companies SET ${setClauses.join(", ")} WHERE id = ?`, params);
877
1130
  logActivity(d, { company_id: id, action: "company.updated", details: `Updated company: ${existing.name}` });
@@ -895,6 +1148,26 @@ function searchCompanies(query, db) {
895
1148
  `).all(`%${query}%`, `%${query}%`, `%${query}%`, `%${query}%`);
896
1149
  return rows.map((row) => loadCompanyDetails(d, rowToCompany2(row)));
897
1150
  }
1151
+ function archiveCompany(id, db) {
1152
+ const d = db || getDatabase();
1153
+ const row = d.query(`SELECT * FROM companies WHERE id = ?`).get(id);
1154
+ if (!row)
1155
+ throw new CompanyNotFoundError(id);
1156
+ d.run(`UPDATE companies SET archived = 1, updated_at = ? WHERE id = ?`, [now(), id]);
1157
+ logActivity(d, { company_id: id, action: "company.archived", details: `Archived company: ${row.name}` });
1158
+ const updated = d.query(`SELECT * FROM companies WHERE id = ?`).get(id);
1159
+ return loadCompanyDetails(d, rowToCompany2(updated));
1160
+ }
1161
+ function unarchiveCompany(id, db) {
1162
+ const d = db || getDatabase();
1163
+ const row = d.query(`SELECT * FROM companies WHERE id = ?`).get(id);
1164
+ if (!row)
1165
+ throw new CompanyNotFoundError(id);
1166
+ d.run(`UPDATE companies SET archived = 0, updated_at = ? WHERE id = ?`, [now(), id]);
1167
+ logActivity(d, { company_id: id, action: "company.unarchived", details: `Unarchived company: ${row.name}` });
1168
+ const updated = d.query(`SELECT * FROM companies WHERE id = ?`).get(id);
1169
+ return loadCompanyDetails(d, rowToCompany2(updated));
1170
+ }
898
1171
 
899
1172
  // src/db/relationships.ts
900
1173
  function rowToRelationship(row) {
@@ -937,6 +1210,39 @@ function deleteRelationship(id, db) {
937
1210
  d.run(`DELETE FROM contact_relationships WHERE id = ?`, [id]);
938
1211
  }
939
1212
 
1213
+ // src/lib/dedup.ts
1214
+ function findEmailDuplicates(db) {
1215
+ const rows = db.query(`
1216
+ SELECT e.address as email, GROUP_CONCAT(e.contact_id) as ids
1217
+ FROM emails e
1218
+ WHERE e.contact_id IS NOT NULL
1219
+ GROUP BY LOWER(e.address)
1220
+ HAVING COUNT(*) > 1
1221
+ `).all();
1222
+ return rows.map((r) => ({ email: r.email, contact_ids: r.ids.split(",") }));
1223
+ }
1224
+ function levenshtein(a, b) {
1225
+ const m = a.length, n = b.length;
1226
+ const dp = Array.from({ length: m + 1 }, (_, i) => Array.from({ length: n + 1 }, (_2, j) => i === 0 ? j : j === 0 ? i : 0));
1227
+ for (let i = 1;i <= m; i++)
1228
+ for (let j = 1;j <= n; j++)
1229
+ dp[i][j] = a[i - 1] === b[j - 1] ? dp[i - 1][j - 1] : 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]);
1230
+ return dp[m][n];
1231
+ }
1232
+ function findNameDuplicates(db) {
1233
+ const contacts = db.query(`SELECT id, display_name FROM contacts`).all();
1234
+ const pairs = [];
1235
+ for (let i = 0;i < contacts.length; i++) {
1236
+ for (let j = i + 1;j < contacts.length; j++) {
1237
+ const dist = levenshtein(contacts[i].display_name.toLowerCase(), contacts[j].display_name.toLowerCase());
1238
+ if (dist <= 2 && dist > 0) {
1239
+ pairs.push({ contact_ids: [contacts[i].id, contacts[j].id], similarity: dist });
1240
+ }
1241
+ }
1242
+ }
1243
+ return pairs;
1244
+ }
1245
+
940
1246
  // src/lib/import.ts
941
1247
  function parseCsv(text) {
942
1248
  const lines = text.split(/\r?\n/);
@@ -1403,6 +1709,9 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
1403
1709
  website: { type: "string", description: "Personal or professional website URL" },
1404
1710
  last_contacted_at: { type: "string", description: "ISO 8601 datetime of last contact" },
1405
1711
  preferred_contact_method: { type: "string", enum: ["email", "phone", "telegram", "whatsapp", "linkedin", "twitter", "other"] },
1712
+ status: { type: "string", enum: ["active", "pending_reply", "converted", "closed", "other"], description: "Contact lifecycle status (default: active)" },
1713
+ follow_up_at: { type: "string", description: "ISO 8601 datetime to follow up with this contact" },
1714
+ project_id: { type: "string", description: "Associate contact with a project ID" },
1406
1715
  emails: {
1407
1716
  type: "array",
1408
1717
  items: {
@@ -1471,7 +1780,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
1471
1780
  },
1472
1781
  {
1473
1782
  name: "update_contact",
1474
- 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.",
1783
+ description: "Update an existing contact's fields. Only provided fields are changed. Supports all contact fields including status, follow_up_at, project_id. Use emails_add/phones_add to append new contact methods without replacing existing ones.",
1475
1784
  inputSchema: {
1476
1785
  type: "object",
1477
1786
  properties: {
@@ -1487,7 +1796,12 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
1487
1796
  website: { type: "string" },
1488
1797
  last_contacted_at: { type: "string", description: "ISO 8601 datetime of last contact" },
1489
1798
  preferred_contact_method: { type: "string", enum: ["email", "phone", "telegram", "whatsapp", "linkedin", "twitter", "other"] },
1490
- source: { type: "string", enum: ["manual", "import", "linkedin", "github", "twitter", "email", "calendar", "crm", "other"] }
1799
+ status: { type: "string", enum: ["active", "pending_reply", "converted", "closed", "other"] },
1800
+ follow_up_at: { type: "string", description: "ISO 8601 datetime for follow-up reminder (null to clear)" },
1801
+ project_id: { type: "string", description: "Project ID to associate this contact with (null to clear)" },
1802
+ source: { type: "string", enum: ["manual", "import", "linkedin", "github", "twitter", "email", "calendar", "crm", "other"] },
1803
+ emails_add: { type: "array", items: { type: "object", properties: { address: { type: "string" }, type: { type: "string" }, is_primary: { type: "boolean" } }, required: ["address"] }, description: "New email addresses to append (duplicates are skipped)" },
1804
+ phones_add: { type: "array", items: { type: "object", properties: { number: { type: "string" }, type: { type: "string" }, country_code: { type: "string" }, is_primary: { type: "boolean" } }, required: ["number"] }, description: "New phone numbers to append (duplicates are skipped)" }
1491
1805
  },
1492
1806
  required: ["id"]
1493
1807
  }
@@ -1503,15 +1817,23 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
1503
1817
  },
1504
1818
  {
1505
1819
  name: "list_contacts",
1506
- description: "List contacts with optional filters. Supports filtering by company, tag, or source. Returns paginated results with total count.",
1820
+ description: "List contacts with optional filters. Supports filtering by company, tag(s), source, status, project, follow-up due, last_contacted date range, and archived state. Returns paginated results with total count.",
1507
1821
  inputSchema: {
1508
1822
  type: "object",
1509
1823
  properties: {
1510
1824
  company_id: { type: "string" },
1511
- tag_id: { type: "string", description: "Filter by tag ID" },
1825
+ tag_id: { type: "string", description: "Filter by a single tag ID" },
1826
+ tag_ids: { type: "array", items: { type: "string" }, description: "Filter by multiple tag IDs (AND logic \u2014 contact must have all tags)" },
1827
+ source: { type: "string", enum: ["manual", "import", "linkedin", "github", "twitter", "email", "calendar", "crm", "other"] },
1828
+ status: { type: "string", enum: ["active", "pending_reply", "converted", "closed", "other"] },
1829
+ project_id: { type: "string", description: "Filter by project ID" },
1830
+ archived: { type: "boolean", description: "Include archived contacts (default false)" },
1831
+ follow_up_due: { type: "boolean", description: "Only return contacts whose follow_up_at is in the past" },
1832
+ last_contacted_after: { type: "string", description: "ISO 8601 date \u2014 only contacts last contacted after this date" },
1833
+ last_contacted_before: { type: "string", description: "ISO 8601 date \u2014 only contacts last contacted before this date" },
1512
1834
  limit: { type: "number", description: "Max results (default 50)" },
1513
1835
  offset: { type: "number" },
1514
- order_by: { type: "string", enum: ["display_name", "created_at", "updated_at"] },
1836
+ order_by: { type: "string", enum: ["display_name", "created_at", "updated_at", "last_contacted_at", "follow_up_at"] },
1515
1837
  order_dir: { type: "string", enum: ["asc", "desc"] }
1516
1838
  }
1517
1839
  }
@@ -1585,12 +1907,14 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
1585
1907
  },
1586
1908
  {
1587
1909
  name: "list_companies",
1588
- description: "List companies with optional filters by industry or tag. Returns paginated results with total count.",
1910
+ description: "List companies with optional filters by industry, tag, project, or archived state. Returns paginated results with total count.",
1589
1911
  inputSchema: {
1590
1912
  type: "object",
1591
1913
  properties: {
1592
1914
  tag_id: { type: "string" },
1593
1915
  industry: { type: "string" },
1916
+ project_id: { type: "string", description: "Filter by project ID" },
1917
+ archived: { type: "boolean", description: "Include archived companies (default false)" },
1594
1918
  limit: { type: "number" },
1595
1919
  offset: { type: "number" }
1596
1920
  }
@@ -1925,6 +2249,188 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
1925
2249
  properties: { contact_id: { type: "string" } },
1926
2250
  required: ["contact_id"]
1927
2251
  }
2252
+ },
2253
+ {
2254
+ name: "get_contact_by_email",
2255
+ description: "Fast lookup of a contact by exact email address. Returns null if not found. Unlike search_contacts, this is a precise read-only lookup with no side effects.",
2256
+ inputSchema: {
2257
+ type: "object",
2258
+ properties: { email: { type: "string", description: "Exact email address to look up" } },
2259
+ required: ["email"]
2260
+ }
2261
+ },
2262
+ {
2263
+ name: "add_email_to_contact",
2264
+ description: "Append a new email address to a contact. Idempotent \u2014 silently skips if the email already exists on this contact.",
2265
+ inputSchema: {
2266
+ type: "object",
2267
+ properties: {
2268
+ contact_id: { type: "string" },
2269
+ address: { type: "string" },
2270
+ type: { type: "string", enum: ["work", "personal", "other"] },
2271
+ is_primary: { type: "boolean" }
2272
+ },
2273
+ required: ["contact_id", "address"]
2274
+ }
2275
+ },
2276
+ {
2277
+ name: "add_phone_to_contact",
2278
+ description: "Append a new phone number to a contact. Idempotent \u2014 silently skips if the number already exists on this contact.",
2279
+ inputSchema: {
2280
+ type: "object",
2281
+ properties: {
2282
+ contact_id: { type: "string" },
2283
+ number: { type: "string" },
2284
+ type: { type: "string", enum: ["mobile", "work", "home", "fax", "whatsapp", "other"] },
2285
+ country_code: { type: "string" },
2286
+ is_primary: { type: "boolean" }
2287
+ },
2288
+ required: ["contact_id", "number"]
2289
+ }
2290
+ },
2291
+ {
2292
+ name: "archive_contact",
2293
+ description: "Soft-delete a contact by setting archived=true. Archived contacts are excluded from list_contacts and search_contacts by default. Use unarchive_contact to restore.",
2294
+ inputSchema: {
2295
+ type: "object",
2296
+ properties: { id: { type: "string" } },
2297
+ required: ["id"]
2298
+ }
2299
+ },
2300
+ {
2301
+ name: "unarchive_contact",
2302
+ description: "Restore an archived contact. Sets archived=false so the contact reappears in lists and searches.",
2303
+ inputSchema: {
2304
+ type: "object",
2305
+ properties: { id: { type: "string" } },
2306
+ required: ["id"]
2307
+ }
2308
+ },
2309
+ {
2310
+ name: "archive_company",
2311
+ description: "Soft-delete a company by setting archived=true. Archived companies are excluded from list_companies by default.",
2312
+ inputSchema: {
2313
+ type: "object",
2314
+ properties: { id: { type: "string" } },
2315
+ required: ["id"]
2316
+ }
2317
+ },
2318
+ {
2319
+ name: "unarchive_company",
2320
+ description: "Restore an archived company. Sets archived=false.",
2321
+ inputSchema: {
2322
+ type: "object",
2323
+ properties: { id: { type: "string" } },
2324
+ required: ["id"]
2325
+ }
2326
+ },
2327
+ {
2328
+ name: "find_duplicates",
2329
+ description: "Scan contacts for potential duplicates \u2014 by shared email address (exact) or by similar display name (Levenshtein distance \u2264 2). Returns groups of contact IDs that may be the same person.",
2330
+ inputSchema: { type: "object", properties: {} }
2331
+ },
2332
+ {
2333
+ name: "list_interactions",
2334
+ description: "List activity log entries for a contact or company. Shows all logged interactions, creates, updates, and merges in reverse chronological order.",
2335
+ inputSchema: {
2336
+ type: "object",
2337
+ properties: {
2338
+ contact_id: { type: "string" },
2339
+ company_id: { type: "string" },
2340
+ limit: { type: "number", description: "Max results (default 50)" },
2341
+ offset: { type: "number" }
2342
+ }
2343
+ }
2344
+ },
2345
+ {
2346
+ name: "add_tag_to_company",
2347
+ description: "Apply an existing tag to a company. Use list_tags to find tag IDs.",
2348
+ inputSchema: {
2349
+ type: "object",
2350
+ properties: {
2351
+ company_id: { type: "string" },
2352
+ tag_id: { type: "string" }
2353
+ },
2354
+ required: ["company_id", "tag_id"]
2355
+ }
2356
+ },
2357
+ {
2358
+ name: "remove_tag_from_company",
2359
+ description: "Remove a tag from a company. The tag itself is not deleted.",
2360
+ inputSchema: {
2361
+ type: "object",
2362
+ properties: {
2363
+ company_id: { type: "string" },
2364
+ tag_id: { type: "string" }
2365
+ },
2366
+ required: ["company_id", "tag_id"]
2367
+ }
2368
+ },
2369
+ {
2370
+ name: "add_company_to_group",
2371
+ description: "Add a company to a group. Returns { added, already_member } \u2014 idempotent.",
2372
+ inputSchema: {
2373
+ type: "object",
2374
+ properties: {
2375
+ company_id: { type: "string" },
2376
+ group_id: { type: "string" }
2377
+ },
2378
+ required: ["company_id", "group_id"]
2379
+ }
2380
+ },
2381
+ {
2382
+ name: "remove_company_from_group",
2383
+ description: "Remove a company from a group.",
2384
+ inputSchema: {
2385
+ type: "object",
2386
+ properties: {
2387
+ company_id: { type: "string" },
2388
+ group_id: { type: "string" }
2389
+ },
2390
+ required: ["company_id", "group_id"]
2391
+ }
2392
+ },
2393
+ {
2394
+ name: "list_companies_in_group",
2395
+ description: "List all company IDs in a group.",
2396
+ inputSchema: {
2397
+ type: "object",
2398
+ properties: { group_id: { type: "string" } },
2399
+ required: ["group_id"]
2400
+ }
2401
+ },
2402
+ {
2403
+ name: "list_groups_for_company",
2404
+ description: "List all groups that a company belongs to.",
2405
+ inputSchema: {
2406
+ type: "object",
2407
+ properties: { company_id: { type: "string" } },
2408
+ required: ["company_id"]
2409
+ }
2410
+ },
2411
+ {
2412
+ name: "bulk_create_contacts",
2413
+ description: "Create multiple contacts in one call. Each item in the contacts array follows the same schema as create_contact. Returns { created, errors }.",
2414
+ inputSchema: {
2415
+ type: "object",
2416
+ properties: {
2417
+ contacts: {
2418
+ type: "array",
2419
+ items: { type: "object" },
2420
+ description: "Array of contact input objects (same schema as create_contact)"
2421
+ }
2422
+ },
2423
+ required: ["contacts"]
2424
+ }
2425
+ },
2426
+ {
2427
+ name: "auto_link_to_company",
2428
+ description: "Auto-link a contact to a company by matching the contact's email domain against known company domains. Only sets company_id if the contact has no company yet and a matching company exists. Returns the updated contact, or null if no match.",
2429
+ inputSchema: {
2430
+ type: "object",
2431
+ properties: { contact_id: { type: "string" } },
2432
+ required: ["contact_id"]
2433
+ }
1928
2434
  }
1929
2435
  ]
1930
2436
  }));
@@ -1946,6 +2452,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1946
2452
  website: a.website,
1947
2453
  last_contacted_at: a.last_contacted_at,
1948
2454
  preferred_contact_method: a.preferred_contact_method,
2455
+ status: a.status,
2456
+ follow_up_at: a.follow_up_at,
2457
+ project_id: a.project_id,
1949
2458
  emails: a.emails,
1950
2459
  phones: a.phones,
1951
2460
  addresses: a.addresses,
@@ -1974,7 +2483,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1974
2483
  website: rest.website,
1975
2484
  last_contacted_at: rest.last_contacted_at,
1976
2485
  preferred_contact_method: rest.preferred_contact_method,
1977
- source: rest.source
2486
+ status: rest.status,
2487
+ follow_up_at: rest.follow_up_at,
2488
+ project_id: rest.project_id,
2489
+ source: rest.source,
2490
+ emails_add: rest.emails_add,
2491
+ phones_add: rest.phones_add
1978
2492
  };
1979
2493
  const contact = updateContact(id, input);
1980
2494
  return { content: [{ type: "text", text: JSON.stringify(contact, null, 2) }] };
@@ -1987,6 +2501,14 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1987
2501
  const result = listContacts({
1988
2502
  company_id: a.company_id,
1989
2503
  tag_id: a.tag_id,
2504
+ tag_ids: a.tag_ids,
2505
+ source: a.source,
2506
+ status: a.status,
2507
+ project_id: a.project_id,
2508
+ archived: a.archived,
2509
+ follow_up_due: a.follow_up_due,
2510
+ last_contacted_after: a.last_contacted_after,
2511
+ last_contacted_before: a.last_contacted_before,
1990
2512
  limit: a.limit,
1991
2513
  offset: a.offset,
1992
2514
  order_by: a.order_by,
@@ -1999,6 +2521,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1999
2521
  return { content: [{ type: "text", text: JSON.stringify(contacts, null, 2) }] };
2000
2522
  }
2001
2523
  case "create_company": {
2524
+ const rawTagIds = a.tag_ids;
2525
+ const tagIds = typeof rawTagIds === "string" ? JSON.parse(rawTagIds) : rawTagIds;
2002
2526
  const input = {
2003
2527
  name: a.name,
2004
2528
  domain: a.domain,
@@ -2011,7 +2535,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2011
2535
  phones: a.phones,
2012
2536
  addresses: a.addresses,
2013
2537
  social_profiles: a.social_profiles,
2014
- tag_ids: a.tag_ids
2538
+ tag_ids: tagIds
2015
2539
  };
2016
2540
  const company = createCompany(input);
2017
2541
  return { content: [{ type: "text", text: JSON.stringify(company, null, 2) }] };
@@ -2045,6 +2569,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2045
2569
  const result = listCompanies({
2046
2570
  tag_id: a.tag_id,
2047
2571
  industry: a.industry,
2572
+ project_id: a.project_id,
2573
+ archived: a.archived,
2048
2574
  limit: a.limit,
2049
2575
  offset: a.offset
2050
2576
  });
@@ -2168,21 +2694,24 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2168
2694
  const emailAddresses = a.emails?.map((e) => e.address) ?? [];
2169
2695
  let found = null;
2170
2696
  for (const addr of emailAddresses) {
2171
- const emailRow = db.prepare(`SELECT contact_id FROM emails WHERE address = ? AND contact_id IS NOT NULL LIMIT 1`).get(addr);
2697
+ const emailRow = db.prepare(`SELECT contact_id FROM emails WHERE LOWER(address) = LOWER(?) AND contact_id IS NOT NULL LIMIT 1`).get(addr);
2172
2698
  if (emailRow) {
2173
2699
  found = getContact(emailRow.contact_id);
2174
2700
  break;
2175
2701
  }
2176
2702
  }
2177
- if (!found && a.display_name) {
2178
- const results = searchContacts(a.display_name);
2179
- if (results.length > 0)
2180
- found = results[0];
2703
+ if (!found) {
2704
+ const nameQuery = a.display_name ?? (a.first_name || a.last_name ? `${a.first_name ?? ""} ${a.last_name ?? ""}`.trim() : null);
2705
+ if (nameQuery) {
2706
+ const results = searchContacts(nameQuery);
2707
+ if (results.length > 0)
2708
+ found = results[0];
2709
+ }
2181
2710
  }
2182
2711
  if (found) {
2183
2712
  return { content: [{ type: "text", text: JSON.stringify({ contact: found, found: true, created: false }, null, 2) }] };
2184
2713
  }
2185
- const input = {
2714
+ const focInput = {
2186
2715
  first_name: a.first_name,
2187
2716
  last_name: a.last_name,
2188
2717
  display_name: a.display_name,
@@ -2194,6 +2723,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2194
2723
  website: a.website,
2195
2724
  last_contacted_at: a.last_contacted_at,
2196
2725
  preferred_contact_method: a.preferred_contact_method,
2726
+ status: a.status,
2727
+ follow_up_at: a.follow_up_at,
2728
+ project_id: a.project_id,
2197
2729
  emails: a.emails,
2198
2730
  phones: a.phones,
2199
2731
  addresses: a.addresses,
@@ -2201,7 +2733,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2201
2733
  tag_ids: a.tag_ids,
2202
2734
  source: a.source
2203
2735
  };
2204
- const contact = createContact(input);
2736
+ const contact = createContact(focInput);
2205
2737
  return { content: [{ type: "text", text: JSON.stringify({ contact, found: false, created: true }, null, 2) }] };
2206
2738
  }
2207
2739
  case "upsert_contact": {
@@ -2325,8 +2857,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2325
2857
  }
2326
2858
  case "add_contact_to_group": {
2327
2859
  const db = getDatabase();
2328
- addContactToGroup(db, a.contact_id, a.group_id);
2329
- return { content: [{ type: "text", text: `Contact ${a.contact_id} added to group ${a.group_id}` }] };
2860
+ const result = addContactToGroup(db, a.contact_id, a.group_id);
2861
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
2330
2862
  }
2331
2863
  case "remove_contact_from_group": {
2332
2864
  const db = getDatabase();
@@ -2343,6 +2875,108 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2343
2875
  const groups = listGroupsForContact(db, a.contact_id);
2344
2876
  return { content: [{ type: "text", text: JSON.stringify(groups, null, 2) }] };
2345
2877
  }
2878
+ case "get_contact_by_email": {
2879
+ const contact = getContactByEmail(a.email);
2880
+ if (!contact)
2881
+ return { content: [{ type: "text", text: "null" }] };
2882
+ return { content: [{ type: "text", text: JSON.stringify(contact, null, 2) }] };
2883
+ }
2884
+ case "add_email_to_contact": {
2885
+ const contact = addEmailToContact(a.contact_id, {
2886
+ address: a.address,
2887
+ type: a.type,
2888
+ is_primary: a.is_primary
2889
+ });
2890
+ return { content: [{ type: "text", text: JSON.stringify(contact, null, 2) }] };
2891
+ }
2892
+ case "add_phone_to_contact": {
2893
+ const contact = addPhoneToContact(a.contact_id, {
2894
+ number: a.number,
2895
+ type: a.type,
2896
+ country_code: a.country_code,
2897
+ is_primary: a.is_primary
2898
+ });
2899
+ return { content: [{ type: "text", text: JSON.stringify(contact, null, 2) }] };
2900
+ }
2901
+ case "archive_contact": {
2902
+ const contact = archiveContact(a.id);
2903
+ return { content: [{ type: "text", text: JSON.stringify(contact, null, 2) }] };
2904
+ }
2905
+ case "unarchive_contact": {
2906
+ const contact = unarchiveContact(a.id);
2907
+ return { content: [{ type: "text", text: JSON.stringify(contact, null, 2) }] };
2908
+ }
2909
+ case "archive_company": {
2910
+ const company = archiveCompany(a.id);
2911
+ return { content: [{ type: "text", text: JSON.stringify(company, null, 2) }] };
2912
+ }
2913
+ case "unarchive_company": {
2914
+ const company = unarchiveCompany(a.id);
2915
+ return { content: [{ type: "text", text: JSON.stringify(company, null, 2) }] };
2916
+ }
2917
+ case "find_duplicates": {
2918
+ const db = getDatabase();
2919
+ const byEmail = findEmailDuplicates(db);
2920
+ const byName = findNameDuplicates(db);
2921
+ return { content: [{ type: "text", text: JSON.stringify({ by_email: byEmail, by_name: byName }, null, 2) }] };
2922
+ }
2923
+ case "list_interactions": {
2924
+ const result = listActivity({
2925
+ contact_id: a.contact_id,
2926
+ company_id: a.company_id,
2927
+ limit: a.limit,
2928
+ offset: a.offset
2929
+ });
2930
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
2931
+ }
2932
+ case "add_tag_to_company": {
2933
+ addTagToCompany(a.company_id, a.tag_id);
2934
+ return { content: [{ type: "text", text: `Tag ${a.tag_id} added to company ${a.company_id}` }] };
2935
+ }
2936
+ case "remove_tag_from_company": {
2937
+ removeTagFromCompany(a.company_id, a.tag_id);
2938
+ return { content: [{ type: "text", text: `Tag ${a.tag_id} removed from company ${a.company_id}` }] };
2939
+ }
2940
+ case "add_company_to_group": {
2941
+ const db = getDatabase();
2942
+ const result = addCompanyToGroup(db, a.company_id, a.group_id);
2943
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
2944
+ }
2945
+ case "remove_company_from_group": {
2946
+ const db = getDatabase();
2947
+ removeCompanyFromGroup(db, a.company_id, a.group_id);
2948
+ return { content: [{ type: "text", text: `Company ${a.company_id} removed from group ${a.group_id}` }] };
2949
+ }
2950
+ case "list_companies_in_group": {
2951
+ const db = getDatabase();
2952
+ const companyIds = listCompaniesInGroup(db, a.group_id);
2953
+ return { content: [{ type: "text", text: JSON.stringify(companyIds, null, 2) }] };
2954
+ }
2955
+ case "list_groups_for_company": {
2956
+ const db = getDatabase();
2957
+ const groups = listGroupsForCompany(db, a.company_id);
2958
+ return { content: [{ type: "text", text: JSON.stringify(groups, null, 2) }] };
2959
+ }
2960
+ case "bulk_create_contacts": {
2961
+ const contacts = a.contacts;
2962
+ let created = 0;
2963
+ const errors = [];
2964
+ for (const item of contacts) {
2965
+ try {
2966
+ createContact(item);
2967
+ created++;
2968
+ } catch (err) {
2969
+ errors.push(err instanceof Error ? err.message : String(err));
2970
+ }
2971
+ }
2972
+ return { content: [{ type: "text", text: JSON.stringify({ created, errors: errors.length, error_details: errors }, null, 2) }] };
2973
+ }
2974
+ case "auto_link_to_company": {
2975
+ const contact = autoLinkContactToCompany(a.contact_id);
2976
+ if (!contact)
2977
+ return { content: [{ type: "text", text: "null" }] };
2978
+ return { content: [{ type: "text", text: JSON.stringify(contact, null, 2) }] };
2979
+ }
2346
2980
  default:
2347
2981
  return { content: [{ type: "text", text: `Unknown tool: ${name}` }], isError: true };
2348
2982
  }