@hasna/contacts 0.2.1 → 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
@@ -160,9 +160,7 @@ var MIGRATIONS = [
160
160
  last_name,
161
161
  nickname,
162
162
  notes,
163
- job_title,
164
- content='contacts',
165
- content_rowid='rowid'
163
+ job_title
166
164
  );
167
165
 
168
166
  CREATE TRIGGER IF NOT EXISTS contacts_fts_insert AFTER INSERT ON contacts BEGIN
@@ -200,6 +198,20 @@ var MIGRATIONS = [
200
198
  group_id TEXT NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
201
199
  PRIMARY KEY (contact_id, group_id)
202
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
+ );
203
215
  `
204
216
  ];
205
217
  var _db = null;
@@ -271,11 +283,32 @@ class DuplicateTagNameError extends Error {
271
283
  }
272
284
 
273
285
  // src/db/activity.ts
286
+ function rowToActivity(row) {
287
+ return { ...row };
288
+ }
274
289
  function logActivity(db, input) {
275
290
  const id = uuid();
276
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]);
277
292
  return db.query(`SELECT * FROM activity_log WHERE id = ?`).get(id);
278
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
+ }
279
312
 
280
313
  // src/db/contacts.ts
281
314
  function rowToContact(row) {
@@ -283,7 +316,11 @@ function rowToContact(row) {
283
316
  ...row,
284
317
  source: row.source,
285
318
  custom_fields: JSON.parse(row.custom_fields || "{}"),
286
- 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
287
324
  };
288
325
  }
289
326
  function rowToEmail(row) {
@@ -320,7 +357,9 @@ function rowToTag(row) {
320
357
  function rowToCompany(row) {
321
358
  return {
322
359
  ...row,
323
- 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
324
363
  };
325
364
  }
326
365
  function insertEmails(db, contactId, companyId, emails) {
@@ -364,8 +403,8 @@ function createContact(input, db) {
364
403
  const firstName = input.first_name ?? "";
365
404
  const lastName = input.last_name ?? "";
366
405
  const displayName = input.display_name ?? (firstName || lastName ? `${firstName} ${lastName}`.trim() : "Unnamed Contact");
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
369
408
  id,
370
409
  firstName,
371
410
  lastName,
@@ -381,6 +420,9 @@ function createContact(input, db) {
381
420
  input.last_contacted_at ?? null,
382
421
  input.website ?? null,
383
422
  input.preferred_contact_method ?? null,
423
+ input.status ?? "active",
424
+ input.follow_up_at ?? null,
425
+ input.project_id ?? null,
384
426
  timestamp,
385
427
  timestamp
386
428
  ]);
@@ -415,12 +457,21 @@ function listContacts(opts = {}, db) {
415
457
  offset = 0,
416
458
  company_id,
417
459
  tag_id,
460
+ tag_ids,
418
461
  source,
462
+ status,
463
+ project_id,
464
+ archived = false,
465
+ follow_up_due,
466
+ last_contacted_after,
467
+ last_contacted_before,
419
468
  order_by = "display_name",
420
469
  order_dir = "asc"
421
470
  } = opts;
422
471
  const conditions = [];
423
472
  const params = [];
473
+ conditions.push("c.archived = ?");
474
+ params.push(archived ? 1 : 0);
424
475
  if (company_id) {
425
476
  conditions.push("c.company_id = ?");
426
477
  params.push(company_id);
@@ -429,12 +480,37 @@ function listContacts(opts = {}, db) {
429
480
  conditions.push("c.source = ?");
430
481
  params.push(source);
431
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
+ }
432
491
  if (tag_id) {
433
492
  conditions.push("EXISTS (SELECT 1 FROM contact_tags ct WHERE ct.contact_id = c.id AND ct.tag_id = ?)");
434
493
  params.push(tag_id);
435
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
+ }
436
512
  const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
437
- 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";
438
514
  const validOrderDir = order_dir === "desc" ? "DESC" : "ASC";
439
515
  const totalRow = d.query(`SELECT COUNT(*) as total FROM contacts c ${where}`).get(...params);
440
516
  const rows = d.query(`SELECT c.* FROM contacts c ${where} ORDER BY c.${validOrderBy} ${validOrderDir} LIMIT ? OFFSET ?`).all(...params, limit, offset);
@@ -504,8 +580,36 @@ function updateContact(id, input, db) {
504
580
  setClauses.push("preferred_contact_method = ?");
505
581
  params.push(input.preferred_contact_method);
506
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
+ }
507
595
  params.push(id);
508
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
+ }
509
613
  logActivity(d, { contact_id: id, action: "contact.updated", details: `Updated contact: ${existing.display_name}` });
510
614
  const row = d.query(`SELECT * FROM contacts WHERE id = ?`).get(id);
511
615
  return loadContactDetails(d, rowToContact(row));
@@ -523,25 +627,31 @@ function searchContacts(query, db) {
523
627
  const ftsRows = d.query(`
