Skip to main content

max / goingson

12.1 KB · 340 lines History Blame Raw
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 .map_err(|e| fail(self.name(), e))?;
114
115 let total = contacts.len();
116 let rows: Vec<Value> = contacts
117 .iter()
118 .skip(offset)
119 .take(limit)
120 .map(contact_row)
121 .collect();
122
123 let mut reply = json!({
124 "count": rows.len(),
125 "total": total,
126 "offset": offset,
127 "contacts": rows,
128 });
129 // Only present when a page remains, so its absence is the stop condition.
130 let next = offset.saturating_add(rows.len());
131 if next < total {
132 reply["next_offset"] = json!(next);
133 }
134
135 Ok(ToolCallResult::text(serde_json::to_string(&reply).unwrap()))
136 }
137 }
138
139 /// Within-call resolution cache, so a bulk import naming the same person 200
140 /// times checks the id once and searches the name once.
141 ///
142 /// Names are cached under their trimmed lowercase form, which is the same key
143 /// the match below compares on.
144 #[derive(Default)]
145 pub(super) struct ContactCache {
146 ids: HashSet<ContactId>,
147 names: HashMap<String, ContactId>,
148 }
149
150 /// Read the contact a write tool was given: `contact_id`, or `contact` (a name).
151 ///
152 /// Absent, null or empty all mean "no contact", so a present-but-empty field is
153 /// how an update detaches one, matching `project_id`.
154 ///
155 /// The two forms are mutually exclusive rather than one taking precedence. A
156 /// caller that passes both has two ideas about who this is, and silently
157 /// honoring one of them would file the meeting under the wrong person without
158 /// saying so.
159 pub(super) async fn contact_arg(
160 ctx: &Ctx,
161 tool: &str,
162 item: &Value,
163 cache: &mut ContactCache,
164 ) -> Result<Option<ContactId>> {
165 let id_field = present(item.get("contact_id"));
166 let name_field = present(item.get("contact"));
167
168 if id_field.is_some() && name_field.is_some() {
169 return Err(Error::InvalidArgs {
170 tool: tool.to_string(),
171 message: "pass `contact_id` or `contact`, not both".to_string(),
172 });
173 }
174
175 if let Some(raw) = id_field {
176 let raw = raw.as_str().ok_or_else(|| Error::InvalidArgs {
177 tool: tool.to_string(),
178 message: format!("`contact_id` must be a string (got `{raw}`)"),
179 })?;
180 let id = parse_contact_id(tool, raw)?;
181 return verify_contact(ctx, tool, id, cache).map(Some);
182 }
183
184 if let Some(raw) = name_field {
185 let name = raw.as_str().ok_or_else(|| Error::InvalidArgs {
186 tool: tool.to_string(),
187 message: format!("`contact` must be a string (got `{raw}`)"),
188 })?;
189 return resolve_contact_name(ctx, tool, name, cache).map(Some);
190 }
191
192 Ok(None)
193 }
194
195 /// A field that is present and not null/empty, which is what "the caller said
196 /// something about this" means on this surface.
197 fn present(value: Option<&Value>) -> Option<&Value> {
198 let value = value?;
199 if value.is_null() {
200 return None;
201 }
202 if value.as_str().is_some_and(|s| s.trim().is_empty()) {
203 return None;
204 }
205 Some(value)
206 }
207
208 /// Check that a `contact_id` names a contact this user owns.
209 ///
210 /// Same reasoning as `verify_project`: an id that resolves to nothing would be
211 /// stored verbatim and the event would read as unattached forever. Implicit
212 /// contacts pass, since the id could only have come from a `list_contacts` run
213 /// that asked for them.
214 fn verify_contact(
215 ctx: &Ctx,
216 tool: &str,
217 id: ContactId,
218 cache: &mut ContactCache,
219 ) -> Result<ContactId> {
220 if cache.ids.contains(&id) {
221 return Ok(id);
222 }
223 let found = ctx
224 .contacts()
225 .get_by_id(id, ctx.user_id)
226 .map_err(|e| fail(tool, e))?;
227 if found.is_none() {
228 return Err(Error::InvalidArgs {
229 tool: tool.to_string(),
230 message: format!("no contact with id `{id}` (call list_contacts for the ids)"),
231 });
232 }
233 cache.ids.insert(id);
234 Ok(id)
235 }
236
237 /// Resolve a person's name to their id, or refuse.
238 ///
239 /// Three passes, narrowest first: an exact display name, then an exact
240 /// nickname, then a unique substring hit. Widening only when the narrower pass
241 /// found nothing is what keeps "Max" from being ambiguous the moment a
242 /// "Maxine" exists while still letting a partial name work when it names one
243 /// person.
244 ///
245 /// Every pass refuses ambiguity rather than picking. Two people really can
246 /// share a name, and quietly attaching the meeting to whichever sorted first
247 /// would be wrong in a way nothing downstream could detect. The error lists the
248 /// candidates with their ids, so the retry is a `contact_id` away.
249 fn resolve_contact_name(
250 ctx: &Ctx,
251 tool: &str,
252 name: &str,
253 cache: &mut ContactCache,
254 ) -> Result<ContactId> {
255 let key = name.trim().to_lowercase();
256 if let Some(id) = cache.names.get(&key) {
257 return Ok(*id);
258 }
259
260 // Implicit contacts included: see the module header.
261 let candidates = ctx
262 .contacts()
263 .list_filtered(ctx.user_id, Some(name.trim()), None, true)
264 .map_err(|e| fail(tool, e))?;
265
266 if candidates.is_empty() {
267 return Err(Error::InvalidArgs {
268 tool: tool.to_string(),
269 message: format!(
270 "no contact matching `{name}` (call list_contacts to see who exists; go-mcp cannot create one)"
271 ),
272 });
273 }
274
275 let by_display: Vec<&Contact> = candidates
276 .iter()
277 .filter(|c| c.display_name.trim().to_lowercase() == key)
278 .collect();
279 let by_nickname: Vec<&Contact> = candidates
280 .iter()
281 .filter(|c| {
282 c.nickname
283 .as_deref()
284 .is_some_and(|n| n.trim().to_lowercase() == key)
285 })
286 .collect();
287
288 let matched = if !by_display.is_empty() {
289 pick_one(tool, name, "display name", &by_display)?
290 } else if !by_nickname.is_empty() {
291 pick_one(tool, name, "nickname", &by_nickname)?
292 } else {
293 let all: Vec<&Contact> = candidates.iter().collect();
294 pick_one(tool, name, "partial match", &all)?
295 };
296
297 cache.names.insert(key, matched);
298 cache.ids.insert(matched);
299 Ok(matched)
300 }
301
302 /// The single candidate, or an `InvalidArgs` naming every one of them.
303 fn pick_one(tool: &str, name: &str, how: &str, candidates: &[&Contact]) -> Result<ContactId> {
304 match candidates {
305 [only] => Ok(only.id),
306 many => {
307 let listed = many
308 .iter()
309 .map(|c| {
310 let email = c.primary_email().unwrap_or("no email");
311 format!("{} <{email}> {}", c.display_name, c.id)
312 })
313 .collect::<Vec<_>>()
314 .join("; ");
315 Err(Error::InvalidArgs {
316 tool: tool.to_string(),
317 message: format!(
318 "`{name}` matches {} contacts by {how}: {listed}. Pass `contact_id` to say which.",
319 many.len()
320 ),
321 })
322 }
323 }
324 }
325
326 /// The `contact_id`/`contact` schema fragment, worded once so every write tool
327 /// says the same thing about how a person is named.
328 pub(super) fn contact_fields() -> Value {
329 json!({
330 "contact_id": {
331 "type": "string",
332 "description": "Contact id (UUID) from list_contacts. Attaches the event to a person."
333 },
334 "contact": {
335 "type": "string",
336 "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`."
337 }
338 })
339 }
340