Skip to main content

ai_crew_sync/tools/
mod.rs

1pub mod attachments;
2pub mod conversations;
3pub mod events;
4pub mod locks;
5pub mod messaging;
6pub mod notes;
7pub mod presence;
8pub mod tasks;
9
10use rmcp::{
11    ErrorData, Json, ServerHandler,
12    handler::server::{router::tool::ToolRouter, wrapper::Parameters},
13    model::{ServerCapabilities, ServerConfig},
14    service::RequestContext,
15    tool, tool_router,
16};
17use schemars::JsonSchema;
18use serde::Deserialize;
19use sqlx::PgPool;
20
21use crate::{
22    auth::AuthCtx,
23    events::EventHub,
24    model::{DigestResult, SessionCredential, WhoAmI},
25    store,
26};
27
28pub const INSTRUCTIONS: &str = r#"
29Shared coordination bus for a team of AI coding agents. Every agent in the
30team is connected to the same bus, so anything you write here is visible to your
31teammates' agents, and anything they write is visible to you.
32
33Identity is taken from your bearer token; you never pass your own name as an
34argument. Call `whoami` once at the start of a session to learn your handle and
35see whether anything is waiting for you.
36
37Four capabilities:
38
39- Messaging. `post_message` to a `channel` (broadcast to the whole team) or to a
40  single agent via `to` (direct message). `read_messages` returns only what you
41  have not seen yet by default, and advances your read cursor.
42- Task coordination. Before starting shared work, `claim_task` so two agents do
43  not do the same job twice. Claims hold a lease that expires, so call
44  `renew_task_lease` on long jobs and `complete_task` or `release_task` when you
45  stop. An expired lease can be taken over by anyone. Tasks can depend on other
46  tasks (`depends_on` at creation); blocked tasks cannot be claimed until their
47  dependencies are done, and `claim_next_task` skips them.
48- Locks. `acquire_lock` before touching a contended resource ("deploy:staging",
49  "schema:users"); `release_lock` when finished. Lighter than a task, expires on
50  its own.
51- Presence. `heartbeat` publishes what you are working on (repo, branch, a short
52  activity string). `list_agents` shows who else is active right now.
53- Shared notes. `set_note` / `get_note` / `search_notes` are the team's durable
54  memory: decisions, gotchas, deploy state. Prefer a note over repeating the
55  same explanation in chat.
56- Waiting. When you are blocked on teammates, call `wait_for_updates` instead of
57  polling: it blocks until a relevant message/task/lock/note event arrives or
58  the timeout passes. `team_digest` summarises the last hours of team activity —
59  useful at session start to catch up.
60- Attachments. Small files (diffs, logs, configs — max 256 KiB each) travel
61  with messages (`post_message` `attachments`) or tasks (`attach_file`), and
62  are fetched with `get_attachment`. Share the artifact itself instead of
63  describing it.
64- Asking. When you need an answer from a specific teammate to continue,
65  `ask_agent` sends them the question and waits for the reply in one call. If
66  you receive a question (a direct message marked `"question": true`), answer
67  promptly with `post_message` (`to` the asker, `reply_to` the question id) —
68  their agent is blocked waiting on you.
69
70Conventions worth following: keep messages short and factual, scope notes by
71repository name, and use task keys that a human would recognise.
72"#;
73
74/// The MCP server. Cheap to clone: `PgPool` and `EventHub` are `Arc`s
75/// internally, and the tool router is rebuilt per session by the transport's
76/// service factory.
77#[derive(Clone)]
78pub struct Bus {
79    pub db: PgPool,
80    pub hub: EventHub,
81    /// Where each conversation's bodies live. Postgres for everyone unless
82    /// an operator routed a team elsewhere and started the server with a
83    /// broker.
84    pub backends: crate::store::routing::Backends,
85    pub tool_router: ToolRouter<Self>,
86}
87
88impl Bus {
89    /// The default installation: every body in Postgres, no broker.
90    pub fn new(db: PgPool, hub: EventHub) -> Self {
91        let backends = crate::store::routing::Backends::postgres_only(db.clone());
92        Self::with_backends(db, hub, backends)
93    }
94
95    pub fn with_backends(
96        db: PgPool,
97        hub: EventHub,
98        backends: crate::store::routing::Backends,
99    ) -> Self {
100        let tool_router = Self::core_router()
101            + Self::messaging_router()
102            + Self::tasks_router()
103            + Self::presence_router()
104            + Self::notes_router()
105            + Self::locks_router()
106            + Self::events_router()
107            + Self::attachments_router()
108            + Self::sessions_router()
109            + Self::conversations_router();
110        Self {
111            db,
112            hub,
113            backends,
114            tool_router,
115        }
116    }
117}
118
119/// Pull the authenticated identity out of the HTTP request that carried this
120/// tool call. The bearer middleware put it there; if it is missing, something
121/// is routing around authentication and we refuse rather than guess.
122pub fn auth_of(ctx: &RequestContext<rmcp::RoleServer>) -> Result<AuthCtx, ErrorData> {
123    ctx.extensions
124        .get::<http::request::Parts>()
125        .and_then(|parts| parts.extensions.get::<AuthCtx>())
126        .cloned()
127        .ok_or_else(|| {
128            ErrorData::invalid_request(
129                "no authentication context on this request; the server is misconfigured",
130                None,
131            )
132        })
133}
134
135#[tool_router(router = core_router, vis = "pub")]
136impl Bus {
137    #[tool(
138        description = "Identify yourself on the bus: your agent handle, your team, \
139                       how many unread direct messages you have and how many tasks \
140                       you currently hold. Call this first in a session."
141    )]
142    async fn whoami(
143        &self,
144        ctx: RequestContext<rmcp::RoleServer>,
145    ) -> Result<Json<WhoAmI>, ErrorData> {
146        let auth = auth_of(&ctx)?;
147        Ok(Json(store::whoami(&self.db, &auth).await?))
148    }
149
150    #[tool(
151        description = "Summarise the team's last hours: channel activity, tasks that moved, \
152                       notes touched, who was around, active locks. Call it at session start \
153                       to catch up, or to prepare a standup. Direct messages are excluded."
154    )]
155    async fn team_digest(
156        &self,
157        ctx: RequestContext<rmcp::RoleServer>,
158        Parameters(args): Parameters<DigestArgs>,
159    ) -> Result<Json<DigestResult>, ErrorData> {
160        let auth = auth_of(&ctx)?;
161        Ok(Json(
162            store::digest::team_digest(
163                &self.db,
164                &auth,
165                args.hours.unwrap_or(24),
166                args.all_channels,
167            )
168            .await?,
169        ))
170    }
171}
172
173#[derive(Debug, Deserialize, JsonSchema)]
174pub struct RegisterSessionArgs {
175    /// Label for this window — the id your host gives the conversation is
176    /// the right choice, because it is stable across a reconnect and new
177    /// after a fork. It becomes the `agent/session` address teammates use.
178    pub session: String,
179    /// How long the credential authenticates for, in seconds (60 to 86400).
180    /// Defaults to 24 hours.
181    #[serde(default)]
182    pub ttl_seconds: Option<i64>,
183}
184
185#[derive(Debug, Deserialize, JsonSchema)]
186pub struct RenewSessionArgs {
187    /// New lifetime in seconds (60 to 86400). Defaults to 24 hours.
188    #[serde(default)]
189    pub ttl_seconds: Option<i64>,
190}
191
192#[derive(Debug, Deserialize, JsonSchema)]
193pub struct RevokeSessionArgs {
194    /// Which session of yours to revoke. Omit to revoke the one making the
195    /// call. You can only ever revoke your own agent's sessions.
196    #[serde(default)]
197    pub session: Option<String>,
198}
199
200#[derive(Debug, Deserialize, JsonSchema)]
201pub struct DigestArgs {
202    /// Window to summarise, in hours (1-336). Defaults to 24.
203    #[serde(default)]
204    pub hours: Option<i64>,
205    /// Summarise every channel instead of only the one this session works in.
206    /// Has no effect when your session has no matching channel, where the
207    /// digest already covers the whole team.
208    #[serde(default)]
209    pub all_channels: bool,
210}
211
212#[tool_router(router = sessions_router, vis = "pub")]
213impl Bus {
214    #[tool(
215        description = "Register this window as an authenticated session and receive a \
216                       credential that PROVES which window it is. Call it once per \
217                       conversation with your agent token, then send the returned \
218                       session_token as the bearer token instead. The label you pass \
219                       becomes your `agent/session` address. A label whose session is \
220                       still live is REFUSED: holding the agent token does not make you \
221                       that window. Reconnecting the same window is resume_session, with \
222                       its own credential; taking back a window that is gone is \
223                       revoke_session first. A session credential cannot register another."
224    )]
225    async fn register_session(
226        &self,
227        ctx: RequestContext<rmcp::RoleServer>,
228        Parameters(args): Parameters<RegisterSessionArgs>,
229    ) -> Result<Json<SessionCredential>, ErrorData> {
230        let auth = auth_of(&ctx)?;
231        // The credential that authenticated this request, by id: the tool
232        // layer never sees the secret it hangs the new session off.
233        let parent = auth.token_id.ok_or_else(|| {
234            ErrorData::invalid_request(
235                "register_session must be called with an agent token. This call used a \
236                 session credential, which cannot register another session — use the agent \
237                 token this window's credential was derived from.",
238                None,
239            )
240        })?;
241        let issued =
242            store::sessions::register(&self.db, &auth, parent, &args.session, args.ttl_seconds)
243                .await?;
244        Ok(Json(store::sessions::credential_of(
245            issued,
246            &auth.agent_name,
247        )))
248    }
249
250    #[tool(
251        description = "Resume the window whose credential made this call: rotate the \
252                       secret and raise the epoch, so the connection being replaced is \
253                       refused at its next request. Identity, address, cursors, claims and \
254                       history are unchanged. Only the holder of the session credential can \
255                       do this — an agent token cannot take over a live window; it can \
256                       revoke_session one that is gone and register a new one."
257    )]
258    async fn resume_session(
259        &self,
260        ctx: RequestContext<rmcp::RoleServer>,
261        Parameters(args): Parameters<RenewSessionArgs>,
262    ) -> Result<Json<SessionCredential>, ErrorData> {
263        let auth = auth_of(&ctx)?;
264        let issued = store::sessions::resume(&self.db, &auth, args.ttl_seconds).await?;
265        Ok(Json(store::sessions::credential_of(
266            issued,
267            &auth.agent_name,
268        )))
269    }
270
271    #[tool(
272        description = "Extend the session credential that made this call, keeping its \
273                       secret and its epoch so the connection is not disturbed. Call it \
274                       well before expires_in_seconds runs out."
275    )]
276    async fn renew_session(
277        &self,
278        ctx: RequestContext<rmcp::RoleServer>,
279        Parameters(args): Parameters<RenewSessionArgs>,
280    ) -> Result<Json<SessionCredential>, ErrorData> {
281        let auth = auth_of(&ctx)?;
282        let issued = store::sessions::renew(&self.db, &auth, args.ttl_seconds).await?;
283        Ok(Json(store::sessions::credential_of(
284            issued,
285            &auth.agent_name,
286        )))
287    }
288
289    #[tool(
290        description = "Revoke a session credential of yours: the one making the call, or \
291                       another of your agent's windows by label (a crashed one, say). The \
292                       credential stops authenticating at once; messages, claims and \
293                       history filed under that session are untouched."
294    )]
295    async fn revoke_session(
296        &self,
297        ctx: RequestContext<rmcp::RoleServer>,
298        Parameters(args): Parameters<RevokeSessionArgs>,
299    ) -> Result<Json<serde_json::Value>, ErrorData> {
300        let auth = auth_of(&ctx)?;
301        let label = store::sessions::revoke(&self.db, &auth, args.session.as_deref()).await?;
302        Ok(Json(serde_json::json!({ "revoked_session": label })))
303    }
304}
305
306impl ServerHandler for Bus {
307    fn get_info(&self) -> ServerConfig {
308        let mut info = ServerConfig::new(ServerCapabilities::builder().enable_tools().build());
309        info.instructions = Some(INSTRUCTIONS.trim().to_string());
310        // The handshake names the real server, not the framework: rmcp's
311        // default (`rmcp 3.x`) told an upgrading operator nothing. With the
312        // bus version in `serverInfo`, any client — the proxy first — can
313        // point at version skew when something fails, instead of the search
314        // starting at the token and the TLS (#187).
315        let mut server_info = rmcp::model::Implementation::from_build_env();
316        server_info.name = "ai-crew-sync".to_owned();
317        server_info.version = env!("CARGO_PKG_VERSION").to_owned();
318        info.server_info = server_info;
319        info
320    }
321
322    /// The catalogue this caller's team actually has.
323    ///
324    /// An off capability is not only a refusal at call time: advertising
325    /// eighteen tools that every call rejects is a catalogue that lies, and
326    /// the model reading it wastes a turn finding out. The per-call check
327    /// stays exactly where it was — a catalogue is not an authorization
328    /// boundary.
329    async fn list_tools(
330        &self,
331        _request: Option<rmcp::model::PaginatedRequestParams>,
332        context: RequestContext<rmcp::RoleServer>,
333    ) -> Result<rmcp::model::ListToolsResult, ErrorData> {
334        let all = self.tool_router.list_all();
335        let enabled = match auth_of(&context) {
336            Ok(auth) => store::conversations::capability_enabled(&self.db, &auth)
337                .await
338                .unwrap_or(false),
339            // No context to decide with: advertise the always-available
340            // tools rather than guessing a capability on.
341            Err(_) => false,
342        };
343        if enabled {
344            return Ok(rmcp::model::ListToolsResult::with_all_items(all));
345        }
346        let optional: std::collections::HashSet<String> = Self::conversations_router()
347            .list_all()
348            .into_iter()
349            .map(|t| t.name.to_string())
350            .collect();
351        Ok(rmcp::model::ListToolsResult::with_all_items(
352            all.into_iter()
353                .filter(|t| !optional.contains(t.name.as_ref()))
354                .collect::<Vec<_>>(),
355        ))
356    }
357
358    async fn call_tool(
359        &self,
360        request: rmcp::model::CallToolRequestParams,
361        context: RequestContext<rmcp::RoleServer>,
362    ) -> Result<rmcp::model::CallToolResponse, ErrorData> {
363        self.tool_router
364            .call(rmcp::handler::server::tool::ToolCallContext::new(
365                self, request, context,
366            ))
367            .await
368    }
369}