524
628
  SELECT c.* FROM contacts c
525
629
  JOIN contacts_fts fts ON fts.id = c.id
526
- WHERE contacts_fts MATCH ?
630
+ WHERE contacts_fts MATCH ? AND c.archived = 0
527
631
  ORDER BY rank
528
632
  LIMIT 50
529
633
  `).all(`"${query.replace(/"/g, '""')}"*`);
530
634
  const emailRows = d.query(`
531
635
  SELECT DISTINCT c.* FROM contacts c
532
636
  JOIN emails e ON e.contact_id = c.id
533
- WHERE e.address LIKE ?
637
+ WHERE e.address LIKE ? AND c.archived = 0
534
638
  LIMIT 20
535
639
  `).all(`%${query}%`);
536
640
  const phoneRows = d.query(`
537
641
  SELECT DISTINCT c.* FROM contacts c
538
642
  JOIN phones p ON p.contact_id = c.id
539
- 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
540
650
  LIMIT 20
541
651
  `).all(`%${query}%`);
542
652
  const seen = new Set;
543
653
  const allRows = [];
544
- for (const row of [...ftsRows, ...emailRows, ...phoneRows]) {
654
+ for (const row of [...ftsRows, ...emailRows, ...phoneRows, ...companyRows]) {
545
655
  if (!seen.has(row.id)) {
546
656
  seen.add(row.id);
547
657
  allRows.push(row);
@@ -557,8 +667,24 @@ function mergeContacts(keepId, mergeId, db) {
557
667
  const mergeRow = d.query(`SELECT * FROM contacts WHERE id = ?`).get(mergeId);
558
668
  if (!mergeRow)
559
669
  throw new ContactNotFoundError(mergeId);
560
- d.run(`UPDATE emails SET contact_id = ? WHERE contact_id = ?`, [keepId, mergeId]);
561
- 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
+ }
562
688
  d.run(`UPDATE addresses SET contact_id = ? WHERE contact_id = ?`, [keepId, mergeId]);
563
689
  d.run(`UPDATE social_profiles SET contact_id = ? WHERE contact_id = ?`, [keepId, mergeId]);
564
690
  const mergeTags = d.query(`SELECT tag_id FROM contact_tags WHERE contact_id = ?`).all(mergeId);
@@ -605,6 +731,79 @@ function mergeContacts(keepId, mergeId, db) {
605
731
  const finalRow = d.query(`SELECT * FROM contacts WHERE id = ?`).get(keepId);
606
732
  return loadContactDetails(d, rowToContact(finalRow));
607
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
+ }
608
807
 
609
808
  // src/db/groups.ts
610
809
  function createGroup(db, input) {
@@ -616,7 +815,10 @@ function getGroup(db, id) {
616
815
  return db.query(`SELECT * FROM groups WHERE id = ?`).get(id);
617
816
  }
618
817
  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();
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();
620
822
  }
621
823
  function updateGroup(db, id, input) {
622
824
  const fields = [];
@@ -639,7 +841,11 @@ function deleteGroup(db, id) {
639
841
  db.query(`DELETE FROM groups WHERE id = ?`).run(id);
640
842
  }
641
843
  function addContactToGroup(db, contactId, groupId) {
642
- 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 };
643
849
  }
644
850
  function removeContactFromGroup(db, contactId, groupId) {
645
851
  db.query(`DELETE FROM contact_groups WHERE contact_id = ? AND group_id = ?`).run(contactId, groupId);
@@ -651,6 +857,23 @@ function listContactsInGroup(db, groupId) {
651
857
  function listGroupsForContact(db, contactId) {
652
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);
653
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
+ }
654
877
 
655
878
  // src/db/tags.ts
656
879
  function rowToTag2(row) {
@@ -695,12 +918,28 @@ function removeTagFromContact(contactId, tagId, db) {
695
918
  const d = db || getDatabase();
696
919
  d.run(`DELETE FROM contact_tags WHERE contact_id = ? AND tag_id = ?`, [contactId, tagId]);
697
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
+ }
698
935
 
699
936
  // src/db/companies.ts
700
937
  function rowToCompany2(row) {
701
938
  return {
702
939
  ...row,
703
- 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
704
943
  };
705
944
  }
706
945
  function insertEmails2(db, companyId, emails) {
@@ -810,15 +1049,23 @@ function listCompanies(opts = {}, db) {
810
1049
  offset = 0,
811
1050
  industry,
812
1051
  tag_id,
1052
+ project_id,
1053
+ archived = false,
813
1054
  order_by = "name",
814
1055
  order_dir = "asc"
815
1056
  } = opts;
816
1057
  const conditions = [];
817
1058
  const params = [];
1059
+ conditions.push("co.archived = ?");
1060
+ params.push(archived ? 1 : 0);
818
1061
  if (industry) {
819
1062
  conditions.push("co.industry = ?");
820
1063
  params.push(industry);
821
1064
  }
1065
+ if (project_id) {
1066
+ conditions.push("co.project_id = ?");
1067
+ params.push(project_id);
1068
+ }
822
1069
  if (tag_id) {
823
1070
  conditions.push("EXISTS (SELECT 1 FROM company_tags ct WHERE ct.company_id = co.id AND ct.tag_id = ?)");
824
1071
  params.push(tag_id);
@@ -874,6 +1121,10 @@ function updateCompany(id, input, db) {
874
1121
  setClauses.push("custom_fields = ?");
875
1122
  params.push(JSON.stringify(input.custom_fields));
876
1123
  }
1124
+ if ("project_id" in input && input.project_id !== undefined) {
1125
+ setClauses.push("project_id = ?");
1126
+ params.push(input.project_id);
1127
+ }
877
1128
  params.push(id);
878
1129
  d.run(`UPDATE companies SET ${setClauses.join(", ")} WHERE id = ?`, params);
879
1130
  logActivity(d, { company_id: id, action: "company.updated", details: `Updated company: ${existing.name}` });
@@ -897,6 +1148,26 @@ function searchCompanies(query, db) {
897
1148
  `).all(`%${query}%`, `%${query}%`, `%${query}%`, `%${query}%`);
898
1149
  return rows.map((row) => loadCompanyDetails(d, rowToCompany2(row)));
899
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
+ }
900
1171
 
901
1172
  // src/db/relationships.ts
902
1173
  function rowToRelationship(row) {
@@ -939,6 +1210,39 @@ function deleteRelationship(id, db) {
939
1210
  d.run(`DELETE FROM contact_relationships WHERE id = ?`, [id]);
940
1211
  }
941
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
+
942
1246
  // src/lib/import.ts
943
1247
  function parseCsv(text) {
944
1248
  const lines = text.split(/\r?\n/);
@@ -1405,6 +1709,9 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
1405
1709
  website: { type: "string", description: "Personal or professional website URL" },
1406
1710
  last_contacted_at: { type: "string", description: "ISO 8601 datetime of last contact" },
1407
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" },
1408
1715
  emails: {
1409
1716
  type: "array",
1410
1717
  items: {
@@ -1473,7 +1780,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
1473
1780
  },
1474
1781
  {
1475
1782
  name: "update_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.",
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.",
1477
1784
  inputSchema: {
1478
1785
  type: "object",
1479
1786
  properties: {
@@ -1489,7 +1796,12 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
1489
1796
  website: { type: "string" },
1490
1797
  last_contacted_at: { type: "string", description: "ISO 8601 datetime of last contact" },
1491
1798
  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"] }
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)" }
1493
1805
  },
1494
1806
  required: ["id"]
1495
1807
  }
@@ -1505,15 +1817,23 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
1505
1817
  },
1506
1818
  {
1507
1819
  name: "list_contacts",
1508
- 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.",
1509
1821
  inputSchema: {
1510
1822
  type: "object",
1511
1823
  properties: {
1512
1824
  company_id: { type: "string" },
1513
- 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" },
1514
1834
  limit: { type: "number", description: "Max results (default 50)" },
1515
1835
  offset: { type: "number" },
1516
- 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"] },
1517
1837
  order_dir: { type: "string", enum: ["asc", "desc"] }
1518
1838
  }
1519
1839
  }
@@ -1587,12 +1907,14 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
1587
1907
  },
1588
1908
  {
1589
1909
  name: "list_companies",
1590
- 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.",
1591
1911
  inputSchema: {
1592
1912
  type: "object",
1593
1913
  properties: {
1594
1914
  tag_id: { type: "string" },
1595
1915
  industry: { type: "string" },
1916
+ project_id: { type: "string", description: "Filter by project ID" },
1917
+ archived: { type: "boolean", description: "Include archived companies (default false)" },
1596
1918
  limit: { type: "number" },
1597
1919
  offset: { type: "number" }
1598
1920
  }
@@ -1927,6 +2249,188 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
1927
2249
  properties: { contact_id: { type: "string" } },
1928
2250
  required: ["contact_id"]
1929
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
+ }
1930
2434
  }
1931
2435
  ]
1932
2436
  }));
@@ -1948,6 +2452,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1948
2452
  website: a.website,
1949
2453
  last_contacted_at: a.last_contacted_at,
1950
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,
1951
2458
  emails: a.emails,
1952
2459
  phones: a.phones,
1953
2460
  addresses: a.addresses,
@@ -1976,7 +2483,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1976
2483
  website: rest.website,
1977
2484
  last_contacted_at: rest.last_contacted_at,
1978
2485
  preferred_contact_method: rest.preferred_contact_method,
1979
- 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
1980
2492
  };
1981
2493
  const contact = updateContact(id, input);
1982
2494
  return { content: [{ type: "text", text: JSON.stringify(contact, null, 2) }] };
@@ -1989,6 +2501,14 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1989
2501
  const result = listContacts({
1990
2502
  company_id: a.company_id,
1991
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,
1992
2512
  limit: a.limit,
1993
2513
  offset: a.offset,
1994
2514
  order_by: a.order_by,
@@ -2001,6 +2521,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2001
2521
  return { content: [{ type: "text", text: JSON.stringify(contacts, null, 2) }] };
2002
2522
  }
2003
2523
  case "create_company": {
2524
+ const rawTagIds = a.tag_ids;
2525
+ const tagIds = typeof rawTagIds === "string" ? JSON.parse(rawTagIds) : rawTagIds;
2004
2526
  const input = {
2005
2527
  name: a.name,
2006
2528
  domain: a.domain,
@@ -2013,7 +2535,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2013
2535
  phones: a.phones,
2014
2536
  addresses: a.addresses,
2015
2537
  social_profiles: a.social_profiles,
2016
- tag_ids: a.tag_ids
2538
+ tag_ids: tagIds
2017
2539
  };
2018
2540
  const company = createCompany(input);
2019
2541
  return { content: [{ type: "text", text: JSON.stringify(company, null, 2) }] };
@@ -2047,6 +2569,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2047
2569
  const result = listCompanies({
2048
2570
  tag_id: a.tag_id,
2049
2571
  industry: a.industry,
2572
+ project_id: a.project_id,
2573
+ archived: a.archived,
2050
2574
  limit: a.limit,
2051
2575
  offset: a.offset
2052
2576
  });
@@ -2170,21 +2694,24 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2170
2694
  const emailAddresses = a.emails?.map((e) => e.address) ?? [];
2171
2695
  let found = null;
2172
2696
  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);
2697
+ const emailRow = db.prepare(`SELECT contact_id FROM emails WHERE LOWER(address) = LOWER(?) AND contact_id IS NOT NULL LIMIT 1`).get(addr);
2174
2698
  if (emailRow) {
2175
2699
  found = getContact(emailRow.contact_id);
2176
2700
  break;
2177
2701
  }
2178
2702
  }
2179
- if (!found && a.display_name) {
2180
- const results = searchContacts(a.display_name);
2181
- if (results.length > 0)
2182
- 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
+ }
2183
2710
  }
2184
2711
  if (found) {
2185
2712
  return { content: [{ type: "text", text: JSON.stringify({ contact: found, found: true, created: false }, null, 2) }] };
2186
2713
  }
2187
- const input = {
2714
+ const focInput = {
2188
2715
  first_name: a.first_name,
2189
2716
  last_name: a.last_name,
2190
2717
  display_name: a.display_name,
@@ -2196,6 +2723,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2196
2723
  website: a.website,
2197
2724
  last_contacted_at: a.last_contacted_at,
2198
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,
2199
2729
  emails: a.emails,
2200
2730
  phones: a.phones,
2201
2731
  addresses: a.addresses,
@@ -2203,7 +2733,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2203
2733
  tag_ids: a.tag_ids,
2204
2734
  source: a.source
2205
2735
  };
2206
- const contact = createContact(input);
2736
+ const contact = createContact(focInput);
2207
2737
  return { content: [{ type: "text", text: JSON.stringify({ contact, found: false, created: true }, null, 2) }] };
2208
2738
  }
2209
2739
  case "upsert_contact": {
@@ -2327,8 +2857,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2327
2857
  }
2328
2858
  case "add_contact_to_group": {
2329
2859
  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}` }] };
2860
+ const result = addContactToGroup(db, a.contact_id, a.group_id);
2861
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
2332
2862
  }
2333
2863
  case "remove_contact_from_group": {
2334
2864
  const db = getDatabase();
@@ -2345,6 +2875,108 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2345
2875
  const groups = listGroupsForContact(db, a.contact_id);
2346
2876
  return { content: [{ type: "text", text: JSON.stringify(groups, null, 2) }] };
2347
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
+ }
2348
2980
  default:
2349
2981
  return { content: [{ type: "text", text: `Unknown tool: ${name}` }], isError: true };
2350
2982
  }