//! Contact tools: the read surface (`list_contacts`) and the name resolution //! the event write tools use to attach a meeting to a person. //! //! # Read-only, deliberately //! //! There is no `create_contact`, `update_contact` or `delete_contact`, and no //! new write capability. Contacts stay app-owned: a person's record carries //! emails, phones, handles and custom fields that the app curates, and an MCP //! session filling in a calendar has no business authoring one. What it needs //! is to *point at* a person who already exists, which is what this module //! gives it. Resolution therefore never creates a contact as a side effect, //! the same rule that stopped a typo becoming a new project. //! //! # Implicit contacts //! //! A contact is implicit when it came from email traffic rather than from the //! contact list. `list_contacts` hides them by default because the list is a //! curated surface, but resolution searches them: a person you have only ever //! emailed is still the person the meeting is with, and refusing the name //! because the row is implicit would be a distinction the caller cannot see. //! An implicit match is reported as such in the list when `include_implicit` //! is on. use std::collections::{HashMap, HashSet}; use std::sync::Arc; use async_trait::async_trait; use goingson_core::repository::ContactRepository; use goingson_core::{Contact, ContactId}; use kberg::{Error, Result, Tool, ToolCallResult, ToolKind}; use serde_json::{Value, json}; use crate::context::Ctx; use crate::convert::{MAX_LIMIT, parse_contact_id, parse_limit, parse_offset}; fn fail(tool: &str, e: impl std::fmt::Display) -> Error { Error::ToolFailed { tool: tool.to_string(), message: e.to_string(), } } /// Compact JSON projection of a contact. /// /// The sub-collections (phones, handles, custom fields) are left out: this /// surface exists to turn a name into an id, and a full contact card would /// cost a page of tokens per person to answer a question nobody asked. The /// primary email is kept because it is what distinguishes two people who share /// a display name. fn contact_row(c: &Contact) -> Value { json!({ "id": c.id.to_string(), "name": c.display_name, "nickname": c.nickname, "company": c.company, "title": c.title, "email": c.primary_email(), "tags": c.tags, "is_implicit": c.is_implicit, }) } pub struct ListContacts(pub Arc); #[async_trait] impl Tool for ListContacts { fn name(&self) -> &'static str { "list_contacts" } fn description(&self) -> &'static str { "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." } fn kind(&self) -> ToolKind { ToolKind::Read } fn small_model_safe(&self) -> bool { true } fn input_schema(&self) -> Value { json!({ "type": "object", "properties": { "search": { "type": "string", "description": "Substring match over name, nickname, company, title, notes and email address." }, "tag": { "type": "string" }, "include_implicit": { "type": "boolean", "description": "Include contacts inferred from email traffic (hidden by default)." }, "limit": { "type": "integer", "minimum": 1, "maximum": MAX_LIMIT }, "offset": { "type": "integer", "minimum": 0 } } }) } async fn call(&self, args: Value) -> Result { let limit = parse_limit(self.name(), args.get("limit"))?; let offset = parse_offset(self.name(), args.get("offset"))?; let search = args .get("search") .and_then(Value::as_str) .map(str::trim) .filter(|s| !s.is_empty()); let tag = args .get("tag") .and_then(Value::as_str) .map(str::trim) .filter(|s| !s.is_empty()); let include_implicit = args .get("include_implicit") .and_then(Value::as_bool) .unwrap_or_default(); let contacts = self .0 .contacts() .list_filtered(self.0.user_id, search, tag, include_implicit) .map_err(|e| fail(self.name(), e))?; let total = contacts.len(); let rows: Vec = contacts .iter() .skip(offset) .take(limit) .map(contact_row) .collect(); let mut reply = json!({ "count": rows.len(), "total": total, "offset": offset, "contacts": rows, }); // Only present when a page remains, so its absence is the stop condition. let next = offset.saturating_add(rows.len()); if next < total { reply["next_offset"] = json!(next); } Ok(ToolCallResult::text(serde_json::to_string(&reply).unwrap())) } } /// Within-call resolution cache, so a bulk import naming the same person 200 /// times checks the id once and searches the name once. /// /// Names are cached under their trimmed lowercase form, which is the same key /// the match below compares on. #[derive(Default)] pub(super) struct ContactCache { ids: HashSet, names: HashMap, } /// Read the contact a write tool was given: `contact_id`, or `contact` (a name). /// /// Absent, null or empty all mean "no contact", so a present-but-empty field is /// how an update detaches one, matching `project_id`. /// /// The two forms are mutually exclusive rather than one taking precedence. A /// caller that passes both has two ideas about who this is, and silently /// honoring one of them would file the meeting under the wrong person without /// saying so. pub(super) async fn contact_arg( ctx: &Ctx, tool: &str, item: &Value, cache: &mut ContactCache, ) -> Result> { let id_field = present(item.get("contact_id")); let name_field = present(item.get("contact")); if id_field.is_some() && name_field.is_some() { return Err(Error::InvalidArgs { tool: tool.to_string(), message: "pass `contact_id` or `contact`, not both".to_string(), }); } if let Some(raw) = id_field { let raw = raw.as_str().ok_or_else(|| Error::InvalidArgs { tool: tool.to_string(), message: format!("`contact_id` must be a string (got `{raw}`)"), })?; let id = parse_contact_id(tool, raw)?; return verify_contact(ctx, tool, id, cache).map(Some); } if let Some(raw) = name_field { let name = raw.as_str().ok_or_else(|| Error::InvalidArgs { tool: tool.to_string(), message: format!("`contact` must be a string (got `{raw}`)"), })?; return resolve_contact_name(ctx, tool, name, cache).map(Some); } Ok(None) } /// A field that is present and not null/empty, which is what "the caller said /// something about this" means on this surface. fn present(value: Option<&Value>) -> Option<&Value> { let value = value?; if value.is_null() { return None; } if value.as_str().is_some_and(|s| s.trim().is_empty()) { return None; } Some(value) } /// Check that a `contact_id` names a contact this user owns. /// /// Same reasoning as `verify_project`: an id that resolves to nothing would be /// stored verbatim and the event would read as unattached forever. Implicit /// contacts pass, since the id could only have come from a `list_contacts` run /// that asked for them. fn verify_contact( ctx: &Ctx, tool: &str, id: ContactId, cache: &mut ContactCache, ) -> Result { if cache.ids.contains(&id) { return Ok(id); } let found = ctx .contacts() .get_by_id(id, ctx.user_id) .map_err(|e| fail(tool, e))?; if found.is_none() { return Err(Error::InvalidArgs { tool: tool.to_string(), message: format!("no contact with id `{id}` (call list_contacts for the ids)"), }); } cache.ids.insert(id); Ok(id) } /// Resolve a person's name to their id, or refuse. /// /// Three passes, narrowest first: an exact display name, then an exact /// nickname, then a unique substring hit. Widening only when the narrower pass /// found nothing is what keeps "Max" from being ambiguous the moment a /// "Maxine" exists while still letting a partial name work when it names one /// person. /// /// Every pass refuses ambiguity rather than picking. Two people really can /// share a name, and quietly attaching the meeting to whichever sorted first /// would be wrong in a way nothing downstream could detect. The error lists the /// candidates with their ids, so the retry is a `contact_id` away. fn resolve_contact_name( ctx: &Ctx, tool: &str, name: &str, cache: &mut ContactCache, ) -> Result { let key = name.trim().to_lowercase(); if let Some(id) = cache.names.get(&key) { return Ok(*id); } // Implicit contacts included: see the module header. let candidates = ctx .contacts() .list_filtered(ctx.user_id, Some(name.trim()), None, true) .map_err(|e| fail(tool, e))?; if candidates.is_empty() { return Err(Error::InvalidArgs { tool: tool.to_string(), message: format!( "no contact matching `{name}` (call list_contacts to see who exists; go-mcp cannot create one)" ), }); } let by_display: Vec<&Contact> = candidates .iter() .filter(|c| c.display_name.trim().to_lowercase() == key) .collect(); let by_nickname: Vec<&Contact> = candidates .iter() .filter(|c| { c.nickname .as_deref() .is_some_and(|n| n.trim().to_lowercase() == key) }) .collect(); let matched = if !by_display.is_empty() { pick_one(tool, name, "display name", &by_display)? } else if !by_nickname.is_empty() { pick_one(tool, name, "nickname", &by_nickname)? } else { let all: Vec<&Contact> = candidates.iter().collect(); pick_one(tool, name, "partial match", &all)? }; cache.names.insert(key, matched); cache.ids.insert(matched); Ok(matched) } /// The single candidate, or an `InvalidArgs` naming every one of them. fn pick_one(tool: &str, name: &str, how: &str, candidates: &[&Contact]) -> Result { match candidates { [only] => Ok(only.id), many => { let listed = many .iter() .map(|c| { let email = c.primary_email().unwrap_or("no email"); format!("{} <{email}> {}", c.display_name, c.id) }) .collect::>() .join("; "); Err(Error::InvalidArgs { tool: tool.to_string(), message: format!( "`{name}` matches {} contacts by {how}: {listed}. Pass `contact_id` to say which.", many.len() ), }) } } } /// The `contact_id`/`contact` schema fragment, worded once so every write tool /// says the same thing about how a person is named. pub(super) fn contact_fields() -> Value { json!({ "contact_id": { "type": "string", "description": "Contact id (UUID) from list_contacts. Attaches the event to a person." }, "contact": { "type": "string", "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`." } }) }