max / goingson
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
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(®, "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(®, "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(®, "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 | + | ®, | |
| 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(®, "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 | + | ®, | |
| 1583 | + | "create_event", | |
| 1584 | + | json!({ "title": "sync", "start": "2026-08-04T09:00:00Z", "contact": name }), | |
| 1585 | + | ) | |
| 1586 | + | .await; | |
| 1587 | + | let event = call(®, "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 | + | ®, | |
| 1607 | + | "create_event", | |
| 1608 | + | json!({ "title": "intro", "start": "2026-08-03T09:00:00Z", "contact": "Sam Reyes" }), | |
| 1609 | + | ) | |
| 1610 | + | .await; | |
| 1611 | + | assert_eq!( | |
| 1612 | + | call(®, "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(®, "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 | + | ®, | |
| 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(®, "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 | + | ®, | |
| 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 | + | ®, | |
| 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 | + | ®, | |
| 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 | + | ®, | |
| 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 | + | ®, | |
| 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(®, "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, ¤t)?; | |
| 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 | + | } |