Skip to main content

ai_crew_sync/tools/
conversations.rs

1//! MCP tools for conversations: addressed threads with honest receipts.
2//!
3//! Thin, like every other handler here. Two things are worth saying about
4//! the descriptions, because they are read by models:
5//!
6//! * they name what a receipt **is not** — reading is not acknowledging, a
7//!   resolution does not complete a task, and an unknown presentation is not
8//!   a "no";
9//! * they never offer a way to speak for someone else. There is no argument
10//!   for whose receipt this is, and there is no "send as".
11
12use 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    /// How many references to take at most. Default 20, ceiling 50.
33    #[serde(default)]
34    pub limit: Option<i64>,
35}
36
37#[derive(Debug, Deserialize, JsonSchema)]
38pub struct InboxConfirmArgs {
39    /// The `delivery_id` of every reference you are now holding durably.
40    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    /// What this thread is about, in a line.
58    pub title: String,
59    /// Project this thread belongs to, by name or id. Everyone with access
60    /// to that project can read it. Required unless `private` is true.
61    #[serde(default)]
62    pub project: Option<String>,
63    /// Make it visible only to its members. Fixed at creation: a thread
64    /// cannot be widened afterwards, because people spoke in it on those
65    /// terms.
66    #[serde(default)]
67    pub private: bool,
68    /// Windows to invite, as `agent` or `agent/session` (list_sessions gives
69    /// you the exact address). They are invited, not enrolled: each accepts
70    /// with join_conversation.
71    #[serde(default)]
72    pub invite: Vec<String>,
73}
74
75#[derive(Debug, Deserialize, JsonSchema)]
76pub struct ConversationIdArgs {
77    /// The conversation's opaque id.
78    pub conversation_id: String,
79}
80
81#[derive(Debug, Deserialize, JsonSchema)]
82pub struct ListConversationsArgs {
83    /// Include archived threads.
84    #[serde(default)]
85    pub include_archived: bool,
86}
87
88#[derive(Debug, Deserialize, JsonSchema)]
89pub struct InviteArgs {
90    pub conversation_id: String,
91    /// `agent` or `agent/session`.
92    pub address: String,
93    /// `moderator`, `participant` (default) or `observer`. An observer reads
94    /// and acknowledges but does not post.
95    #[serde(default)]
96    pub role: Option<String>,
97    /// Let the invitee read the thread from its first message instead of
98    /// from this one. Never more than you can read yourself: a moderator
99    /// admitted late grants history from where it was admitted, whatever
100    /// it asks for, and the audit row records both. A re-admission after a
101    /// removal always starts here.
102    #[serde(default)]
103    pub history_from_start: bool,
104}
105
106#[derive(Debug, Deserialize, JsonSchema)]
107pub struct RemoveMemberArgs {
108    pub conversation_id: String,
109    /// `agent` or `agent/session`.
110    pub address: String,
111}
112
113#[derive(Debug, Deserialize, JsonSchema)]
114pub struct SendConversationMessageArgs {
115    pub conversation_id: String,
116    pub body: String,
117    /// A UUID you generate. Sending the same one twice returns the original
118    /// message instead of posting it again, which is what makes a retry
119    /// safe. The same id with a different body is refused.
120    pub request_id: String,
121    /// Message id this replies to.
122    #[serde(default)]
123    pub reply_to: Option<String>,
124    /// Structured pointers — ids, flags, short labels. Not a document.
125    #[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    /// Return messages after this logical sequence. Use `next_after_seq`
133    /// from the previous page.
134    #[serde(default)]
135    pub after_seq: Option<i64>,
136    /// Messages per page (1-200, default 50).
137    #[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    /// The message you are acknowledging. Explicit: acknowledging happens
149    /// one message at a time, never "everything up to here".
150    pub message_id: String,
151    /// Also record that you acted on it, not only that you read it. This
152    /// does not complete a task, merge a PR or close anything by itself.
153    #[serde(default)]
154    pub resolved: bool,
155    /// A line for the sender: what you did, or why you will not.
156    #[serde(default)]
157    pub note: Option<String>,
158}
159
160#[derive(Debug, Deserialize, JsonSchema)]
161pub struct TransferArgs {
162    pub conversation_id: String,
163    /// Another window **of your own agent**, as `agent/session`. It has to
164    /// accept with join_conversation; nothing moves until it does.
165    pub to: String,
166}
167
168#[derive(Debug, Deserialize, JsonSchema)]
169pub struct WaitConversationArgs {
170    /// How long to block, in seconds (1-300, default 50).
171    #[serde(default)]
172    pub timeout_seconds: Option<i64>,
173}
174
175#[derive(Debug, Deserialize, JsonSchema)]
176pub struct ProjectArgs {
177    /// Project name (one lower-case word) or id.
178    pub project: String,
179}
180
181#[derive(Debug, Deserialize, JsonSchema)]
182pub struct ProjectAccessArgs {
183    pub project: String,
184    /// Teammate's agent handle.
185    pub agent: String,
186    /// Grant access, or revoke it with false.
187    #[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            // Which ids, so a caller that lost the previous answer can tell
530            // what it may forget from what it is still owed.
531            "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        // Subscribe before the first check, so a message that lands between
568        // the two is not missed; notifications are hints and the database is
569        // the truth, exactly as wait_for_updates does it.
570        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
669/// Conversations are off until a team enables them, and the catalogue says
670/// so: a model should not be offered tools that will refuse. The boundary is
671/// still enforced per call — see `store::conversations::require_capability`.
672pub 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}