Skip to main content

ai_crew_sync/
proxy.rs

1//! `ai-crew-sync mcp proxy`: a local stdio MCP server that forwards every
2//! tool call to the remote bus as **one** agent in **one** session.
3//!
4//! Why a proxy at all: the bus is stateless Streamable HTTP and any MCP client
5//! can talk to it directly. What a direct connection cannot do is give each
6//! *conversation* its own session when several windows open the same
7//! repository with the same token — a header set once in a client config is
8//! the same header in every window. A stdio server is started by the host
9//! once per conversation (that is the MCP norm, and what Claude Code and
10//! Codex do), so the process itself is the unit of isolation: it mints the
11//! session label, sends it on every forwarded request, and keeps the
12//! project/role metadata for exactly that window.
13//!
14//! The proxy is host-agnostic. What a host provides is used as an
15//! enrichment, never required:
16//!
17//! - **Conversation identity**, in order: `--host-session` / `BUS_HOST_SESSION`
18//!   (any host that can set per-window environment), `CLAUDE_CODE_SESSION_ID`
19//!   (Claude Code exports it to MCP server processes), the `_meta.threadId`
20//!   Codex attaches to every `tools/call`, and otherwise the process itself
21//!   — a random id that lives as long as this instance. A known conversation
22//!   id derives a **stable** session label, so a resumed conversation
23//!   reconnects to the same session and a forked one gets a new one.
24//! - **Start-of-session context** goes into the `initialize` result's
25//!   `instructions`, which every MCP client hands to the model. No hook
26//!   needed.
27//! - **Presence** is kept by the proxy itself: a heartbeat on connect (with
28//!   repo and branch read from the project directory), a periodic
29//!   keep-alive, `idle` on exit.
30//!
31//! Two local tools, `configure_session` and `session_status`, never reach the
32//! remote server's catalogue. They change metadata (project, role, channel)
33//! and, within the same team, the profile; a team change needs a new
34//! conversation, because switching credentials cannot erase what this
35//! conversation has already seen. Every profile change is verified with
36//! `whoami` before it is committed, in-flight calls to the old context are
37//! cancelled rather than replayed, and claims or locks held by the old
38//! identity are reported, never transferred.
39//!
40//! Stdout carries MCP framing only; everything else goes to stderr.
41
42use 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
72/// Prefix of a session label the proxy mints. Opaque on purpose: the label
73/// is an address, `project`/`role` are the human-facing part.
74pub const SESSION_PREFIX: &str = "s-";
75/// Hex characters after the prefix. 128 bits: the label is an address that
76/// partitions cursors, claims and locks, so two conversations colliding
77/// would merge them silently. The session-label limit is 64 bytes, which
78/// leaves room to spare.
79const SESSION_HEX: usize = 32;
80
81/// Presence lease the proxy keeps alive, and how often it renews it.
82const PRESENCE_TTL_SECS: i64 = 900;
83const KEEPALIVE_EVERY: Duration = Duration::from_secs(300);
84/// Credential lifetime the proxy asks the bus for, in seconds, when
85/// `BUS_SESSION_TTL_SECS` is set; unset takes the bus default. The bus
86/// bounds it (60 s to 24 h).
87const SESSION_TTL_ENV: &str = "BUS_SESSION_TTL_SECS";
88/// How long before its expiry the credential is renewed, in seconds, when
89/// `BUS_SESSION_RENEW_LEAD_SECS` is set; unset renews half-way through the
90/// lifetime, so a transient failure has the other half to retry in.
91const RENEW_LEAD_ENV: &str = "BUS_SESSION_RENEW_LEAD_SECS";
92/// How long a context switch waits for in-flight calls to the old context
93/// after cancelling them, before swapping anyway.
94const DRAIN_TIMEOUT: Duration = Duration::from_secs(5);
95/// How long the exit heartbeat may take; the host is waiting.
96const 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/// Where the conversation id came from, reported by `session_status`.
104#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, JsonSchema)]
105#[serde(rename_all = "kebab-case")]
106pub enum Binding {
107    /// `--host-session` or `BUS_HOST_SESSION`.
108    Explicit,
109    /// `CLAUDE_CODE_SESSION_ID` in the environment.
110    ClaudeCode,
111    /// `_meta.threadId` on the first forwarded call.
112    RequestMeta,
113    /// No conversation id: this process is the conversation.
114    Instance,
115}
116
117/// Command-line shape of the proxy.
118#[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    /// Explicit conversation id.
125    pub host_session: Option<String>,
126    /// Where the proxy keeps its binding record; the config directory.
127    pub state_dir: PathBuf,
128}
129
130/// Session label derived from a conversation id: stable for the same id,
131/// unrelated to repository, role or pid. Shared with the resolver so a hook
132/// of the same conversation lands on the same session without coordinating.
133pub use crate::context::session_for_host as session_for;
134
135/// Validate a discovery label the way the bus does, so `configure_session`
136/// refuses what `heartbeat` would reject rather than storing it locally and
137/// reporting a success the server never saw. Empty clears.
138fn 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
159/// The lifetime the proxy asks for on register, resume and renew; `None`
160/// leaves it to the bus. Clamped to the bounds the bus applies, so the
161/// renewal schedule is computed from the lifetime actually issued rather
162/// than from a number the bus would have cut down.
163fn requested_session_ttl() -> Option<i64> {
164    env_secs(SESSION_TTL_ENV).map(|v| v.clamp(60, crate::auth::MAX_SESSION_TTL_SECS))
165}
166
167/// Seconds before expiry at which the credential is renewed, for a
168/// credential of `lifetime` seconds: the override when set, else half the
169/// lifetime, and always inside the lifetime so the deadline is never
170/// already past on issue.
171fn 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/// The credential this window proved itself with, when the bus issues them.
177#[derive(Clone, Debug)]
178pub struct SessionProof {
179    /// The secret. Written only to the 0600 binding file, never to a tool
180    /// result, a log or argv.
181    pub token: String,
182    pub session_id: String,
183    pub epoch: i64,
184    pub expires_at: String,
185}
186
187/// What one renewal attempt did.
188enum Renewal {
189    Renewed,
190    /// The bus rejected the credential: revoked, expired or replaced.
191    Refused,
192    /// Transient: try again before the credential lapses.
193    Retry,
194    /// Nothing to renew, or the answer no longer applies to this context.
195    Nothing,
196}
197
198/// The verified, connected context of this instance.
199struct 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    /// Cancelled when this context is replaced; forwarded calls race it.
208    ct: CancellationToken,
209}
210
211struct State {
212    /// Present once a profile resolved and verified. Absent means the proxy
213    /// serves only its local tools and says why in `instructions`.
214    connected: Option<Connected>,
215    /// Why there is no connection, for the model.
216    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    /// Bumped on every context switch.
224    generation: u64,
225}
226
227/// Counts calls currently forwarded; decremented on drop so a request the
228/// host abandons mid-flight still lets a context switch drain.
229struct 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    /// Serialises context switches.
249    switch: Arc<Mutex<()>>,
250    /// Pinged whenever the connected context changes, so the keepalive
251    /// recomputes its renewal deadline at once instead of on its next
252    /// presence tick: a credential established after startup, or after a
253    /// refusal, must not wait five minutes for its first schedule.
254    wake: Arc<tokio::sync::Notify>,
255    opts: Arc<ProxyOptions>,
256    project_dir: PathBuf,
257}
258
259// -------------------------------------------------------------- local tools --
260
261#[derive(Debug, Default, Deserialize, JsonSchema)]
262pub struct ConfigureArgs {
263    /// What this window does on the project: implementation, design,
264    /// review, … One lower-case word. Changing it keeps the session and its
265    /// cursors; it only changes how teammates find you.
266    #[serde(default)]
267    pub role: Option<String>,
268    /// Logical project, usually the repository name. Also the channel this
269    /// session posts to by default when one of that name exists.
270    #[serde(default)]
271    pub project: Option<String>,
272    /// Channel to post to by default; overrides the project's.
273    #[serde(default)]
274    pub channel: Option<String>,
275    /// Switch to another locally approved profile. Verified with whoami
276    /// before anything changes; must stay within the same team.
277    #[serde(default)]
278    pub profile: Option<String>,
279}
280
281#[derive(Debug, Serialize, JsonSchema)]
282pub struct Status {
283    /// True when calls are being forwarded to the bus.
284    pub connected: bool,
285    /// Why not, when `connected` is false.
286    pub error: Option<String>,
287    /// Verified with whoami; never asserted.
288    pub agent: Option<String>,
289    pub team: Option<String>,
290    /// Session label every forwarded call carries.
291    pub session: String,
292    /// `agent/session`: what a teammate puts in `to` to reach this window.
293    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    /// What the previous identity still holds, when the profile changed.
307    /// Nothing is transferred: these expire by their own leases, or the
308    /// previous identity releases them from its own window.
309    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
358// --------------------------------------------------------------- connecting --
359
360async 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    // Fencing is opt-in per connection: with it, a request from a process
376    // that has been resumed away is refused instead of writing as the window
377    // that replaced it.
378    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            // `session_status` and every refusal while disconnected show
393            // this text to the model: the bus's words when it refused, one
394            // sentence when it could not be reached, the URL and the OS
395            // error only in the log.
396            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    // The handshake now carries the bus's real version (a 0.7.0-or-older
422    // server identifies as `rmcp`, which says nothing). Skew is reported as
423    // a hint, never as a refusal: nothing here decides that two versions
424    // are incompatible — it only ends the search when something else fails.
425    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
447/// The bus refused this window's bearer. Only the transport can say so: a
448/// 401 never reaches the JSON-RPC layer, and a tool's own error is the bus
449/// talking *to* the model, free to quote a session label, a lease or an id
450/// that happens to spell "401". Matching that text once turned an ordinary
451/// "held by joaquin" refusal into "your credential was revoked".
452fn 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        // A transport error that is not the reqwest one: fall back to the
459        // wording rmcp gives a rejected bearer, still never a tool's text.
460        None => sent.error.to_string().contains("Auth required"),
461    }
462}
463
464/// The HTTP transport's own error inside a transport failure, found by
465/// type along the source chain. Every classification of a failed request
466/// (rejected bearer, HTTP refusal) reads it, never the rendered text.
467fn 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
480/// The bus has no such tool. rmcp answers an unknown tool with
481/// `invalid_params("tool not found")`, and a JSON-RPC layer without the
482/// method with -32601. A refusal from a tool that exists is neither,
483/// whatever it quotes: the bus's own "not found: …" errors share the code,
484/// and a live-label conflict quotes the label, which a conversation id can
485/// hash to `s-32601…`. Matching that text once kept a window label-only,
486/// forwarding with the parent token, exactly when it had to fail closed.
487fn 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/// What a failed call means, decided from the error's shape, never from its
498/// wording. Carried inside the `anyhow` chain by [`call_remote`] so a caller
499/// that only sees `anyhow::Error` reads the same fact.
500#[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
522/// The delivery ids a confirmation settled: the ones the bus committed
523/// now plus the ones it says were already confirmed for this caller. A bus
524/// too old to list ids answers with a count alone; then the count settles
525/// everything only when it matches what was sent, as before.
526fn 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/// The bus answered over HTTP and refused the request before running it.
545///
546/// The bus's middleware (the body limit, the rate limit, a stale epoch, an
547/// expired session credential) answers with a status and `{"error": "…"}`
548/// written for the model. That body is not a JSON-RPC error, so rmcp does
549/// not hand it back as one: it arrives as
550/// `UnexpectedServerResponse("HTTP {status}: {body}")` inside the transport
551/// error, where it looks like a lost connection unless read for its shape.
552#[derive(Debug, PartialEq, Eq)]
553struct Refusal {
554    status: u16,
555    /// The bus's own words, when the body was the bus's `{"error": …}`.
556    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
571/// The refusal inside a transport error, if the bus answered with one.
572/// Only an answer that certainly ran nothing counts: the bus's own body at
573/// any status, or a bare 4xx. A bare 5xx may come from a gateway that timed
574/// out waiting for a call that did run, so it stays a lost connection.
575fn 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
595/// What a failed call to the bus means, in words the model can use.
596///
597/// The bus writes its errors for the model, so an MCP error from it is its
598/// own message — never `ServiceError`'s rendering of it (`"Mcp error:
599/// -32602: …"`), which names a JSON-RPC code nobody downstream can act on.
600/// An HTTP refusal is the bus's too, in its middleware's words. Anything
601/// else is this proxy losing the bus: said once, without the transport's
602/// internals (URLs, OS errors), which go to the log.
603fn 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            // The verdict is read from the error's shape and carried in the
622            // chain; the text is only what a reader of the chain will see.
623            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        // The tool's own words, not the Rust `Debug` of its content blocks.
631        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
641/// Resolve, connect with the session header, and verify who the token is.
642/// Register this window for the first time. A server too old to know the
643/// tool keeps the label-only connection it has always served; any other
644/// failure is fatal, because continuing would forward with the parent token
645/// under an asserted label while reporting a proven identity.
646async 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            // Only "this server has no such tool" is a legacy bus. A refusal,
667            // a database error or a dropped connection is not, and failing
668            // closed is the point.
669            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
683/// Reconnect a window we already hold a credential for. Its own credential
684/// is the proof, so no agent token is involved; a rejected resume means the
685/// window was revoked or expired and the caller must register afresh.
686async 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    // Shadow warnings surface in the log (stderr): the host shows them with
728    // the server's output, and MCP stdout stays protocol-clean.
729    for w in &resolved.warnings {
730        tracing::warn!("{w}");
731    }
732    // First connection: the agent token, with the label in a header, exactly
733    // as any direct client would.
734    // The same wording a forwarded 401 gets, so a window started with a
735    // rotated token says what to do rather than "the bus did not accept the
736    // credential". The bus refuses it at connect or at whoami. Provenance
737    // names where the failing credential came from without revealing it.
738    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    // Then upgrade: talk with a credential that *proves* which window this
782    // is. Reconnecting an existing window resumes it with the credential
783    // already on disk — the bus refuses to hand a live window to whoever
784    // holds the agent token — and a window with no stored credential
785    // registers a new one.
786    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
816/// Repository and branch of the project directory, for presence. Best
817/// effort: a directory that is not a checkout simply reports neither.
818fn 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    /// Resolve the conversation id, connect and verify. Never fails: a proxy
844    /// that cannot connect still serves its local tools and explains itself.
845    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    /// Connect a context and make it current. Verifies before it touches
902    /// state; on failure the previous context, if any, stays.
903    /// `reuse_proof` is true when this is the *same* window reconnecting, so
904    /// the credential on disk is its own and a resume is right. A profile
905    /// switch passes false: that credential belongs to the identity being
906    /// left behind, and resuming it would keep speaking as them.
907    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        // A credential already on disk for this conversation means this is a
921        // reconnect: resume that window with its own proof rather than
922        // asking the bus to hand it over. Only when the identity is
923        // unchanged — see `reuse_proof`.
924        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        // A team switch would let one conversation carry another team's
942        // transcript into this one. The credential was verified and is
943        // dropped unused.
944        {
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        // Project and channel defaults from the project file, unless the
963        // caller set them explicitly.
964        {
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        // Barrier: cancel the old context's in-flight calls, wait for them to
975        // leave, then swap. Nothing is replayed.
976        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                    // This window is no longer that identity, so its session
992                    // is closed rather than left live for nobody: a session
993                    // with no process behind it blocks its own label and
994                    // keeps a credential valid for a day. Claims and locks
995                    // are NOT transferred — they stay with the old identity
996                    // and expire on their leases, which is what `held`
997                    // reports back to the caller.
998                    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                    // The old identity goes quiet in its own name.
1006                    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        // A stored permit, not a broadcast: a keepalive that is not waiting
1039        // at this instant still sees the change on its next select.
1040        self.wake.notify_one();
1041        self.heartbeat("active").await;
1042        self.write_binding().await;
1043        Ok(previous)
1044    }
1045
1046    /// Presence for this window: status, repo/branch from the checkout, and
1047    /// the discovery labels. Best effort.
1048    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        // Omitted keeps, "" clears: send exactly what this window knows.
1063        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    /// Record this instance's binding so lifecycle hooks of the same
1071    /// conversation can find the session and labels. Keyed by the
1072    /// conversation id when there is one (hooks know it), by the session
1073    /// otherwise (nothing else can look it up, but `session_status` can
1074    /// still say where it is).
1075    async fn write_binding(&self) {
1076        let st = self.state.read().await;
1077        // Keyed by the conversation id when the host gives one — that is what
1078        // a hook of the same conversation can look up — and by the session
1079        // label otherwise, where nothing else can find it anyway.
1080        let key = st.host_id.clone().unwrap_or_else(|| st.session.clone());
1081        // The credential goes in here, which is why the file is 0600 inside a
1082        // 0700 directory and why `context hook` is the only thing that reads
1083        // it. It never reaches a tool result, a log or argv.
1084        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            // Hooks send this epoch, so they are fenced with their proxy
1098            // rather than fencing it: a hook never bumps it.
1099            "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        // Under the same lock as every other writer of this record: a
1106        // renewal stamping the expiry checks ownership and writes inside
1107        // that lock, so this replacement cannot slip in between.
1108        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        // Labels first, before anything is committed anywhere.
1143        let mut previous = None;
1144        // Validated like the server validates them, and staged rather than
1145        // committed: a call that also switches profile must leave the
1146        // previous context *entirely* intact when the switch fails, labels
1147        // included.
1148        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            // A profile switch is a switch of credentials: explicit ones from
1173            // the environment would otherwise win and make the call a no-op.
1174            inputs.explicit_token = None;
1175            inputs.explicit_url = None;
1176            previous = self.connect_with(&inputs, false).await?;
1177        }
1178        // Only now, with the switch (if any) verified and committed.
1179        {
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    /// Bind to the conversation a request says it belongs to. First id seen
1200    /// becomes the binding when there was none; a different id later means
1201    /// the host multiplexes conversations over one process, which this proxy
1202    /// does not support and says so rather than mixing them.
1203    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            // Bound by environment or flag: the request's id is informative
1226            // only, the operator's binding wins.
1227            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    /// Adopt a conversation id discovered on the wire: derive the stable
1235    /// session and reconnect so every forwarded call, this one included,
1236    /// carries it.
1237    async fn rebind(&self, host_id: String) -> anyhow::Result<()> {
1238        // Staged, not committed: if the new session cannot connect, the
1239        // previous one must keep serving. Advertising the new identity over
1240        // the old connection is how a window ends up reporting one session
1241        // and writing as another.
1242        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    /// Forward one call to the connected context, racing the host's own
1275    /// cancellation and the context's replacement.
1276    /// Spool the references a fetch returned, fsync, then confirm them.
1277    ///
1278    /// Everything here is best effort in one direction only: a reference
1279    /// that cannot be written to disk is **not** confirmed, so the bus keeps
1280    /// offering it. The model still sees it in this turn — it is in the
1281    /// result either way — but nothing claims durability that does not
1282    /// exist.
1283    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        // Anything a previous process spooled and never confirmed goes in
1328        // the same confirmation: that is what an interrupted delivery looks
1329        // like from here.
1330        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                // The bus says which ids it committed now and which of ours
1360                // it had already: both are settled. An id in neither (a
1361                // stale epoch, somebody else's reference) stays in the
1362                // spool as the only evidence that it is still owed. The
1363                // reply the caller gets is the fetch, never this one: the
1364                // confirmation is the proxy's business.
1365                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                    // The bus has the truth; this only costs a repeated
1387                    // confirmation next time, which the bus answers with
1388                    // `already_confirmed`.
1389                    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                // A rejected bearer says "Auth required" and nothing about
1433                // what to do. This is what a revoked or rotated token looks
1434                // like from here; see `unauthorized` for what it is not.
1435                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                    // The bus wrote this for the model: same code, same
1452                    // words, same data, exactly as a direct call gets it.
1453                    data
1454                } else if let Some(r) = refusal(&e) {
1455                    // The bus answered and ran nothing: its middleware's
1456                    // words, and the caller knows the call did not happen.
1457                    tracing::warn!(error = %e, tool = %name, "the bus refused a forwarded call");
1458                    ErrorData::invalid_request(format!("{name}: {}", r.text()), None)
1459                } else {
1460                    // This proxy lost the bus. One classification, and the
1461                    // fact the caller needs to act safely: whether the call
1462                    // ran is unknown. The transport's detail goes to the log.
1463                    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    /// Record that the bus refused this window's credential, so
1491    /// `session_status` and the next `initialize` say so instead of
1492    /// claiming a healthy connection. Non-blocking: a busy lock means the
1493    /// next call reports it.
1494    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    /// The channel this window posts to when a message names none: its own
1507    /// `channel`, else the channel named after its project.
1508    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    /// Periodic presence, and the credential renewed before it expires,
1557    /// until cancelled. Presence is on a fixed cadence; the renewal is
1558    /// scheduled from the expiry the bus last stated, so an idle window is
1559    /// renewed exactly as a busy one.
1560    pub async fn keepalive(self, ct: CancellationToken) {
1561        let mut next_heartbeat = tokio::time::Instant::now() + KEEPALIVE_EVERY;
1562        // Earliest next renewal attempt, whatever the deadline says: keeps a
1563        // deadline already in the past (a bus that will not renew, a reply
1564        // without an expiry) from becoming a tight loop. Tied to the context
1565        // generation it was set for: a profile switch brings a credential of
1566        // its own, whose first renewal must not wait out the old one's
1567        // backoff.
1568        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                // A new context: go round and schedule for its credential.
1581                _ = 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                        // A refused credential is refused again a moment
1589                        // later; look again on the presence cadence, in
1590                        // case a profile switch brought a live one.
1591                        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    /// When the credential this window holds should be renewed: its expiry
1603    /// less the lead, never earlier than now, with the context generation
1604    /// the credential belongs to. `None` without a credential.
1605    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    /// Gap between renewal attempts: a quarter of the lead, so a transient
1632    /// failure gets several tries before the credential lapses.
1633    async fn renewal_retry(&self) -> Duration {
1634        Duration::from_secs((self.renewal_lead().await / 4).clamp(2, 60) as u64)
1635    }
1636
1637    /// Extend the credential this window holds, without rotating its secret
1638    /// or epoch. Raced against the connected context's cancellation and
1639    /// fenced on the generation, so a profile switch under way discards the
1640    /// answer rather than applying it to the wrong identity.
1641    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                    // A renewal never rotates; an answer that says otherwise
1674                    // is not applied to a credential it does not describe.
1675                    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    /// Persist a renewed expiry into the binding, **only if the record still
1717    /// describes this credential**: a successor's record is left alone, as
1718    /// in `mark_closed`.
1719    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        // Read, check and write under the configuration lock every writer
1726        // of this directory takes: a successor that replaces the record
1727        // between the check and the write would otherwise be overwritten
1728        // with this instance's older credential.
1729        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    /// Go quiet on the bus; the host is closing this window.
1767    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        // The credential stays on disk so a restart of this conversation can
1785        // resume the window; the record is only stamped as closed, and only
1786        // if it still describes this instance.
1787        self.mark_closed().await;
1788    }
1789
1790    /// Mark this window closed, **only if the record still describes this
1791    /// instance**.
1792    ///
1793    /// Two things were wrong with clearing it unconditionally. A successor
1794    /// proxy for the same conversation may already have replaced the record,
1795    /// and wiping it would cut the live window's hooks off from their own
1796    /// credential. And the credential itself has to stay: a restart of this
1797    /// conversation resumes with it, which is the only way back in — the bus
1798    /// refuses to hand a live session to whoever holds the agent token, and
1799    /// rightly so. It is a 0600 file scoped to one window and it expires on
1800    /// its own.
1801    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            // Ownership check: a record whose session or epoch has moved on
1821            // belongs to the instance that replaced us.
1822            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
1849/// Run a binding read-modify-write under the configuration lock, on a
1850/// blocking thread: the lock is a file lock shared with every other writer
1851/// of the directory (other proxies of this conversation included), and a
1852/// wait for it must not stall the runtime that serves the host.
1853async 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
1862/// Close a remote connection we may share with an in-flight call. Sole
1863/// owner: cancel cleanly. Otherwise the last holder drops it, and dropping a
1864/// `RunningService` closes it as well.
1865async fn close_remote(remote: Arc<Remote>) {
1866    if let Ok(owned) = Arc::try_unwrap(remote) {
1867        let _ = owned.cancel().await;
1868    }
1869}
1870
1871/// What an identity still holds on the bus, read before it is set aside.
1872async 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        // Agent AND session: locks are session-scoped, so a sibling window's
1894        // lock is not this one's to report as left behind.
1895        .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        // `get_info` is synchronous; the state lock is uncontended at
1917        // initialize time, and a contended read simply yields the
1918        // disconnected wording until the next call.
1919        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        // The transport lifts `_meta` out of the params into the context.
1945        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                    // The model must read this one, so it is a tool error
1966                    // rather than a protocol error.
1967                    Err(e) => Ok(tool_error(format!("{e:#}")).into()),
1968                }
1969            }
1970            // Delivery has to mean something. The bus hands over references
1971            // and records nothing; this writes them to disk, fsyncs, and
1972            // only then tells the bus they are held. A crash in between
1973            // costs a redelivery, which is idempotent by design.
1974            "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            // A message with no channel and no recipient goes to this
1979            // window's channel. The state was decorative until now: the
1980            // instructions and session_status promised a default the bus
1981            // never saw.
1982            "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
1998/// Run the proxy over stdio until the host closes the pipe.
1999pub 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        // The bus quoting a session label, a lease or an id that contains
2069        // "401" is talking to the model, not rejecting its credential.
2070        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        // rmcp 3.x, unknown tool.
2086        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        // A JSON-RPC layer without the method at all.
2090        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        // The bus's conflict on a live label quotes the label, and a label
2101        // can hash to `s-32601…`. Same code as "tool not found".
2102        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        // The bus's own not-found errors share the code too.
2111        let e = ServiceError::McpError(ErrorData::invalid_params("not found: message 32601", None));
2112        assert!(!no_such_tool(&e));
2113        // Exactly rmcp's wording, not a prefix of it: a tool that exists
2114        // could open its refusal with the same three words.
2115        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        // The bus's middleware body, at any status: its words, nothing ran.
2139        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        // A bare 4xx from something in front of the bus still ran nothing.
2150        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        // A bare 5xx may be a gateway that gave up on a call that ran.
2159        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}