Skip to main content

max / goingson

Let go-mcp attach an event to a person list_contacts is the read surface and the resolver; create_event, bulk_import_events and update_event take contact_id or a contact name. Resolution matches exact display name, then exact nickname, then a unique substring, refusing ambiguity rather than guessing and never creating a contact. Implicit contacts are searched even though the listing hides them. No new capability: attaching runs under the go.event.* caps already gating the write, and there is no contact write tool.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-03 19:10 UTC
Signed with PGP, not checked
Commit: ab557157c0598dd00fc64ad5650ed986cd0e42c0
Parent: f9c4a3c
6 files changed, +727 insertions, -11 deletions
@@ -9,7 +9,8 @@
9 9
10 10 use goingson_core::UserId;
11 11 use goingson_db_sqlite::{
12 - SqliteEventRepository, SqliteProblemRepository, SqliteProjectRepository, SqliteTaskRepository,
12 + SqliteContactRepository, SqliteEventRepository, SqliteProblemRepository,
13 + SqliteProjectRepository, SqliteTaskRepository,
13 14 };
14 15 use sqlx::SqlitePool;
15 16 use uuid::Uuid;
@@ -49,6 +50,10 @@
49 50 pub fn events(&self) -> SqliteEventRepository {
50 51 SqliteEventRepository::new(self.pool.clone())
51 52 }
53 +
54 + pub fn contacts(&self) -> SqliteContactRepository {
55 + SqliteContactRepository::new(self.pool.clone())
56 + }
52 57 }
53 58
54 59 /// Default location of `goingson.db`, matching Tauri's `app_data_dir` for the
@@ -103,6 +103,22 @@
103 103 })
104 104 }
105 105
106 + /// Parse a contact id string into a [`ContactId`], or an `InvalidArgs`.
107 + ///
108 + /// The error names `list_contacts` for the same reason [`parse_project_id`]
109 + /// names `list_projects`: it is the only tool that turns a person's name into
110 + /// the id the write tools key on.
111 + pub fn parse_contact_id(tool: &str, s: &str) -> Result<ContactId, Error> {
112 + Uuid::parse_str(s.trim())
113 + .map(ContactId::from_uuid)
114 + .map_err(|_| Error::InvalidArgs {
115 + tool: tool.to_string(),
116 + message: format!(
117 + "`{s}` is not a valid contact id (expected a UUID; call list_contacts to resolve a name to its id)"
118 + ),
119 + })
120 + }
121 +
106 122 /// Parse an event id string into an [`EventId`], or an `InvalidArgs`.
107 123 pub fn parse_event_id(tool: &str, s: &str) -> Result<EventId, Error> {
108 124 Uuid::parse_str(s.trim())
@@ -1480,3 +1480,328 @@
1480 1480 assert!(matches!(err, Error::InvalidArgs { .. }), "got {err:?}");
1481 1481 }
1482 1482 }
1483 +
1484 + // Contacts. Read-only over MCP, so these seed through the repository directly,
1485 + // the same layer the app writes through.
1486 +
1487 + /// Seed a contact, optionally implicit and optionally with a primary email.
1488 + async fn seed_contact(
1489 + ctx: &Ctx,
1490 + display_name: &str,
1491 + nickname: Option<&str>,
1492 + email: Option<&str>,
1493 + is_implicit: bool,
1494 + ) -> goingson_core::Contact {
1495 + use goingson_core::repository::ContactRepository;
1496 + let repo = ctx.contacts();
1497 + let contact = repo
1498 + .create(
1499 + DESKTOP_USER_ID,
1500 + goingson_core::NewContact {
1501 + display_name: display_name.to_string(),
1502 + nickname: nickname.map(str::to_string),
1503 + company: None,
1504 + title: None,
1505 + notes: String::new(),
1506 + tags: Vec::new(),
1507 + birthday: None,
1508 + timezone: None,
1509 + is_implicit,
1510 + },
1511 + )
1512 + .await
1513 + .expect("seed contact");
1514 + if let Some(address) = email {
1515 + repo.add_email(
1516 + contact.id,
1517 + DESKTOP_USER_ID,
1518 + goingson_core::NewContactEmail {
1519 + address: address.to_string(),
1520 + label: "work".to_string(),
1521 + is_primary: true,
1522 + },
1523 + )
1524 + .await
1525 + .expect("seed contact email");
1526 + }
1527 + contact
1528 + }
1529 +
1530 + #[tokio::test]
1531 + async fn list_contacts_hides_implicit_people_until_asked() {
1532 + let ctx = Arc::new(Ctx::new(seed_db().await));
1533 + seed_contact(&ctx, "Jane Smith", None, Some("jane@example.com"), false).await;
1534 + seed_contact(&ctx, "Mailing List", None, Some("list@example.com"), true).await;
1535 + let reg = tools::registry(ctx);
1536 +
1537 + let listed = call(&reg, "list_contacts", json!({})).await;
1538 + assert_eq!(listed["total"], 1);
1539 + assert_eq!(listed["contacts"][0]["name"], "Jane Smith");
1540 + assert_eq!(listed["contacts"][0]["email"], "jane@example.com");
1541 +
1542 + let all = call(&reg, "list_contacts", json!({ "include_implicit": true })).await;
1543 + assert_eq!(all["total"], 2);
1544 +
1545 + // Search spans the email address, not just the name.
1546 + let found = call(&reg, "list_contacts", json!({ "search": "jane@example" })).await;
1547 + assert_eq!(found["total"], 1);
1548 + assert_eq!(found["contacts"][0]["name"], "Jane Smith");
1549 + }
1550 +
1551 + #[tokio::test]
1552 + async fn an_event_attaches_to_a_person_by_id_or_by_name() {
1553 + let ctx = Arc::new(Ctx::new(seed_db().await));
1554 + let jane = seed_contact(
1555 + &ctx,
1556 + "Jane Smith",
1557 + Some("JJ"),
1558 + Some("jane@example.com"),
1559 + false,
1560 + )
1561 + .await;
1562 + let reg = tools::registry(ctx);
1563 +
1564 + let by_id = call(
1565 + &reg,
1566 + "create_event",
1567 + json!({
1568 + "title": "1:1",
1569 + "start": "2026-08-03T09:00:00Z",
1570 + "contact_id": jane.id.to_string()
1571 + }),
1572 + )
1573 + .await;
1574 + let event = call(&reg, "get_event", json!({ "id": by_id["id"] })).await;
1575 + assert_eq!(event["contact"], "Jane Smith");
1576 + assert_eq!(event["contact_id"], jane.id.to_string());
1577 +
1578 + // Exact display name, exact nickname, and a partial that names one person
1579 + // all land on the same row.
1580 + for name in ["jane smith", "JJ", "Smith"] {
1581 + let created = call(
1582 + &reg,
1583 + "create_event",
1584 + json!({ "title": "sync", "start": "2026-08-04T09:00:00Z", "contact": name }),
1585 + )
1586 + .await;
1587 + let event = call(&reg, "get_event", json!({ "id": created["id"] })).await;
1588 + assert_eq!(
1589 + event["contact_id"],
1590 + jane.id.to_string(),
1591 + "resolving `{name}`"
1592 + );
1593 + }
1594 + }
1595 +
1596 + #[tokio::test]
1597 + async fn a_name_resolves_to_an_implicit_person_but_never_creates_one() {
1598 + let ctx = Arc::new(Ctx::new(seed_db().await));
1599 + seed_contact(&ctx, "Sam Reyes", None, Some("sam@example.com"), true).await;
1600 + let reg = tools::registry(ctx);
1601 + let grants: HashSet<String> = reg.write_capabilities().into_iter().map(|c| c.id).collect();
1602 +
1603 + // Hidden from the default listing, still attachable: the person you have
1604 + // only ever emailed is still the person the meeting is with.
1605 + let created = call(
1606 + &reg,
1607 + "create_event",
1608 + json!({ "title": "intro", "start": "2026-08-03T09:00:00Z", "contact": "Sam Reyes" }),
1609 + )
1610 + .await;
1611 + assert_eq!(
1612 + call(&reg, "get_event", json!({ "id": created["id"] })).await["contact"],
1613 + "Sam Reyes"
1614 + );
1615 +
1616 + // A name nobody answers to is an error, not a new contact.
1617 + let err = reg
1618 + .call(
1619 + "create_event",
1620 + json!({ "title": "x", "start": "2026-08-03T09:00:00Z", "contact": "Nobody At All" }),
1621 + Some(&grants),
1622 + )
1623 + .await
1624 + .expect_err("an unknown name must be refused");
1625 + assert!(matches!(err, Error::InvalidArgs { .. }), "got {err:?}");
1626 +
1627 + let listed = call(&reg, "list_contacts", json!({ "include_implicit": true })).await;
1628 + assert_eq!(
1629 + listed["total"], 1,
1630 + "no contact was created as a side effect"
1631 + );
1632 + }
1633 +
1634 + #[tokio::test]
1635 + async fn an_ambiguous_name_is_refused_rather_than_guessed() {
1636 + let ctx = Arc::new(Ctx::new(seed_db().await));
1637 + let first = seed_contact(&ctx, "Alex Kim", None, Some("alex@one.example"), false).await;
1638 + seed_contact(&ctx, "Alex Kim", None, Some("alex@two.example"), false).await;
1639 + seed_contact(&ctx, "Alexis Wong", None, None, false).await;
1640 + let reg = tools::registry(ctx);
1641 + let grants: HashSet<String> = reg.write_capabilities().into_iter().map(|c| c.id).collect();
1642 +
1643 + for (label, name) in [("two exact names", "Alex Kim"), ("partial hit", "Alex")] {
1644 + let err = reg
1645 + .call(
1646 + "create_event",
1647 + json!({ "title": "x", "start": "2026-08-03T09:00:00Z", "contact": name }),
1648 + Some(&grants),
1649 + )
1650 + .await
1651 + .unwrap_err();
1652 + match err {
1653 + // The candidates and their ids have to be in the message: without
1654 + // them the caller has no way to say which person it meant.
1655 + Error::InvalidArgs { message, .. } => assert!(
1656 + message.contains(&first.id.to_string()),
1657 + "{label}: candidates must be listed, got {message}"
1658 + ),
1659 + other => panic!("{label}: expected InvalidArgs, got {other:?}"),
1660 + }
1661 + }
1662 +
1663 + // Naming the id is the way out of the ambiguity.
1664 + let created = call(
1665 + &reg,
1666 + "create_event",
1667 + json!({
1668 + "title": "x",
1669 + "start": "2026-08-03T09:00:00Z",
1670 + "contact_id": first.id.to_string()
1671 + }),
1672 + )
1673 + .await;
1674 + assert_eq!(
1675 + call(&reg, "get_event", json!({ "id": created["id"] })).await["contact_id"],
1676 + first.id.to_string()
1677 + );
1678 + }
1679 +
1680 + #[tokio::test]
1681 + async fn update_event_reattaches_and_detaches_a_person() {
1682 + let ctx = Arc::new(Ctx::new(seed_db().await));
1683 + let jane = seed_contact(&ctx, "Jane Smith", None, None, false).await;
1684 + let sam = seed_contact(&ctx, "Sam Reyes", None, None, false).await;
1685 + let reg = tools::registry(ctx);
1686 +
1687 + let created = call(
1688 + &reg,
1689 + "create_event",
1690 + json!({
1691 + "title": "review",
1692 + "start": "2026-08-03T09:00:00Z",
1693 + "contact_id": jane.id.to_string()
1694 + }),
1695 + )
1696 + .await;
1697 +
1698 + // An unrelated edit leaves the person alone.
1699 + let renamed = call(
1700 + &reg,
1701 + "update_event",
1702 + json!({ "id": created["id"], "title": "design review" }),
1703 + )
1704 + .await;
1705 + assert_eq!(renamed["contact_id"], jane.id.to_string());
1706 +
1707 + let moved = call(
1708 + &reg,
1709 + "update_event",
1710 + json!({ "id": created["id"], "contact": "Sam Reyes" }),
1711 + )
1712 + .await;
1713 + assert_eq!(moved["contact_id"], sam.id.to_string());
1714 +
1715 + // Present-but-empty detaches, matching project_id.
1716 + let detached = call(
1717 + &reg,
1718 + "update_event",
1719 + json!({ "id": created["id"], "contact_id": "" }),
1720 + )
1721 + .await;
1722 + assert_eq!(detached["contact_id"], Value::Null);
1723 + assert_eq!(detached["contact"], Value::Null);
1724 + }
1725 +
1726 + #[tokio::test]
1727 + async fn a_bad_contact_argument_is_refused() {
1728 + let ctx = Arc::new(Ctx::new(seed_db().await));
1729 + let jane = seed_contact(&ctx, "Jane Smith", None, None, false).await;
1730 + let reg = tools::registry(ctx);
1731 + let grants: HashSet<String> = reg.write_capabilities().into_iter().map(|c| c.id).collect();
1732 +
1733 + for (label, args) in [
1734 + (
1735 + "both forms at once",
1736 + json!({ "title": "x", "start": "2026-08-03T09:00:00Z",
1737 + "contact_id": jane.id.to_string(), "contact": "Jane Smith" }),
1738 + ),
1739 + (
1740 + "not a uuid",
1741 + json!({ "title": "x", "start": "2026-08-03T09:00:00Z", "contact_id": "Jane Smith" }),
1742 + ),
1743 + (
1744 + "uuid naming nothing",
1745 + json!({ "title": "x", "start": "2026-08-03T09:00:00Z",
1746 + "contact_id": Uuid::new_v4().to_string() }),
1747 + ),
1748 + ] {
1749 + let err = reg
1750 + .call("create_event", args, Some(&grants))
1751 + .await
1752 + .unwrap_err();
1753 + assert!(
1754 + matches!(err, Error::InvalidArgs { .. }),
1755 + "{label}: expected InvalidArgs, got {err:?}"
1756 + );
1757 + }
1758 + }
1759 +
1760 + #[tokio::test]
1761 + async fn a_bulk_import_resolves_one_name_for_every_item() {
1762 + let ctx = Arc::new(Ctx::new(seed_db().await));
1763 + let jane = seed_contact(&ctx, "Jane Smith", None, None, false).await;
1764 + let reg = tools::registry(ctx);
1765 +
1766 + let imported = call(
1767 + &reg,
1768 + "bulk_import_events",
1769 + json!({
1770 + "events": [
1771 + { "title": "week 1", "start": "2026-08-03T09:00:00Z", "contact": "Jane Smith" },
1772 + { "title": "week 2", "start": "2026-08-10T09:00:00Z", "contact": "Jane Smith" },
1773 + { "title": "week 3", "start": "2026-08-17T09:00:00Z", "contact_id": jane.id.to_string() }
1774 + ]
1775 + }),
1776 + )
1777 + .await;
1778 + assert_eq!(imported["created"], 3);
1779 +
1780 + for id in imported["event_ids"].as_array().unwrap() {
1781 + let event = call(&reg, "get_event", json!({ "id": id })).await;
1782 + assert_eq!(event["contact_id"], jane.id.to_string());
1783 + }
1784 +
1785 + // A bad name reports which item carried it.
1786 + let grants: HashSet<String> = reg.write_capabilities().into_iter().map(|c| c.id).collect();
1787 + let err = reg
1788 + .call(
1789 + "bulk_import_events",
1790 + json!({
1791 + "events": [
1792 + { "title": "ok", "start": "2026-09-01T09:00:00Z" },
1793 + { "title": "bad", "start": "2026-09-02T09:00:00Z", "contact": "Nobody" }
1794 + ]
1795 + }),
1796 + Some(&grants),
1797 + )
1798 + .await
1799 + .unwrap_err();
1800 + match err {
1801 + Error::InvalidArgs { message, .. } => assert!(
1802 + message.starts_with("events[1]:"),
1803 + "the offending item must be named: {message}"
1804 + ),
1805 + other => panic!("expected InvalidArgs, got {other:?}"),
1806 + }
1807 + }
@@ -43,6 +43,7 @@
43 43 use kberg::{Error, Result, Tool, ToolCallResult, ToolKind};
44 44 use serde_json::{Value, json};
45 45
46 + use super::contact::{ContactCache, contact_arg, contact_fields};
46 47 use super::{project_id_arg, project_id_field};
47 48 use crate::caps;
48 49 use crate::context::Ctx;
@@ -352,14 +353,14 @@
352 353 /// Build a validated [`NewEvent`] from one wire object, shared by `create_event`
353 354 /// and each item of `bulk_import_events`.
354 355 ///
355 - /// `contact_id` and `linked_task_id` are always `None`: go-mcp has no contact
356 - /// surface yet, and a time-block's task link is set by the app when it blocks
357 - /// time, never by a caller filling in a calendar.
356 + /// `linked_task_id` is always `None`: a time-block's task link is set by the
357 + /// app when it blocks time, never by a caller filling in a calendar.
358 358 async fn new_event_from(
359 359 ctx: &Ctx,
360 360 tool: &str,
361 361 item: &Value,
362 362 seen_projects: &mut HashSet<ProjectId>,
363 + contacts: &mut ContactCache,
363 364 ) -> Result<NewEvent> {
364 365 let title = req_str(tool, item, "title")?.to_string();
365 366 let (start_time, end_time) = resolve_span(tool, item)?;
@@ -368,11 +369,12 @@
368 369 let (tz_kind, timezone) = parse_tz_kind(tool, item)?;
369 370
370 371 let project_id = project_id_arg(ctx, tool, item, seen_projects).await?;
372 + let contact_id = contact_arg(ctx, tool, item, contacts).await?;
371 373
372 374 let mut event = NewEvent {
373 375 user_id: Some(ctx.user_id),
374 376 project_id,
375 - contact_id: None,
377 + contact_id,
376 378 title,
377 379 description: item
378 380 .get("description")
@@ -417,7 +419,7 @@
417 419
418 420 /// The JSON schema fragment shared by `create_event` and each import item.
419 421 fn event_fields() -> Value {
420 - json!({
422 + let mut fields = json!({
421 423 "title": { "type": "string" },
422 424 "start": { "type": "string", "description": "RFC 3339 timestamp or YYYY-MM-DD (local midnight)." },
423 425 "end": { "type": "string" },
@@ -439,7 +441,13 @@
439 441 "items": { "type": "integer" },
440 442 "description": "Seconds before start to fire a reminder, e.g. [0, 900]. Max 8."
441 443 }
442 - })
444 + });
445 + // Merged rather than inlined so the contact wording lives once, next to the
446 + // resolution it describes.
447 + for (key, value) in contact_fields().as_object().expect("object literal") {
448 + fields[key] = value.clone();
449 + }
450 + fields
443 451 }
444 452
445 453 pub struct CreateEvent(pub Arc<Ctx>);
@@ -450,7 +458,7 @@
450 458 "create_event"
451 459 }
452 460 fn description(&self) -> &'static str {
453 - "Create one calendar event. `title` and `start` are required; `start`/`end` take RFC 3339 or YYYY-MM-DD (local midnight). Pass `all_day: true` to snap the span to whole local days. `project_id` (from list_projects) files it under a project; omit it for an unfiled event. `recurrence` is a word (None|Daily|Weekly|Monthly) or a rich rule object; a recurring event is stored once as a series, not as one row per occurrence. Use `bulk_import_events` for more than a few."
461 + "Create one calendar event. `title` and `start` are required; `start`/`end` take RFC 3339 or YYYY-MM-DD (local midnight). Pass `all_day: true` to snap the span to whole local days. `project_id` (from list_projects) files it under a project; omit it for an unfiled event. `contact_id` (from list_contacts) or `contact` (a name) attaches it to a person; an ambiguous name is refused rather than guessed, and no contact is created. `recurrence` is a word (None|Daily|Weekly|Monthly) or a rich rule object; a recurring event is stored once as a series, not as one row per occurrence. Use `bulk_import_events` for more than a few."
454 462 }
455 463 fn kind(&self) -> ToolKind {
456 464 ToolKind::Write(caps::event_create())
@@ -464,7 +472,8 @@
464 472 }
465 473 async fn call(&self, args: Value) -> Result<ToolCallResult> {
466 474 let mut seen = HashSet::new();
467 - let event = new_event_from(&self.0, self.name(), &args, &mut seen).await?;
475 + let mut contacts = ContactCache::default();
476 + let event = new_event_from(&self.0, self.name(), &args, &mut seen, &mut contacts).await?;
468 477 let created = self
469 478 .0
470 479 .events()
@@ -530,6 +539,7 @@
530 539
531 540 let repo = self.0.events();
532 541 let mut seen_projects: HashSet<ProjectId> = HashSet::new();
542 + let mut contacts = ContactCache::default();
533 543 let mut seen: HashSet<String> = HashSet::new();
534 544 let mut created_ids = Vec::new();
535 545 let mut skipped = 0usize;
@@ -560,7 +570,15 @@
560 570 }
561 571 }
562 572
563 - let event = match new_event_from(&self.0, self.name(), item, &mut seen_projects).await {
573 + let event = match new_event_from(
574 + &self.0,
575 + self.name(),
576 + item,
577 + &mut seen_projects,
578 + &mut contacts,
579 + )
580 + .await
581 + {
564 582 Ok(event) => event,
565 583 // Report the offending item by index; a 200-item payload with
566 584 // one bad date is otherwise a guessing game.
@@ -661,7 +679,7 @@
661 679 "update_event"
662 680 }
663 681 fn description(&self) -> &'static str {
664 - "Update fields of an existing event. Only the fields you pass change; the rest keep their current values. Accepts the same fields as `create_event`. Passing `recurrence` rewrites the rule for the whole series, not one occurrence: an expanded instance is not a row and cannot be edited on its own. Events synced from an external calendar are read-only and are refused."
682 + "Update fields of an existing event. Only the fields you pass change; the rest keep their current values. Accepts the same fields as `create_event`; passing `project_id` or `contact_id` empty detaches the project or the person. Passing `recurrence` rewrites the rule for the whole series, not one occurrence: an expanded instance is not a row and cannot be edited on its own. Events synced from an external calendar are read-only and are refused."
665 683 }
666 684 fn kind(&self) -> ToolKind {
667 685 ToolKind::Write(caps::event_update())
@@ -728,6 +746,13 @@
728 746 let mut seen = HashSet::new();
729 747 patch.project_id = project_id_arg(&self.0, self.name(), &args, &mut seen).await?;
730 748 }
749 + // Same rule for the person: present-but-empty detaches. Either field
750 + // being present is the signal, since `contact` is the other way to say
751 + // who this is.
752 + if args.get("contact_id").is_some() || args.get("contact").is_some() {
753 + let mut contacts = ContactCache::default();
754 + patch.contact_id = contact_arg(&self.0, self.name(), &args, &mut contacts).await?;
755 + }
731 756
732 757 let (start_time, end_time) = overlay_span(self.name(), &args, &current)?;
733 758 patch.start_time = start_time;
@@ -13,11 +13,13 @@
13 13 use crate::context::Ctx;
14 14 use crate::convert::parse_project_id;
15 15
16 + mod contact;
16 17 mod event;
17 18 mod problem;
18 19 mod project;
19 20 mod task;
20 21
22 + pub use contact::ListContacts;
21 23 pub use event::{
22 24 BulkImportEvents, CreateEvent, DeleteEvent, GetEvent, ListEvents, UpdateEventTool,
23 25 };
@@ -37,6 +39,7 @@
37 39 r.register(GetTask(ctx.clone()));
38 40 r.register(ListEvents(ctx.clone()));
39 41 r.register(GetEvent(ctx.clone()));
42 + r.register(ListContacts(ctx.clone()));
40 43 r.register(ListProblems(ctx.clone()));
41 44 // Writes (capability-gated).
42 45 r.register(CreateProject(ctx.clone()));
@@ -1,0 +1,342 @@
1 + //! Contact tools: the read surface (`list_contacts`) and the name resolution
2 + //! the event write tools use to attach a meeting to a person.
3 + //!
4 + //! # Read-only, deliberately
5 + //!
6 + //! There is no `create_contact`, `update_contact` or `delete_contact`, and no
7 + //! new write capability. Contacts stay app-owned: a person's record carries
8 + //! emails, phones, handles and custom fields that the app curates, and an MCP
9 + //! session filling in a calendar has no business authoring one. What it needs
10 + //! is to *point at* a person who already exists, which is what this module
11 + //! gives it. Resolution therefore never creates a contact as a side effect,
12 + //! the same rule that stopped a typo becoming a new project.
13 + //!
14 + //! # Implicit contacts
15 + //!
16 + //! A contact is implicit when it came from email traffic rather than from the
17 + //! contact list. `list_contacts` hides them by default because the list is a
18 + //! curated surface, but resolution searches them: a person you have only ever
19 + //! emailed is still the person the meeting is with, and refusing the name
20 + //! because the row is implicit would be a distinction the caller cannot see.
21 + //! An implicit match is reported as such in the list when `include_implicit`
22 + //! is on.
23 +
24 + use std::collections::{HashMap, HashSet};
25 + use std::sync::Arc;
26 +
27 + use async_trait::async_trait;
28 + use goingson_core::repository::ContactRepository;
29 + use goingson_core::{Contact, ContactId};
30 + use kberg::{Error, Result, Tool, ToolCallResult, ToolKind};
31 + use serde_json::{Value, json};
32 +
33 + use crate::context::Ctx;
34 + use crate::convert::{MAX_LIMIT, parse_contact_id, parse_limit, parse_offset};
35 +
36 + fn fail(tool: &str, e: impl std::fmt::Display) -> Error {
37 + Error::ToolFailed {
38 + tool: tool.to_string(),
39 + message: e.to_string(),
40 + }
41 + }
42 +
43 + /// Compact JSON projection of a contact.
44 + ///
45 + /// The sub-collections (phones, handles, custom fields) are left out: this
46 + /// surface exists to turn a name into an id, and a full contact card would
47 + /// cost a page of tokens per person to answer a question nobody asked. The
48 + /// primary email is kept because it is what distinguishes two people who share
49 + /// a display name.
50 + fn contact_row(c: &Contact) -> Value {
51 + json!({
52 + "id": c.id.to_string(),
53 + "name": c.display_name,
54 + "nickname": c.nickname,
55 + "company": c.company,
56 + "title": c.title,
57 + "email": c.primary_email(),
58 + "tags": c.tags,
59 + "is_implicit": c.is_implicit,
60 + })
61 + }
62 +
63 + pub struct ListContacts(pub Arc<Ctx>);
64 +
65 + #[async_trait]
66 + impl Tool for ListContacts {
67 + fn name(&self) -> &'static str {
68 + "list_contacts"
69 + }
70 + fn description(&self) -> &'static str {
71 + "List people, so a meeting can be attached to one. This is how a name becomes the `contact_id` the event write tools take. Optional filters: `search` (matches name, nickname, company, title, notes and email address), `tag`. Contacts inferred from email traffic are hidden unless you pass `include_implicit: true`; they can still be named by the write tools. Paged: `limit` (default 50, max 200) and `offset`. Read-only: go-mcp cannot create, edit or delete a contact."
72 + }
73 + fn kind(&self) -> ToolKind {
74 + ToolKind::Read
75 + }
76 + fn small_model_safe(&self) -> bool {
77 + true
78 + }
79 + fn input_schema(&self) -> Value {
80 + json!({
81 + "type": "object",
82 + "properties": {
83 + "search": { "type": "string", "description": "Substring match over name, nickname, company, title, notes and email address." },
84 + "tag": { "type": "string" },
85 + "include_implicit": { "type": "boolean", "description": "Include contacts inferred from email traffic (hidden by default)." },
86 + "limit": { "type": "integer", "minimum": 1, "maximum": MAX_LIMIT },
87 + "offset": { "type": "integer", "minimum": 0 }
88 + }
89 + })
90 + }
91 + async fn call(&self, args: Value) -> Result<ToolCallResult> {
92 + let limit = parse_limit(self.name(), args.get("limit"))?;
93 + let offset = parse_offset(self.name(), args.get("offset"))?;
94 + let search = args
95 + .get("search")
96 + .and_then(Value::as_str)
97 + .map(str::trim)
98 + .filter(|s| !s.is_empty());
99 + let tag = args
100 + .get("tag")
101 + .and_then(Value::as_str)
102 + .map(str::trim)
103 + .filter(|s| !s.is_empty());
104 + let include_implicit = args
105 + .get("include_implicit")
106 + .and_then(Value::as_bool)
107 + .unwrap_or_default();
108 +
109 + let contacts = self
110 + .0
111 + .contacts()
112 + .list_filtered(self.0.user_id, search, tag, include_implicit)
113 + .await
114 + .map_err(|e| fail(self.name(), e))?;
115 +
116 + let total = contacts.len();
117 + let rows: Vec<Value> = contacts
118 + .iter()
119 + .skip(offset)
120 + .take(limit)
121 + .map(contact_row)
122 + .collect();
123 +
124 + let mut reply = json!({
125 + "count": rows.len(),
126 + "total": total,
127 + "offset": offset,
128 + "contacts": rows,
129 + });
130 + // Only present when a page remains, so its absence is the stop condition.
131 + let next = offset.saturating_add(rows.len());
132 + if next < total {
133 + reply["next_offset"] = json!(next);
134 + }
135 +
136 + Ok(ToolCallResult::text(serde_json::to_string(&reply).unwrap()))
137 + }
138 + }
139 +
140 + /// Within-call resolution cache, so a bulk import naming the same person 200
141 + /// times checks the id once and searches the name once.
142 + ///
143 + /// Names are cached under their trimmed lowercase form, which is the same key
144 + /// the match below compares on.
145 + #[derive(Default)]
146 + pub(super) struct ContactCache {
147 + ids: HashSet<ContactId>,
148 + names: HashMap<String, ContactId>,
149 + }
150 +
151 + /// Read the contact a write tool was given: `contact_id`, or `contact` (a name).
152 + ///
153 + /// Absent, null or empty all mean "no contact", so a present-but-empty field is
154 + /// how an update detaches one, matching `project_id`.
155 + ///
156 + /// The two forms are mutually exclusive rather than one taking precedence. A
157 + /// caller that passes both has two ideas about who this is, and silently
158 + /// honoring one of them would file the meeting under the wrong person without
159 + /// saying so.
160 + pub(super) async fn contact_arg(
161 + ctx: &Ctx,
162 + tool: &str,
163 + item: &Value,
164 + cache: &mut ContactCache,
165 + ) -> Result<Option<ContactId>> {
166 + let id_field = present(item.get("contact_id"));
167 + let name_field = present(item.get("contact"));
168 +
169 + if id_field.is_some() && name_field.is_some() {
170 + return Err(Error::InvalidArgs {
171 + tool: tool.to_string(),
172 + message: "pass `contact_id` or `contact`, not both".to_string(),
173 + });
174 + }
175 +
176 + if let Some(raw) = id_field {
177 + let raw = raw.as_str().ok_or_else(|| Error::InvalidArgs {
178 + tool: tool.to_string(),
179 + message: format!("`contact_id` must be a string (got `{raw}`)"),
180 + })?;
181 + let id = parse_contact_id(tool, raw)?;
182 + return verify_contact(ctx, tool, id, cache).await.map(Some);
183 + }
184 +
185 + if let Some(raw) = name_field {
186 + let name = raw.as_str().ok_or_else(|| Error::InvalidArgs {
187 + tool: tool.to_string(),
188 + message: format!("`contact` must be a string (got `{raw}`)"),
189 + })?;
190 + return resolve_contact_name(ctx, tool, name, cache).await.map(Some);
191 + }
192 +
193 + Ok(None)
194 + }
195 +
196 + /// A field that is present and not null/empty, which is what "the caller said
197 + /// something about this" means on this surface.
198 + fn present(value: Option<&Value>) -> Option<&Value> {
199 + let value = value?;
200 + if value.is_null() {
201 + return None;
202 + }
203 + if value.as_str().is_some_and(|s| s.trim().is_empty()) {
204 + return None;
205 + }
206 + Some(value)
207 + }
208 +
209 + /// Check that a `contact_id` names a contact this user owns.
210 + ///
211 + /// Same reasoning as `verify_project`: an id that resolves to nothing would be
212 + /// stored verbatim and the event would read as unattached forever. Implicit
213 + /// contacts pass, since the id could only have come from a `list_contacts` run
214 + /// that asked for them.
215 + async fn verify_contact(
216 + ctx: &Ctx,
217 + tool: &str,
218 + id: ContactId,
219 + cache: &mut ContactCache,
220 + ) -> Result<ContactId> {
221 + if cache.ids.contains(&id) {
222 + return Ok(id);
223 + }
224 + let found = ctx
225 + .contacts()
226 + .get_by_id(id, ctx.user_id)
227 + .await
228 + .map_err(|e| fail(tool, e))?;
229 + if found.is_none() {
230 + return Err(Error::InvalidArgs {
231 + tool: tool.to_string(),
232 + message: format!("no contact with id `{id}` (call list_contacts for the ids)"),
233 + });
234 + }
235 + cache.ids.insert(id);
236 + Ok(id)
237 + }
238 +
239 + /// Resolve a person's name to their id, or refuse.
240 + ///
241 + /// Three passes, narrowest first: an exact display name, then an exact
242 + /// nickname, then a unique substring hit. Widening only when the narrower pass
243 + /// found nothing is what keeps "Max" from being ambiguous the moment a
244 + /// "Maxine" exists while still letting a partial name work when it names one
245 + /// person.
246 + ///
247 + /// Every pass refuses ambiguity rather than picking. Two people really can
248 + /// share a name, and quietly attaching the meeting to whichever sorted first
249 + /// would be wrong in a way nothing downstream could detect. The error lists the
250 + /// candidates with their ids, so the retry is a `contact_id` away.
251 + async fn resolve_contact_name(
252 + ctx: &Ctx,
253 + tool: &str,
254 + name: &str,
255 + cache: &mut ContactCache,
256 + ) -> Result<ContactId> {
257 + let key = name.trim().to_lowercase();
258 + if let Some(id) = cache.names.get(&key) {
259 + return Ok(*id);
260 + }
261 +
262 + // Implicit contacts included: see the module header.
263 + let candidates = ctx
264 + .contacts()
265 + .list_filtered(ctx.user_id, Some(name.trim()), None, true)
266 + .await
267 + .map_err(|e| fail(tool, e))?;
268 +
269 + if candidates.is_empty() {
270 + return Err(Error::InvalidArgs {
271 + tool: tool.to_string(),
272 + message: format!(
273 + "no contact matching `{name}` (call list_contacts to see who exists; go-mcp cannot create one)"
274 + ),
275 + });
276 + }
277 +
278 + let by_display: Vec<&Contact> = candidates
279 + .iter()
280 + .filter(|c| c.display_name.trim().to_lowercase() == key)
281 + .collect();
282 + let by_nickname: Vec<&Contact> = candidates
283 + .iter()
284 + .filter(|c| {
285 + c.nickname
286 + .as_deref()
287 + .is_some_and(|n| n.trim().to_lowercase() == key)
288 + })
289 + .collect();
290 +
291 + let matched = if !by_display.is_empty() {
292 + pick_one(tool, name, "display name", &by_display)?
293 + } else if !by_nickname.is_empty() {
294 + pick_one(tool, name, "nickname", &by_nickname)?
295 + } else {
296 + let all: Vec<&Contact> = candidates.iter().collect();
297 + pick_one(tool, name, "partial match", &all)?
298 + };
299 +
300 + cache.names.insert(key, matched);
301 + cache.ids.insert(matched);
302 + Ok(matched)
303 + }
304 +
305 + /// The single candidate, or an `InvalidArgs` naming every one of them.
306 + fn pick_one(tool: &str, name: &str, how: &str, candidates: &[&Contact]) -> Result<ContactId> {
307 + match candidates {
308 + [only] => Ok(only.id),
309 + many => {
310 + let listed = many
311 + .iter()
312 + .map(|c| {
313 + let email = c.primary_email().unwrap_or("no email");
314 + format!("{} <{email}> {}", c.display_name, c.id)
315 + })
316 + .collect::<Vec<_>>()
317 + .join("; ");
318 + Err(Error::InvalidArgs {
319 + tool: tool.to_string(),
320 + message: format!(
321 + "`{name}` matches {} contacts by {how}: {listed}. Pass `contact_id` to say which.",
322 + many.len()
323 + ),
324 + })
325 + }
326 + }
327 + }
328 +
329 + /// The `contact_id`/`contact` schema fragment, worded once so every write tool
330 + /// says the same thing about how a person is named.
331 + pub(super) fn contact_fields() -> Value {
332 + json!({
333 + "contact_id": {
334 + "type": "string",
335 + "description": "Contact id (UUID) from list_contacts. Attaches the event to a person."
336 + },
337 + "contact": {
338 + "type": "string",
339 + "description": "Contact name, resolved through list_contacts. Exact display name, then exact nickname, then a unique partial match; an ambiguous name is refused rather than guessed, and no contact is ever created. Mutually exclusive with `contact_id`."
340 + }
341 + })
342 + }