diff --git a/crates/busbar-core-connector/src/program.rs b/crates/busbar-core-connector/src/program.rs index d396931756..f76e872cf2 100644 --- a/crates/busbar-core-connector/src/program.rs +++ b/crates/busbar-core-connector/src/program.rs @@ -13,8 +13,10 @@ //! spawns from `1`. Two leases reading one generation reach one running program; a plugin that //! must greet each program once (a handshake) greets each generation once. //! * Every frame the program writes is handed to EVERY lease of its generation that is open when -//! it is read, in order; a lease reads only what arrived after it opened. Whichever lease reads -//! drives the program's pipes for all, and a frame read wakes every lease waiting on one. +//! it is read, in order; a lease reads only what arrived after it opened, and only WHOLE frames: +//! one opened while a frame is part way through skips that frame's rest (another lease's +//! message, never the start of its own). Whichever lease reads drives the program's pipes for +//! all, and a frame read wakes every lease waiting on one. //! * A write is ONE WHOLE MESSAGE ([`Connection::write_whole`]): leases sharing the program never //! interleave part of one message with another's. //! * Closing a lease leaves the program running. A program that ends (its output closed, a failed @@ -136,6 +138,8 @@ struct Inbox { end_read: bool, /// The message its open carried, while the program's queue had no room for it. unsent: Option>, + /// It opened part way through a frame: that frame's rest is skipped. + mid_frame: bool, } /// The program as it runs. @@ -147,6 +151,8 @@ struct Live { struct State { live: Option, generation: u64, + /// The last piece handed to the leases did not end its frame. + mid_frame: bool, retired: bool, next_lease: u64, leases: HashMap, @@ -246,6 +252,7 @@ impl Member { state: Mutex::new(State { live: None, generation: 0, + mid_frame: false, retired: false, next_lease: 1, leases: HashMap::new(), @@ -305,6 +312,7 @@ impl Member { st.generation += 1; let generation = st.generation; st.live = Some(Live { conn, generation }); + st.mid_frame = false; } Err(f) => { // A spawn that fails repeats on every attempt: it is counted like any end. @@ -314,6 +322,7 @@ impl Member { } } let generation = st.generation; + let mid_frame = st.mid_frame; let lease = st.next_lease; st.next_lease += 1; let mut head = Vec::new(); @@ -330,6 +339,7 @@ impl Member { ended: None, end_read: false, unsent: (!first.is_empty()).then(|| first.to_vec()), + mid_frame, }, ); let waker = self.fan_waker(); @@ -404,12 +414,18 @@ impl Member { continue; } for inbox in st.leases.values_mut() { - if inbox.generation == generation && inbox.ended.is_none() { - inbox - .pieces - .push_back((got.bytes.clone(), got.end_of_frame)); + if inbox.generation != generation || inbox.ended.is_some() { + continue; } + if inbox.mid_frame { + inbox.mid_frame = !got.end_of_frame; + continue; + } + inbox + .pieces + .push_back((got.bytes.clone(), got.end_of_frame)); } + st.mid_frame = !got.end_of_frame; self.fan.wake_all(); } Poll::Ready(Ok(None)) => st.ended(generation, None, &self.fan), diff --git a/crates/busbar-core-connector/src/tests/program_tests.rs b/crates/busbar-core-connector/src/tests/program_tests.rs index b41330ef06..56bce3da5b 100644 --- a/crates/busbar-core-connector/src/tests/program_tests.rs +++ b/crates/busbar-core-connector/src/tests/program_tests.rs @@ -16,15 +16,20 @@ use busbar_contract::conn::{ }; use crate::registry::{Entry, Transports}; -use crate::support::{worker, TestDoor}; +use crate::support::{worker, Knobs, TestDoor}; use crate::Connector; const OWNER: InstanceId = InstanceId(1); const NEED: NeedId = NeedId(1); fn connector() -> Connector { + connector_over(TestDoor::identity("bytes")) +} + +/// A connector whose `bytes` entry is `door`. +fn connector_over(door: TestDoor) -> Connector { let view = Transports::new(vec![Entry { - door: Arc::new(TestDoor::identity("bytes")), + door: Arc::new(door), alpn: Vec::new(), }]) .unwrap(); @@ -234,6 +239,52 @@ fn a_frame_reaches_every_open_lease_and_an_opens_body_is_its_first_message() { }); } +/// RED (finding 13): a lease opened while the program is part way through one frame does not read +/// that frame's rest as though it began there (on a program its leases share, another exchange's +/// answer taken as its own): its first body bytes begin a frame. The lease already reading the frame reads +/// it whole. +#[test] +fn a_lease_opened_mid_frame_reads_from_the_next_frame() { + worker().block_on(async { + let c = connector_over(TestDoor::new( + "bytes", + &["bytes"], + &[], + Knobs { + piece: Some(16), + ..Knobs::default() + }, + )); + let long = "A".repeat(100); + let script = format!("read x; echo {long}; read y; echo second; cat >/dev/null"); + declare(&c, &[("one", sh(&script, &[]))]).unwrap(); + let a = open(&c, "one", b"go\n").unwrap(); + assert_eq!(generation(&c, a).await, 1); + let mut buf = [0_u8; 64]; + let first = read(&c, a, &mut buf) + .await + .expect("the long frame's first piece"); + assert_eq!(first.kind, PieceKind::Body); + assert!(!first.end, "the long frame arrives in pieces"); + let mut ha = buf[..first.len].to_vec(); + let b = open(&c, "one", b"next\n").unwrap(); + assert_eq!(generation(&c, b).await, 1); + let mut hb = Vec::new(); + assert_eq!( + line(&c, b, &mut hb).await.as_deref(), + Some("second"), + "the lease opened mid-frame took the rest of a frame it never saw begin" + ); + assert_eq!( + line(&c, a, &mut ha).await, + Some(long), + "a reads its frame whole" + ); + c.close(OWNER, a).unwrap(); + c.close(OWNER, b).unwrap(); + }); +} + /// RED: a program that ends ends every lease of its generation; the next open spawns it anew — the /// next generation, another process — once the restart backoff has passed. #[test] diff --git a/crates/busbar-core-connector/src/tests/support.rs b/crates/busbar-core-connector/src/tests/support.rs index 86993fe47e..a9a94c1e3f 100644 --- a/crates/busbar-core-connector/src/tests/support.rs +++ b/crates/busbar-core-connector/src/tests/support.rs @@ -36,6 +36,9 @@ pub struct Knobs { pub text: bool, /// `locate` answers this protocol offer (ProtocolNameList bytes). pub offer: Option<&'static [u8]>, + /// Every frame piece the framing yields holds at most this many bytes, so one frame arrives + /// in several pieces (the last ends it), as a line framer cuts a line its sink cannot hold. + pub piece: Option, } #[derive(Default)] @@ -46,6 +49,7 @@ struct State { heard: bool, deadline_ns: u64, text: bool, + piece: Option, } /// One `begin` crossing's opening head fields, name and value. @@ -148,7 +152,11 @@ fn answer(st: &mut State, sink: &FramerSink, o: &mut FramerOut, silence: Option< put(sink.wire, &mut st.outbound, w); y.wire_len = w as u64; if !st.inbound.is_empty() && sink.pieces_cap > 0 { - let n = st.inbound.len().min(sink.frame_cap); + let n = st + .inbound + .len() + .min(sink.frame_cap) + .min(st.piece.unwrap_or(usize::MAX)); put(sink.frame, &mut st.inbound, n); let flags = if st.inbound.is_empty() { PIECE_END_OF_FRAME @@ -267,6 +275,7 @@ impl FramerDoor for TestDoor { let token = self.next.fetch_add(1, Ordering::Relaxed); let mut st = State { text: self.knobs.text, + piece: self.knobs.piece, ..State::default() }; answer(&mut st, &i.sink, o, silence); diff --git a/crates/busbar-plane-mcp/Cargo.toml b/crates/busbar-plane-mcp/Cargo.toml index ba65e5ec3f..37503f2e56 100644 --- a/crates/busbar-plane-mcp/Cargo.toml +++ b/crates/busbar-plane-mcp/Cargo.toml @@ -16,9 +16,9 @@ # # THE DEPENDENCY EDGE, AND THE THING THAT CHANGED. There is no longer a codec crate to name. The # MCP wire dialect — the mount path, the revision, the method names, the `_meta` keys, the error -# code table, the notification pair, the durable record vocabulary, the content sanitizer and the -# structured-output check — folded IN here, because definition 16–20 puts everything one protocol -# needs and no other protocol may name in the protocol's own plane crate (#39: no `busbar-*-codec`). +# code table, the notification pair, the durable record vocabulary and the content sanitizer — +# folded IN here, because definition 16–20 puts everything one protocol needs and no other protocol +# may name in the protocol's own plane crate (#39: no `busbar-*-codec`). # The engine's registry row and its two operation cells did NOT come with it: they name the kernel's # handler matrix, which is kernel-side machinery, and they now read this crate's vocabulary rather # than restating it. The adapter still names no runtime, opens nothing, holds no connection and diff --git a/crates/busbar-plane-mcp/src/adapt.rs b/crates/busbar-plane-mcp/src/adapt.rs index 0a097df3c1..48e184b6cc 100644 --- a/crates/busbar-plane-mcp/src/adapt.rs +++ b/crates/busbar-plane-mcp/src/adapt.rs @@ -13,7 +13,9 @@ //! What a session client is allowed to reach is decided here too ([`session_method`]): the methods //! the session revisions define. The stateless revision's own methods (`server/discover`, //! `subscriptions/listen`, the tasks extension) are never offered to a session client, whether it -//! asks for them by name or reads the capabilities `initialize` answered with. +//! asks for them by name or reads the capabilities `initialize` answered with. The session's own +//! verbs (`resources/subscribe`, `resources/unsubscribe`, `logging/setLevel`) stay for the old +//! revisions (THE DESIGN section 2, the mcp bullet) and are answered from the session's state. //! //! The `2024-11-05` event-stream framing lives here as values ([`frame`], [`endpoint_event`]); //! writing them to a connection is the caller's. @@ -96,6 +98,8 @@ pub enum SessionMethod { Dispatch, /// `ping`, answered here with an empty result. Ping, + /// A verb of the session's own state: answered from it, never dispatched. + Session, /// A notification or a response from the client: accepted (202) and not answered. Accept, /// Not a method of the session revisions: `-32601`. @@ -114,6 +118,13 @@ const SESSION_DISPATCHED: &[&str] = &[ "completion/complete", ]; +/// The verbs a session's own state answers (subscribe stays for the old revisions). +const SESSION_STATE_VERBS: &[&str] = &[ + "resources/subscribe", + "resources/unsubscribe", + "logging/setLevel", +]; + /// Classifies one session message. `method` is `None` for a client's response to a request. #[must_use] pub fn session_method(method: Option<&str>, has_id: bool) -> SessionMethod { @@ -122,6 +133,7 @@ pub fn session_method(method: Option<&str>, has_id: bool) -> SessionMethod { Some(_) if !has_id => SessionMethod::Accept, Some(METHOD_PING) => SessionMethod::Ping, Some(m) if SESSION_DISPATCHED.contains(&m) => SessionMethod::Dispatch, + Some(m) if SESSION_STATE_VERBS.contains(&m) => SessionMethod::Session, Some(_) => SessionMethod::NotFound, } } @@ -232,8 +244,10 @@ pub fn lower_result(result: &mut Value) -> Result<(), NotExpressible> { /// Only the capability groups the session revisions define are carried, and only when discovery /// declared them for this caller: `tools`, `prompts`, `resources` and (from `2025-06-18`) /// `completions`. `listChanged` is `list_changed` for all three lists, the caller's statement of -/// whether it delivers those notifications on the session's stream. Resource subscription is never -/// declared: the session revisions reach it by methods this plane does not offer a session. +/// whether it delivers those notifications on the session's stream. Resource subscription and +/// `logging` are declared on every session revision: subscribe stays for the old revisions, and the +/// session's own state answers both (an upstream's `notifications/resources/updated` reaches the +/// session's stream, and its `notifications/message` past the session's floor). #[must_use] pub fn initialize_result(discovery: &Value, revision: Revision, list_changed: bool) -> Value { let declared = discovery.get("capabilities"); @@ -247,6 +261,12 @@ pub fn initialize_result(discovery: &Value, revision: Revision, list_changed: bo ); } } + if let Some(resources) = caps.get_mut("resources").and_then(Value::as_object_mut) { + resources.insert("subscribe".into(), Value::Bool(true)); + } + if has("logging") { + caps.insert("logging".into(), Value::Object(Map::new())); + } if has("completions") && revision != Revision::R2024_11_05 { caps.insert("completions".into(), Value::Object(Map::new())); } diff --git a/crates/busbar-plane-mcp/src/argguard.rs b/crates/busbar-plane-mcp/src/argguard.rs index 4dbf891559..2b28ba21d4 100644 --- a/crates/busbar-plane-mcp/src/argguard.rs +++ b/crates/busbar-plane-mcp/src/argguard.rs @@ -2,7 +2,9 @@ // Copyright (C) 2026 Busbar Inc and contributors //! THE ARGUMENT HALF OF THE DISPATCH SSRF GUARD: a schema-aware walk of the nested per-request -//! `tools/call` arguments, judging every URL and every host they carry. +//! `tools/call` arguments, finding every URL and every host they carry and asking the host's ONE +//! destination judge (`dest.judge`, BUSBAR-1.6.0.md Appendix C B.3 item 11) about each. The plane +//! reads the value; the deployment's egress rules decide it. //! //! ## The gap this closes, stated as the live consequence //! @@ -48,6 +50,10 @@ //! //! ## What this module deliberately does NOT do //! +//! - **No host rules of its own.** The scheme allowlist and the host reader are the walk's; every +//! verdict on a host is `dest.judge`'s, under the deployment's egress rules (its allow-list, the +//! metadata hosts, the private-address setting), so an argument and the connector's dial are +//! judged by the same guard. //! - **No DNS.** The transport guard resolves and PINS because busbar is the party that connects. //! For an argument busbar is NOT the connecting party: the upstream resolves the name itself, //! later, from its own resolver. A lookup here would therefore be advisory at best — trivially @@ -65,19 +71,16 @@ //! busbar's own upstream credential rides that request. No busbar credential rides a tool //! argument, so the rule does not transfer and is not applied. -use busbar_contract::net::{ - dns_name_is_internal, extract_normalized_host, host_ip, host_is_cloud_metadata, ip_is_internal, - is_alternate_ipv4_encoding, scheme_is, +use busbar_contract::abi::host::service::{ + DEST_ALLOWED, DEST_INTERNAL, DEST_METADATA, DEST_NO_HOST, DEST_OBFUSCATED, }; +use busbar_contract::net::{extract_normalized_host, scheme_is}; use serde_json::Value; -/// The registration's addressing policy the walk judges under: `allow_private` (the server's own -/// opt-in to internal hosts) widens the internal-address rule and nothing else. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -pub struct SsrfPolicy { - /// Internal (private, loopback, link-local) hosts are admitted; cloud metadata never is. - pub allow_private: bool, -} +/// THE HOST'S JUDGE, as the walk asks it: one host an argument names (an IPv6 literal bracketed, +/// as `dest.judge` reads a `host[:port]`), answered with the host's `DEST_*` verdict, or `None` +/// when the host gave none (the value is then refused: fail closed). +pub type Judge<'a> = dyn FnMut(&str) -> Option + 'a; /// How deep into the ARGUMENT value the walk goes before refusing. Arguments arrive as already /// parsed JSON, so this is a floor under stack safety rather than the primary bound; exceeding it @@ -125,15 +128,18 @@ pub enum ArgWhy { Scheme(String), /// The value has no host component to judge. NoHost(String), - /// The host is a cloud-metadata endpoint — by address, by an alternate encoding of one, or by - /// one of the metadata DNS names. Refused unconditionally, `allow_private` or not. + /// `dest.judge` answered `DEST_METADATA`: a cloud-metadata endpoint, by name, address or the + /// deployment's own list. Refused unconditionally, `allow_private` or not. CloudMetadata(String), - /// The host is an alternate IPv4 encoding (`2130706433`, `0x7f000001`, `127.1`) that a - /// resolver expands but a canonical IP-literal check misses. + /// `dest.judge` answered `DEST_OBFUSCATED`: an alternate IPv4 encoding (`2130706433`, + /// `0x7f000001`, `127.1`) that a resolver expands but a canonical IP-literal check misses. ObfuscatedHost(String), - /// The host is internal — loopback, RFC-1918, link-local, CGNAT, unique-local, the `localhost` - /// family, or an IPv4-mapped IPv6 spelling of any of them. + /// `dest.judge` answered `DEST_INTERNAL`: an internal host the egress rules do not admit here. InternalHost(String), + /// `dest.judge` refused the host with another `DEST_*` verdict. + Refused(String, u64), + /// The host gave no verdict (it serves no `dest.judge`, or the judgement failed). + Unjudged(String), /// The argument nested deeper than the walk will follow. DepthExceeded, } @@ -161,7 +167,17 @@ impl std::fmt::Display for ArgWhy { ArgWhy::InternalHost(h) => write!( f, "`{h}` is an internal address; set this server's `allow_private` if reaching \ - internal hosts through its tools is deliberate" + internal hosts through its tools is deliberate (the deployment's destination \ + rules still apply)" + ), + ArgWhy::Refused(h, verdict) => write!( + f, + "`{h}` is refused by the deployment's destination rules (verdict {verdict})" + ), + ArgWhy::Unjudged(h) => write!( + f, + "`{h}` could not be checked against the deployment's destination rules, so it is \ + refused" ), ArgWhy::DepthExceeded => write!( f, @@ -239,10 +255,15 @@ impl ArgScan { /// /// Fails on the first refusal rather than collecting all of them, because the call is refused /// either way and the operator's question is "why was this refused", which one named field answers. -pub fn guard(schema: &Value, arguments: &Value, policy: SsrfPolicy) -> Result { +/// Every host found is asked of `judge` ([`Judge`]). +pub fn guard( + schema: &Value, + arguments: &Value, + mut judge: impl FnMut(&str) -> Option, +) -> Result { let mut scan = ArgScan::default(); let mut pointer = String::new(); - walk(&[schema], arguments, &mut pointer, 0, policy, &mut scan)?; + walk(&[schema], arguments, &mut pointer, 0, &mut judge, &mut scan)?; Ok(scan) } @@ -254,7 +275,7 @@ fn walk( value: &Value, pointer: &mut String, depth: usize, - policy: SsrfPolicy, + judge: &mut Judge<'_>, scan: &mut ArgScan, ) -> Result<(), ArgRefusal> { if depth > MAX_VALUE_DEPTH { @@ -272,7 +293,7 @@ fn walk( pointer.push_str(&escape_token(k)); let child = child_for_key(schemas, k); let child_refs: Vec<&Value> = child; - walk(&child_refs, v, pointer, depth + 1, policy, scan)?; + walk(&child_refs, v, pointer, depth + 1, judge, scan)?; pointer.truncate(mark); } Ok(()) @@ -284,12 +305,12 @@ fn walk( pointer.push_str(&i.to_string()); let child = child_for_index(schemas, i); let child_refs: Vec<&Value> = child; - walk(&child_refs, v, pointer, depth + 1, policy, scan)?; + walk(&child_refs, v, pointer, depth + 1, judge, scan)?; pointer.truncate(mark); } Ok(()) } - Value::String(s) => judge_string(schemas, s, pointer, policy, scan), + Value::String(s) => judge_string(schemas, s, pointer, judge, scan), _ => Ok(()), } } @@ -299,14 +320,14 @@ fn judge_string( schemas: &[&Value], s: &str, pointer: &str, - policy: SsrfPolicy, + judge: &mut Judge<'_>, scan: &mut ArgScan, ) -> Result<(), ArgRefusal> { scan.strings_seen += 1; let trimmed = s.trim(); if let Some((format, kind)) = first_urlish(schemas) { scan.declared_judged += 1; - return judge_argument(kind, trimmed, policy).map_err(|why| ArgRefusal { + return judge_argument(kind, trimmed, judge).map_err(|why| ArgRefusal { pointer: pointer.to_string(), declared_format: Some(format), why, @@ -314,7 +335,7 @@ fn judge_string( } if starts_with_http(trimmed) { scan.undeclared_judged += 1; - return judge_argument(Urlish::AbsoluteUrl, trimmed, policy).map_err(|why| ArgRefusal { + return judge_argument(Urlish::AbsoluteUrl, trimmed, judge).map_err(|why| ArgRefusal { pointer: pointer.to_string(), declared_format: None, why, @@ -331,9 +352,9 @@ fn starts_with_http(v: &str) -> bool { lower.starts_with("http://") || lower.starts_with("https://") } -fn judge_argument(kind: Urlish, value: &str, policy: SsrfPolicy) -> Result<(), ArgWhy> { +fn judge_argument(kind: Urlish, value: &str, judge: &mut Judge<'_>) -> Result<(), ArgWhy> { match kind { - Urlish::AbsoluteUrl => judge_absolute(value, policy), + Urlish::AbsoluteUrl => judge_absolute(value, judge), Urlish::Reference => { // A scheme-relative reference (`//169.254.169.254/x`) inherits the base scheme and // names a host, so it is judged as one. A path-relative reference names no host and @@ -341,10 +362,10 @@ fn judge_argument(kind: Urlish, value: &str, policy: SsrfPolicy) -> Result<(), A if let Some(rest) = value.strip_prefix("//") { let authority = rest.split(['/', '?', '#']).next().unwrap_or(rest); let host = authority.rsplit('@').next().unwrap_or(authority); - return judge_host(strip_port(host), policy); + return judge_host(strip_port(host), judge); } if value.contains("://") { - return judge_absolute(value, policy); + return judge_absolute(value, judge); } Ok(()) } @@ -352,22 +373,21 @@ fn judge_argument(kind: Urlish, value: &str, policy: SsrfPolicy) -> Result<(), A // A `hostname`/`ipv4`/`ipv6`-declared field is supposed to carry a bare host, but // nothing stops a caller writing a full absolute URL into it instead. Left to // `judge_host` as-is, a value like `https://169.254.169.254/x` is not a syntactically - // valid host, so the metadata/private/obfuscation checks below (which read it as an - // opaque host string) miss the address entirely — the scheme and path are noise to - // them, not a signal to strip. Any embedded `://` means this is actually a URL wearing + // valid host, so the judge (reading it as an opaque host string) would miss the + // address entirely — the scheme and path are noise to it, not a signal to strip. Any embedded `://` means this is actually a URL wearing // a `hostname` declaration, so it is judged as one (scheme allowlist + host judgement // on the REAL host), the same authority the `Reference` arm above already gives a // scheme-relative value. if value.contains("://") { - return judge_absolute(value, policy); + return judge_absolute(value, judge); } - judge_host(value, policy) + judge_host(value, judge) } } } /// An absolute URL: the scheme allowlist, then the host. -fn judge_absolute(url: &str, policy: SsrfPolicy) -> Result<(), ArgWhy> { +fn judge_absolute(url: &str, judge: &mut Judge<'_>) -> Result<(), ArgWhy> { if !scheme_is(url, "http") && !scheme_is(url, "https") { return Err(ArgWhy::Scheme(url.to_string())); } @@ -376,33 +396,28 @@ fn judge_absolute(url: &str, policy: SsrfPolicy) -> Result<(), ArgWhy> { // percent-decode that a connecting stack applies. Re-deriving any of that here would be a // second copy of a guard that already exists. let host = extract_normalized_host(url).ok_or_else(|| ArgWhy::NoHost(url.to_string()))?; - judge_host(&host, policy) + judge_host(&host, judge) } -/// THE HOST JUDGEMENT, composed from the shared primitives rather than hand-rolled. -/// -/// Order is load-bearing. Metadata first and unconditionally, so an `allow_private` server cannot -/// reach the one endpoint whose whole value to an attacker is that it hands out credentials. -/// Obfuscated encodings next and also unconditionally, matching `busbar_kernel::net_guard::judge_host_name` -/// as `super::ssrf::precheck` does: a value -/// spelled so the check cannot read it is refused rather than guessed at. Internal addressing last, -/// because that is the one an operator can legitimately opt into. -fn judge_host(raw: &str, policy: SsrfPolicy) -> Result<(), ArgWhy> { +/// THE HOST, ASKED OF THE HOST'S ONE JUDGE (`dest.judge`, BUSBAR-1.6.0.md Appendix C B.3 item 11): +/// the plane reads the host and holds no rule about it. `judge`'s verdict is rendered as the +/// refusal; a host that gave none refuses the value. +fn judge_host(raw: &str, judge: &mut Judge<'_>) -> Result<(), ArgWhy> { let host = normalize_host(raw).ok_or_else(|| ArgWhy::NoHost(raw.to_string()))?; - if host_is_cloud_metadata(&host) { - return Err(ArgWhy::CloudMetadata(host)); - } - if is_alternate_ipv4_encoding(&host) { - return Err(ArgWhy::ObfuscatedHost(host)); - } - // The destination guard's predicate, not the 1.5.5 plaintext one: it also covers benchmarking, - // IETF-protocol, "this network", broadcast, multicast and documentation ranges. - if !policy.allow_private - && (dns_name_is_internal(&host) || host_ip(&host).is_some_and(|ip| ip_is_internal(&ip))) - { - return Err(ArgWhy::InternalHost(host)); + let dest = if host.contains(':') { + format!("[{host}]") + } else { + host.clone() + }; + match judge(&dest) { + Some(DEST_ALLOWED) => Ok(()), + Some(DEST_METADATA) => Err(ArgWhy::CloudMetadata(host)), + Some(DEST_OBFUSCATED) => Err(ArgWhy::ObfuscatedHost(host)), + Some(DEST_INTERNAL) => Err(ArgWhy::InternalHost(host)), + Some(DEST_NO_HOST) => Err(ArgWhy::NoHost(host)), + Some(verdict) => Err(ArgWhy::Refused(host, verdict)), + None => Err(ArgWhy::Unjudged(host)), } - Ok(()) } /// Normalize a bare host the same way a URL's host component is normalized, by routing it through @@ -413,7 +428,7 @@ fn normalize_host(raw: &str) -> Option { } fn probe_url(host: &str) -> String { - if host_ip(host).is_some_and(|ip| ip.is_ipv6()) { + if busbar_contract::net::host_ip(host).is_some_and(|ip| ip.is_ipv6()) { format!("https://[{host}]/") } else { format!("https://{host}/") diff --git a/crates/busbar-plane-mcp/src/call.rs b/crates/busbar-plane-mcp/src/call.rs index a042e1734b..4894de4e2b 100644 --- a/crates/busbar-plane-mcp/src/call.rs +++ b/crates/busbar-plane-mcp/src/call.rs @@ -14,8 +14,8 @@ //! the kernel's auth binding adds it when it sends. //! 3. [`settle`]: the far end's answer read as the engine reads it — the last event of a streamed //! answer, the JSON-RPC correlation, an upstream's ask judged against the operator's grants and -//! relayed to the caller (busbar answers none itself, Law 11), the published output schema, and -//! the content normalised — into the caller's answer and its call-log line. +//! relayed to the caller (busbar answers none itself, Law 11), and a result relayed as the +//! upstream sent it — into the caller's answer and its call-log line. //! //! What is not here is the kernel's: the trust lifecycle (pin, sightings, demotion), the hook gate //! and rewrite, the outbound credential, the breaker and the pool walk, the budget and the meter. @@ -415,7 +415,6 @@ pub fn admit_call( header, admit, &mut |_| Trust::Verdict(DISTRUST_NONE), - &|_| false, ask, ) } @@ -520,12 +519,7 @@ fn trust_refusal( /// kernel's Approve said of it ([`Trust`], `trust.serves` over the tool's registration and trust /// key after the door's re-fetch sighted it), rendered by [`trust_refusal`]. Asked after the name /// and the grants, before the header mirror and the ask: a refused dispatch never reaches the wire. -/// -/// THE ARGUMENT GUARD ([`crate::argguard`]) judges the arguments that go out (the caller's, with -/// the operator-bounded answers merged) against the tool's approved input schema, under the -/// registration's `allow_private` (`allow_private` answers it for the tool): a URL or host the -/// call carries to an internal or metadata address is refused before the call is sent. -#[allow(clippy::too_many_arguments)] // `admit`'s five, the trust gate and the addressing policy +/// The arguments it admits are judged next, by [`judge_arguments`]. pub fn admit_trusted( catalogue: &Catalogue, id: &Value, @@ -533,7 +527,6 @@ pub fn admit_trusted( header: &impl Fn(&str) -> Option, admit: &impl Fn(&str, &str) -> bool, trust: &mut dyn FnMut(&ToolEntry) -> Trust, - allow_private: &dyn Fn(&ToolEntry) -> bool, ask: &mut dyn FnMut(&ToolEntry, &Value) -> crate::ask::AskDecision, ) -> Admission { let Some(name) = params.and_then(|p| p.get("name")).and_then(Value::as_str) else { @@ -701,28 +694,6 @@ pub fn admit_trusted( ); } } - // THE ARGUMENT GUARD, on the arguments that go out. A tool that declared no schema is walked - // against `{"type": "object"}`: the walk is value-driven, so declaring no schema narrows what it - // calls "declared" and nothing else. - let schema = entry - .input_schema - .clone() - .unwrap_or_else(|| json!({ "type": "object" })); - let policy = crate::argguard::SsrfPolicy { - allow_private: allow_private(entry), - }; - if let Err(refused) = crate::argguard::guard(&schema, &arguments, policy) { - return Admission::Refused( - error( - STATUS_FORBIDDEN, - id, - crate::codec::CODE_REFUSED, - refused.to_string(), - Some(json!({ "reason": REASON_TOOL_ARGUMENT_REFUSED })), - ), - line(REASON_TOOL_ARGUMENT_REFUSED), - ); - } Admission::Go(AdmittedCall { entry: entry.clone(), arguments, @@ -737,6 +708,44 @@ pub fn admit_trusted( }) } +/// THE ARGUMENT GUARD ([`crate::argguard`]) on an admitted call: the arguments that go out (the +/// caller's, with the operator-bounded answers merged) are walked against the tool's approved input +/// schema, and every URL or host they carry is asked of the host's ONE destination judge: `judge` +/// answers `dest.judge`'s verdict for the tool and the host (BUSBAR-1.6.0.md Appendix C B.3 item +/// 11), `None` when it gave none. A refused one refuses the call before it is sent. A tool that +/// declared no schema is walked against `{"type": "object"}`: the walk is value-driven, so declaring +/// no schema narrows what it calls "declared" and nothing else. Any other admission is unchanged. +pub fn judge_arguments( + admission: Admission, + judge: &mut dyn FnMut(&ToolEntry, &str) -> Option, +) -> Admission { + let Admission::Go(admitted) = admission else { + return admission; + }; + let entry = &admitted.entry; + let schema = entry + .input_schema + .clone() + .unwrap_or_else(|| json!({ "type": "object" })); + match crate::argguard::guard(&schema, &admitted.arguments, |dest| judge(entry, dest)) { + Ok(_) => Admission::Go(admitted), + Err(refused) => Admission::Refused( + error( + STATUS_FORBIDDEN, + &admitted.id, + crate::codec::CODE_REFUSED, + refused.to_string(), + Some(json!({ "reason": REASON_TOOL_ARGUMENT_REFUSED })), + ), + Some(CallLine::resolved( + entry, + vocab::OUTCOME_REFUSED, + REASON_TOOL_ARGUMENT_REFUSED, + )), + ), + } +} + /// What busbar declares to `def`'s server for `admitted`'s call: each ask kind the operator lets the /// server put to callers AND the caller declared it can answer (the ask is relayed, Law 11). fn advertised(admitted: &AdmittedCall, def: &McpServerDefCfg) -> AdvertisedCaps { @@ -981,7 +990,8 @@ pub fn upstream_ask_field(value: &Value) -> Option<&'static str> { } /// The tool-execution-error RESULT for an upstream leg that failed: `isError: true` with the -/// failure, busbar-attributed and naming the server, in a normalised text block. +/// failure, busbar-attributed and naming the server, in one text block. An upstream's own words in +/// `reason` (its JSON-RPC error message) are carried unchanged (Law 11). #[must_use] pub fn upstream_failure_result(server: &str, reason: &str) -> Value { json!({ @@ -989,9 +999,7 @@ pub fn upstream_failure_result(server: &str, reason: &str) -> Value { "isError": true, "content": [{ "type": "text", - "text": crate::sanitize::normalise(&format!( - "The MCP server `{server}` did not complete this tool call: {reason}" - )), + "text": format!("The MCP server `{server}` did not complete this tool call: {reason}"), }], }) } @@ -1175,7 +1183,7 @@ pub fn settle_call_as( ), }; match wire::parse_response(&body, sent_id) { - RpcOutcome::Result(value) => completed(admitted, value), + RpcOutcome::Result(value) => completed(admitted, &value, &body), failure @ (RpcOutcome::Error { .. } | RpcOutcome::Malformed(_) | RpcOutcome::Uncorrelated(_)) => { @@ -1330,12 +1338,12 @@ pub fn ask_refused(admitted: &AdmittedCall, refusal: &AskRefusal) -> Settled { } } -/// A finished result: the terminal ask check, the published output schema, then the normalised -/// content. -fn completed(admitted: &AdmittedCall, value: Value) -> Settled { +/// A finished result: the terminal ask check, then the upstream's result relayed as it came +/// (Law 11). `value` is the result parsed, `body` the answer it was parsed from. +fn completed(admitted: &AdmittedCall, value: &Value, body: &[u8]) -> Settled { let entry = &admitted.entry; let id = &admitted.id; - if let Some(field) = upstream_ask_field(&value) { + if let Some(field) = upstream_ask_field(value) { return Settled::Answer { status: STATUS_FORBIDDEN, body: catalogue_refusal( @@ -1354,38 +1362,42 @@ fn completed(admitted: &AdmittedCall, value: Value) -> Settled { line: CallLine::resolved(entry, vocab::OUTCOME_REFUSED, REASON_ASK_NOT_PROXIED), }; } - if let (Some(schema), Some(structured)) = (&entry.output_schema, value.get("structuredContent")) - { - if let Err(why) = crate::outputschema::check(structured, schema) { - return Settled::Answer { - status: STATUS_OK, - body: result( - id, - upstream_failure_result( - &entry.server, - &format!( - "it returned structured output that violates the `outputSchema` this \ - tool is published with ({why}). The structured result was NOT served: \ - a result that does not conform to the schema busbar published for it \ - would make busbar's own answer unverifiable." - ), - ), - ), - line: CallLine::resolved( - entry, - vocab::OUTCOME_DISPATCHED, - vocab::REASON_UPSTREAM_FAILED, - ), - }; - } - } Settled::Answer { status: STATUS_OK, - body: result(id, crate::sanitize::normalise_json(&value)), + body: relayed(id, value, body), line: CallLine::resolved(entry, vocab::OUTCOME_DISPATCHED, ""), } } +/// The caller's answer carrying the upstream's `result` as the upstream wrote it: its own bytes, +/// under the caller's id. The one addition is the dialect's `resultType: complete` on a result +/// object that carries no `resultType` at all; a `resultType` the upstream sent is its own. +fn relayed(id: &Value, value: &Value, body: &[u8]) -> Vec { + let busbar_contract::spans::Resolved::Found(span) = + busbar_contract::spans::resolve_pointer(body, "/result") + else { + return result(id, value.clone()); + }; + let Ok(sent) = std::str::from_utf8(span.of(body)) else { + return result(id, value.clone()); + }; + let mut out = format!("{{\"id\":{id},\"jsonrpc\":\"2.0\",\"result\":"); + match sent.trim_start().strip_prefix('{') { + Some(members) if value.get("resultType").is_none() => { + out.push_str("{\"resultType\":\""); + out.push_str(RESULT_TYPE_COMPLETE); + out.push('"'); + if !members.trim_start().starts_with('}') { + out.push(','); + } + out.push_str(members); + } + _ => out.push_str(sent), + } + out.push('}'); + out.into_bytes() +} + #[cfg(test)] #[path = "tests/call.rs"] mod tests; diff --git a/crates/busbar-plane-mcp/src/checks.rs b/crates/busbar-plane-mcp/src/checks.rs index 6268c169a2..9e3a934ce3 100644 --- a/crates/busbar-plane-mcp/src/checks.rs +++ b/crates/busbar-plane-mcp/src/checks.rs @@ -19,7 +19,10 @@ use crate::codec::{ PROTOCOL_VERSION, }; -/// The revisions this dispatch serves, as `data.supported` names them. +/// The revisions the STATELESS dispatch serves, as `data.supported` names them: the revision a +/// request's own `_meta` may name. The session revisions are never named there, because none of +/// them is carried in `_meta`: a client of theirs opens a session with `initialize`, whose answer +/// negotiates the revision ([`crate::revision::negotiate`]). pub const SUPPORTED_PROTOCOL_VERSIONS: &[&str] = &[PROTOCOL_VERSION]; /// The status every refusal here is answered with. diff --git a/crates/busbar-plane-mcp/src/codec.rs b/crates/busbar-plane-mcp/src/codec.rs index 623bc2228c..b124d1b2f7 100644 --- a/crates/busbar-plane-mcp/src/codec.rs +++ b/crates/busbar-plane-mcp/src/codec.rs @@ -70,13 +70,16 @@ pub fn protected_resource_metadata_path(mount_path: &str) -> String { format!("{PROTECTED_RESOURCE_WELL_KNOWN}{mount_path}") } -/// The single MCP protocol revision busbar implements. +/// THE STATELESS REVISION: the one the plane's dispatch is written against, and the one busbar +/// speaks to an upstream first. /// -/// ONE revision, deliberately. The conformance suite runs each scenario per revision and one run -/// does not cover another, so supporting two revisions is two test legs and two wire formats, not a -/// compatibility shim. `2025-11-25` and earlier are stateful: they have an `initialize` handshake, -/// protocol sessions and a GET stream, all of which this revision deleted, and building them means -/// building session machinery this release can otherwise skip entirely. +/// It is not the only revision served. The session revisions (`2025-11-25`, `2025-06-18`) and the +/// `2024-11-05` event stream are served too, in both directions, by revision negotiation and never +/// by configuration (THE DESIGN section 2, the mcp bullet): a session request is RAISED into this +/// revision's shape before the one dispatch and its answer LOWERED after it +/// ([`crate::adapt`]), and the revisions and their negotiation are [`crate::revision`]'s. Every +/// request of this revision is answered on the stateless path exactly as it was before sessions +/// existed. /// /// It lives on the CODEC side of the split because it is protocol vocabulary — the plane's envelope /// layer (`busbar_mcp::mcp::envelope`) re-exports it under its historical path, and the conformance diff --git a/crates/busbar-plane-mcp/src/declares.json b/crates/busbar-plane-mcp/src/declares.json index b55dc52720..4b7ca5a3c5 100644 --- a/crates/busbar-plane-mcp/src/declares.json +++ b/crates/busbar-plane-mcp/src/declares.json @@ -60,10 +60,10 @@ { "code": 7065, "slug": "mcp-output-schema-violation", - "title": "MCP upstream structuredContent violates the published outputSchema", + "title": "MCP upstream structuredContent violated the published outputSchema — RETIRED", "severity": "benign_recurring", - "summary": "An upstream MCP tool returned `structuredContent` that does not validate against the tool's own published `outputSchema`, so the result is refused. This is an upstream contract violation that can recur per request, so it is logged at debug to avoid spam.", - "action": "If a specific tool trips this repeatedly, report the schema mismatch to that MCP server's operator. No local action is needed.", + "summary": "RETIRED. An upstream MCP tool's `structuredContent` that did not validate against the tool's published `outputSchema` was once refused and replaced by busbar's own tool error. 1.6.0 removed that check: busbar relays an upstream's tool result unchanged (THE DESIGN Law 11), and the caller judges it against the published schema.", + "action": "Nothing emits this code.", "since": "1.6.0" }, { diff --git a/crates/busbar-plane-mcp/src/diagnostics.rs b/crates/busbar-plane-mcp/src/diagnostics.rs index 680a02e1b4..f216ea7258 100644 --- a/crates/busbar-plane-mcp/src/diagnostics.rs +++ b/crates/busbar-plane-mcp/src/diagnostics.rs @@ -116,20 +116,23 @@ pub const MCP_ASK_RECOGNISER_MISSED: Diagnostic = Diagnostic { retired: false, }; -/// MCP upstream structuredContent violates the published outputSchema. +/// RETIRED in 1.6.0 with the structured-output check it reported: an upstream's tool result is +/// relayed unchanged (THE DESIGN Law 11). The number is kept so a log line from a pre-release build +/// still resolves to an entry that says what happened. pub const MCP_OUTPUT_SCHEMA_VIOLATION: Diagnostic = Diagnostic { code: 7065, class: Class::Plane, slug: "mcp-output-schema-violation", - title: "MCP upstream structuredContent violates the published outputSchema", + title: "MCP upstream structuredContent violated the published outputSchema — RETIRED", severity: Severity::BenignRecurring, - summary: "An upstream MCP tool returned `structuredContent` that does not validate against the \ - tool's own published `outputSchema`, so the result is refused. This is an upstream \ - contract violation that can recur per request, so it is logged at debug to avoid spam.", - action: "If a specific tool trips this repeatedly, report the schema mismatch to that MCP \ - server's operator. No local action is needed.", + summary: "RETIRED. An upstream MCP tool's `structuredContent` that did not validate against \ + the tool's published `outputSchema` was once refused and replaced by busbar's own \ + tool error. 1.6.0 removed that check: busbar relays an upstream's tool result \ + unchanged (THE DESIGN Law 11), and the caller judges it against the published \ + schema.", + action: "Nothing emits this code.", since: "1.6.0", - retired: false, + retired: true, }; /// MCP tools/call refused by policy. diff --git a/crates/busbar-plane-mcp/src/door.rs b/crates/busbar-plane-mcp/src/door.rs index f30688cca7..96b8d5dbea 100644 --- a/crates/busbar-plane-mcp/src/door.rs +++ b/crates/busbar-plane-mcp/src/door.rs @@ -329,16 +329,21 @@ pub fn op_class_index(op: busbar_contract::ids::OpClassId) -> Option { .map(|i| i as u32) } -/// The status of a verb the endpoint does not serve. +/// The status of a verb the endpoint does not serve for the request as it came: a GET or DELETE +/// that names no session (a GET naming no revision and accepting an event stream is the +/// `2024-11-05` stream instead), THE DESIGN section 2, the mcp bullet. pub const STATUS_METHOD_NOT_ALLOWED: u32 = 405; -/// The served engine's body for a verb the endpoint does not serve. +/// The body of a GET or DELETE the endpoint does not serve: what each revision opens instead. #[must_use] pub fn method_not_allowed_body() -> Vec { serde_json::to_vec(&serde_json::json!({ "error": "method_not_allowed", "error_description": - "MCP revision 2026-07-28 has no GET stream and no sessions; the endpoint accepts POST only.", + "A GET or DELETE here must name an MCP session (Mcp-Session-Id). MCP 2026-07-28 is \ + POST only; 2025-11-25 and 2025-06-18 open a session with a POSTed `initialize`; a \ + 2024-11-05 client opens its event stream with a GET that accepts text/event-stream \ + and names no MCP-Protocol-Version.", })) .unwrap_or_default() } diff --git a/crates/busbar-plane-mcp/src/door_line.rs b/crates/busbar-plane-mcp/src/door_line.rs index deb51f2275..4939d66643 100644 --- a/crates/busbar-plane-mcp/src/door_line.rs +++ b/crates/busbar-plane-mcp/src/door_line.rs @@ -46,6 +46,9 @@ pub(super) struct LineUnit { pub(super) next_ask: Option, /// The live round this unit's retry is: `0` for a request the caller sent itself. pub(super) round: u32, + /// Its line was raised from the session revision its carrier session negotiated + /// ([`crate::adapt::raise`]): its answer is lowered back into it. + pub(super) raised: bool, } /// One subscription kept on a carrier session. @@ -127,6 +130,28 @@ pub(super) fn arrive(plane: &McpDoor, session: u64, body: &[u8]) -> Arrival { } match line::era(&value) { Era::Dispatch => {} + Era::Opened(revision, answer) => { + // THE CARRIER SESSION NOW SPEAKS `revision` (revision by negotiation, THE DESIGN section 2). + // The table is bounded and evicts nothing (admission bounds live work): a full one + // answers the stateless revision, which needs no entry. + let kept = plane.line_revisions.with_all(|m| { + let full = m.len() >= super::MAX_UNITS && !m.contains_key(&session); + if !full { + m.insert(session, revision); + } + !full + }); + let answer = if kept { + answer + } else { + line::initialize_result(value.get("id").unwrap_or(&Value::Null)) + }; + unit.preset = Some(serde_json::to_vec(&answer).unwrap_or_default()); + return Arrival { + unit, + dispatch: None, + }; + } Era::Answer(answer) => { unit.preset = Some(serde_json::to_vec(&answer).unwrap_or_default()); return Arrival { @@ -135,6 +160,24 @@ pub(super) fn arrive(plane: &McpDoor, session: u64, body: &[u8]) -> Arrival { }; } } + // A LINE OF A SESSION REVISION carries no stateless `_meta`: it is raised into the one + // dispatch's shape, and its answer lowered back ([`crate::adapt`]). + let stateless = value + .pointer("/params/_meta") + .and_then(|m| m.get(crate::codec::META_PROTOCOL_VERSION)) + .is_some(); + if !stateless && plane.line_revisions.get(&session).is_some() { + let mut raised = value.clone(); + if crate::adapt::raise(&mut raised).is_some() { + unit.raised = true; + let dispatch = serde_json::to_vec(&raised).unwrap_or_default(); + unit.original = Some(raised); + return Arrival { + unit, + dispatch: Some(dispatch), + }; + } + } unit.original = Some(value); Arrival { unit, diff --git a/crates/busbar-plane-mcp/src/door_listen.rs b/crates/busbar-plane-mcp/src/door_listen.rs index 650d829a30..580a675440 100644 --- a/crates/busbar-plane-mcp/src/door_listen.rs +++ b/crates/busbar-plane-mcp/src/door_listen.rs @@ -20,7 +20,7 @@ //! - Every frame is one `message` event (`event: message`, `data: `), as predev wrote them. //! - THE SESSION'S ONE LINE carries the request's fee unit, reported with the acknowledgement. -use super::{ask_entitlements, keep, write, CallUnit, Held, McpDoor, Pending, Written, MAX_UNITS}; +use super::{ask_entitlements, write, CallUnit, Held, McpDoor, Pending, Written, MAX_UNITS}; use crate::catalogue::Lookup; use crate::framing::{events, CACHE_CONTROL, CONTENT_TYPE, EVENT_STREAM, NO_STORE}; use crate::subscribe::{Listen, Standing, Step as ListenStep, KEEPALIVE_NS, MAX_LIFETIME_NS}; @@ -38,6 +38,11 @@ use serde_json::Value; /// re-asked the permission every 250 ms of a stream's life). pub(super) const POLL_NS: u64 = 250_000_000; +/// The most subscriptions one caller holds open at once. A caller at it is refused its next one: +/// a quota is a refusal its caller can act on (close one, then open), where an eviction of another +/// caller's stream is not. +pub(super) const MAX_LISTENS_PER_OWNER: usize = 64; + /// What an idle stream writes to say it is alive: an event-stream comment, which every reader /// drops, so an intermediary does not reclaim a working connection. const KEEPALIVE: &[u8] = b": keepalive\n\n"; @@ -45,9 +50,15 @@ const KEEPALIVE: &[u8] = b": keepalive\n\n"; /// The status an invalid `subscriptions/listen` is refused with. const STATUS_INVALID: u32 = 400; +/// The status a `subscriptions/listen` is refused with when its caller is at its quota. +const STATUS_QUOTA: u32 = 429; + /// One held subscription, by its session's stream. #[derive(Debug, Clone)] pub(super) struct Listening { + /// The caller that opened it, by the reference the kernel lends every piece of its units (the + /// one the quota counts under). + owner: String, /// The subscription. listen: Box, /// The session's caller-side ticket. @@ -63,7 +74,7 @@ pub(super) struct Listening { } /// A frame of the stream: its head (status, fields and the request's fee unit) with the first. -fn frame(bytes: Vec, done: bool, headed: bool) -> Pending { +pub(super) fn frame(bytes: Vec, done: bool, headed: bool) -> Pending { let (fields, units) = if headed { (Vec::new(), Vec::new()) } else { @@ -201,14 +212,40 @@ fn open(plane: &McpDoor, ticket: Ticket, principal: &str, unit: &mut CallUnit) - Some(true) } Ok(listen) => { + let id = listen.id.clone(); let listening = Listening { + owner: principal.to_string(), listen: Box::new(listen), ticket, first_tick: 0, due: false, headed: false, }; - keep(&plane.listens, MAX_UNITS, unit.key, listening); + // THE QUOTA REFUSES THE NEW STREAM, never evicts a held one: this caller at its own + // bound, or the instance at its total, is told so. + let admitted = plane.listens.with_all(|held| { + let full = held.len() >= MAX_UNITS + || held.values().filter(|l| l.owner == principal).count() + >= MAX_LISTENS_PER_OWNER; + if !full { + held.insert(unit.key, listening); + } + !full + }); + if !admitted { + let refusal = crate::tool_arrival::Refusal { + status: STATUS_QUOTA, + id: Some(id), + code: crate::codec::CODE_REFUSED, + message: format!( + "this caller holds {MAX_LISTENS_PER_OWNER} open subscriptions, the most one \ + caller may hold; close one and open again" + ), + data: Some(serde_json::json!({ "reason": "subscription_quota" })), + }; + unit.pending = Some(Pending::answer(refusal.status, refusal.body(), None, &[])); + return Some(true); + } step(plane, ticket, principal, unit) } } @@ -303,12 +340,15 @@ pub(super) fn drive( input: Lent<'_, PlaneDriveIn>, out: &mut Out<'_, PlaneDriveOut>, ) -> Outcome { - let due: Vec = plane.listens.with_all(|m| { + let mut due: Vec = plane.listens.with_all(|m| { m.iter() .filter(|(_, l)| l.due) .map(|(stream, _)| *stream) .collect() }); + // The session revisions' held streams owe their collections on the same driver ticket. + let streams = super::door_sessions::due_streams(plane); + due.extend(&streams); let mut buf = input.sessions_buf(); for stream in &due { buf.push(*stream); @@ -325,6 +365,7 @@ pub(super) fn drive( } } }); + super::door_sessions::taken(plane, &streams); Outcome::Ready } @@ -336,7 +377,7 @@ pub(super) fn cancelled(plane: &McpDoor, ticket: Ticket) { } /// Name the instance's driver ticket (as its last tick handed it): a held subscription owes a step. -fn wake_driver(plane: &McpDoor) { +pub(super) fn wake_driver(plane: &McpDoor) { let driver = plane.driver.get(&()).map(|(ticket, _)| ticket); if let (Some(wake), Some(ticket)) = (plane.wake, driver) { if ticket != Ticket::NONE { diff --git a/crates/busbar-plane-mcp/src/door_program.rs b/crates/busbar-plane-mcp/src/door_program.rs index 26f2f8ed72..0d69dee9bb 100644 --- a/crates/busbar-plane-mcp/src/door_program.rs +++ b/crates/busbar-plane-mcp/src/door_program.rs @@ -13,14 +13,55 @@ //! member's; its answer is the message carrying the unit's id among everything the child writes //! ([`far`]), and a request of the child's own read on the way is answered on a lease of its own //! (`ping`, an unknown method, an ungranted ask) or, a granted authority ask, handed up as busbar's -//! caller's to answer ([`Far::Asked`], Law 11). The caller's answer comes back on a retry of the -//! call ([`ProgramRelay::retrying`]): written to the child under its own request id, and the call -//! the child still owes read on. +//! caller's to answer ([`Far::Asked`], Law 11) — the caller of the call FIRST IN THE CHILD'S +//! LINE ([`Relaying`]): calls to a member whose grants let its asks be relayed reach its child one +//! at a time, in arrival order, each holding its place until its answer is read (across a relayed +//! ask's round trip, until its retry or the ask's state lapses); a call behind it waits its turn +//! within its own deadline ([`in_line`]). An ask raised while no such call is open is refused. The +//! caller's answer comes back on a retry of the call ([`ProgramRelay::retrying`]): written to the +//! child under its own request id, and the call the child still owes read on. //! * Verify-on-call's `tools/list` ([`tools_listed`]), an operator's `connect`, and a further round reach the //! same child the same way. use super::*; -use crate::tool_program::{call_id, list_id, Correlator, Exchanged, Peer, ProgramExchange}; +use crate::tool_program::{ + call_id, first_in_line, list_id, AskOwner, Correlator, Exchanged, Peer, ProgramExchange, +}; + +/// THE LINE OF CALLS RELAYED TO STDIO CHILDREN, by serial (arrival order) (finding 5): every +/// relayed call is entered before its request could reach the kernel's walk and leaves when its +/// relay is dropped (or, holding its place across a relayed ask's round trip, when that lapses). +/// For a member whose asks may be relayed, only the call first in its line is sent ([`in_line`]), +/// so the child serves one such call at a time and its ask is that call's ([`first_in_line`]). +pub(super) type Relaying = Arc>; + +/// One call in a stdio member's line. +#[derive(Debug, Clone)] +pub(super) struct InLine { + member: String, + /// The generation its lease reached; `0` before its head. + generation: u64, + /// The id the call carries on the child. + call: u64, + /// The ticket of its unit while it waits its turn: woken when the call ahead of it leaves. + ticket: Option, + /// Held across a relayed ask's round trip until this instant of the host's monotonic clock + /// (ms): the child still serves the call, so its place is kept for the retry. `0` = a live + /// relay. + held_until: u64, +} + +/// Whether member `def`'s calls wait their turn on its child: its grants let a child's ask be +/// relayed to a caller (a call whose asks are all refused may share the child freely). +pub(super) fn serialised(def: &crate::tools_config::McpServerDefCfg) -> bool { + let g = def.grants.as_ask_grants(); + g.sampling || g.elicitation || g.roots +} + +/// The sentence a call answers when its deadline passed while another call held the child. +pub(super) const BUSY: &str = "the stdio MCP child was serving another call whose asks are \ + relayed to its own caller, and this call's deadline passed while it \ + waited its turn; it was never sent"; /// Where the door's own program exchanges number their host services on a unit's ticket: the /// greeting before each attempt, [`crate::tool_program::EXCHANGE_SERVICES`] apart per attempt. @@ -30,8 +71,8 @@ const READY_SEQ: u32 = 1 << 31; /// [`crate::tool_program::EXCHANGE_SERVICES`] apart. const REPLY_SEQ: u32 = (1 << 31) + (1 << 30); -/// The most requests of a child's own the door remembers having answered (per member and -/// generation, by id), so two exchanges reading one request answer it once. +/// The most requests of a child's own the door remembers having answered or put to a call (per +/// member and generation, by id), so two exchanges reading one request decide it once. const MAX_ANSWERED: usize = 4096; /// Whether `def` is a `transport: stdio` registration: a member of the program need. @@ -65,11 +106,59 @@ pub(super) struct ProgramRelay { /// A retry answering the child's own requests: the generation that asked, which is the only one /// its answers and its wait mean anything to. expect: Option, + /// Its place in the line ([`Relaying`]), left when it is dropped. + open: (Relaying, u64), + /// The host's wake, for the call behind it when it leaves the line. + wake: Option, + /// Its asks went to its caller: dropped, it holds its place until this instant (ms). + hold: u64, +} + +impl Drop for ProgramRelay { + fn drop(&mut self) { + let (line, serial) = &self.open; + let hold = self.hold; + let next = line.with_all(|m| { + if hold != 0 { + if let Some(e) = m.get_mut(serial) { + e.held_until = hold; + } + return None; + } + let gone = m.remove(serial)?; + if m.range(..*serial).any(|(_, e)| e.member == gone.member) { + return None; + } + m.values().find(|e| e.member == gone.member)?.ticket + }); + if let (Some(wake), Some(ticket)) = (self.wake, next) { + wake.wake(ticket); + } + } } impl ProgramRelay { - /// The relay of a call whose round carries `wait`. - pub(super) fn waiting(wait: u64) -> Self { + /// The relay of a call to member `member` whose round carries `wait`: entered at the end of + /// the member's line. + pub(super) fn waiting(plane: &McpDoor, member: &str, wait: u64) -> Self { + let serial = plane.relaying.with_all(|line| { + let serial = line.last_key_value().map_or(1, |(k, _)| k.wrapping_add(1)); + line.insert( + serial, + InLine { + member: member.to_string(), + generation: 0, + call: wait, + ticket: None, + held_until: 0, + }, + ); + serial + }); + ProgramRelay::at(plane, wait, serial) + } + + fn at(plane: &McpDoor, wait: u64, serial: u64) -> Self { ProgramRelay { wait, generation: 0, @@ -78,14 +167,40 @@ impl ProgramRelay { replies: 0, settled: None, expect: None, + open: (Arc::clone(&plane.relaying), serial), + wake: plane.wake, + hold: 0, } } + /// Its asks went to its caller: dropped, it keeps its place in the line until `until` (the + /// host's monotonic clock, ms), for the retry that answers them. + pub(super) fn hold(&mut self, until: u64) { + self.hold = until; + } + /// THE RETRY OF A CHILD'S RELAYED ASK: the caller's answers after the first (which the walk's /// own lease carries) owed on the child of `generation`, and the call `wait` it still owes read - /// on, on that generation only. - pub(super) fn retrying(wait: u64, generation: u64, rest: Vec>) -> Self { - let mut relay = ProgramRelay::waiting(wait); + /// on, on that generation only. It takes up the place in the line the call held while its + /// caller answered. + pub(super) fn retrying( + plane: &McpDoor, + member: &str, + wait: u64, + generation: u64, + rest: Vec>, + ) -> Self { + let held = plane.relaying.with_all(|line| { + let (serial, e) = line + .iter_mut() + .find(|(_, e)| e.held_until != 0 && e.member == member && e.call == wait)?; + e.held_until = 0; + Some(*serial) + }); + let mut relay = match held { + Some(serial) => ProgramRelay::at(plane, wait, serial), + None => ProgramRelay::waiting(plane, member, wait), + }; relay.expect = Some(generation); relay.corr.outbox.extend(rest); relay @@ -96,6 +211,34 @@ impl ProgramRelay { pub(super) const RESTARTED: &str = "the stdio MCP child that asked was restarted before the \ caller's answer reached it, so the call it was serving is gone"; +/// WHETHER `relay`'s CALL IS FIRST IN ITS MEMBER'S LINE, so may be sent: a place held across a +/// relayed ask's round trip that lapsed by `now` (the host's monotonic clock, ms) is given up first. +/// One that is not waits its turn, its unit's `ticket` woken when the call ahead of it leaves. +pub(super) fn in_line( + plane: &McpDoor, + relay: &ProgramRelay, + now: Option, + ticket: Ticket, +) -> bool { + let serial = relay.open.1; + plane.relaying.with_all(|line| { + let Some(member) = line.get(&serial).map(|e| e.member.clone()) else { + return true; + }; + if let Some(now) = now { + line.retain(|_, e| e.member != member || e.held_until == 0 || e.held_until > now); + } + let first = line + .iter() + .find(|(_, e)| e.member == member) + .is_none_or(|(s, _)| *s == serial); + if let Some(e) = line.get_mut(&serial).filter(|_| !first) { + e.ticket = Some(ticket); + } + first + }) +} + /// The door, as an exchange with member `member`'s child needs it. struct DoorPeer<'a> { plane: &'a McpDoor, @@ -103,31 +246,69 @@ struct DoorPeer<'a> { grants: crate::client::jsonrpc::ServerRequestGrants, } -impl Peer for DoorPeer<'_> { - fn grants(&self) -> crate::client::jsonrpc::ServerRequestGrants { - self.grants - } - - fn claim(&mut self, generation: u64, id: &Value) -> bool { +impl DoorPeer<'_> { + /// What became of the child's request `id` of `generation`: the call it was put to (`None` = + /// busbar answered it), decided by `first` when this exchange is the first to read it (`true`). + fn decide( + &self, + generation: u64, + id: &Value, + first: impl FnOnce() -> Option, + ) -> (Option, bool) { let key = (self.member.to_string(), generation, id.to_string()); self.plane.answered.with_all(|m| { - if m.contains_key(&key) { - return false; + if let Some(owner) = m.get(&key) { + return (*owner, false); } while m.len() >= MAX_ANSWERED { if m.pop_first().is_none() { break; } } - m.insert(key, ()); - true + let owner = first(); + m.insert(key, owner); + (owner, true) }) } +} + +impl Peer for DoorPeer<'_> { + fn grants(&self) -> crate::client::jsonrpc::ServerRequestGrants { + self.grants + } + + fn claim(&mut self, generation: u64, id: &Value) -> bool { + self.decide(generation, id, || None).1 + } + + fn owner(&mut self, generation: u64, id: &Value) -> AskOwner { + let member = self.member; + let line = &self.plane.relaying; + match self.decide(generation, id, || { + line.with_all(|l| { + first_in_line( + l.values() + .filter(|e| e.member == member) + .map(|e| (e.call, e.generation)), + generation, + ) + }) + }) { + (Some(call), _) => AskOwner::Call(call), + (None, true) => AskOwner::Refuse, + (None, false) => AskOwner::Refused, + } + } fn notice(&mut self) { // The child's lists moved: the next call re-verifies (the timing, never the content). self.plane.checked.remove(&self.member.to_string()); } + + fn announced(&mut self, uri: &str) { + // The child's resource changed: every session watching it hears so. + super::door_sessions::announce(self.plane, self.member, uri); + } } /// Drive `exchange` with member `member` on `ticket` from `base`: the door's greeting record kept. @@ -317,6 +498,11 @@ pub(super) fn far( if relay.replying.is_none() && relay.settled.is_none() { if let Some(generation) = head { relay.generation = generation; + plane.relaying.with(&relay.open.1, |e| { + if let Some(e) = e { + e.generation = generation; + } + }); // A retry reaches only the child that asked: a restarted one owes nothing. if relay.expect.is_some_and(|g| g != generation) { relay.corr.outbox.clear(); @@ -332,6 +518,7 @@ pub(super) fn far( member, grants: def.grants.as_ask_grants(), }; + relay.hold = 0; match relay .corr .take(bytes, relay.wait, member, relay.generation, &mut peer) diff --git a/crates/busbar-plane-mcp/src/door_sessions.rs b/crates/busbar-plane-mcp/src/door_sessions.rs new file mode 100644 index 0000000000..1bc3f96b6a --- /dev/null +++ b/crates/busbar-plane-mcp/src/door_sessions.rs @@ -0,0 +1,1646 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (C) 2026 Busbar Inc and contributors + +//! THE SESSION REVISIONS AND THE LEGACY EVENT STREAM, SERVED, IN BOTH DIRECTIONS (THE DESIGN section 2, +//! the mcp bullet; `docs/design/BUSBAR-1.6.0.md` lines 379-390). +//! +//! INBOUND, on the endpoint's three verbs: +//! +//! - A POST that carries the stateless revision's own marker, or names neither a session nor +//! `initialize`, is the stateless path, untouched ([`crate::adapt::classify_post`]). +//! - `initialize` opens a session in the revision negotiation picks ([`crate::revision::negotiate`]; +//! no revision is configured): its id is 128 bits of the host's CSPRNG (`random.fill`), bound to +//! its OWNER, the unit's principal and credential (the kernel's opaque caller reference, minted +//! from the key id; the ungoverned chain's one constant), and named in `Mcp-Session-Id`. +//! - A message in a session is answered only for that owner: any other owner, an unknown, ended or +//! expired id, is `404` on every path. A dispatched method is RAISED into the stateless shape, sent +//! through the one dispatch, and its answer LOWERED ([`crate::adapt`]); `ping`, the session's own +//! verbs (`resources/subscribe`, `resources/unsubscribe`, `logging/setLevel`: subscribe stays for +//! the old revisions) and notifications are the session's. +//! - A GET naming a session opens that session's stream (resumed after `Last-Event-ID`); a GET +//! naming none and no `MCP-Protocol-Version` opens the `2024-11-05` stream, whose first event names +//! the message address its client POSTs to, every answer then arriving on the stream +//! ([`revision::sessionless_get`]); a GET naming a revision, a GET that accepts no event stream and +//! a DELETE naming no session are `405`. DELETE ends the owner's session. +//! - Sessions are this process's ([`crate::tool_sessions`]), bounded per owner in count and bytes. +//! On the ungoverned chain every caller is one owner, so the binding isolates nothing: the first +//! `2024-11-05` stream of an instance (whose session id rides a URL) says so once, as the +//! instance's diagnostic [`UNGOVERNED_LEGACY_STREAM`]. +//! +//! OUTBOUND ([`Negotiating`]): busbar sends an upstream the stateless request first, byte for byte +//! what it always sent; only when the upstream refuses it does the client ladder +//! ([`crate::client::negotiate`]) open a session with it (`initialize`, `notifications/initialized`, +//! the request lowered into the session), every further hop over the door's own connector. An +//! upstream answering in the stateless revision is remembered as such and never renegotiated. The +//! ladder's last rung, the `2024-11-05` event stream, is not carried as a client: an upstream that +//! refuses both the stateless request and `initialize` fails the call in words that name it. + +use std::collections::VecDeque; +use std::task::Poll; + +use serde_json::{json, Value}; + +use super::{ + ask_entitlements, door_listen, exchange_at, write, CallUnit, Held, McpDoor, Pending, Step, + Written, +}; +use crate::adapt::{self, SessionMethod}; +use crate::catalogue::Lookup; +use crate::client::jsonrpc::OutboundRequest; +use crate::client::negotiate::{Action, HopAnswer, Negotiator, Refusal as Refused, Verb}; +use crate::codec::{ + CODE_INTERNAL, CODE_INVALID_PARAMS, CODE_INVALID_REQUEST, CODE_METHOD_NOT_FOUND, CODE_REFUSED, + H_MCP_METHOD, H_MCP_NAME, H_PROTOCOL_VERSION, PROTOCOL_VERSION, +}; +use crate::framing::{CONTENT_LENGTH, CONTENT_TYPE, EVENT_STREAM}; +use crate::revision::{self, HeaderCheck, Revision, SessionlessGet}; +use crate::tool_arrival::{Disposition, Refusal}; +use crate::tool_sessions::{Carriage, OpenRefused, Owner, Remembered, SessionTable}; +use busbar_contract::abi::mechanism::call::{Outcome, SEVERITY_WARN}; +use busbar_contract::abi::mechanism::ticket::{CompletionHandle, Ticket}; +use busbar_contract::abi::plane::{OnPieceIn, OnPieceOut, FROM_CALLER, FROM_KERNEL, PIECE_LAST}; +use busbar_contract::abi::sdk::{Instance, Lent, Out, Services}; + +/// THE INSTANCE'S DIAGNOSTIC that an ungoverned chain's sessions are unisolated, latched on its +/// first `2024-11-05` stream (the index of its id in the Statement's diagnostic ids). +pub(super) const UNGOVERNED_LEGACY_STREAM: u32 = 0; + +/// The Statement's diagnostic ids, in index order. +pub(super) const DIAG_IDS: &[busbar_contract::abi::mechanism::call::AbiStr] = + &[busbar_contract::abi::sdk::door::abi_str( + "mcp-legacy-stream-ungoverned", + )]; + +/// The words of [`UNGOVERNED_LEGACY_STREAM`]. +const UNGOVERNED_WORDS: &str = + "an MCP 2024-11-05 event stream opened on the ungoverned chain: every \ + caller there is one owner, so a session id (which this revision carries in a URL) isolates \ + nothing between callers; govern the deployment to bind each session to its key"; + +/// The status a session that is not the caller's (unknown, ended, expired, or another owner's) is +/// answered with, on every path. +const STATUS_NOT_FOUND: u32 = 404; +/// The status of a session request whose version header disagrees with its session. +const STATUS_BAD_REQUEST: u32 = 400; +/// The status a session that cannot be opened is answered with. +const STATUS_UNAVAILABLE: u32 = 503; +/// The status of a message accepted and answered elsewhere (a notification, or a `2024-11-05` +/// message whose answer goes on its stream). +const STATUS_ACCEPTED: u32 = 202; + +/// The most resource subscriptions one owner holds across its sessions. +const MAX_OWNER_SUBSCRIPTIONS: usize = 256; +/// The ceiling on one retained subscription uri, in bytes. +const MAX_RESOURCE_SUB_URI_BYTES: usize = 2048; +/// The most announced updates a session holds until its stream collects them; past it, the oldest +/// gives way. +const MAX_PENDING_UPDATES: usize = 64; + +/// The RFC 5424 severities `logging/setLevel` names, least severe first. +const LEVELS: &[&str] = &[ + "debug", + "info", + "notice", + "warning", + "error", + "critical", + "alert", + "emergency", +]; + +/// What a unit is to the session machinery. +#[derive(Debug, Clone)] +pub(super) enum SessionUnit { + /// `initialize` POSTed to the endpoint: it opens a session. `id` is the request's id. + Open { + id: Value, + requested: Option, + }, + /// A message in `session`, carried as `carriage`, with the version header it carried. + Message { + session: String, + carriage: Carriage, + header: Option, + kind: Kind, + /// The session check passed: its answer is lowered (and, on the `2024-11-05` stream, + /// delivered there). + checked: bool, + }, + /// A GET stream: a session's (`Some`), or the `2024-11-05` stream opening (`None`). `mount` + /// is the path the arrival named, the message address's own. + Stream { + session: Option, + last_event_id: Option, + mount: String, + }, + /// DELETE of `session`. + Delete { session: String }, + /// A line of a carrier session that negotiated a session revision: its answer is lowered. + Line, +} + +/// What a session message asks. +#[derive(Debug, Clone)] +pub(super) enum Kind { + /// A method of the one dispatch, raised. + Dispatch, + /// Answered here: `ping`, the session's own verbs, `initialize` inside a `2024-11-05` session, + /// or a method no session revision has. + Here(Value), + /// A notification or a client's response: accepted, unanswered. + Accept(Value), + /// Refused as it arrived, answered only once the session is the caller's. + Refused(Box), +} + +/// One arrival the session machinery takes. +pub(super) struct Arrival { + pub(super) unit: SessionUnit, + /// The raised body the one dispatch decides on; `None` = the unit is answered here. + pub(super) dispatch: Option>, + /// The head fields the raised body implies: read ahead of the caller's own. + pub(super) mirror: Vec<(String, String)>, +} + +/// What `arrive` makes of an arrival on the endpoint. +pub(super) enum Inbound { + /// The stateless path, untouched. + Stateless, + /// `405`. + NotAllowed, + /// The session machinery's. + Session(Box), +} + +/// THE OWNER a unit's session is bound to: the kernel's caller reference (derived from the +/// principal's key id), or the ungoverned chain's one constant. +pub(super) fn owner_of(caller: &str) -> Owner { + let who = if caller.is_empty() { + crate::ask::UNGOVERNED + } else { + caller + }; + Owner { + principal: who.to_string(), + credential: who.to_string(), + } +} + +/// READ ONE ARRIVAL on the endpoint (`verb`, `target`, `body`, and a reader over its head fields). +pub(super) fn arrive( + held: Option<&Held>, + verb: &str, + target: &str, + body: &[u8], + field: &dyn Fn(&str) -> Option, +) -> Inbound { + let session = field(adapt::H_SESSION_ID).filter(|s| !s.is_empty()); + let (mount, query) = target + .split_once('?') + .map_or((target, None), |(p, q)| (p, Some(q))); + match verb { + "GET" => { + let streams = field("accept").is_some_and(|a| a.contains(EVENT_STREAM)); + if !streams { + return Inbound::NotAllowed; + } + let opens = session.is_some() + || revision::sessionless_get(field(H_PROTOCOL_VERSION).as_deref()) + == SessionlessGet::EventStream; + if !opens { + return Inbound::NotAllowed; + } + Inbound::Session(Box::new(Arrival { + unit: SessionUnit::Stream { + session, + last_event_id: field(adapt::H_LAST_EVENT_ID), + mount: mount.to_string(), + }, + dispatch: None, + mirror: Vec::new(), + })) + } + "DELETE" => match session { + Some(session) => Inbound::Session(Box::new(Arrival { + unit: SessionUnit::Delete { session }, + dispatch: None, + mirror: Vec::new(), + })), + None => Inbound::NotAllowed, + }, + "POST" => { + let value = serde_json::from_slice::(body).ok(); + let message_session = adapt::message_session_of(query).map(str::to_string); + let kind = match &value { + Some(v) => adapt::classify_post(v, session.as_deref(), message_session.as_deref()), + None if message_session.is_some() => adapt::PostKind::EventStreamMessage, + None if session.is_some() => adapt::PostKind::InSession, + None => adapt::PostKind::Stateless, + }; + match (kind, value) { + (adapt::PostKind::Initialize, Some(v)) => Inbound::Session(Box::new(Arrival { + unit: SessionUnit::Open { + id: v.get("id").cloned().unwrap_or(Value::Null), + requested: v + .pointer("/params/protocolVersion") + .and_then(Value::as_str) + .map(str::to_string), + }, + dispatch: None, + mirror: Vec::new(), + })), + (adapt::PostKind::InSession, value) => Inbound::Session(message( + held, + session.unwrap_or_default(), + Carriage::Endpoint, + field(H_PROTOCOL_VERSION), + value, + )), + (adapt::PostKind::EventStreamMessage, value) => Inbound::Session(message( + held, + message_session.unwrap_or_default(), + Carriage::EventStream, + None, + value, + )), + _ => Inbound::Stateless, + } + } + _ => Inbound::NotAllowed, + } +} + +/// One message in a session: what it asks, and the raised body when the one dispatch answers it. +fn message( + held: Option<&Held>, + session: String, + carriage: Carriage, + header: Option, + value: Option, +) -> Box { + let unit = |kind| SessionUnit::Message { + session, + carriage, + header, + kind, + checked: false, + }; + let Some(mut value) = value else { + let refusal = Refusal { + status: STATUS_BAD_REQUEST, + id: None, + code: busbar_contract::jsonrpc::PARSE_ERROR, + message: crate::tool_arrival::NOT_JSON.to_string(), + data: None, + }; + return Box::new(Arrival { + unit: unit(Kind::Refused(Box::new(refusal))), + dispatch: None, + mirror: Vec::new(), + }); + }; + let method = value + .get("method") + .and_then(Value::as_str) + .map(str::to_string); + let has_id = value.get("id").is_some_and(|i| !i.is_null()); + let (kind, dispatch, mirror) = match adapt::session_method(method.as_deref(), has_id) { + SessionMethod::Accept => (Kind::Accept(value), None, Vec::new()), + SessionMethod::Ping | SessionMethod::Session | SessionMethod::NotFound => { + (Kind::Here(value), None, Vec::new()) + } + SessionMethod::Dispatch => match adapt::raise(&mut value) { + None => (Kind::Here(value), None, Vec::new()), + Some(raised) => { + let mut mirror = vec![ + (H_PROTOCOL_VERSION.to_string(), raised.version.to_string()), + (H_MCP_METHOD.to_string(), raised.method.clone()), + ]; + if let Some(name) = &raised.name { + mirror.push(( + H_MCP_NAME.to_string(), + crate::client::jsonrpc::encode_sentinel(name), + )); + } + // A `tools/call` mirrors its annotated arguments, as its caller's revision + // cannot. + if raised.method == crate::codec::METHOD_TOOLS_CALL { + let schema = raised + .name + .as_deref() + .and_then(|n| held?.catalogue.tool(n)?.input_schema.clone()); + let arguments = value.pointer("/params/arguments"); + for (name, v) in adapt::param_mirror(schema.as_ref(), arguments) { + mirror.push((name.to_ascii_lowercase(), v)); + } + } + let dispatch = serde_json::to_vec(&value).unwrap_or_default(); + (Kind::Dispatch, Some(dispatch), mirror) + } + }, + }; + Box::new(Arrival { + unit: unit(kind), + dispatch, + mirror, + }) +} + +/// The disposition a session unit answered here is decided under, and counted as: an `initialize` +/// or a message answered here is a discovery, a stream holds open as a subscription does, and a +/// DELETE or an accepted message is a notification. +pub(super) fn disposition(unit: &SessionUnit) -> Disposition { + let request = |method: &str, id: Value| match crate::tool_ops::method_row_for(method) { + Some(row) => Disposition::Request { row, id }, + None => Disposition::Notice { + method: String::new(), + }, + }; + match unit { + SessionUnit::Open { id, .. } => request("server/discover", id.clone()), + SessionUnit::Stream { .. } => request("subscriptions/listen", Value::Null), + SessionUnit::Message { kind, .. } => match kind { + Kind::Here(v) => request( + "server/discover", + v.get("id").cloned().unwrap_or(Value::Null), + ), + Kind::Refused(r) => request("server/discover", r.id.clone().unwrap_or(Value::Null)), + Kind::Accept(v) => Disposition::Notice { + method: v + .get("method") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), + }, + Kind::Dispatch => Disposition::Notice { + method: String::new(), + }, + }, + SessionUnit::Delete { .. } | SessionUnit::Line => Disposition::Notice { + method: String::new(), + }, + } +} + +/// A session unit's arrival refused by the one dispatch: answered once the session is the +/// caller's, never before (a mismatch answers `404` on every path). +pub(super) fn refused_in_session(unit: &mut SessionUnit, refusal: &Refusal) -> bool { + match unit { + SessionUnit::Message { kind, .. } => { + *kind = Kind::Refused(Box::new(refusal.clone())); + true + } + _ => false, + } +} + +// ── the host's services, on a unit's ticket ───────────────────────────────────────────────────── + +/// A fresh completion handle on `ticket`, counted in `issued`. +fn handle(ticket: Ticket, issued: &mut u32) -> CompletionHandle { + let h = CompletionHandle { + ticket, + seq: *issued, + _reserved: 0, + }; + *issued += 1; + h +} + +/// The kernel's wall clock in milliseconds; `0` with no clock. +pub(super) fn wall_ms(services: Option, ticket: Ticket, issued: &mut u32) -> u64 { + services.map_or(0, |s| { + s.clock_now(handle(ticket, issued)) + .map_or(0, |r| r.wall_ns / 1_000_000) + }) +} + +/// The session table. +fn slots(plane: &McpDoor, f: impl FnOnce(&mut SessionTable) -> R) -> Option { + plane.sessions.with(&(), |t| t.map(f)) +} + +/// OPENS a session for `owner`: 128 bits from the host's CSPRNG (a collision draws again once). +fn open_session( + plane: &McpDoor, + ticket: Ticket, + unit: &mut CallUnit, + owner: &Owner, + revision: Revision, + carriage: Carriage, + now: u64, +) -> Result { + for _ in 0..2 { + let mut entropy = [0_u8; 16]; + let drawn = plane.services.is_some_and(|s| { + s.random_fill(handle(ticket, &mut unit.issued), &mut entropy) + .is_ok() + }); + if !drawn { + return Err("the host's random source did not answer, so no session id can be minted"); + } + let opened = slots(plane, |t| { + t.open(entropy, owner.clone(), revision, carriage, now) + }); + match opened { + Some(Ok(id)) => return Ok(id.as_str().to_string()), + Some(Err(OpenRefused::Collision)) => {} + Some(Err(OpenRefused::NoEntropy)) => { + return Err("the host's random source answered no entropy") + } + Some(Err(OpenRefused::Full)) | None => { + return Err("this caller holds as many MCP sessions as this node keeps for it") + } + } + } + Err("two fresh session ids collided") +} + +/// A JSON-RPC error envelope. +fn error_json(id: &Value, code: i64, message: &str) -> Vec { + serde_json::to_vec(&busbar_contract::jsonrpc::error_body( + id.clone(), + code, + message, + None, + )) + .unwrap_or_default() +} + +/// A whole JSON answer. +fn answer_of(status: u32, body: Vec) -> Pending { + Pending::answer(status, body, None, &[]) +} + +/// The answer to a session that is not the caller's. +fn not_found(id: &Value) -> Pending { + answer_of( + STATUS_NOT_FOUND, + error_json( + id, + CODE_REFUSED, + "Session not found: it ended, expired, or is not this caller's. Send `initialize` \ + without Mcp-Session-Id to open a new one.", + ), + ) +} + +/// The id a session message carries. +fn id_of(kind: &Kind) -> Value { + match kind { + Kind::Here(v) | Kind::Accept(v) => v.get("id").cloned().unwrap_or(Value::Null), + Kind::Refused(r) => r.id.clone().unwrap_or(Value::Null), + Kind::Dispatch => Value::Null, + } +} + +// ── the answers ───────────────────────────────────────────────────────────────────────────────── + +/// ANSWER A SESSION UNIT on its caller's piece, the session first checked against its owner: +/// `None` for a unit the one dispatch answers (its session checked). +pub(super) fn answer( + plane: &McpDoor, + ticket: Ticket, + caller: &str, + unit: &mut CallUnit, +) -> Option { + let session_unit = unit.session.clone()?; + let owner = owner_of(caller); + let now = wall_ms(plane.services, ticket, &mut unit.issued); + let pending = match session_unit { + SessionUnit::Line | SessionUnit::Stream { .. } => return None, + SessionUnit::Open { id, requested } => { + open_endpoint(plane, ticket, unit, &owner, &id, requested.as_deref(), now) + } + SessionUnit::Delete { session } => { + if slots(plane, |t| t.close(&session, &owner, now)) == Some(true) { + plane.session_state.remove(&session); + // Each of its streams ends at its next collection. + due(plane, &session); + answer_of(200, Vec::new()) + } else { + not_found(&Value::Null) + } + } + SessionUnit::Message { + session, + carriage, + header, + kind, + .. + } => { + let held = slots(plane, |t| { + let revision = t.revision(&session, &owner, now)?; + (t.carriage(&session, &owner, now)? == carriage).then_some(revision) + }) + .flatten(); + let Some(revision) = held else { + return Some(written(unit, not_found(&id_of(&kind)))); + }; + if carriage == Carriage::Endpoint + && revision::check_header(revision, header.as_deref()) == HeaderCheck::Disagrees + { + let body = error_json( + &id_of(&kind), + CODE_INVALID_REQUEST, + "The MCP-Protocol-Version header names another revision than this session's.", + ); + return Some(written(unit, answer_of(STATUS_BAD_REQUEST, body))); + } + if let Some(SessionUnit::Message { checked, .. }) = unit.session.as_mut() { + *checked = true; + } + match kind { + Kind::Dispatch => return None, + Kind::Accept(v) => { + if v.get("method").and_then(Value::as_str) == Some(adapt::METHOD_INITIALIZED) { + slots(plane, |t| t.mark_initialized(&session, &owner, now)); + } + answer_of(STATUS_ACCEPTED, Vec::new()) + } + Kind::Refused(r) => answer_of(r.status, r.body()), + Kind::Here(v) => { + let body = here( + plane, + ticket, + unit, + (session.as_str(), &owner), + revision, + carriage, + &v, + ); + answer_of(200, body) + } + } + } + }; + Some(written(unit, pending)) +} + +/// `pending` kept as the unit's answer. +fn written(unit: &mut CallUnit, pending: Pending) -> Step { + unit.pending = Some(pending); + Step::Write +} + +/// The discovery document this caller is entitled to, as `initialize` answers from it. +fn discovery(plane: &McpDoor, ticket: Ticket, unit: &mut CallUnit, id: &Value) -> Option { + let held = unit.held.clone().or_else(|| plane.current())?; + ask_entitlements(plane.services, ticket, unit, &held.catalogue.grants()); + let entitled = &unit.entitled; + let admit = |kind: &str, name: &str| { + entitled + .get(&format!("{kind}:{name}")) + .copied() + .unwrap_or(false) + }; + let document = crate::answer::discover(&held.catalogue, id, &admit); + serde_json::from_slice::(&document) + .ok()? + .get("result") + .cloned() +} + +/// `initialize` on the endpoint: the revision negotiation picks, a session opened in it. +fn open_endpoint( + plane: &McpDoor, + ticket: Ticket, + unit: &mut CallUnit, + owner: &Owner, + id: &Value, + requested: Option<&str>, + now: u64, +) -> Pending { + // The event-stream revision is opened by its GET, never by a POSTed `initialize`. + let revision = match revision::negotiate(requested) { + r if r.is_event_stream_revision() => revision::SESSION_REVISIONS[0], + r => r, + }; + let Some(discovered) = discovery(plane, ticket, unit, id) else { + return answer_of( + STATUS_UNAVAILABLE, + error_json( + id, + CODE_INTERNAL, + "this node publishes no MCP catalogue yet", + ), + ); + }; + let session = match open_session( + plane, + ticket, + unit, + owner, + revision, + Carriage::Endpoint, + now, + ) { + Ok(session) => session, + Err(why) => return answer_of(STATUS_UNAVAILABLE, error_json(id, CODE_INTERNAL, why)), + }; + let result = adapt::initialize_result(&discovered, revision, false); + let body = serde_json::to_vec(&crate::line::result(id, result)).unwrap_or_default(); + let mut pending = answer_of(200, body); + pending + .fields + .push((adapt::H_SESSION_ID.to_string(), session)); + pending +} + +/// A message the session answers itself. +fn here( + plane: &McpDoor, + ticket: Ticket, + unit: &mut CallUnit, + (session, owner): (&str, &Owner), + revision: Revision, + carriage: Carriage, + value: &Value, +) -> Vec { + let id = value.get("id").cloned().unwrap_or(Value::Null); + let method = value.get("method").and_then(Value::as_str).unwrap_or(""); + let ok = + |result: Value| serde_json::to_vec(&crate::line::result(&id, result)).unwrap_or_default(); + let invalid = |message: &str| error_json(&id, CODE_INVALID_PARAMS, message); + let uri = value.pointer("/params/uri").and_then(Value::as_str); + match method { + adapt::METHOD_PING => ok(json!({})), + // The `2024-11-05` session opened with its stream; its `initialize` arrives inside it. + adapt::METHOD_INITIALIZE if carriage == Carriage::EventStream => { + match discovery(plane, ticket, unit, &id) { + Some(d) => ok(adapt::initialize_result(&d, revision, false)), + None => error_json( + &id, + CODE_INTERNAL, + "this node publishes no MCP catalogue yet", + ), + } + } + adapt::METHOD_INITIALIZE => error_json( + &id, + CODE_INVALID_REQUEST, + "this session is already open: send `initialize` without Mcp-Session-Id to open \ + another", + ), + "resources/subscribe" => { + let Some(uri) = uri else { + return invalid("`params.uri` is required: the resource to watch."); + }; + if uri.len() > MAX_RESOURCE_SUB_URI_BYTES { + return invalid( + "`params.uri` is longer than a session retains: a subscription is held for \ + the session, so it is bounded.", + ); + } + let Some(held) = unit.held.clone().or_else(|| plane.current()) else { + return invalid("this node publishes no MCP catalogue yet"); + }; + ask_entitlements(plane.services, ticket, unit, &held.catalogue.grants()); + let entitled = &unit.entitled; + let admit = |kind: &str, name: &str| { + entitled + .get(&format!("{kind}:{name}")) + .copied() + .unwrap_or(false) + }; + if !matches!(held.catalogue.resource_by_uri(&admit, uri), Lookup::One(_)) { + return invalid("`params.uri` names no resource this caller may read."); + } + if subscribe(plane, session, owner, uri) { + ok(json!({})) + } else { + invalid( + "this caller already holds as many resource subscriptions as this node \ + watches for it: unsubscribe from one before subscribing to another.", + ) + } + } + "resources/unsubscribe" => { + let Some(uri) = uri else { + return invalid("`params.uri` is required: the resource to stop watching."); + }; + plane.session_state.with(&session.to_string(), |s| { + if let Some(s) = s { + s.uris.retain(|u| u != uri); + } + }); + ok(json!({})) + } + "logging/setLevel" => { + let level = value.pointer("/params/level").and_then(Value::as_str); + match level.filter(|l| LEVELS.contains(l)) { + Some(level) => { + state_of(plane, session, owner, |s| s.level = Some(level.to_string())); + ok(json!({})) + } + None => invalid( + "`params.level` must name an RFC 5424 severity: the floor this session's \ + `notifications/message` records are filtered at.", + ), + } + } + _ => error_json( + &id, + CODE_METHOD_NOT_FOUND, + &format!("Method `{method}` is not implemented by this server for this revision."), + ), + } +} + +// ── what a session holds beside its table row ─────────────────────────────────────────────────── + +/// A session's own state: its owner, the resources it watches, its log floor, and the updates +/// announced for it its stream has not collected. +#[derive(Debug, Clone, Default)] +pub(super) struct SessionState { + owner: Option, + uris: Vec, + level: Option, + updates: VecDeque<(String, String)>, +} + +/// Runs `f` on `session`'s state, made for `owner` when it has none. +fn state_of(plane: &McpDoor, session: &str, owner: &Owner, f: impl FnOnce(&mut SessionState)) { + plane.session_state.with_all(|m| { + let s = m.entry(session.to_string()).or_default(); + s.owner.get_or_insert_with(|| owner.clone()); + f(s); + }); +} + +/// Subscribes `session` to `uri`: `false` past the owner's subscription quota. +fn subscribe(plane: &McpDoor, session: &str, owner: &Owner, uri: &str) -> bool { + plane.session_state.with_all(|m| { + if m.get(session) + .is_some_and(|s| s.uris.iter().any(|u| u == uri)) + { + return true; + } + let held: usize = m + .values() + .filter(|s| s.owner.as_ref() == Some(owner)) + .map(|s| s.uris.len()) + .sum(); + if held >= MAX_OWNER_SUBSCRIPTIONS { + return false; + } + let s = m.entry(session.to_string()).or_default(); + s.owner.get_or_insert_with(|| owner.clone()); + s.uris.push(uri.to_string()); + true + }) +} + +/// AN UPSTREAM ANNOUNCED that `uri` of its registration `server` changed: every session watching +/// that uri holds it until its stream collects it, where the subscriber's entitlement is asked +/// again (Listen's relay rule: the uri resolves under its live grant to the announcing server's +/// declared resource). +pub(super) fn announce(plane: &McpDoor, server: &str, uri: &str) { + let sessions: Vec = plane.session_state.with_all(|m| { + let mut hit = Vec::new(); + for (session, s) in m.iter_mut() { + if s.uris.iter().any(|u| u == uri) { + if s.updates.len() >= MAX_PENDING_UPDATES { + s.updates.pop_front(); + } + s.updates.push_back((server.to_string(), uri.to_string())); + hit.push(session.clone()); + } + } + hit + }); + for session in sessions { + due(plane, &session); + } +} + +/// WHAT AN UPSTREAM SAID ON ITS EVENT STREAM beside a relayed call's answer: its resource updates +/// are announced to every watching session, and, for a caller in a session, its log records past +/// the session's floor are delivered on that session's stream. +pub(super) fn heard( + plane: &McpDoor, + server: &str, + raw: &[u8], + session: Option<(&str, &Owner, u64)>, +) { + for line in String::from_utf8_lossy(raw).lines() { + let Some(data) = line.strip_prefix("data:") else { + continue; + }; + let Ok(frame) = serde_json::from_str::(data.trim()) else { + continue; + }; + match frame.get("method").and_then(Value::as_str) { + Some("notifications/resources/updated") => { + if let Some(uri) = frame.pointer("/params/uri").and_then(Value::as_str) { + announce(plane, server, uri); + } + } + Some("notifications/message") => { + let Some((id, owner, now)) = session else { + continue; + }; + let floor = plane + .session_state + .get(&id.to_string()) + .and_then(|s| s.level); + let level = frame.pointer("/params/level").and_then(Value::as_str); + let rank = |l: &str| LEVELS.iter().position(|x| *x == l); + let passes = match (floor.as_deref().and_then(rank), level.and_then(rank)) { + (Some(floor), Some(level)) => level >= floor, + (Some(_), None) => false, + (None, _) => true, + }; + if passes { + deliver(plane, id, owner, &frame.to_string(), now); + } + } + _ => {} + } + } +} + +/// The session a unit's caller is in, when it is in one whose check passed. +pub(super) fn session_of(unit: &Option) -> Option { + match unit { + Some(SessionUnit::Message { + session, + checked: true, + .. + }) => Some(session.clone()), + _ => None, + } +} + +// ── the answer, lowered ───────────────────────────────────────────────────────────────────────── + +/// LOWERS the answer a session unit's dispatch wrote, before its first byte: the session +/// revision's shape (a result no session revision can carry becomes an error), every JSON-RPC +/// answer at `200`; on the `2024-11-05` stream, the answer goes on the stream and the POST is +/// accepted (`202`). +pub(super) fn lower(plane: &McpDoor, ticket: Ticket, caller: &str, unit: &mut CallUnit) { + let (line, legacy) = match &unit.session { + Some(SessionUnit::Message { + checked: true, + carriage, + session, + .. + }) => ( + false, + (*carriage == Carriage::EventStream).then(|| session.clone()), + ), + Some(SessionUnit::Line) => (true, None), + _ => return, + }; + let lowerable = unit + .pending + .as_ref() + .is_some_and(|p| !p.headed && p.request.is_none() && !p.bytes.is_empty()); + if !lowerable { + return; + } + let now = legacy + .as_ref() + .map_or(0, |_| wall_ms(plane.services, ticket, &mut unit.issued)); + let Some(pending) = unit.pending.as_mut() else { + return; + }; + let Ok(mut v) = serde_json::from_slice::(&pending.bytes) else { + return; + }; + let inexpressible = v + .get_mut("result") + .is_some_and(|result| adapt::lower_result(result).is_err()); + if inexpressible { + let id = v.get("id").cloned().unwrap_or(Value::Null); + v = busbar_contract::jsonrpc::error_body( + id, + CODE_REFUSED, + "this answer asks for input or names a task, which this session's MCP revision \ + cannot carry", + None, + ); + } + let rpc = v.get("jsonrpc").is_some() && (v.get("result").is_some() || v.get("error").is_some()); + let bytes = serde_json::to_vec(&v).unwrap_or_default(); + if line { + pending.bytes = bytes; + return; + } + match legacy { + Some(session) => { + deliver( + plane, + &session, + &owner_of(caller), + &String::from_utf8_lossy(&bytes), + now, + ); + pending.bytes.clear(); + pending.fields.clear(); + pending.status = STATUS_ACCEPTED; + } + None => { + if rpc { + pending.status = 200; + } + for (name, value) in &mut pending.fields { + if name.as_str() == CONTENT_LENGTH { + *value = bytes.len().to_string(); + } + } + pending.bytes = bytes; + } + } +} + +// ── the streams ───────────────────────────────────────────────────────────────────────────────── + +/// One held GET stream, by its unit. +#[derive(Debug, Clone)] +pub(super) struct Stream { + session: String, + owner: Owner, + /// The session table's stream the events are buffered on. + stream: u32, + /// The last event seq written to the caller. + delivered: u64, + /// The `2024-11-05` stream (no event ids). + legacy: bool, + /// The session's caller-side ticket. + ticket: Ticket, + /// The stream owes a collection. + due: bool, + /// When it last wrote (the host's monotonic clock). + last_write_ns: u64, +} + +/// Whether `unit` is a held session stream (its pieces are this module's). +pub(super) fn holds(plane: &McpDoor, unit: u64) -> bool { + plane.units.with(&unit, |u| { + u.is_some_and(|u| matches!(u.session, Some(SessionUnit::Stream { .. }))) + }) +} + +/// `data` buffered on `session`'s stream, and the stream named due. +fn deliver(plane: &McpDoor, session: &str, owner: &Owner, data: &str, now: u64) { + let target = plane + .streams + .with_all(|m| m.values().find(|s| s.session == session).map(|s| s.stream)); + if let Some(stream) = target { + slots(plane, |t| t.push(session, owner, stream, data, now)); + due(plane, session); + } +} + +/// Every stream of `session` named due, and the driver woken. +fn due(plane: &McpDoor, session: &str) { + let any = plane.streams.with_all(|m| { + let mut any = false; + for s in m.values_mut().filter(|s| s.session == session) { + s.due = true; + any = true; + } + any + }); + if any { + door_listen::wake_driver(plane); + } +} + +/// The streams owing a collection. +pub(super) fn due_streams(plane: &McpDoor) -> Vec { + plane + .streams + .with_all(|m| m.iter().filter(|(_, s)| s.due).map(|(k, _)| *k).collect()) +} + +/// The streams named to the host: no longer owing. +pub(super) fn taken(plane: &McpDoor, named: &[u64]) { + plane.streams.with_all(|m| { + for key in named { + if let Some(s) = m.get_mut(key) { + s.due = false; + } + } + }); +} + +/// THE TICK: every held stream owes a collection (its keepalive, or its end once its session is +/// gone), and the state of every session the table no longer holds is dropped. +pub(super) fn tick(plane: &McpDoor) { + let any = plane.streams.with_all(|m| { + for s in m.values_mut() { + s.due = true; + } + !m.is_empty() + }); + let gone: Vec = plane + .session_state + .with_all(|m| m.keys().cloned().collect()); + let gone: Vec = gone + .into_iter() + .filter(|id| slots(plane, |t| !t.holds(id)).unwrap_or(true)) + .collect(); + plane.session_state.with_all(|m| { + for id in &gone { + m.remove(id); + } + }); + if any { + door_listen::wake_driver(plane); + } +} + +/// The op on `ticket` was cancelled: the stream it carried is dropped. +pub(super) fn cancelled(plane: &McpDoor, ticket: Ticket) { + plane + .streams + .with_all(|m| m.retain(|_, s| s.ticket != ticket)); +} + +/// What an idle stream writes to say it is alive. +const KEEPALIVE: &[u8] = b": keepalive\n\n"; + +/// ONE PIECE OF A HELD SESSION STREAM: the caller's piece opens it, a collection writes what its +/// session buffered (or a keepalive, or its end), and the caller's side ending ends it. +pub(super) fn piece( + plane: &McpDoor, + input: Lent<'_, OnPieceIn>, + out: &mut Out<'_, OnPieceOut>, +) -> Outcome { + let given = input.get(); + let (key, ticket) = (given.unit, given.head.ticket); + let caller = input + .field(|i| &i.caller_ref) + .as_str() + .unwrap_or_default() + .to_string(); + let owner = owner_of(&caller); + let mut say_ungoverned = false; + let step = plane.units.with(&key, |unit| { + let unit = unit?; + unit.ticket = Some(ticket); + if unit.pending.is_some() { + return Some(true); + } + match given.from { + FROM_CALLER if given.flags & PIECE_LAST != 0 => { + plane.streams.remove(&key); + unit.pending = Some(door_listen::frame(Vec::new(), true, true)); + Some(true) + } + FROM_CALLER => { + let (wrote, legacy_ungoverned) = open_stream(plane, ticket, &owner, unit)?; + say_ungoverned = legacy_ungoverned; + Some(wrote) + } + FROM_KERNEL if given.attempt_no == 0 => step_stream(plane, ticket, unit), + FROM_KERNEL => Some(false), + _ => None, + } + }); + // THE UNGOVERNED CHAIN SAYS SO, once per instance, on its first `2024-11-05` stream. + if say_ungoverned && plane.said_ungoverned.insert((), ()).is_none() { + let _reported = out.diag(UNGOVERNED_LEGACY_STREAM, SEVERITY_WARN, UNGOVERNED_WORDS); + } + match step { + None => Outcome::Refused, + Some(false) => Outcome::Ready, + Some(true) => { + let written = plane.units.with(&key, |unit| { + let pending = unit?.pending.as_mut()?; + Some(write(&input, out, pending)) + }); + match written { + None | Some(Err(())) => Outcome::Failed, + Some(Ok(Written::More)) => Outcome::Ready, + Some(Ok(Written::Sent)) => { + plane.units.with(&key, |unit| { + if let Some(unit) = unit { + unit.pending = None; + } + }); + Outcome::Ready + } + Some(Ok(Written::Done)) => { + plane.units.remove(&key); + plane.streams.remove(&key); + Outcome::Ready + } + } + } + } +} + +/// The events after a stream's cursor, framed: `(bytes, the last seq)`. +fn frames(events: &[(String, String)], legacy: bool) -> (Vec, Option) { + let mut bytes = Vec::new(); + let mut last = None; + for (id, data) in events { + let id_word = (!legacy).then_some(id.as_str()); + bytes.extend_from_slice(adapt::frame(id_word, Some(adapt::EVENT_MESSAGE), data).as_bytes()); + last = id.split_once('-').and_then(|(_, q)| q.parse().ok()); + } + (bytes, last) +} + +/// OPENS the stream the unit's GET asks for: `(wrote, an ungoverned 2024-11-05 stream)`. +fn open_stream( + plane: &McpDoor, + ticket: Ticket, + owner: &Owner, + unit: &mut CallUnit, +) -> Option<(bool, bool)> { + let Some(SessionUnit::Stream { + session, + last_event_id, + mount, + }) = unit.session.clone() + else { + return None; + }; + if plane.streams.with(&unit.key, |s| s.is_some()) { + return Some((false, false)); + } + let now = wall_ms(plane.services, ticket, &mut unit.issued); + let mono = door_listen::mono_ns(plane.services, ticket, unit); + let ungoverned = owner.principal == crate::ask::UNGOVERNED; + let (session, stream, delivered, legacy, bytes) = match session { + None => { + let opened = open_session( + plane, + ticket, + unit, + owner, + Revision::R2024_11_05, + Carriage::EventStream, + now, + ); + let session = match opened { + Ok(session) => session, + Err(why) => { + let body = error_json(&Value::Null, CODE_INTERNAL, why); + unit.pending = Some(answer_of(STATUS_UNAVAILABLE, body)); + return Some((true, false)); + } + }; + let stream = slots(plane, |t| t.open_stream(&session, owner, now)).flatten()?; + let endpoint = adapt::endpoint_event(&mount, &session); + (session, stream, 0, true, endpoint.into_bytes()) + } + Some(session) => { + let carriage = slots(plane, |t| t.carriage(&session, owner, now)).flatten(); + if carriage != Some(Carriage::Endpoint) { + unit.pending = Some(not_found(&Value::Null)); + return Some((true, false)); + } + let resumed = last_event_id + .and_then(|cursor| slots(plane, |t| t.replay(&session, owner, &cursor, now))) + .flatten(); + match resumed { + Some(replay) => { + let (bytes, last) = frames(&replay.events, false); + let delivered = last.unwrap_or(0); + let bytes = if bytes.is_empty() { + KEEPALIVE.to_vec() + } else { + bytes + }; + (session, replay.stream, delivered, false, bytes) + } + None => { + let stream = slots(plane, |t| t.open_stream(&session, owner, now)).flatten()?; + (session, stream, 0, false, KEEPALIVE.to_vec()) + } + } + } + }; + // One stream per live unit, so the unit table's admission bound (it refuses past `MAX_UNITS`, + // and evicts nothing) bounds this table too. + plane.streams.insert( + unit.key, + Stream { + session, + owner: owner.clone(), + stream, + delivered, + legacy, + ticket, + due: false, + last_write_ns: mono, + }, + ); + unit.pending = Some(door_listen::frame(bytes, false, false)); + Some((true, legacy && ungoverned)) +} + +/// ONE COLLECTION of a held stream: the updates announced for its session (each re-judged under +/// the caller's live grants), then every event buffered after its cursor; a keepalive when quiet; +/// its end once its session is gone. +fn step_stream(plane: &McpDoor, ticket: Ticket, unit: &mut CallUnit) -> Option { + let held_stream = plane.streams.get(&unit.key)?; + let now = wall_ms(plane.services, ticket, &mut unit.issued); + let mono = door_listen::mono_ns(plane.services, ticket, unit); + let updates: Vec<(String, String)> = plane + .session_state + .with(&held_stream.session, |s| { + s.map(|s| s.updates.drain(..).collect::>()) + }) + .unwrap_or_default(); + if !updates.is_empty() { + if let Some(held) = plane.current().or_else(|| unit.held.clone()) { + door_listen::entitled_afresh(plane, ticket, unit, &held); + let entitled = &unit.entitled; + let admit = |kind: &str, name: &str| { + entitled + .get(&format!("{kind}:{name}")) + .copied() + .unwrap_or(false) + }; + for (server, uri) in updates { + let theirs = matches!( + held.catalogue.resource_by_uri(&admit, &uri), + Lookup::One(entry) if entry.server == server + ); + if theirs { + let note = json!({ + "jsonrpc": "2.0", + "method": "notifications/resources/updated", + "params": { "uri": uri }, + }); + slots(plane, |t| { + t.push( + &held_stream.session, + &held_stream.owner, + held_stream.stream, + ¬e.to_string(), + now, + ) + }); + } + } + } + } + let cursor = format!("{}-{}", held_stream.stream, held_stream.delivered); + let replay = slots(plane, |t| { + t.replay(&held_stream.session, &held_stream.owner, &cursor, now) + }) + .flatten(); + let Some(replay) = replay else { + // The session is gone (ended, expired, evicted): so is its stream. + plane.streams.remove(&unit.key); + unit.pending = Some(door_listen::frame(Vec::new(), true, true)); + return Some(true); + }; + let (bytes, last) = frames(&replay.events, held_stream.legacy); + let bytes = if !bytes.is_empty() { + bytes + } else if mono.saturating_sub(held_stream.last_write_ns) >= crate::subscribe::KEEPALIVE_NS { + KEEPALIVE.to_vec() + } else { + return Some(false); + }; + plane.streams.with(&unit.key, |s| { + if let Some(s) = s { + if let Some(last) = last { + s.delivered = last; + } + s.last_write_ns = mono; + } + }); + unit.pending = Some(door_listen::frame(bytes, false, true)); + Some(true) +} + +// ── busbar as a client: the negotiated upstream conversation ──────────────────────────────────── + +/// How many connector handles one hop of a negotiation may number. +pub(super) const HOP_SPAN: u32 = 1 << 9; + +/// What busbar remembers about `member`. +fn remembered(plane: &McpDoor, member: &str) -> Option { + plane.upstreams.with(&(), |t| t.and_then(|t| t.get(member))) +} + +/// One hop's answer as the connector read it: its status, its head fields (lower-case names) and +/// its body. +type Hop = (u16, Vec<(String, String)>, Vec); + +/// What a negotiation came to. +pub(super) enum Negotiated { + /// The answer to hand on: the status, the body as the upstream sent it, and whether it is an + /// event stream. + Answer(u16, Vec, bool), + /// No answer: why, in words, for the caller's upstream-failure answer. + Failed(String), +} + +/// ONE UPSTREAM CONVERSATION, NEGOTIATED ([`Negotiator`]), parked on its unit across PENDING +/// entries: the action under way, the hops sent, and what the answers so far said. +#[derive(Debug)] +pub(super) struct Negotiating { + negotiator: Negotiator, + next: Option, + hops: u32, + /// The first hop is the stateless request, not yet answered. + probing: bool, + /// The upstream answered the stateless revision before: it is not renegotiated. + stateless_known: bool, + /// The walk's answer was fed (a resumed FAR-END piece carries it again). + pub(super) fed: bool, + /// The session the walk's answer named (its head crosses with its first piece). + pub(super) walk_session: Option, + /// The last answer's body as sent, and whether it is an event stream. + raw: (Vec, bool), + /// The statuses of the hops that refused, for the words of a failure. + refused: Vec, + /// Why the last hop had no answer at all. + failure: Option, +} + +impl Negotiating { + /// A conversation carrying `original` (a stateless request) to `member`, and the request its + /// first hop is: `original` itself, or lowered into the session busbar remembers. + pub(super) fn begin(plane: &McpDoor, member: &str, original: OutboundRequest) -> Self { + let remembered = remembered(plane, member); + let stateless_known = remembered + .as_ref() + .is_some_and(|r| r.revision == Revision::R2026_07_28); + let probing = !remembered + .as_ref() + .is_some_and(|r| r.revision.has_sessions()); + let mut negotiator = Negotiator::new(original, remembered, super::VERSION); + let first = negotiator.start(); + Negotiating { + negotiator, + next: Some(first), + hops: 0, + probing, + stateless_known, + fed: false, + walk_session: None, + raw: (Vec::new(), false), + refused: Vec::new(), + failure: None, + } + } + + /// The request the first hop sends, when it is one. + pub(super) fn first_request(&self) -> Option<&OutboundRequest> { + match &self.next { + Some(Action::Send { request, .. }) => Some(request), + _ => None, + } + } + + /// Feeds one hop's answer (`None`: none at all) and answers the next action. + fn feed(&mut self, answer: Option) -> Action { + let probing = std::mem::replace(&mut self.probing, false); + let Some((status, fields, body)) = answer else { + return self.negotiator.on_answer(None); + }; + let sse = fields + .iter() + .any(|(n, v)| n.eq_ignore_ascii_case(CONTENT_TYPE) && v.starts_with(EVENT_STREAM)); + let json = if sse { + crate::call::last_sse_data(&body) + } else { + body.clone() + }; + self.raw = (body, sse); + let hop = HopAnswer { + status, + fields, + body: json, + }; + // A KNOWN STATELESS UPSTREAM, or a refusal that says nothing about the revision (the + // credential, or a rate): its answer is the stateless path's, unrenegotiated. + if probing && (self.stateless_known || matches!(status, 401 | 403 | 407 | 429)) { + return Action::Finish(hop); + } + if !(200..300).contains(&status) { + self.refused.push(status); + } + self.negotiator.on_answer(Some(hop)) + } + + /// DRIVES the conversation over the door's connector, hop by hop, from `base`: PENDING while a + /// hop pends (the action kept), READY with what it came to. What busbar learnt is kept for the + /// next call to `member`. + fn drive( + &mut self, + instance: &Instance<'_, McpDoor>, + plane: &McpDoor, + base: u32, + member: &str, + timeout_ms: u64, + ) -> Poll { + loop { + match self.next.take() { + Some(Action::Send { verb, request }) => { + let at = base.saturating_add(self.hops.saturating_mul(HOP_SPAN)); + let polled = exchange_at( + instance, + plane.host.as_ref(), + at, + &request.url, + member, + || exchange_request(verb, &request, timeout_ms), + ); + let Poll::Ready(answered) = polled else { + self.next = Some(Action::Send { verb, request }); + return Poll::Pending; + }; + self.hops += 1; + let answered = match answered { + Ok(r) => Some(( + r.status, + r.fields + .iter() + .map(|(n, v)| { + ( + String::from_utf8_lossy(n).to_ascii_lowercase(), + String::from_utf8_lossy(v).into_owned(), + ) + }) + .collect(), + r.body, + )), + Err(e) => { + self.failure = Some(e.to_string()); + None + } + }; + self.next = Some(self.feed(answered)); + } + Some(Action::Finish(answer)) => { + self.keep(plane, member); + let (raw, sse) = std::mem::take(&mut self.raw); + let raw = if raw.is_empty() { answer.body } else { raw }; + return Poll::Ready(Negotiated::Answer(answer.status, raw, sse)); + } + Some(Action::Fail(refusal)) => { + self.keep(plane, member); + return Poll::Ready(self.failed(&refusal, member)); + } + // THE LAST RUNG, NOT CARRIED: the `2024-11-05` event stream is read only as a held + // stream, which the exchange cannot hold. Said, never papered over. + Some(Action::OpenStream { .. } | Action::AwaitEvent) | None => { + self.keep(plane, member); + return Poll::Ready(Negotiated::Failed(self.legacy_only(member))); + } + } + } + } + + /// What busbar learnt, kept (or forgotten) for the next call to `member`. + fn keep(&self, plane: &McpDoor, member: &str) { + plane.upstreams.with(&(), |t| { + if let Some(t) = t { + match self.negotiator.remembered() { + Some(r) => t.put(member, r), + None => t.forget(member), + } + } + }); + } + + /// The words of an upstream that refused every revision busbar carries as a client. + fn legacy_only(&self, member: &str) -> String { + let statuses: Vec = self.refused.iter().map(u16::to_string).collect(); + format!( + "MCP server `{member}` refused the stateless {PROTOCOL_VERSION} request and refused \ + `initialize` (HTTP {}); the one revision left is 2024-11-05 (HTTP+SSE), which busbar \ + does not speak as a client", + statuses.join(", ") + ) + } + + /// A failed conversation, in words; an answer it did get is handed on as it came. + fn failed(&mut self, refusal: &Refused, member: &str) -> Negotiated { + match refusal { + Refused::Unreachable(Some(answer)) => { + let (raw, sse) = std::mem::take(&mut self.raw); + let raw = if raw.is_empty() { + answer.body.clone() + } else { + raw + }; + Negotiated::Answer(answer.status, raw, sse) + } + Refused::Unreachable(None) => Negotiated::Failed( + self.failure + .clone() + .unwrap_or_else(|| format!("MCP server `{member}` could not be reached")), + ), + Refused::OfferedUnsupported => Negotiated::Failed(format!( + "MCP server `{member}` answered `initialize` with an MCP revision busbar does not \ + carry as a client (it carries 2025-11-25 and 2025-06-18 on a session)" + )), + Refused::NoCommonRevision | Refused::AddressRefused | Refused::StreamLost => { + Negotiated::Failed(self.legacy_only(member)) + } + } + } +} + +/// One hop as the connector's exchange sends it. +fn exchange_request( + verb: Verb, + request: &OutboundRequest, + timeout_ms: u64, +) -> busbar_contract::abi::sdk::exchange::Request { + busbar_contract::abi::sdk::exchange::Request { + method: match verb { + Verb::Post => b"POST".to_vec(), + Verb::Delete => b"DELETE".to_vec(), + }, + target: crate::call::path_of(&request.url).into_bytes(), + fields: request + .headers + .iter() + .map(|(n, v)| (n.as_bytes().to_vec(), v.as_bytes().to_vec())) + .collect(), + body: request.body.clone(), + timeout_ms, + } +} + +/// A FETCH OF THE DOOR'S OWN (verify-on-call's `tools/list`), negotiated: every hop over the +/// connector from `base`, the conversation parked on the unit while one pends. +pub(super) fn fetch( + instance: &Instance<'_, McpDoor>, + plane: &McpDoor, + unit: &mut CallUnit, + (base, member, timeout_ms): (u32, &str, u64), + original: OutboundRequest, +) -> Poll> { + let negotiating = unit + .negotiating + .get_or_insert_with(|| Box::new(Negotiating::begin(plane, member, original))); + let Poll::Ready(done) = negotiating.drive(instance, plane, base, member, timeout_ms) else { + return Poll::Pending; + }; + unit.negotiating = None; + Poll::Ready(match done { + Negotiated::Answer(status, raw, sse) => { + Ok(busbar_contract::abi::sdk::exchange::ExchangeResponse { + status, + body: if sse { + crate::call::last_sse_data(&raw) + } else { + raw + }, + ..Default::default() + }) + } + Negotiated::Failed(reason) => Err(reason), + }) +} + +/// THE WALK'S ANSWER to a relayed call's first hop (`status`, event stream or not, `far`), and the +/// conversation from there: READY with what it came to, `None` when the answer is handed on as it +/// came (the stateless path, or no connector to negotiate over). +pub(super) fn far_answer( + instance: &Instance<'_, McpDoor>, + plane: &McpDoor, + negotiating: &mut Negotiating, + (base, member, timeout_ms): (u32, &str, u64), + (status, sse, far): (u32, bool, &[u8]), +) -> Poll> { + if !negotiating.fed { + negotiating.fed = true; + let mut fields = Vec::new(); + if sse { + fields.push((CONTENT_TYPE.to_string(), EVENT_STREAM.to_string())); + } + if let Some(session) = negotiating.walk_session.clone() { + fields.push((adapt::H_SESSION_ID.to_string(), session)); + } + let status = u16::try_from(status).unwrap_or(0); + let next = negotiating.feed(Some((status, fields, far.to_vec()))); + // The walk's own answer, or a refusal with no connector to negotiate over: handed on. + let lends = plane.host.is_some_and(|h| h.lends_connector()); + match next { + Action::Finish(_) => { + negotiating.keep(plane, member); + return Poll::Ready(None); + } + Action::Send { .. } if !lends => return Poll::Ready(None), + next => negotiating.next = Some(next), + } + } + negotiating + .drive(instance, plane, base, member, timeout_ms) + .map(Some) +} + +/// The stateless request a relayed call's outbound is, for its negotiation. +pub(super) fn original(url: &str, outbound: &crate::call::OutboundCall) -> OutboundRequest { + OutboundRequest { + url: url.to_string(), + headers: outbound.fields.clone(), + body: outbound.body.clone(), + } +} diff --git a/crates/busbar-plane-mcp/src/door_tasks.rs b/crates/busbar-plane-mcp/src/door_tasks.rs index f774e15d95..516fe5023b 100644 --- a/crates/busbar-plane-mcp/src/door_tasks.rs +++ b/crates/busbar-plane-mcp/src/door_tasks.rs @@ -17,7 +17,9 @@ //! `records.claim`), binds the handle (`work.resume`, principal-checked), asks its caller the //! task's own rounds (`task_ask_caller`, answered through `tasks/update`), sends the call to the //! member its walk picked, and settles the handle with the task's terminal state (`work.settle`). -//! A settle the handle refuses means `tasks/cancel` got there first. +//! A settle the handle refuses means `tasks/cancel` got there first. PARKED ON ITS CALLER (its +//! own round unanswered), it ends with the handle live; the `tasks/update` that answers the round, +//! on whichever node, nests the resume ([`Retry::Asked`]). //! * AN UPSTREAM'S ASK, RELAYED (Law 11: busbar answers nothing on the caller's behalf; ARCHITECT //! Q6): when the member answers the continuation's call with an `input_required` result, the task //! parks `input_required` with the upstream's `inputRequests` verbatim, under busbar's sealed @@ -26,10 +28,15 @@ //! key is answered the update nests a NEW continuation ([`continuation_asked`], the retry): it //! presents the state, which is opened, matched and spent once, binds the handle and sends the //! call back to the SAME member with the caller's answers and the upstream's own state. -//! * `tasks/get` is `work.find` plus what the instance holds of the task (or, when it holds none, -//! the result written in the plane's records); `tasks/update` delivers input and wakes the -//! continuation; `tasks/cancel` settles the handle `cancelled`, which the continuation observes -//! on its next step. +//! * THE TASK STORE IS HOST RECORDS (THE DESIGN, the mcp bullet): `tasks/get` is `work.find` plus +//! the task's live state, or its result, in the plane's records — the same answer on every +//! node and across a restart; `tasks/update` delivers input, writes the state it leaves, and nests +//! the run that continues; `tasks/cancel` settles the handle `cancelled`, which a running +//! continuation observes when it settles. +//! * A SETTLE THAT DOES NOT LAND is owed: its task is held unsettled and the next create's sweep +//! settles it again, until it lands. The same sweep settles the live tasks of its caller a process +//! that is gone left behind ([`tasks::TaskLease`]), so they never exhaust the bound +//! of live work (THE DESIGN: admission bounds live work; nothing evicts it). use std::task::Poll; @@ -60,12 +67,19 @@ pub(super) enum TaskUnit { Verb(Verb), } -/// The caller's answer to a relayed ask, as the retry continuation carries it: busbar's sealed state -/// and the caller's `inputResponses`. +/// What a continuation that is not the task's first run carries. #[derive(Clone)] -pub(super) struct Retry { - state: String, - responses: Value, +pub(super) enum Retry { + /// The caller's answer to a relayed ask: busbar's sealed state and the caller's + /// `inputResponses`. + Relayed { + /// Busbar's sealed state. + state: String, + /// The caller's answers. + responses: Value, + }, + /// The round of the task's own asks it was parked on, answered: the run resumes there. + Asked(usize), } impl TaskUnit { @@ -88,7 +102,6 @@ impl TaskUnit { local: false, handle: 0, round: 0, - parked: None, end: None, at: None, answered: false, @@ -99,7 +112,8 @@ impl TaskUnit { /// The creating unit's progress: the handle numbers its host calls were issued under (a call that /// pends is re-issued under its first number), the handle it opened, and whether the continuation -/// was nested. +/// was nested; this caller's index rows read, the tasks left behind it settles, and the records the +/// settles leave to strike. #[derive(Default)] pub(super) struct Create { open: Option, @@ -108,6 +122,18 @@ pub(super) struct Create { nested: bool, swept: bool, at: Option, + index: Listing, + indexed: bool, + left: Vec, + struck: Vec<(Vec, Vec)>, +} + +/// A task left behind, being settled: its reference, the handle number its find was issued under, +/// and whether it is done. +pub(super) struct Left { + reference: String, + find: Option, + done: bool, } /// A verb's progress. @@ -115,13 +141,21 @@ pub(super) struct Create { pub(super) struct Verb { find: Option, settle: Option, - list: Option, - after: Option>, - read: Vec, - listed: bool, + result: Listing, + live: Listing, at: Option, } +/// A listing of the task kind's records under a prefix, page by page: the handle number the page in +/// flight was issued under, where the next page starts, the records read, and whether it is done. +#[derive(Default)] +pub(super) struct Listing { + page: Option, + after: Option>, + rows: Vec<(Vec, Vec)>, + done: bool, +} + /// A continuation. pub(super) struct Run { reference: String, @@ -142,9 +176,8 @@ pub(super) struct Run { /// It holds the instance's own half of the one-time run. local: bool, handle: u64, - /// The task ask round it is on, and the round it parked the task on. + /// The task ask round it is on. round: usize, - parked: Option, /// The terminal state it settles, and when it reached it. end: Option, at: Option, @@ -159,7 +192,7 @@ pub(super) struct Run { enum Phase { /// Finding, claiming and binding its handle. Bind, - /// Asking its caller the task's own rounds. + /// Asking its caller the task's own rounds: parked, it ends. Ask, /// Sending the call. Call, @@ -292,19 +325,20 @@ pub(super) type RunArrival = (String, Value, Vec, Option); /// THE CONTINUATION'S ARRIVAL on the task-run claim: its reference, the call it runs, and that call /// as the `tools/call` body the one dispatch decides (its head fields mirrored from it), and — the -/// retry of a relayed ask — `relay: {requestState, inputResponses}`. `None` for a body that is not -/// one. +/// retry of a relayed ask — `relay: {requestState, inputResponses}`, or — the resume of an answered +/// round of the task's own asks — `resume: `. `None` for a body that is not one. pub(super) fn run_arrival(body: &[u8]) -> Option { let value: Value = serde_json::from_slice(body).ok()?; let reference = value.get("taskId")?.as_str()?.to_string(); let params = value.get("params")?.clone(); params.get("name")?.as_str()?; - let retry = match value.get("relay") { - None => None, - Some(relay) => Some(Retry { + let retry = match (value.get("relay"), value.get("resume")) { + (None, None) => None, + (Some(relay), _) => Some(Retry::Relayed { state: relay.get("requestState")?.as_str()?.to_string(), responses: relay.get("inputResponses")?.clone(), }), + (None, Some(round)) => Some(Retry::Asked(usize::try_from(round.as_u64()?).ok()?)), }; let call = json!({ "jsonrpc": "2.0", @@ -364,10 +398,164 @@ fn invalid(id: &Value, message: &str) -> Refusal { } } -/// Wake the continuation `task` runs on, where one waits. -fn wake(plane: &McpDoor, task: &Task) { - if let (Some(wake), Some((_, ticket))) = (plane.wake, task.runner) { - wake.wake(ticket); +/// SETTLE handle `work` with `row`, on a fresh handle of `ticket`: whether it LANDED (settled now, or +/// settled first by another unit). One that pends or fails is owed ([`owe`]): its task is held +/// unsettled and the next create's sweep settles it again, until it lands. +fn settled(services: Services, ticket: Ticket, issued: &mut u32, work: u64, row: &[u8]) -> bool { + use busbar_contract::abi::sdk::services::ServiceError; + let mut fresh = None; + let h = handle(ticket, issued, &mut fresh); + matches!( + services.work_settle(h, work, row), + Poll::Ready(Ok(()) | Err(ServiceError::Declined(AbiOutcome::Refused))) + ) +} + +/// A settle of `task` that did not land, OWED: the instance holds the task unsettled, for the next +/// create's sweep. +fn owe(plane: &McpDoor, task: &Task) { + plane.tasks.with_all(|m| { + m.entry(task.id.clone()) + .or_insert_with(|| task.clone()) + .unsettled = true; + }); +} + +/// THE TASK'S LIVE STATE AS ITS RECORDS (THE DESIGN, the mcp bullet: the task store is +/// host records): its live chunks, any this instance wrote past them struck, and — `until` given — its caller's index row, +/// its run's lease `until` (`0`: no run holds it). +fn live_records(plane: &McpDoor, task: &Task, until: Option) -> Vec<(Vec, Vec)> { + let mut out = tasks::live_parts(&task.id, &task.live()); + let wrote = u32::try_from(out.len()).unwrap_or(u32::MAX); + let before = plane.tasks.with(&task.id, |t| { + t.map_or(0, |t| std::mem::replace(&mut t.live_chunks, wrote)) + }); + out.extend((wrote..before).map(|n| (tasks::live_key(&task.id, n), Vec::new()))); + if let Some(until) = until { + let lease = tasks::TaskLease { + until_ms: until, + updated_ms: task.stamps().1, + }; + out.push((task.index_key(), lease.bytes())); + } + out +} + +/// THE RECORDS A SETTLED TASK LEAVES, struck: its live chunks and its caller's index row. +fn struck_records(task: &Task) -> Vec<(Vec, Vec)> { + let mut out: Vec<(Vec, Vec)> = (0..task.live_chunks.max(tasks::LIVE_STRIKES)) + .map(|n| (tasks::live_key(&task.id, n), Vec::new())) + .collect(); + out.push((task.index_key(), Vec::new())); + out +} + +/// `records` of the task kind ride the unit's pending write. +fn ride(unit: &mut CallUnit, records: Vec<(Vec, Vec)>) { + if let Some(p) = unit.pending.as_mut() { + p.records + .extend(records.into_iter().map(|(k, v)| (RECORD_TASK, k, v))); + } +} + +/// THE TASK KIND'S RECORDS under `prefix`, read page by page into `l.rows` in key order (one page +/// only when `once`). `Ready(false)`: the host could not list them. +fn list_records( + services: Services, + ticket: Ticket, + issued: &mut u32, + prefix: &[u8], + once: bool, + l: &mut Listing, +) -> Poll { + use busbar_contract::abi::sdk::services::ServiceError; + while !l.done { + let h = handle(ticket, issued, &mut l.page); + let mut sizes = (64 * 1024, PAGE as usize); + let mut page: Option<(usize, Option>)> = None; + for _ in 0..2 { + let mut buf = vec![0u8; sizes.0]; + let mut spans = vec![blank(); sizes.1]; + match services.records_list( + h, + crate::door::KIND_TASK, + prefix, + l.after.as_deref(), + PAGE, + (&mut buf, &mut spans), + ) { + Poll::Pending => return Poll::Pending, + Poll::Ready(Ok(records)) => { + let mut n = 0; + for (key, value) in records.records() { + l.rows.push((key.to_vec(), value.to_vec())); + n += 1; + } + page = Some((n, records.last_key().map(<[u8]>::to_vec))); + break; + } + Poll::Ready(Err(ServiceError::Short { bytes, items })) => { + sizes = ( + usize::try_from(bytes).unwrap_or(usize::MAX).max(sizes.0), + usize::try_from(items).unwrap_or(usize::MAX).max(sizes.1), + ); + } + Poll::Ready(Err(_)) => return Poll::Ready(false), + } + } + let Some((n, last)) = page else { + return Poll::Ready(false); + }; + l.page = None; + if once || n < PAGE as usize || last.is_none() { + l.done = true; + } else { + l.after = last; + } + } + Poll::Ready(true) +} + +/// A TASK LEFT BEHIND, settled `cancelled`: found (scoped to this caller), its own row cancelled and +/// settled. The records its settle leaves to strike; none while it is owed. +fn settle_left( + plane: &McpDoor, + services: Services, + ticket: Ticket, + issued: &mut u32, + principal: &str, + left: &mut Left, + now: u64, +) -> Poll, Vec)>> { + if left.done { + return Poll::Ready(Vec::new()); + } + let h = handle(ticket, issued, &mut left.find); + let (mut buf, mut spans) = ([0u8; WORK_BYTES], [blank(); 1]); + let found = match services.work_find(h, &left.reference, &mut buf, &mut spans) { + Poll::Pending => return Poll::Pending, + Poll::Ready(found) => found, + }; + left.done = true; + let mut task = match found { + Ok(Some(f)) if f.state == WORK_LIVE => match WorkRow::read(f.record) { + Some(row) => Task::from_row(&left.reference, principal, f.handle, &row, None), + None => return Poll::Ready(Vec::new()), + }, + // Settled, or past its retention: only its records are left. + Ok(_) => { + let gone = Task::new(&left.reference, principal, 0, "", now); + return Poll::Ready(struck_records(&gone)); + } + // The host could not say: the next create reads it again. + Err(_) => return Poll::Ready(Vec::new()), + }; + task.cancel(now); + if settled(services, ticket, issued, task.handle, &task.row()) { + Poll::Ready(struck_records(&task)) + } else { + owe(plane, &task); + Poll::Ready(Vec::new()) } } @@ -442,7 +630,8 @@ fn creating( }; // THE SWEEP, as the served engine ran it, on a create: abandoned tasks cancelled (their // continuations woken to see it), expired ones dropped. A task that reached its terminal state - // in hand with its handle unsettled is settled now; its answer is not waited on. + // in hand with its handle unsettled is settled now; one whose settle does not land is owed + // again, until it lands. if !st.swept { st.swept = true; let swept = plane.tasks.with_all(|m| tasks::sweep(m, now)); @@ -450,9 +639,11 @@ fn creating( swept.wake.iter().for_each(|t| wake.wake(*t)); } for (work, task) in &swept.settle { - let mut fresh = None; - let h = handle(ticket, &mut unit.issued, &mut fresh); - let _unheard = services.work_settle(h, *work, &task.row()); + if settled(services, ticket, &mut unit.issued, *work, &task.row()) { + st.struck.extend(struck_records(task)); + } else { + owe(plane, task); + } } plane.strikes.with_all(|m| { for (id, chunks) in swept.strike { @@ -460,6 +651,61 @@ fn creating( } }); } + // THE TASKS LEFT BEHIND (THE DESIGN: admission bounds live work; nothing evicts it): this + // caller's live tasks no unit here runs whose run's lease lapsed — the process running it is gone — or + // that nothing moved past the abandonment ceiling, read from the plane's records and settled + // `cancelled` before the handle is opened, so the handles an earlier process left never exhaust + // `work.open`. From a submit, never a read or a timer. + if !st.indexed { + let prefix = tasks::index_prefix(principal); + if list_records( + services, + ticket, + &mut unit.issued, + &prefix, + true, + &mut st.index, + ) + .is_pending() + { + return Step::Pending; + } + st.indexed = true; + for (key, value) in std::mem::take(&mut st.index.rows) { + let Some(id) = key + .strip_prefix(prefix.as_slice()) + .and_then(|k| std::str::from_utf8(k).ok()) + else { + continue; + }; + let left = tasks::TaskLease::read(&value).is_some_and(|l| l.left_behind(now)); + let running = plane + .tasks + .get(&id.to_string()) + .is_some_and(|t| t.runner.is_some() || t.unsettled); + if left && !running { + st.left.push(Left { + reference: id.to_string(), + find: None, + done: false, + }); + } + } + } + for left in &mut st.left { + match settle_left( + plane, + services, + ticket, + &mut unit.issued, + principal, + left, + now, + ) { + Poll::Pending => return Step::Pending, + Poll::Ready(struck) => st.struck.extend(struck), + } + } if st.opened.is_none() { let row = WorkRow { status: Status::Working, @@ -486,10 +732,14 @@ fn creating( ); st.opened = Some((opened.handle, reference)); } - Poll::Ready(Err(_)) => return unavailable( - unit, - "the deployment holds as many live tasks as it keeps, or keeps no store for them", - ), + Poll::Ready(Err(_)) => { + let step = unavailable( + unit, + "the deployment holds as many live tasks as it keeps, or keeps no store for them", + ); + ride(unit, std::mem::take(&mut st.struck)); + return step; + } } } let Some((work, reference)) = st.opened.clone() else { @@ -516,7 +766,7 @@ fn creating( .. })) => st.nested = true, // The host runs no continuation for it (no nesting, too deep, too many at once): the - // task is settled failed, not waited on, and the call refused. + // task is settled failed — owed until the settle lands — and the call refused. Poll::Ready(Err(_)) => { if let Some(mut task) = plane.tasks.remove(&reference) { task.fail( @@ -524,11 +774,13 @@ fn creating( "the host ran no continuation for this task".to_string(), now, ); - let mut fresh = None; - let h = handle(ticket, &mut unit.issued, &mut fresh); - let _unheard = services.work_settle(h, work, &task.row()); + if !settled(services, ticket, &mut unit.issued, work, &task.row()) { + owe(plane, &task); + } } - return unavailable(unit, "the host runs no continuation for it"); + let step = unavailable(unit, "the host runs no continuation for it"); + ride(unit, std::mem::take(&mut st.struck)); + return step; } } } @@ -571,7 +823,18 @@ fn creating( ts, ); pending.records.extend(strikes); + // ITS INDEX ROW, the run's lease taken: the continuation is nested at once. + let lease = tasks::TaskLease { + until_ms: now.saturating_add(tasks::RUN_LEASE_MS), + updated_ms: now, + }; + pending.records.push(( + RECORD_TASK, + tasks::index_key(principal, &reference), + lease.bytes(), + )); unit.pending = Some(pending); + ride(unit, std::mem::take(&mut st.struck)); Step::Write } @@ -595,8 +858,8 @@ pub(super) fn begin(plane: &McpDoor, ticket: Ticket, principal: &str, unit: &mut advance(plane, ticket, principal, unit) } -/// A continuation called again part way through a phase that waits on the host or its caller (a -/// pend's wake, a delivered answer, a cancel): the phase goes on. `None` for any other unit. +/// A continuation called again part way through a phase that waits on the host (a pend's wake): the +/// phase goes on. `None` for any other unit. pub(super) fn resume( plane: &McpDoor, ticket: Ticket, @@ -605,7 +868,7 @@ pub(super) fn resume( ) -> Option { match unit.task.as_ref() { Some(TaskUnit::Run(run)) - if run.begun && matches!(run.phase, Phase::Bind | Phase::Ask | Phase::Settle) => {} + if run.begun && matches!(run.phase, Phase::Bind | Phase::Settle) => {} _ => return None, } Some(advance(plane, ticket, principal, unit)) @@ -709,17 +972,24 @@ fn asking( .unwrap_or_default(); let now = task_clock_ms(services, ticket, &mut unit.issued); let params = run.params.clone(); - let gone = plane.tasks.with(&run.reference, |t| match t { + let parked = plane.tasks.with(&run.reference, |t| match t { Some(t) if !t.status().is_terminal() => { t.park_relay(&requests, state, params, now); t.runner = None; - None + Ok(t.clone()) } - Some(t) => Some(t.status()), - None => Some(Status::Cancelled), + Some(t) => Err(t.status()), + None => Err(Status::Cancelled), }); - // THE CONTINUATION ENDS with the handle live: the caller's answer runs the next one. - reply(unit, run, gone.unwrap_or(Status::InputRequired), Vec::new()) + // THE CONTINUATION ENDS with the handle live, its live state in the plane's records: the + // caller's answer, on whichever node, runs the next one. + match parked { + Ok(task) => { + let records = live_records(plane, &task, Some(0)); + reply(unit, run, Status::InputRequired, records) + } + Err(status) => reply(unit, run, status, Vec::new()), + } } /// What a task's relayed-ask state is bound to: the principal, the tool (`name`, as published) and, @@ -751,7 +1021,7 @@ pub(super) fn continuation_answered( run.far_bytes = run.far_bytes.saturating_add(far); } run.end = Some(match leg { - Leg::Done(value) => End::Completed(crate::sanitize::normalise_json(&value)), + Leg::Done(value) => End::Completed(value), Leg::Failed(reason) => End::Failed(format!("the MCP upstream call failed: {reason}")), Leg::Asked => End::Failed( serde_json::from_slice::(body) @@ -825,7 +1095,7 @@ fn relayed_leg( principal: &str, unit: &mut CallUnit, run: &Run, - retry: &Retry, + state: &str, ) -> Poll> { let held = plane.tasks.get(&run.reference); let Some(task) = held.filter(|t| t.owned_by(principal) && !t.status().is_terminal()) else { @@ -850,12 +1120,7 @@ fn relayed_leg( }; // A clock that cannot be read opens nothing: the state's window is not judged at the epoch. let opened = seal.now().map(|now| { - crate::ask::open_relayed( - &retry.state, - relay_bind(principal, name, now), - &digest, - &mut seal, - ) + crate::ask::open_relayed(state, relay_bind(principal, name, now), &digest, &mut seal) }); if seal.pending { return Poll::Pending; @@ -914,29 +1179,48 @@ fn running( // THE RUN, TAKEN ONCE: the instance's own half, then the host's one-time claim. The // retry of a relayed ask is taken by its state instead, spent once. if !run.local { - if let Some(retry) = run.retry.clone() { - match relayed_leg(plane, services, ticket, principal, unit, run, &retry) { - Poll::Pending => return Step::Pending, - Poll::Ready(Some(leg)) => run.leg = Some(leg), - Poll::Ready(None) => return not_run(unit, run), + match run.retry.clone() { + Some(Retry::Relayed { state, .. }) => { + match relayed_leg(plane, services, ticket, principal, unit, run, &state) + { + Poll::Pending => return Step::Pending, + Poll::Ready(Some(leg)) => run.leg = Some(leg), + Poll::Ready(None) => return not_run(unit, run), + } } - } else { - let took = plane.tasks.with(&run.reference, |t| match t { - Some(t) if !t.started && t.owned_by(principal) => { - t.started = true; - true + // THE RESUME of an answered round of the task's own asks: the update that + // answered it nested it once, and the host's one-time claim of that round + // takes it; it asks from that round on. + Some(Retry::Asked(round)) => { + let working = plane.tasks.get(&run.reference).is_some_and(|t| { + t.owned_by(principal) && t.status() == Status::Working + }); + if !working { + return not_run(unit, run); + } + run.round = round; + } + None => { + let took = plane.tasks.with(&run.reference, |t| match t { + Some(t) if !t.started && t.owned_by(principal) => { + t.started = true; + true + } + _ => false, + }); + if !took { + return not_run(unit, run); } - _ => false, - }); - if !took { - return not_run(unit, run); } } run.local = true; } if run.leg.is_none() { let h = handle(ticket, &mut unit.issued, &mut run.claim); - let key = format!("task-run:{}", run.reference); + let key = match run.retry { + Some(Retry::Asked(round)) => format!("task-run:{}/{round}", run.reference), + _ => format!("task-run:{}", run.reference), + }; match services.records_claim( h, crate::door::KIND_APPROVAL, @@ -986,11 +1270,10 @@ fn running( while let Some(asks) = rounds.get(run.round) { enum Then { Next, - Wait, + Park(Box), Gone(Status), } - let parked = run.parked == Some(run.round); - let runner = (unit.key, ticket); + let asked = (run.round, run.params.clone()); let then = plane.tasks.with(&run.reference, |t| { let Some(t) = t else { return Then::Gone(Status::Cancelled); @@ -998,26 +1281,24 @@ fn running( if t.status().is_terminal() { return Then::Gone(t.status()); } - if !parked { - t.park(asks.clone(), now); - } + t.park(asks.clone(), now); if t.answered() { - Then::Next - } else { - t.runner = Some(runner); - Then::Wait + return Then::Next; } + t.asked = Some(asked); + t.runner = None; + Then::Park(Box::new(t.clone())) }); match then { Then::Gone(status) => return reply(unit, run, status, Vec::new()), - Then::Wait => { - run.parked = Some(run.round); - return Step::Wait(0); - } - Then::Next => { - run.round += 1; - run.parked = None; + // PARKED ON ITS CALLER: the continuation ends with the handle live and the + // task's live state in the plane's records; the `tasks/update` that answers + // the round, on whichever node, nests the resume. + Then::Park(task) => { + let records = live_records(plane, &task, Some(0)); + return reply(unit, run, Status::InputRequired, records); } + Then::Next => run.round += 1, } } plane.tasks.with(&run.reference, |t| { @@ -1042,7 +1323,25 @@ fn running( .get(&run.reference) .map(|t| t.answers().clone()) .unwrap_or_default(); - match call(&held, unit, run, &answers) { + match call(services, ticket, &held, unit, run, &answers) { + // THE RUN'S LEASE, renewed as its call goes out: the task's live state and its + // index row ride the request. + Ok(Step::Write) => { + if let Some(task) = plane.tasks.get(&run.reference) { + let timeout = unit + .member + .as_ref() + .and_then(|m| held.section.servers.get(m)) + .map_or(0, crate::tools_config::McpServerDefCfg::timeout_ms); + let now = task_clock_ms(services, ticket, &mut unit.issued); + let until = now + .saturating_add(timeout) + .saturating_add(tasks::RUN_LEASE_MS); + let records = live_records(plane, &task, Some(until)); + ride(unit, records); + } + return Step::Write; + } Ok(step) => return step, Err(end) => { run.end = Some(end); @@ -1077,8 +1376,8 @@ fn running( Poll::Ready(Ok(())) => Some(true), // The handle was settled first: `tasks/cancel` won. Poll::Ready(Err(ServiceError::Declined(AbiOutcome::Refused))) => Some(false), - // The host could not say: the instance's word stands, and the handle is settled - // by the next create's sweep. + // The host could not say: the instance's word stands, and the settle is owed to + // the next create's sweep. Poll::Ready(Err(_)) => None, }; let reference = run.reference.clone(); @@ -1104,7 +1403,14 @@ fn running( } } }); - return reply(unit, run, status, chunks); + // A settle that landed leaves the task's live state and index row to strike. + let mut records = chunks; + if won.is_some() { + if let Some(task) = plane.tasks.get(&reference) { + records.extend(struck_records(&task)); + } + } + return reply(unit, run, status, records); } } } @@ -1113,8 +1419,11 @@ fn running( /// THE CALL, sent to the member the continuation's walk picked: the arguments as admitted with the /// task's answers merged in (`answers`), judged again by the argument guard on what is actually /// about to be dispatched (the answers were never screened), a pool's twin taken as the synchronous -/// path takes it. `Err` = the task ends here, in the engine's words. +/// path takes it. Every host the arguments name is asked of `dest.judge` on `ticket`. `Err` = the +/// task ends here, in the engine's words. fn call( + services: Services, + ticket: Ticket, held: &Held, unit: &mut CallUnit, run: &mut Run, @@ -1137,14 +1446,11 @@ fn call( .input_schema .clone() .unwrap_or_else(|| json!({ "type": "object" })); - let policy = crate::argguard::SsrfPolicy { - allow_private: held - .section - .servers - .get(&entry.server) - .is_some_and(|d| d.allow_private), - }; - if let Err(refused) = crate::argguard::guard(&schema, &arguments, policy) { + let judged = crate::argguard::guard(&schema, &arguments, |dest| { + let h = handle(ticket, &mut unit.issued, &mut None); + super::dest_verdict(services, h, held, &entry, dest) + }); + if let Err(refused) = judged { return Err(End::Failed(refused.to_string())); } let Some(member) = unit.member.clone() else { @@ -1169,8 +1475,8 @@ fn call( // THE RETRY: the caller's answers and the upstream's own state, verbatim, on the next round. let relay = run.leg.as_ref().map(|leg| { let mut continuation = Map::new(); - if let Some(retry) = &run.retry { - continuation.insert("inputResponses".into(), retry.responses.clone()); + if let Some(Retry::Relayed { responses, .. }) = &run.retry { + continuation.insert("inputResponses".into(), responses.clone()); } if let Some(state) = &leg.state { continuation.insert("requestState".into(), state.clone()); @@ -1211,27 +1517,20 @@ fn call( // ── the verbs ───────────────────────────────────────────────────────────────────────────────── -/// THE RETRY OF A RELAYED ASK, nested by the `tasks/update` that answered its last key: the task's -/// call, busbar's sealed state and the caller's answers, on the task-run claim (under the updating -/// caller's principal, the task's own). A host that runs no continuation for it fails the task. -fn retry_relayed( +/// THE RUN THAT CONTINUES A TASK, nested by the `tasks/update` that answered it (under the updating +/// caller's principal, the task's own) on the task-run claim: `body` names the task, its call and +/// what continues it. The records the update leaves: the task's live state, its run's lease taken; +/// a host that runs no continuation for it fails the task, its settle owed until it lands. +fn nest_run( plane: &McpDoor, services: Services, ticket: Ticket, unit: &mut CallUnit, task: &Task, - park: tasks::RelayPark, + body: &Value, at: u64, -) { - let body = serde_json::to_vec(&json!({ - "taskId": task.id, - "params": park.params, - "relay": { - "requestState": park.state, - "inputResponses": Value::Object(park.responses), - }, - })) - .unwrap_or_default(); +) -> Vec<(Vec, Vec)> { + let body = serde_json::to_vec(body).unwrap_or_default(); let mut fresh = None; let h = handle(ticket, &mut unit.issued, &mut fresh); let mut buf = vec![0u8; 1024]; @@ -1244,10 +1543,12 @@ fn retry_relayed( &mut buf, &mut spans, ) { - // The retry runs: its answer is its own, and nobody waits for it here. + // The run continues: its answer is its own, and nobody waits for it here. Poll::Pending | Poll::Ready(Ok(_)) - | Poll::Ready(Err(busbar_contract::abi::sdk::services::ServiceError::Short { .. })) => {} + | Poll::Ready(Err(busbar_contract::abi::sdk::services::ServiceError::Short { .. })) => { + live_records(plane, task, Some(at.saturating_add(tasks::RUN_LEASE_MS))) + } Poll::Ready(Err(_)) => { let failed = plane.tasks.with(&task.id, |t| { t.and_then(|t| { @@ -1259,10 +1560,15 @@ fn retry_relayed( .then(|| t.clone()) }) }); - if let Some(t) = failed { - let mut fresh = None; - let h = handle(ticket, &mut unit.issued, &mut fresh); - let _unheard = services.work_settle(h, t.handle, &t.row()); + match failed { + Some(t) if settled(services, ticket, &mut unit.issued, t.handle, &t.row()) => { + struck_records(&t) + } + Some(t) => { + owe(plane, &t); + Vec::new() + } + None => Vec::new(), } } } @@ -1366,33 +1672,68 @@ fn verbing( .cloned() .unwrap_or_default(); let delivered = plane.tasks.with(&task.id, |t| { - t.map(|t| (t.deliver(&responses, at), t.take_relay(), t.clone())) + t.map(|t| { + let was = t.status(); + let took = t.deliver(&responses, at); + let park = t.take_relay(); + let resume = if took + && park.is_none() + && was == Status::InputRequired + && t.status() == Status::Working + { + t.asked.take() + } else { + None + }; + (took, park, resume, t.clone()) + }) }); - return match delivered { - // THE CALLER'S ANSWER TO THE UPSTREAM'S ASK continues the task as a NEW unit: the retry, - // nested on the task-run claim, back to the member that asked. - Some((true, Some(park), t)) => { - retry_relayed(plane, services, ticket, unit, &t, park, at); - ack(unit) - } - Some((false, _, _)) => refuse( - unit, - &invalid( - id, - &format!( - "this task already holds {} answer keys, the most one task retains. \ - `inputResponses` may still update any of its existing keys, but adding a \ - new one past that ceiling is refused.", - tasks::MAX_TASK_ANSWERS + let (park, resume, t) = match delivered { + Some((false, ..)) => { + return refuse( + unit, + &invalid( + id, + &format!( + "this task already holds {} answer keys, the most one task retains. \ + `inputResponses` may still update any of its existing keys, but \ + adding a new one past that ceiling is refused.", + tasks::MAX_TASK_ANSWERS + ), ), - ), - ), - Some((true, None, t)) => { - wake(plane, &t); - ack(unit) + ) } - None => ack(unit), + Some((true, park, resume, t)) => (park, resume, t), + None => return ack(unit), + }; + let records = if t.status().is_terminal() { + // A terminal task takes no input: nothing moved. + Vec::new() + } else if let Some(park) = park { + // THE CALLER'S ANSWER TO THE UPSTREAM'S ASK continues the task as a NEW unit: the + // retry, back to the member that asked. + let body = json!({ + "taskId": t.id, + "params": park.params, + "relay": { + "requestState": park.state, + "inputResponses": Value::Object(park.responses), + }, + }); + nest_run(plane, services, ticket, unit, &t, &body, at) + } else if let Some((round, call)) = resume { + // THE ROUND OF ITS OWN ASKS, ANSWERED: the run resumes there, as a NEW unit. + let body = json!({ "taskId": t.id, "params": call, "resume": round }); + nest_run(plane, services, ticket, unit, &t, &body, at) + } else if t.status() == Status::InputRequired { + live_records(plane, &t, Some(0)) + } else { + // A run holds it: its state is written, its lease left as the run took it. + live_records(plane, &t, None) }; + let step = ack(unit); + ride(unit, records); + return step; } // `tasks/cancel`: idempotent on a settled task, and audited either way (the served engine's // `mcp_task.cancel` row on every cancel of a task the caller holds). @@ -1409,34 +1750,35 @@ fn verbing( let mut cancelled = task.clone(); cancelled.cancel(at); let h = handle(ticket, &mut unit.issued, &mut st.settle); - let settled = match services.work_settle(h, task.handle, &cancelled.row()) { + let landed = match services.work_settle(h, task.handle, &cancelled.row()) { Poll::Pending => return Step::Pending, Poll::Ready(Ok(())) => Some(true), // The continuation settled it first: its terminal state stands. Poll::Ready(Err(ServiceError::Declined(AbiOutcome::Refused))) => Some(false), + // Owed to the next create's sweep, until it lands. Poll::Ready(Err(_)) => None, }; - if settled != Some(false) { - let moved = plane.tasks.with(&task.id, |t| { - t.and_then(|t| { - t.cancel(at).then(|| { - t.unsettled = settled.is_none(); - t.clone() - }) - }) + if landed != Some(false) { + plane.tasks.with(&task.id, |t| { + if let Some(t) = t { + if t.cancel(at) { + t.unsettled = landed.is_none(); + } + } }); - if let Some(t) = moved { - wake(plane, &t); - } } - cancel_ack(unit) + let step = cancel_ack(unit); + if landed == Some(true) { + ride(unit, struck_records(&cancelled)); + } + step } -/// The task `task_id` names FOR THIS CALLER: `work.find` (scoped to the instance and the principal; -/// every denial alike), then what the instance holds of it, else — for a settled handle — its row -/// and the result written in the plane's records. A live handle the instance holds nothing of has -/// no continuation in this process: unknown, as the served engine answered every task after a -/// restart. +/// The task `task_id` names FOR THIS CALLER, from the host's rows (THE DESIGN, the mcp bullet: the +/// task store is host records): `work.find` (scoped to the instance and the principal; every denial alike); a +/// live handle's live state in the plane's records; a settled one's row and the result written +/// there (or the result this instance holds, when it holds one the records could not). The +/// instance's own halves of the task are kept beside what the host says. fn resolve( plane: &McpDoor, services: Services, @@ -1446,84 +1788,85 @@ fn resolve( task_id: &str, st: &mut Verb, ) -> Resolved { - use busbar_contract::abi::sdk::services::ServiceError; let h = handle(ticket, &mut unit.issued, &mut st.find); let (mut buf, mut spans) = ([0u8; WORK_BYTES], [blank(); 1]); - let found = match services.work_find(h, task_id, &mut buf, &mut spans) { + let (state, work, row) = match services.work_find(h, task_id, &mut buf, &mut spans) { Poll::Pending => return Resolved::Pending, - Poll::Ready(Ok(Some(found))) => found, + Poll::Ready(Ok(Some(found))) => (found.state, found.handle, WorkRow::read(found.record)), Poll::Ready(_) => return Resolved::Unknown, }; - if let Some(task) = plane.tasks.get(&task_id.to_string()) { - return if task.owned_by(principal) { - Resolved::Task(Box::new(task)) - } else { - Resolved::Unknown - }; - } - if found.state == WORK_LIVE { + let held = plane.tasks.get(&task_id.to_string()); + if held.as_ref().is_some_and(|t| !t.owned_by(principal)) { return Resolved::Unknown; } - let Some(row) = WorkRow::read(found.record) else { + if state != WORK_LIVE { + if let Some(task) = held.as_ref().filter(|t| t.status().is_terminal()) { + return Resolved::Task(Box::new(task.clone())); + } + } + // A host that lists none of the plane's records: what the instance holds answers. + let unlisted = + |held: Option| held.map_or(Resolved::Unknown, |t| Resolved::Task(Box::new(t))); + let Some(row) = row else { return Resolved::Unknown; }; - let terminal = match row.status { - Status::Completed | Status::Failed => { - // THE RESULT, read back from its chunks, page by page. - while !st.listed { + let mut task = if state == WORK_LIVE { + // ITS LIVE STATE, read back from its chunks. + let prefix = tasks::live_prefix(task_id); + match list_records( + services, + ticket, + &mut unit.issued, + &prefix, + false, + &mut st.live, + ) { + Poll::Pending => return Resolved::Pending, + Poll::Ready(false) => return unlisted(held), + Poll::Ready(true) => {} + } + let mut task = Task::from_row(task_id, principal, work, &row, None); + let bytes: Vec = st.live.rows.iter().flat_map(|(_, v)| v.clone()).collect(); + if let Some(live) = tasks::read_live(&bytes) { + task.take_live(&live); + } + task + } else { + let terminal = match row.status { + Status::Completed | Status::Failed => { + // THE RESULT, read back from its chunks. let prefix = tasks::chunk_prefix(task_id); - let h = handle(ticket, &mut unit.issued, &mut st.list); - let mut sizes = (64 * 1024, PAGE as usize); - let mut page: Option<(usize, Option>)> = None; - for _ in 0..2 { - let mut buf = vec![0u8; sizes.0]; - let mut spans = vec![blank(); sizes.1]; - match services.records_list( - h, - crate::door::KIND_TASK, - &prefix, - st.after.as_deref(), - PAGE, - (&mut buf, &mut spans), - ) { - Poll::Pending => return Resolved::Pending, - Poll::Ready(Ok(records)) => { - let mut n = 0; - for (_, value) in records.records() { - st.read.extend_from_slice(value); - n += 1; - } - page = Some((n, records.last_key().map(<[u8]>::to_vec))); - break; - } - Poll::Ready(Err(ServiceError::Short { bytes, items })) => { - sizes = ( - usize::try_from(bytes).unwrap_or(usize::MAX).max(sizes.0), - usize::try_from(items).unwrap_or(usize::MAX).max(sizes.1), - ); - } - Poll::Ready(Err(_)) => return Resolved::Unknown, - } + match list_records( + services, + ticket, + &mut unit.issued, + &prefix, + false, + &mut st.result, + ) { + Poll::Pending => return Resolved::Pending, + Poll::Ready(false) => return unlisted(held), + Poll::Ready(true) => {} } - let Some((n, last)) = page else { - return Resolved::Unknown; - }; - st.list = None; - if n < PAGE as usize || last.is_none() { - st.listed = true; - } else { - st.after = last; + match tasks::read_chunks(st.result.rows.iter().map(|(_, v)| v.as_slice())) { + Some(terminal) => Some(terminal), + None => return Resolved::Unknown, } } - match tasks::read_chunks(std::iter::once(st.read.as_slice())) { - Some(terminal) => Some(terminal), - None => return Resolved::Unknown, - } - } - _ => None, + _ => None, + }; + Task::from_row(task_id, principal, work, &row, terminal.as_ref()) }; - let task = Task::from_row(task_id, principal, found.handle, &row, terminal.as_ref()); - plane.tasks.insert(task_id.to_string(), task.clone()); + let host = task.clone(); + plane.tasks.with_all(|m| match m.get_mut(task_id) { + Some(t) => { + t.hosted(host); + task = t.clone(); + } + None => { + m.insert(task_id.to_string(), host); + } + }); Resolved::Task(Box::new(task)) } diff --git a/crates/busbar-plane-mcp/src/lib.rs b/crates/busbar-plane-mcp/src/lib.rs index df96d89696..bb97fc80da 100644 --- a/crates/busbar-plane-mcp/src/lib.rs +++ b/crates/busbar-plane-mcp/src/lib.rs @@ -61,14 +61,12 @@ pub mod identity; pub mod jsonrpc; /// The line carrier's meanings: one JSON-RPC message per line, each its own unit. pub mod line; -pub mod outputschema; pub mod tool_arrival; pub mod tool_claims; pub mod tool_door; pub mod tool_facts; pub mod tool_meta; pub mod tool_ops; -pub mod tool_plane; /// The `transport: stdio` servers: one long-lived child per server, its messages correlated by id. pub mod tool_program; pub mod tool_scope; @@ -112,57 +110,19 @@ pub mod trust; /// called" start to differ. pub const PLANE_KEY: &str = "mcp"; -use busbar_contract::ids::LaneId; use busbar_contract::plugin::{AbiVersion, Kind, Plugin}; -/// One registered server this plane may name. -/// -/// Every string is borrowed for the life of the program, because a plane's declarations are read at -/// registration and sealed. Configured names reach here through the seam that says so: -/// [`busbar_contract::ids::Registration`]. The composition root builds one at boot, interns every -/// config-derived key through it exactly once, and hands over names that outlive it — so nothing -/// after registration can vary them, and the memory the names occupy is a fixed term rather than -/// one that grows with traffic. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct Server { - /// The name the operator gave this server, and the resource the scope unit judges. - pub id: &'static str, - /// The priced lane this server is reached on. - pub lane: LaneId, - /// The host to dial, or the empty string for a server this node launches itself. - pub host: &'static str, - /// Which of the three transports the hop is made over. - pub transport: &'static str, -} - /// The MCP plane. /// -/// The one field is a borrowed, immutable list. There is no cell here, no lock and no atomic: the -/// purity test asserts that by walking the type, not by trusting this sentence. +/// It carries no field: it is the type the plane's declarations (`PlaneMeta`) and its identity +/// (`Plugin`) hang on, and the served door reads those. There is no cell here, no lock and no +/// atomic: the purity test asserts that by walking the source, not by trusting this sentence. #[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct McpPlane { - servers: &'static [Server], -} +pub struct McpPlane; impl McpPlane { - /// A plane with a registered server set. - #[must_use] - pub const fn new(servers: &'static [Server]) -> Self { - Self { servers } - } - - /// A plane with nothing registered. - /// - /// It answers every question the loop asks, and its answer to "where does this go" is a - /// destination the trust unit refuses. That is the honest answer for a plane with no server — - /// not a panic, and not a fabricated host. - pub const EMPTY: Self = Self::new(&[]); - - /// The registered servers, in declaration order. - #[must_use] - pub const fn servers(&self) -> &'static [Server] { - self.servers - } + /// The plane. Nothing varies per instance, so there is one value of it. + pub const EMPTY: Self = Self; } impl Default for McpPlane { diff --git a/crates/busbar-plane-mcp/src/line.rs b/crates/busbar-plane-mcp/src/line.rs index bd9a0d8360..42706c605a 100644 --- a/crates/busbar-plane-mcp/src/line.rs +++ b/crates/busbar-plane-mcp/src/line.rs @@ -29,6 +29,7 @@ use serde_json::{json, Map, Value}; use crate::codec::PROTOCOL_VERSION; +use crate::revision::Revision; /// The prefix of the id busbar spells its own requests on the line in. pub const ASK_ID_PREFIX: &str = "busbar:"; @@ -59,24 +60,16 @@ fn unsupported(id: &Value, message: &str) -> Value { }) } -/// The `initialize` answer: the dual-era negotiation the revision scopes to stdio. busbar -/// implements ONE revision and says so; no session is created because the revision has none. +/// The `initialize` answer to a client that names no session revision: the stateless revision, +/// which needs no handshake and says so. #[must_use] pub fn initialize_result(id: &Value) -> Value { result( id, json!({ "protocolVersion": PROTOCOL_VERSION, - "capabilities": { - "tools": { "listChanged": true }, - "prompts": { "listChanged": true }, - "resources": { "listChanged": true }, - "completions": {}, - }, - "serverInfo": { - "name": "busbar", - "version": crate::tool_door::VERSION, - }, + "capabilities": capabilities(), + "serverInfo": server_info(), "instructions": format!( "This server speaks MCP revision {PROTOCOL_VERSION}: no handshake is required, \ and every request states its protocol version and client capabilities in \ @@ -86,11 +79,57 @@ pub fn initialize_result(id: &Value) -> Value { ) } +/// The `initialize` answer to a client that asked for a SESSION revision this plane carries: that +/// revision, by negotiation (THE DESIGN section 2, the mcp bullet; [`crate::revision::accept_offered`]). +/// The carrier session then speaks it: each following line is raised into the stateless shape and its +/// answer lowered ([`crate::adapt`]). +#[must_use] +pub fn session_initialize_result(id: &Value, revision: Revision) -> Value { + result( + id, + json!({ + "protocolVersion": revision.wire(), + "capabilities": capabilities(), + "serverInfo": server_info(), + }), + ) +} + +/// The capabilities the line carrier declares, in every revision it speaks: no `subscribe` and no +/// `logging`, which the carrier refuses (`-32601`, [`era`]). +fn capabilities() -> Value { + json!({ + "tools": { "listChanged": true }, + "prompts": { "listChanged": true }, + "resources": { "listChanged": true }, + "completions": {}, + }) +} + +/// The server's name and version. +fn server_info() -> Value { + json!({ + "name": "busbar", + "version": crate::tool_door::VERSION, + }) +} + +/// The session revision an `initialize` asks for, when this plane carries it. +fn requested_revision(value: &Value) -> Option { + value + .pointer("/params/protocolVersion") + .and_then(Value::as_str) + .and_then(crate::revision::accept_offered) +} + /// What a stdio-era verb came to. #[derive(Debug, Clone, PartialEq)] pub enum Era { /// Answered here, whole. Answer(Value), + /// `initialize` asking for a session revision: the revision the carrier session now speaks, + /// and the answer. + Opened(Revision, Value), /// Not a stdio-era verb: the one dispatch's. Dispatch, } @@ -106,7 +145,10 @@ pub fn era(value: &Value) -> Era { return Era::Dispatch; }; match method { - crate::adapt::METHOD_INITIALIZE => Era::Answer(initialize_result(id)), + crate::adapt::METHOD_INITIALIZE => match requested_revision(value) { + Some(revision) => Era::Opened(revision, session_initialize_result(id, revision)), + None => Era::Answer(initialize_result(id)), + }, crate::adapt::METHOD_PING => Era::Answer(result(id, json!({}))), "logging/setLevel" => Era::Answer(unsupported( id, diff --git a/crates/busbar-plane-mcp/src/outputschema.rs b/crates/busbar-plane-mcp/src/outputschema.rs deleted file mode 100644 index d70d340d18..0000000000 --- a/crates/busbar-plane-mcp/src/outputschema.rs +++ /dev/null @@ -1,246 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// Copyright (C) 2026 Busbar Inc and contributors - -//! KEEPING THE PROMISE `outputSchema` MAKES. -//! -//! ## The obligation, and why it is busbar's rather than the upstream's -//! -//! `tools/list` may publish an `outputSchema` for a tool, and the specification turns the mere -//! PRESENCE of that key into a MUST: *"If an output schema is provided: Servers MUST provide -//! structured results that conform to this schema."* The server that published the schema is the -//! server that is held to it, and on this plane that server is busbar — a caller never speaks to the -//! upstream and has no way to attribute a violation to it. -//! -//! busbar publishes the OPERATOR's schema (`tools..tools_allow..output_schema`), for -//! the same reason it publishes the operator's description and the operator's input schema: an -//! upstream that could write the schema could rewrite the promise busbar is held to, narrowing it -//! after a client cached it or widening it to legalise whatever it returned today. -//! -//! But busbar does not COMPUTE the structured result; the upstream does. So publishing without -//! checking would put busbar in violation of that MUST every time the upstream lied, with busbar's -//! name on the answer — which is exactly the shape the battery's own `outputschema-lie` hostile peer -//! exists to plant. This module is the check. -//! -//! ## DELIBERATELY A SUBSET, AND DELIBERATELY ONE-SIDED -//! -//! A wrong answer here has two very different costs. A missed violation lets a lie through, which is -//! the status quo this module improves on. A FALSE violation turns a working tool call into a -//! failure for a caller who did nothing wrong — a self-inflicted outage on the request path. The two -//! are not symmetric, so this validator is arranged to be silent whenever it is not certain: -//! -//! - **Every keyword it does not model is IGNORED**, never treated as unsatisfied. `allOf`, -//! `anyOf`, `oneOf`, `not`, `if`/`then`/`else`, `patternProperties`, `format`, `pattern`, -//! `dependentRequired` and the rest are not evaluated, so a document that only those keywords -//! would reject is passed. The honest consequence is stated rather than softened: this catches the -//! violations a plain type/required/enum schema describes, which is what real MCP output schemas -//! overwhelmingly are, and it does not claim to be a complete JSON Schema implementation. -//! - **`$ref` IS NEVER DEREFERENCED**, local or remote. The remote case is forbidden outright -//! (`Implementations MUST NOT automatically dereference $ref values that resolve to a network -//! URI`), and resolving only the local case would make the behaviour depend on where an author -//! happened to put a definition. A subschema behind a `$ref` is therefore UNCHECKED, not failed. -//! - **A schema that is not an object is not a schema busbar can read**, and is ignored rather than -//! treated as rejecting everything. -//! -//! What it DOES model is type (including the `integer`/`number` relationship and type unions), -//! `required`, `properties`, `additionalProperties: false`, `items`, `enum` and `const` — walked -//! recursively under a depth bound, because a schema and a value that recurse into each other must -//! not be able to exhaust the stack on the request path. - -use serde_json::Value; - -/// How deep the paired schema/value walk goes before it stops. Exceeding it STOPS CHECKING rather -/// than reporting a violation, for this module's one-sided rule: a bound that fires is a statement -/// about this walker, never about the document. -const MAX_DEPTH: usize = 32; - -/// How many violations are collected before the walk stops. The first few are what an operator -/// reads; an unbounded list is a way for an upstream to make busbar build a large string. -const MAX_ERRORS: usize = 8; - -/// CHECK a structured result against a published output schema. -/// -/// `Ok(())` means "no violation this validator can see" — which, given the one-sided rule above, is -/// weaker than "conforms" and is documented as such at the call site. `Err` carries a short, -/// operator-readable list of the violations found. -pub fn check(value: &Value, schema: &Value) -> Result<(), String> { - let mut errors = Vec::new(); - walk(value, schema, "$", 0, &mut errors); - if errors.is_empty() { - Ok(()) - } else { - Err(errors.join("; ")) - } -} - -fn walk(v: &Value, s: &Value, path: &str, depth: usize, errors: &mut Vec) { - if depth > MAX_DEPTH || errors.len() >= MAX_ERRORS { - return; - } - let Some(obj) = s.as_object() else { return }; // not a schema busbar can read: unchecked - if obj.contains_key("$ref") { - return; // never dereferenced — see the module header - } - - // `const` and `enum` are exact-value constraints, and a mismatch is unambiguous. - if let Some(c) = obj.get("const") { - if !json_eq(c, v) { - errors.push(format!("{path}: expected the constant {c}, got {v}")); - return; - } - } - if let Some(Value::Array(allowed)) = obj.get("enum") { - if !allowed.iter().any(|a| json_eq(a, v)) { - errors.push(format!( - "{path}: {v} is not one of the declared enum values" - )); - return; - } - } - - if let Some(declared) = obj.get("type") { - if !type_matches(v, declared) { - errors.push(format!( - "{path}: expected type {declared}, got {}", - actual_type(v) - )); - return; // a value of the wrong type cannot usefully be walked against this schema - } - } - - match v { - Value::Object(map) => { - for name in obj - .get("required") - .and_then(Value::as_array) - .into_iter() - .flatten() - .filter_map(Value::as_str) - { - if !map.contains_key(name) { - errors.push(format!("{path}: missing required property `{name}`")); - if errors.len() >= MAX_ERRORS { - return; - } - } - } - let props = obj.get("properties").and_then(Value::as_object); - // `additionalProperties: false` and NOTHING ELSE from that keyword: the schema form is - // also legal, and a schema there would be an evaluation this module does not model, so - // it is ignored rather than guessed at. - if obj.get("additionalProperties") == Some(&Value::Bool(false)) { - for key in map.keys() { - if !props.is_some_and(|p| p.contains_key(key)) { - errors.push(format!("{path}: property `{key}` is not permitted")); - if errors.len() >= MAX_ERRORS { - return; - } - } - } - } - if let Some(props) = props { - for (key, sub) in props { - if let Some(child) = map.get(key) { - walk(child, sub, &format!("{path}.{key}"), depth + 1, errors); - } - } - } - } - Value::Array(items) => { - // The single-schema form only. The tuple form (`items` as an array) is a different - // evaluation and is not modelled, so it is left unchecked rather than misapplied. - if let Some(item_schema) = obj.get("items").filter(|i| i.is_object()) { - for (i, item) in items.iter().enumerate() { - walk( - item, - item_schema, - &format!("{path}[{i}]"), - depth + 1, - errors, - ); - if errors.len() >= MAX_ERRORS { - return; - } - } - } - } - _ => {} - } -} - -/// ARE THESE THE SAME JSON VALUE? `PartialEq` on `Value` compares a number's REPRESENTATION, so it -/// answers no for `1` against `1.0` — but JSON has one numeric type and those are one number, which -/// is the same fact [`type_matches`] already acts on for `integer`. Left as a representation compare, -/// an exact-value keyword would report a violation against a conforming upstream purely because its -/// serialiser emitted a trailing `.0`, and a FALSE violation is the one answer this module must not -/// give. Numbers are therefore compared by value and everything else structurally — recursing so the -/// rule reaches a number nested inside an array or object constant too. -fn json_eq(a: &Value, b: &Value) -> bool { - match (a, b) { - (Value::Number(x), Value::Number(y)) => match (x.as_f64(), y.as_f64()) { - (Some(x), Some(y)) => x == y, - // A number too large to reach `f64` is compared as written: there is no value-level - // answer available, and guessing one would be the false violation again. - _ => x == y, - }, - (Value::Array(x), Value::Array(y)) => { - x.len() == y.len() && x.iter().zip(y).all(|(x, y)| json_eq(x, y)) - } - (Value::Object(x), Value::Object(y)) => { - x.len() == y.len() - && x.iter() - .all(|(k, xv)| y.get(k).is_some_and(|yv| json_eq(xv, yv))) - } - _ => a == b, - } -} - -/// Does `v` satisfy a `type` keyword? Handles the union form, and the one relationship JSON Schema -/// defines between two of its type names: every `integer` is a `number`, and a `number` with no -/// fractional part IS an `integer` (JSON has one numeric type, so `1.0` and `1` are the same value). -fn type_matches(v: &Value, declared: &Value) -> bool { - match declared { - Value::String(name) => one_type_matches(v, name), - Value::Array(names) => names - .iter() - .filter_map(Value::as_str) - .any(|n| one_type_matches(v, n)), - // A `type` that is neither a string nor an array of strings is not something this validator - // reads, so it constrains nothing here. - _ => true, - } -} - -fn one_type_matches(v: &Value, name: &str) -> bool { - match name { - "object" => v.is_object(), - "array" => v.is_array(), - "string" => v.is_string(), - "boolean" => v.is_boolean(), - "null" => v.is_null(), - "number" => v.is_number(), - "integer" => v.as_f64().is_some_and(|f| f.fract() == 0.0), - // An unknown type name is not a constraint this validator can apply. - _ => true, - } -} - -fn actual_type(v: &Value) -> &'static str { - match v { - Value::Null => "null", - Value::Bool(_) => "boolean", - Value::Number(n) => { - if n.as_f64().is_some_and(|f| f.fract() == 0.0) { - "integer" - } else { - "number" - } - } - Value::String(_) => "string", - Value::Array(_) => "array", - Value::Object(_) => "object", - } -} - -#[cfg(test)] -#[path = "tests/outputschema_tests.rs"] -mod outputschema_tests; diff --git a/crates/busbar-plane-mcp/src/sanitize.rs b/crates/busbar-plane-mcp/src/sanitize.rs index bed4eb64bb..622b92afb8 100644 --- a/crates/busbar-plane-mcp/src/sanitize.rs +++ b/crates/busbar-plane-mcp/src/sanitize.rs @@ -5,14 +5,14 @@ //! //! ## What this does //! -//! It strips instruction-injection MARKUP — ``, ``, HTML-like tags — from three -//! places. All three are named explicitly, and the third is the one that gets forgotten: it is easy -//! to reach for the two surfaces a caller READS and to miss the ones an upstream SERVES back, even -//! though they carry the same text by the same route: +//! It strips instruction-injection MARKUP — ``, ``, HTML-like tags — from the +//! text busbar composes and serves itself: //! -//! - tool and prompt DESCRIPTIONS, before they are shown or fed as context; -//! - tool OUTPUTS, before results re-enter model context; -//! - `resources/read` CONTENT and `prompts` TEMPLATES, which are equally injectable. +//! - tool, prompt and resource DESCRIPTIONS and names in the catalogue; +//! - `resources/read` CONTENT and `prompts` TEMPLATES answered from the section. +//! +//! An upstream's tool RESULT and its error message are not among them: they are the upstream's +//! data and reach the caller as the upstream sent them (Law 11). //! //! ## What this does NOT do, stated here rather than in a footnote //! @@ -118,28 +118,6 @@ pub fn normalise_opt(input: Option<&str>) -> Option { input.map(normalise) } -/// Recursively normalise every STRING VALUE in a JSON document, leaving keys, numbers, booleans and -/// structure untouched. -/// -/// Tool OUTPUT is arbitrary JSON, and the injection rides in its string leaves. Keys are left alone -/// deliberately: a key is a schema element the caller's own code indexes by, and rewriting one turns -/// a sanitiser into a data-corruption bug — whereas a key containing markup is not a channel into a -/// model's instruction stream in the way a value is. -pub fn normalise_json(value: &serde_json::Value) -> serde_json::Value { - match value { - serde_json::Value::String(s) => serde_json::Value::String(normalise(s)), - serde_json::Value::Array(items) => { - serde_json::Value::Array(items.iter().map(normalise_json).collect()) - } - serde_json::Value::Object(map) => serde_json::Value::Object( - map.iter() - .map(|(k, v)| (k.clone(), normalise_json(v))) - .collect(), - ), - other => other.clone(), - } -} - /// Remove the tag that the just-completed deletion may have reconstituted across the seam between /// the emitted text and `bytes[i..]`, and keep removing while each removal makes another. /// diff --git a/crates/busbar-plane-mcp/src/subscribe.rs b/crates/busbar-plane-mcp/src/subscribe.rs index c45ac0159e..e7272dbf7e 100644 --- a/crates/busbar-plane-mcp/src/subscribe.rs +++ b/crates/busbar-plane-mcp/src/subscribe.rs @@ -18,6 +18,8 @@ //! `SubscriptionsListenResult`, correlated to the request that opened it. //! - QUIET IS NOT NOTHING: an idle stream says it is alive every [`KEEPALIVE_NS`]. +use std::collections::BTreeSet; + use serde_json::{json, Map, Value}; use crate::catalogue::{Catalogue, Lookup}; @@ -97,13 +99,19 @@ impl Filter { let resources = match n.get("resourceSubscriptions") { None => None, Some(Value::Array(a)) => { - let mut uris = Vec::with_capacity(a.len()); + // Deduplicated through a set, and read no further than the first uri past the + // ceiling: the request is refused for it, so the rest of a long array costs nothing. + let mut seen = BTreeSet::new(); + let mut uris = Vec::new(); for u in a { let Some(u) = u.as_str() else { return Filter::default(); }; - if !uris.iter().any(|seen: &String| seen == u) { + if seen.insert(u) { uris.push(u.to_string()); + if uris.len() > MAX_SUBSCRIBED_URIS { + break; + } } } Some(uris) diff --git a/crates/busbar-plane-mcp/src/tests/adapt_tests.rs b/crates/busbar-plane-mcp/src/tests/adapt_tests.rs index 1505f27447..4c812a9152 100644 --- a/crates/busbar-plane-mcp/src/tests/adapt_tests.rs +++ b/crates/busbar-plane-mcp/src/tests/adapt_tests.rs @@ -70,6 +70,17 @@ fn red_no_stateless_only_method_reaches_a_session_client() { SessionMethod::Accept ); assert_eq!(session_method(None, true), SessionMethod::Accept); + for m in [ + "resources/subscribe", + "resources/unsubscribe", + "logging/setLevel", + ] { + assert_eq!( + session_method(Some(m), true), + SessionMethod::Session, + "{m}: subscribe stays for the old revisions" + ); + } } #[test] @@ -123,8 +134,15 @@ fn red_initialize_declares_no_stateless_only_capability() { let caps = r["capabilities"].as_object().unwrap(); let mut keys: Vec<&str> = caps.keys().map(String::as_str).collect(); keys.sort_unstable(); - assert_eq!(keys, ["completions", "prompts", "resources", "tools"]); - assert_eq!(caps["resources"], json!({"listChanged": false})); + assert_eq!( + keys, + ["completions", "logging", "prompts", "resources", "tools"] + ); + assert_eq!( + caps["resources"], + json!({"listChanged": false, "subscribe": true}), + "subscribe stays for the old revisions" + ); assert!(r.get("methods").is_none()); let old = initialize_result(&discovery, Revision::R2024_11_05, true); diff --git a/crates/busbar-plane-mcp/src/tests/argguard_tests.rs b/crates/busbar-plane-mcp/src/tests/argguard_tests.rs index 35097051d0..efc643e517 100644 --- a/crates/busbar-plane-mcp/src/tests/argguard_tests.rs +++ b/crates/busbar-plane-mcp/src/tests/argguard_tests.rs @@ -9,17 +9,46 @@ //! refusal test it is given, so every test here that asserts a refusal also asserts the walk //! reached the field, and one test pins the visited counts exactly. -use super::{guard, ArgRefusal, ArgWhy, SsrfPolicy}; +use super::{guard, ArgRefusal, ArgWhy}; +use busbar_contract::abi::host::service::{ + DEST_ALLOWED, DEST_INTERNAL, DEST_METADATA, DEST_OBFUSCATED, +}; +use busbar_contract::net::{ + dns_name_is_internal, host_ip, host_is_cloud_metadata, ip_is_internal, + is_alternate_ipv4_encoding, +}; use serde_json::{json, Value}; -fn public() -> SsrfPolicy { - SsrfPolicy::default() +/// A STAND-IN HOST'S `dest.judge`, over the contract's address predicates: what the walk is judged +/// by in these tests. `refuse_private` is `DEST_REFUSE_PRIVATE` (a registration with no private +/// reach). The deployment's real judge answering a served call is proven in the root crate +/// (`root::tests::door_steps`). +fn judge(refuse_private: bool) -> impl FnMut(&str) -> Option { + move |dest: &str| { + let host = dest + .strip_prefix('[') + .and_then(|h| h.strip_suffix(']')) + .unwrap_or(dest); + Some(if host_is_cloud_metadata(host) { + DEST_METADATA + } else if is_alternate_ipv4_encoding(host) { + DEST_OBFUSCATED + } else if refuse_private + && (dns_name_is_internal(host) || host_ip(host).is_some_and(|ip| ip_is_internal(&ip))) + { + DEST_INTERNAL + } else { + DEST_ALLOWED + }) + } } -fn private_ok() -> SsrfPolicy { - SsrfPolicy { - allow_private: true, - } +fn public() -> impl FnMut(&str) -> Option { + judge(true) +} + +fn private_ok() -> impl FnMut(&str) -> Option { + judge(false) } /// A schema whose `uri` field sits two levels down inside nested objects — the shape a real diff --git a/crates/busbar-plane-mcp/src/tests/call.rs b/crates/busbar-plane-mcp/src/tests/call.rs index b51f573d87..a1fa96f3b0 100644 --- a/crates/busbar-plane-mcp/src/tests/call.rs +++ b/crates/busbar-plane-mcp/src/tests/call.rs @@ -178,7 +178,6 @@ fn the_kernels_trust_verdict_is_rendered_and_never_judged_here() { asked.push((entry.server.clone(), entry.tool.clone())); trust.clone() }, - &|_| false, &mut proceed, ); assert_eq!( @@ -220,7 +219,6 @@ fn the_verdict_is_asked_only_after_the_grants() { &no_header, &|_, _| false, &mut |_| panic!("the kernel is asked about an ungranted call"), - &|_| false, &mut proceed, )); assert_eq!(r.status, 404); @@ -346,6 +344,83 @@ fn an_answer_binds_only_what_was_asked_and_never_a_sent_argument() { )); } +/// `fs_read_file` admitted with `arguments`, then judged by `judge` ([`judge_arguments`]). +fn judged(arguments: Value, judge: &mut dyn FnMut(&ToolEntry, &str) -> Option) -> Admission { + let p = params("fs_read_file", arguments); + let header = |name: &str| (name == "mcp-param-path").then(|| "/a".to_string()); + let admission = admit_call( + &catalogue(), + &json!(7), + Some(&p), + &header, + &everyone, + &mut proceed, + ); + judge_arguments(admission, judge) +} + +/// THE ARGUMENT GUARD ASKS THE HOST (BUSBAR-1.6.0.md Appendix C B.3 item 11, `dest.judge`): every +/// host the arguments name is put to the judge for the called tool, an IPv6 literal bracketed as +/// `dest.judge` reads it, and the host's verdict alone decides. A host the plane holds no rule +/// about is refused when the judge refuses it, in the guard's bytes; one the judge admits is sent. +#[test] +fn the_hosts_verdict_decides_an_argument_url() { + let arguments = json!({ "path": "/a", "fetch": "http://public.example/x", "peer": "https://[2001:db8::1]:8443/" }); + let mut asked = Vec::new(); + let a = go(judged(arguments.clone(), &mut |entry, dest| { + asked.push((entry.tool.clone(), dest.to_string())); + Some(busbar_contract::abi::host::service::DEST_ALLOWED) + })); + assert_eq!( + a.arguments, arguments, + "admitted, the arguments are sent as they came" + ); + asked.sort(); + assert_eq!( + asked, + [ + ("read_file".to_string(), "[2001:db8::1]".to_string()), + ("read_file".to_string(), "public.example".to_string()), + ], + "each host is asked of the host, once, for the called tool" + ); + + let (r, line) = refused(judged(arguments, &mut |_, dest| { + Some(if dest == "public.example" { + busbar_contract::abi::host::service::DEST_METADATA + } else { + busbar_contract::abi::host::service::DEST_ALLOWED + }) + })); + assert_eq!((r.status, r.code), (403, crate::codec::CODE_REFUSED)); + assert_eq!(r.data, Some(json!({ "reason": "tool_argument_refused" }))); + assert!( + r.message.starts_with("tool argument /fetch carries a URL"), + "{}", + r.message + ); + let line = line.expect("logged"); + assert_eq!( + (line.outcome, line.reason.as_str()), + ("refused", "tool_argument_refused") + ); +} + +/// A host that gives no verdict (it serves no `dest.judge`, or the judgement failed) refuses the +/// value rather than admitting it unjudged; arguments naming no host never ask it. +#[test] +fn a_host_that_gives_no_verdict_refuses_the_argument() { + let (r, _) = refused(judged( + json!({ "path": "/a", "fetch": "https://public.example/" }), + &mut |_, _| None, + )); + assert_eq!(r.status, 403); + assert!(r.message.contains("could not be checked"), "{}", r.message); + go(judged(json!({ "path": "/a" }), &mut |_, dest| { + panic!("no host is named, yet `{dest}` was asked") + })); +} + fn admitted() -> AdmittedCall { let right = |name: &str| (name == "mcp-param-path").then(|| "/a".to_string()); go(admit_call( @@ -432,26 +507,93 @@ fn settle_far(far: &str) -> Settled { ) } +/// A result with no `resultType` gets the dialect's `complete` ahead of the upstream's own members, +/// which follow as they came; an empty result is the stamp alone. #[test] -fn a_result_is_normalised_stamped_and_dispatched() { - let (status, body, line) = answer_of(settle_far( +fn a_result_is_stamped_and_dispatched() { + let settled = settle_far( r#"{"jsonrpc":"2.0","id":0,"result":{"content":[],"structuredContent":{"n":1}}}"#, - )); + ); + let Settled::Answer { body: bytes, .. } = &settled else { + panic!("an answer: {settled:?}") + }; + assert_eq!( + String::from_utf8_lossy(bytes), + r#"{"id":7,"jsonrpc":"2.0","result":{"resultType":"complete","content":[],"structuredContent":{"n":1}}}"# + ); + let (status, body, line) = answer_of(settled); assert_eq!(status, 200); assert_eq!(body["id"], json!(7)); assert_eq!(body["result"]["resultType"], json!("complete")); assert_eq!((line.outcome, line.reason.as_str()), ("dispatched", "")); assert_eq!(line.audit, Some(AuditRow::tool("fs_read_file", true))); + let (_, empty, _) = answer_of(settle_far(r#"{"jsonrpc":"2.0","id":0,"result":{ }}"#)); + assert_eq!(empty["result"], json!({ "resultType": "complete" })); } +/// The far end's answer carrying `result` as the upstream wrote it. +fn far_result(result: &str) -> String { + format!(r#"{{"jsonrpc":"2.0","id":0,"result":{result}}}"#) +} + +/// LAW 11 (THE DESIGN 2126, 2131-2132; product hard rule 3369-3373): a tool's result is the +/// upstream's data and reaches the caller as the upstream sent it. `Vec` and `x` in +/// the content text, in `structuredContent` and in `_meta` are the tool's answer, not markup busbar +/// may strip, and the result's bytes are the upstream's own, member order included. +/// RED arm: a built-in strip served `Vec` for `Vec` and `x` for `x`. #[test] -fn structured_output_breaking_the_published_schema_is_a_tool_failure() { - let (status, body, line) = answer_of(settle_far( - r#"{"jsonrpc":"2.0","id":0,"result":{"content":[],"structuredContent":{}}}"#, - )); +fn a_result_reaches_its_caller_as_the_upstream_sent_it() { + let sent = r#"{"resultType":"complete","structuredContent":{"t":"Vec","n":1,"h":"x"},"content":[{"type":"text","text":"fn f() -> Vec { x }"}],"_meta":{"m":"y"}}"#; + let settled = settle_far(&far_result(sent)); + let Settled::Answer { body: bytes, .. } = &settled else { + panic!("an answer: {settled:?}") + }; + assert!( + bytes.windows(sent.len()).any(|w| w == sent.as_bytes()), + "the upstream's result bytes, verbatim: {}", + String::from_utf8_lossy(bytes) + ); + let (status, body, line) = answer_of(settled); + assert_eq!(status, 200); + assert_eq!(body["id"], json!(7)); + assert_eq!( + body["result"], + serde_json::from_str::(sent).expect("json"), + "the upstream's result, unchanged" + ); + assert_eq!((line.outcome, line.reason.as_str()), ("dispatched", "")); +} + +/// LAW 11 (THE DESIGN 2131-2132): a `structuredContent` that does not match the tool's published +/// `outputSchema` is still the upstream's answer. It is relayed unchanged; busbar never answers in +/// the upstream's place. +/// RED arm: busbar replaced it with its own `isError` result, "The structured result was NOT +/// served". +#[test] +fn structured_output_breaking_the_published_schema_is_relayed_unchanged() { + let sent = r#"{"resultType":"complete","content":[],"structuredContent":{}}"#; + let (status, body, line) = answer_of(settle_far(&far_result(sent))); assert_eq!(status, 200); - assert_eq!(body["result"]["isError"], json!(true)); - assert_eq!(line.reason, "upstream_failed"); + assert_eq!( + body["result"], + serde_json::from_str::(sent).expect("json"), + "the upstream's result, as it came" + ); + assert_eq!((line.outcome, line.reason.as_str()), ("dispatched", "")); + assert_eq!(line.audit, Some(AuditRow::tool("fs_read_file", true))); +} + +/// THE TASK PATH SETTLES THE SAME RESULT (Law 11): a task's completed result is the upstream's, +/// stored and served as it came. A source plant, because the task's continuation is reached only +/// through a live door: no rewrite of the result is named on that path. +/// RED arm: `door_tasks.rs` ran the markup strip over every completed task result. +#[test] +fn the_task_path_names_no_rewrite_of_the_result() { + let source = include_str!("../door_tasks.rs"); + assert!( + !source.contains("sanitize::"), + "a task's completed result passes through no markup strip" + ); } #[test] @@ -476,6 +618,22 @@ fn an_upstream_error_is_a_tool_failure_naming_the_server() { ); } +/// LAW 11 (THE DESIGN 2126, 2131-2132; product hard rule 3369-3373): the upstream's JSON-RPC +/// error message is the upstream's data and reaches the caller unchanged inside busbar's failure +/// words; `Vec` and `x` are not markup busbar may strip. +/// RED arm: the failure text ran through the markup strip, serving `Vec` and `x`. +#[test] +fn an_upstream_error_message_reaches_its_caller_unchanged() { + let (_, body, _) = answer_of(settle_far( + r#"{"jsonrpc":"2.0","id":0,"error":{"code":-32602,"message":"want Vec, got x"}}"#, + )); + let text = body["result"]["content"][0]["text"].as_str().expect("text"); + assert_eq!( + text, + "The MCP server `fs` did not complete this tool call: MCP upstream answered JSON-RPC error -32602: want Vec, got x" + ); +} + #[test] fn an_answer_to_something_else_is_never_served() { let (_, body, _) = answer_of(settle_far(r#"{"jsonrpc":"2.0","id":5,"result":{}}"#)); diff --git a/crates/busbar-plane-mcp/src/tests/line_tests.rs b/crates/busbar-plane-mcp/src/tests/line_tests.rs index 8cffac6592..5d083b47ee 100644 --- a/crates/busbar-plane-mcp/src/tests/line_tests.rs +++ b/crates/busbar-plane-mcp/src/tests/line_tests.rs @@ -134,3 +134,21 @@ fn a_live_ask_lapses_at_its_timeout_and_a_request_is_livened_a_bounded_number_of assert!(may_liven(MAX_LIVE_ASK_ROUNDS - 1)); assert!(!may_liven(MAX_LIVE_ASK_ROUNDS)); } + +/// `initialize` naming a session revision this plane carries is answered in it (THE DESIGN section 2, +/// the mcp bullet: revision by negotiation), and the carrier still declares neither `subscribe` nor +/// `logging` in it; naming none, or the stateless one, keeps the stateless answer. +#[test] +fn initialize_negotiates_a_session_revision() { + let init = json!({ "jsonrpc": "2.0", "id": 1, "method": "initialize", "params": { "protocolVersion": "2025-06-18" } }); + let Era::Opened(revision, answer) = era(&init) else { + panic!("a session revision is opened"); + }; + assert_eq!(revision, crate::revision::Revision::R2025_06_18); + assert_eq!(answer["result"]["protocolVersion"], "2025-06-18"); + let caps = &answer["result"]["capabilities"]; + assert!(caps["resources"].get("subscribe").is_none()); + assert!(caps.get("logging").is_none()); + let stateless = json!({ "jsonrpc": "2.0", "id": 2, "method": "initialize", "params": { "protocolVersion": "2026-07-28" } }); + assert_eq!(era(&stateless), Era::Answer(initialize_result(&json!(2)))); +} diff --git a/crates/busbar-plane-mcp/src/tests/outputschema_tests.rs b/crates/busbar-plane-mcp/src/tests/outputschema_tests.rs deleted file mode 100644 index cf173f5d51..0000000000 --- a/crates/busbar-plane-mcp/src/tests/outputschema_tests.rs +++ /dev/null @@ -1,227 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// Copyright (C) 2026 Busbar Inc and contributors - -//! Tests for [`crate::outputschema`] — the check that keeps the promise `outputSchema` makes. -//! -//! The suite is arranged around this module's ONE-SIDED rule, because that rule is the whole of its -//! safety argument: a missed violation lets a lie through, but a FALSE violation turns a working -//! tool call into a failure for a caller who did nothing wrong. So for every keyword the validator -//! DOES model there is a test that it catches a violation, and for the keywords it does NOT model -//! there is a test that it stays silent rather than guessing. - -use crate::outputschema::{check, MAX_ERRORS}; -use serde_json::{json, Value}; - -#[test] -fn a_conforming_object_passes() { - let schema = json!({ - "type": "object", - "properties": { "count": { "type": "integer" }, "label": { "type": "string" } }, - "required": ["count"], - }); - assert!(check(&json!({ "count": 3, "label": "x" }), &schema).is_ok()); -} - -#[test] -fn a_wrong_type_is_a_violation() { - let schema = json!({ "type": "object", "properties": { "count": { "type": "integer" } } }); - let e = check(&json!({ "count": "not-an-integer" }), &schema).unwrap_err(); - assert!(e.contains("$.count"), "{e}"); - assert!(e.contains("expected type"), "{e}"); -} - -#[test] -fn a_missing_required_property_is_a_violation() { - let schema = json!({ "type": "object", "required": ["count"] }); - let e = check(&json!({}), &schema).unwrap_err(); - assert!(e.contains("missing required property `count`"), "{e}"); -} - -#[test] -fn the_hostile_peers_lie_is_caught() { - // Byte-for-byte the battery's `outputschema-lie` mode: it declares `{count: integer}` with - // `count` required, and returns `{count: "not-an-integer", extra: true}`. - let schema = json!({ - "type": "object", - "properties": { "count": { "type": "integer" } }, - "required": ["count"], - }); - assert!(check( - &json!({ "count": "not-an-integer", "extra": true }), - &schema - ) - .is_err()); -} - -#[test] -fn an_integral_float_satisfies_integer() { - // JSON has one numeric type: `1.0` and `1` are the same value, and refusing the former - // would fail a conforming upstream over its serialiser's formatting. - let schema = json!({ "type": "object", "properties": { "n": { "type": "integer" } } }); - assert!(check(&json!({ "n": 1.0 }), &schema).is_ok()); -} - -#[test] -fn a_ref_is_never_dereferenced_and_never_fails() { - // THE ONE-SIDED RULE. A subschema behind a `$ref` is UNCHECKED, not violated — dereferencing - // it is a MUST NOT for the network case and a guess for the local one. - let schema = json!({ - "type": "object", - "properties": { "a": { "$ref": "https://example.invalid/s.json" } }, - "$defs": { "x": { "type": "integer" } }, - }); - assert!(check(&json!({ "a": "anything at all" }), &schema).is_ok()); -} - -#[test] -fn unmodelled_keywords_never_manufacture_a_violation() { - // A document that ONLY `allOf`/`if`/`pattern` would reject must pass, because this module - // does not evaluate them and a false violation is a self-inflicted outage. - let schema = json!({ - "type": "object", - "properties": { "s": { "type": "string", "pattern": "^\\d+$", "minLength": 40 } }, - "allOf": [{ "required": ["nope"] }], - "if": { "required": ["s"] }, - "then": { "required": ["also-nope"] }, - }); - assert!(check(&json!({ "s": "abc" }), &schema).is_ok()); -} - -#[test] -fn additional_properties_false_is_enforced_and_the_schema_form_is_not() { - let closed = json!({ - "type": "object", - "properties": { "a": { "type": "string" } }, - "additionalProperties": false, - }); - assert!(check(&json!({ "a": "x", "b": 1 }), &closed).is_err()); - // The SCHEMA form of the same keyword is an evaluation this module does not model, so it - // must not be read as `false`. - let schema_form = json!({ - "type": "object", - "properties": { "a": { "type": "string" } }, - "additionalProperties": { "type": "integer" }, - }); - assert!(check(&json!({ "a": "x", "b": "also a string" }), &schema_form).is_ok()); -} - -#[test] -fn arrays_are_walked_and_the_tuple_form_is_not() { - let schema = json!({ "type": "array", "items": { "type": "integer" } }); - assert!(check(&json!([1, 2, 3]), &schema).is_ok()); - assert!(check(&json!([1, "two"]), &schema).is_err()); - // The tuple form is a different evaluation: unchecked, never misapplied. - let tuple = json!({ "type": "array", "items": [{ "type": "integer" }] }); - assert!(check(&json!(["not an integer"]), &tuple).is_ok()); -} - -#[test] -fn enum_and_const_are_exact() { - let e = json!({ "type": "object", "properties": { "k": { "enum": ["a", "b"] } } }); - assert!(check(&json!({ "k": "a" }), &e).is_ok()); - assert!(check(&json!({ "k": "c" }), &e).is_err()); - let c = json!({ "type": "object", "properties": { "k": { "const": 7 } } }); - assert!(check(&json!({ "k": 7 }), &c).is_ok()); - assert!(check(&json!({ "k": 8 }), &c).is_err()); -} - -/// JSON HAS ONE NUMERIC TYPE, and `const`/`enum` must read it the way `type` already does. `1` and -/// `1.0` are the same number, so a schema that pins one and an upstream that serialised the other -/// agree — and reporting that as a violation would fail a conforming tool over its serialiser's -/// formatting, which is exactly the false violation this module promises never to produce. -#[test] -fn an_integral_float_and_an_integer_are_the_same_constant() { - let c = json!({ "type": "object", "properties": { "k": { "const": 1.0 } } }); - assert!(check(&json!({ "k": 1 }), &c).is_ok()); - let c = json!({ "type": "object", "properties": { "k": { "const": 1 } } }); - assert!(check(&json!({ "k": 1.0 }), &c).is_ok()); - let e = json!({ "type": "object", "properties": { "k": { "enum": [1.0, 2.0] } } }); - assert!(check(&json!({ "k": 1 }), &e).is_ok()); - assert!(check(&json!({ "k": 2 }), &e).is_ok()); - // And a number that is genuinely a different number is still a violation. - assert!(check(&json!({ "k": 3 }), &e).is_err()); -} - -#[test] -fn a_self_referential_value_cannot_exhaust_the_stack() { - // The depth bound STOPS CHECKING; it never manufactures a violation. - let mut v = json!(1); - let mut s = json!({ "type": "integer" }); - for _ in 0..200 { - v = json!([v]); - s = json!({ "type": "array", "items": s }); - } - assert!(check(&v, &s).is_ok()); -} - -/// THE VIOLATION LIST IS BOUNDED, and the bound is a defence rather than a tidiness rule: without -/// it an upstream returning a thousand unexpected properties makes busbar build a thousand-clause -/// string on the request path and hand it to an operator who reads the first few. -#[test] -fn the_violation_list_stops_at_the_cap() { - let closed = json!({ - "type": "object", - "properties": { "a": { "type": "string" } }, - "additionalProperties": false, - }); - let mut value = serde_json::Map::new(); - value.insert("a".to_string(), json!("x")); - for i in 0..100 { - value.insert(format!("extra{i}"), json!(i)); - } - let e = check(&Value::Object(value), &closed).unwrap_err(); - assert_eq!( - e.split("; ").count(), - MAX_ERRORS, - "a hundred unexpected properties must report the cap's worth and stop: {e}" - ); -} - -/// THE TYPE UNION IS A DISJUNCTION. `["string", "null"]` is how a schema says "a string, or nothing" -/// — the commonest optional-field shape there is — and reading it as a conjunction would reject -/// every value, which is the false violation this module must never produce. -#[test] -fn a_type_union_accepts_any_of_its_members() { - let schema = json!({ - "type": "object", - "properties": { "note": { "type": ["string", "null"] } }, - }); - assert!(check(&json!({ "note": "x" }), &schema).is_ok()); - assert!(check(&json!({ "note": null }), &schema).is_ok()); - let e = check(&json!({ "note": 1 }), &schema).unwrap_err(); - assert!(e.contains("$.note"), "{e}"); - assert!(e.contains("expected type"), "{e}"); -} - -/// THE DEPTH BOUND IS A STATEMENT ABOUT THIS WALKER, NEVER ABOUT THE DOCUMENT — so it must fire -/// nowhere near ordinary nesting, and where it does fire it must go SILENT rather than report. -/// A schema and a value nested ten deep are entirely ordinary and are still checked; the same pair -/// nested a hundred deep is past what this walker will follow, and it says nothing at all. -#[test] -fn a_violation_inside_the_depth_bound_is_reported_and_one_beyond_it_is_silent() { - /// A value that is `"not an integer"` under `n` nested arrays, and the matching `n`-deep schema - /// that declares the innermost item an integer. The pair violates at exactly depth `n`. - fn nested(n: usize) -> (Value, Value) { - let mut v = json!("not an integer"); - let mut s = json!({ "type": "integer" }); - for _ in 0..n { - v = json!([v]); - s = json!({ "type": "array", "items": s }); - } - (v, s) - } - let (v, s) = nested(10); - let e = check(&v, &s).unwrap_err(); - assert!(e.contains("expected type"), "{e}"); - let (v, s) = nested(100); - assert!( - check(&v, &s).is_ok(), - "past the bound the walk STOPS; it never manufactures a violation" - ); -} - -#[test] -fn a_non_object_schema_constrains_nothing() { - assert!(check(&json!({ "anything": true }), &json!(true)).is_ok()); - assert!(check(&json!(1), &json!("not a schema")).is_ok()); -} diff --git a/crates/busbar-plane-mcp/src/tests/plane.rs b/crates/busbar-plane-mcp/src/tests/plane.rs deleted file mode 100644 index bc86bd6133..0000000000 --- a/crates/busbar-plane-mcp/src/tests/plane.rs +++ /dev/null @@ -1,295 +0,0 @@ -//! Tests for `plane.rs`. Lifted out of the implementation file so its line count -//! measures implementation and nothing else; still a direct child module, so `use -//! super::*` reaches the private items it always did. - -use super::{finish_of, member_of, refusal_words, sampling_destination, Codec}; -use busbar_contract::dest::DestinationFacts; -use busbar_contract::unit::{AbortBy, FailureReason, RefusalReason, Step, UnitEnd}; - -/// The nested destination a sampling request reaches is the declared operation CLASS, and it is -/// the SAME value on both steps -- `verify` seals it and `route` dials it out of one expression, so -/// a change to the constant moves both or neither. It names a class and no plane (#47/#49): which -/// plane answers is the host's resolution over what registered planes declare they serve. -#[test] -fn verify_and_route_reach_one_declared_sampling_destination() { - assert_eq!( - sampling_destination(), - DestinationFacts::NestedPlane { - op: crate::tool_meta::SAMPLING_OP, - } - ); - let DestinationFacts::NestedPlane { op } = sampling_destination() else { - panic!("a sampling request is answered by another plane, not by anything else"); - }; - assert_eq!(op.as_str(), "chat"); -} - -/// Every closed refusal reason has an answer, and every answer is a code this plane may write. -/// -/// Totality is the point: a reason with no row would be a caller who is told nothing, and the -/// contract's reason list is closed precisely so this can be checked rather than hoped for. -/// EVERY reason the kernel closes a unit for, so a new variant cannot be added without deciding what -/// this dialect answers it with. Exhaustive against `busbar_contract::unit::RefusalReason` (43 -/// variants); `refusal_words`'s own match is `_`-free, so the two lists are kept in step on purpose. -const ALL_REFUSAL_REASONS: [RefusalReason; 43] = [ - RefusalReason::InFlightCap, - RefusalReason::CursorBudget, - RefusalReason::CredentialBudget, - RefusalReason::SessionBudget, - RefusalReason::BodyTooLarge, - RefusalReason::OpenSlotBusy, - RefusalReason::SchemeNotDeclared, - RefusalReason::CredentialRejected, - RefusalReason::SessionUnbound, - RefusalReason::Revoked, - RefusalReason::ScopeMissing, - RefusalReason::Vetoed, - RefusalReason::NoDestination, - RefusalReason::OverBudget, - RefusalReason::GroupFrozen, - RefusalReason::Unpriced, - RefusalReason::OverdraftCeiling, - RefusalReason::StaleSlice, - RefusalReason::DurabilityUnavailable, - RefusalReason::TierMismatch, - RefusalReason::SpillBudget, - RefusalReason::ScratchExhausted, - RefusalReason::RateLimited, - RefusalReason::DecodeFailed, - RefusalReason::ChallengeExhausted, - RefusalReason::PoolNotPermitted, - RefusalReason::NoRate, - RefusalReason::Replayed, - RefusalReason::InFlight, - RefusalReason::DestinationBudgetExhausted, - RefusalReason::BreakerOpen, - RefusalReason::DestinationUnreachable, - RefusalReason::MeterDisputed, - RefusalReason::HandoffMismatch, - RefusalReason::PlanePanic, - RefusalReason::TaskLost, - RefusalReason::Stalled, - RefusalReason::SecretPlaceholder, - RefusalReason::Drain, - RefusalReason::Superseded, - RefusalReason::ClientGone, - RefusalReason::DeadlineExceeded, - RefusalReason::Untrusted, -]; - -#[test] -fn every_refusal_reason_has_an_answer() { - for reason in ALL_REFUSAL_REASONS { - let (code, message) = refusal_words(reason); - assert!( - crate::jsonrpc::CODES.contains(&code), - "{reason:?} renders unknown code {code}" - ); - assert!( - !crate::jsonrpc::RETIRED_CODES.contains(&code), - "{reason:?} renders the retired code {code}" - ); - assert!(!message.is_empty(), "{reason:?} renders no words"); - } -} - -/// An operational refusal — a rate limit, an open breaker, a drain, a spent budget — is NOT a node -/// fault. Before the exhaustive mapping every reason but a hand-picked nine fell through a `_` arm to -/// `CODE_INTERNAL`, so a throttled client was told this node had broken. These must reach the caller -/// as a real refusal (a policy `CODE_REFUSED`, or `CODE_UPSTREAM_UNAVAILABLE` for a shut route), -/// never as internal. -#[test] -fn no_operational_refusal_is_answered_as_an_internal_fault() { - for reason in [ - RefusalReason::RateLimited, - RefusalReason::BreakerOpen, - RefusalReason::Drain, - RefusalReason::OverBudget, - RefusalReason::GroupFrozen, - RefusalReason::PoolNotPermitted, - RefusalReason::Replayed, - RefusalReason::DestinationBudgetExhausted, - RefusalReason::DestinationUnreachable, - RefusalReason::OverdraftCeiling, - RefusalReason::Superseded, - RefusalReason::DeadlineExceeded, - ] { - let (code, _) = refusal_words(reason); - assert_ne!( - code, - crate::jsonrpc::CODE_INTERNAL, - "{reason:?} reaches the caller as an internal fault" - ); - } -} - -/// A refusal tells the caller nothing about the money. -#[test] -fn a_refusal_leaks_nothing_about_the_money() { - for reason in [ - RefusalReason::OverBudget, - RefusalReason::GroupFrozen, - RefusalReason::Unpriced, - RefusalReason::OverdraftCeiling, - RefusalReason::StaleSlice, - ] { - let (_, message) = refusal_words(reason); - for leak in ["budget", "bucket", "frozen", "price", "slice", "overdraft"] { - assert!( - !message.to_ascii_lowercase().contains(leak), - "{reason:?} leaks {leak}" - ); - } - } -} - -/// A held stream ends a turn when it completes; a single answer is complete. -#[test] -fn a_held_stream_ends_a_turn() { - assert_eq!( - finish_of(&UnitEnd::Completed, true), - busbar_contract::unit::FinishClass::TurnComplete - ); - assert_eq!( - finish_of(&UnitEnd::Completed, false), - busbar_contract::unit::FinishClass::Complete - ); -} - -/// Who ended it decides how it ended. -#[test] -fn who_ended_it_decides_how_it_ended() { - assert_eq!( - finish_of(&UnitEnd::Aborted(AbortBy::Client), false), - busbar_contract::unit::FinishClass::Partial - ); - assert_eq!( - finish_of( - &UnitEnd::Failed { - step: Step::Route, - reason: FailureReason::Transport - }, - false - ), - busbar_contract::unit::FinishClass::Error - ); -} - -/// A metadata member whose name carries separators is read by name, not by pointer. -/// -/// This is the case a pointer cannot express: the key itself contains the character a pointer -/// uses to mean "one level down", so a pointer naming it would read it as three levels. -#[test] -fn a_member_whose_name_carries_separators_is_read() { - let block = br#"{"io.modelcontextprotocol/protocolVersion":"2026-07-28","other":1}"#; - assert_eq!( - member_of(block, "io.modelcontextprotocol/protocolVersion"), - Some("2026-07-28") - ); - assert_eq!(member_of(block, "io.modelcontextprotocol/clientInfo"), None); -} - -/// A member of a nested object is not a member of the block. -/// -/// The scan used to be for the quoted name anywhere in the block's bytes, so the first thing -/// that LOOKED like the member won — a nested object's own key, or the name written inside -/// somebody else's string value. Either one hands a later step a value the caller never put at -/// that name, and the progress token in particular is a correlation. -#[test] -fn a_nested_or_quoted_decoy_is_not_read_as_the_member() { - let nested = br#"{"inner":{"progressToken":"decoy"},"progressToken":"real"}"#; - assert_eq!(member_of(nested, "progressToken"), Some("real")); - - let quoted = - br#"{"note":"the \"progressToken\":\"decoy\" is only prose","progressToken":"real"}"#; - assert_eq!(member_of(quoted, "progressToken"), Some("real")); - - let only_nested = br#"{"inner":{"progressToken":"decoy"}}"#; - assert_eq!(member_of(only_nested, "progressToken"), None); - - let suffix = br#"{"notTheProgressToken":"decoy"}"#; - assert_eq!(member_of(suffix, "progressToken"), None); -} - -/// A member that is present and is not a string reads as absent. -#[test] -fn a_member_that_is_not_a_string_reads_as_absent() { - let block = br#"{"progressToken":42}"#; - assert_eq!(member_of(block, "progressToken"), None); -} - -/// A member spelled twice reads as the LAST one, which is what the server will read. -/// -/// Every other reading of a body here goes through the span grammar, and the span grammar takes -/// the last occurrence because serde_json and the servers' own parsers do. This walk is the one -/// place a member is read WITHOUT the grammar — the block's keys carry separators a pointer -/// would read as levels — so it owes the same answer. Taking the first lets the client attribute -/// a fact to a value the server never sees: the progress token is a correlation, and a -/// correlation read off the losing duplicate answers a different request than the one that asked. -#[test] -fn a_member_spelled_twice_reads_as_the_last_one() { - let block = br#"{"progressToken":"decoy","progressToken":"real"}"#; - assert_eq!(member_of(block, "progressToken"), Some("real")); - - let three = br#"{"progressToken":"a","progressToken":"b","progressToken":"c"}"#; - assert_eq!(member_of(three, "progressToken"), Some("c")); - - // The last one deciding also means a last one that is not a string reads as absent, however - // many string-valued spellings came before it. - let shadowed = br#"{"progressToken":"real","progressToken":42}"#; - assert_eq!(member_of(shadowed, "progressToken"), None); - - // And a non-string first spelling does not blind the walk to the string that follows it. - let recovered = br#"{"progressToken":42,"progressToken":"real"}"#; - assert_eq!(member_of(recovered, "progressToken"), Some("real")); - - // The same for the separator-carrying name the block actually uses. - let version = br#"{"io.modelcontextprotocol/protocolVersion":"old","io.modelcontextprotocol/protocolVersion":"new"}"#; - assert_eq!( - member_of(version, "io.modelcontextprotocol/protocolVersion"), - Some("new") - ); -} - -/// The codec state starts at nothing and counts up on both axes. -#[test] -fn the_codec_state_counts() { - let mut codec = Codec::default(); - assert_eq!((codec.events_read, codec.rounds_asked), (0, 0)); - codec.events_read = codec.events_read.saturating_add(1); - codec.rounds_asked = codec.rounds_asked.saturating_add(1); - assert_eq!((codec.events_read, codec.rounds_asked), (1, 1)); -} - -/// P-ITEM: REFUSAL-REASON COLLAPSE (spec DONE item 2, "All P-item behaviours match 1.5.5"; drive -/// log P2, commit 470351a480; TODO L-ENG9). 1.5.5's one surface gave every limit reason its own -/// status and kind and answered none of them as an internal error (v1.5.5 -/// `crates/busbar/src/ingress/mod.rs:237-305`). On this plane: a reason renders as the internal -/// code exactly when its class is a node fault, and every reason of one class renders the same -/// answer, so the reason-to-family decision is the one classification's -/// (`busbar_contract::abi::plane::RefusalCode::class`) and never this plane's own. -#[test] -fn p_item_refusal_reason_collapse_only_a_node_fault_is_internal_and_one_class_one_answer() { - use busbar_contract::abi::plane::{reason_of, RefusalClass, RefusalCode}; - let mut answers: Vec<(RefusalClass, (i64, &'static str))> = Vec::new(); - for code in RefusalCode::ALL { - // Two codes are the kernel's own money verdicts and never reach a plane (`reason_of`). - let Some(reason) = reason_of(code.code()) else { - continue; - }; - let class = code.class(); - let answer = refusal_words(busbar_contract::unit::RefusalReason::from(reason)); - assert_eq!( - answer.0 == crate::jsonrpc::CODE_INTERNAL, - class.is_node_fault(), - "{code:?} (class {class:?}) renders {answer:?}" - ); - match answers.iter().find(|(c, _)| *c == class) { - Some((_, first)) => assert_eq!( - *first, answer, - "{code:?} answers differently from the rest of {class:?}" - ), - None => answers.push((class, answer)), - } - } -} diff --git a/crates/busbar-plane-mcp/src/tests/sanitize_tests.rs b/crates/busbar-plane-mcp/src/tests/sanitize_tests.rs index 2ce1d25ec4..8a22ebe3ec 100644 --- a/crates/busbar-plane-mcp/src/tests/sanitize_tests.rs +++ b/crates/busbar-plane-mcp/src/tests/sanitize_tests.rs @@ -14,7 +14,7 @@ //! of the feature, and a module whose tests only ever demonstrate its strengths lets a caveat rot //! into a claim. -use super::{normalise, normalise_json, normalise_opt}; +use super::{normalise, normalise_opt}; /// The exact markup CVE-2025-54136-class tool poisoning uses. Every one of these must leave, and the /// INNER TEXT must stay: the text is what a human reviewer reads at approval time, and deleting it @@ -110,37 +110,16 @@ fn an_unterminated_tag_keeps_the_tail_verbatim() { ); } -/// The three injectable SITES — tool descriptions, prompt templates, and `resources/read` content — -/// all reduce to this one function, so the optional and JSON wrappers must behave identically to the -/// scalar one: a wrapper that forgot to call through would leave one of the three sites unsanitised -/// while the other two passed. +/// Every catalogue field reaches the strip through [`normalise_opt`], so the optional wrapper must +/// behave exactly as the scalar one: a wrapper that forgot to call through would leave every +/// catalogue site unstripped while the scalar tests passed. #[test] -fn every_wrapper_normalises_through_the_same_function() { +fn the_optional_wrapper_normalises_through_the_same_function() { assert_eq!( normalise_opt(Some("x")), Some("x".to_string()) ); assert_eq!(normalise_opt(None), None); - - let doc = serde_json::json!({ - "text": "call transfer_fundsok", - "nested": { "deep": ["a", 7, true, null] }, - // A KEY containing markup is deliberately left alone: a key is a schema element the caller's - // own code indexes by, and rewriting one turns a sanitiser into a data-corruption bug. - "": "value", - }); - let out = normalise_json(&doc); - assert_eq!(out.pointer("/text").unwrap(), "call transfer_fundsok"); - assert_eq!(out.pointer("/nested/deep/0").unwrap(), "a"); - assert_eq!( - out.pointer("/nested/deep/1").unwrap(), - &serde_json::json!(7) - ); - assert_eq!( - out.pointer("/nested/deep/2").unwrap(), - &serde_json::json!(true) - ); - assert!(out.as_object().unwrap().contains_key("")); } /// THE HONEST SCOPE, asserted rather than written down: markup-stripping does not stop diff --git a/crates/busbar-plane-mcp/src/tests/session_tests.rs b/crates/busbar-plane-mcp/src/tests/session_tests.rs index 2af527eb70..f30a9a0563 100644 --- a/crates/busbar-plane-mcp/src/tests/session_tests.rs +++ b/crates/busbar-plane-mcp/src/tests/session_tests.rs @@ -346,3 +346,22 @@ fn red_an_owners_byte_pressure_never_costs_another_owner_a_stream() { assert!(r.complete, "none of bob's events was trimmed for alice"); assert_eq!(r.events[0].0, first); } + +/// Finding 18: a push whose trim frees more than the pushed event cost (one older large event +/// gives way to a small one) charges the owner the difference back, never a negative amount. +#[test] +fn red_a_trim_that_frees_more_than_the_push_cost_gives_the_difference_back() { + let mut t = small(10, 100, 1 << 30); + let id = open(&mut t, 1, "p", 0); + let s = t.open_stream(&id, &owner("p"), 0).unwrap(); + let base = t.owner_bytes(&owner("p")); + t.push(&id, &owner("p"), s, &"a".repeat(60), 0).unwrap(); + assert_eq!(t.owner_bytes(&owner("p")), base + 60 + EVENT_OVERHEAD); + t.push(&id, &owner("p"), s, &"b".repeat(10), 0).unwrap(); + assert_eq!( + t.owner_bytes(&owner("p")), + base + 10 + EVENT_OVERHEAD, + "the large event was trimmed and only the small one is charged" + ); + assert_eq!(t.total_bytes(), t.owner_bytes(&owner("p"))); +} diff --git a/crates/busbar-plane-mcp/src/tests/subscribe_tests.rs b/crates/busbar-plane-mcp/src/tests/subscribe_tests.rs index b45e6fe301..2038e1a620 100644 --- a/crates/busbar-plane-mcp/src/tests/subscribe_tests.rs +++ b/crates/busbar-plane-mcp/src/tests/subscribe_tests.rs @@ -218,3 +218,24 @@ fn the_long_lived_response_holds_no_principal_it_resolved_at_open() { "an ended stream writes nothing more, whatever a later frame would say" ); } + +/// A request naming more distinct uris than the ceiling is refused as soon as the distinct count +/// passes it, never after reading the rest of the array; duplicates are folded without a scan of +/// what was kept, so many copies of a few uris are accepted. +#[test] +fn the_distinct_uris_are_counted_through_a_set_and_the_ceiling_refuses_at_once() { + let many: Vec = (0..200).map(|n| format!("file:///r{n}")).collect(); + let params = json!({ "notifications": { "resourceSubscriptions": many } }); + let refused = Listen::open(Some(¶ms), json!("sub"), 0, |_| true).expect_err("over the cap"); + let message = refused["error"]["message"].as_str().expect("a sentence"); + assert!( + message.contains(&format!("names {} distinct uris", MAX_SUBSCRIBED_URIS + 1)), + "the refusal came at the first uri past the ceiling: {message}" + ); + let copies: Vec<&str> = (0..100_000) + .map(|n| ["file:///a", "file:///b", "file:///c"][n % 3]) + .collect(); + let params = json!({ "notifications": { "resourceSubscriptions": copies } }); + let l = Listen::open(Some(¶ms), json!("sub"), 0, |_| true).expect("duplicates fold"); + assert_eq!(l.accepted().resources.as_ref().map(Vec::len), Some(3)); +} diff --git a/crates/busbar-plane-mcp/src/tests/tasks.rs b/crates/busbar-plane-mcp/src/tests/tasks.rs index ff6ef04105..d7e67fbe38 100644 --- a/crates/busbar-plane-mcp/src/tests/tasks.rs +++ b/crates/busbar-plane-mcp/src/tests/tasks.rs @@ -480,3 +480,85 @@ fn a_task_parked_on_its_upstreams_ask_hands_the_answers_back_once() { assert_eq!(gone.status(), Status::Cancelled); assert_eq!(gone.take_relay(), None); } + +/// THE TASK STORE IS HOST RECORDS (BUSBAR-1.6.0.md, the mcp bullet): a live task's state — its +/// status, its `inputRequests` in order, its answers, the round of its own asks and the upstream +/// ask it is parked on — reads back from its records into the task its row makes, and answers the +/// same `tasks/get`. +#[test] +fn a_live_tasks_state_reads_back_from_its_records() { + let row = WorkRow { + status: Status::Working, + created_ms: T0, + updated_ms: T0, + digest: "d1".into(), + }; + let mut own = task("k"); + own.park(vec![elicitation("b"), elicitation("a")], T0 + 1); + assert!(own.deliver(&serde_json::from_value(json!({"z": 1})).unwrap(), T0 + 2)); + own.asked = Some((1, json!({"name": "fs_x"}))); + let mut relayed = task("k"); + let requests: Map = + serde_json::from_value(json!({"r": {"method": "roots/list"}})).unwrap(); + relayed.park_relay(&requests, "sealed".into(), json!({"name": "fs_x"}), T0 + 3); + for held in [own, relayed] { + let parts = live_parts(&held.id, &held.live()); + assert!(parts + .iter() + .all(|(k, _)| k.starts_with(&live_prefix(&held.id)))); + let bytes: Vec = parts.iter().flat_map(|(_, v)| v.clone()).collect(); + let mut read = Task::from_row(&held.id, "k", 7, &row, None); + read.take_live(&read_live(&bytes).expect("a live document")); + assert_eq!(read.detailed(), held.detailed()); + assert_eq!(read.answers(), held.answers()); + assert_eq!(read.asked, held.asked); + assert_eq!(read.relayed(), held.relayed()); + assert_eq!(read.take_relay(), held.clone().take_relay()); + } +} + +/// A SHORTER STATE WRITTEN OVER A LONGER ONE reads back as itself: the longer one's chunks past it +/// are not read. +#[test] +fn a_live_state_reads_back_whole_over_an_earlier_longer_one() { + let short = json!({"status": "working", "updated": 1}); + let long = json!({"status": "input_required", "updated": 0, "pad": "x".repeat(2000)}); + let mut stored: BTreeMap, Vec> = live_parts("t", &long).into_iter().collect(); + stored.extend(live_parts("t", &short)); + let bytes: Vec = stored.values().flatten().copied().collect(); + assert_eq!(read_live(&bytes), Some(short)); +} + +/// THE INDEX ROW of a live task: a run's lease that lapsed, or nothing moving it past the +/// abandonment ceiling, leaves it behind; a task parked on its caller is not left behind by time +/// short of that ceiling. +#[test] +fn a_task_is_left_behind_once_its_runs_lease_lapses_or_it_is_abandoned() { + let run = TaskLease { + until_ms: T0 + 10, + updated_ms: T0, + }; + assert_eq!(TaskLease::read(&run.bytes()), Some(run)); + assert!(!run.left_behind(T0 + 10)); + assert!(run.left_behind(T0 + 11)); + let parked = TaskLease { + until_ms: 0, + updated_ms: T0, + }; + assert!(!parked.left_behind(T0 + ACTIVE_TASK_ABANDON_MS)); + assert!(parked.left_behind(T0 + ACTIVE_TASK_ABANDON_MS + 1)); + assert_eq!(TaskLease::read(b"l1|1"), None); + assert!(index_key("k", "t").starts_with(&index_prefix("k"))); + assert_ne!(index_prefix("k"), index_prefix("j")); +} + +/// Finding 24 [LOW]: `tasks/update` on a terminal task changes nothing — no answer is kept and its +/// last update stands. +#[test] +fn an_update_to_a_terminal_task_changes_nothing() { + let mut t = task("k"); + assert!(t.complete(json!({"content": []}), T0 + 1)); + let before = t.clone(); + assert!(t.deliver(&serde_json::from_value(json!({"a": 1})).unwrap(), T0 + 9)); + assert_eq!(t, before); +} diff --git a/crates/busbar-plane-mcp/src/tests/tool_program.rs b/crates/busbar-plane-mcp/src/tests/tool_program.rs index 5ff2e3bb3d..e19aceae58 100644 --- a/crates/busbar-plane-mcp/src/tests/tool_program.rs +++ b/crates/busbar-plane-mcp/src/tests/tool_program.rs @@ -7,12 +7,15 @@ use super::*; use serde_json::json; -/// The door, as these tests stand in for it: what it answered, what it was told. +/// The door, as these tests stand in for it: what it answered, what it was told, and the child's +/// line of relayed calls (each by the id it waits on, in arrival order). #[derive(Default)] struct Door { claimed: Vec<(u64, String)>, noticed: u32, grants: ServerRequestGrants, + open: Vec, + owners: Vec<((u64, String), Option)>, } impl Peer for Door { @@ -27,6 +30,15 @@ impl Peer for Door { self.claimed.push(key); true } + fn owner(&mut self, generation: u64, id: &Value) -> AskOwner { + let key = (generation, id.to_string()); + if let Some((_, owner)) = self.owners.iter().find(|(k, _)| *k == key) { + return owner.map_or(AskOwner::Refused, AskOwner::Call); + } + let owner = first_in_line(self.open.iter().map(|&call| (call, 0)), generation); + self.owners.push((key, owner)); + owner.map_or(AskOwner::Refuse, AskOwner::Call) + } fn notice(&mut self) { self.noticed += 1; } @@ -174,6 +186,7 @@ fn a_granted_ask_is_taken_for_the_caller_and_never_answered_here() { sampling: true, ..ServerRequestGrants::default() }, + open: vec![9], ..Door::default() }; let sampling = json!({"jsonrpc": "2.0", "id": "srv-1", "method": "sampling/createMessage", @@ -219,8 +232,8 @@ fn a_granted_ask_is_taken_for_the_caller_and_never_answered_here() { ); } -/// An exchange of the door's own (a greeting, a tool list) relays no call: it neither takes nor -/// answers a granted ask, and leaves it unclaimed for the exchange relaying the call to take. +/// An exchange of the door's own (a greeting, a tool list) relays no call: reading a granted ask +/// first, it neither takes nor answers it, and leaves it for the one call relaying to take. #[test] fn an_exchange_of_the_doors_own_leaves_a_granted_ask_for_the_call() { let mut door = Door { @@ -228,6 +241,7 @@ fn an_exchange_of_the_doors_own_leaves_a_granted_ask_for_the_call() { elicitation: true, ..ServerRequestGrants::default() }, + open: vec![9], ..Door::default() }; let ask = bytes( @@ -287,3 +301,98 @@ fn the_childs_asks_go_out_verbatim_and_the_answers_come_back_under_its_ids() { ] ); } + +/// RED (finding 5, SECURITY): two callers, A (call id 9) and B (call id 10), call one stdio child +/// at once, and the child asks each of them for a sampling. A request over stdio names no call it +/// serves, so the door puts calls whose asks are relayed to a child in a line: A is first, B waits +/// its turn (`open`, in arrival order), and an ask is the call's first in line. First-reader-wins +/// handed A's ask to whichever exchange read it first: B's (and wrote B's answer to the child for +/// A's work). Asserted: A's ask reaches only A, B's only B, and neither is refused. +#[test] +fn each_callers_ask_reaches_only_that_caller() { + let mut door = Door { + grants: ServerRequestGrants { + sampling: true, + ..ServerRequestGrants::default() + }, + open: vec![9, 10], + ..Door::default() + }; + let ask = |id: &str, text: &str| { + bytes( + &json!({"jsonrpc": "2.0", "id": id, "method": "sampling/createMessage", + "params": {"messages": [{"role": "user", + "content": {"type": "text", "text": text}}], + "maxTokens": 9}}), + ) + }; + let mut a = Correlator::relaying(); + let mut b = Correlator::relaying(); + // A's ask, read first by B's exchange. + assert_eq!( + b.take(&ask("a-1", "A's data"), 10, "srv", 1, &mut door), + Ok(None) + ); + assert!(b.asks.is_empty(), "B was handed A's ask: {:?}", b.asks); + assert_eq!( + a.take(&ask("a-1", "A's data"), 9, "srv", 1, &mut door), + Ok(None) + ); + assert_eq!(a.asks.len(), 1, "A's ask reaches A"); + assert_eq!(a.asks[0].id, json!("a-1")); + // A's call is answered and leaves the line: B's call is first, and its ask, read first by A's + // exchange, is B's. + door.open = vec![10]; + assert_eq!( + a.take(&ask("b-1", "B's data"), 9, "srv", 1, &mut door), + Ok(None) + ); + assert_eq!(a.asks.len(), 1, "A was handed B's ask: {:?}", a.asks); + assert_eq!( + b.take(&ask("b-1", "B's data"), 10, "srv", 1, &mut door), + Ok(None) + ); + assert_eq!(b.asks.len(), 1, "B's ask reaches B"); + assert_eq!(b.asks[0].id, json!("b-1")); + assert!( + a.outbox.is_empty() && b.outbox.is_empty(), + "neither ask is refused: {:?} {:?}", + a.outbox, + b.outbox + ); + // An ask raised while no call is in the line has no caller: refused once. + door.open.clear(); + let mut own = Correlator::default(); + assert_eq!(own.take(&ask("c-1", "?"), 1, "srv", 1, &mut door), Ok(None)); + assert_eq!(b.take(&ask("c-1", "?"), 10, "srv", 1, &mut door), Ok(None)); + let refusals: Vec = own + .outbox + .iter() + .chain(b.outbox.iter()) + .map(|r| serde_json::from_slice(r).unwrap()) + .collect(); + assert_eq!(refusals.len(), 1, "refused once: {refusals:?}"); + assert_eq!( + refusals[0]["error"]["data"]["reason"], + json!("ask_unattributed") + ); +} + +/// An ask is the call's first in its child's line: a call whose lease has not named its generation +/// counts on every generation; a line whose first call is on another child, or an empty line, has +/// no call for it. +#[test] +fn the_call_an_ask_belongs_to() { + assert_eq!(first_in_line([(9, 1), (10, 1)], 1), Some(9)); + assert_eq!( + first_in_line([(9, 0), (10, 1)], 1), + Some(9), + "before its head" + ); + assert_eq!( + first_in_line([(9, 2)], 1), + None, + "another generation's child" + ); + assert_eq!(first_in_line([], 1), None, "no call"); +} diff --git a/crates/busbar-plane-mcp/src/tool_door.rs b/crates/busbar-plane-mcp/src/tool_door.rs index 3b50ee73b2..17a15d6672 100644 --- a/crates/busbar-plane-mcp/src/tool_door.rs +++ b/crates/busbar-plane-mcp/src/tool_door.rs @@ -53,6 +53,7 @@ use busbar_contract::abi::plane::{ VERDICT_RETRY, }; use busbar_contract::abi::sdk::door::statement; +use busbar_contract::abi::sdk::exchange::{REPLY_MAX, REPLY_OVER_BOUND}; use busbar_contract::abi::sdk::life::Refusal; use busbar_contract::abi::sdk::publish::{Generations, Keyed}; use busbar_contract::abi::sdk::services::ServiceError; @@ -86,6 +87,10 @@ pub const STATEMENT: Statement = Statement { needs_len: door::NEEDS.len(), secret_refs: door::SECRET_REFS.as_ptr(), secret_refs_len: door::SECRET_REFS.len(), + // The instance's own diagnostic: an ungoverned chain's sessions are unisolated + // ([`door_sessions::UNGOVERNED_LEGACY_STREAM`]). + diag_ids: door_sessions::DIAG_IDS.as_ptr(), + diag_ids_len: door_sessions::DIAG_IDS.len(), ..statement(crate::PLANE_KEY, VERSION, MAX_INFLIGHT) }; @@ -181,9 +186,13 @@ pub struct McpDoor { /// THE STDIO SERVERS' GREETINGS: the generation of each member's child the door ran /// `initialize` on ([`door_program`]), once per generation. greeted: Keyed, - /// The requests of a stdio child's own the door answered, by member, generation and id: one - /// answer each, whichever exchange read it first. - answered: Keyed<(String, u64, String), ()>, + /// The requests of a stdio child's own the door decided, by member, generation and id: the call + /// a granted ask was put to, or `None` = busbar answered it, decided once by whichever exchange + /// read it first. + answered: Keyed<(String, u64, String), Option>, + /// The line of calls relayed to stdio children: which call a child serves, so whose its ask + /// is ([`door_program::Relaying`]). + relaying: door_program::Relaying, /// THE TASKS this instance holds, by `taskId` (SEP-2663, [`door_tasks`]). tasks: Keyed, /// The result chunks of dropped tasks still to strike, by `taskId`: how many. @@ -198,6 +207,21 @@ pub struct McpDoor { /// lapses (Unix ms). One the caller never came back for is settled by the next relay's sweep, /// so an abandoned ask does not hold a live handle forever. relays: Keyed, + /// THE SESSIONS of the session revisions and the `2024-11-05` stream: this process's, bounded + /// per owner in count and bytes ([`crate::tool_sessions::SessionTable`], [`door_sessions`]). + sessions: Keyed<(), crate::tool_sessions::SessionTable>, + /// Each session's own state beside its table row: its subscriptions, its log floor, the + /// updates announced for it. + session_state: Keyed, + /// The held GET streams of the sessions, by unit. + streams: Keyed, + /// WHAT BUSBAR REMEMBERS ABOUT EACH UPSTREAM as a client: the revision it negotiated and its + /// session ([`crate::tool_sessions::UpstreamTable`]). + upstreams: Keyed<(), crate::tool_sessions::UpstreamTable>, + /// Whether this instance said its ungoverned chain's sessions are unisolated (once). + said_ungoverned: Keyed<(), ()>, + /// The session revision each line-carrier session negotiated with `initialize`. + line_revisions: Keyed, } impl McpDoor { @@ -305,11 +329,26 @@ slot!( roots: Keyed::new(), greeted: Keyed::new(), answered: Keyed::new(), + relaying: Arc::default(), live_asks: Keyed::new(), line_listens: Keyed::new(), ask_seq: Keyed::new(), relays: Keyed::new(), + sessions: Keyed::new(), + session_state: Keyed::new(), + streams: Keyed::new(), + upstreams: Keyed::new(), + said_ungoverned: Keyed::new(), + line_revisions: Keyed::new(), }; + plane.sessions.insert( + (), + crate::tool_sessions::SessionTable::new(crate::tool_sessions::Bounds::default()), + ); + plane.upstreams.insert( + (), + crate::tool_sessions::UpstreamTable::new(UPSTREAMS_KEPT, UPSTREAM_BYTES_KEPT), + ); let spec = door::snapshot_spec_with(plane.admitted.clone(), plane.facts.clone()); let held = Held::pooled(generation, cfg, pools); out.publish_with(|o| &o.snapshot, &plane.generations, generation, &spec, held); @@ -356,7 +395,9 @@ slot!( let given = input.get(); let next = instance.get().map_or(0, |plane| { door_line::tick(plane, given.head.ticket, given.now_ns); - door_listen::tick(plane, given.head.ticket, given.now_ns) + let next = door_listen::tick(plane, given.head.ticket, given.now_ns); + door_sessions::tick(plane); + next }); out.set(|o| &o.next_tick_ns, next); Outcome::Ready @@ -384,6 +425,7 @@ slot!( stopped = door_tasks::cancelled(plane, ticket); plane.units.with_all(|m| m.retain(|_, u| u.ticket != Some(ticket))); door_listen::cancelled(plane, ticket); + door_sessions::cancelled(plane, ticket); } // The rows ride the one cancel answer (it is never re-called): what fits is written. let (mut records, mut arena) = (input.records_buf(), input.arena_buf()); @@ -418,9 +460,15 @@ slot!( // ── the request path ────────────────────────────────────────────────────────────────────────── -/// The most units the instance keeps state for at once; past it, the oldest is dropped first. +/// The most units the instance keeps state for at once; past it, a new arrival is refused. pub const MAX_UNITS: usize = 4096; +/// The most upstreams busbar remembers a negotiated revision for, and their bytes: forgetting one +/// costs one renegotiation. +const UPSTREAMS_KEPT: usize = 1024; +/// See [`UPSTREAMS_KEPT`]. +const UPSTREAM_BYTES_KEPT: usize = 1 << 20; + /// One unit's state, from its arrival to its end. struct CallUnit { /// The unit's own key, as the kernel minted it. @@ -465,6 +513,11 @@ struct CallUnit { line: Option, /// The retry of a stdio child's relayed ask: its work handle found, bound and settled. child_work: ChildWork, + /// What the unit is to the session revisions ([`door_sessions`]). + session: Option, + /// An upstream conversation under negotiation, parked across PENDING entries + /// ([`door_sessions::Negotiating`]). + negotiating: Option>, } /// The retry of a stdio child's relayed ask, binding the work handle the ask is correlated under @@ -505,6 +558,9 @@ struct Relay { /// (ms): what tells a walk that spent the server's `timeout:` on a dispatched call from one /// that dispatched nothing ([`timed_out`]). dispatched_ms: Option, + /// A stdio call waiting its turn on its member's child ([`door_program::in_line`]): its + /// request, and the instant its deadline passes (the kernel's monotonic clock, ms; `0` = none). + queued: Option<(crate::call::OutboundCall, u64)>, } /// The most progress frames one request relays: a progress stream is untrusted upstream input, @@ -746,18 +802,6 @@ const CLASS_TOOL_CALLS_INDEX: u32 = 0; /// The tail index of the byte class (the second of [`door::TAIL`]'s billable classes). const CLASS_BYTES_INDEX: u32 = 1; -/// Keep `value` under `key` in `map`, dropping the smallest keys first past `cap`. -fn keep(map: &Keyed, cap: usize, key: u64, value: V) { - map.with_all(|m| { - while m.len() >= cap && !m.contains_key(&key) { - if m.pop_first().is_none() { - break; - } - } - m.insert(key, value); - }); -} - /// The operation class a disposition is counted under: its row's, or the notification class. A /// refused arrival is counted under none. fn op_class(disposition: &Disposition) -> Option { @@ -805,6 +849,9 @@ const NOT_ALLOWED_TEXT: &str = r#"{"allow":"POST"}"#; /// The status a line naming no carrier session is refused with. const STATUS_BAD_REQUEST: u32 = 400; +/// The status an arrival is refused with when the unit table is full. +const STATUS_BUSY: u32 = 429; + /// A line that names no carrier session: the host states one on every line it opens a unit for. const NO_SESSION_TEXT: &str = r#"{"status":400,"id":null,"code":-32600,"message":"a line of the line carrier names no carrier session"}"#; @@ -887,12 +934,55 @@ slot!( FORBIDDEN_ORIGIN_TEXT.to_string(), ); } - if claim.is_some_and(|r| r.verb != "POST") { - return refused_arrival( &mut out, - door::STATUS_METHOD_NOT_ALLOWED, - NOT_ALLOWED_TEXT.to_string(), - ); - } + // THE ENDPOINT'S THREE VERBS (THE DESIGN section 2, the mcp bullet): the stateless revision's + // POST is the path it always was; `initialize`, a session's messages and streams, DELETE + // and the `2024-11-05` stream are the session revisions' ([`door_sessions`]); a GET or + // DELETE naming no session (and no legacy stream) is `405`. + let endpoint = claim.is_some_and(|r| { + r.target == crate::tool_claims::DEFAULT_MOUNT + && r.carrier == crate::tool_claims::CARRIER_HTTP + }); + let head_field = |name: &str| { + fields + .iter() + .find(|f| { + f.field(|f| &f.name) + .as_str() + .is_ok_and(|n| n.eq_ignore_ascii_case(name)) + }) + .and_then(|f| f.field(|f| &f.value).as_str().ok()) + .map(str::to_string) + }; + let arrived = if endpoint { + let held = plane.current(); + door_sessions::arrive( + held.as_deref(), + claim.map_or("POST", |r| r.verb), + input.field(|i| &i.target).as_str().unwrap_or_default(), + body, + &head_field, + ) + } else if claim.is_some_and(|r| r.verb != "POST") { + door_sessions::Inbound::NotAllowed + } else { + door_sessions::Inbound::Stateless + }; + let (mut session_unit, session_body, session_mirror) = match arrived { + door_sessions::Inbound::NotAllowed => { + return refused_arrival( + &mut out, + door::STATUS_METHOD_NOT_ALLOWED, + NOT_ALLOWED_TEXT.to_string(), + ) + } + door_sessions::Inbound::Stateless => (None, None, Vec::new()), + door_sessions::Inbound::Session(a) => { + let a = *a; + (Some(a.unit), a.dispatch, a.mirror) + } + }; + // A session unit the session answers itself: nothing for the one dispatch to decide. + let session_local = session_unit.is_some() && session_body.is_none(); // A TASK'S CONTINUATION (ARCHITECT round 5 Q-L3B-TASKS (b) → (A)): the task it runs, and the // call it carries, decided below as the `tools/call` it is. let task_run = (input.get().claim as usize == door::TASK_RUN_ROUTE) @@ -941,19 +1031,32 @@ slot!( None => mirrored, }; let line_unit = line_arrival.map(|a| a.unit); + // A line raised from its carrier session's negotiated revision is lowered back. + if line_unit.as_ref().is_some_and(|u| u.raised) { + session_unit = Some(door_sessions::SessionUnit::Line); + } let body: &[u8] = line_body .as_deref() .or(task_body.as_deref()) + .or(session_body.as_deref()) .unwrap_or(body); + // A raised session message is decided under the fields its raised body implies, ahead of + // the caller's own (which name its session revision). let field = |name: &str| { - fields + session_mirror .iter() - .find(|f| { - f.field(|f| &f.name) - .as_str() - .is_ok_and(|n| n.eq_ignore_ascii_case(name)) + .find(|(n, _)| n.eq_ignore_ascii_case(name)) + .map(|(_, v)| v.as_str()) + .or_else(|| { + fields + .iter() + .find(|f| { + f.field(|f| &f.name) + .as_str() + .is_ok_and(|n| n.eq_ignore_ascii_case(name)) + }) + .and_then(|f| f.field(|f| &f.value).as_str().ok()) }) - .and_then(|f| f.field(|f| &f.value).as_str().ok()) .or_else(|| { mirrored .iter() @@ -961,11 +1064,13 @@ slot!( .map(|(_, v)| v.as_str()) }) }; - let disposition = if line_silent { + let mut disposition = if line_silent { // A line the carrier answers itself: nothing for the one dispatch to decide. Disposition::Notice { method: String::new(), } + } else if let Some(unit) = session_unit.as_ref().filter(|_| session_local) { + door_sessions::disposition(unit) } else { match crate::tool_arrival::decide(body, field) { // A notification is never answered on the line, refused or not. @@ -977,9 +1082,39 @@ slot!( decided => decided, } }; + // A session message the one dispatch refused is answered once its session is the caller's + // (a mismatch answers `404` on every path), never at its arrival. + let refused = match &disposition { + Disposition::Refused(r) => Some(r.clone()), + _ => None, + }; + if let (Some(refusal), Some(unit)) = (refused, session_unit.as_mut()) { + if door_sessions::refused_in_session(unit, &refusal) { + disposition = door_sessions::disposition(unit); + } + } if let Disposition::Refused(refusal) = &disposition { return refused_arrival(&mut out, refusal.status, refusal_text(refusal)); } + // ADMISSION BOUNDS LIVE WORK; NOTHING EVICTS IT: a full table refuses the new arrival, before + // anything else is stated of it, and every unit it holds goes on. Arrivals racing the last + // free places overshoot by no more than the instance's in-flight bound. + let at_cap = plane + .units + .with_all(|held| held.len() >= MAX_UNITS && !held.contains_key(&input.get().unit)); + if at_cap { + let refusal = crate::tool_arrival::Refusal { + status: STATUS_BUSY, + id: None, + code: crate::codec::CODE_REFUSED, + message: format!( + "this server holds {MAX_UNITS} requests in flight, the most it will; retry \ + when some have finished" + ), + data: Some(serde_json::json!({ "reason": "capacity" })), + }; + return refused_arrival(&mut out, refusal.status, refusal_text(&refusal)); + } let Some(op_class) = op_class(&disposition) else { return Outcome::Failed; }; @@ -987,14 +1122,14 @@ slot!( out.set(|o| &o.principal_need, PRINCIPAL_REQUIRED); out.set(|o| &o.dialect, 0); let value = serde_json::from_slice::(body).ok(); - // A line's answer is one line: never an event stream. + // A line's answer is one line, and a session's is one document: never an event stream. let framing = match (&disposition, value.as_ref()) { - (Disposition::Request { row, .. }, Some(v)) if !line_route => { + (Disposition::Request { row, .. }, Some(v)) if !line_route && session_unit.is_none() => { Framing::of(field("accept"), row.method, v) } _ => None, }; - let param_fields = fields + let mut param_fields: Vec<(String, String)> = fields .iter() .filter_map(|f| { let name = f.field(|f| &f.name).as_str().ok()?.to_ascii_lowercase(); @@ -1002,6 +1137,13 @@ slot!( name.starts_with(PARAM_FIELD_PREFIX).then(|| (name, value.to_string())) }) .collect(); + // A raised `tools/call` carries the parameter mirror its session revision could not. + for (name, value) in &session_mirror { + if name.starts_with(PARAM_FIELD_PREFIX) { + param_fields.retain(|(n, _)| n != name); + param_fields.push((name.clone(), value.clone())); + } + } // THE ROUTE (ARCHITECT Q-SW6 / Q-FL3): a relayed call names the one registered server its // published tool is served by, a DIRECT entry of the `tools:` section; the kernel resolves // (plane key, entry) and never parses the name. @@ -1099,8 +1241,10 @@ slot!( }), line: line_unit, child_work: ChildWork::default(), + session: session_unit, + negotiating: None, }; - keep(&plane.units, MAX_UNITS, input.get().unit, unit); + plane.units.insert(input.get().unit, unit); Outcome::Ready } ); @@ -1132,6 +1276,36 @@ fn ask_entitlements( } } +/// THE KERNEL'S DESTINATION JUDGE on a host a call's arguments name (BUSBAR-1.6.0.md Appendix C +/// B.3 item 11: `dest.judge`, called where the argument guard runs): the deployment's egress rules +/// under its default class, private reach refused outright for a registration that was granted +/// none (`allow_private`, as `DEST_REFUSE_PRIVATE`). The name alone is judged, never resolved, so +/// it never pends (the B.2 row: "may pend: no"); a host that serves no judge, pends or fails +/// gives no verdict, and the argument guard refuses (fail closed). +fn dest_verdict( + services: Services, + handle: CompletionHandle, + held: &Held, + entry: &crate::catalogue::ToolEntry, + dest: &str, +) -> Option { + let reach = held + .section + .servers + .get(&entry.server) + .is_some_and(|d| d.allow_private); + let flags = if reach { + 0 + } else { + busbar_contract::abi::host::service::DEST_REFUSE_PRIVATE + }; + let class = busbar_contract::abi::host::conn::connector::EGRESS_DEFAULT; + match services.dest_judge_as(handle, dest, class, flags, None) { + std::task::Poll::Ready(Ok(judged)) => Some(judged.verdict), + _ => None, + } +} + /// What one piece came to. enum Step { /// Nothing to write: the piece was taken. @@ -1142,8 +1316,8 @@ enum Step { Write, /// A host service pended (the approval's claim): called again on its wake. Pending, - /// Held open (a task's continuation waiting on its phase): called again on a wake, or at this - /// instant of the host's monotonic clock (`0` = on a wake only). + /// Held open (a stdio call waiting for its turn behind the call ahead of it): called again on a + /// wake, or at this instant of the host's monotonic clock (`0` = on a wake only). Wait(u64), /// The walk's member is one this unit may not be sent to: declined, the walk moves on. Decline, @@ -1355,26 +1529,22 @@ fn verify_on_call( held.section.effective_upstream_credentials(server), ) .then(|| crate::tool_scope::registration_scope(server, def)); - let answer = exchange_at(instance, plane.host.as_ref(), base, &url, server, || { - let mut request = - crate::client::jsonrpc::tools_list(&url, CONNECT_REQUEST_ID, None); - scoped(&mut request.headers, scope.as_deref()); - busbar_contract::abi::sdk::exchange::Request { - method: b"POST".to_vec(), - target: crate::call::path_of(&url).into_bytes(), - fields: request - .headers - .iter() - .map(|(n, v)| (n.as_bytes().to_vec(), v.as_bytes().to_vec())) - .collect(), - body: request.body, - timeout_ms, - } - }); + // The fetch is the stateless `tools/list` first, NEGOTIATED with an upstream that + // requires a session ([`door_sessions::fetch`]), every hop on the connector. + let mut request = + crate::client::jsonrpc::tools_list(&url, CONNECT_REQUEST_ID, None); + scoped(&mut request.headers, scope.as_deref()); + let answer = door_sessions::fetch( + instance, + plane, + unit, + (base, server, timeout_ms), + request, + ); let std::task::Poll::Ready(answer) = answer else { return Looked::Pending; }; - sighting_of(answer.map_err(|e| e.to_string()), CONNECT_REQUEST_ID) + sighting_of(answer, CONNECT_REQUEST_ID) } } }; @@ -1527,6 +1697,51 @@ fn hides(trust: &crate::call::Trust) -> bool { ) } +/// A STDIO CALL WAITING ITS TURN (finding 5), called again on the wake of the call ahead of it +/// leaving its member's line or at its deadline: sent once it is first in line; answered busy, +/// never sent, once its deadline passed while it waited. `None` for a unit that is not waiting. +fn take_turn( + plane: &McpDoor, + ticket: Ticket, + principal: &str, + generation: u64, + unit: &mut CallUnit, +) -> Option { + let deadline = unit.relay.as_ref()?.queued.as_ref()?.1; + let now = mono_ms(plane.services, ticket, unit); + let relay = unit.relay.as_mut()?; + let first = relay + .program + .as_ref() + .is_none_or(|p| door_program::in_line(plane, p, now, ticket)); + if first { + let (outbound, _) = relay.queued.take()?; + relay.dispatched_ms = now; + unit.pending = Some(Pending::far(outbound).laned(&relay.admitted.entry.namespaced)); + return Some(Step::Write); + } + if deadline == 0 || now.is_none_or(|now| now < deadline) { + return Some(Step::Wait(deadline.saturating_mul(1_000_000))); + } + relay.queued = None; + relay.program = None; + let Settled::Answer { status, body, line } = + crate::call::upstream_failed(&relay.admitted, door_program::BUSY) + else { + return None; + }; + let ts = clock_s(plane.services, ticket, unit); + unit.pending = Some( + Pending::answer(status, body, unit.framing.as_ref(), &[]).logged( + Some(&line), + principal, + generation, + ts, + ), + ); + Some(Step::Write) +} + fn answer_body( instance: &Instance<'_, McpDoor>, plane: &McpDoor, @@ -1540,6 +1755,11 @@ fn answer_body( } else { caller }; + // A SESSION REVISION'S UNIT: its session checked against its owner first (a mismatch is `404` + // on every path); what the session answers itself is answered here. + if let Some(step) = door_sessions::answer(plane, ticket, caller, unit) { + return Some(step); + } // A LINE the carrier answers itself (an era verb, an answer busbar asked for), and a line's // subscription, kept on its carrier session ([`door_line`]). if let Some(pending) = door_line::preset(plane, ticket, unit) { @@ -1567,6 +1787,9 @@ fn answer_body( } } let held = unit.held.clone()?; + if let Some(step) = take_turn(plane, ticket, principal, held.catalogue.generation(), unit) { + return Some(step); + } let mut params = unit.params.clone(); let disposition = unit.disposition.clone(); // THE POOL'S TWIN (ARCHITECT round 4 Q-L3B-SURFACES (h)): the kernel's walk picked another @@ -1752,17 +1975,20 @@ fn answer_body( &header, &admit, &mut trust, - &|entry: &crate::catalogue::ToolEntry| { - held.section - .servers - .get(&entry.server) - .is_some_and(|d| d.allow_private) - }, &mut ask, ); if seal.is_some_and(|s| s.pending) { return Some(Step::Pending); } + let admission = crate::call::judge_arguments(admission, &mut |entry, dest| { + let handle = CompletionHandle { + ticket, + seq: unit.issued, + _reserved: 0, + }; + unit.issued += 1; + dest_verdict(services?, handle, &held, entry, dest) + }); return Some(match admission { Admission::Asked(body, line) | Admission::Unreached(body, line) => { unit.pending = Some( @@ -1858,6 +2084,8 @@ fn answer_body( replies.remove(0) }; relay.program = Some(door_program::ProgramRelay::retrying( + plane, + &member, child.wait, child.generation, replies, @@ -1871,7 +2099,7 @@ fn answer_body( } else if door_program::is_program(def) { // A stdio member: the call carries the unit's own id on the child. let id = door_program::id_of(unit.key, round); - relay.program = Some(door_program::ProgramRelay::waiting(id)); + relay.program = Some(door_program::ProgramRelay::waiting(plane, &member, id)); crate::call::outbound_program( &relay.admitted, &member, @@ -1894,8 +2122,32 @@ fn answer_body( relay.scope = exchange_scope(services, ticket, unit, &held, &member, &relay.admitted); scoped(&mut outbound.fields, relay.scope.as_deref()); + // THE CLIENT LADDER'S FIRST HOP is the walk's: the stateless request as it + // always was, or, to an upstream busbar holds a session with, that request + // lowered into the session ([`door_sessions::Negotiating`]). + let original = door_sessions::original(&def.url, &outbound); + let negotiating = door_sessions::Negotiating::begin(plane, &member, original); + if let Some(first) = negotiating.first_request() { + outbound.fields.clone_from(&first.headers); + outbound.body.clone_from(&first.body); + } + unit.negotiating = Some(Box::new(negotiating)); + } + // ONE CALL AT A TIME on a child whose asks are relayed (finding 5): a call behind + // another in its member's line waits its turn, within its own deadline. + let now = mono_ms(services, ticket, unit); + if door_program::serialised(def) + && relay + .program + .as_ref() + .is_some_and(|p| !door_program::in_line(plane, p, now, ticket)) + { + let deadline = now.map_or(0, |now| now.saturating_add(def.timeout_ms())); + relay.queued = Some((outbound, deadline)); + unit.relay = Some(relay); + return Some(Step::Wait(deadline.saturating_mul(1_000_000))); } - relay.dispatched_ms = mono_ms(services, ticket, unit); + relay.dispatched_ms = now; unit.pending = Some(Pending::far(outbound).laned(&relay.admitted.entry.namespaced)); unit.relay = Some(relay); Step::Write @@ -2391,6 +2643,7 @@ impl Relay { asked: None, work_slot: None, dispatched_ms: None, + queued: None, } } } @@ -2467,8 +2720,12 @@ slot!( return Outcome::Failed; }; let piece = input.get(); - // A piece of a session (a subscription on the HTTP carrier) is the session's. + // A piece of a session (a subscription on the HTTP carrier, or a session revision's held + // stream) is the session's. if piece.stream != 0 { + if door_sessions::holds(plane, piece.unit) { + return door_sessions::piece(plane, input, &mut out); + } return door_listen::piece(plane, input, &mut out); } let key = piece.unit; @@ -2485,6 +2742,16 @@ slot!( .is_ok_and(|n| n.eq_ignore_ascii_case(CONTENT_TYPE)) }) .and_then(|f| f.field(|f| &f.value).as_str().ok().map(str::to_string)); + // A session revision's far end names its session in its head. + let far_session = input + .head_fields() + .iter() + .find(|f| { + f.field(|f| &f.name) + .as_str() + .is_ok_and(|n| n.eq_ignore_ascii_case(crate::adapt::H_SESSION_ID)) + }) + .and_then(|f| f.field(|f| &f.value).as_str().ok().map(str::to_string)); // A stdio member's lease names the generation of the child it reached in its head. let far_generation = input .head_fields() @@ -2516,6 +2783,8 @@ slot!( unit.member = Some(member); unit.attempt = piece.attempt_no; unit.verified = None; + // A new attempt negotiates afresh with its own member. + unit.negotiating = None; Some(Step::Taken) } FROM_KERNEL => Some(Step::Taken), @@ -2580,6 +2849,24 @@ slot!( plane, ticket, member, def, program, &ids, &refusal, )); } + // The child still serves the call: its place in the line + // is held for the retry, until the ask's state lapses. + if let Some(now) = plane + .services + .and_then(|s| { + s.clock_now(door_tasks::handle( + ticket, + &mut unit.issued, + &mut None, + )) + .ok() + }) + .map(|r| r.mono_ns / 1_000_000) + { + program.hold(now.saturating_add( + crate::ask::DEFAULT_TTL_SECS.saturating_mul(1000), + )); + } let (result, keys) = crate::tool_program::relayed_asks(&asks); Settled::Relay { result, @@ -2614,24 +2901,88 @@ slot!( } } () => { - if piece.flags & PIECE_HAS_STATUS != 0 { - relay.status = piece.status_code; - relay.sse = far_type - .as_deref() - .is_some_and(|t| t.starts_with(EVENT_STREAM)); + // A negotiation under way is entered again with the piece it began + // on: the far end's answer is already whole. + let resumed = unit.negotiating.as_ref().is_some_and(|n| n.fed); + let mut over = None; + if !resumed { + if piece.flags & PIECE_HAS_STATUS != 0 { + relay.status = piece.status_code; + relay.sse = far_type + .as_deref() + .is_some_and(|t| t.starts_with(EVENT_STREAM)); + if let Some(n) = unit.negotiating.as_mut() { + n.walk_session = far_session.clone(); + } + } + // AN UPSTREAM'S ANSWER IS BOUNDED as the SDK bounds a reply: one + // past the ceiling fails the call as any bad upstream answer does, + // and nothing more of it is kept. + if relay.far.len().saturating_add(bytes.len()) > REPLY_MAX { + relay.far = Vec::new(); + (relay.status, relay.sse) = (0, false); + over = Some(crate::call::upstream_failed( + &relay.admitted, + REPLY_OVER_BOUND, + )); + } else { + relay.far.extend_from_slice(bytes); + if piece.flags & PIECE_LAST == 0 { + return Some(Step::Taken); + } + } } - relay.far.extend_from_slice(bytes); - if piece.flags & PIECE_LAST == 0 { - return Some(Step::Taken); + if let Some(settled) = over { + unit.negotiating = None; + settled + } else { + // THE CLIENT LADDER (THE DESIGN section 2, the mcp bullet): an + // upstream that refuses the stateless request is negotiated with + // over the door's connector; one that answered is handed on as it + // answered. + let mut failed = None; + if let (Some(n), Some(def)) = (unit.negotiating.as_mut(), def) { + let base = NEGOTIATE_SEQ + .saturating_add(unit.attempt.saturating_mul(ROUND_SEQ_SPAN)); + let member = unit.member.as_deref().unwrap_or_default(); + let polled = door_sessions::far_answer( + &instance, + plane, + n, + (base, member, def.timeout_ms()), + (relay.status, relay.sse, &relay.far), + ); + let std::task::Poll::Ready(done) = polled else { + return Some(Step::Pending); + }; + match done { + None => {} + Some(door_sessions::Negotiated::Answer(status, raw, sse)) => { + relay.status = u32::from(status); + relay.far = raw; + relay.sse = sse; + } + Some(door_sessions::Negotiated::Failed(reason)) => { + relay.status = 0; + failed = Some(reason); + } + } + } + unit.negotiating = None; + match failed { + Some(reason) => { + crate::call::upstream_failed(&relay.admitted, &reason) + } + None => crate::call::settle_call( + &relay.admitted, + def, + relay.status, + &relay.far, + relay.sse, + relay.round, + ), + } } - crate::call::settle_call( - &relay.admitted, - def, - relay.status, - &relay.far, - relay.sse, - relay.round, - ) } }; let mut frames = std::mem::take(&mut relay.frames); @@ -2696,6 +3047,20 @@ slot!( if relay.sse { frames.extend(crate::call::progress_frames(&relay.far)); frames.truncate(MAX_PROGRESS_FRAMES); + // What the upstream said beside its answer: its resource updates reach the + // sessions watching them, its log records the caller's own session. + let server = unit.member.clone().unwrap_or_default(); + let in_session = door_sessions::session_of(&unit.session); + let owner = door_sessions::owner_of(&caller); + let now = in_session.as_ref().map_or(0, |_| { + door_sessions::wall_ms(plane.services, ticket, &mut unit.issued) + }); + door_sessions::heard( + plane, + &server, + &relay.far, + in_session.as_deref().map(|s| (s, &owner, now)), + ); } let progress = frames; let Settled::Answer { status, body, line } = settled else { @@ -2733,7 +3098,11 @@ slot!( } }); match step { - None | Some(Step::Declined) => Outcome::Refused, + // A piece the plane refuses ends the unit: its state is dropped with it. + None | Some(Step::Declined) => { + plane.units.remove(&key); + Outcome::Refused + } Some(Step::Taken) => Outcome::Ready, Some(Step::Pending) => Outcome::Pending, Some(Step::Decline) => { @@ -2749,6 +3118,9 @@ slot!( Some(Step::Write) => { let finished = plane.units.with(&key, |unit| { let unit = unit?; + // A session revision's answer is lowered into it before its first byte goes + // ([`door_sessions::lower`]). + door_sessions::lower(plane, ticket, &caller, unit); // A line's `input_required` answer is put to its caller as live requests on // the carrier session ([`door_line::liven`]), before its first byte goes. if unit.line.is_some() && unit.pending.as_ref().is_some_and(|p| !p.headed) { @@ -3126,6 +3498,11 @@ slot!( return Outcome::Failed; } out.set(|o| &o.status, status); + // THE REFUSED UNIT IS OVER: the kernel ends a unit it refuses, and nothing else would ever + // name it again, so its state (its request's params among it) goes with the refusal. + if let Some(plane) = instance.get() { + plane.units.remove(&given.unit); + } Outcome::Ready } ); @@ -3163,6 +3540,11 @@ const VERIFY_SEQ: u32 = 1 << 29; /// How many handles one attempt's verify-on-call exchange may number. const ROUND_SEQ_SPAN: u32 = 1 << 12; +/// The first handle seq a relayed call's negotiation (its hops after the walk's) numbers its +/// connector services from on a unit's ticket: clear of the unit's own handles and of +/// verify-on-call's ([`VERIFY_SEQ`]), each attempt [`ROUND_SEQ_SPAN`] apart. +const NEGOTIATE_SEQ: u32 = 1 << 28; + /// ONE EXCHANGE ON A UNIT'S TICKET, its connector services numbered from `base` (a unit's ticket /// carries several exchanges, one after another: each counts its own handles, so none reads /// another's stored answer), parked on the instance while it pends. @@ -3587,6 +3969,10 @@ mod door_line; /// unit and relay state. #[path = "door_program.rs"] mod door_program; +/// THE SESSION REVISIONS AND THE LEGACY EVENT STREAM, both directions (THE DESIGN section 2, the mcp +/// bullet): a child of the door, so it reads the door's own unit, answer and stream state. +#[path = "door_sessions.rs"] +mod door_sessions; /// THE TASKS EXTENSION'S UNITS (ARCHITECT round 5 Q-L3B-TASKS (b) → (A)): a child of the door, so it /// reads the door's own unit and answer state. #[path = "door_tasks.rs"] diff --git a/crates/busbar-plane-mcp/src/tool_meta.rs b/crates/busbar-plane-mcp/src/tool_meta.rs index d4ff261892..9ec19c2ce0 100644 --- a/crates/busbar-plane-mcp/src/tool_meta.rs +++ b/crates/busbar-plane-mcp/src/tool_meta.rs @@ -61,17 +61,6 @@ pub const CLASS_TOOL_CALLS: MeterClassId = MeterClassId::new("tool_calls"); /// The class key the bytes an exchange moved are counted under. pub const CLASS_BYTES: MeterClassId = MeterClassId::new("bytes"); -/// The operation class a sampling request is answered as, one level down. -/// -/// A CLASS, never a plane: this plane names no other plane (#47/#49). The host answers the nested -/// destination with whichever registered plane declares it serves this class -/// (`busbar_contract::plane::plane_serving`), and a deployment where none does refuses the ask. -/// Declared here, once, and not read from configuration: a nested destination is what the VERIFY -/// step seals and what the ROUTE step then dials, and the two must be the same destination or the -/// unit routes somewhere it was never verified for. Naming it in one constant is what makes them -/// the same by construction rather than by two authors agreeing. -pub const SAMPLING_OP: OpClassId = OpClassId::new("chat"); - /// The read-only verb that lists the registered servers. pub const VERB_TOOLS: AdminVerbId = AdminVerbId::new("tools"); diff --git a/crates/busbar-plane-mcp/src/tool_plane.rs b/crates/busbar-plane-mcp/src/tool_plane.rs deleted file mode 100644 index 3af8ecab06..0000000000 --- a/crates/busbar-plane-mcp/src/tool_plane.rs +++ /dev/null @@ -1,1004 +0,0 @@ -//! The plane itself: seventeen methods, each of them a few lines over the codec's own vocabulary. -//! -//! Every method here returns FACTS AND LOCATORS. Not an amount, not a decision, not a credential, -//! not a price. Nothing in this file opens a connection, reads a file, reads a clock other than the -//! one the context hands it, or keeps a byte across a call. -//! -//! ## The one shape worth reading before the code -//! -//! The intermediate representation the contract asks a plane to build carries the body AND the -//! resolved pointer spans, and `view` builds both: it resolves the pointers this plane declares -//! through the contract's own span grammar and allocates the resulting table in the unit's arena, -//! so every draft below hands the loop a body the kernel does not have to re-walk. The plane once -//! handed back an empty table because the arena could not allocate one; it can, and this does. - -use busbar_contract::abi::plane::{class_of_refusal, RefusalClass}; -use busbar_contract::bounded::{FactValue, Facts, ScratchBytes}; -use busbar_contract::dest::{DestinationFacts, EgressBody, Leg, RoutePlan, VerifiedDestination}; -use busbar_contract::ids::{AdminVerbId, LaneId, SchemeAlt}; -use busbar_contract::kinds::{ContentFacts, CredentialLocator, PlaneFacts}; -use busbar_contract::plane::{ - Ingress, Plane, PlaneSessionState, Progress, Response, SessionPlane, UnitDraft, -}; -use busbar_contract::unit::{ - AuditFacts, Ctx, FinishClass, Refusal, RefusalReason, Unit, UnitEnd, UsageLocator, - UsageLocators, -}; -use busbar_contract::wire::{Decode, DiscardCode, Encode, Frame, FrameCursor, TransportEnvelope}; - -use crate::jsonrpc; -use crate::tool_facts as f; -use crate::tool_meta::{self as meta, CLASS_BYTES, CLASS_TOOL_CALLS}; -use crate::tool_ops as ops; -use crate::tool_records as rec; -use crate::McpPlane; -use busbar_contract::abi::sdk::body::{has, read_raw, read_str, view}; - -/// The per-connection codec state this plane keeps. -/// -/// It holds two counts and nothing else. This protocol frames one document per frame, so there is no -/// partial document to carry across a call; what a connection does need to remember is how far into -/// a held stream it is, and how many rounds an upstream has asked for during one call — the second -/// because a round cap is only a cap if something counts. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -pub struct Codec { - /// How many event frames of a held stream this half has read. - pub events_read: u32, - /// How many times an upstream has asked for something during the call on this half. - pub rounds_asked: u32, -} - -/// The credential scheme the outbound hop is decorated under. -/// -/// The plane NAMES the scheme and never holds what is behind it. Which secret the scheme resolves, -/// and whether the caller may use it at all, is the egress-auth unit's answer. -const EGRESS_SCHEME: &str = "mcp-egress"; - -/// The envelope member naming the document type of an outbound body. -const FIELD_CONTENT_TYPE: &str = "content-type"; - -/// The document type every body of this protocol is. -const CONTENT_TYPE_JSON: &[u8] = b"application/json"; - -/// The envelope member naming which revision the hop is made under. -const FIELD_PROTOCOL_VERSION: &str = crate::codec::H_PROTOCOL_VERSION; - -/// The fact key the per-name projection reports the registration's own name under. -const SUBJECT_FACT_NAME: &str = "name"; - -/// The fact key the per-name projection reports the priced lane under. -const SUBJECT_FACT_LANE: &str = "lane"; - -/// The fact key the per-name projection reports the dialling transport under. -const SUBJECT_FACT_TRANSPORT: &str = "transport"; - -/// The fact key the per-name projection reports a locally launched registration under. -const SUBJECT_FACT_LOCAL: &str = "local"; - -impl McpPlane { - /// A leg reaching one of this plane's own records. - fn record_leg(schema: busbar_contract::ids::RecordSchemaId, op: &'static str) -> Leg { - Leg { - destination: DestinationFacts::PlaneRecord { schema, op }, - } - } - - /// A leg reaching the configured server, or an unreachable one when none is configured. - fn upstream_leg(&self) -> Leg { - Leg { - destination: self.upstream_destination(), - } - } - - /// Where a hop to the configured server goes. - /// - /// A plane with nothing configured answers honestly rather than panicking or inventing a host: - /// the empty host is refused by the trust unit against the allow-list, which is the right place - /// for that refusal to happen. - fn upstream_destination(&self) -> DestinationFacts { - match self.servers().first() { - Some(server) => DestinationFacts::Upstream { - transport: server.transport, - address: busbar_contract::UpstreamAddress::socket(server.host), - lane: server.lane, - }, - None => DestinationFacts::Upstream { - transport: crate::tool_claims::CARRIER_HTTP, - address: busbar_contract::UpstreamAddress::socket(""), - lane: LaneId::new(""), - }, - } - } - - /// Which method row a unit's operation class came from, where the class names one. - fn row_for_op(op: busbar_contract::ids::OpClassId) -> Option<&'static ops::RpcMethodRow> { - ops::METHODS.iter().find(|r| r.op == op) - } -} - -/// The facts a request body yields, read once. -fn request_facts<'u>(body: &'u [u8], envelope: &jsonrpc::Envelope) -> Facts<'u> { - let mut facts = Facts::new(); - if let Some(method) = envelope.method_str(body) { - let _ = facts.set(f::FACT_METHOD, FactValue::Str(method)); - if let Some(row) = ops::method_row_for(method) { - // The subject is what the request is ABOUT, read from where the codec's own table says - // it lives — never from the request's content. - if let Some(pointer) = row.name_pointer { - if let Some(subject) = read_str(body, pointer) { - let _ = facts.set(f::FACT_SUBJECT, FactValue::Str(subject)); - } - } - } - } - if let Some(raw) = envelope.id_bytes(body) { - if let Ok(text) = core::str::from_utf8(raw) { - let _ = facts.set(f::FACT_RPC_ID, FactValue::Str(text)); - } - } - // The caller's own metadata block, read for the two members the loop needs and no others. The - // block's keys carry separators, which a pointer would read as levels, so the whole block is - // located by pointer and its members are read by name out of it. - if let Some(block) = read_raw(body, "/params/_meta") { - if let Some(version) = member_of(block, f::META_PROTOCOL_VERSION) { - let _ = facts.set(f::FACT_PROTOCOL_VERSION, FactValue::Str(version)); - } - if let Some(token) = member_of(block, f::META_PROGRESS_TOKEN) { - let _ = facts.set(f::FACT_PROGRESS_TOKEN, FactValue::Str(token)); - } - } - facts -} - -/// One quoted member of an object, by its exact name, one level down and no further. -/// -/// The metadata block's own keys contain separators, and a pointer reads a separator as a level, so -/// they cannot be reached by pointer at all. This walks the block's own members instead. -/// -/// It used to scan the block's bytes for the quoted name and take whatever followed. That finds the -/// name wherever it appears — as a key of a NESTED object, or written inside another member's -/// string value — and hands back a value the caller never put at that name. The progress token in -/// particular is a correlation, and a correlation read off a decoy answers the wrong request. -/// -/// A member spelled twice reads as the LAST one. This is the only place a member is read without -/// the span grammar, and the span grammar takes the last occurrence because serde_json and the -/// servers' own parsers do; a walk that stopped at the first would let the client attribute a fact -/// to a value the server never sees, which is the same decoy in a different spelling. -fn member_of<'u>(object: &'u [u8], name: &str) -> Option<&'u str> { - let mut i = skip_space(object, 0); - if object.get(i) != Some(&b'{') { - return None; - } - i += 1; - let mut latest: Option<&'u str> = None; - loop { - i = skip_space(object, i); - match object.get(i) { - Some(b'}') => return latest, - None => return None, - Some(b',') => { - i += 1; - continue; - } - Some(b'"') => {} - Some(_) => return None, - } - let (key, after_key) = string_at(object, i)?; - i = skip_space(object, after_key); - if object.get(i) != Some(&b':') { - return None; - } - i = skip_space(object, i + 1); - let matched = key_is(key, name); - if object.get(i) == Some(&b'"') { - let (value, after_value) = string_at(object, i)?; - if matched { - // A member present as a string reads as its own bytes, unescaped no more than - // before. - latest = core::str::from_utf8(value).ok(); - } - i = after_value; - } else { - i = skip_value(object, i)?; - if matched { - // A member present and not a string reads as absent, as it always has — and a later - // spelling that is not a string takes the answer back off an earlier one that was, - // because the last spelling is the one the server reads. - latest = None; - } - } - } -} - -/// Past any whitespace, from one position. -fn skip_space(bytes: &[u8], mut i: usize) -> usize { - while matches!(bytes.get(i), Some(b' ' | b'\t' | b'\n' | b'\r')) { - i += 1; - } - i -} - -/// The content of the quoted string beginning at `i`, and the position just past its closing quote. -fn string_at(bytes: &[u8], i: usize) -> Option<(&[u8], usize)> { - if bytes.get(i) != Some(&b'"') { - return None; - } - let start = i + 1; - let mut j = start; - while j < bytes.len() { - match bytes[j] { - b'\\' => j += 2, - b'"' => return Some((bytes.get(start..j)?, j + 1)), - _ => j += 1, - } - } - None -} - -/// Whether one member's raw key names exactly this member. -/// -/// The comparison is against the key as WRITTEN, which is what a name is: the two-character escapes -/// stand for the characters they name, and a `\u` escape is answered "not this member" rather than -/// half-decoded — no key this plane looks for is spelled that way, and a wrong answer here is a -/// fact attributed to the wrong member. -fn key_is(raw: &[u8], name: &str) -> bool { - let mut want = name.bytes(); - let mut i = 0; - while i < raw.len() { - let (byte, width) = match raw[i] { - b'\\' => match raw.get(i + 1) { - Some(b'"') => (b'"', 2), - Some(b'\\') => (b'\\', 2), - Some(b'/') => (b'/', 2), - Some(b'n') => (b'\n', 2), - Some(b't') => (b'\t', 2), - Some(b'r') => (b'\r', 2), - Some(b'b') => (0x08, 2), - Some(b'f') => (0x0c, 2), - _ => return false, - }, - other => (other, 1), - }; - if want.next() != Some(byte) { - return false; - } - i += width; - } - want.next().is_none() -} - -/// Past one whole non-string member value, from its first byte. -/// -/// Objects and arrays are stepped over by depth, with strings inside them consumed whole so a brace -/// written in one does not move the depth. Anything else runs to the member separator. -fn skip_value(bytes: &[u8], mut i: usize) -> Option { - let mut depth = 0usize; - loop { - match bytes.get(i)? { - b'"' => { - let (_, after) = string_at(bytes, i)?; - i = after; - if depth == 0 { - return Some(i); - } - continue; - } - b'{' | b'[' => depth += 1, - b'}' | b']' => { - if depth == 0 { - return Some(i); - } - depth -= 1; - if depth == 0 { - return Some(i + 1); - } - } - b',' if depth == 0 => return Some(i), - _ => {} - } - i += 1; - } -} - -/// A JSON boolean literal, and nothing else. -/// -/// `isError` is a boolean in the specification. A value that is not one (the string `"true"`, a -/// number, an object) says nothing either way, so it yields NO fact rather than `false`: reading it -/// as `false` reported a failing tool as a succeeding one. The answer itself is relayed unchanged, -/// as the served engine relays it; only the fact changes. -fn bool_literal(raw: &[u8]) -> Option { - match raw { - b"true" => Some(true), - b"false" => Some(false), - _ => None, - } -} - -/// Which code and words this dialect answers one refusal reason with. -/// -/// ## What this mapping is, and what it is not -/// -/// The existing codec renders a refusal through builders that are visible to its own crate only, so -/// this table cannot be read off them. What IS pinned is the ENVELOPE — the member order, the -/// always-written identifier on an error, the omitted one on a success, and the code table, all -/// asserted byte for byte in the envelope module's own tests. What is NOT pinned is the message -/// TEXT, which the composition root must compare against the battery's recorded answers on the day -/// it switches this plane on. That is stated here rather than left for someone to discover. -fn refusal_words(reason: RefusalReason) -> (i64, &'static str) { - // A class-to-wire table over the one classification (`busbar_contract::abi::plane:: - // RefusalClass`; the P-item "refusal-reason collapse"). Which family a reason belongs to is - // decided once, there; this plane says only how each family reads on its wire. The match has no - // `_` arm. Before the exhaustive form only nine reasons were mapped and the rest collapsed to - // `CODE_INTERNAL`, so a rate limit, an open breaker, a drain or a spent budget reached the caller - // as "this node broke". This protocol has its own code for a policy refusal (`CODE_REFUSED`), so - // a busbar admission / rate / budget refusal is a policy refusal and says so; only a genuine node - // fault is internal. - match class_of_refusal(reason) { - RefusalClass::TooLarge => (jsonrpc::CODE_INVALID_REQUEST, "the request is too large"), - RefusalClass::Unreadable => ( - jsonrpc::CODE_INVALID_REQUEST, - "the request could not be read", - ), - RefusalClass::Unauthenticated => ( - jsonrpc::CODE_INVALID_REQUEST, - "the request did not carry usable authority", - ), - // The caller is known and may not do this. This protocol has its own code for a policy - // refusal, and it is outside the range the specification reserves for itself. - RefusalClass::Forbidden => ( - jsonrpc::CODE_REFUSED, - "the caller may not perform this operation", - ), - // There is nowhere for it to go, or the way there is shut, which this protocol names - // specifically. - RefusalClass::NotFound | RefusalClass::Unreachable => ( - jsonrpc::CODE_UPSTREAM_UNAVAILABLE, - "no server is reachable for this request", - ), - // Every busbar-specific admission / capacity / rate / budget / drain refusal. A policy said - // no; the caller is told that and nothing about the money, the buckets or the store. - RefusalClass::Rejected - | RefusalClass::Throttled - | RefusalClass::Busy - | RefusalClass::QuotaExhausted - | RefusalClass::Unavailable - | RefusalClass::Timeout => ( - jsonrpc::CODE_REFUSED, - "the request could not be served at this time", - ), - // A genuine node-internal fault -- this node did break, and the caller is owed that fact and - // not a false policy refusal. - RefusalClass::PlaneFault | RefusalClass::NodeFault => ( - jsonrpc::CODE_INTERNAL, - "the request could not be served at this time", - ), - } -} - -/// THE nested destination a sampling request reaches, named ONCE. -/// -/// `verify` seals a destination and `route` then dials one, and a unit routed somewhere it was not -/// verified for is the failure this seam exists to make impossible. Two hand-written copies of the -/// same pair are two things that can drift; one expression cannot. -const fn sampling_destination() -> DestinationFacts { - DestinationFacts::NestedPlane { - op: meta::SAMPLING_OP, - } -} - -/// The finish class one unit ending is. -fn finish_of(end: &UnitEnd, event_framed: bool) -> FinishClass { - // One mapping, written once in the contract and read by every plane. All this plane decides is - // what a COMPLETED unit is, which is a question about the exchange and not about the ending: a - // streamed unit ends a turn of a session that continues, a unary one ends the whole answer. - busbar_contract::unit::finish_class_of( - end, - if event_framed { - FinishClass::TurnComplete - } else { - FinishClass::Complete - }, - ) -} - -impl Plane for McpPlane { - fn decode_ingress<'u>( - &self, - frames: &mut FrameCursor<'u>, - _st: Option<&mut PlaneSessionState>, - ctx: &Ctx<'u>, - ) -> Result, Decode> { - let Some(frame) = frames.next_frame() else { - return Ok(Ingress::NeedMore); - }; - let body = frame.bytes.as_slice(); - if body.is_empty() { - return Ok(Ingress::NeedMore); - } - let envelope = jsonrpc::read(body)?; - let method = envelope.method_str(body).ok_or(Decode::Malformed)?; - let facts = request_facts(body, &envelope); - - // A message with no identifier is a NOTICE. The specification forbids answering one, so a - // notice this plane recognises opens a unit that ends without writing anything, and one it - // does not recognise is DROPPED — never refused, because a refusal is an answer. - if !envelope.is_request() { - if !ops::is_known_notification(method) { - return Ok(Ingress::Discard { - reason: DiscardCode::Unsupported, - }); - } - return Ok(Ingress::OneShot(Box::new(UnitDraft { - op: ops::OP_NOTIFICATION, - body_ir: view(body, jsonrpc::REQUEST_PTRS, ctx)?, - correlates: None, - correlation_out: None, - facts, - }))); - } - - let row = ops::method_row_for(method).ok_or(Decode::UnsupportedOperation)?; - // A method an UPSTREAM sends is not one a caller may send. Reading it here would let a - // caller open a unit that only a paired server is allowed to open. - if row.sender == ops::Sender::Provider { - return Err(Decode::UnsupportedOperation); - } - let draft = UnitDraft { - op: row.op, - body_ir: view(body, jsonrpc::REQUEST_PTRS, ctx)?, - correlates: None, - correlation_out: envelope - .id_bytes(body) - .and_then(|raw| f::correlation_for(raw, ctx.arena())), - facts, - }; - if row.event_framed { - Ok(Ingress::Open(Box::new(draft))) - } else { - Ok(Ingress::OneShot(Box::new(draft))) - } - } - - fn encode_egress<'u>( - &self, - u: &Unit<'u>, - dest: &VerifiedDestination, - _st: Option<&mut PlaneSessionState>, - ctx: &Ctx<'u>, - ) -> Result, Encode> { - // The caller's envelope goes on unchanged. This protocol names its operation in the body, - // so there is nothing in an outbound request that this node rewrites — and rewriting one - // would be a byte on the wire that is not there today. - // - // The relay is a BORROW, not a copy. These bytes already live for the unit that is about to - // carry them, so copying them into the arena spent the unit's whole bounded budget on a - // second copy of what it was already holding — and a request larger than that budget could - // not be relayed at all, however small the hop it was going out on. - let body = ScratchBytes::new(u.body().body()); - let mut envelope = TransportEnvelope::default(); - let content_type = ctx - .arena() - .alloc_bytes(CONTENT_TYPE_JSON) - .map_err(|_| Encode::ScratchExhausted)?; - let _ = envelope.fields.push(busbar_contract::wire::EnvelopeField { - name: FIELD_CONTENT_TYPE, - value: content_type, - }); - if let Some(version) = ctx - .session() - .and_then(|s| s.session_fact(f::FACT_PROTOCOL_VERSION)) - { - let value = ctx - .arena() - .alloc_bytes(version.as_bytes()) - .map_err(|_| Encode::ScratchExhausted)?; - let _ = envelope.fields.push(busbar_contract::wire::EnvelopeField { - name: FIELD_PROTOCOL_VERSION, - value, - }); - } - if !matches!( - dest.facts(), - DestinationFacts::Upstream { .. } | DestinationFacts::SessionUpstream { .. } - ) { - return Err(Encode::Unrepresentable); - } - Ok(EgressBody { - envelope, - body, - auth: busbar_contract::ids::SchemeKey::new(EGRESS_SCHEME), - }) - } - - fn encode_ingress_frame<'u>( - &self, - _u: &Unit<'u>, - _f: &Frame, - _dest: &VerifiedDestination, - _st: Option<&mut PlaneSessionState>, - _ctx: &Ctx<'u>, - ) -> Result>, Encode> { - // An OPEN unit of this plane is a HELD STREAM: the request that opened it was complete in - // one frame, and what flows afterwards flows outward. So an inbound frame arriving under an - // open unit belongs to no outbound request, and the honest answer is that it is consumed and - // nothing goes out for it. - Ok(None) - } - - fn decode_response<'u>( - &self, - frames: &mut FrameCursor<'u>, - _dest: &VerifiedDestination, - st: Option<&mut PlaneSessionState>, - ctx: &Ctx<'u>, - ) -> Result, Decode> { - let Some(frame) = frames.next_frame() else { - return Ok(Progress::NeedMore); - }; - let body = frame.bytes.as_slice(); - if body.is_empty() { - return Ok(Progress::NeedMore); - } - - // A document arriving from a server that names a METHOD is the server ASKING for something, - // not answering. It opens a unit of its own and runs all seven steps, and what answers it - // costs money on this node's budget rather than on the server's. - if has(body, jsonrpc::PTR_METHOD) { - let envelope = jsonrpc::read(body)?; - let method = envelope.method_str(body).ok_or(Decode::Malformed)?; - let mut facts = Facts::new(); - let _ = facts.set(f::FACT_METHOD, FactValue::Str(method)); - if let Some(raw) = envelope.id_bytes(body) { - if let Ok(text) = core::str::from_utf8(raw) { - let _ = facts.set(f::FACT_RPC_ID, FactValue::Str(text)); - } - } - if let Some(state) = st { - if let Some(codec) = state.get_mut::() { - codec.rounds_asked = codec.rounds_asked.saturating_add(1); - } - } - let Some(row) = ops::method_row_for(method) else { - // A notice a server sends is dropped, exactly as one a caller sends is. - return Ok(Progress::Discard { - reason: DiscardCode::Unsupported, - }); - }; - // THE MIRROR OF THE INGRESS CHECK, and it must stay a mirror. - // - // Ingress refuses a caller who names a `Sender::Provider` method, because that would - // let a caller open a unit only a paired server may open. The same asymmetry runs the - // other way and is worse: `method_row_for` searches the WHOLE vocabulary, so without this an - // upstream could name `tools/call` — a `Sender::Client` method — on the response leg - // and have it minted as a genuine unit. That unit then runs all seven governance steps - // under the ORIGINAL CALLER's identity, budget and approval grant, for work the caller - // never asked for. A compromised or hostile upstream spending its victim's authority is - // the textbook confused deputy. - // - // Exactly three methods are server-initiated (`sampling/createMessage`, `roots/list`, - // `elicitation/create`). Everything else arriving with a method on this leg is refused. - if row.sender != ops::Sender::Provider { - return Ok(Progress::Discard { - reason: DiscardCode::Unsupported, - }); - } - // The subject is read HERE, at the one step entitled to read the bytes, so the steps - // after this one read it off the draft rather than scanning the request a second time. - if let Some(pointer) = row.name_pointer { - if let Some(subject) = read_str(body, pointer) { - let _ = facts.set(f::FACT_SUBJECT, FactValue::Str(subject)); - } - } - return Ok(Progress::OneShot(Box::new(UnitDraft { - op: row.op, - body_ir: view(body, jsonrpc::REQUEST_PTRS, ctx)?, - // A server's own request answers nothing; it is answered. - correlates: None, - correlation_out: envelope - .id_bytes(body) - .and_then(|raw| f::correlation_for(raw, ctx.arena())), - facts, - }))); - } - - let id = read_raw(body, jsonrpc::PTR_ID); - let is_error = has(body, jsonrpc::PTR_ERROR); - let mut facts = Facts::new(); - if let Some(raw) = id { - if let Ok(text) = core::str::from_utf8(raw) { - let _ = facts.set(f::FACT_RPC_ID, FactValue::Str(text)); - } - } - if let Some(kind) = read_str(body, jsonrpc::PTR_RESULT_TYPE) { - let _ = facts.set(f::FACT_RESULT_TYPE, FactValue::Str(kind)); - } - if let Some(flag) = read_raw(body, jsonrpc::PTR_IS_ERROR).and_then(bool_literal) { - let _ = facts.set(f::FACT_IS_ERROR, FactValue::Bool(flag)); - } - if let Some(code) = read_raw(body, jsonrpc::PTR_ERROR_CODE) { - if let Ok(text) = core::str::from_utf8(code) { - let _ = facts.set(f::FACT_ERROR_CODE, FactValue::Str(text)); - } - } - if let Some(state) = st { - if let Some(codec) = state.get_mut::() { - codec.events_read = codec.events_read.saturating_add(1); - } - } - // A result whose discriminator says it is finished IS finished. One that asks the caller for - // something, or hands back a task, is a turn rather than an ending: the exchange continues. - // - // COMPLETE IS EARNED, NOT ASSUMED. A JSON-RPC answer carries exactly one of `result` or - // `error`; a document with NEITHER is not a terminal answer at all. It used to fall through to - // the `else` and bill `Complete` — charging the caller for a full answer that never came, a - // money boundary crossed on an empty envelope. Complete now requires the `result` member to - // be present; an envelope with no result and no error is `Partial` (what arrived, arrived) - // and is billed as such, never as a completed turn. - let has_result = has(body, jsonrpc::PTR_RESULT); - let kind = read_str(body, jsonrpc::PTR_RESULT_TYPE); - let finish = if is_error { - FinishClass::Error - } else if !has_result { - FinishClass::Partial - } else if matches!( - kind, - Some(jsonrpc::RESULT_TYPE_INPUT_REQUIRED | jsonrpc::RESULT_TYPE_TASK) - ) { - FinishClass::TurnComplete - } else { - FinishClass::Complete - }; - let r = Response { - ir: view(body, jsonrpc::RESPONSE_PTRS, ctx)?, - finish, - facts, - }; - // Every answer of this protocol is one document. There is no partial answer to relay: the - // frame that carries a result carries all of it. - Ok(Progress::Terminal { - for_: id.and_then(|raw| f::correlation_for(raw, ctx.arena())), - r: Box::new(r), - }) - } - - fn encode_response<'u>( - &self, - r: &Response<'u>, - _st: Option<&mut PlaneSessionState>, - ctx: &Ctx<'u>, - ) -> Result, Encode> { - let body = r.ir.body(); - // An answer that already IS an envelope goes back exactly as it arrived. This is the common - // path and it is byte-identical by construction: the server answered the caller's own - // identifier, because the caller's own envelope is what was relayed. - if has(body, jsonrpc::PTR_VERSION) { - return ctx - .arena() - .alloc_bytes(body) - .map_err(|_| Encode::ScratchExhausted); - } - // An answer this node composed itself arrives as a bare result and is wrapped here, with - // the identifier the decode step recorded and the discriminator this node chose. - let id = match r.facts.get(f::FACT_RPC_ID) { - Some(FactValue::Str(text)) => Some(jsonrpc::id_value(text.as_bytes())?), - _ => None, - }; - let kind = match r.facts.get(f::FACT_RESULT_TYPE) { - Some(FactValue::Str(text)) => text, - _ => jsonrpc::RESULT_TYPE_COMPLETE, - }; - let bytes = jsonrpc::success(id.as_ref(), body, kind)?; - ctx.arena() - .alloc_bytes(&bytes) - .map_err(|_| Encode::ScratchExhausted) - } - - fn encode_refusal<'u>( - &self, - refusal: &Refusal, - draft: Option<&UnitDraft<'u>>, - _st: Option<&PlaneSessionState>, - ctx: &Ctx<'u>, - ) -> Result, Encode> { - let id = match draft.and_then(|d| d.facts.get(f::FACT_RPC_ID)) { - Some(FactValue::Str(text)) => Some(jsonrpc::id_value(text.as_bytes())?), - _ => None, - }; - let (code, message) = refusal_words(refusal.reason); - // A reason that implies a wait says so, under the member a caller can act on. Nothing else - // about why is disclosed. - let data = refusal - .retry_after_secs - .map(|secs| serde_json::json!({ "retryAfterSeconds": secs })); - let bytes = jsonrpc::error(id.as_ref(), code, message, data)?; - ctx.arena() - .alloc_bytes(&bytes) - .map_err(|_| Encode::ScratchExhausted) - } - - fn encode_end<'u>( - &self, - _u: &Unit<'u>, - _end: &UnitEnd, - _st: Option<&mut PlaneSessionState>, - _ctx: &Ctx<'u>, - ) -> Result>, Encode> { - // This protocol writes nothing to end a unit. An answer ends when its document has been - // written; a held stream ends when the connection does. Emitting a closing frame would be a - // byte on the wire that is not there today. - Ok(None) - } - - fn authenticate<'u>(&self, _u: &Unit<'u>, ctx: &Ctx<'u>) -> CredentialLocator { - // A locally launched server has no request to carry a header on: its credential is handed to - // it when it starts. Everything on the document transport presents a bearer credential. - let over_stdio = crate::tool_claims::is_stdio(ctx.transport().key()); - let alt = if over_stdio { "environment" } else { "bearer" }; - // A notice asks for nothing, and it used to be narrowed to an invented "anonymous" - // alternative for that reason. A notice arrives on the SAME claim a request does, though, - // and that claim declares a scheme; the surface that genuinely carries no credential is the - // discovery document, and it says so on its own claim. So a notice narrows like everything - // else on the mount, and what its credential resolves to is the auth unit's answer. - CredentialLocator { - narrowing: Some(SchemeAlt::new(alt)), - from_session: ctx - .session() - .is_some_and(busbar_contract::unit::SessionView::is_bound), - } - } - - fn verify<'u>(&self, u: &Unit<'u>, _ctx: &Ctx<'u>) -> DestinationFacts { - match u.op() { - // The listings this node answers out of its own catalogue reach a record, not a server. - ops::OP_DISCOVER - | ops::OP_TOOLS_LIST - | ops::OP_PROMPTS_LIST - | ops::OP_RESOURCES_LIST - | ops::OP_RESOURCE_TEMPLATES_LIST => DestinationFacts::PlaneRecord { - schema: rec::SCHEMA_CATALOGUE, - op: rec::OP_SCAN, - }, - // A held stream delivers back to the caller that opened it. - ops::OP_SUBSCRIPTIONS_LISTEN => DestinationFacts::Client { - selector: "opener", - mode: busbar_contract::dest::ClientMode::Deliver, - }, - // A completion is answered out of the catalogue this node already holds. It is named - // here rather than left to fall through to the server, because the routing step gives it - // one leg and that leg is the catalogue: a unit VERIFIED for a server it is never routed - // to has an upstream sealed, and the admission that seals one is spent whether or not - // anything is ever dialled. - ops::OP_COMPLETION => DestinationFacts::PlaneRecord { - schema: rec::SCHEMA_CATALOGUE, - op: rec::OP_GET, - }, - // The task operations are answered out of this node's own task records. - ops::OP_TASK_GET => DestinationFacts::PlaneRecord { - schema: rec::SCHEMA_TASK, - op: rec::OP_GET, - }, - ops::OP_TASK_UPDATE | ops::OP_TASK_CANCEL => DestinationFacts::PlaneRecord { - schema: rec::SCHEMA_TASK, - op: rec::OP_PUT, - }, - // A notice reaches nothing and answers nothing. It is recorded and that is all. - ops::OP_NOTIFICATION => DestinationFacts::PlaneRecord { - schema: rec::SCHEMA_CATALOGUE, - op: rec::OP_PUT, - }, - // A server asking for a completion is answered by the OTHER plane, one level down. This - // is the one nested destination this plane names, and it is written once so that what - // this step seals and what `route` dials are the same expression, not two agreeing ones. - ops::OP_SAMPLING => sampling_destination(), - // A server asking which roots it may work under is answered from configuration, which - // this plane reads through its own settings records. - ops::OP_ROOTS_LIST => DestinationFacts::PlaneRecord { - schema: rec::SCHEMA_SETTINGS, - op: rec::OP_GET, - }, - // A server asking the CALLER for something goes back to the caller. - ops::OP_ELICITATION => DestinationFacts::Client { - selector: "opener", - mode: busbar_contract::dest::ClientMode::Deliver, - }, - // Everything else is a hop to the server. - _ => self.upstream_destination(), - } - } - - fn route<'u>(&self, u: &Unit<'u>, _ctx: &Ctx<'u>) -> RoutePlan { - let mut plan = RoutePlan::default(); - let mut leg = |l: Leg| { - let _ = plan.legs.push(l); - }; - match u.op() { - // A call is the whole point, and it is the only operation with an approval to spend. - ops::OP_TOOL_CALL => { - // Resolve the tool, spend the grant that says this caller may use it, hop, then - // record what happened. The grant is spent BEFORE the hop, because a grant spent - // after a hop is a grant a failed hop leaves unspent for a retry to spend again. - leg(Self::record_leg(rec::SCHEMA_CATALOGUE, rec::OP_GET)); - leg(Self::record_leg(rec::SCHEMA_DEMOTION, rec::OP_GET)); - leg(Self::record_leg(rec::SCHEMA_APPROVAL, rec::OP_REDEEM)); - leg(self.upstream_leg()); - leg(Self::record_leg(rec::SCHEMA_CALL, rec::OP_APPEND)); - } - ops::OP_DISCOVER - | ops::OP_TOOLS_LIST - | ops::OP_PROMPTS_LIST - | ops::OP_RESOURCES_LIST - | ops::OP_RESOURCE_TEMPLATES_LIST => { - // A listing is answered from what was approved, minus what is quarantined. - leg(Self::record_leg(rec::SCHEMA_CATALOGUE, rec::OP_SCAN)); - leg(Self::record_leg(rec::SCHEMA_DEMOTION, rec::OP_SCAN)); - } - ops::OP_PROMPT_GET | ops::OP_RESOURCE_READ => { - leg(Self::record_leg(rec::SCHEMA_CATALOGUE, rec::OP_GET)); - leg(self.upstream_leg()); - leg(Self::record_leg(rec::SCHEMA_CALL, rec::OP_APPEND)); - } - ops::OP_COMPLETION => leg(Self::record_leg(rec::SCHEMA_CATALOGUE, rec::OP_GET)), - ops::OP_TASK_GET => leg(Self::record_leg(rec::SCHEMA_TASK, rec::OP_GET)), - ops::OP_TASK_UPDATE | ops::OP_TASK_CANCEL => { - leg(Self::record_leg(rec::SCHEMA_TASK, rec::OP_GET)); - leg(Self::record_leg(rec::SCHEMA_TASK, rec::OP_PUT)); - } - ops::OP_SUBSCRIPTIONS_LISTEN => { - leg(Self::record_leg(rec::SCHEMA_CATALOGUE, rec::OP_SCAN)); - leg(Leg { - destination: DestinationFacts::Client { - selector: "opener", - mode: busbar_contract::dest::ClientMode::Deliver, - }, - }); - } - // A server asking for a completion opens a child unit of the other plane, with its own - // hold drawn from this node's own budget. - ops::OP_SAMPLING => { - leg(Self::record_leg(rec::SCHEMA_APPROVAL, rec::OP_REDEEM)); - leg(Leg { - destination: sampling_destination(), - }); - } - ops::OP_ROOTS_LIST => leg(Self::record_leg(rec::SCHEMA_SETTINGS, rec::OP_GET)), - ops::OP_ELICITATION => leg(Leg { - destination: DestinationFacts::Client { - selector: "opener", - mode: busbar_contract::dest::ClientMode::Deliver, - }, - }), - // A notice is recorded and answered with nothing. - ops::OP_NOTIFICATION => leg(Self::record_leg(rec::SCHEMA_CATALOGUE, rec::OP_PUT)), - // An operation class this plane does not carry gets no legs, which is an empty plan and - // a refusal at the routing step. Not a panic, and not a guess. - _ => {} - } - plan - } - - fn meter<'u>(&self, u: &Unit<'u>, r: &Response<'u>, _ctx: &Ctx<'u>) -> UsageLocators { - let mut locators = UsageLocators::default(); - // A call that was answered is a call that was made. This is a count, and it is flat: the - // codec meters one attributed event per round, and this is the same statement in the - // contract's own vocabulary. - if u.op() == ops::OP_TOOL_CALL { - let _ = locators.lines.push(UsageLocator { - class: CLASS_TOOL_CALLS, - location: None, - quantity: Some(1), - lane: None, - }); - } - let _ = locators.lines.push(UsageLocator { - class: CLASS_BYTES, - // The quantity is not at a pointer: it is the size of the document the plane just read. - // So the locator carries the value and no location, which the contract allows precisely - // for the case where the plane already has the number in front of it. - location: None, - quantity: Some(r.ir.body().len() as u64), - // This protocol's answers do not name a lane. The lane is the server's, and the trust - // unit sealed it; a plane naming a second one would be a second opinion. - lane: None, - }); - locators - } - - fn audit<'u>(&self, u: &Unit<'u>, out: &UnitEnd, _ctx: &Ctx<'u>) -> AuditFacts { - let event_framed = Self::row_for_op(u.op()).is_some_and(|r| r.event_framed); - AuditFacts { - // The DRAFT's class is the one that priced the unit, and this is that class read back - // off the unit. A plane that named a different class here would be disputing its own - // earlier answer, which is exactly what the loop treats it as. - op_class: u.op(), - finish: finish_of(out, event_framed), - } - } - - fn plane_facts<'u>( - &self, - verb: AdminVerbId, - subject: Option<&'u str>, - ctx: &Ctx<'u>, - ) -> Result, Decode> { - let _ = ctx; - let mut facts = Facts::new(); - match verb { - v if v == crate::tool_meta::VERB_TOOLS => { - let _ = facts.set("count", FactValue::Int(self.servers().len() as i64)); - for server in self.servers() { - // The server's name is the key and the lane it is priced on is the value. - // Nothing here is a credential, a price or an address: an operator reading this - // learns which servers are registered and on which lane, which is what an - // introspection verb is for. - let _ = facts.set(server.id, FactValue::Str(server.lane.as_str())); - } - } - v if v == crate::tool_meta::VERB_SERVER => { - // The projection over ONE registration. A subject that names no registration is an - // unsupported operation rather than an empty answer: "there is no such server" and - // "that server has nothing to report" are different facts. - let name = subject.ok_or(Decode::UnsupportedOperation)?; - let server = self - .servers() - .iter() - .find(|s| s.id == name) - .ok_or(Decode::UnsupportedOperation)?; - let _ = facts.set(SUBJECT_FACT_NAME, FactValue::Str(server.id)); - let _ = facts.set(SUBJECT_FACT_LANE, FactValue::Str(server.lane.as_str())); - let _ = facts.set(SUBJECT_FACT_TRANSPORT, FactValue::Str(server.transport)); - // Whether this node launches the server itself, which is the one structural thing - // about a registration an operator cannot read off the name. The host itself stays - // out: an address is not introspection, it is configuration. - let _ = facts.set(SUBJECT_FACT_LOCAL, FactValue::Bool(server.host.is_empty())); - } - _ => return Err(Decode::UnsupportedOperation), - } - Ok(PlaneFacts { facts }) - } - - fn content_facts<'u>( - &self, - u: &Unit<'u>, - r: &Response<'u>, - _ctx: &Ctx<'u>, - ) -> ContentFacts<'u> { - let body = r.ir.body(); - let mut facts = Facts::new(); - // Only the declared keys, and only what was actually read. The tool's own output never - // appears here, and neither does anything the caller presented as authority. - if let Some(kind) = read_str(body, jsonrpc::PTR_RESULT_TYPE) { - let _ = facts.set(f::FACT_RESULT_TYPE, FactValue::Str(kind)); - } - if let Some(flag) = read_raw(body, jsonrpc::PTR_IS_ERROR).and_then(bool_literal) { - let _ = facts.set(f::FACT_IS_ERROR, FactValue::Bool(flag)); - } - if let Some(code) = read_raw(body, jsonrpc::PTR_ERROR_CODE) { - if let Ok(text) = core::str::from_utf8(code) { - let _ = facts.set(f::FACT_ERROR_CODE, FactValue::Str(text)); - } - } - // What the request was FOR travels with what came back, so the record joins them without a - // second read of the request: decode already found the subject and the unit carries it. - if let Some(FactValue::Str(subject)) = u.draft_facts().get(f::FACT_SUBJECT) { - let _ = facts.set(f::FACT_SUBJECT, FactValue::Str(subject)); - } - if let Some(server) = self.servers().first() { - let _ = facts.set(f::FACT_SERVER, FactValue::Str(server.id)); - } - ContentFacts { facts } - } -} - -impl SessionPlane for McpPlane { - fn open_session<'u>(&self, _ctx: &Ctx<'u>) -> PlaneSessionState { - PlaneSessionState::new(Codec::default()) - } - - fn open_upstream<'u>(&self, _dest: &VerifiedDestination, _ctx: &Ctx<'u>) -> PlaneSessionState { - PlaneSessionState::new(Codec::default()) - } -} - -#[cfg(test)] -#[path = "tests/plane.rs"] -mod tests; diff --git a/crates/busbar-plane-mcp/src/tool_program.rs b/crates/busbar-plane-mcp/src/tool_program.rs index 779c6eece4..feda190879 100644 --- a/crates/busbar-plane-mcp/src/tool_program.rs +++ b/crates/busbar-plane-mcp/src/tool_program.rs @@ -17,9 +17,12 @@ //! notification brings verify-on-call forward; every other notification is passed over; `ping`, //! an unknown method and an UNGRANTED authority ask are ANSWERED (the empty result, `-32601`, the //! operator's refusal), once per child generation whichever exchange read it first -//! ([`Peer::claim`]). A GRANTED authority ask is busbar's caller's to answer (Law 11): the -//! exchange relaying a call takes it ([`Correlator::asks`]) and busbar writes nothing back; an -//! exchange of the door's own leaves it for the call it belongs to. At most +//! ([`Peer::claim`]). A GRANTED authority ask is busbar's caller's to answer (Law 11), and only +//! the caller whose call it serves: calls whose asks may be relayed reach a child ONE AT A TIME, +//! in arrival order (the door's line), so the ask is the call's first in line ([`first_in_line`], +//! fixed by the first exchange to read it, [`Peer::owner`]); that call's exchange takes it +//! ([`Correlator::asks`]) and busbar writes nothing back, every other exchange passes it over, +//! and an ask raised while no such call is open is refused on the child's input, once. At most //! [`MAX_INTERLEAVED_MESSAGES`] such messages per exchange. //! * `initialize` runs ONCE PER GENERATION of the child (the generation each lease's head names): //! an exchange that opens on a generation the door has not greeted greets it first @@ -94,10 +97,20 @@ pub enum Message { } /// THE CHILD'S MESSAGES, out of its frames however the host's buffer cut them: whole JSON values, -/// read as they complete; the rest kept for the next piece. +/// back to back (the stdio framer hands each line without its newline). Each byte is scanned ONCE +/// for where a value ends (its nesting depth, outside strings) and a value is parsed only once it is +/// whole, so a message read in many pieces costs its size, never its size per piece (the design's +/// no-blocking rule: bounded work on the worker). #[derive(Debug, Default)] pub struct Frames { buf: Vec, + /// How far `buf` is scanned. + seen: usize, + /// Where the value being read starts in `buf`, once its first byte came. + start: Option, + depth: usize, + in_string: bool, + escaped: bool, } impl Frames { @@ -105,23 +118,58 @@ impl Frames { pub fn push(&mut self, bytes: &[u8]) -> Vec { self.buf.extend_from_slice(bytes); let mut out = Vec::new(); - let mut read = serde_json::Deserializer::from_slice(&self.buf).into_iter::(); - loop { - match read.next() { - Some(Ok(value)) => out.push(Message::Value(value)), - Some(Err(e)) if e.is_eof() => break, - Some(Err(_)) => { - // Not JSON: what is held cannot be resynchronised, so it is handed on whole. - let rest = self.buf.split_off(read.byte_offset()); - self.buf.clear(); - out.push(Message::NotJson(rest)); - return out; + let mut used = 0; + while let Some(&b) = self.buf.get(self.seen) { + let at = self.seen; + self.seen += 1; + let Some(start) = self.start else { + match b { + b' ' | b'\t' | b'\r' | b'\n' => used = self.seen, + b'{' | b'[' => { + self.start = Some(at); + self.depth = 1; + } + _ => { + // Not a message: what is held cannot be resynchronised, so it is handed on + // whole. + let rest = self.buf.split_off(at); + *self = Frames::default(); + out.push(Message::NotJson(rest)); + return out; + } + } + continue; + }; + if self.in_string { + match b { + _ if self.escaped => self.escaped = false, + b'\\' => self.escaped = true, + b'"' => self.in_string = false, + _ => {} } - None => break, + continue; + } + match b { + b'"' => self.in_string = true, + b'{' | b'[' => self.depth += 1, + b'}' | b']' => { + self.depth -= 1; + if self.depth == 0 { + let whole = &self.buf[start..self.seen]; + out.push(match serde_json::from_slice(whole) { + Ok(value) => Message::Value(value), + Err(_) => Message::NotJson(whole.to_vec()), + }); + self.start = None; + used = self.seen; + } + } + _ => {} } } - let used = read.byte_offset(); self.buf.drain(..used); + self.seen -= used; + self.start = self.start.map(|s| s - used); out } @@ -139,10 +187,47 @@ pub trait Peer { /// Whether this exchange is the first to read the request `id` of the child of `generation`, /// and so answers it. fn claim(&mut self, generation: u64, id: &Value) -> bool; + /// WHOSE the child's GRANTED ask `id` of `generation` is, fixed by the first exchange to read + /// it ([`first_in_line`]) and the same for every exchange after. + fn owner(&mut self, generation: u64, id: &Value) -> AskOwner; /// The child said its lists changed: verify-on-call is brought forward. fn notice(&mut self); + /// The child announced that its resource `uri` changed (`notifications/resources/updated`): + /// the sessions watching it are told. A peer that holds no session hears nothing. + fn announced(&mut self, uri: &str) { + let _ = uri; + } } +/// Whose one of a child's granted asks is, as the exchange reading it is told. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AskOwner { + /// The call relayed under this id: the call first in the child's line when the ask was first + /// read. Its exchange takes the ask for its caller; every other passes it over. + Call(u64), + /// No call of the child's: this exchange read it first, and refuses it on the child's input. + Refuse, + /// No call of the child's, and an exchange that read it first refused it. + Refused, +} + +/// THE CALL A CHILD'S ASK BELONGS TO (finding 5). A request over stdio names no call it serves +/// (MCP carries no related-request id on the wire, a call's progress token is its own and is never +/// echoed on an ask, and the child numbers its own requests), so the door makes the answer +/// unambiguous instead: calls whose asks may be relayed reach a child one at a time, in arrival +/// order, and the ask is the call FIRST in that line. `line` is the child's member's calls in +/// arrival order, each its id and the generation its lease reached (`0` before its head, which +/// counts on every generation). An empty line, or one whose first call is on another generation's +/// child, is `None`: the ask is refused, never handed to whichever exchange reads first (Law 11: +/// relayed down the session it belongs to, as-is). +pub fn first_in_line(line: impl IntoIterator, generation: u64) -> Option { + let (call, on) = line.into_iter().next()?; + (on == 0 || on == generation).then_some(call) +} + +/// The audit word a child's ask that no call owns is refused under. +pub const UNATTRIBUTED: &str = "ask_unattributed"; + /// ONE EXCHANGE'S READING of a child's messages: its answer by id, the replies it owes the child, /// and the progress it relays. #[derive(Debug, Default)] @@ -289,8 +374,13 @@ impl Correlator { let reply = match message { ServerMessage::Notification(n) => { match n.effect() { - NotificationEffect::BringRefreshForward - | NotificationEffect::RelayResourceUpdate => peer.notice(), + NotificationEffect::BringRefreshForward => peer.notice(), + NotificationEffect::RelayResourceUpdate => { + peer.notice(); + if let Some(uri) = value.pointer("/params/uri").and_then(Value::as_str) { + peer.announced(uri); + } + } NotificationEffect::RelayProgress => { let token = format!("busbar-{wait}"); if value @@ -306,21 +396,38 @@ impl Correlator { None } ServerMessage::UnknownNotification(_) => None, - // A GRANTED AUTHORITY ASK is the caller's (Law 11): the exchange relaying the call takes - // it, and one of the door's own leaves it unclaimed for that exchange to read. + // A GRANTED AUTHORITY ASK is the caller's (Law 11), and only the caller whose call it + // serves: the exchange relaying the call first in the child's line takes it, every + // other passes it over, and one raised while no call is open is refused once. ServerMessage::Request { id, verb } - if verb.ask().is_some_and(|ask| peer.grants().allows(ask)) => + if verb.ask().is_some_and(|a| peer.grants().allows(a)) => { - if self.relays && peer.claim(generation, &id) { - let mut request = value.as_object().cloned().unwrap_or_default(); - request.remove("jsonrpc"); - request.remove("id"); - self.asks.push(ChildAsk { - id, - request: Value::Object(request), - }); + match peer.owner(generation, &id) { + AskOwner::Call(call) if self.relays && call == wait => { + let mut request = value.as_object().cloned().unwrap_or_default(); + request.remove("jsonrpc"); + request.remove("id"); + self.asks.push(ChildAsk { + id, + request: Value::Object(request), + }); + None + } + AskOwner::Refuse => { + let kind = verb.ask().map(|a| a.key()).unwrap_or_default(); + Some(crate::client::peer::refused( + &id, + UNATTRIBUTED, + format!( + "server `{member}` asked for `{kind}` while busbar was relaying it \ + no call, and a request over stdio names no call it serves, so the \ + ask has no caller to go to; it is refused rather than put to \ + another caller." + ), + )) + } + AskOwner::Call(_) | AskOwner::Refused => None, } - None } ServerMessage::Request { id, verb } => peer .claim(generation, &id) diff --git a/crates/busbar-plane-mcp/src/tool_sessions.rs b/crates/busbar-plane-mcp/src/tool_sessions.rs index abead3847e..6140d28a43 100644 --- a/crates/busbar-plane-mcp/src/tool_sessions.rs +++ b/crates/busbar-plane-mcp/src/tool_sessions.rs @@ -298,6 +298,13 @@ impl SessionTable { self.bounds } + /// Whether `id` names a session the table still holds, whoever owns it (state kept beside a + /// session is dropped once this is false). + #[must_use] + pub fn holds(&self, id: &str) -> bool { + self.sessions.contains_key(id) + } + /// Live sessions (expired ones not yet swept included). #[must_use] pub fn len(&self) -> usize { @@ -485,7 +492,13 @@ impl SessionTable { } freed += f; } - self.hold_bytes(owner, cost - freed); + // Trimming to fit can free more than this event cost (one older large event, or this + // event itself past `max_session_bytes`): the owner is then charged less, never negative. + if freed > cost { + self.release_bytes(owner, freed - cost); + } else { + self.hold_bytes(owner, cost - freed); + } self.enforce(owner, id); Some(event_id(stream, seq)) } diff --git a/crates/busbar-plane-mcp/src/tool_tasks.rs b/crates/busbar-plane-mcp/src/tool_tasks.rs index 2fe54cb5c4..8be2d8fdde 100644 --- a/crates/busbar-plane-mcp/src/tool_tasks.rs +++ b/crates/busbar-plane-mcp/src/tool_tasks.rs @@ -35,11 +35,20 @@ //! //! ## DURABILITY //! -//! The served engine's registry was in-process: a restart lost every task. Here the handle is the -//! kernel's (durable, scoped to the instance and the principal), and a terminal task's result is -//! written to the plane's own records in chunks ([`result_chunks`]), so a settled task is answered -//! across a restart. A task still running when its process ended has no continuation left; it is -//! answered as the served engine answered every task after a restart: unknown. +//! The served engine's registry was in-process: a restart lost every task. Here THE TASK STORE IS +//! HOST RECORDS (THE DESIGN, the mcp bullet): the handle is the kernel's (durable, scoped to the +//! instance and the principal); a live task's state — its status, its `inputRequests`, the answers +//! it holds, the upstream ask it is parked on — is written to the plane's own records in chunks +//! ([`live_parts`]) by every unit that moves it; and a terminal task's result likewise +//! ([`result_chunks`]). So `tasks/get`, `tasks/update` and `tasks/cancel` answer a task from any +//! node and across a restart; what an instance holds of a task is a cache of those rows, plus the +//! halves only its own process has (the run it took, the unit running it). +//! +//! Every live task is indexed under its caller in the plane's records with its run's LEASE +//! ([`TaskLease`]). A task-creating call settles, `cancelled`, every live task of its caller no process +//! holds whose lease lapsed or that nothing moved past the abandonment ceiling: the handles a +//! process that is gone left behind never exhaust the bound of live work (THE DESIGN, "Admission bounds +//! live work; nothing evicts it"). //! //! What IS honoured unconditionally is STRONG CONSISTENCY: the creating unit holds the task before //! the caller is handed its id, so a `tasks/get` issued with no delay between the two resolves. @@ -101,6 +110,15 @@ pub const RESULT_CHUNK_BYTES: usize = 480; /// in hand and is not written. pub const MAX_RESULT_CHUNKS: usize = 256; +/// THE RUN'S LEASE beyond its server's own `timeout:`: how long a task's run may go unheard from +/// before another process takes its handle as left behind (the process running it gone) and settles +/// it `cancelled`. Taken when the continuation is nested and renewed as each call goes out. +pub const RUN_LEASE_MS: u64 = 300_000; + +/// The live-state chunks a settle strikes at the least: what a process that did not write a task's +/// live state strikes of it. +pub const LIVE_STRIKES: u32 = 4; + /// A task's lifecycle state, as the wire spells it. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Status { @@ -187,6 +205,12 @@ pub struct Task { /// THE UPSTREAM'S ASK IT IS PARKED ON, relayed (Law 11): answered through `tasks/update` and /// carried back to the member that asked, never merged into the tool's arguments. relay: Option, + /// THE ROUND OF ITS OWN ASKS IT IS PARKED ON, and the call its run resumes with: the + /// continuation ended with the handle live, and the `tasks/update` that answers the round + /// nests the resume. + pub asked: Option<(usize, Value)>, + /// How many live-state chunks this instance wrote for it. + pub live_chunks: u32, } /// A TASK PARKED ON ITS UPSTREAM'S ASK: busbar's sealed state (bound to the principal, the tool and @@ -226,6 +250,8 @@ impl Task { unsettled: false, chunks: 0, relay: None, + asked: None, + live_chunks: 0, } } @@ -256,6 +282,117 @@ impl Task { self.principal == principal } + /// The key of its index row ([`TaskLease`]) in the plane's records. + #[must_use] + pub fn index_key(&self) -> Vec { + index_key(&self.principal, &self.id) + } + + /// Whether it is parked on its upstream's ask. + #[must_use] + pub fn relayed(&self) -> bool { + self.relay.is_some() + } + + /// THE LIVE STATE its records keep ([`live_parts`]): its status and last update, its + /// `inputRequests` in order, the answers it holds, the upstream ask and the round of its own + /// asks it is parked on. + #[must_use] + pub fn live(&self) -> Value { + let requests: Vec = self + .input_requests + .iter() + .map(|(k, v)| json!([k, v])) + .collect(); + json!({ + "status": self.status.token(), + "updated": self.updated_ms, + "requests": requests, + "answers": self.answers, + "relay": self.relay.as_ref().map(|p| json!({ + "state": p.state, + "params": p.params, + "keys": p.keys, + "responses": p.responses, + })), + "asked": self.asked.as_ref().map(|(round, params)| json!([round, params])), + }) + } + + /// The live state its records hold ([`Task::live`]'s document), laid over a task read from its + /// row; a document that is not one leaves it as it is. + pub fn take_live(&mut self, live: &Value) { + let (Some(status), Some(updated)) = ( + live.get("status") + .and_then(Value::as_str) + .and_then(Status::of_token), + live.get("updated").and_then(Value::as_u64), + ) else { + return; + }; + self.status = status; + self.updated_ms = updated; + self.input_requests = live + .get("requests") + .and_then(Value::as_array) + .map(|pairs| { + pairs + .iter() + .filter_map(|p| { + let pair = p.as_array()?; + Some((pair.first()?.as_str()?.to_string(), pair.get(1)?.clone())) + }) + .collect() + }) + .unwrap_or_default(); + self.answers = live + .get("answers") + .and_then(Value::as_object) + .cloned() + .unwrap_or_default(); + self.relay = live.get("relay").and_then(|r| { + Some(RelayPark { + state: r.get("state")?.as_str()?.to_string(), + params: r.get("params")?.clone(), + keys: r + .get("keys")? + .as_array()? + .iter() + .filter_map(|k| k.as_str().map(str::to_string)) + .collect(), + responses: r.get("responses")?.as_object()?.clone(), + }) + }); + self.asked = live.get("asked").and_then(|a| { + let pair = a.as_array()?; + Some(( + usize::try_from(pair.first()?.as_u64()?).ok()?, + pair.get(1)?.clone(), + )) + }); + } + + /// THE HOST'S WORD over what the instance holds of this task: `host` (read from its rows and + /// records) replaces it, the instance keeping only its own halves — the run taken here, the unit + /// running it, a settle still owed, the chunks it wrote. + pub fn hosted(&mut self, host: Task) { + let kept = ( + self.started, + self.runner, + self.unsettled, + self.chunks, + self.live_chunks, + ); + *self = host; + ( + self.started, + self.runner, + self.unsettled, + self.chunks, + self.live_chunks, + ) = kept; + } + /// Its status. #[must_use] pub fn status(&self) -> Status { @@ -375,7 +512,12 @@ impl Task { /// Returns `false`, applying NOTHING, when this batch would grow the task's answer map past /// [`MAX_TASK_ANSWERS`] DISTINCT keys — refused whole, never truncated. A key already held is a /// REPEAT, not new, so re-answering one never counts against the ceiling. + /// + /// A TERMINAL task takes no input: the update is acknowledged and changes nothing. pub fn deliver(&mut self, responses: &Map, now_ms: u64) -> bool { + if self.status.is_terminal() { + return true; + } if let Some(park) = self.relay.as_mut() { for (key, value) in responses { if park.keys.contains(key) { @@ -649,17 +791,115 @@ pub fn chunk_prefix(id: &str) -> Vec { /// [`RESULT_CHUNK_BYTES`]; none when it would take more than [`MAX_RESULT_CHUNKS`]. #[must_use] pub fn result_chunks(id: &str, terminal: &Value) -> Vec<(Vec, Vec)> { - let bytes = serde_json::to_vec(terminal).unwrap_or_default(); + chunked(|n| chunk_key(id, n), terminal) +} + +/// `value`'s bytes cut into plane records of at most [`RESULT_CHUNK_BYTES`], keyed `key(n)`; none +/// when it would take more than [`MAX_RESULT_CHUNKS`]. +fn chunked(key: impl Fn(u32) -> Vec, value: &Value) -> Vec<(Vec, Vec)> { + let bytes = serde_json::to_vec(value).unwrap_or_default(); let chunks: Vec<&[u8]> = bytes.chunks(RESULT_CHUNK_BYTES).collect(); if chunks.len() > MAX_RESULT_CHUNKS { return Vec::new(); } (0u32..) .zip(chunks) - .map(|(n, c)| (chunk_key(id, n), c.to_vec())) + .map(|(n, c)| (key(n), c.to_vec())) .collect() } +/// The key of chunk `n` of task `id`'s live state: ordered, apart from its result's. +#[must_use] +pub fn live_key(id: &str, n: u32) -> Vec { + format!("{id}~/{n:04}").into_bytes() +} + +/// The prefix every chunk of task `id`'s live state is keyed under. +#[must_use] +pub fn live_prefix(id: &str) -> Vec { + format!("{id}~/").into_bytes() +} + +/// THE LIVE STATE, IN CHUNKS ([`Task::live`]), as [`result_chunks`] cuts a result. +#[must_use] +pub fn live_parts(id: &str, live: &Value) -> Vec<(Vec, Vec)> { + chunked(|n| live_key(id, n), live) +} + +/// The live state read back from its chunks' bytes, in key order: the first document they hold (an +/// earlier, longer state's chunks past it are not read); `None` when they hold none. +#[must_use] +pub fn read_live(bytes: &[u8]) -> Option { + serde_json::Deserializer::from_slice(bytes) + .into_iter::() + .next()? + .ok() +} + +/// The prefix `principal`'s index rows are keyed under: a digest of the principal, never the +/// principal itself. +#[must_use] +pub fn index_prefix(principal: &str) -> Vec { + let owner = busbar_contract::redacted::sha256_hex(principal.as_bytes()); + format!("o/{}/", &owner[..32]).into_bytes() +} + +/// The key of task `id`'s index row under `principal`. +#[must_use] +pub fn index_key(principal: &str, id: &str) -> Vec { + let mut key = index_prefix(principal); + key.extend_from_slice(id.as_bytes()); + key +} + +/// A LIVE TASK'S INDEX ROW: until when a run holds it, and when it last moved. What a create reads +/// to find the tasks of its caller a process that is gone left behind. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct TaskLease { + /// Until when its run holds it (Unix ms); `0`: no run does (it is parked on its caller). + pub until_ms: u64, + /// When it last moved (Unix ms). + pub updated_ms: u64, +} + +/// The index row's version word. +const LEASE_V1: &str = "l1"; + +impl TaskLease { + /// The row's bytes: `l1||`. + #[must_use] + pub fn bytes(&self) -> Vec { + format!("{LEASE_V1}|{}|{}", self.until_ms, self.updated_ms).into_bytes() + } + + /// A row read back; `None` for bytes that are not one. + #[must_use] + pub fn read(bytes: &[u8]) -> Option { + let text = std::str::from_utf8(bytes).ok()?; + let mut parts = text.split('|'); + if parts.next()? != LEASE_V1 { + return None; + } + let until_ms = parts.next()?.parse().ok()?; + let updated_ms = parts.next()?.parse().ok()?; + if parts.next().is_some() { + return None; + } + Some(TaskLease { + until_ms, + updated_ms, + }) + } + + /// Whether the task it indexes is LEFT BEHIND at `now_ms`: its run's lease lapsed (the process + /// running it is gone), or nothing moved it past [`ACTIVE_TASK_ABANDON_MS`]. + #[must_use] + pub fn left_behind(&self, now_ms: u64) -> bool { + (self.until_ms != 0 && now_ms > self.until_ms) + || now_ms.saturating_sub(self.updated_ms) > ACTIVE_TASK_ABANDON_MS + } +} + /// The terminal value read back from its chunks (in key order); `None` when they do not make one. #[must_use] pub fn read_chunks<'a>(chunks: impl Iterator) -> Option { diff --git a/crates/busbar-plane-mcp/src/tools_config.rs b/crates/busbar-plane-mcp/src/tools_config.rs index cc2d43c425..ab932a8aa3 100644 --- a/crates/busbar-plane-mcp/src/tools_config.rs +++ b/crates/busbar-plane-mcp/src/tools_config.rs @@ -241,17 +241,16 @@ pub struct ToolAllowCfg { /// widening it to legalise whatever it felt like returning that day. So it is approved here, /// beside the digest, by the operator who vouches for the tool. /// - /// ## And publishing it is only half of keeping it + /// ## Publishing it is all busbar does with it /// - /// busbar does not compute the structured result; an upstream does. Publishing a schema and - /// relaying whatever came back would put busbar in violation of that MUST every time the - /// upstream lied, with busbar's name on the answer. So `mcp::method` VALIDATES an upstream's - /// `structuredContent` against this schema before it reaches the caller, and a violation is - /// reported as a TOOL FAILURE — the upstream did not do what the operator approved it to do. + /// busbar does not compute the structured result; an upstream does, and its result reaches the + /// caller as the upstream sent it (Law 11). busbar never validates `structuredContent` against + /// this schema and never replaces a result that does not match it: the caller holds the schema + /// and judges the result. /// - /// ABSENT ⇒ no `outputSchema` is published and nothing is validated, which is every - /// registration that predates this field. There is no default and there is no inference: a - /// schema busbar guessed would be a promise nobody made. + /// ABSENT ⇒ no `outputSchema` is published, which is every registration that predates this + /// field. There is no default and there is no inference: a schema busbar guessed would be a + /// promise nobody made. #[serde(default, skip_serializing_if = "Option::is_none")] pub output_schema: Option, /// THE WIRE NAME busbar publishes for this tool, overriding the default diff --git a/crates/busbar-plane-mcp/tests/alloc_gate.rs b/crates/busbar-plane-mcp/tests/alloc_gate.rs index 458f80ca93..445dc260ba 100644 --- a/crates/busbar-plane-mcp/tests/alloc_gate.rs +++ b/crates/busbar-plane-mcp/tests/alloc_gate.rs @@ -1,21 +1,10 @@ -//! What reading a request's metadata block is allowed to allocate. +//! What reading a stdio child's message is allowed to allocate. //! -//! Two of the block's keys are read on every request that carries one, and both are constants. A -//! lookup that builds its search text at request time spends a heap allocation per key to spell out -//! something that was known at compile time — too small for a stopwatch to see on a shared runner, -//! and paid on every single request. An allocation COUNT sees it, and is the same number on every -//! machine, so it can be pinned. -//! -//! The bound is exact. Decoding is a pure synchronous walk over bytes with no I/O and no clock, so -//! its count does not vary run to run. If an intentional change moves it, run with `--nocapture`, -//! read the printed count, and move the constant in the same commit. - -mod common; +//! An allocation COUNT is the same number on every machine, so work that grows with the input +//! where it should not is seen here where a stopwatch on a shared runner would miss it. (The +//! decode-allocation pin that lived here drove the unserved `Plane` impl, deleted with it under +//! plane-mcp finding 12.) -use busbar_contract::plane::{Ingress, Plane}; -use busbar_contract::wire::FrameCursor; -use busbar_plane_mcp::{tool_facts as facts, McpPlane}; -use common::{frame, Scaffold}; use std::alloc::{GlobalAlloc, Layout, System}; use std::cell::Cell; @@ -54,71 +43,43 @@ fn allocations_of(f: impl FnOnce()) -> u64 { ALLOCS.with(Cell::get) - before } -/// One request carrying a metadata block with both of the keys the decode step reads. -fn body_with_metadata() -> Vec { - format!( - r#"{{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{{"_meta":{{"{}":"2026-07-28","{}":"tok-1"}}}}}}"#, - facts::META_PROTOCOL_VERSION, - facts::META_PROGRESS_TOKEN - ) - .into_bytes() -} - -/// COMMITTED BASELINE — the exact allocation count of decoding ONE request whose metadata block -/// carries both read keys. -/// -/// Two: the span table the decode resolves into the arena for the loop to read, and the box the -/// draft travels in (the plane's per-frame answer is an enum whose largest arm would otherwise be -/// copied by value at every hand-over, so the draft is heap-placed once, at decode). Nothing else -/// about reading a request of this protocol needs memory: the body is read where it lies and a -/// numeric identifier correlates as the number it is. -/// -/// It was eight. The six that are gone were the search text the member lookup built for each of the -/// two keys it reads — a formatted string apiece, and formatting a string is more than one -/// allocation — to spell out names the crate was compiled holding. -const DECODE_WITH_METADATA_ALLOCS: u64 = 2; - +/// RED (finding 14, the design's no-blocking rule: bounded work): a stdio child's message arriving +/// in many small pieces is read ONCE. Re-parsing everything held on every piece builds and drops +/// the message's completed part once per piece: quadratic in its size (a multi-megabyte tool result +/// costs seconds of worker CPU per lease). Scanning only the new bytes and parsing only a whole +/// message keeps the count linear: one parse's worth of allocations and the buffer's growth. #[test] -fn reading_the_metadata_block_builds_no_search_text() { - let plane = McpPlane::EMPTY; - let scaffold = Scaffold::new("http"); - let ctx = scaffold.ctx(); - let body = body_with_metadata(); - let frames = vec![frame(&body)]; - - // One warm call outside the window, so nothing a first call sets up for the process is charged - // to the measured one. - { - let mut cursor = FrameCursor::new(&frames); - let decoded = plane - .decode_ingress(&mut cursor, None, &ctx) - .expect("the body is this protocol's shape"); - assert!( - matches!(decoded, Ingress::OneShot(_) | Ingress::Open(_)), - "a whole request decodes as a unit, got {decoded:?}" - ); - } - - let mut carried = None; +fn a_childs_message_read_in_small_pieces_is_parsed_once() { + use busbar_plane_mcp::tool_program::{Frames, Message}; + use serde_json::{json, Value}; + const ITEMS: u64 = 2000; + let content: Vec = (0..ITEMS) + .map(|i| json!({"type": "text", "text": format!("t{i}")})) + .collect(); + let message = + serde_json::to_vec(&json!({"jsonrpc": "2.0", "id": 7, "result": {"content": content}})) + .unwrap(); + let mut frames = Frames::default(); + let mut read = Vec::with_capacity(4); let count = allocations_of(|| { - let mut cursor = FrameCursor::new(&frames); - let decoded = plane - .decode_ingress(&mut cursor, None, &ctx) - .expect("the body is this protocol's shape"); - let draft = match decoded { - Ingress::OneShot(d) | Ingress::Open(d) | Ingress::Handshake(d) => d, - other => panic!("a whole request decodes as a unit, got {other:?}"), - }; - carried = draft.facts.get(facts::FACT_PROGRESS_TOKEN); + for piece in message.chunks(64) { + read.extend(frames.push(piece)); + } }); - println!("decode-with-metadata allocations: {count}"); - assert!( - carried.is_some(), - "the progress token in the metadata block is still read" + println!( + "{} bytes in {} pieces: {count} allocations", + message.len(), + message.len().div_ceil(64) ); assert_eq!( - count, DECODE_WITH_METADATA_ALLOCS, - "decoding allocated {count} times, not {DECODE_WITH_METADATA_ALLOCS}: it is building text \ - it could have been compiled with" + read, + vec![Message::Value(serde_json::from_slice(&message).unwrap())], + "the message is read whole, once" + ); + assert!( + count < 20 * ITEMS, + "reading one {}-byte message in 64-byte pieces allocated {count} times: what is held is \ + re-parsed on every piece", + message.len() ); } diff --git a/crates/busbar-plane-mcp/tests/common/mod.rs b/crates/busbar-plane-mcp/tests/common/mod.rs deleted file mode 100644 index bd5113b355..0000000000 --- a/crates/busbar-plane-mcp/tests/common/mod.rs +++ /dev/null @@ -1,311 +0,0 @@ -//! The scaffolding one plane call needs, and nothing more. -//! -//! A plane is handed a context carrying one resource — the arena — and a handful of borrowed -//! read-only views. Everything below is the smallest honest stand-in for each: an arena that hands -//! out bytes, views that answer what they were told to answer, and a seal that lets a test build the -//! kernel-owned values the loop would otherwise build. -//! -//! The seal deserves a sentence. The contract's kernel-side values take a reference to a seal so a -//! plugin cannot fabricate its own evidence, and the contract says out loud that the trait is public -//! and that what really stops a plugin implementing it is the manifest allow-list rather than the -//! type system. A TEST implementing it is exactly the case that admission contemplates: nothing here -//! ships, and a test that could not build a unit could not call the methods that take one. - -#![allow(dead_code)] - -use busbar_contract::bounded::{ - Labels, PlaneAlloc, PlaneAllocBudget, ScratchBytes, SlabBytes, Span, -}; -use busbar_contract::ids::{PrincipalId, SessionId}; -use busbar_contract::unit::{Clock, ConfigView, Ctx, SessionView, TransportView}; -use busbar_contract::wire::{Direction, Frame, FrameMeta}; -use std::sync::atomic::{AtomicUsize, Ordering}; - -/// An arena that hands out bytes and counts what it handed out. -/// -/// It leaks rather than reusing a buffer, which is the right trade for a test: the real arena -/// resets per unit, and a test that had to model the reset would be testing the arena rather than -/// the plane. -pub struct TestPlaneAlloc { - used: AtomicUsize, - ceiling: usize, -} - -impl TestPlaneAlloc { - /// An arena with the contract's own per-unit ceiling. - pub fn new() -> Self { - Self { - used: AtomicUsize::new(0), - ceiling: busbar_contract::bounded::SCRATCH_BASE_BYTES, - } - } - - /// An arena that runs out after a given number of bytes. - pub fn with_ceiling(ceiling: usize) -> Self { - Self { - used: AtomicUsize::new(0), - ceiling, - } - } -} - -impl Default for TestPlaneAlloc { - fn default() -> Self { - Self::new() - } -} - -impl PlaneAlloc for TestPlaneAlloc { - fn alloc_bytes<'a>(&'a self, src: &[u8]) -> Result, PlaneAllocBudget> { - let remaining = self - .ceiling - .saturating_sub(self.used.load(Ordering::Relaxed)); - if src.len() > remaining { - return Err(PlaneAllocBudget { - wanted: src.len(), - remaining, - }); - } - self.used.fetch_add(src.len(), Ordering::Relaxed); - let leaked: &'static [u8] = Box::leak(src.to_vec().into_boxed_slice()); - Ok(ScratchBytes::new(leaked)) - } - - fn alloc_str<'a>(&'a self, src: &str) -> Result<&'a str, PlaneAllocBudget> { - let remaining = self - .ceiling - .saturating_sub(self.used.load(Ordering::Relaxed)); - if src.len() > remaining { - return Err(PlaneAllocBudget { - wanted: src.len(), - remaining, - }); - } - self.used.fetch_add(src.len(), Ordering::Relaxed); - let leaked: &'static str = Box::leak(src.to_string().into_boxed_str()); - Ok(leaked) - } - - fn alloc_spans<'a>( - &'a self, - src: &[(&'a str, Span)], - ) -> Result<&'a [(&'a str, Span)], PlaneAllocBudget> { - let wanted = std::mem::size_of_val(src); - let remaining = self - .ceiling - .saturating_sub(self.used.load(Ordering::Relaxed)); - if wanted > remaining { - return Err(PlaneAllocBudget { wanted, remaining }); - } - self.used.fetch_add(wanted, Ordering::Relaxed); - Ok(Box::leak(src.to_vec().into_boxed_slice())) - } - - fn remaining(&self) -> usize { - self.ceiling - .saturating_sub(self.used.load(Ordering::Relaxed)) - } -} - -/// A configuration block with nothing in it. -pub struct EmptyConfig; - -impl ConfigView for EmptyConfig { - fn get_str(&self, _key: &str) -> Option<&str> { - None - } - fn get_int(&self, _key: &str) -> Option { - None - } - fn get_bool(&self, _key: &str) -> Option { - None - } -} - -/// A transport that answers with the key it was given. -pub struct TestTransport { - pub key: &'static str, - pub chain: Vec<&'static str>, -} - -impl TestTransport { - /// A transport stack of one named layer. - pub fn new(key: &'static str) -> Self { - Self { - key, - chain: vec![key], - } - } -} - -impl TransportView for TestTransport { - fn key(&self) -> &'static str { - self.key - } - fn chain(&self) -> &[&'static str] { - &self.chain - } - fn fact(&self, _key: &str) -> Option<&str> { - None - } -} - -/// A session that answers what it was told to answer. -pub struct TestSession { - pub id: SessionId, - pub bound: bool, - pub facts: Vec<(&'static str, String)>, -} - -impl TestSession { - /// An unbound session with no facts. - pub fn new() -> Self { - Self { - id: SessionId(1), - bound: false, - facts: Vec::new(), - } - } - - /// The same session, bound. - pub fn bound() -> Self { - Self { - bound: true, - ..Self::new() - } - } -} - -impl Default for TestSession { - fn default() -> Self { - Self::new() - } -} - -impl SessionView for TestSession { - fn id(&self) -> SessionId { - self.id - } - fn is_bound(&self) -> bool { - self.bound - } - fn session_fact(&self, key: &str) -> Option<&str> { - self.facts - .iter() - .find(|(k, _)| *k == key) - .map(|(_, v)| v.as_str()) - } - fn transport_fact(&self, _key: &str) -> Option<&str> { - None - } - fn upstream_count(&self) -> usize { - 0 - } -} - -/// The blessed TEST seal (#65). `KernelSeal` is SEALED — no crate outside `busbar-contract` can -/// implement it — so a fixture names the contract's own `test-seal` type instead of forging one. -/// The type system stops a plugin now, not the manifest allow-list alone. -// Shared by several test binaries; not every one of them builds a sealed value. -#[allow(unused_imports)] -pub use busbar_contract::plugin::TestKernelSeal as TestSeal; - -/// A clock frozen at a readable instant, so nothing here varies with when it ran. -pub const CLOCK: Clock = Clock { - unix_secs: 1_700_000_000, - monotonic_nanos: 0, -}; - -/// One inbound frame carrying a document. -pub fn frame(bytes: &[u8]) -> Frame { - Frame { - direction: Direction::Inbound, - stream: busbar_contract::ids::StreamId(0), - bytes: SlabBytes::new(std::sync::Arc::from(bytes.to_vec().into_boxed_slice())), - meta: FrameMeta { - bytes: bytes.len() as u64, - transport_units: None, - status: None, - status_code: None, - retry_after_secs: None, - text: false, - }, - } -} - -/// One outbound frame carrying a document. -pub fn response_frame(bytes: &[u8]) -> Frame { - Frame { - direction: Direction::Outbound, - ..frame(bytes) - } -} - -/// Everything a context borrows, held together so a test can build one. -pub struct Scaffold { - pub arena: TestPlaneAlloc, - pub config: EmptyConfig, - pub transport: TestTransport, - pub session: TestSession, - pub labels: Labels<'static>, -} - -impl Scaffold { - /// A scaffold over one named transport. - pub fn new(transport: &'static str) -> Self { - Self { - arena: TestPlaneAlloc::new(), - config: EmptyConfig, - transport: TestTransport::new(transport), - session: TestSession::new(), - labels: Labels::new(), - } - } - - /// The context itself. - pub fn ctx(&self) -> Ctx<'_> { - Ctx::new( - CLOCK, - &self.config, - Some(&self.session), - &self.transport, - &self.labels, - &self.arena, - ) - } - - /// The same context, with the wall-clock reading chosen by the caller. - /// - /// A test that asks whether an answer moves with the clock needs two readings to hand over; - /// every other test wants the one frozen reading [`Self::ctx`] supplies. - pub fn ctx_at(&self, unix_secs: u64) -> Ctx<'_> { - Ctx::new( - Clock { - unix_secs, - monotonic_nanos: CLOCK.monotonic_nanos, - }, - &self.config, - Some(&self.session), - &self.transport, - &self.labels, - &self.arena, - ) - } - - /// A context with no session, as a one-shot transport hands one over. - pub fn ctx_without_session(&self) -> Ctx<'_> { - Ctx::new( - CLOCK, - &self.config, - None, - &self.transport, - &self.labels, - &self.arena, - ) - } -} - -/// A principal, for the units a test builds. -pub fn principal() -> PrincipalId { - PrincipalId::new("test-principal") -} diff --git a/crates/busbar-plane-mcp/tests/conformance.rs b/crates/busbar-plane-mcp/tests/conformance.rs index 44ab860013..b6c417ba2a 100644 --- a/crates/busbar-plane-mcp/tests/conformance.rs +++ b/crates/busbar-plane-mcp/tests/conformance.rs @@ -1,33 +1,18 @@ -//! The plane, driven over the bytes the conformance battery actually sends. -//! -//! ## Why this shape, and what it is not +//! The plane's vocabulary against the conformance battery, and the plane's door driven both ways. //! //! The judge of this work is the battery: the official suite and the in-house adversarial battery, -//! both of which speak to a booted node over a socket. Neither can run here, because the composition -//! root does not yet hand a request to this plane — the existing engine still answers every one of -//! them. So these tests do the next thing that is actually evidence rather than decoration: they -//! build requests the way the battery's own request builder builds them, drive each through this -//! plane's decode step, and assert the operation class and the correlation it produces. The -//! vocabulary is read out of the battery's own suites and out of the codec's own source, so a -//! battery that starts sending something new fails HERE rather than in a run someone has to -//! interpret. +//! both of which speak to a booted node over a socket. The vocabulary tests here read the +//! battery's own sources (its method names, error code table, metadata keys and revision) and +//! assert this crate's tables carry them, so a battery that starts sending something new fails HERE +//! rather than in a run someone has to interpret. The `both_ways` module drives the served door +//! through the loader, linked and dropped-in, and compares the transcripts. //! -//! What these tests DO NOT do is drive the existing engine beside this plane and compare. That is -//! written down as a limitation rather than worked around: the existing plane's request entry point -//! is visible to its own crate only, it takes an engine handle and an async runtime, and its request -//! and context types are private. There is no way to call it from here at all. The envelope side is -//! therefore pinned differently — against the serializer, the codec's own code table and the -//! battery's own metadata keys, byte for byte — and the operation side is pinned against the -//! battery's own vocabulary. - -mod common; - -use busbar_contract::plane::{ - Ingress, Plane, PlaneMeta, Progress, Response, SessionPlane, UnitDraft, -}; -use busbar_contract::wire::{Decode, DiscardCode, FrameCursor}; +//! The tests that drove the unserved `Plane` trait impl (decode, encode, verify, route, meter) were +//! retired with that impl (finding 12; the coordinator's order "delete the dead Plane impl with +//! its struck route"); the served door's own tests carry what the door does. + +use busbar_contract::plane::PlaneMeta; use busbar_plane_mcp::{jsonrpc, tool_facts as facts, tool_ops as ops, McpPlane}; -use common::{frame, response_frame, Scaffold}; use std::path::{Path, PathBuf}; /// The battery's own source tree. @@ -89,44 +74,6 @@ fn looks_like_a_method(piece: &str) -> bool { heads.iter().any(|h| piece.starts_with(h)) && !piece.contains(' ') } -/// One request envelope, built the way the battery's own builder builds one. -/// -/// The battery always sends the metadata block, so every request here does too: a fixture that -/// omitted it would be exercising a shape no run ever produces. -fn request(id: &str, method: &str) -> Vec { - format!( - r#"{{"jsonrpc":"2.0","id":{id},"method":"{method}","params":{{"_meta":{{"{}":"2026-07-28","{}":{{}}}}}}}}"#, - facts::META_PROTOCOL_VERSION, - facts::META_CLIENT_CAPABILITIES - ) - .into_bytes() -} - -/// One notification, built the same way. -fn notification(method: &str) -> Vec { - format!(r#"{{"jsonrpc":"2.0","method":"{method}","params":{{}}}}"#).into_bytes() -} - -/// Drive one body through the decode step and hand back what the plane made of it. -fn decode(plane: &McpPlane, body: &[u8]) -> Result, Decode> { - let scaffold = Box::leak(Box::new(Scaffold::new("http"))); - let ctx = scaffold.ctx(); - let frames: &'static [busbar_contract::wire::Frame] = Box::leak(vec![frame(body)].into()); - let mut cursor = FrameCursor::new(frames); - // The context borrows the leaked scaffold, so the draft it produces borrows the leaked frames - // and outlives this call. Leaking is the right trade for a test: the real arena resets per unit, - // and a test that had to model the reset would be testing the arena rather than the plane. - plane.decode_ingress(&mut cursor, None, &ctx) -} - -/// The draft a decode produced, or a failure naming what it produced instead. -fn draft_of(ingress: Ingress<'static>) -> UnitDraft<'static> { - match ingress { - Ingress::Open(d) | Ingress::OneShot(d) | Ingress::Handshake(d) => *d, - other => panic!("a well-formed request decoded as {other:?}"), - } -} - /// Every method the battery sends is one this plane carries, in one of its three roles. #[test] fn every_method_the_battery_sends_is_carried() { @@ -165,135 +112,30 @@ fn rows_of(sender: ops::Sender) -> Vec<&'static ops::RpcMethodRow> { ops::METHODS.iter().filter(|r| r.sender == sender).collect() } -/// Every method a caller sends decodes to a declared class, carrying the caller's identifier. -#[test] -fn every_client_method_decodes() { - let plane = McpPlane::EMPTY; - let rows = rows_of(ops::Sender::Client); - assert_eq!(rows.len(), CLIENT_ROWS); - for row in rows { - let body = request("1", row.method); - let draft = draft_of(decode(&plane, &body).unwrap_or_else(|e| { - panic!( - "a caller may send {} and this plane answered {e:?}", - row.method - ) - })); - assert_eq!(draft.op, row.op, "{} named the wrong class", row.method); - assert_eq!( - draft.correlation_out.expect("a request correlates").value, - busbar_contract::ids::CorrelationValue::Num(1), - "{} lost its identifier", - row.method - ); - // A request answers nothing; it is answered. - assert!(draft.correlates.is_none()); - } -} - -/// The four the battery sends by name decode to exactly the classes they should. +/// The method table keeps the roles the door serves: how many rows each role has, every row's class +/// is one the plane declares, and exactly the held stream is event-framed. /// -/// The battery's suites send these four and no others, so this is the narrowest statement that -/// covers what a run actually exercises. +/// The surviving, plane-free half of what the decode-driven tests asserted (finding 12): the door +/// reads this same table (`ops::method_row_for`), so the table is what is pinned here. #[test] -fn the_four_the_battery_sends_name_their_classes() { - let plane = McpPlane::EMPTY; - for (method, expected) in [ - ("server/discover", ops::OP_DISCOVER), - ("tools/list", ops::OP_TOOLS_LIST), - ("tools/call", ops::OP_TOOL_CALL), - ("subscriptions/listen", ops::OP_SUBSCRIPTIONS_LISTEN), - ] { - let draft = draft_of( - decode(&plane, &request("1", method)).expect("the battery's own method decodes"), - ); - assert_eq!(draft.op, expected); - } -} - -/// A method the battery sends deliberately, expecting a refusal, is refused. -#[test] -fn the_batterys_nonsense_method_is_refused() { - let plane = McpPlane::EMPTY; - assert_eq!( - decode(&plane, &request("1", "this/method/does/not/exist")), - Err(Decode::UnsupportedOperation) - ); -} - -/// A method only an upstream may send is refused on the ingress side. -/// -/// A caller that could send one would be opening a unit only a paired server is allowed to open, -/// and this node would answer it on the caller's behalf. -#[test] -fn a_caller_cannot_send_an_upstreams_method() { - let plane = McpPlane::EMPTY; - let rows = rows_of(ops::Sender::Provider); - assert_eq!(rows.len(), PROVIDER_ROWS); - for row in rows { - assert_eq!( - decode(&plane, &request("1", row.method)), - Err(Decode::UnsupportedOperation), - "a caller was allowed to send {}", - row.method - ); - } -} - -/// A held stream opens a unit; every other method is complete in one frame. -#[test] -fn only_the_held_stream_opens_a_unit() { - let plane = McpPlane::EMPTY; - let rows = rows_of(ops::Sender::Client); - assert_eq!(rows.len(), CLIENT_ROWS); - for row in rows { - match (decode(&plane, &request("1", row.method)), row.event_framed) { - (Ok(Ingress::Open(_)), true) | (Ok(Ingress::OneShot(_)), false) => {} - (other, _) => panic!("{} decoded as {other:?}", row.method), - } - } -} - -/// A notice this plane recognises opens a unit that answers nothing. -#[test] -fn a_recognised_notice_opens_a_unit_that_answers_nothing() { - let plane = McpPlane::EMPTY; +fn the_method_table_keeps_its_roles_and_its_declared_classes() { + assert_eq!(rows_of(ops::Sender::Client).len(), CLIENT_ROWS); + assert_eq!(rows_of(ops::Sender::Provider).len(), PROVIDER_ROWS); assert_eq!(ops::NOTIFICATIONS.len(), NOTICE_ROWS); - for name in ops::NOTIFICATIONS { - let draft = draft_of(decode(&plane, ¬ification(name)).expect("a notice decodes")); - assert_eq!(draft.op, ops::OP_NOTIFICATION); - // Nothing correlates: a notice obliges no answer, so there is nothing to answer it with. - assert!(draft.correlation_out.is_none()); - assert!(draft.correlates.is_none()); + for row in ops::METHODS { + assert!( + McpPlane::OP_CLASSES.contains(&row.op), + "{} names the undeclared class {}", + row.method, + row.op + ); } -} - -/// A notice this plane does not recognise is dropped, never refused. -/// -/// The specification forbids answering a notice, and a refusal is an answer. -#[test] -fn an_unrecognised_notice_is_dropped() { - let plane = McpPlane::EMPTY; - assert_eq!( - decode(&plane, ¬ification("notifications/something/else")), - Ok(Ingress::Discard { - reason: DiscardCode::Unsupported - }) - ); -} - -/// The metadata block the battery sends is read, keys and all. -/// -/// The keys carry separators, which a pointer would read as levels, so this is the case that would -/// silently read as absent if the reader were written the obvious way. -#[test] -fn the_batterys_metadata_block_is_read() { - let plane = McpPlane::EMPTY; - let draft = draft_of(decode(&plane, &request("1", "tools/list")).expect("it decodes")); - assert_eq!( - draft.facts.get(facts::FACT_PROTOCOL_VERSION), - Some(busbar_contract::bounded::FactValue::Str("2026-07-28")) - ); + let framed: Vec<_> = ops::METHODS + .iter() + .filter(|r| r.event_framed) + .map(|r| r.op) + .collect(); + assert_eq!(framed, vec![ops::OP_SUBSCRIPTIONS_LISTEN]); } /// The revision the battery declares is the revision the codec declares. @@ -368,746 +210,6 @@ fn the_metadata_keys_are_the_batterys_own() { } } -/// An answer that already is an envelope goes back exactly as it arrived. -#[test] -fn an_answer_goes_back_as_it_arrived() { - let plane = McpPlane::EMPTY; - let scaffold = Scaffold::new("http"); - let ctx = scaffold.ctx(); - let answer = br#"{"id":1,"jsonrpc":"2.0","result":{"resultType":"complete","tools":[]}}"#; - let r = Response { - ir: busbar_contract::bounded::Ir::new(answer, &[]), - finish: busbar_contract::unit::FinishClass::Complete, - facts: busbar_contract::bounded::Facts::new(), - }; - let out = plane - .encode_response(&r, None, &ctx) - .expect("it re-encodes"); - assert_eq!(out.as_slice(), answer); -} - -/// An answer this node composed itself is wrapped, stamped and given the caller's identifier. -#[test] -fn a_composed_answer_is_stamped_and_wrapped() { - let plane = McpPlane::EMPTY; - let scaffold = Scaffold::new("http"); - let ctx = scaffold.ctx(); - let mut facts_map = busbar_contract::bounded::Facts::new(); - facts_map - .set( - facts::FACT_RPC_ID, - busbar_contract::bounded::FactValue::Str("1"), - ) - .expect("one key fits"); - let r = Response { - ir: busbar_contract::bounded::Ir::new(br#"{"tools":[]}"#, &[]), - finish: busbar_contract::unit::FinishClass::Complete, - facts: facts_map, - }; - let out = plane.encode_response(&r, None, &ctx).expect("it wraps"); - assert_eq!( - core::str::from_utf8(out.as_slice()).unwrap(), - r#"{"id":1,"jsonrpc":"2.0","result":{"resultType":"complete","tools":[]}}"# - ); -} - -/// AN UPSTREAM CANNOT SEND A CALLER'S METHOD — the mirror of -/// [`a_caller_cannot_send_an_upstreams_method`], and the direction that is actually dangerous. -/// -/// `ops::method_row_for` searches the WHOLE vocabulary with no sender filter. Without the guard in -/// `decode_response`, a compromised upstream naming `tools/call` on the response leg had it minted -/// as a genuine unit and run through all seven governance steps under the ORIGINAL CALLER's -/// identity, budget and approval grant — a confused deputy spending its victim's authority for work -/// the victim never requested. Ingress had this check; egress did not. -#[test] -fn an_upstream_cannot_send_a_callers_method() { - let plane = McpPlane::EMPTY; - let scaffold = Scaffold::new("http"); - let ctx = scaffold.ctx(); - let rows = rows_of(ops::Sender::Client); - assert_eq!(rows.len(), CLIENT_ROWS); - for row in rows { - let asked = format!( - r#"{{"jsonrpc":"2.0","id":7,"method":"{}","params":{{}}}}"#, - row.method - ); - let frames = vec![response_frame(asked.as_bytes())]; - let mut cursor = FrameCursor::new(&frames); - match plane - .decode_response(&mut cursor, &sealed_destination(), None, &ctx) - .expect("a refused method still decodes to a verdict") - { - Progress::Discard { .. } => {} - other => panic!( - "an upstream was allowed to open a unit with the caller-only method {}: {other:?}", - row.method - ), - } - } -} - -/// A document a server sends back mid-call opens a unit of the server's own. -#[test] -fn a_servers_own_request_opens_a_provider_unit() { - let plane = McpPlane::EMPTY; - let scaffold = Scaffold::new("http"); - let ctx = scaffold.ctx(); - let asked = br#"{"jsonrpc":"2.0","id":42,"method":"sampling/createMessage","params":{}}"#; - let frames = vec![response_frame(asked)]; - let mut cursor = FrameCursor::new(&frames); - match plane - .decode_response(&mut cursor, &sealed_destination(), None, &ctx) - .expect("a server's own request decodes") - { - Progress::OneShot(draft) => { - assert_eq!(draft.op, ops::OP_SAMPLING); - assert_eq!( - draft.correlation_out.expect("it correlates").value, - busbar_contract::ids::CorrelationValue::Num(42) - ); - } - other => panic!("a server's own request decoded as {other:?}"), - } -} - -/// A result that asks the caller for something is a turn, not an ending. -#[test] -fn a_result_that_asks_for_something_is_a_turn() { - let plane = McpPlane::EMPTY; - let scaffold = Scaffold::new("http"); - let ctx = scaffold.ctx(); - for (kind, expected) in [ - ( - jsonrpc::RESULT_TYPE_COMPLETE, - busbar_contract::unit::FinishClass::Complete, - ), - ( - jsonrpc::RESULT_TYPE_INPUT_REQUIRED, - busbar_contract::unit::FinishClass::TurnComplete, - ), - ( - jsonrpc::RESULT_TYPE_TASK, - busbar_contract::unit::FinishClass::TurnComplete, - ), - ] { - let answer = format!(r#"{{"id":1,"jsonrpc":"2.0","result":{{"resultType":"{kind}"}}}}"#) - .into_bytes(); - let frames = vec![response_frame(&answer)]; - let mut cursor = FrameCursor::new(&frames); - match plane - .decode_response(&mut cursor, &sealed_destination(), None, &ctx) - .expect("an answer decodes") - { - Progress::Terminal { r, .. } => assert_eq!(r.finish, expected, "{kind} ended wrongly"), - other => panic!("{kind} decoded as {other:?}"), - } - } -} - -/// P-ITEM: EMPTY REPLY / UNARY-EMPTY TERMINALITY (spec DONE item 2, "All P-item behaviours match -/// 1.5.5"; the drive log's P4, commit 470351a480, which the money briefs name "mcp bills empty -/// answer as complete"). An envelope with a real result is billed complete exactly once; one with -/// neither result nor error is billed `Partial`, never complete. -/// -/// This is a money boundary. A JSON-RPC answer carries exactly one of `result` or `error`; a -/// document with NEITHER used to fall through to `Complete` and charge the caller for a full answer -/// that never came. A genuine result closes the unit `Complete`; an empty envelope is `Partial`. -/// The 1.5.5 behaviour this plane matches is its one surface's (the llm surface; owner correction -/// 2026-09-28): a response the caller cannot use is not billed as a delivered one (v1.5.5 -/// `crates/busbar/src/proxy/response_body.rs:415-440`). -#[test] -fn p_item_empty_reply_only_a_real_result_bills_complete() { - let plane = McpPlane::EMPTY; - let scaffold = Scaffold::new("http"); - let ctx = scaffold.ctx(); - - // A genuine result: complete, exactly once (one Terminal, one Complete). - let answer = br#"{"id":1,"jsonrpc":"2.0","result":{"resultType":"complete","tools":[]}}"#; - let frames = vec![response_frame(answer)]; - let mut cursor = FrameCursor::new(&frames); - match plane - .decode_response(&mut cursor, &sealed_destination(), None, &ctx) - .expect("a real answer decodes") - { - Progress::Terminal { r, .. } => assert_eq!( - r.finish, - busbar_contract::unit::FinishClass::Complete, - "a real result must bill complete" - ), - other => panic!("a real result decoded as {other:?}"), - } - - // An envelope with neither result nor error must NOT bill complete. - let empty = br#"{"id":1,"jsonrpc":"2.0"}"#; - let frames = vec![response_frame(empty)]; - let mut cursor = FrameCursor::new(&frames); - match plane - .decode_response(&mut cursor, &sealed_destination(), None, &ctx) - .expect("an empty envelope decodes") - { - Progress::Terminal { r, .. } => assert_eq!( - r.finish, - busbar_contract::unit::FinishClass::Partial, - "an empty envelope must bill what arrived (Partial), never complete" - ), - other => panic!("an empty envelope decoded as {other:?}"), - } -} - -/// `isError` yields a fact only when it is a JSON boolean. -/// -/// A non-boolean `isError` (the string `"true"`, a number) used to read as `false`, reporting a -/// failing tool as a succeeding one. It now yields no fact at all; a real boolean still yields -/// itself. The answer is relayed unchanged either way, as predev relays it. -#[test] -fn a_non_boolean_is_error_is_not_read_as_success() { - let plane = McpPlane::EMPTY; - // Reading an answer consults no carrier, so the scaffold names none. - let scaffold = Scaffold::new(""); - let ctx = scaffold.ctx(); - for (flag, expected) in [ - ("true", Some(true)), - ("false", Some(false)), - ("\"true\"", None), - ("\"false\"", None), - ("1", None), - ("{}", None), - ] { - let answer = - format!(r#"{{"id":1,"jsonrpc":"2.0","result":{{"content":[],"isError":{flag}}}}}"#) - .into_bytes(); - let frames = vec![response_frame(&answer)]; - let mut cursor = FrameCursor::new(&frames); - match plane - .decode_response(&mut cursor, &sealed_destination(), None, &ctx) - .expect("an answer decodes") - { - Progress::Terminal { r, .. } => { - let read = match r.facts.get(facts::FACT_IS_ERROR) { - Some(busbar_contract::bounded::FactValue::Bool(b)) => Some(b), - None => None, - other => panic!("isError {flag} read as {other:?}"), - }; - assert_eq!(read, expected, "isError {flag}"); - } - other => panic!("isError {flag} decoded as {other:?}"), - } - } -} - -/// A refusal is rendered as this dialect's error envelope, with the caller's identifier. -#[test] -fn a_refusal_is_rendered_in_this_dialect() { - let plane = McpPlane::EMPTY; - let scaffold = Scaffold::new("http"); - let ctx = scaffold.ctx(); - let draft = draft_of(decode(&plane, &request("8", "tools/call")).expect("it decodes")); - let refusal = busbar_contract::unit::Refusal { - step: busbar_contract::unit::Step::Approve, - reason: busbar_contract::unit::RefusalReason::ScopeMissing, - retry_after_secs: None, - stream: None, - correlates: None, - }; - let out = plane - .encode_refusal(&refusal, Some(&draft), None, &ctx) - .expect("a refusal renders"); - let value: serde_json::Value = - serde_json::from_slice(out.as_slice()).expect("it is a document"); - assert_eq!(value["id"], 8); - assert_eq!(value["jsonrpc"], "2.0"); - assert_eq!(value["error"]["code"], jsonrpc::CODE_REFUSED); -} - -/// A refusal that implies a wait says so, under a member a caller can act on. -#[test] -fn a_refusal_that_implies_a_wait_says_so() { - let plane = McpPlane::EMPTY; - let scaffold = Scaffold::new("http"); - let ctx = scaffold.ctx(); - let refusal = busbar_contract::unit::Refusal { - step: busbar_contract::unit::Step::Admit, - reason: busbar_contract::unit::RefusalReason::OverBudget, - retry_after_secs: Some(30), - stream: None, - correlates: None, - }; - let out = plane - .encode_refusal(&refusal, None, None, &ctx) - .expect("a refusal renders"); - let value: serde_json::Value = - serde_json::from_slice(out.as_slice()).expect("it is a document"); - assert_eq!(value["error"]["data"]["retryAfterSeconds"], 30); - // And the identifier member is present and empty, because a peer's own test for "is this a - // response" is whether the member is there at all. - assert!(value["id"].is_null()); - assert!(value.as_object().expect("an object").contains_key("id")); -} - -/// How many legs each operation class routes to. -/// -/// The count is the shape of the plan — a call spends a grant, hops, and settles; a listing reaches -/// one record — so a leg that appears or disappears is a change to what an operation DOES, and it is -/// written down here rather than left to a bound that can never fail. -const EXPECTED_LEGS: &[(&str, usize)] = &[ - ("discover", 2), - ("tools_list", 2), - ("tool_call", 5), - ("prompts_list", 2), - ("prompt_get", 3), - ("resources_list", 2), - ("resource_templates_list", 2), - ("resource_read", 3), - ("completion", 1), - ("task_get", 1), - ("task_update", 2), - ("task_cancel", 2), - ("subscriptions_listen", 2), - ("sampling", 2), - ("roots_list", 1), - ("elicitation", 1), - ("notification", 1), -]; - -/// Every operation class routes to at least one leg, and every leg is one its schema declares. -#[test] -fn every_operation_routes_somewhere() { - let plane = McpPlane::EMPTY; - let scaffold = Scaffold::new("http"); - let ctx = scaffold.ctx(); - let seal = common::TestSeal; - let mut covered = 0usize; - for op in McpPlane::OP_CLASSES { - covered += 1; - let unit = busbar_contract::unit::Unit::new( - &seal, - busbar_contract::UnitKey::new(1), - busbar_contract::unit::Origin::Client, - None, - None, - busbar_contract::wire::Direction::Inbound, - Some(common::principal()), - *op, - busbar_contract::bounded::Ir::new(b"{}", &[]), - busbar_contract::bounded::Facts::new(), - None, - ); - let plan = plane.route(&unit, &ctx); - assert!(!plan.legs.is_empty(), "{op} routes nowhere"); - let name = op.to_string(); - let expected = EXPECTED_LEGS - .iter() - .find(|(n, _)| *n == name) - .map(|(_, legs)| *legs) - .unwrap_or_else(|| panic!("{op} has no expected leg count written down")); - assert_eq!(plan.legs.len(), expected, "{op} routes to a different plan"); - // A plan filled to its ceiling is one a further leg would be dropped from without a word, - // so the ceiling is asserted as headroom rather than as a bound that cannot fail. - assert!( - !plan.legs.is_full(), - "{op} routes with no leg headroom left" - ); - for leg in plan.legs.as_slice() { - if let busbar_contract::dest::DestinationFacts::PlaneRecord { schema, op: rop } = - leg.destination - { - assert!( - busbar_plane_mcp::tool_records::operations_for(schema).contains(&rop), - "{op} reaches {schema} with an operation it does not declare: {rop}" - ); - } - } - } - // The loop walks a DECLARED table, so an empty one would walk nothing and report `ok`, and a - // class dropped from it would leave its written-down leg count behind unchallenged. The two - // tables are pinned equal in size, which makes both of those a failure here. - assert_eq!( - covered, - EXPECTED_LEGS.len(), - "the plane declares {covered} operation classes and {} leg counts are written down: a \ - class with no row is unproven, and a row with no class proves nothing", - EXPECTED_LEGS.len() - ); -} - -/// A call spends its grant before the hop, never after. -/// -/// A grant spent after a hop is a grant a failed hop leaves unspent, and a retry can then spend it -/// again. The order of the legs is what makes that impossible, so the order is what is asserted. -#[test] -fn a_call_spends_its_grant_before_the_hop() { - let plane = McpPlane::EMPTY; - let scaffold = Scaffold::new("http"); - let ctx = scaffold.ctx(); - let seal = common::TestSeal; - let unit = busbar_contract::unit::Unit::new( - &seal, - busbar_contract::UnitKey::new(1), - busbar_contract::unit::Origin::Client, - None, - None, - busbar_contract::wire::Direction::Inbound, - Some(common::principal()), - ops::OP_TOOL_CALL, - busbar_contract::bounded::Ir::new(b"{}", &[]), - busbar_contract::bounded::Facts::new(), - None, - ); - let plan = plane.route(&unit, &ctx); - let legs = plan.legs.as_slice(); - let redeem = legs - .iter() - .position(|l| { - matches!( - l.destination, - busbar_contract::dest::DestinationFacts::PlaneRecord { op, .. } - if op == busbar_plane_mcp::tool_records::OP_REDEEM - ) - }) - .expect("a call spends a grant"); - let hop = legs - .iter() - .position(|l| { - matches!( - l.destination, - busbar_contract::dest::DestinationFacts::Upstream { .. } - ) - }) - .expect("a call hops"); - assert!(redeem < hop, "the grant is spent after the hop"); -} - -/// The metering step reports both declared classes for a call, and one for everything else. -#[test] -fn the_metering_step_reports_what_it_read() { - let plane = McpPlane::EMPTY; - let scaffold = Scaffold::new("http"); - let ctx = scaffold.ctx(); - let seal = common::TestSeal; - let answer = br#"{"id":1,"jsonrpc":"2.0","result":{"resultType":"complete"}}"#; - let r = Response { - ir: busbar_contract::bounded::Ir::new(answer, &[]), - finish: busbar_contract::unit::FinishClass::Complete, - facts: busbar_contract::bounded::Facts::new(), - }; - for (op, lines) in [(ops::OP_TOOL_CALL, 2), (ops::OP_TOOLS_LIST, 1)] { - let unit = busbar_contract::unit::Unit::new( - &seal, - busbar_contract::UnitKey::new(1), - busbar_contract::unit::Origin::Client, - None, - None, - busbar_contract::wire::Direction::Inbound, - Some(common::principal()), - op, - busbar_contract::bounded::Ir::new(b"{}", &[]), - busbar_contract::bounded::Facts::new(), - None, - ); - let locators = plane.meter(&unit, &r, &ctx); - assert_eq!( - locators.lines.len(), - lines, - "{op} metered the wrong number of lines" - ); - for line in locators.lines.as_slice() { - // A plane names no lane and no price. - assert!(line.lane.is_none()); - let declared = McpPlane::METER_CLASSES - .iter() - .find(|c| c.key == line.class) - .unwrap_or_else(|| panic!("{op} meters the undeclared class {}", line.class)); - // Which SIDE the class says it is sized from is the side the quantity was taken from. - // Both quantities here come off the answer — the call is counted once it has been - // answered, and the byte count is the answer's own length — so both classes declare - // themselves sized from the answer. A class that declared the request and reported the - // answer would have a rate card pricing one side of the exchange at the size of the - // other. - assert_eq!( - declared.direction, - busbar_contract::ids::ClassDirection::Response, - "{} is metered off the answer and declares another side", - line.class - ); - } - } -} - -/// The introspection verb answers, and an undeclared verb does not. -#[test] -fn the_introspection_verb_answers_only_what_is_declared() { - let plane = McpPlane::EMPTY; - let scaffold = Scaffold::new("http"); - let ctx = scaffold.ctx(); - let facts = plane - .plane_facts(busbar_plane_mcp::tool_meta::VERB_TOOLS, None, &ctx) - .expect("the declared verb answers"); - assert_eq!( - facts.facts.get("count"), - Some(busbar_contract::bounded::FactValue::Int(0)) - ); - assert!(plane - .plane_facts( - busbar_contract::ids::AdminVerbId::new("secrets"), - None, - &ctx - ) - .is_err()); -} - -/// The per-name projection answers for the registration the subject names, and for no other. -/// -/// This is the projection that could not be declared at all while the introspection verb carried no -/// argument: one verb, one subject, one registration. A subject naming nothing is refused rather -/// than answered empty, because "there is no such server" is not "that server has nothing to say". -#[test] -fn the_per_name_projection_answers_for_the_named_registration() { - static SERVERS: &[busbar_plane_mcp::Server] = &[ - busbar_plane_mcp::Server { - id: "alpha", - lane: busbar_contract::ids::LaneId::new("mcp-a"), - host: "alpha.invalid:443", - transport: "http", - }, - busbar_plane_mcp::Server { - id: "beta", - lane: busbar_contract::ids::LaneId::new("mcp-b"), - host: "", - transport: "stdio", - }, - ]; - let plane = McpPlane::new(SERVERS); - let scaffold = Scaffold::new("http"); - let ctx = scaffold.ctx(); - let verb = busbar_plane_mcp::tool_meta::VERB_SERVER; - - let alpha = plane - .plane_facts(verb, Some("alpha"), &ctx) - .expect("a named registration answers"); - assert_eq!( - alpha.facts.get("name"), - Some(busbar_contract::bounded::FactValue::Str("alpha")) - ); - assert_eq!( - alpha.facts.get("lane"), - Some(busbar_contract::bounded::FactValue::Str("mcp-a")) - ); - assert_eq!( - alpha.facts.get("transport"), - Some(busbar_contract::bounded::FactValue::Str("http")) - ); - assert_eq!( - alpha.facts.get("local"), - Some(busbar_contract::bounded::FactValue::Bool(false)) - ); - - // The other registration answers for itself, so the subject is what selects, not the order. - let beta = plane - .plane_facts(verb, Some("beta"), &ctx) - .expect("the other named registration answers"); - assert_eq!( - beta.facts.get("lane"), - Some(busbar_contract::bounded::FactValue::Str("mcp-b")) - ); - assert_eq!( - beta.facts.get("local"), - Some(busbar_contract::bounded::FactValue::Bool(true)) - ); - - // A subject that names nothing, and no subject at all, are both refusals. - assert!(plane.plane_facts(verb, Some("gamma"), &ctx).is_err()); - assert!(plane.plane_facts(verb, None, &ctx).is_err()); - - // And the per-name verb is declared, so the loop can reach it. - assert!(::INTROSPECTION_VERBS.contains(&verb)); -} - -/// The session halves open, and each one starts fresh. -#[test] -fn the_session_halves_open_fresh() { - let plane = McpPlane::EMPTY; - let scaffold = Scaffold::new("http"); - let ctx = scaffold.ctx(); - let client = plane.open_session(&ctx); - let upstream = plane.open_upstream(&sealed_destination(), &ctx); - for half in [&client, &upstream] { - let codec = half - .get::() - .expect("the half carries this plane's own state"); - assert_eq!((codec.events_read, codec.rounds_asked), (0, 0)); - } -} - -/// A locally launched server's units narrow to the alternative that has no request to sit on. -#[test] -fn a_local_server_narrows_to_the_environment_alternative() { - let plane = McpPlane::EMPTY; - let seal = common::TestSeal; - let unit = busbar_contract::unit::Unit::new( - &seal, - busbar_contract::UnitKey::new(1), - busbar_contract::unit::Origin::Client, - None, - None, - busbar_contract::wire::Direction::Inbound, - Some(common::principal()), - ops::OP_TOOL_CALL, - busbar_contract::bounded::Ir::new(b"{}", &[]), - busbar_contract::bounded::Facts::new(), - None, - ); - for (transport, expected) in [("stdio", "environment"), ("http", "bearer")] { - let scaffold = Scaffold::new(transport); - let ctx = scaffold.ctx(); - let locator = plane.authenticate(&unit, &ctx); - assert_eq!( - locator.narrowing.expect("it narrows").as_str(), - expected, - "{transport} narrowed wrongly" - ); - } -} - -/// Every alternative the plane narrows to is one its claims declare. -/// -/// A plane may only narrow within the set its claim declares; anything else is refused at the -/// authenticate step, and a plane that narrowed outside it would be refusing its own units. -#[test] -fn every_narrowing_is_declared() { - // The alternatives of a claim that DECLARES a scheme. The open surface's claim declares none, - // which is the whole point of it: there is nothing there to narrow to, so it cannot be the - // claim this check is read against. - let declared = McpPlane::CLAIMS - .iter() - .find(|c| c.scheme.is_some()) - .expect("some claim declares a scheme") - .scheme_alternatives; - let plane = McpPlane::EMPTY; - let seal = common::TestSeal; - for op in McpPlane::OP_CLASSES { - for transport in ["http", "sse", "stdio"] { - let scaffold = Scaffold::new(transport); - let ctx = scaffold.ctx(); - let unit = busbar_contract::unit::Unit::new( - &seal, - busbar_contract::UnitKey::new(1), - busbar_contract::unit::Origin::Client, - None, - None, - busbar_contract::wire::Direction::Inbound, - Some(common::principal()), - *op, - busbar_contract::bounded::Ir::new(b"{}", &[]), - busbar_contract::bounded::Facts::new(), - None, - ); - let narrowing = plane - .authenticate(&unit, &ctx) - .narrowing - .expect("it narrows"); - assert!( - declared.contains(&narrowing.as_str()), - "{op} on {transport} narrows to {narrowing}, which no claim declares" - ); - } - } -} - -/// A request bigger than the per-unit arena is still relayed, byte for byte. -/// -/// The bytes the hop carries are the bytes that arrived, and they already live for the unit that -/// carries them. Copying them into the arena first spent the whole bounded budget on a second copy -/// of what the unit was already holding, so a request larger than that budget could not be relayed -/// at all — a size limit nobody configured, imposed by an allocation with no purpose. -#[test] -fn a_request_larger_than_the_arena_is_relayed() { - let plane = McpPlane::EMPTY; - let scaffold = Scaffold::new("http"); - let ctx = scaffold.ctx(); - let seal = common::TestSeal; - let argument = "x".repeat(busbar_contract::bounded::SCRATCH_BASE_BYTES * 2); - let body = format!( - r#"{{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{{"name":"search","arguments":{{"q":"{argument}"}}}}}}"# - ); - let body = body.into_bytes(); - assert!(body.len() > busbar_contract::bounded::SCRATCH_BASE_BYTES); - let unit = busbar_contract::unit::Unit::new( - &seal, - busbar_contract::UnitKey::new(1), - busbar_contract::unit::Origin::Client, - None, - None, - busbar_contract::wire::Direction::Inbound, - Some(common::principal()), - ops::OP_TOOL_CALL, - busbar_contract::bounded::Ir::new(&body, &[]), - busbar_contract::bounded::Facts::new(), - None, - ); - let egress = plane - .encode_egress(&unit, &sealed_destination(), None, &ctx) - .expect("a request larger than the arena is still a request this plane can relay"); - assert_eq!( - egress.body.as_slice(), - body.as_slice(), - "the server is sent the caller's own bytes, whole" - ); -} - -/// What every operation SEALS is somewhere its own plan then goes. -/// -/// The verify step seals one destination and the route step lists the legs the unit dials. A class -/// that seals an upstream and then plans no leg to it has had an upstream admitted, checked against -/// the allow-list and counted against this node's admission for a hop that never happens; a class -/// that plans a leg it was not verified for is the other half of the same seam. Asserted over the -/// whole declared vocabulary, so a class added later cannot quietly acquire either shape. -#[test] -fn every_operation_is_verified_for_a_destination_its_plan_reaches() { - let plane = McpPlane::EMPTY; - let scaffold = Scaffold::new("http"); - let ctx = scaffold.ctx(); - let seal = common::TestSeal; - for op in ::OP_CLASSES { - let unit = busbar_contract::unit::Unit::new( - &seal, - busbar_contract::UnitKey::new(1), - busbar_contract::unit::Origin::Client, - None, - None, - busbar_contract::wire::Direction::Inbound, - Some(common::principal()), - *op, - busbar_contract::bounded::Ir::new(b"{}", &[]), - busbar_contract::bounded::Facts::new(), - None, - ); - let verified = plane.verify(&unit, &ctx); - let plan = plane.route(&unit, &ctx); - assert!( - plan.legs - .as_slice() - .iter() - .any(|l| l.destination == verified), - "{op} is verified for {verified:?}, which none of its legs reach" - ); - } -} - -/// A sealed destination, for the calls that take one. -fn sealed_destination() -> busbar_contract::dest::VerifiedDestination { - let seal = common::TestSeal; - busbar_contract::dest::VerifiedDestination::seal( - &seal, - busbar_contract::dest::DestinationFacts::Upstream { - transport: "http", - address: busbar_contract::UpstreamAddress::socket("server.example"), - lane: busbar_contract::ids::LaneId::new("standard"), - }, - "http", - None, - ) -} - /// THE MCP PLANE'S CONFORMANCE RIG, BOTH WAYS (BUSBAR-1.6.0.md, the plane driver's "Proven by": /// the plane conformance suite, compiled-in and dropped-in through one table). /// @@ -2669,3 +1771,346 @@ mod both_ways { assert_ne!(red, honest); } } + +/// WHAT THE DOOR HOLDS AND GATHERS IS BOUNDED. A unit the kernel refuses is over, and the door +/// drops it; an upstream's answer is gathered only under the SDK's reply ceiling. +/// +/// The units are driven through the linked door op by op, as `both_ways` drives it. It lives in +/// this file because the loader is named by this crate's conformance witness alone (the gate's +/// conformance-witness grant). +mod bounds { + use std::mem::zeroed; + use std::path::Path; + use std::sync::Arc; + + use busbar_contract::abi::mechanism::call::{AbiStr, Blob, Field, Outcome, BLOB_OCTETS}; + use busbar_contract::abi::mechanism::lifecycle::GenIn; + use busbar_contract::abi::plane::{ + slot, ArriveIn, ArriveOut, OnPieceIn, OnPieceOut, OutField, PlaneOpenIn, PlaneOpenOut, + RecordWrite, RefusalIn, RefusalOut, UnitCount, FROM_CALLER, FROM_FAR_END, PIECE_LAST, + REFUSAL_GATE, + }; + use busbar_contract::abi::sdk::door::abi_str; + use busbar_plane_mcp::codec::{H_MCP_METHOD, H_PROTOCOL_VERSION, PROTOCOL_VERSION}; + use busbar_plane_mcp::{door, tool_door as plane_door}; + use busbar_plugin_loader::dispatch::kinds::plane::Plane; + use busbar_plugin_loader::dispatch::{ + in_head, load_linked, out_head, Bind, DispatchConfig, Dispatcher, Frame, LinkedRow, NoSink, + Plugin, + }; + + fn z() -> T { + // SAFETY: every `in`/`out` here is plain C data; all-zero is a valid value of each. + unsafe { zeroed() } + } + + fn octets(b: &'static [u8]) -> Blob { + Blob { + ptr: b.as_ptr(), + len: b.len(), + fmt: BLOB_OCTETS, + flags: 0, + } + } + + fn text(b: &'static [u8]) -> AbiStr { + AbiStr { + ptr: b.as_ptr(), + len: b.len(), + } + } + + const SECTION: &[u8] = + br#"{"fs": {"url": "https://mcp.example/fs", "pin": {"mechanism": "unpinned"}}}"#; + const PUBLIC_URL: &str = "https://busbar.example"; + const TOOLS_LIST: &[u8] = br#"{"jsonrpc":"2.0","id":9,"method":"tools/list","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}}}"#; + const LIST_FIELDS: &[Field] = &[ + Field { + name: abi_str(H_PROTOCOL_VERSION), + value: abi_str(PROTOCOL_VERSION), + }, + Field { + name: abi_str(H_MCP_METHOD), + value: abi_str("tools/list"), + }, + ]; + + /// The linked door, opened over [`SECTION`] and started. + fn started(d: &Dispatcher) -> Plugin { + let row = LinkedRow::of(plane_door::door).expect("the linked door states its Statement"); + let bind = Bind { + instance: Arc::from("the-instance"), + max_inflight_cap: 64, + sink: Arc::new(NoSink), + dispatcher: d.adopter(), + conns: busbar_plugin_loader::dispatch::ConnTable::Probe, + }; + let p = load_linked(&row, bind).expect("the linked door loads"); + let mut i: PlaneOpenIn = z(); + i.open.head = in_head(); + (i.open.generation, i.open.settings) = (1, octets(SECTION)); + i.public_url = text(PUBLIC_URL.as_bytes()); + let mut o: PlaneOpenOut = z(); + o.open.head = out_head(); + let (c, _) = p.open(&mut Frame::new(i, o)); + assert_eq!(c.outcome, Outcome::Ready, "open"); + for op in [slot::HYDRATE, slot::START] { + let mut g = Frame::new( + GenIn { + head: in_head(), + generation: 1, + }, + out_head(), + ); + assert_eq!(p.call(op, &mut g).outcome, Outcome::Ready); + } + p + } + + /// A `tools/list` of `unit` arrives (the plane answers it from what it holds). + fn arrive_list(p: &Plugin, unit: u64) { + let route = door::ROUTES + .iter() + .position(|r| r.verb == "POST" && r.target == "/mcp") + .expect("the door routes it"); + let mut a: Frame = Frame::new(z(), z()); + (a.input.head, a.out.head) = (in_head(), out_head()); + a.input.unit = unit; + a.input.claim = u32::try_from(route).expect("a small table"); + (a.input.method, a.input.target) = (text(b"POST"), text(b"/mcp")); + a.input.body = octets(TOOLS_LIST); + (a.input.fields, a.input.fields_len) = (LIST_FIELDS.as_ptr(), LIST_FIELDS.len()); + assert_eq!( + p.call(slot::ARRIVE, &mut a).outcome, + Outcome::Ready, + "arrive" + ); + } + + /// What one `on_piece` answered: its outcome, the status it stated and the bytes it emitted. + struct Piece { + outcome: Outcome, + status: u32, + reply: Vec, + } + + /// One `on_piece` of `unit` from `from` carrying `flags`, `stream` and `caller`, through a + /// reply buffer of `cap` bytes and buffers that hold the rest. + fn push( + p: &Plugin, + (unit, from, flags): (u64, u32, u32), + (stream, caller): (u64, &'static str), + cap: usize, + ) -> Piece { + let mut reply = vec![0_u8; cap]; + let (mut fields, mut arena) = ([z::(); 16], vec![0_u8; 4096]); + let mut records = [z::(); 16]; + let mut units = [z::(); 8]; + let mut i: OnPieceIn = z(); + i.head = in_head(); + (i.unit, i.from, i.stream) = (unit, from, stream); + i.caller_ref = text(caller.as_bytes()); + (i.flags, i.bytes) = (flags, octets(TOOLS_LIST)); + (i.reply_buf, i.reply_cap) = (reply.as_mut_ptr(), reply.len()); + (i.fields_buf, i.fields_cap) = (fields.as_mut_ptr(), fields.len()); + (i.arena_buf, i.arena_cap) = (arena.as_mut_ptr(), arena.len()); + (i.records_buf, i.records_cap) = (records.as_mut_ptr(), records.len()); + (i.units_buf, i.units_cap) = (units.as_mut_ptr(), units.len()); + let mut o: OnPieceOut = z(); + o.head = out_head(); + let mut f = Frame::new(i, o); + let c = p.call(slot::ON_PIECE, &mut f); + let emitted = usize::try_from(f.out.emitted).expect("small"); + Piece { + outcome: c.outcome, + status: f.out.reply_status, + reply: reply[..emitted].to_vec(), + } + } + + /// One whole-body `on_piece` of `unit` from `from`: its outcome. + fn piece(p: &Plugin, unit: u64, from: u32) -> Outcome { + push(p, (unit, from, PIECE_LAST), (0, ""), 1 << 16).outcome + } + + /// The kernel refuses `unit` (a gate's `403`), and the plane renders the refusal. + fn refuse(p: &Plugin, unit: u64) { + let (mut reply, mut fields, mut arena) = + (vec![0_u8; 4096], [z::(); 4], vec![0_u8; 512]); + let mut records = [z::(); 4]; + let mut r: Frame = Frame::new(z(), z()); + (r.input.head, r.out.head) = (in_head(), out_head()); + r.input.unit = unit; + (r.input.cause, r.input.status) = (REFUSAL_GATE, 403); + r.input.text = text(b"denied"); + (r.input.reply_buf, r.input.reply_cap) = (reply.as_mut_ptr(), reply.len()); + (r.input.fields_buf, r.input.fields_cap) = (fields.as_mut_ptr(), fields.len()); + (r.input.arena_buf, r.input.arena_cap) = (arena.as_mut_ptr(), arena.len()); + (r.input.records_buf, r.input.records_cap) = (records.as_mut_ptr(), records.len()); + assert_eq!( + p.call(slot::REFUSAL, &mut r).outcome, + Outcome::Ready, + "refusal" + ); + } + + /// A unit the kernel refuses after it arrived is over: the plane no longer holds it, so a + /// piece naming it is refused rather than answered from the request params it kept. + #[test] + fn a_unit_the_kernel_refuses_is_dropped() { + let d = Dispatcher::new(DispatchConfig::default()); + let p = started(&d); + arrive_list(&p, 7); + refuse(&p, 7); + assert_eq!( + piece(&p, 7, FROM_CALLER), + Outcome::Refused, + "the refused unit is still held" + ); + } + + /// A unit a piece of which the plane refuses is over too: nothing else would ever end it. + #[test] + fn a_unit_whose_piece_the_plane_refuses_is_dropped() { + let d = Dispatcher::new(DispatchConfig::default()); + let p = started(&d); + arrive_list(&p, 8); + // A far end's piece for a unit that sent nothing on. + assert_eq!(piece(&p, 8, FROM_FAR_END), Outcome::Refused); + assert_eq!( + piece(&p, 8, FROM_CALLER), + Outcome::Refused, + "the unit whose piece was refused is still held" + ); + } + + /// An upstream's answer is gathered only under the SDK's reply ceiling, and the call that + /// passes it fails as any bad upstream answer does. The gather site is reached only by a call + /// the kernel has admitted and entitled, which this harness has no host to do, so the bound is + /// read off the source at that site. + #[test] + fn an_upstreams_answer_is_gathered_only_under_the_reply_ceiling() { + let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("src/tool_door.rs"); + let source = std::fs::read_to_string(path).expect("the door's source reads"); + let lines: Vec<&str> = source.lines().collect(); + let gather = lines + .iter() + .position(|l| l.contains("relay.far.extend_from_slice(bytes)")) + .expect("the gather site"); + let guard = lines[gather.saturating_sub(8)..gather].join("\n"); + assert!( + guard.contains("REPLY_MAX") && guard.contains("upstream_failed"), + "the gather is not bounded by the reply ceiling, failing the call past it:\n{guard}" + ); + } + + /// A `subscriptions/listen` of `unit` arrives, opting in to a list category. + fn arrive_listen(p: &Plugin, unit: u64) { + const LISTEN: &[u8] = br#"{"jsonrpc":"2.0","id":3,"method":"subscriptions/listen","params":{"notifications":{"toolsListChanged":true},"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}}}"#; + const FIELDS: &[Field] = &[ + Field { + name: abi_str(H_PROTOCOL_VERSION), + value: abi_str(PROTOCOL_VERSION), + }, + Field { + name: abi_str(H_MCP_METHOD), + value: abi_str("subscriptions/listen"), + }, + ]; + let route = door::ROUTES + .iter() + .position(|r| r.verb == "POST" && r.target == "/mcp") + .expect("the door routes it"); + let mut a: Frame = Frame::new(z(), z()); + (a.input.head, a.out.head) = (in_head(), out_head()); + a.input.unit = unit; + a.input.claim = u32::try_from(route).expect("a small table"); + (a.input.method, a.input.target) = (text(b"POST"), text(b"/mcp")); + a.input.body = octets(LISTEN); + (a.input.fields, a.input.fields_len) = (FIELDS.as_ptr(), FIELDS.len()); + assert_eq!( + p.call(slot::ARRIVE, &mut a).outcome, + Outcome::Ready, + "arrive listen" + ); + } + + /// One caller opening its quota's worth of subscriptions, plus one, cuts off no other caller's + /// stream: the extra one is refused, in the caller's own answer. + #[test] + fn a_caller_at_its_subscription_quota_is_refused_and_evicts_no_other_caller() { + const QUOTA: u64 = 64; + let d = Dispatcher::new(DispatchConfig::default()); + let p = started(&d); + // Streams are opened through a one-byte reply buffer, so each stays held, its head part + // written. + let open = |unit: u64, caller: &'static str, cap: usize| { + arrive_listen(&p, unit); + push(&p, (unit, FROM_CALLER, 0), (unit, caller), cap) + }; + open(1, "caller-b", 1); + for unit in 100..100 + QUOTA { + assert_eq!(open(unit, "caller-a", 1).outcome, Outcome::Ready); + } + let over = open(100 + QUOTA, "caller-a", 1 << 16); + assert_eq!( + over.status, 429, + "the caller's extra subscription is refused" + ); + assert!( + String::from_utf8_lossy(&over.reply).contains("subscription_quota"), + "the refusal names its reason" + ); + let b = push(&p, (1, FROM_CALLER, 0), (1, "caller-b"), 1 << 16); + assert_eq!( + b.outcome, + Outcome::Ready, + "caller B's stream survives caller A" + ); + assert!( + !b.reply.is_empty(), + "caller B's stream still has its frame to write" + ); + } + + /// A `tools/list` of `unit` arrives: its outcome and the status the plane refused it with. + fn arrival(p: &Plugin, unit: u64) -> (Outcome, u32) { + let route = door::ROUTES + .iter() + .position(|r| r.verb == "POST" && r.target == "/mcp") + .expect("the door routes it"); + let mut a: Frame = Frame::new(z(), z()); + (a.input.head, a.out.head) = (in_head(), out_head()); + a.input.unit = unit; + a.input.claim = u32::try_from(route).expect("a small table"); + (a.input.method, a.input.target) = (text(b"POST"), text(b"/mcp")); + a.input.body = octets(TOOLS_LIST); + (a.input.fields, a.input.fields_len) = (LIST_FIELDS.as_ptr(), LIST_FIELDS.len()); + let outcome = p.call(slot::ARRIVE, &mut a).outcome; + (outcome, a.out.refusal_status) + } + + /// A full unit table refuses the next arrival (429) and never evicts: the first unit is still + /// held and still answers. + #[test] + fn a_full_unit_table_refuses_the_next_arrival_and_evicts_nothing() { + let d = Dispatcher::new(DispatchConfig::default()); + let p = started(&d); + let mut first_refused = None; + for unit in 1..=5000 { + if arrival(&p, unit) != (Outcome::Ready, 0) && first_refused.is_none() { + first_refused = Some((unit, arrival(&p, unit))); + } + } + assert_eq!( + first_refused, + Some((4097, (Outcome::Refused, 429))), + "the arrival past the cap" + ); + assert_eq!( + piece(&p, 1, FROM_CALLER), + Outcome::Ready, + "the first unit was evicted to make room" + ); + } +} diff --git a/crates/busbar-plane-mcp/tests/declarations_agree.rs b/crates/busbar-plane-mcp/tests/declarations_agree.rs index 514a2cbc14..31a3f7da3f 100644 --- a/crates/busbar-plane-mcp/tests/declarations_agree.rs +++ b/crates/busbar-plane-mcp/tests/declarations_agree.rs @@ -1,85 +1,19 @@ // SPDX-License-Identifier: Apache-2.0 // Copyright (C) 2026 Busbar Inc and contributors -//! THE PLANE'S OWN DECLARATIONS AGREE WITH EACH OTHER — four facts asked where the declarations -//! live. +//! THE PLANE'S OWN DECLARATIONS AGREE WITH EACH OTHER — facts asked where the declarations live. //! -//! Each of the four is a compile-time fact of this crate: a schema nothing can reach, a leg naming -//! an operation its schema never declared, a completed call metered under a class the plane does not -//! have, and a claim set whose credential alternatives disagree. None depends on a configuration, a -//! store or a request, so none needs a node to answer it: a test that runs on every build of this -//! crate answers each one before any binary is linked. Contract only — nothing here names the -//! kernel. (The composition root's pre-unification mcp unit asked the same four at boot, over the -//! same constants.) -//! -//! The leg check reads the legs off the plane's own `verify` and `route` for every operation class -//! it declares — this crate's test seal builds the unit reaching the plan needs — and keeps a -//! written-down table as the pin those legs must equal, so a plan that starts or stops touching a -//! record is a reviewed change. - -mod common; - -use std::collections::BTreeSet; +//! Each is a compile-time fact of this crate: a schema nothing can reach, a completed call metered +//! under a class the plane does not have, a meter class sized from a side the door does not report +//! it from, and a claim set whose credential alternatives disagree. None depends on a configuration, +//! a store or a request, so none needs a node to answer it. Contract only — nothing here names the +//! kernel. (The record-leg pin that read legs off the unserved `Plane::verify`/`route` was retired +//! with that impl, finding 12.) -use busbar_contract::dest::DestinationFacts; -use busbar_contract::ids::RecordSchemaId; -use busbar_contract::plane::{Plane, PlaneMeta}; +use busbar_contract::ids::ClassDirection; +use busbar_contract::plane::PlaneMeta; use busbar_plane_mcp::tool_meta::CLASS_TOOL_CALLS; use busbar_plane_mcp::{tool_records as records, McpPlane}; -use common::Scaffold; - -/// Every record leg this plane's plans reach, written down. -/// -/// The pin the plans are compared against, in both directions: a plan that starts reaching a pair -/// not listed here, or stops reaching one that is, is a change to what an operation touches in the -/// node's own records, and it is reviewed here rather than discovered in a store. -const PLANNED_LEGS: &[(RecordSchemaId, &str)] = &[ - (records::SCHEMA_CATALOGUE, records::OP_GET), - (records::SCHEMA_CATALOGUE, records::OP_PUT), - (records::SCHEMA_CATALOGUE, records::OP_SCAN), - (records::SCHEMA_DEMOTION, records::OP_GET), - (records::SCHEMA_DEMOTION, records::OP_SCAN), - (records::SCHEMA_APPROVAL, records::OP_REDEEM), - (records::SCHEMA_CALL, records::OP_APPEND), - (records::SCHEMA_TASK, records::OP_GET), - (records::SCHEMA_TASK, records::OP_PUT), - (records::SCHEMA_SETTINGS, records::OP_GET), -]; - -/// The record legs the plane itself names, read off `verify` and `route` for every operation class -/// it declares. -fn reached_record_legs() -> BTreeSet<(&'static str, &'static str)> { - let plane = McpPlane::EMPTY; - let scaffold = Scaffold::new("http"); - let ctx = scaffold.ctx(); - let seal = common::TestSeal; - let mut reached = BTreeSet::new(); - for op in ::OP_CLASSES { - let unit = busbar_contract::unit::Unit::new( - &seal, - busbar_contract::UnitKey::new(1), - busbar_contract::unit::Origin::Client, - None, - None, - busbar_contract::wire::Direction::Inbound, - Some(common::principal()), - *op, - busbar_contract::bounded::Ir::new(b"{}", &[]), - busbar_contract::bounded::Facts::new(), - None, - ); - let sealed = plane.verify(&unit, &ctx); - let plan = plane.route(&unit, &ctx); - for destination in - std::iter::once(&sealed).chain(plan.legs.as_slice().iter().map(|leg| &leg.destination)) - { - if let DestinationFacts::PlaneRecord { schema, op } = destination { - reached.insert((schema.as_str(), *op)); - } - } - } - reached -} /// Every schema the plane declares carries at least one operation — a schema with none is one no /// leg can ever reach, and a record written under it is a record nothing reads back. @@ -98,42 +32,6 @@ fn every_declared_record_schema_carries_operations() { } } -/// Every record leg the plane's own plans reach names an operation its schema declares, the legs -/// reached are exactly the written-down set, and every declared schema is reached by some plan. -/// -/// A leg naming an undeclared operation would be refused by the trust unit on every request that -/// planned it: an operation whose plan depends on that refusal is an operation that never works. -#[test] -fn every_record_leg_the_plans_reach_is_one_its_schema_declares() { - let reached = reached_record_legs(); - for (schema, op) in &reached { - assert!( - records::operations_for(RecordSchemaId::new(schema)).contains(op), - "a route leg names an undeclared {op} on {schema}" - ); - } - let pinned: BTreeSet<(&str, &str)> = PLANNED_LEGS - .iter() - .map(|(schema, op)| (schema.as_str(), *op)) - .collect(); - assert_eq!( - reached, pinned, - "the record legs the plans reach moved; the written-down set is reviewed with them" - ); - for (schema, op) in PLANNED_LEGS { - assert!( - records::operations_for(*schema).contains(op), - "the written-down leg {op} on {schema} is not declared" - ); - } - for schema in ::RECORD_SCHEMAS { - assert!( - reached.iter().any(|(s, _)| *s == schema.as_str()), - "{schema} is declared and no plan reaches it" - ); - } -} - /// The class a completed call is metered under is one the plane declares. /// /// The plane's served path ledgers each answered call under this class; a class the declaration @@ -169,3 +67,26 @@ fn every_credentialed_claim_declares_the_same_scheme_alternatives() { ); } } + +/// Both declared meter classes are sized from the ANSWER, because the served door reports both off +/// the answer: a call is counted once the upstream has answered the round, and the byte count is +/// the answered document's own length (`tool_door.rs`, `Pending::counted`). A class that declared +/// the request and was reported from the answer would have a rate card pricing one side of the +/// exchange at the size of the other. The surviving half of the retired +/// `the_metering_step_reports_what_it_read`, which drove the unserved `Plane::meter`. +#[test] +fn both_meter_classes_are_sized_from_the_answer() { + let classes = ::METER_CLASSES; + assert!( + !classes.is_empty(), + "non-vacuity: the plane declares meter classes" + ); + for class in classes { + assert_eq!( + class.direction, + ClassDirection::Response, + "{} is reported off the answer and declares another side", + class.key + ); + } +} diff --git a/crates/busbar-plane-mcp/tests/program.rs b/crates/busbar-plane-mcp/tests/program.rs index 3bae435e31..76452cf986 100644 --- a/crates/busbar-plane-mcp/tests/program.rs +++ b/crates/busbar-plane-mcp/tests/program.rs @@ -23,7 +23,7 @@ use busbar_contract::abi::mechanism::ticket::{HostCtx, HostTables, Ticket}; use busbar_contract::abi::sdk::conn::Host; use busbar_contract::abi::transport::FrameSpan; use busbar_plane_mcp::client::jsonrpc::ServerRequestGrants; -use busbar_plane_mcp::tool_program::{list_id, Exchanged, Peer, ProgramExchange}; +use busbar_plane_mcp::tool_program::{list_id, AskOwner, Exchanged, Peer, ProgramExchange}; use serde_json::{json, Value}; /// THE STAND-IN CHILD: its generation, every message it received, the messages each open lease has @@ -221,6 +221,14 @@ impl Peer for Door { } fresh } + fn owner(&mut self, generation: u64, id: &Value) -> AskOwner { + // No call is relayed here: an ask is no call's, refused by the first exchange to read it. + if self.claim(generation, id) { + AskOwner::Refuse + } else { + AskOwner::Refused + } + } fn notice(&mut self) { self.noticed += 1; } diff --git a/crates/busbar-plane-mcp/tests/purity.rs b/crates/busbar-plane-mcp/tests/purity.rs index 94d543812f..939385ec94 100644 --- a/crates/busbar-plane-mcp/tests/purity.rs +++ b/crates/busbar-plane-mcp/tests/purity.rs @@ -1,18 +1,12 @@ //! The plane is pure, and this is what says so. //! -//! A plane is pure over its inputs and performs no input or output of its own. Those are two -//! separate claims and they are checked two separate ways: the source is walked for the shapes that -//! would make either false, and the methods are driven twice over the same inputs and their answers -//! compared. A comment claiming purity is worth nothing; a scan and a repeat are worth something. +//! A plane is pure over its inputs and performs no input or output of its own. The source is walked +//! for the shapes that would make either claim false. A comment claiming purity is worth nothing; a +//! scan is worth something. (The repeat-the-call determinism tests drove the unserved `Plane` impl +//! and were retired with it, finding 12.) -use busbar_contract::plane::{Ingress, Plane}; -use busbar_contract::wire::FrameCursor; -use busbar_plane_mcp::McpPlane; use std::path::{Path, PathBuf}; -mod common; -use common::{frame, Scaffold}; - /// The crate's own source directory. fn src_dir() -> PathBuf { Path::new(env!("CARGO_MANIFEST_DIR")).join("src") @@ -185,113 +179,6 @@ fn the_plane_names_no_kernel_side_crate() { ); } -/// The decode step gives the same answer every time it is asked the same question. -#[test] -fn the_decode_step_is_deterministic() { - let plane = McpPlane::EMPTY; - // Only the methods a CALLER sends. A method an upstream sends back is refused on the ingress - // side by design, and a test that fed one in would be asserting the refusal rather than the - // determinism. - let bodies: Vec> = busbar_plane_mcp::tool_ops::METHODS - .iter() - .filter(|r| r.sender == busbar_plane_mcp::tool_ops::Sender::Client) - .map(|row| { - format!( - r#"{{"jsonrpc":"2.0","id":5,"method":"{}","params":{{"id":"t1"}}}}"#, - row.method - ) - .into_bytes() - }) - .collect(); - // The bodies are DERIVED — a filter over the declared method table — so a table that lost its - // caller-sent rows empties this vector and the loop below runs zero times. A determinism test - // that examined nothing would report `ok`, which is the shape this assertion exists to make - // impossible. - assert!( - !bodies.is_empty(), - "no request body was derived from the declared caller methods, so nothing was driven twice" - ); - for body in &bodies { - let mut answers = Vec::new(); - for _ in 0..8 { - let scaffold = Scaffold::new("http"); - let ctx = scaffold.ctx(); - let frames = vec![frame(body)]; - let mut cursor = FrameCursor::new(&frames); - let ingress = plane - .decode_ingress(&mut cursor, None, &ctx) - .expect("a known method decodes"); - let summary = match ingress { - Ingress::Open(d) | Ingress::OneShot(d) => { - format!("{:?}/{:?}/{}", d.op, d.correlation_out, d.facts.len()) - } - other => format!("{other:?}"), - }; - answers.push(summary); - } - assert!( - answers.windows(2).all(|w| w[0] == w[1]), - "the decode step varied over one body: {answers:?}" - ); - } -} - -/// The encode step writes the same bytes every time it is asked the same question. -#[test] -fn the_encode_step_is_deterministic() { - let plane = McpPlane::EMPTY; - let answer = br#"{"id":1,"jsonrpc":"2.0","result":{"a":1,"b":2}}"#; - let mut written = Vec::new(); - for _ in 0..8 { - let scaffold = Scaffold::new("http"); - let ctx = scaffold.ctx(); - let r = busbar_contract::plane::Response { - ir: busbar_contract::bounded::Ir::new(answer, &[]), - finish: busbar_contract::unit::FinishClass::Complete, - facts: busbar_contract::bounded::Facts::new(), - }; - let out = plane - .encode_response(&r, None, &ctx) - .expect("it re-encodes"); - written.push(out.as_slice().to_vec()); - } - assert!( - written.windows(2).all(|w| w[0] == w[1]), - "the encode step varied over one answer" - ); -} - -/// The plane does not read the clock, so a call at a different time gives the same answer. -/// -/// The two readings are DECADES apart and the second is the earlier one, so a plane that folded -/// `ctx.clock` into what it decoded — a timestamp, an expiry, a monotonic tie-break — cannot land on -/// the same answer by accident. -#[test] -fn the_answer_does_not_move_with_the_clock() { - let plane = McpPlane::EMPTY; - let body = br#"{"jsonrpc":"2.0","id":9,"method":"tasks/get","params":{"id":"t1"}}"#; - let mut answers = Vec::new(); - for unix_secs in [2_000_000_000_u64, 1_000_000_000] { - let scaffold = Scaffold::new("http"); - let ctx = scaffold.ctx_at(unix_secs); - assert_eq!( - ctx.clock().unix_secs, - unix_secs, - "the scaffold handed the plane the reading this test chose" - ); - let frames = vec![frame(body)]; - let mut cursor = FrameCursor::new(&frames); - let Ok(Ingress::OneShot(draft)) = plane.decode_ingress(&mut cursor, None, &ctx) else { - panic!("a single-answer method decodes as one shot"); - }; - answers.push(format!("{:?}{:?}", draft.op, draft.correlation_out)); - } - assert_eq!( - answers[0], answers[1], - "the decoded answer moved with the clock: {answers:?}" - ); -} - /// The plane never hands back a decision, an amount or a credential. /// /// This is a source scan for the words a plane must not be able to say. It is coarse on purpose: a @@ -365,3 +252,38 @@ fn the_plane_satisfies_no_upstream_ask() { "the plane carries a satisfier of an upstream's ask: {offenders:?}" ); } + +/// No code in this crate routes a call to another plane, and none implements the unserved plane +/// traits (finding 12; BUSBAR-1.6.0.md Law 11 "routes no call to another plane on the content's +/// say-so", and "a capability that is never constructed does not ship"). +/// +/// The door is the only thing this crate serves. A destination that opens a child unit of ANOTHER +/// plane (`NestedPlane`), the operation class a sampling ask was answered as (`SAMPLING_OP`), and a +/// `Plane`/`SessionPlane` impl nothing constructs are the struck route and the dead machinery that +/// carried it. This is a source scan, the pure-deletion plant: it fails while any of them exists. +#[test] +fn no_code_routes_a_call_to_another_plane_or_implements_the_unserved_plane_traits() { + let forbidden = [ + "NestedPlane", + "SAMPLING_OP", + "impl Plane for", + "impl SessionPlane for", + ]; + let mut offenders = Vec::new(); + walk(&src_dir(), &mut |path, text| { + for (n, line) in text.lines().enumerate() { + if is_comment(line) { + continue; + } + for name in forbidden { + if line.contains(name) { + offenders.push(format!("{}:{}: {name}", path.display(), n + 1)); + } + } + } + }); + assert!( + offenders.is_empty(), + "the crate carries the struck cross-plane route or a plane impl nothing serves: {offenders:?}" + ); +} diff --git a/crates/busbar-plane-mcp/tests/sanitize_complexity.rs b/crates/busbar-plane-mcp/tests/sanitize_complexity.rs index a265901d04..c06afed20d 100644 --- a/crates/busbar-plane-mcp/tests/sanitize_complexity.rs +++ b/crates/busbar-plane-mcp/tests/sanitize_complexity.rs @@ -3,9 +3,10 @@ //! THE SANITISER'S COMPLEXITY GUARDS — the half of markup-normalisation that is measured in time. //! -//! Tool output and `resources/read` content are bytes an UPSTREAM chose. A scan that goes quadratic -//! on a shape the sender picks is a denial of service the sender can spell in a 200 KB body, so the -//! two adversarial shapes below carry a wall-clock bound as well as an output assertion. +//! The strip reads bytes a sender chose: a caller's arguments substituted into a prompt or resource +//! template. A scan that goes quadratic on a shape the sender picks is a denial of service the +//! sender can spell in a 200 KB body, so the two adversarial shapes below carry a wall-clock bound +//! as well as an output assertion. //! //! WHY THESE TWO LIVE OUT HERE AND THE REST OF THE SUITE LIVES IN `src/tests/`. They read a clock, //! and this crate's purity oracle (`tests/purity.rs::the_plane_performs_no_input_or_output`) walks @@ -18,9 +19,9 @@ use busbar_plane_mcp::sanitize::normalise; /// AN UNTERMINATED `<` MUST NOT COST MORE THAN THE BYTES IT ARRIVED IN. /// -/// Tool output and `resources/read` content are bytes an upstream MCP server chose, and this is the -/// module that exists because of that. Re-scanning the whole tail for every ` RawOutcome { + // SAFETY: the SDK's `out`, live for the call. + unsafe { + out.write(ServiceOut { + size: std::mem::size_of::() as u32, + outcome: RawOutcome::of(outcome), + _reserved: [0; 3], + value, + len, + items: 0, + needed_bytes: 0, + needed_items: 0, + error: AbiStr { + ptr: std::ptr::null(), + len: 0, + }, + }); + } + RawOutcome::of(outcome) +} + +/// The host's monotonic clock: it moves a second on every reading. +static MONO: AtomicU64 = AtomicU64::new(1_000_000_000); + +extern "C" fn clock_now(_: HostCtx, input: *const c_void, out: *mut ServiceOut) -> RawOutcome { + // SAFETY: the SDK hands a `ClockNowIn` whose reading slot it owns. + let i = unsafe { input.cast::().read() }; + let mono = MONO.fetch_add(1_000_000_000, Ordering::SeqCst); + // SAFETY: as above. + unsafe { + (*i.reading).wall_ns = 1_800_000_000_000_000_000 + mono; + (*i.reading).mono_ns = mono; + } + answer(out, Outcome::Ready, 0, 0) +} + +extern "C" fn entitled(_: HostCtx, _: *const c_void, out: *mut ServiceOut) -> RawOutcome { + answer(out, Outcome::Ready, ENTITLED, 0) +} + +/// Every fill differs from the last: a counter, spelled into the buffer. +static FILLS: AtomicU64 = AtomicU64::new(0); + +extern "C" fn random_fill(_: HostCtx, input: *const c_void, out: *mut ServiceOut) -> RawOutcome { + // SAFETY: the SDK hands a `RandomFillIn` over its own buffer. + let i = unsafe { input.cast::().read() }; + let n = FILLS.fetch_add(1, Ordering::SeqCst) + 1; + let len = usize::try_from(i.len).expect("a small fill"); + // SAFETY: the SDK's buffer of `cap >= len` bytes. + let buf = unsafe { std::slice::from_raw_parts_mut(i.into.buf, i.into.cap) }; + for (k, b) in buf.iter_mut().take(len).enumerate() { + *b = (n as u8).wrapping_mul(31).wrapping_add(k as u8) | 1; + } + answer(out, Outcome::Ready, 0, i.len) +} + +extern "C" fn serves(_: HostCtx, _: *const c_void, out: *mut ServiceOut) -> RawOutcome { + answer(out, Outcome::Ready, DISTRUST_NONE, 0) +} + +extern "C" fn sighted(_: HostCtx, _: *const c_void, out: *mut ServiceOut) -> RawOutcome { + answer(out, Outcome::Ready, TRUST_NEW, 0) +} + +static HOST_SLOTS: HostSlots = HostSlots { + size: std::mem::size_of::() as u32, + slots: HOST_SERVICES, + clock_now: Some(clock_now), + records_get: None, + records_list: None, + records_claim: None, + dest_judge: None, + sign: None, + unit_nest: None, + work_open: None, + work_find: None, + work_settle: None, + work_resume: None, + trust_sight: Some(sighted), + trust_due: None, + verify_lookup: None, + verify_store: None, + entitlement_check: Some(entitled), + content_scan: None, + hook_call: None, + random_fill: Some(random_fill), + need_admit: None, + trust_verify: None, + records_secret: None, + disk_append: None, + snapshot_read: None, + trust_sight_item: Some(sighted), + trust_serves: Some(serves), + trust_decide: None, + trust_state: None, + session_emit: None, +}; + +// ── the upstreams behind the connector ────────────────────────────────────────────────────────── + +/// The upstream that requires a session. +const SESSION_URL: &str = "https://up.example/sess"; +/// The upstream that speaks the stateless revision. +const STATELESS_URL: &str = "https://up.example/st"; +/// The session the session upstream names. +const UPSTREAM_SESSION: &str = "up-session-1"; + +/// One request an upstream received over the connector. +#[derive(Debug, Clone)] +struct Seen { + url: String, + fields: Vec<(String, String)>, + body: Value, +} + +/// One reply an upstream writes: its status, its head fields and its body. +type Reply = (u32, Vec<(String, String)>, Vec); + +/// One open exchange: where it goes, what it sent so far, and its reply once the request ended. +#[derive(Default)] +struct Wire { + url: String, + head: Vec<(String, String)>, + body: Vec, + reply: Option, + step: u8, +} + +#[derive(Default)] +struct Upstreams { + next: u64, + open: HashMap, + seen: Vec, +} + +static UPSTREAMS: Mutex> = Mutex::new(None); + +fn upstreams() -> MutexGuard<'static, Option> { + UPSTREAMS.lock().unwrap_or_else(PoisonError::into_inner) +} + +fn field_of<'a>(fields: &'a [(String, String)], name: &str) -> Option<&'a str> { + fields + .iter() + .find(|(n, _)| n.eq_ignore_ascii_case(name)) + .map(|(_, v)| v.as_str()) +} + +fn rpc_result(id: &Value, result: Value) -> Vec { + serde_json::to_vec(&json!({"jsonrpc": "2.0", "id": id, "result": result})).unwrap() +} + +const JSON_TYPE: (&str, &str) = ("content-type", "application/json"); + +fn json_head() -> Vec<(String, String)> { + vec![(JSON_TYPE.0.to_string(), JSON_TYPE.1.to_string())] +} + +/// What an upstream answers one request. +fn upstream_answer(url: &str, fields: &[(String, String)], body: &Value) -> Reply { + let id = body.get("id").cloned().unwrap_or(Value::Null); + let method = body.get("method").and_then(Value::as_str).unwrap_or(""); + let tools = json!({"tools": [{"name": "read_file", "inputSchema": {"type": "object"}}]}); + if url.starts_with(STATELESS_URL) { + return match method { + "tools/list" => (200, json_head(), rpc_result(&id, tools)), + "tools/call" => ( + 200, + json_head(), + rpc_result( + &id, + json!({"content": [{"type": "text", "text": "stateless"}], "resultType": "complete"}), + ), + ), + _ => (404, Vec::new(), b"no such method".to_vec()), + }; + } + if method == "initialize" { + let mut head = json_head(); + head.push(("mcp-session-id".to_string(), UPSTREAM_SESSION.to_string())); + let asked = body + .pointer("/params/protocolVersion") + .cloned() + .unwrap_or(Value::Null); + let result = json!({ + "protocolVersion": asked, + "capabilities": {"tools": {}}, + "serverInfo": {"name": "up", "version": "1"}, + }); + return (200, head, rpc_result(&id, result)); + } + if field_of(fields, "mcp-session-id") != Some(UPSTREAM_SESSION) { + return ( + 400, + json_head(), + br#"{"jsonrpc":"2.0","id":null,"error":{"code":-32600,"message":"Bad Request: no valid session ID"}}"# + .to_vec(), + ); + } + match method { + "notifications/initialized" => (202, Vec::new(), Vec::new()), + "tools/list" => (200, json_head(), rpc_result(&id, tools)), + "tools/call" => ( + 200, + json_head(), + rpc_result( + &id, + json!({"content": [{"type": "text", "text": "in-session"}]}), + ), + ), + _ => (404, Vec::new(), Vec::new()), + } +} + +fn span(buf: &[u8], s: FrameSpan) -> &[u8] { + let at = usize::try_from(s.offset).unwrap(); + &buf[at..at + usize::try_from(s.len).unwrap()] +} + +extern "C" fn establish(_: HostCtx, input: *const c_void, out: *mut ServiceOut) -> RawOutcome { + // SAFETY: the SDK hands an `EstablishIn`. + let i = unsafe { input.cast::().read() }; + // SAFETY: the SDK's target text, live for the call. + let url = unsafe { std::slice::from_raw_parts(i.target.ptr, i.target.len) }; + let url = String::from_utf8_lossy(url).into_owned(); + let mut ups = upstreams(); + let ups = ups.get_or_insert_with(Upstreams::default); + ups.next += 1; + let id = ups.next; + ups.open.insert( + id, + Wire { + url, + ..Wire::default() + }, + ); + answer(out, Outcome::Ready, id, 0) +} + +extern "C" fn write_request(_: HostCtx, input: *const c_void, out: *mut ServiceOut) -> RawOutcome { + // SAFETY: the SDK hands a `RequestIn` over its own bytes and descriptor. + let i = unsafe { input.cast::().read() }; + // SAFETY: as above. + let (bytes, piece) = unsafe { + ( + std::slice::from_raw_parts(i.buf, i.len), + i.piece.cast::().read(), + ) + }; + let mut ups = upstreams(); + let ups = ups.get_or_insert_with(Upstreams::default); + let Some(wire) = ups.open.get_mut(&i.stream) else { + return answer(out, Outcome::Failed, 0, 0); + }; + match piece.kind { + REQUEST_HEAD => { + let block = String::from_utf8_lossy(span(bytes, piece.fields)).into_owned(); + wire.head = block + .split("\r\n") + .filter_map(|l| l.split_once(": ")) + .map(|(n, v)| (n.to_ascii_lowercase(), v.to_string())) + .collect(); + } + REQUEST_BODY => wire.body.extend_from_slice(bytes), + _ => { + let body: Value = serde_json::from_slice(&wire.body).unwrap_or(Value::Null); + wire.reply = Some(upstream_answer(&wire.url, &wire.head, &body)); + let seen = Seen { + url: wire.url.clone(), + fields: wire.head.clone(), + body, + }; + ups.seen.push(seen); + } + } + answer(out, Outcome::Ready, 0, i.len as u64) +} + +extern "C" fn read_reply(_: HostCtx, input: *const c_void, out: *mut ServiceOut) -> RawOutcome { + // SAFETY: the SDK hands a `ReplyIn` over its own buffer and descriptor slot. + let i = unsafe { input.cast::().read() }; + // SAFETY: as above. + let buf = unsafe { std::slice::from_raw_parts_mut(i.buf, i.len) }; + let mut ups = upstreams(); + let ups = ups.get_or_insert_with(Upstreams::default); + let Some(wire) = ups.open.get_mut(&i.stream) else { + return answer(out, Outcome::Failed, 0, 0); + }; + let Some((status, head, body)) = wire.reply.clone() else { + return answer(out, Outcome::Failed, 0, 0); + }; + let (piece, n) = match wire.step { + 0 => { + let block: String = head.iter().map(|(n, v)| format!("{n}: {v}\r\n")).collect(); + buf[..block.len()].copy_from_slice(block.as_bytes()); + ( + ReplyPiece { + kind: REPLY_HEAD, + code: status, + fields: FrameSpan { + offset: 0, + len: block.len() as u64, + }, + ..ReplyPiece::default() + }, + block.len(), + ) + } + 1 if !body.is_empty() => { + buf[..body.len()].copy_from_slice(&body); + ( + ReplyPiece { + kind: REPLY_BODY, + ..ReplyPiece::default() + }, + body.len(), + ) + } + _ => ( + ReplyPiece { + kind: REPLY_END, + ..ReplyPiece::default() + }, + 0, + ), + }; + wire.step = if wire.step == 0 && body.is_empty() { + 2 + } else { + wire.step + 1 + }; + // SAFETY: the SDK's descriptor slot. + unsafe { i.piece.write(piece) }; + answer(out, Outcome::Ready, 0, n as u64) +} + +extern "C" fn close(_: HostCtx, input: *const c_void, out: *mut ServiceOut) -> RawOutcome { + // SAFETY: the SDK hands a `StreamIn`. + let i = unsafe { input.cast::().read() }; + if let Some(ups) = upstreams().as_mut() { + ups.open.remove(&i.stream); + } + answer(out, Outcome::Ready, 0, 0) +} + +static CONN_SLOTS: ConnectorSlots = ConnectorSlots { + size: std::mem::size_of::() as u32, + slots: CONN_SERVICES, + establish: Some(establish), + reject_endpoint: None, + side_stream: None, + read: None, + write: None, + upgrade_secure: None, + facts: None, + checkout: None, + checkin: None, + close: Some(close), + random: None, + identity: None, + read_reply: Some(read_reply), + write_request: Some(write_request), +}; + +struct Tables(HostTables); +// SAFETY: the tables name only `'static` tables of `extern "C"` functions. +unsafe impl Sync for Tables {} + +static TABLES: Tables = Tables(HostTables { + size: std::mem::size_of::() as u32, + _reserved: 0, + ctx: HostCtx { + ptr: std::ptr::null_mut(), + }, + wake: None, + conns: &CONN_SLOTS, + services: &HOST_SLOTS, +}); + +/// One test at a time: the upstreams are the process's. +static SERIAL: Mutex<()> = Mutex::new(()); + +// ── the door, op by op ────────────────────────────────────────────────────────────────────────── + +fn z() -> T { + // SAFETY: every `in`/`out` here is plain C data; all-zero is a valid value of each. + unsafe { std::mem::zeroed() } +} + +fn str_of(s: &str) -> AbiStr { + AbiStr { + ptr: s.as_ptr(), + len: s.len(), + } +} + +fn octets(b: &[u8]) -> Blob { + Blob { + ptr: b.as_ptr(), + len: b.len(), + fmt: BLOB_OCTETS, + flags: 0, + } +} + +fn head(op: u32, ticket: Ticket) -> InHead { + InHead { + size: std::mem::size_of::() as u32, + op, + ticket, + ..z() + } +} + +fn out_head() -> OutHead { + OutHead { + size: std::mem::size_of::() as u32, + ..z() + } +} + +fn ops() -> &'static Ops { + // SAFETY: the door is a `'static` table whose ops are the plane kind's. + unsafe { &*(*tool_door::door()).ops.cast::() } +} + +fn lifecycle() -> &'static OpsHead { + &ops().head +} + +/// One opened instance of the door. +struct Door { + instance: *mut c_void, +} + +/// The section: a registration that requires a session, and one that speaks the stateless revision. +const SECTION: &str = r#"{ + "fs": {"url": "https://up.example/sess", "pin": {"mechanism": "unpinned"}, "verify_ttl": "1h", "tools_allow": {"read_file": {}}}, + "st": {"url": "https://up.example/st", "pin": {"mechanism": "unpinned"}, "verify_ttl": "1h", "tools_allow": {"read_file": {}}, "resources_allow": {"file:///readme": {"name": "readme", "text": "hi"}}} +}"#; + +fn open() -> Door { + let mut i: PlaneOpenIn = z(); + i.open = OpenIn { + head: head::(life::OPEN, Ticket::NONE), + host: &TABLES.0, + settings: octets(SECTION.as_bytes()), + generation: 1, + ..z() + }; + i.public_url = str_of("https://busbar.example"); + let mut o: PlaneOpenOut = z(); + o.open = OpenOut { + head: out_head::(), + ..z() + }; + let f = lifecycle().open.expect("the door opens"); + let ret = f( + std::ptr::null_mut(), + std::ptr::from_ref(&i).cast(), + std::ptr::from_mut(&mut o).cast(), + ); + assert_eq!(ret.outcome(), Outcome::Ready, "the door opens"); + Door { + instance: o.open.instance, + } +} + +/// What `arrive` answered: its outcome and, refused, the status the refusal wears. +#[derive(Debug)] +struct Arrived { + outcome: Outcome, + refusal_status: u32, +} + +fn claim(verb: &str) -> u32 { + let i = door::ROUTES + .iter() + .position(|r| r.verb == verb && r.target == "/mcp" && r.carrier == "http") + .expect("the door routes it"); + u32::try_from(i).unwrap() +} + +impl Door { + fn arrive( + &self, + unit: u64, + verb: &str, + target: &str, + body: &[u8], + fields: &[(&str, &str)], + ) -> Arrived { + let fields: Vec = fields + .iter() + .map(|(n, v)| Field { + name: str_of(n), + value: str_of(v), + }) + .collect(); + let mut i: ArriveIn = z(); + i.head = head::(slot::ARRIVE, Ticket::NONE); + i.unit = unit; + i.claim = claim(verb); + i.target = str_of(target); + i.method = str_of(verb); + i.body = octets(body); + (i.fields, i.fields_len) = (fields.as_ptr(), fields.len()); + let mut units = [z::(); 8]; + (i.units_buf, i.units_cap) = (units.as_mut_ptr(), units.len()); + let mut o: ArriveOut = z(); + o.head = out_head::(); + let f = ops().arrive.expect("arrive"); + let ret = f( + self.instance, + std::ptr::from_ref(&i).cast(), + std::ptr::from_mut(&mut o).cast(), + ); + Arrived { + outcome: ret.outcome(), + refusal_status: o.refusal_status, + } + } + + /// One piece of `unit`, re-called while the door says `more`: the outcome, the status, the + /// head fields, the bytes, whether the bytes went to the far end, and the request line. + fn piece(&self, unit: u64, given: &Given<'_>) -> Answered { + let ticket = Ticket { + slot: u32::try_from(unit).unwrap(), + generation: 1, + }; + let head_fields: Vec = given + .head + .iter() + .map(|(n, v)| Field { + name: str_of(n), + value: str_of(v), + }) + .collect(); + let mut reply = vec![0_u8; 1 << 16]; + let mut fields = [z::(); 16]; + let mut arena = vec![0_u8; 1 << 14]; + let mut records = [z::(); 16]; + let mut units = [z::(); 8]; + let mut got = Answered::default(); + let mut first = true; + for _ in 0..64 { + let mut i: OnPieceIn = z(); + i.head = head::(slot::ON_PIECE, ticket); + i.unit = unit; + i.from = given.from; + i.stream = given.stream; + i.caller_ref = str_of(given.caller); + if first { + i.flags = given.flags; + i.bytes = octets(given.bytes); + i.status_code = given.status; + i.attempt_no = given.attempt; + i.member = str_of(given.member); + (i.head_fields, i.head_fields_len) = (head_fields.as_ptr(), head_fields.len()); + } else { + i.bytes = octets(b""); + } + (i.reply_buf, i.reply_cap) = (reply.as_mut_ptr(), reply.len()); + (i.fields_buf, i.fields_cap) = (fields.as_mut_ptr(), fields.len()); + (i.arena_buf, i.arena_cap) = (arena.as_mut_ptr(), arena.len()); + (i.records_buf, i.records_cap) = (records.as_mut_ptr(), records.len()); + (i.units_buf, i.units_cap) = (units.as_mut_ptr(), units.len()); + let mut o: OnPieceOut = z(); + o.head = out_head::(); + let f = ops().on_piece.expect("on_piece"); + let ret = f( + self.instance, + std::ptr::from_ref(&i).cast(), + std::ptr::from_mut(&mut o).cast(), + ); + first = false; + got.outcome = Some(ret.outcome()); + if o.reply_status != 0 { + got.status = o.reply_status; + } + let at = |s: busbar_contract::abi::mechanism::call::Span| { + let from = s.offset as usize; + String::from_utf8_lossy(&arena[from..from + s.len as usize]).into_owned() + }; + got.fields.extend( + fields[..o.fields_written as usize] + .iter() + .map(|f| (at(f.name), at(f.value))), + ); + if o.verb.len != 0 { + got.request = format!("{} {}", at(o.verb), at(o.target)); + } + got.far |= o.flags & EMIT_TO_FAR_END != 0; + got.done |= o.flags & EMIT_DONE != 0; + got.bytes + .extend_from_slice(&reply[..usize::try_from(o.emitted).unwrap()]); + if ret.outcome() != Outcome::Ready || o.more == 0 { + break; + } + } + got + } +} + +/// One piece as the kernel pushes it. +struct Given<'a> { + from: u32, + flags: u32, + stream: u64, + status: u32, + attempt: u32, + member: &'a str, + caller: &'a str, + head: Vec<(&'a str, &'a str)>, + bytes: &'a [u8], +} + +impl<'a> Given<'a> { + /// The caller's whole body, under the caller reference `caller`. + fn caller(caller: &'a str, bytes: &'a [u8]) -> Self { + Given { + from: FROM_CALLER, + flags: PIECE_LAST, + stream: 0, + status: 0, + attempt: 0, + member: "", + caller, + head: Vec::new(), + bytes, + } + } + + /// The caller's side of a held stream opening: the request's (empty) body. + fn stream_open(caller: &'a str, stream: u64) -> Self { + Given { + flags: 0, + stream, + ..Given::caller(caller, b"") + } + } + + /// The kernel collecting a held stream's output. + fn collect(caller: &'a str, stream: u64) -> Self { + Given { + from: FROM_KERNEL, + flags: 0, + stream, + ..Given::caller(caller, b"") + } + } + + /// The kernel's ATTEMPT naming `member`. + fn attempt(caller: &'a str, member: &'a str) -> Self { + Given { + from: FROM_KERNEL, + flags: 0, + attempt: 1, + member, + ..Given::caller(caller, b"") + } + } + + /// The far end's whole answer. + fn far(caller: &'a str, status: u32, head: Vec<(&'a str, &'a str)>, bytes: &'a [u8]) -> Self { + Given { + from: FROM_FAR_END, + flags: PIECE_LAST | PIECE_HAS_STATUS, + status, + head, + ..Given::caller(caller, bytes) + } + } +} + +#[derive(Debug, Default)] +struct Answered { + outcome: Option, + status: u32, + fields: Vec<(String, String)>, + bytes: Vec, + far: bool, + done: bool, + request: String, +} + +impl Answered { + fn field(&self, name: &str) -> Option<&str> { + field_of(&self.fields, name) + } + + fn json(&self) -> Value { + serde_json::from_slice(&self.bytes).unwrap_or_else(|e| { + panic!( + "a JSON answer ({e}): {}", + String::from_utf8_lossy(&self.bytes) + ) + }) + } + + fn text(&self) -> String { + String::from_utf8_lossy(&self.bytes).into_owned() + } +} + +/// Two callers' opaque references, as the kernel lends them. +const ALICE: &str = "a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1"; +const BOB: &str = "b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2"; + +const INIT_2025: &[u8] = br#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"client","version":"1"}}}"#; +const LIST_2025: &[u8] = br#"{"jsonrpc":"2.0","id":2,"method":"tools/list"}"#; +const INITIALIZED: &[u8] = br#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#; +const ACCEPT_BOTH: (&str, &str) = ("accept", "application/json, text/event-stream"); + +/// `initialize` on the endpoint, as `caller`: the answer. +fn initialize(d: &Door, unit: u64, caller: &str) -> Answered { + let arrived = d.arrive(unit, "POST", "/mcp", INIT_2025, &[ACCEPT_BOTH]); + assert_eq!( + arrived.outcome, + Outcome::Ready, + "a 2025-06-18 `initialize` is served, not refused: {arrived:?}" + ); + d.piece(unit, &Given::caller(caller, INIT_2025)) +} + +/// A `tools/list` in session `session` as `caller`. +fn listed(d: &Door, unit: u64, session: &str, caller: &str) -> Answered { + let fields = [ + ACCEPT_BOTH, + ("mcp-session-id", session), + ("mcp-protocol-version", "2025-06-18"), + ]; + let arrived = d.arrive(unit, "POST", "/mcp", LIST_2025, &fields); + assert_eq!(arrived.outcome, Outcome::Ready, "{arrived:?}"); + d.piece(unit, &Given::caller(caller, LIST_2025)) +} + +// ── (a) and (b): the session revisions ────────────────────────────────────────────────────────── + +/// A `2025-06-18` client: `initialize` answers the revision it asked for and names a session of 128 +/// bits in `Mcp-Session-Id`; a request in that session is served, its answer in the session +/// revision's shape (no stateless-only members). +#[test] +fn a_2025_initialize_opens_a_session_that_serves_its_requests() { + let _serial = SERIAL.lock().unwrap_or_else(PoisonError::into_inner); + let d = open(); + let init = initialize(&d, 1, ALICE); + assert_eq!(init.status, 200, "{}", init.text()); + assert_eq!(init.outcome, Some(Outcome::Ready)); + assert!(init.done, "the answer is whole"); + let session = init + .field("mcp-session-id") + .expect("the answer names its session") + .to_string(); + assert_eq!(session.len(), 32, "128 bits, as hex"); + assert!(session.bytes().all(|b| b.is_ascii_hexdigit())); + let body = init.json(); + assert_eq!(body["id"], json!(1)); + assert_eq!(body["result"]["protocolVersion"], json!("2025-06-18")); + assert!(body["result"]["capabilities"]["tools"].is_object()); + + let arrived = d.arrive( + 2, + "POST", + "/mcp", + INITIALIZED, + &[ACCEPT_BOTH, ("mcp-session-id", session.as_str())], + ); + assert_eq!(arrived.outcome, Outcome::Ready); + let noted = d.piece(2, &Given::caller(ALICE, INITIALIZED)); + assert_eq!(noted.status, 202); + + let list = listed(&d, 3, &session, ALICE); + assert_eq!(list.status, 200, "{}", list.text()); + let body = list.json(); + assert_eq!(body["id"], json!(2)); + let tools = body["result"]["tools"].as_array().expect("a tool list"); + assert!(!tools.is_empty(), "the caller is entitled to every tool"); + assert!( + body["result"].get("resultType").is_none() && body["result"].get("ttlMs").is_none(), + "lowered into the session revision: {body}" + ); +} + +/// A session is bound to the owner that opened it: another caller presenting its id is told it is +/// unknown (`404`), on POST and on DELETE alike, and the owner's DELETE ends it. +#[test] +fn a_session_presented_by_another_owner_is_unknown() { + let _serial = SERIAL.lock().unwrap_or_else(PoisonError::into_inner); + let d = open(); + let init = initialize(&d, 11, ALICE); + let session = init + .field("mcp-session-id") + .expect("the answer names its session") + .to_string(); + assert_eq!(listed(&d, 12, &session, BOB).status, 404); + assert_eq!(listed(&d, 13, &session, ALICE).status, 200); + assert_eq!( + listed(&d, 14, "0123456789abcdef0123456789abcdef", ALICE).status, + 404 + ); + + let delete = |unit: u64, caller: &str| { + let arrived = d.arrive( + unit, + "DELETE", + "/mcp", + b"", + &[("mcp-session-id", session.as_str())], + ); + assert_eq!(arrived.outcome, Outcome::Ready, "{arrived:?}"); + d.piece(unit, &Given::caller(caller, b"")) + }; + assert_eq!(delete(15, BOB).status, 404); + assert_eq!(delete(16, ALICE).status, 200); + assert_eq!(listed(&d, 17, &session, ALICE).status, 404, "ended"); +} + +// ── (c) and (d): the sessionless GET and DELETE ───────────────────────────────────────────────── + +/// A GET that names no session and no revision is the `2024-11-05` client's: its stream opens with +/// the `endpoint` event naming the message address; a message POSTed there is accepted (`202`) and +/// answered on the stream. A GET that names a revision, or does not accept an event stream, is +/// `405`, and so is a DELETE that names no session. +#[test] +fn a_sessionless_get_is_the_legacy_stream_or_405() { + let _serial = SERIAL.lock().unwrap_or_else(PoisonError::into_inner); + let d = open(); + let sse = ("accept", "text/event-stream"); + let named = d.arrive( + 21, + "GET", + "/mcp", + b"", + &[sse, ("mcp-protocol-version", "2025-06-18")], + ); + assert_eq!( + (named.outcome, named.refusal_status), + (Outcome::Refused, 405) + ); + let plain = d.arrive(22, "GET", "/mcp", b"", &[("accept", "application/json")]); + assert_eq!( + (plain.outcome, plain.refusal_status), + (Outcome::Refused, 405) + ); + let delete = d.arrive(23, "DELETE", "/mcp", b"", &[]); + assert_eq!( + (delete.outcome, delete.refusal_status), + (Outcome::Refused, 405) + ); + + let legacy = d.arrive(24, "GET", "/mcp", b"", &[sse]); + assert_eq!(legacy.outcome, Outcome::Ready, "{legacy:?}"); + let opened = d.piece(24, &Given::stream_open(ALICE, 24)); + assert_eq!(opened.status, 200); + assert_eq!(opened.field("content-type"), Some("text/event-stream")); + let text = opened.text(); + let address = text + .strip_prefix("event: endpoint\ndata: ") + .and_then(|rest| rest.split('\n').next()) + .unwrap_or_else(|| panic!("the stream opens with the endpoint event: {text:?}")) + .to_string(); + let session = address + .strip_prefix("/mcp?sessionId=") + .expect("the message address names the session"); + assert_eq!(session.len(), 32); + + let init = br#"{"jsonrpc":"2.0","id":7,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"old","version":"1"}}}"#; + let posted = d.arrive( + 25, + "POST", + &address, + init, + &[("content-type", "application/json")], + ); + assert_eq!(posted.outcome, Outcome::Ready, "{posted:?}"); + let accepted = d.piece(25, &Given::caller(ALICE, init)); + assert_eq!(accepted.status, 202); + assert!(accepted.bytes.is_empty()); + + let delivered = d.piece(24, &Given::collect(ALICE, 24)); + let text = delivered.text(); + let data = text + .strip_prefix("event: message\ndata: ") + .and_then(|rest| rest.split('\n').next()) + .unwrap_or_else(|| panic!("the answer is delivered on the stream: {text:?}")); + let answer: Value = serde_json::from_str(data).expect("one JSON-RPC message"); + assert_eq!(answer["id"], json!(7)); + assert_eq!(answer["result"]["protocolVersion"], json!("2024-11-05")); + + // The message address is the owner's: another caller posting to it is told it is unknown. + let foreign = d.arrive( + 26, + "POST", + &address, + init, + &[("content-type", "application/json")], + ); + assert_eq!(foreign.outcome, Outcome::Ready); + assert_eq!(d.piece(26, &Given::caller(BOB, init)).status, 404); +} + +// ── (e): busbar as a client ────────────────────────────────────────────────────────────────────── + +const CALL: &[u8] = br#"{"jsonrpc":"2.0","id":30,"method":"tools/call","params":{"name":"fs_read_file","arguments":{},"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}}}"#; +const CALL_FIELDS: &[(&str, &str)] = &[ + ("mcp-protocol-version", "2026-07-28"), + ("mcp-method", "tools/call"), + ("mcp-name", "fs_read_file"), +]; +const ST_CALL: &[u8] = br#"{"jsonrpc":"2.0","id":31,"method":"tools/call","params":{"name":"st_read_file","arguments":{},"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}}}"#; +const ST_FIELDS: &[(&str, &str)] = &[ + ("mcp-protocol-version", "2026-07-28"), + ("mcp-method", "tools/call"), + ("mcp-name", "st_read_file"), +]; + +fn seen() -> Vec { + upstreams() + .as_ref() + .map(|u| u.seen.clone()) + .unwrap_or_default() +} + +/// An upstream that refuses the stateless revision and requires `initialize` is reached by +/// negotiation: busbar opens a session with it, and the relayed call is answered from inside it. +/// When the upstream then forgets that session, the walk's answer (`404`) is recovered by a fresh +/// `initialize` and the call is still answered. +#[test] +fn an_upstream_that_requires_a_session_is_reached_by_negotiation() { + let _serial = SERIAL.lock().unwrap_or_else(PoisonError::into_inner); + *upstreams() = Some(Upstreams::default()); + let d = open(); + assert_eq!( + d.arrive(40, "POST", "/mcp", CALL, CALL_FIELDS).outcome, + Outcome::Ready + ); + d.piece(40, &Given::attempt(ALICE, "fs")); + let send = d.piece(40, &Given::caller(ALICE, CALL)); + assert!( + send.far, + "the call is relayed, not refused: {} {}", + send.status, + send.text() + ); + let sent: Value = serde_json::from_slice(&send.bytes).expect("a JSON-RPC request"); + assert_eq!(sent["method"], json!("tools/call")); + assert_eq!( + send.field("mcp-session-id"), + Some(UPSTREAM_SESSION), + "the relayed call rides the session negotiated with the upstream: {:?}", + send.fields + ); + assert!( + sent.pointer("/params/_meta/io.modelcontextprotocol~1protocolVersion") + .is_none(), + "lowered into the session revision: {sent}" + ); + let methods: Vec = seen() + .iter() + .filter(|s| s.url.starts_with(SESSION_URL)) + .map(|s| s.body["method"].as_str().unwrap_or("").to_string()) + .collect(); + assert_eq!( + methods, + [ + "tools/list", + "initialize", + "notifications/initialized", + "tools/list" + ], + "the verify fetch probed statelessly, was refused, and negotiated" + ); + let far_ok = + br#"{"jsonrpc":"2.0","id":0,"result":{"content":[{"type":"text","text":"in-session"}]}}"#; + let answered = d.piece(40, &Given::far(ALICE, 200, vec![JSON_TYPE], far_ok)); + assert_eq!(answered.status, 200, "{}", answered.text()); + let body = answered.json(); + assert_eq!(body["id"], json!(30)); + assert_eq!(body["result"]["content"][0]["text"], json!("in-session")); + + // The upstream forgot the session: the walk's answer is its 404, and busbar re-initialises. + assert_eq!( + d.arrive(41, "POST", "/mcp", CALL, CALL_FIELDS).outcome, + Outcome::Ready + ); + d.piece(41, &Given::attempt(ALICE, "fs")); + let send = d.piece(41, &Given::caller(ALICE, CALL)); + assert!(send.far); + let answered = d.piece( + 41, + &Given::far( + ALICE, + 404, + vec![JSON_TYPE], + br#"{"error":"unknown session"}"#, + ), + ); + assert_eq!(answered.status, 200, "{}", answered.text()); + let body = answered.json(); + assert_eq!(body["id"], json!(30)); + assert_eq!(body["result"]["content"][0]["text"], json!("in-session")); +} + +/// An upstream that speaks the stateless revision sees exactly the request the dialect's builder +/// writes, with no handshake ahead of it. +#[test] +fn a_stateless_upstream_sees_the_stateless_request_unchanged() { + let _serial = SERIAL.lock().unwrap_or_else(PoisonError::into_inner); + *upstreams() = Some(Upstreams::default()); + let d = open(); + assert_eq!( + d.arrive(50, "POST", "/mcp", ST_CALL, ST_FIELDS).outcome, + Outcome::Ready + ); + d.piece(50, &Given::attempt(ALICE, "st")); + let send = d.piece(50, &Given::caller(ALICE, ST_CALL)); + assert!(send.far, "{} {}", send.status, send.text()); + assert_eq!(send.request, "POST /st"); + let sent: Value = serde_json::from_slice(&send.bytes).expect("a JSON-RPC request"); + assert_eq!( + sent, + json!({ + "jsonrpc": "2.0", + "id": 0, + "method": "tools/call", + "params": { + "name": "read_file", + "arguments": {}, + "_meta": { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientCapabilities": {}, + "progressToken": null, + }, + }, + }) + ); + assert_eq!( + send.fields, + vec![ + ("content-type".to_string(), "application/json".to_string()), + ( + "accept".to_string(), + "application/json, text/event-stream".to_string() + ), + ("mcp-protocol-version".to_string(), "2026-07-28".to_string()), + ("mcp-method".to_string(), "tools/call".to_string()), + ("mcp-name".to_string(), "read_file".to_string()), + ] + ); + let to_st: Vec = seen() + .into_iter() + .filter(|s| s.url.starts_with(STATELESS_URL)) + .collect(); + assert_eq!(to_st.len(), 1, "one verify fetch, no handshake: {to_st:?}"); + assert_eq!(to_st[0].body["method"], json!("tools/list")); + assert_eq!( + field_of(&to_st[0].fields, "mcp-protocol-version"), + Some("2026-07-28") + ); + let far_ok = br#"{"jsonrpc":"2.0","id":0,"result":{"content":[{"type":"text","text":"stateless"}],"resultType":"complete"}}"#; + let answered = d.piece(50, &Given::far(ALICE, 200, vec![JSON_TYPE], far_ok)); + assert_eq!(answered.status, 200, "{}", answered.text()); + assert_eq!( + answered.json()["result"]["content"][0]["text"], + json!("stateless") + ); +} + +// ── subscribe stays for the old revisions ─────────────────────────────────────────────────────── + +/// A `2025-06-18` session that subscribes to a declared resource hears the announcing server's +/// `notifications/resources/updated` on its GET stream: here the upstream announces it on the event +/// stream it answers a relayed call with. +#[test] +fn a_session_subscription_hears_the_upstreams_resource_update() { + let _serial = SERIAL.lock().unwrap_or_else(PoisonError::into_inner); + *upstreams() = Some(Upstreams::default()); + let d = open(); + let init = initialize(&d, 60, ALICE); + let session = init + .field("mcp-session-id") + .expect("the answer names its session") + .to_string(); + assert_eq!( + init.json()["result"]["capabilities"]["resources"]["subscribe"], + json!(true), + "subscribe stays for the old revisions" + ); + let in_session = [ + ACCEPT_BOTH, + ("mcp-session-id", session.as_str()), + ("mcp-protocol-version", "2025-06-18"), + ]; + let subscribe = + br#"{"jsonrpc":"2.0","id":3,"method":"resources/subscribe","params":{"uri":"file:///readme"}}"#; + assert_eq!( + d.arrive(61, "POST", "/mcp", subscribe, &in_session).outcome, + Outcome::Ready + ); + let subscribed = d.piece(61, &Given::caller(ALICE, subscribe)); + assert_eq!(subscribed.status, 200, "{}", subscribed.text()); + assert_eq!(subscribed.json()["result"], json!({})); + + let stream = [ + ("accept", "text/event-stream"), + ("mcp-session-id", session.as_str()), + ("mcp-protocol-version", "2025-06-18"), + ]; + assert_eq!( + d.arrive(62, "GET", "/mcp", b"", &stream).outcome, + Outcome::Ready + ); + let opened = d.piece(62, &Given::stream_open(ALICE, 62)); + assert_eq!(opened.status, 200); + assert_eq!(opened.field("content-type"), Some("text/event-stream")); + + assert_eq!( + d.arrive(63, "POST", "/mcp", ST_CALL, ST_FIELDS).outcome, + Outcome::Ready + ); + d.piece(63, &Given::attempt(BOB, "st")); + assert!(d.piece(63, &Given::caller(BOB, ST_CALL)).far); + let far = b"event: message\ndata: {\"jsonrpc\":\"2.0\",\"method\":\"notifications/resources/updated\",\"params\":{\"uri\":\"file:///readme\"}}\n\nevent: message\ndata: {\"jsonrpc\":\"2.0\",\"id\":0,\"result\":{\"content\":[],\"resultType\":\"complete\"}}\n\n"; + let answered = d.piece( + 63, + &Given::far(BOB, 200, vec![("content-type", "text/event-stream")], far), + ); + assert_eq!(answered.status, 200); + + let delivered = d.piece(62, &Given::collect(ALICE, 62)).text(); + assert!( + delivered.contains(r#""method":"notifications/resources/updated""#) + && delivered.contains("file:///readme"), + "the subscriber hears the update: {delivered:?}" + ); +} diff --git a/crates/busbar/src/root/tests/door_steps.rs b/crates/busbar/src/root/tests/door_steps.rs index 7c68139bd6..e24eb3db84 100644 --- a/crates/busbar/src/root/tests/door_steps.rs +++ b/crates/busbar/src/root/tests/door_steps.rs @@ -1269,6 +1269,8 @@ pub(crate) mod tool_door { pub(crate) plane: crate::root::boot::DoorPlane, /// How far the kernel's monotonic clock (`clock.now`) reads ahead of the runtime's. pub(crate) clock: Arc, + /// How far the kernel's wall clock reads ahead of the system's, in milliseconds. + pub(crate) wall: Arc, } impl Rig { @@ -1299,9 +1301,12 @@ pub(crate) mod tool_door { ) -> busbar_kernel::test_support::TestApp, ) -> Self { // THE CONNECTOR, over every linked transport door (the default distribution links the - // http framer's door), its dials judged by a destination guard that admits loopback. + // http framer's door), its dials judged by a destination guard that admits loopback. The + // same guard is the kernel's `dest.judge` (as `root::serve::kernel_services` composes + // it), and the deployment blocks one extra host ([`BLOCKED_HOST`]). let judge = crate::root::connector::guard_for(&busbar_kernel::config::Destinations { block_private_addresses: footing.guarded, + blocked: vec![BLOCKED_HOST.to_string()], ..Default::default() }) .expect("the guard"); @@ -1344,10 +1349,19 @@ pub(crate) mod tool_door { } }; let clock = Arc::new(std::sync::atomic::AtomicU64::new(0)); - let (late, store) = records_composed(footing.ledger, Arc::clone(&gov), &clock); + let wall = Arc::new(std::sync::atomic::AtomicU64::new(0)); + let (late, store) = records_composed( + footing.ledger, + Arc::clone(&gov), + (&clock, &wall), + Arc::clone(&judge) as Arc, + ); let dispatcher = Arc::new(Dispatcher::with_services( DispatchConfig::default(), - Arc::clone(&late) as Arc, + Arc::new(Clocked { + inner: Arc::clone(&late) as Arc, + wall: Arc::clone(&wall), + }), )); crate::root::connector::install_io(&dispatcher); // Its connection reads through a ticket (the door's `exchange`) wake on the dispatcher. @@ -1524,6 +1538,7 @@ pub(crate) mod tool_door { door_table, plane, clock, + wall, } } @@ -1594,24 +1609,36 @@ pub(crate) mod tool_door { /// The kernel's host services composed whole for a door plane that writes records: a record store /// (an in-memory store: its typed record rows and its plane-record slots, where a chained kind's - /// journal persists), the runtime's blocking pool the store calls run on, installed as the - /// process's late services. + /// journal persists), the runtime's blocking pool the store calls run on, and `dest`, the + /// deployment's one destination judge, installed as the process's late services. fn records_composed( ledger: Ledger, signer: Arc, - clock: &Arc, + (clock, wall): ( + &Arc, + &Arc, + ), + dest: Arc, ) -> ( Arc, Arc, ) { let store = Arc::new(busbar_kernel::governance::MemoryStore::new()); let (origin, ahead) = (std::time::Instant::now(), Arc::clone(clock)); + let wall_ahead = Arc::clone(wall); let kernel = busbar_kernel::host_services::KernelServices::new() + .with_dest_judge(dest) .with_mono_clock(Arc::new(move || { u64::try_from(origin.elapsed().as_nanos()) .unwrap_or(u64::MAX) .saturating_add(ahead.load(std::sync::atomic::Ordering::SeqCst)) })) + .with_wall_clock(Arc::new(move || { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |d| u64::try_from(d.as_millis()).unwrap_or(u64::MAX)) + .saturating_add(wall_ahead.load(std::sync::atomic::Ordering::SeqCst)) + })) .with_signer(signer) .with_pool(Arc::new(busbar_kernel::host_services::BlockingPool::new( tokio::runtime::Handle::current(), @@ -1622,6 +1649,12 @@ pub(crate) mod tool_door { kernel.with_records(Arc::new(Rows::default()), Arc::clone(&store) as _) } Ledger::Store(claims) => kernel.with_records(Arc::new(Rows::default()), claims), + Ledger::Rows(rows, max_live) => kernel + .with_work_bounds(busbar_kernel::host_work::WorkBounds { + max_live, + ..Default::default() + }) + .with_records(rows, Arc::clone(&store) as _), Ledger::Unbound => kernel, }); let late = crate::root::serve::LateServices::new(); @@ -1630,6 +1663,257 @@ pub(crate) mod tool_door { (late, store) } + /// The host services as the door reads them, the wall clock `clock.now` answers read `wall` + /// milliseconds ahead, as the kernel's work book reads it: every other service is `inner`'s. + struct Clocked { + inner: Arc, + wall: Arc, + } + + impl busbar_contract::services::HostServices for Clocked { + fn now(&self) -> busbar_contract::services::Reading { + let mut reading = self.inner.now(); + let ahead = self.wall.load(std::sync::atomic::Ordering::SeqCst); + reading.wall_ns = reading + .wall_ns + .saturating_add(ahead.saturating_mul(1_000_000)); + reading + } + fn dest_judge( + &self, + dest: &str, + class: u32, + flags: u32, + later: Option, + ) -> busbar_contract::services::Ran { + self.inner.dest_judge(dest, class, flags, later) + } + fn records_get( + &self, + caller: &busbar_contract::services::Caller, + kind: &str, + key: &[u8], + later: busbar_contract::services::Later, + ) -> busbar_contract::services::Ran { + self.inner.records_get(caller, kind, key, later) + } + fn records_list( + &self, + caller: &busbar_contract::services::Caller, + list: busbar_contract::services::RecordsList, + later: busbar_contract::services::Later, + ) -> busbar_contract::services::Ran { + self.inner.records_list(caller, list, later) + } + fn records_claim( + &self, + caller: &busbar_contract::services::Caller, + kind: &str, + key: &[u8], + ttl_ms: u64, + later: busbar_contract::services::Later, + ) -> busbar_contract::services::Ran { + self.inner.records_claim(caller, kind, key, ttl_ms, later) + } + fn sign( + &self, + caller: &busbar_contract::services::Caller, + data: &[u8], + ) -> busbar_contract::services::Stored { + self.inner.sign(caller, data) + } + fn trust_sight( + &self, + caller: &busbar_contract::services::Caller, + counterparty: &str, + hash: &str, + later: busbar_contract::services::Later, + ) -> busbar_contract::services::Ran { + self.inner.trust_sight(caller, counterparty, hash, later) + } + fn trust_unreached( + &self, + caller: &busbar_contract::services::Caller, + counterparty: &str, + ) -> busbar_contract::services::Stored { + self.inner.trust_unreached(caller, counterparty) + } + fn trust_sight_item( + &self, + caller: &busbar_contract::services::Caller, + counterparty: &str, + item: &str, + digest: &str, + ) -> busbar_contract::services::Stored { + self.inner + .trust_sight_item(caller, counterparty, item, digest) + } + fn trust_decide( + &self, + caller: &busbar_contract::services::Caller, + key: busbar_contract::services::TrustKeyRef<'_>, + expected: Option<&str>, + approve: bool, + ) -> busbar_contract::services::Stored { + self.inner.trust_decide(caller, key, expected, approve) + } + fn trust_state( + &self, + caller: &busbar_contract::services::Caller, + counterparty: &str, + ) -> busbar_contract::services::Stored { + self.inner.trust_state(caller, counterparty) + } + fn trust_serves( + &self, + caller: &busbar_contract::services::Caller, + counterparty: &str, + item: Option<&str>, + digest: Option<&str>, + ) -> busbar_contract::services::Stored { + self.inner.trust_serves(caller, counterparty, item, digest) + } + fn trust_due( + &self, + caller: &busbar_contract::services::Caller, + ) -> busbar_contract::services::Stored { + self.inner.trust_due(caller) + } + fn trust_verify( + &self, + caller: &busbar_contract::services::Caller, + counterparty: &str, + payload: &[u8], + signatures: &[u8], + ) -> busbar_contract::services::Stored { + self.inner + .trust_verify(caller, counterparty, payload, signatures) + } + fn entitlement_check( + &self, + caller: &busbar_contract::services::Caller, + unit: Option, + target: &str, + ) -> busbar_contract::services::Stored { + self.inner.entitlement_check(caller, unit, target) + } + fn session_emit( + &self, + caller: &busbar_contract::services::Caller, + session: u64, + bytes: &[u8], + ) -> busbar_contract::services::Stored { + self.inner.session_emit(caller, session, bytes) + } + fn random_fill(&self, len: u64) -> busbar_contract::services::Stored { + self.inner.random_fill(len) + } + fn records_secret( + &self, + kind: &str, + id: &str, + later: busbar_contract::services::Later, + ) -> busbar_contract::services::Ran { + self.inner.records_secret(kind, id, later) + } + fn unit_nest( + &self, + caller: &busbar_contract::services::Caller, + unit: Option, + ask: busbar_contract::services::NestAsk, + later: busbar_contract::services::Later, + ) -> busbar_contract::services::Ran { + self.inner.unit_nest(caller, unit, ask, later) + } + fn work_open( + &self, + caller: &busbar_contract::services::Caller, + unit: Option, + kind: &str, + record: &[u8], + later: busbar_contract::services::Later, + ) -> busbar_contract::services::Ran { + self.inner.work_open(caller, unit, kind, record, later) + } + fn work_find( + &self, + caller: &busbar_contract::services::Caller, + unit: Option, + reference: &[u8], + later: busbar_contract::services::Later, + ) -> busbar_contract::services::Ran { + self.inner.work_find(caller, unit, reference, later) + } + fn work_settle( + &self, + caller: &busbar_contract::services::Caller, + handle: u64, + record: &[u8], + later: busbar_contract::services::Later, + ) -> busbar_contract::services::Ran { + self.inner.work_settle(caller, handle, record, later) + } + fn work_resume( + &self, + caller: &busbar_contract::services::Caller, + unit: Option, + handle: u64, + later: busbar_contract::services::Later, + ) -> busbar_contract::services::Ran { + self.inner.work_resume(caller, unit, handle, later) + } + fn disk_append( + &self, + dest: &busbar_contract::services::DiskDest, + bytes: Vec, + later: busbar_contract::services::Later, + ) -> busbar_contract::services::Ran { + self.inner.disk_append(dest, bytes, later) + } + fn verify_lookup( + &self, + caller: &busbar_contract::services::Caller, + key: &[u8], + later: busbar_contract::services::Later, + ) -> busbar_contract::services::Ran { + self.inner.verify_lookup(caller, key, later) + } + fn verify_store( + &self, + caller: &busbar_contract::services::Caller, + key: &[u8], + entry: &[u8], + ttl_ms: u64, + ) -> busbar_contract::services::Stored { + self.inner.verify_store(caller, key, entry, ttl_ms) + } + fn content_scan( + &self, + caller: &busbar_contract::services::Caller, + unit: Option, + content: &[u8], + later: busbar_contract::services::Later, + ) -> busbar_contract::services::Ran { + self.inner.content_scan(caller, unit, content, later) + } + fn hook_call( + &self, + caller: &busbar_contract::services::Caller, + unit: Option, + ask: busbar_contract::services::HookAsk, + later: busbar_contract::services::Later, + ) -> busbar_contract::services::Ran { + self.inner.hook_call(caller, unit, ask, later) + } + fn snapshot_read( + &self, + caller: &busbar_contract::services::Caller, + scope: u32, + ) -> busbar_contract::services::Snapshot { + self.inner.snapshot_read(caller, scope) + } + } + /// What a rig stands on: the record store its host services bind, and whose governance book it /// reads (its own, or a first node's: a fleet). pub(crate) struct Footing<'a> { @@ -1652,25 +1936,36 @@ pub(crate) mod tool_door { } } + /// A public-looking host the rig's deployment refuses by its own rules alone + /// (`security.blocked_metadata_hosts`): no address rule refuses it, so only the deployment's + /// destination judge can. + pub(crate) const BLOCKED_HOST: &str = "blocked.example"; + /// The record store a rig's host services bind. pub(crate) enum Ledger { /// A fresh in-memory store (the rig's own). Memory, /// The given store: a handle on a durable journal (a restart or a fleet node). Store(Arc), + /// The given typed rows, which the nodes of one deployment and the successive processes of + /// one node share (their work handles and their plugins' records), the host keeping at most + /// the given number of live work handles. + Rows(Arc, usize), /// None: the host binds no store. Unbound, } - /// Typed record rows in memory: the store kind's record slots, for the records services. + /// Typed record rows in memory: the store kind's record slots, for the records services; and + /// how many writes of a settled work handle the store refuses next. #[derive(Default)] - struct Rows( - std::sync::Mutex< + pub(crate) struct Rows( + pub(crate) std::sync::Mutex< BTreeMap< (busbar_contract::ids::RecordSchemaId, Vec), busbar_contract::kinds::RecordBytes, >, >, + pub(crate) std::sync::atomic::AtomicU32, ); impl busbar_kernel::host_records::RecordRows for Rows { @@ -1680,6 +1975,21 @@ pub(crate) mod tool_door { key: &[u8], value: &busbar_contract::kinds::RecordBytes, ) -> Result<(), busbar_contract::kinds::StoreError> { + let settles = schema == busbar_kernel::host_work::WORK_SCHEMA + && value.as_slice().get(1) + == Some(&busbar_contract::abi::host::service::WORK_SETTLED); + let refused = settles + && self + .1 + .fetch_update( + std::sync::atomic::Ordering::SeqCst, + std::sync::atomic::Ordering::SeqCst, + |n| n.checked_sub(1), + ) + .is_ok(); + if refused { + return Err(busbar_contract::kinds::StoreError::Unavailable); + } self.0 .lock() .expect("unpoisoned") @@ -2453,6 +2763,98 @@ pub(crate) mod tool_door { assert_eq!(status, StatusCode::OK, "{}", String::from_utf8_lossy(&body)); } + /// A `tools/call` of the rig's one tool whose undeclared `fetch` argument carries `url`. + fn fetching(url: &str) -> String { + serde_json::json!({ + "jsonrpc": "2.0", "id": 32, "method": "tools/call", + "params": { + "name": "fs_read_file", + "arguments": { "path": "notes.txt", "fetch": url }, + "_meta": { + "io.modelcontextprotocol/protocolVersion": protocol_version(), + "io.modelcontextprotocol/clientCapabilities": {} + } + } + }) + .to_string() + } + + /// Each URL in `urls`, carried in a call's arguments, is refused before the call is sent, with + /// the argument guard's bytes (403, JSON-RPC -32000, `tool_argument_refused`); then a public + /// URL in the same field is served. + async fn refused_in_the_arguments(instance: &'static str, urls: &[&str]) { + let (port, mut heard) = tool_server().await; + let rig = Rig::new(instance, port, None); + for url in urls { + let (status, body) = send(&rig.router, Some(&rig.token), &fetching(url)).await; + let shown = String::from_utf8_lossy(&body); + assert_eq!(status.as_u16(), 403, "`{url}` must be refused: {shown}"); + let body: serde_json::Value = serde_json::from_slice(&body).expect("JSON-RPC"); + assert_eq!(body["error"]["code"], -32000, "`{url}`: {body}"); + assert_eq!( + body["error"]["data"]["reason"], "tool_argument_refused", + "`{url}`: {body}" + ); + } + let mut wire = Vec::new(); + while let Ok(r) = heard.try_recv() { + wire.push(r); + } + assert!( + wire.iter().all(|r| !r.contains("\"tools/call\"")), + "no refused call reached the wire: {wire:?}" + ); + let (status, body) = send( + &rig.router, + Some(&rig.token), + &fetching("https://docs.example.com/notes"), + ) + .await; + assert_eq!(status, StatusCode::OK, "{}", String::from_utf8_lossy(&body)); + } + + /// THE ARGUMENT GUARD ASKS THE KERNEL'S `dest.judge` (THE DESIGN §11 host services B.3 item + /// 11): a host the deployment's own egress rules refuse ([`BLOCKED_HOST`], a + /// `security.blocked_metadata_hosts` entry no address rule would refuse) is refused when a + /// call's arguments name it. A guard deciding in the plane admits it. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn a_host_the_deployment_refuses_is_refused_in_the_arguments() { + let _one = PUBLISHING.lock().await; + let instance = "serve-door-tools-destjudge-blocked"; + let _published = Published(instance); + let (plain, secure) = ( + format!("http://{BLOCKED_HOST}/x"), + format!("https://{BLOCKED_HOST}:8443/y"), + ); + refused_in_the_arguments(instance, &[&plain, &secure]).await; + } + + /// The addresses the connector's guard refuses as internal are refused in a call's arguments + /// too, by that same judgement (`dest.judge`, private reach refused for a registration that + /// was granted none): benchmarking, IETF protocol assignments, "this network", the mapped, + /// scoped and link-local IPv6 spellings, multicast and broadcast. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn every_internal_address_the_guard_refuses_is_refused_in_the_arguments() { + let _one = PUBLISHING.lock().await; + let instance = "serve-door-tools-destjudge-internal"; + let _published = Published(instance); + refused_in_the_arguments( + instance, + &[ + "http://198.18.0.1/x", + "http://192.0.0.8/x", + "http://0.1.2.3/x", + "http://[::ffff:198.18.0.1]/x", + "http://[::1%25lo]/x", + "http://[fe80::1%25eth0]/x", + "http://224.0.0.1/x", + "http://255.255.255.255/x", + "http://[ff02::1]/x", + ], + ) + .await; + } + /// A UNIT THE PLANE ANSWERS ITSELF (ARCHITECT Q-L3B-LOCAL): a keyed `tools/list` names no entry, /// is admitted with no route walk and answered 200 by the plane from its catalogue — nothing /// charged (only far-end-reported units bill), nothing dialled, every unit ended. @@ -3734,7 +4136,8 @@ mod task_continuation { use tokio::io::{AsyncReadExt, AsyncWriteExt}; use super::tool_door::{ - protocol_version, rig_tools, send_as, tool_digest, tool_listing, Rig, CALL, + protocol_version, rig_on, rig_tools, send_as, tool_digest, tool_listing, Footing, Ledger, + Rig, Rows, CALL, }; use crate::root::serve::planes_tests::{Published, PUBLISHING}; @@ -4306,6 +4709,178 @@ mod task_continuation { ); assert!(failed["result"].get("inputRequests").is_none(), "{failed}"); } + + // ── THE TASK STORE IS HOST RECORDS (BUSBAR-1.6.0.md §2, the mcp bullet; §1 "Admission bounds + // live work; nothing evicts it") ──────────────────────────────────────────────────────────── + + /// One node of a deployment of `instance` on `port`, standing on the typed rows `rows` its + /// other nodes and processes share, its host keeping `max_live` live work handles, reading + /// `book`'s governance. + fn node( + instance: &'static str, + port: u16, + rows: &std::sync::Arc, + max_live: usize, + book: Option<&Rig>, + ) -> Rig { + rig_on( + instance, + port, + tools(port, "optional"), + Footing { + ledger: Ledger::Rows(std::sync::Arc::clone(rows), max_live), + book, + guarded: false, + }, + ) + } + + /// Every unit `rig` admitted has ended. + async fn ended(rig: &Rig) { + let ended = async { + while !rig.all_ended() { + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + } + }; + tokio::time::timeout(std::time::Duration::from_secs(10), ended) + .await + .expect("every unit ended"); + } + + /// A LIVE TASK IS ANSWERED FROM THE HOST'S ROWS: created through one node (its call held at + /// the server, so the task stays live), `tasks/get` through another node of the deployment + /// answers it exactly as the creating node does, and never as an unknown task. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn a_live_task_created_on_one_node_is_answered_by_another() { + let _one = PUBLISHING.lock().await; + let instance = "door-task-fleet"; + let _published = Published(instance); + let (_release, gate) = tokio::sync::watch::channel(false); + let (port, mut heard) = gated_server(gate).await; + let rows = std::sync::Arc::new(Rows::default()); + let a = node(instance, port, &rows, 4096, None); + let b = node(instance, port, &rows, 4096, Some(&a)); + let (_, task_id) = create(&a).await; + let _call = next_call(&mut heard).await; + let (status, here) = ask(&a, &a.token, "tasks/get", &task_id).await; + assert_eq!(status, 200, "{here}"); + assert_eq!(here["result"]["status"], "working", "{here}"); + // The other node reads the host's rows once the first node's writes reach them. + let mut there = serde_json::Value::Null; + for _ in 0..250 { + there = ask(&b, &b.token, "tasks/get", &task_id).await.1; + if there == here { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + } + assert_eq!( + there, here, + "the other node answers the live task from the host's rows" + ); + } + + /// THE HANDLES A GONE PROCESS LEFT ARE SETTLED: a process opens live work up to its host's + /// bound and ends with it live; past the lease of their runs, the next process's task-creating + /// call settles them `cancelled` and is admitted, where it would be refused at the bound. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn the_live_handles_a_gone_process_left_are_settled_and_a_new_task_is_admitted() { + let _one = PUBLISHING.lock().await; + let instance = "door-task-restart"; + let _published = Published(instance); + let (_release, gate) = tokio::sync::watch::channel(false); + let (port, _heard) = gated_server(gate).await; + let rows = std::sync::Arc::new(Rows::default()); + let gone = node(instance, port, &rows, 2, None); + let (_, first) = create(&gone).await; + let (_, second) = create(&gone).await; + let (status, refused) = send_as( + &gone.router, + Some(&gone.token), + &task_call(), + "tools/call", + Some("fs_read_file"), + ) + .await; + assert_eq!( + status.as_u16(), + 503, + "at the bound of live work: {}", + String::from_utf8_lossy(&refused) + ); + // Its writes reach the shared rows; then its process ends, and the clock moves on. + tokio::time::sleep(std::time::Duration::from_millis(300)).await; + let next = node(instance, port, &rows, 2, Some(&gone)); + drop(gone); + next.wall + .fetch_add(3_600_000, std::sync::atomic::Ordering::SeqCst); + let (status, body) = send_as( + &next.router, + Some(&next.token), + &task_call(), + "tools/call", + Some("fs_read_file"), + ) + .await; + assert_eq!( + status.as_u16(), + 200, + "admitted: {}", + String::from_utf8_lossy(&body) + ); + for id in [&first, &second] { + let (_, got) = ask(&next, &next.token, "tasks/get", id).await; + assert_eq!(got["result"]["status"], "cancelled", "{got}"); + } + } + + /// A SETTLE THE STORE REFUSES IS MADE AGAIN UNTIL IT LANDS: the store refuses the first two + /// writes of a settled handle (the continuation's own settle, and the next create's); the + /// handle is settled by a following create rather than left live for ever. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn a_settle_the_store_refuses_is_made_again_until_it_lands() { + let _one = PUBLISHING.lock().await; + let instance = "door-task-settle-retry"; + let _published = Published(instance); + let (_release, gate) = tokio::sync::watch::channel(true); + let (port, _heard) = gated_server(gate).await; + let rows = std::sync::Arc::new(Rows::default()); + rows.1.store(2, std::sync::atomic::Ordering::SeqCst); + let rig = node(instance, port, &rows, 4096, None); + let (_, task_id) = create(&rig).await; + ended(&rig).await; + let reference = + busbar_kernel::host_work::parse_reference(task_id.as_bytes()).expect("a reference"); + let key = ( + busbar_kernel::host_work::WORK_SCHEMA, + busbar_kernel::host_work::work_key(instance, &reference), + ); + let state = || { + rows.0 + .lock() + .expect("unpoisoned") + .get(&key) + .and_then(|row| row.as_slice().get(1).copied()) + }; + assert_eq!( + state(), + Some(busbar_contract::abi::host::service::WORK_LIVE), + "the store refused the continuation's settle" + ); + for _ in 0..2 { + create(&rig).await; + ended(&rig).await; + } + let mut settled = false; + for _ in 0..250 { + if state() == Some(busbar_contract::abi::host::service::WORK_SETTLED) { + settled = true; + break; + } + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + } + assert!(settled, "the owed settle landed"); + } } /// THE HOOK PARITY BATTERY ON THE DRIVER (BUSBAR-1.6.0.md Part 3 section 12 "Hooks": the hook stages diff --git a/crates/busbar/tests/mcp_stdio_serve.rs b/crates/busbar/tests/mcp_stdio_serve.rs index 525d60fea8..9ef9be9af6 100644 --- a/crates/busbar/tests/mcp_stdio_serve.rs +++ b/crates/busbar/tests/mcp_stdio_serve.rs @@ -677,7 +677,9 @@ fn a_bound_session_serves_and_eof_with_a_live_subscription_exits_promptly() { ); let mut child = spawn(&dir, Some(&token)); - // A LEGACY-era opening: `initialize`, no `_meta` — the stdio dual-era negotiation. + // A SESSION-era opening: `initialize`, no `_meta`. The carrier answers in the session revision + // the client asked for (revision by negotiation, THE DESIGN section 2, the mcp bullet); a line + // that states the stateless `_meta` after it is still served as it is. child.send(&serde_json::json!({ "jsonrpc": "2.0", "id": 1, "method": "initialize", "params": { "protocolVersion": "2025-06-18", "capabilities": {}, @@ -687,7 +689,7 @@ fn a_bound_session_serves_and_eof_with_a_live_subscription_exits_promptly() { assert_eq!( init.pointer("/result/protocolVersion") .and_then(|v| v.as_str()), - Some("2026-07-28"), + Some("2025-06-18"), "{init}" ); child.send(&serde_json::json!({ "jsonrpc": "2.0", "method": "notifications/initialized" })); diff --git a/docs/diagnostics-mcp.json b/docs/diagnostics-mcp.json index b8129f66f0..b7673849f5 100644 --- a/docs/diagnostics-mcp.json +++ b/docs/diagnostics-mcp.json @@ -76,12 +76,12 @@ "number": 7065, "class": "plane", "slug": "mcp-output-schema-violation", - "title": "MCP upstream structuredContent violates the published outputSchema", + "title": "MCP upstream structuredContent violated the published outputSchema — RETIRED", "severity": "benign_recurring", - "summary": "An upstream MCP tool returned `structuredContent` that does not validate against the tool's own published `outputSchema`, so the result is refused. This is an upstream contract violation that can recur per request, so it is logged at debug to avoid spam.", - "action": "If a specific tool trips this repeatedly, report the schema mismatch to that MCP server's operator. No local action is needed.", + "summary": "RETIRED. An upstream MCP tool's `structuredContent` that did not validate against the tool's published `outputSchema` was once refused and replaced by busbar's own tool error. 1.6.0 removed that check: busbar relays an upstream's tool result unchanged (THE DESIGN Law 11), and the caller judges it against the published schema.", + "action": "Nothing emits this code.", "since": "1.6.0", - "retired": false + "retired": true }, { "code": "BUSBAR-7066", diff --git a/docs/diagnostics-mcp.md b/docs/diagnostics-mcp.md index 52e889825a..b6af69a227 100644 --- a/docs/diagnostics-mcp.md +++ b/docs/diagnostics-mcp.md @@ -75,15 +75,15 @@ An upstream MCP tool returned an input-required result that reached the terminal **What to do:** Report the named tool and field: the ask-recognition path has a gap that let an input-required shape through. This is a code-level fix, not an operator misconfig. -### BUSBAR-7065 — MCP upstream structuredContent violates the published outputSchema +### BUSBAR-7065 — MCP upstream structuredContent violated the published outputSchema — RETIRED *(retired)* - **Severity:** benign_recurring - **Since:** 1.6.0 - **Slug:** `mcp-output-schema-violation` -An upstream MCP tool returned `structuredContent` that does not validate against the tool's own published `outputSchema`, so the result is refused. This is an upstream contract violation that can recur per request, so it is logged at debug to avoid spam. +RETIRED. An upstream MCP tool's `structuredContent` that did not validate against the tool's published `outputSchema` was once refused and replaced by busbar's own tool error. 1.6.0 removed that check: busbar relays an upstream's tool result unchanged (THE DESIGN Law 11), and the caller judges it against the published schema. -**What to do:** If a specific tool trips this repeatedly, report the schema mismatch to that MCP server's operator. No local action is needed. +**What to do:** Nothing emits this code. ### BUSBAR-7066 — MCP tools/call refused by policy diff --git a/docs/mcp.md b/docs/mcp.md index 6d0252b33e..63a24dc6ed 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -62,7 +62,7 @@ That mounts four route entries over two paths (`crates/busbar-core/src/router.rs |---|---|---| | `GET /.well-known/oauth-protected-resource/mcp` | **none** | RFC 9728 §3.1 metadata. The one open route on this plane — every caller who needs it is by definition one that has no token yet. | | `POST /mcp` | key | The endpoint. JSON-RPC 2.0. | -| `GET /mcp`, `DELETE /mcp` | key | `405`. This revision has no GET stream and no sessions. Behind the key so an anonymous caller gets the `401` challenge instead of a description of the surface. | +| `GET /mcp`, `DELETE /mcp` | key | The session revisions (2025-11-25, 2025-06-18): a GET naming `Mcp-Session-Id` opens that session's event stream, a DELETE naming it ends it; a session another key presents is `404`. A GET accepting `text/event-stream` with no session and no `MCP-Protocol-Version` opens the 2024-11-05 event stream. Any other GET or DELETE is `405`. Behind the key so an anonymous caller gets the `401` challenge instead of a description of the surface. | The metadata path is the well-known prefix with the resource's path appended **after** it, per RFC 9728's path-insertion rule (`crates/busbar-mcp/src/mcp/mod.rs:266-272`). Getting that backwards 404s every compliant client's discovery. The document Busbar renders carries `resource` always, `authorization_servers` and `scopes_supported` when non-empty, and `bearer_methods_supported: ["header"]`, with `Cache-Control: public, max-age=3600` (`crates/busbar-core/src/ingress/protocol.rs:344-372`). `bearer_methods_supported` is not configurable: Busbar accepts a bearer in the `Authorization` header and nowhere else, on every plane. @@ -133,7 +133,7 @@ Naming a server `hooks` or `upstream_credentials` is refused at parse with a mes | `schema_hash` | string | absent | The APPROVED digest. **An empty value object means "allowed, no hash approved yet", which is `pending` and does not serve.** | | `description` | string | absent | The OPERATOR's description, published in the catalogue. Markup-normalised on the way out. Never an input to a routing decision. | | `input_schema` | JSON | absent | Echoed verbatim as `inputSchema`. Absent publishes `{"type":"object"}`. | -| `output_schema` | JSON | absent | Published as `outputSchema`, **and enforced**: an upstream's `structuredContent` is validated against it and a violation is reported as a tool failure. Absent means no promise and no validation — Busbar never invents one. | +| `output_schema` | JSON | absent | Published as `outputSchema`. Busbar does not validate an upstream's `structuredContent` against it: the upstream's result reaches the caller as the upstream sent it, and the caller judges it against the published schema. Absent publishes none — Busbar never invents one. | | `publish_as` | string | absent | Overrides the default published wire name `_`. This is the value `tools/list` emits, the value `tools/call` dispatches on, **and the value an `mcp_tool:` grant must name** — so setting it changes who can call the tool. | | `ask_caller` | list of rounds | `[]` | What Busbar asks its own caller for, synchronously (SEP-2322), before dispatching. | | `task_support` | `none` \| `optional` \| `required` | `none` | SEP-2663. `required` refuses a client that did not declare the tasks extension with `-32021` before the handler runs. | @@ -328,9 +328,9 @@ Statuses: `crates/busbar-mcp/src/mcp/method.rs:2034-2047`; wording: `mcp/catalog ### Routing binds identity, never description -A catalogue entry is keyed on `(server-id, published name, schema-hash)`. The description is the **operator's**, carried for display, markup-normalised on the way out, and read by no decision anywhere in the module (`crates/busbar-mcp/src/mcp/catalogue.rs:74-80`). Publishing the operator's text rather than the upstream's is what keeps an upstream from rewriting the instructions a model reads. `outputSchema` follows the same rule and goes one step further: publishing a schema makes conforming structured results a MUST for the server that published it, and on this wire that server is Busbar — so Busbar validates what the upstream returned against the approved schema and reports a violation as a tool failure (`crates/busbar-mcp/src/mcp/config.rs:200-222`). +A catalogue entry is keyed on `(server-id, published name, schema-hash)`. The description is the **operator's**, carried for display, markup-normalised on the way out, and read by no decision anywhere in the module (`crates/busbar-mcp/src/mcp/catalogue.rs:74-80`). Publishing the operator's text rather than the upstream's is what keeps an upstream from rewriting the instructions a model reads. `outputSchema` follows the same rule: it is the operator's approved schema, published as approved. Busbar does not validate an upstream's `structuredContent` against it or replace a result that does not match; the upstream's result reaches the caller as it came, and the caller judges it (THE DESIGN Law 11). -Descriptions, prompt templates, resource contents and tool outputs are all markup-normalised: ``, `` and HTML-like tags are stripped before the text re-enters model context (`crates/busbar-mcp/src/mcp/sanitize.rs:1-16`). **This is a floor, not a claim that injection is handled.** "now call `transfer_funds`" carries no markup and survives unchanged, by design — there is nothing to strip. Semantic injection is a hook residual and a model-alignment problem; Busbar reduces the markup-shaped attack surface (`mcp/sanitize.rs:17-25`). +Descriptions, prompt templates and resource contents (the text Busbar serves from its own section) are markup-normalised: ``, `` and HTML-like tags are stripped before the text re-enters model context (`crates/busbar-plane-mcp/src/sanitize.rs`). A tool's result is not: it is the upstream's data and reaches the caller as the upstream sent it (THE DESIGN Law 11); markup policy on results is a response-rewrite hook. **This is a floor, not a claim that injection is handled.** "now call `transfer_funds`" carries no markup and survives unchanged, by design — there is nothing to strip. Semantic injection is a hook residual and a model-alignment problem; Busbar reduces the markup-shaped attack surface of its own text. --- @@ -520,7 +520,7 @@ The claim is **tamper-evidence, not tamper-prevention.** A chain detects an alte **An upstream's ask is relayed to the caller; Busbar answers nothing on the caller's behalf.** A server-initiated ask arrives as an `input_required` result of a call Busbar made. `grants.{sampling,elicitation,roots}` decides whether that server may put the ask to your callers at all — every kind the ask names must be granted, or the call is refused (`ask_ungranted`). A granted ask reaches the caller with the upstream's `inputRequests` exactly as sent, under Busbar's own sealed `requestState`, which nests the upstream's state and binds it to that caller, that call (tool and arguments) and the server that asked. The caller's retry is a new request with its own admission: the state is checked and spent once, the call goes back to that same server and no other, and the caller's `inputResponses` and the upstream's own state are handed to it verbatim. Busbar only declares a capability upstream that both the grant and the caller declare. A result that still carries an ask after the relay decision is refused (`ask_not_proxied`). Busbar's own `ask_caller:` rounds are resolved first, as before (`crates/busbar-plane-mcp/src/call.rs`, `crates/busbar-plane-mcp/src/ask.rs`). -The same rule holds where there is no request waiting to carry the ask back. **Inside a task** (SEP-2663), an upstream's ask parks the task `input_required` with the upstream's `inputRequests` verbatim, under Busbar's sealed state (bound to the caller, the tool and the server that asked, nesting the upstream's own); `tasks/get` shows the asks and `tasks/update` answers them. Once every key is answered the task continues as a new unit: the call goes back to that same server with the caller's answers and the upstream's state, verbatim, and the state is spent once. The answers never become tool arguments (`crates/busbar-plane-mcp/src/door_tasks.rs`). **From a `transport: stdio` child**, a `sampling/createMessage`, `elicitation/create` or `roots/list` request the child sends while it serves a call goes to the caller of that call: across HTTP it comes back as an `input_required` result whose `inputRequests` carry the child's requests verbatim (keyed by the child's request ids), correlated with a work handle; on `busbar --mcp-stdio` it is sent to the caller as a live request on the pipe. The caller's answer is written to the child under the child's own request id, and the call the child still owes is read on. A retry must answer every request the child made (`ask_unanswered` otherwise, and the state stays unspent); a spent or forged state is refused. An ungranted ask is refused on the child's input with `-32001` (`ask_ungranted`), as is one past `max_input_required_rounds`; `ping` is still answered by Busbar, because it is keepalive, not an ask (`crates/busbar-plane-mcp/src/client/peer.rs`, `crates/busbar-plane-mcp/src/door_program.rs`). +The same rule holds where there is no request waiting to carry the ask back. **Inside a task** (SEP-2663), an upstream's ask parks the task `input_required` with the upstream's `inputRequests` verbatim, under Busbar's sealed state (bound to the caller, the tool and the server that asked, nesting the upstream's own); `tasks/get` shows the asks and `tasks/update` answers them. Once every key is answered the task continues as a new unit: the call goes back to that same server with the caller's answers and the upstream's state, verbatim, and the state is spent once. The answers never become tool arguments (`crates/busbar-plane-mcp/src/door_tasks.rs`). **From a `transport: stdio` child**, a `sampling/createMessage`, `elicitation/create` or `roots/list` request the child sends while it serves a call goes to the caller of that call: across HTTP it comes back as an `input_required` result whose `inputRequests` carry the child's requests verbatim (keyed by the child's request ids), correlated with a work handle; on `busbar --mcp-stdio` it is sent to the caller as a live request on the pipe. The caller's answer is written to the child under the child's own request id, and the call the child still owes is read on. A retry must answer every request the child made (`ask_unanswered` otherwise, and the state stays unspent); a spent or forged state is refused. A request over stdio names no call it serves, so calls to a stdio server that holds any of these grants reach its child ONE AT A TIME, in arrival order: a call waits its turn behind the one the child is serving (within its own `timeout:`; past it, it is answered as an upstream failure and never sent), and a call whose ask went to its caller keeps its place until the caller's retry or the ask's state lapses. The child's ask is the caller's of the call it is serving, never another's; one raised while no call is open is refused on the child's input with `-32001` (`ask_unattributed`). An ungranted ask is refused on the child's input with `-32001` (`ask_ungranted`), as is one past `max_input_required_rounds`; `ping` is still answered by Busbar, because it is keepalive, not an ask (`crates/busbar-plane-mcp/src/client/peer.rs`, `crates/busbar-plane-mcp/src/door_program.rs`). --- diff --git a/xtask/src/gates/kind_isolation.rs b/xtask/src/gates/kind_isolation.rs index c9063144e7..58b6451a69 100644 --- a/xtask/src/gates/kind_isolation.rs +++ b/xtask/src/gates/kind_isolation.rs @@ -10232,10 +10232,11 @@ impl Gate for KindIsolationGate { )); // A STEP THAT IS DECLARED AND NOT RUN. The compiler is satisfied and the loop stops there, - // which is the case the presence check alone cannot see. + // which is the case the presence check alone cannot see. Planted in a plane that still has + // a legacy face (the mcp plane is a door plane only: its unserved `Plane` impl is deleted). let mut ov = Overlay::new(); ov.set( - "crates/busbar-plane-mcp/src/planted_step.rs", + "crates/busbar-plane-a2a/src/planted_step.rs", "impl Foo {\n fn route(&self) -> RoutePlan {\n todo!()\n }\n}\n", ); report.push(prove_rows_red( diff --git a/xtask/tests/p_item_refusal_reason_collapse.rs b/xtask/tests/p_item_refusal_reason_collapse.rs index 49b7852d06..61f17d1df6 100644 --- a/xtask/tests/p_item_refusal_reason_collapse.rs +++ b/xtask/tests/p_item_refusal_reason_collapse.rs @@ -14,6 +14,10 @@ //! The decisions plane is not scanned: it leaves this tree for its own repository //! (GetBusbar/busbar-plane-decisions), which carries its class-to-wire table there. //! +//! The mcp plane is not scanned: its served door renders no refusal reason of its own (the +//! kernel's plane driver answers a refused arrival), and the unserved `Plane` impl that held +//! `refusal_words` is deleted (plane-mcp finding 12). +//! //! This file reads the renderers' source and proves none of them holds a reason match of its own, //! so no renderer can drift from the others again. It lives here, beside the gates, because it //! names every plane's renderer and the tree's crates may not name one another's planes. @@ -36,10 +40,6 @@ const RENDERERS: &[(&str, &[&str])] = &[ &["refusal_shape", "refusal_message"], ), ("crates/busbar-plane-a2a/src/plane.rs", &["refusal_render"]), - ( - "crates/busbar-plane-mcp/src/tool_plane.rs", - &["refusal_words"], - ), ( "crates/busbar-plane-streaming/src/plane.rs", &["refusal_render"],