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#[derive(Clone)]
78pub struct Bus {
79 pub db: PgPool,
80 pub hub: EventHub,
81 pub backends: crate::store::routing::Backends,
85 pub tool_router: ToolRouter<Self>,
86}
87
88impl Bus {
89 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
119pub 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 pub session: String,
179 #[serde(default)]
182 pub ttl_seconds: Option<i64>,
183}
184
185#[derive(Debug, Deserialize, JsonSchema)]
186pub struct RenewSessionArgs {
187 #[serde(default)]
189 pub ttl_seconds: Option<i64>,
190}
191
192#[derive(Debug, Deserialize, JsonSchema)]
193pub struct RevokeSessionArgs {
194 #[serde(default)]
197 pub session: Option<String>,
198}
199
200#[derive(Debug, Deserialize, JsonSchema)]
201pub struct DigestArgs {
202 #[serde(default)]
204 pub hours: Option<i64>,
205 #[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 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 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 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 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}