1use std::{
43 path::{Path, PathBuf},
44 sync::{
45 Arc,
46 atomic::{AtomicUsize, Ordering},
47 },
48 time::Duration,
49};
50
51use anyhow::Context as _;
52use rmcp::{
53 ErrorData, ServerHandler, ServiceExt,
54 model::{
55 CallToolRequestParams, CallToolResponse, CallToolResult, ErrorCode, ListToolsResult,
56 PaginatedRequestParams, ServerCapabilities, ServerConfig, Tool,
57 },
58 service::{ClientInitializeError, RequestContext, RoleServer, RunningService, ServiceError},
59 transport::{
60 StreamableHttpClientTransport,
61 streamable_http_client::{StreamableHttpClientTransportConfig, StreamableHttpError},
62 },
63};
64use schemars::JsonSchema;
65use serde::{Deserialize, Serialize};
66use serde_json::{Value, json};
67use tokio::sync::{Mutex, RwLock};
68use tokio_util::sync::CancellationToken;
69
70use crate::context::{self, Inputs, Resolved};
71
72pub const SESSION_PREFIX: &str = "s-";
75const SESSION_HEX: usize = 32;
80
81const PRESENCE_TTL_SECS: i64 = 900;
83const KEEPALIVE_EVERY: Duration = Duration::from_secs(300);
84const SESSION_TTL_ENV: &str = "BUS_SESSION_TTL_SECS";
88const RENEW_LEAD_ENV: &str = "BUS_SESSION_RENEW_LEAD_SECS";
92const DRAIN_TIMEOUT: Duration = Duration::from_secs(5);
95const EXIT_TIMEOUT: Duration = Duration::from_secs(3);
97
98pub const CONFIGURE_TOOL: &str = "configure_session";
99pub const STATUS_TOOL: &str = "session_status";
100
101type Remote = RunningService<rmcp::RoleClient, rmcp::model::ClientConfig>;
102
103#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, JsonSchema)]
105#[serde(rename_all = "kebab-case")]
106pub enum Binding {
107 Explicit,
109 ClaudeCode,
111 RequestMeta,
113 Instance,
115}
116
117#[derive(Clone, Debug, Default)]
119pub struct ProxyOptions {
120 pub inputs: Inputs,
121 pub project: Option<String>,
122 pub role: Option<String>,
123 pub channel: Option<String>,
124 pub host_session: Option<String>,
126 pub state_dir: PathBuf,
128}
129
130pub use crate::context::session_for_host as session_for;
134
135fn check_label(field: &str, raw: &str) -> Result<Option<String>, ErrorData> {
139 crate::store::presence::normalize_label(field, raw)
140 .map(|v| (!v.is_empty()).then_some(v))
141 .map_err(|e| ErrorData::invalid_params(e.to_string(), None))
142}
143
144fn random_session() -> String {
145 let raw = crate::auth::generate_token();
146 format!(
147 "{SESSION_PREFIX}{}",
148 &raw[crate::auth::TOKEN_PREFIX.len()..crate::auth::TOKEN_PREFIX.len() + SESSION_HEX]
149 )
150}
151
152fn env_secs(name: &str) -> Option<i64> {
153 std::env::var(name)
154 .ok()
155 .and_then(|v| v.trim().parse::<i64>().ok())
156 .filter(|v| *v > 0)
157}
158
159fn requested_session_ttl() -> Option<i64> {
164 env_secs(SESSION_TTL_ENV).map(|v| v.clamp(60, crate::auth::MAX_SESSION_TTL_SECS))
165}
166
167fn renewal_lead_secs(lifetime: i64, override_secs: Option<i64>) -> i64 {
172 let lifetime = lifetime.max(2);
173 override_secs.unwrap_or(lifetime / 2).clamp(1, lifetime - 1)
174}
175
176#[derive(Clone, Debug)]
178pub struct SessionProof {
179 pub token: String,
182 pub session_id: String,
183 pub epoch: i64,
184 pub expires_at: String,
185}
186
187enum Renewal {
189 Renewed,
190 Refused,
192 Retry,
194 Nothing,
196}
197
198struct Connected {
200 resolved: Resolved,
201 agent: String,
202 team: String,
203 remote: Arc<Remote>,
204 tools: Vec<Tool>,
205 remote_instructions: Option<String>,
206 proof: Option<SessionProof>,
207 ct: CancellationToken,
209}
210
211struct State {
212 connected: Option<Connected>,
215 disconnected_reason: Option<String>,
217 session: String,
218 binding: Binding,
219 host_id: Option<String>,
220 project: Option<String>,
221 role: Option<String>,
222 channel: Option<String>,
223 generation: u64,
225}
226
227struct InFlight(Arc<AtomicUsize>);
230
231impl InFlight {
232 fn enter(counter: &Arc<AtomicUsize>) -> Self {
233 counter.fetch_add(1, Ordering::SeqCst);
234 Self(counter.clone())
235 }
236}
237
238impl Drop for InFlight {
239 fn drop(&mut self) {
240 self.0.fetch_sub(1, Ordering::SeqCst);
241 }
242}
243
244#[derive(Clone)]
245pub struct Proxy {
246 state: Arc<RwLock<State>>,
247 in_flight: Arc<AtomicUsize>,
248 switch: Arc<Mutex<()>>,
250 wake: Arc<tokio::sync::Notify>,
255 opts: Arc<ProxyOptions>,
256 project_dir: PathBuf,
257}
258
259#[derive(Debug, Default, Deserialize, JsonSchema)]
262pub struct ConfigureArgs {
263 #[serde(default)]
267 pub role: Option<String>,
268 #[serde(default)]
271 pub project: Option<String>,
272 #[serde(default)]
274 pub channel: Option<String>,
275 #[serde(default)]
278 pub profile: Option<String>,
279}
280
281#[derive(Debug, Serialize, JsonSchema)]
282pub struct Status {
283 pub connected: bool,
285 pub error: Option<String>,
287 pub agent: Option<String>,
289 pub team: Option<String>,
290 pub session: String,
292 pub address: Option<String>,
294 pub project: Option<String>,
295 pub role: Option<String>,
296 pub channel: Option<String>,
297 pub profile: Option<String>,
298 pub binding: Binding,
299 pub project_root: Option<String>,
300 pub bus: Option<String>,
301}
302
303#[derive(Debug, Serialize, JsonSchema)]
304pub struct ConfigureResult {
305 pub status: Status,
306 pub previous: Option<PreviousIdentity>,
310}
311
312#[derive(Debug, Serialize, JsonSchema)]
313pub struct PreviousIdentity {
314 pub agent: String,
315 pub team: String,
316 pub session: String,
317 pub open_claims: Vec<String>,
318 pub held_locks: Vec<String>,
319}
320
321fn schema_of<T: JsonSchema>() -> Arc<rmcp::model::JsonObject> {
322 let schema = schemars::schema_for!(T);
323 match serde_json::to_value(schema) {
324 Ok(Value::Object(map)) => Arc::new(map),
325 _ => Arc::new(rmcp::model::JsonObject::new()),
326 }
327}
328
329fn local_tools() -> Vec<Tool> {
330 vec![
331 Tool::new(
332 CONFIGURE_TOOL,
333 "Set how THIS window presents itself on the bus: role (implementation, design, \
334 review, …), project and default channel. Metadata only — it never changes who \
335 you are or your session id, so cursors, claims and locks stay yours. `profile` \
336 switches to another locally approved credential of the same team after \
337 verifying it; a different team needs a new conversation. Affects this window \
338 only.",
339 schema_of::<ConfigureArgs>(),
340 )
341 .with_title("Configure this session")
342 .with_output_schema::<ConfigureResult>(),
343 Tool::new(
344 STATUS_TOOL,
345 "Who this window is on the bus (verified agent and team), its session id and \
346 address (`agent/session`, what teammates use to reach exactly this window), \
347 project, role and default channel. Never returns credentials.",
348 schema_of::<EmptyArgs>(),
349 )
350 .with_title("Session status")
351 .with_output_schema::<Status>(),
352 ]
353}
354
355#[derive(Debug, Default, Deserialize, JsonSchema)]
356pub struct EmptyArgs {}
357
358async fn connect_remote(
361 url: &str,
362 credential: &str,
363 session: &str,
364 epoch: Option<i64>,
365) -> anyhow::Result<Remote> {
366 let mut config = StreamableHttpClientTransportConfig::with_uri(url.to_owned());
367 config.auth_header = Some(credential.to_owned());
368 config.allow_stateless = true;
369 config.custom_headers.insert(
370 crate::auth::SESSION_HEADER.parse()?,
371 session
372 .parse()
373 .context("session label is not a valid header value")?,
374 );
375 if let Some(epoch) = epoch {
379 config.custom_headers.insert(
380 crate::auth::EPOCH_HEADER.parse()?,
381 epoch
382 .to_string()
383 .parse()
384 .context("epoch is not a valid header value")?,
385 );
386 }
387 let transport = StreamableHttpClientTransport::from_config(config);
388 let remote = rmcp::model::ClientConfig::default()
389 .serve(transport)
390 .await
391 .map_err(|e| {
392 tracing::warn!(error = %e, url, "could not open a connection to the bus");
397 let lost = || "the connection failed before an answer came back".to_owned();
398 let (why, rejected) = match &e {
399 ClientInitializeError::JsonRpcError(data) => (data.message.to_string(), false),
400 ClientInitializeError::TransportError { error, .. } => {
401 let rejected = matches!(
402 http_error_in(&*error.error),
403 Some(StreamableHttpError::AuthRequired(_))
404 );
405 let why = match refusal_in(&*error.error) {
406 Some(r) => r.text(),
407 None if rejected => "the bus rejected the credential".to_owned(),
408 None => lost(),
409 };
410 (why, rejected)
411 }
412 _ => (lost(), false),
413 };
414 let text = format!("could not connect to the bus: {why}");
415 if rejected {
416 anyhow::Error::new(Verdict::Unauthorized).context(text)
417 } else {
418 anyhow::anyhow!(text)
419 }
420 })?;
421 let peer_info = remote.peer_info();
426 if let Some(si) = peer_info.as_ref().and_then(|i| i.server_info.as_ref()) {
427 let ours = env!("CARGO_PKG_VERSION");
428 if si.name == "ai-crew-sync" && si.version != ours {
429 tracing::warn!(
430 binary = ours,
431 bus = %si.version,
432 "this binary and the bus run different ai-crew-sync versions; \
433 if tools fail to load or calls are refused, align the two \
434 before debugging anything else"
435 );
436 } else if si.name != "ai-crew-sync" {
437 tracing::debug!(
438 server = %si.name,
439 version = %si.version,
440 "the bus did not identify an ai-crew-sync version (0.7.0 or older)"
441 );
442 }
443 }
444 Ok(remote)
445}
446
447fn unauthorized(e: &ServiceError) -> bool {
453 let ServiceError::TransportSend(sent) = e else {
454 return false;
455 };
456 match http_error_in(&*sent.error) {
457 Some(http) => matches!(http, StreamableHttpError::AuthRequired(_)),
458 None => sent.error.to_string().contains("Auth required"),
461 }
462}
463
464fn http_error_in<'a>(
468 root: &'a (dyn std::error::Error + 'static),
469) -> Option<&'a StreamableHttpError<reqwest::Error>> {
470 let mut cause = Some(root);
471 while let Some(err) = cause {
472 if let Some(http) = err.downcast_ref::<StreamableHttpError<reqwest::Error>>() {
473 return Some(http);
474 }
475 cause = err.source();
476 }
477 None
478}
479
480fn no_such_tool(e: &ServiceError) -> bool {
488 match e {
489 ServiceError::McpError(err) => {
490 err.code == ErrorCode::METHOD_NOT_FOUND
491 || (err.code == ErrorCode::INVALID_PARAMS && err.message.trim() == "tool not found")
492 }
493 _ => false,
494 }
495}
496
497#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
501enum Verdict {
502 #[error("the bus rejected the credential")]
503 Unauthorized,
504 #[error("the bus has no such tool")]
505 NoSuchTool,
506}
507
508fn verdict(e: &ServiceError) -> Option<Verdict> {
509 if unauthorized(e) {
510 Some(Verdict::Unauthorized)
511 } else if no_such_tool(e) {
512 Some(Verdict::NoSuchTool)
513 } else {
514 None
515 }
516}
517
518fn verdict_of(e: &anyhow::Error) -> Option<Verdict> {
519 e.chain().find_map(|c| c.downcast_ref::<Verdict>().copied())
520}
521
522fn settled_ids(reply: Option<&Value>, sent: &[String]) -> std::collections::HashSet<String> {
527 let mut settled = std::collections::HashSet::new();
528 let Some(reply) = reply else {
529 return settled;
530 };
531 let listed = reply.get("confirmed_ids").is_some() || reply.get("already_confirmed").is_some();
532 if listed {
533 for key in ["confirmed_ids", "already_confirmed"] {
534 if let Some(ids) = reply.get(key).and_then(|v| v.as_array()) {
535 settled.extend(ids.iter().filter_map(|v| v.as_str()).map(str::to_owned));
536 }
537 }
538 } else if reply.get("confirmed").and_then(|v| v.as_i64()) == Some(sent.len() as i64) {
539 settled.extend(sent.iter().cloned());
540 }
541 settled
542}
543
544#[derive(Debug, PartialEq, Eq)]
553struct Refusal {
554 status: u16,
555 said: Option<String>,
557}
558
559impl Refusal {
560 fn text(&self) -> String {
561 match &self.said {
562 Some(said) => format!("the bus refused it before running it: {said}"),
563 None => format!(
564 "the bus refused it with HTTP {} before running it",
565 self.status
566 ),
567 }
568 }
569}
570
571fn refusal_in(root: &(dyn std::error::Error + 'static)) -> Option<Refusal> {
576 let StreamableHttpError::UnexpectedServerResponse(msg) = http_error_in(root)? else {
577 return None;
578 };
579 let rest = msg.strip_prefix("HTTP ")?;
580 let (head, body) = rest.split_once(": ").unwrap_or((rest, ""));
581 let status: u16 = head.split_whitespace().next()?.parse().ok()?;
582 let said = serde_json::from_str::<Value>(body)
583 .ok()
584 .and_then(|v| v.get("error")?.as_str().map(str::to_owned));
585 (said.is_some() || (400..500).contains(&status)).then_some(Refusal { status, said })
586}
587
588fn refusal(e: &ServiceError) -> Option<Refusal> {
589 match e {
590 ServiceError::TransportSend(sent) => refusal_in(&*sent.error),
591 _ => None,
592 }
593}
594
595fn remote_error_text(e: &ServiceError) -> String {
604 if let ServiceError::McpError(data) = e {
605 return data.message.to_string();
606 }
607 tracing::warn!(error = %e, "the call to the bus failed in transport");
608 match refusal(e) {
609 Some(r) => r.text(),
610 None => "the connection to the bus failed before an answer came back".to_owned(),
611 }
612}
613
614async fn call_remote(remote: &Remote, name: &str, args: Value) -> anyhow::Result<Value> {
615 let arguments: rmcp::model::JsonObject =
616 serde_json::from_value(args).context("arguments must be an object")?;
617 let result = remote
618 .call_tool(CallToolRequestParams::new(name.to_owned()).with_arguments(arguments))
619 .await
620 .map_err(|e| {
621 let text = format!("{name}: {}", remote_error_text(&e));
624 match verdict(&e) {
625 Some(v) => anyhow::Error::new(v).context(text),
626 None => anyhow::anyhow!(text),
627 }
628 })?;
629 if result.is_error == Some(true) {
630 let said: Vec<String> = result
632 .content
633 .iter()
634 .filter_map(|c| c.as_text().map(|t| t.text.clone()))
635 .collect();
636 anyhow::bail!("{name}: {}", said.join(" "));
637 }
638 Ok(result.structured_content.unwrap_or(Value::Null))
639}
640
641async fn register_new(remote: &Remote, session: &str) -> anyhow::Result<Option<SessionProof>> {
647 let mut args = json!({ "session": session });
648 if let Some(ttl) = requested_session_ttl() {
649 args["ttl_seconds"] = json!(ttl);
650 }
651 match call_remote(remote, "register_session", args).await {
652 Ok(v) => match v["session_token"].as_str() {
653 Some(token) => Ok(Some(SessionProof {
654 token: token.to_owned(),
655 session_id: v["session_id"].as_str().unwrap_or_default().to_owned(),
656 epoch: v["epoch"].as_i64().unwrap_or(1),
657 expires_at: v["expires_at"].as_str().unwrap_or_default().to_owned(),
658 })),
659 None => anyhow::bail!(
660 "the bus accepted register_session but returned no credential; refusing to \
661 continue with an asserted label while reporting a proven identity"
662 ),
663 },
664 Err(e) => {
665 let text = e.to_string();
666 if verdict_of(&e) == Some(Verdict::NoSuchTool) {
670 tracing::warn!(
671 "this bus does not issue session credentials; continuing with the label only"
672 );
673 Ok(None)
674 } else {
675 Err(anyhow::anyhow!(
676 "could not register this window's session: {text}"
677 ))
678 }
679 }
680 }
681}
682
683async fn resume_with(
687 url: &str,
688 prior: &SessionProof,
689 session: &str,
690) -> anyhow::Result<Option<SessionProof>> {
691 let remote = connect_remote(url, &prior.token, session, Some(prior.epoch))
692 .await
693 .context("the stored session credential could not open a connection")?;
694 let mut args = json!({});
695 if let Some(ttl) = requested_session_ttl() {
696 args["ttl_seconds"] = json!(ttl);
697 }
698 let outcome = call_remote(&remote, "resume_session", args).await;
699 let _ = remote.cancel().await;
700 let v =
701 outcome.context("this window's session could not be resumed; it may have been revoked")?;
702 let token = v["session_token"]
703 .as_str()
704 .context("resume_session returned no credential")?;
705 Ok(Some(SessionProof {
706 token: token.to_owned(),
707 session_id: v["session_id"].as_str().unwrap_or_default().to_owned(),
708 epoch: v["epoch"].as_i64().unwrap_or(1),
709 expires_at: v["expires_at"].as_str().unwrap_or_default().to_owned(),
710 }))
711}
712
713async fn establish(
714 inputs: &Inputs,
715 session: &str,
716 existing_proof: Option<SessionProof>,
717) -> anyhow::Result<(
718 Resolved,
719 String,
720 String,
721 Remote,
722 Vec<Tool>,
723 Option<String>,
724 Option<SessionProof>,
725)> {
726 let resolved = context::resolve(inputs)?;
727 for w in &resolved.warnings {
730 tracing::warn!("{w}");
731 }
732 let rejected = || {
739 anyhow::anyhow!(
740 "the bus rejected this window's credential — it has been revoked or \
741 rotated{}. The credential came from {}. Issue a new token \
742 (`ai-crew-sync admin token issue --save`) or select another \
743 approved profile",
744 resolved
745 .profile
746 .as_deref()
747 .map(|p| format!(" (profile '{p}')"))
748 .unwrap_or_default(),
749 resolved.credential_provenance()
750 )
751 };
752 let remote = match connect_remote(&resolved.mcp_url, &resolved.token, session, None).await {
753 Ok(remote) => remote,
754 Err(e) if verdict_of(&e) == Some(Verdict::Unauthorized) => return Err(rejected()),
755 Err(e) => return Err(e),
756 };
757 let me = match call_remote(&remote, "whoami", json!({})).await {
758 Ok(me) => me,
759 Err(e) => {
760 let raw = e.to_string();
761 let _ = remote.cancel().await;
762 if verdict_of(&e) == Some(Verdict::Unauthorized) {
763 return Err(rejected());
764 }
765 anyhow::bail!("the bus did not accept the credential: {raw}");
766 }
767 };
768 let agent = me["agent"].as_str().unwrap_or_default().to_owned();
769 let team = me["team"].as_str().unwrap_or_default().to_owned();
770 if let Some((exp_team, exp_agent)) = &resolved.expected
771 && (&agent != exp_agent || &team != exp_team)
772 {
773 let _ = remote.cancel().await;
774 anyhow::bail!(
775 "profile '{}' expects {exp_agent}@{exp_team} but the token authenticates as \
776 {agent}@{team}; fix the profile or its token entry",
777 resolved.profile.as_deref().unwrap_or("?")
778 );
779 }
780
781 let stored = existing_proof;
787 let proof = match &stored {
788 Some(prior) => resume_with(&resolved.mcp_url, prior, session).await?,
789 None => register_new(&remote, session).await?,
790 };
791
792 let (remote, tools, instructions) = match &proof {
793 Some(proof) => {
794 let _ = remote.cancel().await;
795 let remote =
796 connect_remote(&resolved.mcp_url, &proof.token, session, Some(proof.epoch))
797 .await
798 .context("the session credential could not open a connection")?;
799 let tools = remote.list_all_tools().await.map_err(|e| {
800 anyhow::anyhow!("could not list the bus's tools: {}", remote_error_text(&e))
801 })?;
802 let instructions = remote.peer_info().and_then(|i| i.instructions.clone());
803 (remote, tools, instructions)
804 }
805 None => {
806 let tools = remote.list_all_tools().await.map_err(|e| {
807 anyhow::anyhow!("could not list the bus's tools: {}", remote_error_text(&e))
808 })?;
809 let instructions = remote.peer_info().and_then(|i| i.instructions.clone());
810 (remote, tools, instructions)
811 }
812 };
813 Ok((resolved, agent, team, remote, tools, instructions, proof))
814}
815
816fn git_place(dir: &Path) -> (Option<String>, Option<String>) {
819 let run = |args: &[&str]| {
820 std::process::Command::new("git")
821 .args(args)
822 .current_dir(dir)
823 .output()
824 .ok()
825 .filter(|o| o.status.success())
826 .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_owned())
827 .filter(|s| !s.is_empty())
828 };
829 let repo = run(&["config", "--get", "remote.origin.url"]).map(|url| {
830 let trimmed = url.trim_end_matches(".git");
831 let tail: Vec<&str> = trimmed.rsplit(['/', ':']).take(2).collect();
832 if tail.len() == 2 {
833 format!("{}/{}", tail[1], tail[0])
834 } else {
835 trimmed.to_owned()
836 }
837 });
838 let branch = run(&["branch", "--show-current"]);
839 (repo, branch)
840}
841
842impl Proxy {
843 pub async fn start(opts: ProxyOptions) -> Self {
846 let project_dir = opts
847 .inputs
848 .project_dir
849 .clone()
850 .or_else(|| std::env::var_os("CLAUDE_PROJECT_DIR").map(PathBuf::from))
851 .or_else(|| std::env::current_dir().ok())
852 .unwrap_or_else(|| PathBuf::from("."));
853
854 let (host_id, binding) = if let Some(id) = opts
855 .host_session
856 .as_deref()
857 .map(str::trim)
858 .filter(|s| !s.is_empty())
859 {
860 (Some(id.to_owned()), Binding::Explicit)
861 } else if let Some(id) = std::env::var("CLAUDE_CODE_SESSION_ID")
862 .ok()
863 .map(|s| s.trim().to_owned())
864 .filter(|s| !s.is_empty())
865 {
866 (Some(id), Binding::ClaudeCode)
867 } else {
868 (None, Binding::Instance)
869 };
870 let session = match &host_id {
871 Some(id) => session_for(id),
872 None => random_session(),
873 };
874
875 let proxy = Self {
876 state: Arc::new(RwLock::new(State {
877 connected: None,
878 disconnected_reason: None,
879 session,
880 binding,
881 host_id,
882 project: opts.project.clone(),
883 role: opts.role.clone(),
884 channel: opts.channel.clone(),
885 generation: 0,
886 })),
887 in_flight: Arc::new(AtomicUsize::new(0)),
888 switch: Arc::new(Mutex::new(())),
889 wake: Arc::new(tokio::sync::Notify::new()),
890 opts: Arc::new(opts),
891 project_dir,
892 };
893 let inputs = proxy.opts.inputs.clone();
894 if let Err(e) = proxy.connect_with(&inputs, true).await {
895 tracing::warn!(error = %e, "proxy started without a bus connection");
896 proxy.state.write().await.disconnected_reason = Some(format!("{e:#}"));
897 }
898 proxy
899 }
900
901 async fn connect_with(
908 &self,
909 inputs: &Inputs,
910 reuse_proof: bool,
911 ) -> anyhow::Result<Option<PreviousIdentity>> {
912 let _guard = self.switch.lock().await;
913 let (session, binding_key) = {
914 let st = self.state.read().await;
915 (
916 st.session.clone(),
917 st.host_id.clone().unwrap_or_else(|| st.session.clone()),
918 )
919 };
920 let existing = reuse_proof
925 .then(|| context::read_binding(&self.opts.state_dir, &binding_key))
926 .flatten()
927 .and_then(|b| match (b.session_token, b.session_id, b.epoch) {
928 (Some(token), Some(session_id), Some(epoch)) if !token.is_empty() => {
929 Some(SessionProof {
930 token,
931 session_id,
932 epoch,
933 expires_at: b.expires_at.unwrap_or_default(),
934 })
935 }
936 _ => None,
937 });
938 let (resolved, agent, team, remote, tools, instructions, proof) =
939 establish(inputs, &session, existing).await?;
940
941 {
945 let st = self.state.read().await;
946 if let Some(old) = &st.connected
947 && old.team != team
948 {
949 let _ = remote.cancel().await;
950 anyhow::bail!(
951 "this conversation is bound to team '{}'; the profile '{}' belongs to team \
952 '{team}'. Switching teams inside a conversation is not allowed — the \
953 transcript already holds '{}' material. Start a new conversation with \
954 that profile instead",
955 old.team,
956 resolved.profile.as_deref().unwrap_or("?"),
957 old.team
958 );
959 }
960 }
961
962 {
965 let mut st = self.state.write().await;
966 if st.project.is_none() {
967 st.project = resolved.project.clone();
968 }
969 if st.channel.is_none() {
970 st.channel = resolved.channel.clone();
971 }
972 }
973
974 let previous = {
977 let old = {
978 let mut st = self.state.write().await;
979 st.connected.take()
980 };
981 match old {
982 Some(old) => {
983 old.ct.cancel();
984 let started = std::time::Instant::now();
985 while self.in_flight.load(Ordering::SeqCst) > 0
986 && started.elapsed() < DRAIN_TIMEOUT
987 {
988 tokio::time::sleep(Duration::from_millis(20)).await;
989 }
990 let held = report_holdings(&old.remote, &old.agent, &old.team, &session).await;
991 if old.proof.is_some() {
999 let _ = tokio::time::timeout(
1000 EXIT_TIMEOUT,
1001 call_remote(&old.remote, "revoke_session", json!({})),
1002 )
1003 .await;
1004 }
1005 let _ = tokio::time::timeout(
1007 EXIT_TIMEOUT,
1008 call_remote(
1009 &old.remote,
1010 "heartbeat",
1011 json!({"status": "idle", "ttl_seconds": 30}),
1012 ),
1013 )
1014 .await;
1015 close_remote(old.remote).await;
1016 Some(held)
1017 }
1018 None => None,
1019 }
1020 };
1021
1022 let connected = Connected {
1023 resolved,
1024 agent,
1025 team,
1026 remote: Arc::new(remote),
1027 tools,
1028 remote_instructions: instructions,
1029 proof,
1030 ct: CancellationToken::new(),
1031 };
1032 {
1033 let mut st = self.state.write().await;
1034 st.connected = Some(connected);
1035 st.disconnected_reason = None;
1036 st.generation += 1;
1037 }
1038 self.wake.notify_one();
1041 self.heartbeat("active").await;
1042 self.write_binding().await;
1043 Ok(previous)
1044 }
1045
1046 async fn heartbeat(&self, status: &str) {
1049 let (remote, project, role) = {
1050 let st = self.state.read().await;
1051 let Some(c) = &st.connected else { return };
1052 (c.remote.clone(), st.project.clone(), st.role.clone())
1053 };
1054 let (repo, branch) = git_place(&self.project_dir);
1055 let mut args = json!({"status": status, "ttl_seconds": PRESENCE_TTL_SECS});
1056 if let Some(r) = repo {
1057 args["repo"] = Value::String(r);
1058 }
1059 if let Some(b) = branch {
1060 args["branch"] = Value::String(b);
1061 }
1062 args["project"] = Value::String(project.unwrap_or_default());
1064 args["role"] = Value::String(role.unwrap_or_default());
1065 if let Err(e) = call_remote(&remote, "heartbeat", args).await {
1066 tracing::warn!(error = %e, "heartbeat failed");
1067 }
1068 }
1069
1070 async fn write_binding(&self) {
1076 let st = self.state.read().await;
1077 let key = st.host_id.clone().unwrap_or_else(|| st.session.clone());
1081 let record = json!({
1085 "host_id_present": st.host_id.is_some(),
1086 "binding": st.binding,
1087 "session": st.session,
1088 "project": st.project,
1089 "role": st.role,
1090 "channel": st.channel,
1091 "profile": st.connected.as_ref().and_then(|c| c.resolved.profile.clone()),
1092 "agent": st.connected.as_ref().map(|c| c.agent.clone()),
1093 "team": st.connected.as_ref().map(|c| c.team.clone()),
1094 "mcp_url": st.connected.as_ref().map(|c| c.resolved.mcp_url.clone()),
1095 "session_token": st.connected.as_ref().and_then(|c| c.proof.as_ref().map(|p| p.token.clone())),
1096 "session_id": st.connected.as_ref().and_then(|c| c.proof.as_ref().map(|p| p.session_id.clone())),
1097 "epoch": st.connected.as_ref().and_then(|c| c.proof.as_ref().map(|p| p.epoch)),
1100 "expires_at": st.connected.as_ref().and_then(|c| c.proof.as_ref().map(|p| p.expires_at.clone())),
1101 "proxy_pid": std::process::id(),
1102 "updated_at": chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
1103 });
1104 drop(st);
1105 let path = context::binding_path(&self.opts.state_dir, &key);
1109 let text = record.to_string();
1110 if let Err(e) = under_config_lock(self.opts.state_dir.clone(), move || {
1111 context::write_binding_file(&path, &text)
1112 })
1113 .await
1114 {
1115 tracing::warn!(error = %e, "could not write the session binding");
1116 }
1117 }
1118
1119 async fn status(&self) -> Status {
1120 let st = self.state.read().await;
1121 let c = st.connected.as_ref();
1122 Status {
1123 connected: c.is_some(),
1124 error: st.disconnected_reason.clone(),
1125 agent: c.map(|c| c.agent.clone()),
1126 team: c.map(|c| c.team.clone()),
1127 session: st.session.clone(),
1128 address: c.map(|c| format!("{}/{}", c.agent, st.session)),
1129 project: st.project.clone(),
1130 role: st.role.clone(),
1131 channel: st.channel.clone(),
1132 profile: c.and_then(|c| c.resolved.profile.clone()),
1133 binding: st.binding,
1134 project_root: c
1135 .and_then(|c| c.resolved.project_root.as_ref())
1136 .map(|p| p.display().to_string()),
1137 bus: c.map(|c| c.resolved.mcp_url.clone()),
1138 }
1139 }
1140
1141 async fn configure(&self, args: ConfigureArgs) -> anyhow::Result<ConfigureResult> {
1142 let mut previous = None;
1144 let staged_role = match args.role {
1149 Some(v) => Some(check_label("role", &v).map_err(|e| anyhow::anyhow!("{}", e.message))?),
1150 None => None,
1151 };
1152 let staged_project = match args.project {
1153 Some(v) => {
1154 Some(check_label("project", &v).map_err(|e| anyhow::anyhow!("{}", e.message))?)
1155 }
1156 None => None,
1157 };
1158 let staged_channel = match args.channel {
1159 Some(v) => {
1160 Some(check_label("channel", &v).map_err(|e| anyhow::anyhow!("{}", e.message))?)
1161 }
1162 None => None,
1163 };
1164
1165 if let Some(profile) = args
1166 .profile
1167 .map(|p| p.trim().to_owned())
1168 .filter(|p| !p.is_empty())
1169 {
1170 let mut inputs = self.opts.inputs.clone();
1171 inputs.profile = Some(profile);
1172 inputs.explicit_token = None;
1175 inputs.explicit_url = None;
1176 previous = self.connect_with(&inputs, false).await?;
1177 }
1178 {
1180 let mut st = self.state.write().await;
1181 if let Some(role) = staged_role {
1182 st.role = role;
1183 }
1184 if let Some(project) = staged_project {
1185 st.project = project;
1186 }
1187 if let Some(channel) = staged_channel {
1188 st.channel = channel;
1189 }
1190 }
1191 self.heartbeat("active").await;
1192 self.write_binding().await;
1193 Ok(ConfigureResult {
1194 status: self.status().await,
1195 previous,
1196 })
1197 }
1198
1199 async fn observe_meta(&self, meta: &rmcp::model::RequestMetaObject) -> Result<(), ErrorData> {
1204 let thread = meta
1205 .0
1206 .0
1207 .get("threadId")
1208 .or_else(|| meta.0.0.get("sessionId"))
1209 .and_then(Value::as_str)
1210 .map(str::trim)
1211 .filter(|s| !s.is_empty());
1212 let Some(thread) = thread else { return Ok(()) };
1213 let (bound, current) = {
1214 let st = self.state.read().await;
1215 (st.host_id.clone(), st.binding)
1216 };
1217 match bound {
1218 Some(id) if id == thread => Ok(()),
1219 Some(_) if current == Binding::RequestMeta => Err(ErrorData::invalid_request(
1220 "this proxy instance is bound to another conversation; a second one is using \
1221 the same MCP process, which is not supported. Configure the host to start \
1222 one `ai-crew-sync mcp proxy` per conversation",
1223 None,
1224 )),
1225 Some(_) => Ok(()),
1228 None => self.rebind(thread.to_owned()).await.map_err(|e| {
1229 ErrorData::internal_error(format!("could not bind the conversation: {e:#}"), None)
1230 }),
1231 }
1232 }
1233
1234 async fn rebind(&self, host_id: String) -> anyhow::Result<()> {
1238 let previous = {
1243 let st = self.state.read().await;
1244 (st.host_id.clone(), st.binding, st.session.clone())
1245 };
1246 {
1247 let mut st = self.state.write().await;
1248 st.host_id = Some(host_id.clone());
1249 st.binding = Binding::RequestMeta;
1250 st.session = session_for(&host_id);
1251 }
1252 let inputs = {
1253 let st = self.state.read().await;
1254 match &st.connected {
1255 Some(c) => {
1256 let mut i = self.opts.inputs.clone();
1257 i.profile = c.resolved.profile.clone();
1258 i
1259 }
1260 None => self.opts.inputs.clone(),
1261 }
1262 };
1263 match self.connect_with(&inputs, true).await {
1264 Ok(_) => Ok(()),
1265 Err(e) => {
1266 let mut st = self.state.write().await;
1267 (st.host_id, st.binding, st.session) = previous;
1268 st.disconnected_reason = Some(format!("{e:#}"));
1269 Err(e)
1270 }
1271 }
1272 }
1273
1274 async fn spool_and_confirm(
1284 &self,
1285 result: CallToolResult,
1286 host_ct: CancellationToken,
1287 ) -> CallToolResult {
1288 let Some(structured) = result.structured_content.clone() else {
1289 return result;
1290 };
1291 let session = self.state.read().await.session.clone();
1292 let path = crate::spool::spool_path(&self.opts.state_dir, &session);
1293
1294 let mut entries: Vec<crate::spool::Entry> = structured
1295 .get("references")
1296 .and_then(|v| v.as_array())
1297 .map(|refs| {
1298 refs.iter()
1299 .filter_map(|r| {
1300 Some(crate::spool::Entry {
1301 delivery_id: r.get("delivery_id")?.as_str()?.to_owned(),
1302 message_id: r.get("message_id")?.as_str()?.to_owned(),
1303 conversation_id: r
1304 .get("conversation_id")
1305 .and_then(|v| v.as_str())
1306 .unwrap_or_default()
1307 .to_owned(),
1308 seq: r.get("seq").and_then(|v| v.as_i64()).unwrap_or(0),
1309 from_address: r
1310 .get("from_address")
1311 .and_then(|v| v.as_str())
1312 .unwrap_or_default()
1313 .to_owned(),
1314 created_at: r
1315 .get("created_at")
1316 .and_then(|v| v.as_str())
1317 .unwrap_or_default()
1318 .to_owned(),
1319 confirmed: false,
1320 spooled_at: chrono::Utc::now().to_rfc3339(),
1321 })
1322 })
1323 .collect::<Vec<_>>()
1324 })
1325 .unwrap_or_default();
1326
1327 let mut held = crate::spool::read(&path);
1331 let spooled = match crate::spool::append(&path, &entries) {
1332 Ok(written) => written,
1333 Err(e) => {
1334 tracing::warn!(error = %e, "could not spool inbox references; not confirming");
1335 return result;
1336 }
1337 };
1338 held.extend(spooled.iter().cloned());
1339 entries.clear();
1340 let to_confirm = crate::spool::unconfirmed(&held);
1341 if to_confirm.is_empty() {
1342 return result;
1343 }
1344
1345 let mut params = rmcp::model::JsonObject::new();
1346 params.insert(
1347 "delivery_ids".into(),
1348 Value::Array(
1349 to_confirm
1350 .iter()
1351 .map(|id| Value::String(id.clone()))
1352 .collect(),
1353 ),
1354 );
1355 let confirm =
1356 CallToolRequestParams::new("confirm_inbox_delivery".to_string()).with_arguments(params);
1357 match self.forward(confirm, host_ct).await {
1358 Ok(confirmation) => {
1359 let settled = settled_ids(confirmation.structured_content.as_ref(), &to_confirm);
1366 let sent: std::collections::HashSet<&String> = to_confirm.iter().collect();
1367 let mut left = 0usize;
1368 for entry in held.iter_mut() {
1369 if sent.contains(&entry.delivery_id) {
1370 if settled.contains(&entry.delivery_id) {
1371 entry.confirmed = true;
1372 } else {
1373 left += 1;
1374 }
1375 }
1376 }
1377 if left > 0 {
1378 tracing::warn!(
1379 sent = to_confirm.len(),
1380 left,
1381 "the bus did not settle every reference sent; keeping the rest in \
1382 the spool"
1383 );
1384 }
1385 if let Err(e) = crate::spool::rewrite(&path, &held) {
1386 tracing::warn!(error = %e, "could not compact the inbox spool");
1390 }
1391 }
1392 Err(e) => tracing::warn!(error = %e, "could not confirm inbox delivery"),
1393 }
1394 result
1395 }
1396
1397 async fn forward(
1398 &self,
1399 request: CallToolRequestParams,
1400 host_ct: CancellationToken,
1401 ) -> Result<CallToolResult, ErrorData> {
1402 let (remote, ct, generation, _guard) = {
1403 let st = self.state.read().await;
1404 let Some(c) = &st.connected else {
1405 return Err(ErrorData::invalid_request(
1406 format!(
1407 "not connected to the bus: {}. Call {CONFIGURE_TOOL} with an approved \
1408 profile, or fix the local configuration and start a new conversation",
1409 st.disconnected_reason
1410 .as_deref()
1411 .unwrap_or("no profile resolved")
1412 ),
1413 None,
1414 ));
1415 };
1416 (
1417 c.remote.clone(),
1418 c.ct.clone(),
1419 st.generation,
1420 InFlight::enter(&self.in_flight),
1421 )
1422 };
1423 let name = request.name.to_string();
1424 let profile = {
1425 let st = self.state.read().await;
1426 st.connected
1427 .as_ref()
1428 .and_then(|c| c.resolved.profile.clone())
1429 };
1430 let outcome = tokio::select! {
1431 r = remote.call_tool(request) => r.map_err(|e| {
1432 if unauthorized(&e) {
1436 self.mark_unauthorized(&profile);
1437 ErrorData::invalid_request(
1438 format!(
1439 "{name}: the bus rejected this window's credential — it has been \
1440 revoked or rotated{}. Issue a new token (`ai-crew-sync admin \
1441 token issue --save`) and call {CONFIGURE_TOOL} with an approved \
1442 profile; nothing was sent",
1443 profile
1444 .as_deref()
1445 .map(|p| format!(" (profile '{p}')"))
1446 .unwrap_or_default()
1447 ),
1448 None,
1449 )
1450 } else if let ServiceError::McpError(data) = e {
1451 data
1454 } else if let Some(r) = refusal(&e) {
1455 tracing::warn!(error = %e, tool = %name, "the bus refused a forwarded call");
1458 ErrorData::invalid_request(format!("{name}: {}", r.text()), None)
1459 } else {
1460 tracing::warn!(error = %e, tool = %name, "a forwarded call failed in transport");
1464 ErrorData::internal_error(
1465 format!(
1466 "{name} could not reach the bus: the connection failed before an \
1467 answer came back, so the call may or may not have run. Check \
1468 before repeating anything that is not safe to repeat."
1469 ),
1470 None,
1471 )
1472 }
1473 }),
1474 _ = ct.cancelled() => Err(ErrorData::invalid_request(
1475 format!(
1476 "{name} was cancelled: this window switched credentials while the call was \
1477 in flight (generation {generation}). Nothing was replayed; call again if \
1478 it is still wanted, as the new identity"
1479 ),
1480 None,
1481 )),
1482 _ = host_ct.cancelled() => Err(ErrorData::invalid_request(
1483 format!("{name} was cancelled by the client"),
1484 None,
1485 )),
1486 };
1487 outcome
1488 }
1489
1490 fn mark_unauthorized(&self, profile: &Option<String>) {
1495 if let Ok(mut st) = self.state.try_write() {
1496 st.disconnected_reason = Some(format!(
1497 "the bus rejected the credential{} (revoked or rotated)",
1498 profile
1499 .as_deref()
1500 .map(|p| format!(" of profile '{p}'"))
1501 .unwrap_or_default()
1502 ));
1503 }
1504 }
1505
1506 async fn default_channel(&self) -> Option<String> {
1509 let st = self.state.read().await;
1510 st.channel.clone().or_else(|| st.project.clone())
1511 }
1512
1513 fn instructions(&self, st: &State) -> String {
1514 let mut lines = Vec::new();
1515 match &st.connected {
1516 Some(c) => {
1517 lines.push(format!(
1518 "[ai-crew-sync] You are agent '{}' on team '{}', in session '{}'. Teammates \
1519 reach exactly this window at '{}/{}'.",
1520 c.agent, c.team, st.session, c.agent, st.session
1521 ));
1522 lines.push(format!(
1523 "- project: {}, role: {}, default channel: {}. Change them with \
1524 {CONFIGURE_TOOL}; see them with {STATUS_TOOL}. Find teammates' windows \
1525 with list_sessions.",
1526 st.project.as_deref().unwrap_or("(none — set it)"),
1527 st.role.as_deref().unwrap_or("(none — set it)"),
1528 st.channel
1529 .as_deref()
1530 .or(st.project.as_deref())
1531 .unwrap_or("(none)"),
1532 ));
1533 lines.push(
1534 "- Nothing is pushed into an idle turn: call read_messages or wait_for_updates \
1535 to receive what teammates sent."
1536 .to_owned(),
1537 );
1538 if let Some(remote) = &c.remote_instructions {
1539 lines.push(String::new());
1540 lines.push(remote.clone());
1541 }
1542 }
1543 None => {
1544 lines.push(format!(
1545 "[ai-crew-sync] Not connected to the team bus: {}. Only {CONFIGURE_TOOL} and \
1546 {STATUS_TOOL} are available until a locally approved profile connects.",
1547 st.disconnected_reason
1548 .as_deref()
1549 .unwrap_or("no profile resolved")
1550 ));
1551 }
1552 }
1553 lines.join("\n")
1554 }
1555
1556 pub async fn keepalive(self, ct: CancellationToken) {
1561 let mut next_heartbeat = tokio::time::Instant::now() + KEEPALIVE_EVERY;
1562 let mut not_before: Option<(u64, tokio::time::Instant)> = None;
1569 loop {
1570 let deadline = self.renewal_deadline().await;
1571 let renew_at = deadline.map(|(generation, at)| match not_before {
1572 Some((for_generation, nb)) if for_generation == generation => at.max(nb),
1573 _ => at,
1574 });
1575 let renew_sleep = tokio::time::sleep_until(
1576 renew_at.unwrap_or_else(|| tokio::time::Instant::now() + KEEPALIVE_EVERY),
1577 );
1578 tokio::select! {
1579 _ = ct.cancelled() => return,
1580 _ = self.wake.notified() => {}
1582 _ = tokio::time::sleep_until(next_heartbeat) => {
1583 self.heartbeat("active").await;
1584 next_heartbeat = tokio::time::Instant::now() + KEEPALIVE_EVERY;
1585 }
1586 _ = renew_sleep, if renew_at.is_some() => {
1587 let pause = match self.renew_credential().await {
1588 Renewal::Refused => KEEPALIVE_EVERY,
1592 Renewal::Renewed | Renewal::Retry | Renewal::Nothing => self.renewal_retry().await,
1593 };
1594 if let Some((generation, _)) = deadline {
1595 not_before = Some((generation, tokio::time::Instant::now() + pause));
1596 }
1597 }
1598 }
1599 }
1600 }
1601
1602 async fn renewal_deadline(&self) -> Option<(u64, tokio::time::Instant)> {
1606 let (generation, expires_at) = {
1607 let st = self.state.read().await;
1608 let expires_at = st
1609 .connected
1610 .as_ref()
1611 .and_then(|c| c.proof.as_ref())
1612 .map(|p| p.expires_at.clone())?;
1613 (st.generation, expires_at)
1614 };
1615 let expires_at = chrono::DateTime::parse_from_rfc3339(&expires_at).ok()?;
1616 let remaining = (expires_at.with_timezone(&chrono::Utc) - chrono::Utc::now())
1617 .num_seconds()
1618 .max(0);
1619 let due_in = (remaining - self.renewal_lead().await).max(0) as u64;
1620 Some((
1621 generation,
1622 tokio::time::Instant::now() + Duration::from_secs(due_in),
1623 ))
1624 }
1625
1626 async fn renewal_lead(&self) -> i64 {
1627 let lifetime = requested_session_ttl().unwrap_or(crate::auth::SESSION_TTL_SECS);
1628 renewal_lead_secs(lifetime, env_secs(RENEW_LEAD_ENV))
1629 }
1630
1631 async fn renewal_retry(&self) -> Duration {
1634 Duration::from_secs((self.renewal_lead().await / 4).clamp(2, 60) as u64)
1635 }
1636
1637 async fn renew_credential(&self) -> Renewal {
1642 let (remote, proof, generation, ct, profile) = {
1643 let st = self.state.read().await;
1644 let Some(c) = &st.connected else {
1645 return Renewal::Nothing;
1646 };
1647 let Some(p) = &c.proof else {
1648 return Renewal::Nothing;
1649 };
1650 (
1651 c.remote.clone(),
1652 p.clone(),
1653 st.generation,
1654 c.ct.clone(),
1655 c.resolved.profile.clone(),
1656 )
1657 };
1658 let mut args = json!({});
1659 if let Some(ttl) = requested_session_ttl() {
1660 args["ttl_seconds"] = json!(ttl);
1661 }
1662 let outcome = tokio::select! {
1663 _ = ct.cancelled() => return Renewal::Nothing,
1664 r = call_remote(&remote, "renew_session", args) => r,
1665 };
1666 match outcome {
1667 Ok(v) => {
1668 let Some(expires_at) = v["expires_at"].as_str().map(str::to_owned) else {
1669 tracing::warn!("renew_session answered without an expiry; keeping the old one");
1670 return Renewal::Retry;
1671 };
1672 if v["epoch"].as_i64().is_some_and(|e| e != proof.epoch) {
1673 tracing::warn!(
1676 "renew_session answered for another epoch; keeping the credential this \
1677 window holds"
1678 );
1679 return Renewal::Retry;
1680 }
1681 {
1682 let mut st = self.state.write().await;
1683 if st.generation != generation {
1684 return Renewal::Nothing;
1685 }
1686 let Some(current) = st.connected.as_mut().and_then(|c| c.proof.as_mut()) else {
1687 return Renewal::Nothing;
1688 };
1689 if current.session_id != proof.session_id || current.epoch != proof.epoch {
1690 return Renewal::Nothing;
1691 }
1692 current.expires_at = expires_at.clone();
1693 }
1694 self.stamp_binding_expiry(&proof, &expires_at).await;
1695 tracing::debug!(expires_at = %expires_at, "session credential renewed");
1696 Renewal::Renewed
1697 }
1698 Err(e) => match verdict_of(&e) {
1699 Some(Verdict::Unauthorized) => {
1700 tracing::warn!(error = %e, "the bus refused to renew this window's credential");
1701 self.mark_unauthorized(&profile);
1702 Renewal::Refused
1703 }
1704 Some(Verdict::NoSuchTool) => {
1705 tracing::debug!("this bus does not renew credentials");
1706 Renewal::Nothing
1707 }
1708 None => {
1709 tracing::warn!(error = %e, "could not renew this window's credential; retrying");
1710 Renewal::Retry
1711 }
1712 },
1713 }
1714 }
1715
1716 async fn stamp_binding_expiry(&self, proof: &SessionProof, expires_at: &str) {
1720 let key = {
1721 let st = self.state.read().await;
1722 st.host_id.clone().unwrap_or_else(|| st.session.clone())
1723 };
1724 let path = context::binding_path(&self.opts.state_dir, &key);
1725 let (session_id, epoch, expires_at) =
1730 (proof.session_id.clone(), proof.epoch, expires_at.to_owned());
1731 let stamped = under_config_lock(self.opts.state_dir.clone(), move || {
1732 let Ok(text) = std::fs::read_to_string(&path) else {
1733 return Ok(false);
1734 };
1735 let Ok(mut value) = serde_json::from_str::<Value>(&text) else {
1736 return Ok(false);
1737 };
1738 let same = value["session_id"].as_str() == Some(session_id.as_str())
1739 && value["epoch"].as_i64() == Some(epoch);
1740 if !same {
1741 return Ok(false);
1742 }
1743 if let Some(map) = value.as_object_mut() {
1744 map.insert("expires_at".into(), json!(expires_at));
1745 map.insert(
1746 "updated_at".into(),
1747 json!(chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)),
1748 );
1749 }
1750 context::write_binding_file(&path, &value.to_string())?;
1751 Ok(true)
1752 })
1753 .await;
1754 match stamped {
1755 Ok(true) => {}
1756 Ok(false) => tracing::debug!(
1757 binding = %key,
1758 "another instance owns this binding now, or it is gone; not stamping"
1759 ),
1760 Err(e) => {
1761 tracing::warn!(error = %e, binding = %key, "could not record the renewed expiry")
1762 }
1763 }
1764 }
1765
1766 pub async fn shutdown(&self) {
1768 let remote = {
1769 let st = self.state.read().await;
1770 st.connected.as_ref().map(|c| c.remote.clone())
1771 };
1772 if let Some(remote) = remote {
1773 let _ = tokio::time::timeout(
1774 EXIT_TIMEOUT,
1775 call_remote(
1776 &remote,
1777 "heartbeat",
1778 json!({"status": "idle", "ttl_seconds": 120}),
1779 ),
1780 )
1781 .await;
1782 close_remote(remote).await;
1783 }
1784 self.mark_closed().await;
1788 }
1789
1790 async fn mark_closed(&self) {
1802 let (key, mine) = {
1803 let st = self.state.read().await;
1804 let key = st.host_id.clone().unwrap_or_else(|| st.session.clone());
1805 let mine = st
1806 .connected
1807 .as_ref()
1808 .and_then(|c| c.proof.as_ref().map(|p| (p.session_id.clone(), p.epoch)));
1809 (key, mine)
1810 };
1811 let path = context::binding_path(&self.opts.state_dir, &key);
1812 let shown = key.clone();
1813 let outcome = under_config_lock(self.opts.state_dir.clone(), move || {
1814 let Ok(text) = std::fs::read_to_string(&path) else {
1815 return Ok(false);
1816 };
1817 let Ok(mut value) = serde_json::from_str::<Value>(&text) else {
1818 return Ok(false);
1819 };
1820 if let Some((session_id, epoch)) = mine {
1823 let same = value["session_id"].as_str() == Some(session_id.as_str())
1824 && value["epoch"].as_i64() == Some(epoch);
1825 if !same {
1826 return Ok(false);
1827 }
1828 }
1829 if let Some(map) = value.as_object_mut() {
1830 map.insert("closed_at".into(), json!(chrono::Utc::now().to_rfc3339()));
1831 }
1832 context::write_binding_file(&path, &value.to_string())?;
1833 Ok(true)
1834 })
1835 .await;
1836 match outcome {
1837 Ok(true) => {}
1838 Ok(false) => tracing::debug!(
1839 binding = %shown,
1840 "another instance owns this binding now, or it is gone; leaving it alone"
1841 ),
1842 Err(e) => {
1843 tracing::warn!(error = %e, binding = %shown, "could not mark the binding closed")
1844 }
1845 }
1846 }
1847}
1848
1849async fn under_config_lock<T: Send + 'static>(
1854 dir: PathBuf,
1855 f: impl FnOnce() -> anyhow::Result<T> + Send + 'static,
1856) -> anyhow::Result<T> {
1857 tokio::task::spawn_blocking(move || context::with_config_lock(&dir, f))
1858 .await
1859 .map_err(|e| anyhow::anyhow!("the binding writer task failed: {e}"))?
1860}
1861
1862async fn close_remote(remote: Arc<Remote>) {
1866 if let Ok(owned) = Arc::try_unwrap(remote) {
1867 let _ = owned.cancel().await;
1868 }
1869}
1870
1871async fn report_holdings(
1873 remote: &Remote,
1874 agent: &str,
1875 team: &str,
1876 session: &str,
1877) -> PreviousIdentity {
1878 let open_claims = call_remote(remote, "list_tasks", json!({"mine_only": true}))
1879 .await
1880 .ok()
1881 .and_then(|v| v["tasks"].as_array().cloned())
1882 .unwrap_or_default()
1883 .iter()
1884 .filter(|t| t["status"] == "claimed")
1885 .filter_map(|t| t["key"].as_str().map(str::to_owned))
1886 .collect();
1887 let held_locks = call_remote(remote, "list_locks", json!({}))
1888 .await
1889 .ok()
1890 .and_then(|v| v["locks"].as_array().cloned())
1891 .unwrap_or_default()
1892 .iter()
1893 .filter(|l| {
1896 l["holder"] == agent && l["holder_session"].as_str().unwrap_or_default() == session
1897 })
1898 .filter_map(|l| l["name"].as_str().map(str::to_owned))
1899 .collect();
1900 PreviousIdentity {
1901 agent: agent.to_owned(),
1902 team: team.to_owned(),
1903 session: session.to_owned(),
1904 open_claims,
1905 held_locks,
1906 }
1907}
1908
1909fn tool_error(msg: String) -> CallToolResult {
1910 CallToolResult::error(vec![rmcp::model::ContentBlock::text(msg)])
1911}
1912
1913impl ServerHandler for Proxy {
1914 fn get_info(&self) -> ServerConfig {
1915 let mut info = ServerConfig::new(ServerCapabilities::builder().enable_tools().build());
1916 let text = match self.state.try_read() {
1920 Ok(st) => self.instructions(&st),
1921 Err(_) => format!("[ai-crew-sync] initialising; call {STATUS_TOOL} for details."),
1922 };
1923 info.instructions = Some(text);
1924 info
1925 }
1926
1927 async fn list_tools(
1928 &self,
1929 _request: Option<PaginatedRequestParams>,
1930 _context: RequestContext<RoleServer>,
1931 ) -> Result<ListToolsResult, ErrorData> {
1932 let mut tools = local_tools();
1933 if let Some(c) = &self.state.read().await.connected {
1934 tools.extend(c.tools.iter().cloned());
1935 }
1936 Ok(ListToolsResult::with_all_items(tools))
1937 }
1938
1939 async fn call_tool(
1940 &self,
1941 request: CallToolRequestParams,
1942 context: RequestContext<RoleServer>,
1943 ) -> Result<CallToolResponse, ErrorData> {
1944 self.observe_meta(&context.meta).await?;
1946 match request.name.as_ref() {
1947 STATUS_TOOL => {
1948 let status = self.status().await;
1949 let value = serde_json::to_value(status)
1950 .map_err(|e| ErrorData::internal_error(e.to_string(), None))?;
1951 Ok(CallToolResult::structured(value).into())
1952 }
1953 CONFIGURE_TOOL => {
1954 let args: ConfigureArgs = match request.arguments {
1955 Some(map) => serde_json::from_value(Value::Object(map))
1956 .map_err(|e| ErrorData::invalid_params(e.to_string(), None))?,
1957 None => ConfigureArgs::default(),
1958 };
1959 match self.configure(args).await {
1960 Ok(result) => {
1961 let value = serde_json::to_value(result)
1962 .map_err(|e| ErrorData::internal_error(e.to_string(), None))?;
1963 Ok(CallToolResult::structured(value).into())
1964 }
1965 Err(e) => Ok(tool_error(format!("{e:#}")).into()),
1968 }
1969 }
1970 "fetch_conversation_inbox" => {
1975 let result = self.forward(request, context.ct.clone()).await?;
1976 Ok(self.spool_and_confirm(result, context.ct).await.into())
1977 }
1978 "post_message" => {
1983 let mut request = request;
1984 if let Some(channel) = self.default_channel().await {
1985 let args = request.arguments.get_or_insert_with(Default::default);
1986 let addressed = args.contains_key("channel") || args.contains_key("to");
1987 if !addressed {
1988 args.insert("channel".into(), Value::String(channel));
1989 }
1990 }
1991 Ok(self.forward(request, context.ct).await?.into())
1992 }
1993 _ => Ok(self.forward(request, context.ct).await?.into()),
1994 }
1995 }
1996}
1997
1998pub async fn run(opts: ProxyOptions) -> anyhow::Result<()> {
2000 let proxy = Proxy::start(opts).await;
2001 let ct = CancellationToken::new();
2002 let keepalive = tokio::spawn(proxy.clone().keepalive(ct.child_token()));
2003
2004 let running = proxy
2005 .clone()
2006 .serve(rmcp::transport::stdio())
2007 .await
2008 .context("MCP initialize over stdio failed")?;
2009 let quit = running.waiting().await;
2010 tracing::debug!(?quit, "host closed the connection");
2011
2012 ct.cancel();
2013 let _ = keepalive.await;
2014 proxy.shutdown().await;
2015 Ok(())
2016}
2017
2018#[cfg(test)]
2019mod renewal_tests {
2020 #[test]
2021 fn renewal_lead_is_half_the_lifetime_unless_overridden_and_inside_it() {
2022 assert_eq!(super::renewal_lead_secs(24 * 3600, None), 12 * 3600);
2023 assert_eq!(super::renewal_lead_secs(60, None), 30);
2024 assert_eq!(super::renewal_lead_secs(60, Some(50)), 50);
2025 assert_eq!(
2026 super::renewal_lead_secs(60, Some(600)),
2027 59,
2028 "never past the lifetime"
2029 );
2030 assert_eq!(
2031 super::renewal_lead_secs(1, None),
2032 1,
2033 "a degenerate lifetime still yields a lead"
2034 );
2035 }
2036}
2037
2038#[cfg(test)]
2039mod unauthorized_tests {
2040 use rmcp::{
2041 RoleClient,
2042 model::{ErrorCode, ErrorData},
2043 transport::{
2044 DynamicTransportError, StreamableHttpClientTransport,
2045 streamable_http_client::{AuthRequiredError, StreamableHttpError},
2046 },
2047 };
2048
2049 use super::*;
2050
2051 fn transport(e: StreamableHttpError<reqwest::Error>) -> ServiceError {
2052 ServiceError::TransportSend(DynamicTransportError::new::<
2053 StreamableHttpClientTransport<reqwest::Client>,
2054 RoleClient,
2055 >(e))
2056 }
2057
2058 #[test]
2059 fn a_rejected_bearer_is_the_transport_saying_so() {
2060 let e = transport(StreamableHttpError::AuthRequired(AuthRequiredError::new(
2061 "Bearer".into(),
2062 )));
2063 assert!(unauthorized(&e));
2064 }
2065
2066 #[test]
2067 fn a_refusal_that_spells_401_is_still_a_refusal() {
2068 let e = ServiceError::McpError(ErrorData::invalid_request(
2071 "you do not hold the claim on 'api#1': it is held by joaquin (session \
2072 's-a7da401d8d70'), the lease expires in 401s",
2073 None,
2074 ));
2075 assert!(!unauthorized(&e));
2076 let e = ServiceError::McpError(ErrorData::internal_error("Auth required", None));
2077 assert!(
2078 !unauthorized(&e),
2079 "not even when it borrows the transport's words"
2080 );
2081 }
2082
2083 #[test]
2084 fn a_missing_tool_is_the_code_saying_so() {
2085 let e = ServiceError::McpError(ErrorData::invalid_params("tool not found", None));
2087 assert!(no_such_tool(&e));
2088 assert_eq!(verdict(&e), Some(Verdict::NoSuchTool));
2089 let e = ServiceError::McpError(ErrorData::new(
2091 ErrorCode::METHOD_NOT_FOUND,
2092 "Method not found",
2093 None,
2094 ));
2095 assert!(no_such_tool(&e));
2096 }
2097
2098 #[test]
2099 fn a_refusal_that_spells_a_missing_method_is_still_a_refusal() {
2100 let e = ServiceError::McpError(ErrorData::invalid_params(
2103 "conflict: session 's-32601f03e877' is already registered and still live. \
2104 Holding the agent token does not make you that window: Method aside, \
2105 reconnect it with resume_session",
2106 None,
2107 ));
2108 assert!(!no_such_tool(&e));
2109 assert_eq!(verdict(&e), None);
2110 let e = ServiceError::McpError(ErrorData::invalid_params("not found: message 32601", None));
2112 assert!(!no_such_tool(&e));
2113 let e = ServiceError::McpError(ErrorData::invalid_params(
2116 "tool not found: the deploy tool named in `depends_on` does not exist",
2117 None,
2118 ));
2119 assert!(!no_such_tool(&e));
2120 assert!(!no_such_tool(&ServiceError::TransportClosed));
2121 }
2122
2123 #[test]
2124 fn a_verdict_survives_the_anyhow_chain() {
2125 let e = anyhow::Error::new(Verdict::NoSuchTool).context("register_session failed");
2126 assert_eq!(verdict_of(&e), Some(Verdict::NoSuchTool));
2127 let e = anyhow::anyhow!("register_session failed: tool not found -32601 Method");
2128 assert_eq!(verdict_of(&e), None, "words are not a verdict");
2129 }
2130
2131 #[test]
2132 fn an_http_refusal_is_read_for_its_shape() {
2133 let answered = |msg: &str| {
2134 transport(StreamableHttpError::UnexpectedServerResponse(
2135 msg.to_owned().into(),
2136 ))
2137 };
2138 let e = answered(r#"HTTP 429 Too Many Requests: {"error":"rate limit exceeded"}"#);
2140 assert_eq!(
2141 refusal(&e),
2142 Some(Refusal {
2143 status: 429,
2144 said: Some("rate limit exceeded".into())
2145 })
2146 );
2147 assert!(remote_error_text(&e).contains("rate limit exceeded"));
2148 assert!(!unauthorized(&e));
2149 let e = answered("HTTP 404 Not Found: <html>nope</html>");
2151 assert_eq!(
2152 refusal(&e),
2153 Some(Refusal {
2154 status: 404,
2155 said: None
2156 })
2157 );
2158 assert_eq!(refusal(&answered("HTTP 504 Gateway Timeout: ")), None);
2160 assert_eq!(
2161 refusal(&answered("invalid www-authenticate header value")),
2162 None
2163 );
2164 assert_eq!(refusal(&ServiceError::TransportClosed), None);
2165 }
2166
2167 #[test]
2168 fn another_transport_failure_is_not_a_rejected_bearer() {
2169 let e = transport(StreamableHttpError::UnexpectedContentType(Some(
2170 "text/html; 401".into(),
2171 )));
2172 assert!(!unauthorized(&e));
2173 assert!(!unauthorized(&ServiceError::TransportClosed));
2174 }
2175}