1use rmcp::{
13 ErrorData, Json, handler::server::wrapper::Parameters, service::RequestContext, tool,
14 tool_router,
15};
16use schemars::JsonSchema;
17use serde::Deserialize;
18use uuid::Uuid;
19
20use super::{Bus, auth_of};
21use crate::{
22 model::{
23 ConversationInfo, ConversationList, ConversationRead, ConversationUpdates, InboxBatch,
24 InboxState, MessageReceipts, ProjectInfo, ProjectList, ReceiptInfo, SentMessage,
25 TransferResult,
26 },
27 store::{conversations as store, inbox},
28};
29
30#[derive(Debug, Deserialize, JsonSchema)]
31pub struct InboxFetchArgs {
32 #[serde(default)]
34 pub limit: Option<i64>,
35}
36
37#[derive(Debug, Deserialize, JsonSchema)]
38pub struct InboxConfirmArgs {
39 pub delivery_ids: Vec<String>,
41}
42
43#[derive(Debug, Default, Deserialize, JsonSchema)]
44pub struct EmptyInboxArgs {}
45
46fn uuid_arg(field: &str, raw: &str) -> Result<Uuid, ErrorData> {
47 raw.trim().parse::<Uuid>().map_err(|_| {
48 ErrorData::invalid_params(
49 format!("{field} must be the opaque id returned by the bus, not a name"),
50 None,
51 )
52 })
53}
54
55#[derive(Debug, Deserialize, JsonSchema)]
56pub struct CreateConversationArgs {
57 pub title: String,
59 #[serde(default)]
62 pub project: Option<String>,
63 #[serde(default)]
67 pub private: bool,
68 #[serde(default)]
72 pub invite: Vec<String>,
73}
74
75#[derive(Debug, Deserialize, JsonSchema)]
76pub struct ConversationIdArgs {
77 pub conversation_id: String,
79}
80
81#[derive(Debug, Deserialize, JsonSchema)]
82pub struct ListConversationsArgs {
83 #[serde(default)]
85 pub include_archived: bool,
86}
87
88#[derive(Debug, Deserialize, JsonSchema)]
89pub struct InviteArgs {
90 pub conversation_id: String,
91 pub address: String,
93 #[serde(default)]
96 pub role: Option<String>,
97 #[serde(default)]
103 pub history_from_start: bool,
104}
105
106#[derive(Debug, Deserialize, JsonSchema)]
107pub struct RemoveMemberArgs {
108 pub conversation_id: String,
109 pub address: String,
111}
112
113#[derive(Debug, Deserialize, JsonSchema)]
114pub struct SendConversationMessageArgs {
115 pub conversation_id: String,
116 pub body: String,
117 pub request_id: String,
121 #[serde(default)]
123 pub reply_to: Option<String>,
124 #[serde(default)]
126 pub metadata: Option<serde_json::Value>,
127}
128
129#[derive(Debug, Deserialize, JsonSchema)]
130pub struct ReadConversationArgs {
131 pub conversation_id: String,
132 #[serde(default)]
135 pub after_seq: Option<i64>,
136 #[serde(default)]
138 pub limit: Option<i64>,
139}
140
141#[derive(Debug, Deserialize, JsonSchema)]
142pub struct MessageIdArgs {
143 pub message_id: String,
144}
145
146#[derive(Debug, Deserialize, JsonSchema)]
147pub struct AckArgs {
148 pub message_id: String,
151 #[serde(default)]
154 pub resolved: bool,
155 #[serde(default)]
157 pub note: Option<String>,
158}
159
160#[derive(Debug, Deserialize, JsonSchema)]
161pub struct TransferArgs {
162 pub conversation_id: String,
163 pub to: String,
166}
167
168#[derive(Debug, Deserialize, JsonSchema)]
169pub struct WaitConversationArgs {
170 #[serde(default)]
172 pub timeout_seconds: Option<i64>,
173}
174
175#[derive(Debug, Deserialize, JsonSchema)]
176pub struct ProjectArgs {
177 pub project: String,
179}
180
181#[derive(Debug, Deserialize, JsonSchema)]
182pub struct ProjectAccessArgs {
183 pub project: String,
184 pub agent: String,
186 #[serde(default = "default_true")]
188 pub grant: bool,
189}
190
191fn default_true() -> bool {
192 true
193}
194
195#[tool_router(router = conversations_router, vis = "pub")]
196impl Bus {
197 #[tool(
198 description = "Start a thread with an explicit membership, so you can later ask who \
199 has seen it and who acted on it — which a channel cannot answer and \
200 three direct messages cannot converge. Pass `project` for a thread \
201 everyone with access to that project can read, or `private: true` \
202 for one only its members can see; the choice is permanent. Invite \
203 exact windows (`agent/session` from list_sessions); each accepts \
204 with join_conversation."
205 )]
206 async fn create_conversation(
207 &self,
208 ctx: RequestContext<rmcp::RoleServer>,
209 Parameters(args): Parameters<CreateConversationArgs>,
210 ) -> Result<Json<ConversationInfo>, ErrorData> {
211 let auth = auth_of(&ctx)?;
212 Ok(Json(
213 store::create_conversation(
214 &self.db,
215 &auth,
216 store::CreateInput {
217 title: args.title,
218 project: args.project,
219 private: args.private,
220 invite: args.invite,
221 },
222 )
223 .await?,
224 ))
225 }
226
227 #[tool(
228 description = "Threads you can read: the ones you belong to, plus the project \
229 threads your project access covers. A private thread you are not in \
230 is not listed and does not exist as far as this call is concerned."
231 )]
232 async fn list_conversations(
233 &self,
234 ctx: RequestContext<rmcp::RoleServer>,
235 Parameters(args): Parameters<ListConversationsArgs>,
236 ) -> Result<Json<ConversationList>, ErrorData> {
237 let auth = auth_of(&ctx)?;
238 Ok(Json(ConversationList {
239 conversations: store::list_conversations(&self.db, &auth, args.include_archived)
240 .await?,
241 }))
242 }
243
244 #[tool(
245 description = "Invite a window into a thread. Owners and moderators only. The \
246 invitee is not a member until it accepts, so a thread never \
247 conscripts someone into its receipts. By default they see the thread \
248 from now on; `history_from_start` gives them the thread from where \
249 you can read it yourself, its start only if you can, which is a \
250 decision worth making deliberately. Inviting someone already seated \
251 changes their role at once; an owner's seat is only an owner's to \
252 change."
253 )]
254 async fn invite_to_conversation(
255 &self,
256 ctx: RequestContext<rmcp::RoleServer>,
257 Parameters(args): Parameters<InviteArgs>,
258 ) -> Result<Json<ConversationInfo>, ErrorData> {
259 let auth = auth_of(&ctx)?;
260 let id = uuid_arg("conversation_id", &args.conversation_id)?;
261 Ok(Json(
262 store::invite(
263 &self.db,
264 &auth,
265 id,
266 &args.address,
267 args.role.as_deref(),
268 args.history_from_start,
269 )
270 .await?,
271 ))
272 }
273
274 #[tool(
275 description = "Accept an invitation addressed to THIS window. A sibling window of \
276 yours cannot accept it for you: the invitation names one \
277 agent/session, and so will the receipts."
278 )]
279 async fn join_conversation(
280 &self,
281 ctx: RequestContext<rmcp::RoleServer>,
282 Parameters(args): Parameters<ConversationIdArgs>,
283 ) -> Result<Json<ConversationInfo>, ErrorData> {
284 let auth = auth_of(&ctx)?;
285 let id = uuid_arg("conversation_id", &args.conversation_id)?;
286 Ok(Json(store::join(&self.db, &auth, id).await?))
287 }
288
289 #[tool(
290 description = "Leave a thread. What you already said and already acknowledged stays \
291 exactly as it is; you simply stop being addressed."
292 )]
293 async fn leave_conversation(
294 &self,
295 ctx: RequestContext<rmcp::RoleServer>,
296 Parameters(args): Parameters<ConversationIdArgs>,
297 ) -> Result<Json<serde_json::Value>, ErrorData> {
298 let auth = auth_of(&ctx)?;
299 let id = uuid_arg("conversation_id", &args.conversation_id)?;
300 store::leave(&self.db, &auth, id).await?;
301 Ok(Json(serde_json::json!({ "left": args.conversation_id })))
302 }
303
304 #[tool(
305 description = "Remove someone else from a thread. Owners and moderators only, and \
306 only an owner can remove an owner. Their history and receipts are \
307 kept — a removal is not a rewrite — and they lose access from now on."
308 )]
309 async fn remove_conversation_member(
310 &self,
311 ctx: RequestContext<rmcp::RoleServer>,
312 Parameters(args): Parameters<RemoveMemberArgs>,
313 ) -> Result<Json<ConversationInfo>, ErrorData> {
314 let auth = auth_of(&ctx)?;
315 let id = uuid_arg("conversation_id", &args.conversation_id)?;
316 Ok(Json(
317 store::remove_member(&self.db, &auth, id, &args.address).await?,
318 ))
319 }
320
321 #[tool(
322 description = "Close a thread to new messages. Its history stays readable to \
323 everyone who could read it. Owners and moderators only."
324 )]
325 async fn archive_conversation(
326 &self,
327 ctx: RequestContext<rmcp::RoleServer>,
328 Parameters(args): Parameters<ConversationIdArgs>,
329 ) -> Result<Json<ConversationInfo>, ErrorData> {
330 let auth = auth_of(&ctx)?;
331 let id = uuid_arg("conversation_id", &args.conversation_id)?;
332 Ok(Json(
333 store::archive_conversation(&self.db, &auth, id).await?,
334 ))
335 }
336
337 #[tool(
338 description = "Hand THIS window's seat to another window of your own agent — when a \
339 conversation moves to a different repository, say. It is a proposal: \
340 the target accepts with join_conversation, and only then is your \
341 seat superseded. Authorship, history boundary and old receipts are \
342 preserved; nothing is acknowledged on your behalf. To bring in \
343 someone else, invite them instead."
344 )]
345 async fn transfer_membership(
346 &self,
347 ctx: RequestContext<rmcp::RoleServer>,
348 Parameters(args): Parameters<TransferArgs>,
349 ) -> Result<Json<TransferResult>, ErrorData> {
350 let auth = auth_of(&ctx)?;
351 let id = uuid_arg("conversation_id", &args.conversation_id)?;
352 Ok(Json(
353 store::transfer_membership(&self.db, &auth, id, &args.to).await?,
354 ))
355 }
356
357 #[tool(
358 description = "Post into a thread. Returns the message id, its logical sequence, \
359 `stored` and `publication` (where the body stands with its backend \
360 right now, NOT that anyone read it) and the exact list of windows it \
361 was addressed to, snapshotted now: someone who joins later never \
362 enters this message's denominator. On a team whose conversations are \
363 published to a broker a fresh send returns `stored: false` with \
364 `publication: \"pending_publication\"`: the message is recorded and \
365 awaiting the backend's confirmation, and it later settles as \
366 `stored` or, if the backend refuses it for good, `failed`. Do NOT \
367 send it again while it is pending. Pass a fresh \
368 `request_id` UUID; repeating one returns the original message with its \
369 current state, so a retry can neither double-post nor lose anything."
370 )]
371 async fn send_conversation_message(
372 &self,
373 ctx: RequestContext<rmcp::RoleServer>,
374 Parameters(args): Parameters<SendConversationMessageArgs>,
375 ) -> Result<Json<SentMessage>, ErrorData> {
376 let auth = auth_of(&ctx)?;
377 let id = uuid_arg("conversation_id", &args.conversation_id)?;
378 let request_id = uuid_arg("request_id", &args.request_id)?;
379 let reply_to = match args.reply_to.as_deref() {
380 Some(r) => Some(uuid_arg("reply_to", r)?),
381 None => None,
382 };
383 Ok(Json(
384 store::send(
385 &self.db,
386 &auth,
387 id,
388 store::SendInput {
389 body: args.body,
390 request_id,
391 reply_to,
392 metadata: args.metadata,
393 },
394 )
395 .await?,
396 ))
397 }
398
399 #[tool(
400 description = "Read a thread, oldest first, from where your membership starts. \
401 READING IS NOT ACKNOWLEDGING: no receipt is touched here and no \
402 cursor moves on anyone's behalf. Each message carries your own \
403 receipt so you can see what you have already acknowledged. Page with \
404 `next_after_seq`. Every message has an `unavailable` field: `null` \
405 means `body` is the real text; a reason there means `body` is an \
406 empty placeholder for a body this bus cannot give you — its backend \
407 is unreachable right now, or it was never stored, or it is no longer \
408 held — and NOT an empty message. Never quote or summarise an empty \
409 body without checking `unavailable` first."
410 )]
411 async fn read_conversation(
412 &self,
413 ctx: RequestContext<rmcp::RoleServer>,
414 Parameters(args): Parameters<ReadConversationArgs>,
415 ) -> Result<Json<ConversationRead>, ErrorData> {
416 let auth = auth_of(&ctx)?;
417 let id = uuid_arg("conversation_id", &args.conversation_id)?;
418 Ok(Json(
419 store::read(
420 &self.db,
421 &self.backends,
422 &auth,
423 id,
424 args.after_seq,
425 args.limit,
426 )
427 .await?,
428 ))
429 }
430
431 #[tool(
432 description = "One message by id, with your own receipt. Access is rechecked now, \
433 so a membership that has ended does not keep reading. `unavailable` \
434 is always present: `null` means `body` is the real text; a reason \
435 there means `body` is an empty placeholder for a body this bus \
436 cannot give you — its backend is unreachable right now, or it was \
437 never stored, or it is no longer held — and NOT an empty message. \
438 Never quote or summarise an empty body without checking \
439 `unavailable` first."
440 )]
441 async fn get_conversation_message(
442 &self,
443 ctx: RequestContext<rmcp::RoleServer>,
444 Parameters(args): Parameters<MessageIdArgs>,
445 ) -> Result<Json<crate::model::ConversationMessage>, ErrorData> {
446 let auth = auth_of(&ctx)?;
447 let message_id = uuid_arg("message_id", &args.message_id)?;
448 Ok(Json(
449 store::get_message(&self.db, &self.backends, &auth, message_id).await?,
450 ))
451 }
452
453 #[tool(
454 description = "Record YOUR OWN observation of one message: acknowledged (you read \
455 it), and with `resolved` that you acted on it. You can only ever \
456 speak for the window making the call — there is no argument for \
457 whose receipt this is. Resolving does not complete a task, merge a \
458 PR or close an issue; it says you consider this message dealt with. \
459 Only a window the message was addressed to has anything to \
460 acknowledge."
461 )]
462 async fn ack_message(
463 &self,
464 ctx: RequestContext<rmcp::RoleServer>,
465 Parameters(args): Parameters<AckArgs>,
466 ) -> Result<Json<ReceiptInfo>, ErrorData> {
467 let auth = auth_of(&ctx)?;
468 let message_id = uuid_arg("message_id", &args.message_id)?;
469 Ok(Json(
470 store::ack(&self.db, &auth, message_id, args.resolved, args.note).await?,
471 ))
472 }
473
474 #[tool(
475 description = "Who a message was addressed to and what each of them has observed. \
476 Five independent facts per recipient: stored, delivered, presented, \
477 acknowledged, resolved. An absent timestamp means NOT OBSERVED, not \
478 'no' — `presented_at` in particular is null wherever the host cannot \
479 confirm the message reached the model. Use it to see who is still \
480 to answer, not to conclude who ignored you."
481 )]
482 async fn get_message_receipts(
483 &self,
484 ctx: RequestContext<rmcp::RoleServer>,
485 Parameters(args): Parameters<MessageIdArgs>,
486 ) -> Result<Json<MessageReceipts>, ErrorData> {
487 let auth = auth_of(&ctx)?;
488 let message_id = uuid_arg("message_id", &args.message_id)?;
489 Ok(Json(store::receipts(&self.db, &auth, message_id).await?))
490 }
491
492 #[tool(
493 description = "Take the references waiting for THIS window: which messages exist \
494 for you, not their bodies. Nothing is marked delivered here — you \
495 confirm that separately with confirm_inbox_delivery once you are \
496 holding them, so a crash in between costs a redelivery and not a \
497 message. A reference may arrive twice (`redelivered`); handling it \
498 twice must change nothing. Read the body with \
499 get_conversation_message, which checks your access at that moment."
500 )]
501 async fn fetch_conversation_inbox(
502 &self,
503 ctx: RequestContext<rmcp::RoleServer>,
504 Parameters(args): Parameters<InboxFetchArgs>,
505 ) -> Result<Json<InboxBatch>, ErrorData> {
506 let auth = auth_of(&ctx)?;
507 Ok(Json(
508 inbox::fetch(&self.db, &self.backends, &auth, args.limit).await?,
509 ))
510 }
511
512 #[tool(
513 description = "Say that you are now holding these references durably. THIS is what \
514 records delivered — the reference reached your process, which is not \
515 the same as a model having seen it (presented), read it \
516 (acknowledged) or acted on it (resolved). Confirming twice is \
517 harmless. Confirm only what you can still find after a restart."
518 )]
519 async fn confirm_inbox_delivery(
520 &self,
521 ctx: RequestContext<rmcp::RoleServer>,
522 Parameters(args): Parameters<InboxConfirmArgs>,
523 ) -> Result<Json<serde_json::Value>, ErrorData> {
524 let auth = auth_of(&ctx)?;
525 let done = inbox::confirm(&self.db, &self.backends, &auth, &args.delivery_ids).await?;
526 Ok(Json(serde_json::json!({
527 "confirmed": done.confirmed.len(),
528 "of": args.delivery_ids.len(),
529 "confirmed_ids": done.confirmed,
532 "already_confirmed": done.already_confirmed,
533 })))
534 }
535
536 #[tool(
537 description = "What is waiting for this window, and where. `undelivered` is the \
538 authoritative count from the bus's own records; the broker numbers \
539 are a cache and may lag or be missing. A missing consumer is not an \
540 empty inbox."
541 )]
542 async fn conversation_inbox_status(
543 &self,
544 ctx: RequestContext<rmcp::RoleServer>,
545 Parameters(_args): Parameters<EmptyInboxArgs>,
546 ) -> Result<Json<InboxState>, ErrorData> {
547 let auth = auth_of(&ctx)?;
548 Ok(Json(inbox::state(&self.db, &self.backends, &auth).await?))
549 }
550
551 #[tool(
552 description = "Block until a thread you belong to has something you have not \
553 acknowledged, or the timeout passes. Use it instead of polling. It \
554 returns which threads are waiting and how many messages, never the \
555 bodies — read_conversation fetches those, and neither call \
556 acknowledges anything."
557 )]
558 async fn wait_for_conversation_updates(
559 &self,
560 ctx: RequestContext<rmcp::RoleServer>,
561 Parameters(args): Parameters<WaitConversationArgs>,
562 ) -> Result<Json<ConversationUpdates>, ErrorData> {
563 let auth = auth_of(&ctx)?;
564 store::require_capability(&self.db, &auth).await?;
565 let timeout = args.timeout_seconds.unwrap_or(50).clamp(1, 300);
566 let started = std::time::Instant::now();
567 let mut rx = self.hub.subscribe();
571 loop {
572 let waiting = store::activity(&self.db, &auth).await?;
573 if !waiting.is_empty() {
574 return Ok(Json(ConversationUpdates {
575 conversations: waiting,
576 waited_seconds: started.elapsed().as_secs() as i64,
577 }));
578 }
579 let left = timeout - started.elapsed().as_secs() as i64;
580 if left <= 0 {
581 return Ok(Json(ConversationUpdates {
582 conversations: Vec::new(),
583 waited_seconds: started.elapsed().as_secs() as i64,
584 }));
585 }
586 let _ = tokio::time::timeout(
587 std::time::Duration::from_secs(left.min(5) as u64),
588 rx.recv(),
589 )
590 .await;
591 }
592 }
593
594 #[tool(
595 description = "Create a project: the unit a conversation can be visible to. Access \
596 is an explicit grant, never your working directory or a role label. \
597 You get access to what you create; everyone else needs \
598 grant_project_access."
599 )]
600 async fn create_project(
601 &self,
602 ctx: RequestContext<rmcp::RoleServer>,
603 Parameters(args): Parameters<ProjectArgs>,
604 ) -> Result<Json<ProjectInfo>, ErrorData> {
605 let auth = auth_of(&ctx)?;
606 Ok(Json(
607 store::create_project(&self.db, &auth, &args.project).await?,
608 ))
609 }
610
611 #[tool(description = "Projects you have access to, with who else has it.")]
612 async fn list_projects(
613 &self,
614 ctx: RequestContext<rmcp::RoleServer>,
615 ) -> Result<Json<ProjectList>, ErrorData> {
616 let auth = auth_of(&ctx)?;
617 Ok(Json(ProjectList {
618 projects: store::list_projects(&self.db, &auth).await?,
619 }))
620 }
621
622 #[tool(
623 description = "Grant a teammate access to a project, or revoke it with \
624 `grant: false`. Only someone who already has access can extend it, \
625 so the chain stays inside the project. Revoking takes effect at \
626 once, including for threads they are reading."
627 )]
628 async fn grant_project_access(
629 &self,
630 ctx: RequestContext<rmcp::RoleServer>,
631 Parameters(args): Parameters<ProjectAccessArgs>,
632 ) -> Result<Json<ProjectInfo>, ErrorData> {
633 let auth = auth_of(&ctx)?;
634 Ok(Json(
635 store::set_project_access(&self.db, &auth, &args.project, &args.agent, args.grant)
636 .await?,
637 ))
638 }
639
640 #[tool(
641 description = "Read back a thread's history when EVERY window of your agent is \
642 gone — closed, revoked or expired. The documented exception: a \
643 private thread is private within the team, not from the agent that \
644 was in it. Requires your agent token, not a window's credential; it \
645 is read-only, grants no membership, acknowledges nothing, skips \
646 memberships you were removed from, and is audited."
647 )]
648 async fn recover_conversation_history(
649 &self,
650 ctx: RequestContext<rmcp::RoleServer>,
651 Parameters(args): Parameters<ReadConversationArgs>,
652 ) -> Result<Json<ConversationRead>, ErrorData> {
653 let auth = auth_of(&ctx)?;
654 let id = uuid_arg("conversation_id", &args.conversation_id)?;
655 Ok(Json(
656 store::recover_history(
657 &self.db,
658 &self.backends,
659 &auth,
660 id,
661 args.after_seq,
662 args.limit,
663 )
664 .await?,
665 ))
666 }
667}
668
669pub async fn enabled_for(db: &sqlx::PgPool, team_id: uuid::Uuid) -> bool {
673 matches!(
674 sqlx::query_as::<_, (bool,)>("SELECT conversations_enabled FROM teams WHERE id = $1")
675 .bind(team_id)
676 .fetch_optional(db)
677 .await,
678 Ok(Some((true,)))
679 )
680}