//! MCP tool surface for bugwarden.
//!
//! Every tool that takes a bug id runs guard assessment (`Guard::assess`)
//! BEFORE any side effect or data return (invariant I8; `bug_url` is the
//! documented exception — it computes a URL string locally and contacts
//! nothing). Denials use the uniform text from `Guard::denial` only, so a
//! policy-denied bug and a nonexistent bug are indistinguishable (I2).
use std::borrow::Cow;
use std::collections::{BTreeMap, BTreeSet};
use std::future::Future;
use std::panic::AssertUnwindSafe;
use std::pin::Pin;
use std::sync::Arc;
use std::task::{Context, Poll};
use std::time::Instant;
use base64::{engine::general_purpose, Engine as _};
use bugwarden_core::client::{with_upstream_stats, BugzillaClient, UpstreamStats, CLASSIFY_FIELDS};
use bugwarden_core::guard::{Guard, SearchRequest, SearchWindow};
use bugwarden_core::policy::{Access, Action, Capability, IdentitySource};
use chrono::{DateTime, Utc};
use rmcp::{
handler::server::{router::tool::ToolRouter, tool::ToolCallContext, wrapper::Parameters},
model::*,
schemars,
service::RequestContext,
tool, tool_router,
transport::streamable_http_server::StreamableHttpServerConfig,
ErrorData as McpError, RoleServer, ServerHandler,
};
use serde_json::{json, Value};
use crate::audit::{self, AuditCell, AuditState, FailMode, TransportKind, Verdict};
use crate::config::{Cli, KeyCustody, Transport};
use crate::http_auth::{self, Scope};
use crate::stdio::BoundedLines;
use crate::tracing_fields::PARAM_VALUE_MAX_CHARS;
/// The MCP revisions this build actually implements, newest last.
///
/// Deliberately not `ProtocolVersion::KNOWN_VERSIONS`: the SDK knows more
/// revisions than this handler serves, so every rmcp bump has to widen
/// this list deliberately rather than by inheriting a longer one. What
/// the list bounds is precisely which DECLARED revisions `initialize`,
/// per-request `_meta` and `server/discover` may agree to; it does not
/// decide which request lifecycle the transport routes to — see
/// [`lifecycle_of`]. Read here and, for the stdio discover answer rmcp no
/// longer gives, in [`crate::stdio::DiscoverAnswering`] (#267).
///
/// `2026-07-28` is served as of issue #34 stage 2. Its handshake-free
/// requests carry the calling client in their own `_meta`, which
/// [`client_of`] reads, so a record on that path names the caller or names
/// nobody — never the SDK placeholder rmcp synthesises for a peer that
/// never handshook (#34 §3c).
pub(crate) const SUPPORTED_PROTOCOL_VERSIONS: &[ProtocolVersion] = &[
ProtocolVersion::V_2024_11_05,
ProtocolVersion::V_2025_03_26,
ProtocolVersion::V_2025_06_18,
ProtocolVersion::V_2025_11_25,
ProtocolVersion::V_2026_07_28,
];
/// The revision offered when a client asks for one this build cannot
/// serve, and the one `get_info` advertises. Pinned rather than left to
/// `ProtocolVersion::default()`, whose value moves with the SDK: an
/// rmcp release that advances `LATEST` past what [`SUPPORTED_PROTOCOL_VERSIONS`]
/// lists would otherwise make the fallback a revision this build rejects.
const DEFAULT_PROTOCOL_VERSION: ProtocolVersion = ProtocolVersion::V_2025_11_25;
/// The name this server reports as its own, in the handshake and in
/// `mcp_server_info`.
const SERVER_NAME: &str = env!("CARGO_PKG_NAME");
/// The version this server reports as its own, in the handshake and in
/// `mcp_server_info`.
const SERVER_VERSION: &str = env!("CARGO_PKG_VERSION");
/// This build's identity, as sent in the `initialize` handshake.
///
/// Deliberately not `Implementation::from_build_env()` (nor
/// `Implementation::default()`, which calls it): that constructor expands
/// `env!("CARGO_CRATE_NAME")` and `env!("CARGO_PKG_VERSION")` *inside
/// rmcp*, so it names the SDK — every rmcp-based server then introduces
/// itself identically, and the one question `serverInfo` exists to answer,
/// which build is deployed, becomes unanswerable from the handshake
/// (issue #53). The `env!`s here expand in this crate.
fn server_identity() -> Implementation {
Implementation::new(SERVER_NAME, SERVER_VERSION)
}
/// The `User-Agent` every request to Bugzilla carries.
///
/// The same identity the handshake sends, in the other direction: with a
/// server-held key (#27) and no per-caller identity yet (#32), one Bugzilla
/// account carries a whole fleet's traffic, and without this header that
/// traffic is anonymous — the operator on the other end has no name to
/// allowlist or complain about — nothing distinguishes it from a person
/// with a browser (issue #55). The repository is included so that name
/// leads somewhere; it is read from the manifest rather than written out
/// again here, so it cannot become a second copy to keep in step.
///
/// Everything here is public by construction — it is sent to every
/// configured Bugzilla and lands in its access log — so it carries the
/// program's name and version and nothing about the deployment: no key
/// material, no policy path, no host. That is the discipline of I12 on a
/// surface I12 does not itself name; the invariant is about logs, error
/// messages and tool results.
///
/// The `env!`s expand in this crate. Built in `bugwarden-core`, where the
/// client lives, they would expand to `bugwarden-core` and its version —
/// the shape of issue #53 one layer down, and just as invisible.
pub const USER_AGENT: &str = concat!(
env!("CARGO_PKG_NAME"),
"/",
env!("CARGO_PKG_VERSION"),
" (+",
env!("CARGO_PKG_REPOSITORY"),
")"
);
/// Build the Bugzilla client this build's requests go through.
///
/// The single production construction path for [`BugzillaClient`]: it
/// lives here rather than in `main.rs` so that the identity on the wire is
/// reachable from a test (`main` is a thin transport wrapper — see the
/// crate docs), and a second path that skipped it would put an
/// unidentified client back on the network.
///
/// # Errors
///
/// Returns an error when [`USER_AGENT`] is not a valid HTTP header value,
/// or when the underlying HTTP client cannot be built — see
/// [`BugzillaClient::new`].
pub fn bugzilla_client(cli: &Cli) -> anyhow::Result<BugzillaClient> {
use anyhow::Context as _;
BugzillaClient::new(&cli.bugzilla_server, cli.use_auth_header, USER_AGENT)
.context("failed to build Bugzilla client")
}
/// Names of the tools that modify bug state. These routes are removed from
/// the router in read-only mode (I13).
pub const WRITE_TOOLS: &[&str] = &[
"add_comment",
"update_bug_status",
"assign_bug",
"update_bug_fields",
"update_bug_dependencies",
"add_cc_to_bug",
"mark_as_duplicate",
"create_bug",
"add_attachment",
];
/// Names of the product/field discovery tools. These routes are removed
/// from the router unless `global.allow_discovery = true` (I13, I16).
pub const DISCOVERY_TOOLS: &[&str] = &["bugzilla_products", "bug_fields"];
/// Success result: ONE text block containing compact JSON.
///
/// Compact, not pretty-printed: the block is parsed as JSON by clients, and
/// the whitespace difference is ~15-25% of a bug payload's size — context
/// the model pays for on every call.
fn ok_json(value: Value) -> CallToolResult {
let text = serde_json::to_string(&value).unwrap_or_else(|_| value.to_string());
CallToolResult::success(vec![ContentBlock::text(text)])
}
/// Tool-level failure: error result with a text block (NOT a protocol error).
fn err_text(msg: impl Into<String>) -> CallToolResult {
CallToolResult::error(vec![ContentBlock::text(msg.into())])
}
fn action_name(action: Action) -> &'static str {
match action {
Action::Allow => "allow",
Action::Deny => "deny",
Action::Restrict => "restrict",
}
}
/// The name of the policy rule that decided an assessment; both variants
/// carry one.
fn access_rule(access: &Access) -> &str {
match access {
Access::Denied { rule } | Access::Granted { rule, .. } => rule,
}
}
/// The request's audit enrichment cell, when auditing is on. The audit
/// wrapper inserts one before dispatch; with auditing off (or a caller
/// outside the wrapper) there is none and every note is a no-op at the
/// call site — enrichment can never change what a tool returns.
fn audit_cell(ctx: &RequestContext<RoleServer>) -> Option<Arc<AuditCell>> {
ctx.extensions.get::<Arc<AuditCell>>().cloned()
}
/// Note a `Refused` verdict on the request's audit cell. Mechanical
/// companion for the `return Ok(err_text(..))` refusal sites that answer
/// from the request alone.
fn note_refused(ctx: &RequestContext<RoleServer>) {
if let Some(cell) = audit_cell(ctx) {
cell.note_verdict(Verdict::Refused);
}
}
/// Whether the pre-dispatch audit gate holds `tool` back while the sink is
/// already failing: `Open` never gates, `ClosedWritesDenials` gates the
/// write tools only, `ClosedAll` gates everything.
fn gate_applies(fail_mode: FailMode, tool: &str) -> bool {
match fail_mode {
FailMode::Open => false,
FailMode::ClosedWritesDenials => WRITE_TOOLS.contains(&tool),
FailMode::ClosedAll => true,
}
}
/// The refusal text for a tool call refused because its audit record
/// cannot be persisted (the closed fail modes). One fixed text per TOOL —
/// each tool's EXISTING generic failure wording, so an audit outage looks
/// like the failure the client already knows and is not a new fingerprint;
/// tools without a generic failure text get a pattern-consistent one. The
/// text depends on the tool name alone, never on anything the guard
/// decided. `None` only for names outside the tool surface — the map must
/// be total over the full router (tested), and every routed tool must
/// have its own entry here before it can ship.
fn audit_refusal_text(tool: &str) -> Option<String> {
let text = match tool {
"bug_info" => "Failed to fetch bug information".to_string(),
"bug_history" => "Failed to fetch bug history".to_string(),
"bug_comments" => "Failed to fetch bug comments".to_string(),
"bugs_quicksearch" => "Search failed".to_string(),
"summarize_bug" => "Summarize Comments Failed".to_string(),
"list_attachments" => "Failed to fetch bug attachments".to_string(),
"download_attachment" => "Failed to fetch attachment".to_string(),
"bugzilla_server_info" => "Failed to fetch bugzilla server info".to_string(),
"bugzilla_products" => "Failed to fetch products".to_string(),
"bug_fields" => "Failed to fetch bug fields".to_string(),
"quicksearch_syntax" => "Failed to fetch quicksearch documentation".to_string(),
"bug_url" => "Failed to compute the bug url".to_string(),
"mcp_server_info" => "Failed to compute server info".to_string(),
// Write tools: the first line of their existing failure text,
// without the upstream detail — truthfully so: nothing upstream
// happened when the gate refused before dispatch.
"add_comment" => "Failed to create a comment".to_string(),
"update_bug_status" => "Failed to update bug status".to_string(),
"assign_bug" => "Failed to assign bug".to_string(),
"update_bug_fields" => "Failed to update bug fields".to_string(),
"update_bug_dependencies" => "Failed to update bug dependencies".to_string(),
"add_cc_to_bug" => "Failed to add CC".to_string(),
"add_attachment" => "Failed to add attachment".to_string(),
"mark_as_duplicate" => "Failed to mark as duplicate".to_string(),
"create_bug" => Guard::create_denial(),
_ => return None,
};
Some(text)
}
/// The uniform fail-closed refusal for `tool`. Unknown names fall back to
/// a generic text; unreachable for routed tools (see
/// [`audit_refusal_text`]) — the fallback exists so the map is total.
fn audit_refusal(tool: &str) -> CallToolResult {
err_text(audit_refusal_text(tool).unwrap_or_else(|| "Request failed".to_string()))
}
/// Keys of client-authored tool parameters whose VALUES may enter an audit
/// record. The bar: identifiers, projections and routing/vocabulary fields
/// yes; free text no. `comment`, `summary`, `description`, `url`,
/// `whiteboard`, `data` (attachment bytes), `custom_fields`
/// (operator-defined values) and the `see_also_add`/`see_also_remove` URL
/// lists never appear by value — a non-allowlisted key is recorded as
/// `{"_len": <serialized byte length>}`, so presence and size are loggable
/// while content is not. Allowlisted string values are truncated to 1024
/// characters. KEY NAMES are bounded on the same boundary but RELOCATED,
/// never truncated in place: a client-chosen key over the cap — or
/// spelling a reserved name — does not enter the map as a key at any
/// depth, it moves to the reserved `_overlong_keys` array behind a capped
/// prefix ([`recorded_object`]).
/// Neither cap bounds the record's SIZE — element and entry counts are
/// uncapped, so an allowlisted value's shape can still make a large
/// record; each bounds the length of any ONE verbatim string, not their
/// total.
const PARAM_ALLOWLIST: &[&str] = &[
"assignee",
"attachment_id",
"blocks_add",
"blocks_remove",
"bug_id",
"bug_ids",
"cc_email",
"component",
"content_type",
"depends_on_add",
"depends_on_remove",
"detail",
"duplicate_of",
"field_names",
"file_name",
"group_by",
"groups",
"head",
"head_lines",
"id",
"include_fields",
"include_private",
"is_patch",
"is_private",
"keywords",
"keywords_add",
"keywords_remove",
"limit",
"max_chars",
"max_comment_chars",
"new_since",
"offset",
"on_bug_entry_only",
"op_sys",
"platform",
"priority",
"product",
"products",
"query",
"resolution",
"severity",
"status",
"tail",
"tail_lines",
"target_milestone",
"version",
];
// The cap on recorded string values AND on object key names (see
// `PARAM_ALLOWLIST`) is `PARAM_VALUE_MAX_CHARS`, imported above. It moved
// to `tracing_fields` in #260, where the same constant bounds every field
// of every tracing line at the sink: one number rather than two that have
// to agree.
/// Reserved key under which one recorded object holds its over-cap and
/// reserved-name keys; see [`recorded_object`].
const OVERLONG_KEYS: &str = "_overlong_keys";
/// Reserved key carrying a non-allowlisted value's size in place of its
/// content; written by [`allowlisted`], reserved by [`recorded_object`].
const RESERVED_LEN: &str = "_len";
/// An allowlisted value with every string [`capped`], at ANY depth: the
/// client picks the JSON shape, so the bound must not depend on it (#211).
/// Keys are bounded by [`recorded_object`] on the way in (#220).
fn truncated(value: &Value) -> Value {
match value {
Value::String(s) => Value::String(capped(s)),
Value::Array(items) => Value::Array(items.iter().map(truncated).collect()),
Value::Object(fields) => {
Value::Object(recorded_object(fields, |_, value| truncated(value)))
}
other => other.clone(),
}
}
/// `value` truncated to [`PARAM_VALUE_MAX_CHARS`] characters, owned —
/// the one rule every recorded CLIENT string obeys, `trace` aside
/// (parsed, never capped): an audited parameter leaf ([`truncated`]), an
/// over-cap key's prefix ([`recorded_object`]), a recorded client
/// identity, a recorded TOOL NAME and a recorded JSON-RPC REQUEST ID
/// alike. The cut itself is [`Capped::as_str`] (#191, #240): tracing
/// fields take [`Capped`], every site that records a client string takes
/// this.
///
/// The tool name is capped in the RECORD only (#243): a call the router
/// will not serve is recorded like any other, so the name is client text
/// of any length, while routing, the fail-mode gate and [`WRITE_TOOLS`]
/// keep matching the raw one. No served name is near the cap, so no
/// served record moves and only an unknown name is ever shortened — and
/// "an unknown name of at least 1024 chars" is the whole of what such a
/// record has to say.
///
/// The request id is capped in the record only (#248): JSON-RPC pairs
/// the reply to its request by it, and the reply is rmcp's — written off
/// the id it parsed, which no code of ours touches.
fn capped(value: &str) -> String {
Capped(value).as_str().to_string()
}
/// [`capped`] as a tracing field: `?Capped(&p.query)` formats a leading
/// slice of the value in place, quoted, allocating nothing. Two bounds
/// meet here — [`PARAM_VALUE_MAX_CHARS`] on the value's own characters,
/// which is the audit record's, and [`CAPPED_DEBUG_MAX_CHARS`] on what
/// the `Debug` below WRITES, which is the diagnostic line's.
///
/// The tracing path reaches the same operator and the same collector as
/// the audit record, so a client string left raw there undoes the bar
/// `call_tool`'s record enforces on the same call (#240). It cuts
/// SILENTLY, no marker, exactly as the record's own cut does. [`capped`]
/// delegates here, so there is one cut to remember (#191); the unit
/// tests pin both at the boundary.
///
/// `?`, never `%`, at every site (#278). A `%` field is written bare, so
/// a value of `evil status=HACKED` put a second `status` on the line and
/// nothing marked where the client's text stopped. This type's `Debug`
/// quotes the value and escapes `"` and `\` inside it, so a reader that
/// honours quotes finds the end the WRITER marked. Hence no `Display`
/// impl: `%Capped(..)` is how the defect is written, and it no longer
/// compiles.
///
/// A quoted value only marks its end if the closing quote SURVIVES, and
/// the sink cuts a field at [`PARAM_VALUE_MAX_CHARS`] rendered
/// characters without knowing one is open. A cut value therefore ran on
/// to the next unescaped `"` on the line — the next client field's
/// OPENING quote — swallowing the server's own field between them and
/// handing the reader a key the client wrote. So the cut a quoted value
/// answers to is this type's own, [`CAPPED_DEBUG_MAX_CHARS`], applied to
/// the RENDERED characters between the quotes and taken here, where the
/// shape is known: `Capped` writes its own `"`, spends the budget on
/// whole escapes, and writes the closing `"`. Nothing this type renders
/// reaches the sink's budget, so no field of ours is ever cut open — on
/// stderr or in the OTLP diagnostics body, which builds its own text
/// from the same `Debug` through the same writer.
///
/// That makes the wrapper LOAD-BEARING again, which it had not been
/// since #260: `?Capped(&p.query)` and `?p.query` no longer agree on a
/// long value — the bare one is cut open at 1024, this one closes at
/// 1018 — so `binary_tracing_caps` measures this type and the sink both.
/// [`capped`] still delegates to [`Capped::as_str`] and IS the record's
/// cut, which `audit_wiremock` measures; `bug_ids` still needs a
/// count-plus-head SHAPE no sink can synthesise from a rendered value.
struct Capped<'a>(&'a str);
/// The rendered characters [`Capped`]'s `Debug` puts between its quotes.
///
/// Eight less than [`PARAM_VALUE_MAX_CHARS`], which is what the WIDEST
/// shape a site wraps a `Capped` in costs: `Some("` and `")` are six
/// characters, the quotes are two, and `Some("` + 1016 + `")` is 1024
/// exactly — the sink's budget, reached and never passed. A bare
/// `?Capped` field renders 1018. A `Capped` is rendered bare or inside
/// `Some(..)` and nowhere else; a site that wraps it in anything wider
/// than six characters has to lower this number, and the boundary unit
/// tests are where that is noticed.
const CAPPED_DEBUG_MAX_CHARS: usize = PARAM_VALUE_MAX_CHARS - 8;
impl<'a> Capped<'a> {
/// The leading at-most-[`PARAM_VALUE_MAX_CHARS`]-char slice.
fn as_str(&self) -> &'a str {
// A string of at most 1024 BYTES has at most 1024 chars; the cheap
// length check skips the char walk for the common case.
if self.0.len() <= PARAM_VALUE_MAX_CHARS {
return self.0;
}
match self.0.char_indices().nth(PARAM_VALUE_MAX_CHARS) {
Some((end, _)) => &self.0[..end],
// Over the byte bound but under the char one: multi-byte text.
None => self.0,
}
}
}
/// The only formatting this type has, and every site's (#278): `str`'s
/// own `Debug` rendering, stopped at [`CAPPED_DEBUG_MAX_CHARS`] rendered
/// characters so the closing quote always fits. Under an `Option` it
/// still reads `Some("…")` / `None`, the shape a bare `?p.resolution`
/// printed before the cap.
///
/// Transcribed from `impl Debug for str` rather than delegated to it
/// because that impl offers no seam to stop at, and the transcription is
/// exact: for every Unicode scalar value, `char::escape_debug` is what
/// `str` writes, save the apostrophe — `str` escapes the double quote
/// and not the single one, where a bare `char` escapes both. A unit test
/// pins the byte-equality for any value under the budget.
///
/// A character is written whole or not at all. Stopping mid-escape would
/// put `\u{20` on the line, which is neither the client's text nor a
/// legal escape, and stopping AFTER a too-wide character while carrying
/// on with the next one would reorder the value; both misreport what was
/// sent, and the cut is silent, so it has to be a prefix.
impl std::fmt::Debug for Capped<'_> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
use std::fmt::Write as _;
f.write_char('"')?;
let mut remaining = CAPPED_DEBUG_MAX_CHARS;
for ch in self.as_str().chars() {
// The apostrophe is the one character `str` leaves raw where
// a bare `char` escapes it, so it is the one width not read
// off `escape_debug`.
let raw_quote = ch == '\'';
let width = if raw_quote {
1
} else {
ch.escape_debug().count()
};
if width > remaining {
break;
}
remaining -= width;
if raw_quote {
f.write_char('\'')?;
} else {
write!(f, "{}", ch.escape_debug())?;
}
}
f.write_char('"')
}
}
/// Assemble one level of a recorded params object: `record` says what
/// each VALUE becomes, this says which key names may be map keys at all.
///
/// A key longer than [`PARAM_VALUE_MAX_CHARS`] never becomes one. Copied
/// verbatim it is an unbounded client-chosen free-text channel into the
/// operator's append-only file and its OTLP export — exactly what the
/// allowlist denies values (#220). It moves instead into the reserved
/// [`OVERLONG_KEYS`] array as
/// `{"prefix": capped(key), "key_chars": N, "value": <recorded>}`. Array
/// elements cannot collide, so two keys sharing a 1024-char prefix stay
/// two faithful entries; plain truncation would fold them into one and
/// MISREPORT which key was sent, which is why #211 left keys uncapped
/// rather than truncate them. A client key equal to either reserved name
/// — [`OVERLONG_KEYS`] or [`RESERVED_LEN`] — routes there too at every
/// depth, carrying its own true (short) `key_chars`, so neither name is
/// ever client-written (#241: a withheld-content marker must not be
/// forgeable by content). Both call sites — top level and nested — go
/// through here so the rule cannot drift between them (#191).
fn recorded_object<'a, M: FromIterator<(String, Value)>>(
fields: impl IntoIterator<Item = (&'a String, &'a Value)>,
record: impl Fn(&str, &Value) -> Value,
) -> M {
let mut entries: Vec<(String, Value)> = Vec::new();
let mut overlong: Vec<Value> = Vec::new();
for (key, value) in fields {
let recorded = record(key, value);
let key_chars = key.chars().count();
if key_chars > PARAM_VALUE_MAX_CHARS || matches!(key.as_str(), OVERLONG_KEYS | RESERVED_LEN)
{
overlong.push(json!({
"prefix": capped(key),
"key_chars": key_chars,
"value": recorded,
}));
} else {
entries.push((key.clone(), recorded));
}
}
if !overlong.is_empty() {
entries.push((OVERLONG_KEYS.to_string(), Value::Array(overlong)));
}
entries.into_iter().collect()
}
/// Project a tool call's arguments through [`PARAM_ALLOWLIST`] into the
/// audit record's `params` map.
///
/// An over-cap top-level key is by construction NOT allowlisted (every
/// [`PARAM_ALLOWLIST`] entry is short), so the `value` beside it in
/// [`OVERLONG_KEYS`] is already the `{"_len": N}` reduction: the key name
/// is the only thing #220 had left to bound here.
fn allowlisted(params: Option<&JsonObject>) -> BTreeMap<String, Value> {
let Some(params) = params else {
return BTreeMap::new();
};
recorded_object(params, |key, value| {
if PARAM_ALLOWLIST.contains(&key) {
truncated(value)
} else {
let len = serde_json::to_vec(value).map(|b| b.len()).unwrap_or(0);
json!({ RESERVED_LEN: len })
}
})
}
/// The calling client as it declared itself, for an audit record.
/// Self-declared at every source and therefore untrusted; `principal` and
/// `work_context` stay `None` — both are server-verified slots, this build
/// has no verifier to fill either, and nothing carried in `_meta` is ever
/// promoted into them.
///
/// The source is the request's [`Lifecycle`], read here rather than passed
/// in, so identity cannot diverge from routing by call-site ordering:
///
/// * [`Lifecycle::PerRequest`] — the request's own `_meta`. **Never
/// `ctx.client_info()`**: that convenience accessor falls back to
/// `peer_info().client_info` unless `request_metadata_required()`, which
/// rmcp 3.1.4 sets only on the stdio handshake-free path
/// (`service/server.rs`), so over streamable http it IS the
/// `{"name":"rmcp",…}` placeholder rmcp synthesises for a peer that
/// never handshook (`peer_info_for_stateless_request`, #34 §3c) — and
/// never `peer_info()` here for the same reason. `ctx.meta.client_info()`
/// decodes all-or-nothing, so a `clientInfo` missing `version`, or not an
/// object at all, yields nothing rather than half an identity:
/// fail-to-absent comes free.
/// * [`Lifecycle::Handshake`] — the peer rmcp populated at `initialize`.
/// The client this server actually greeted.
/// * [`Lifecycle::OutOfContract`] — nobody; the call is refused anyway.
///
/// Both fields are [`capped`]: per-request sourcing repeats a
/// client-controlled string into EVERY record where the handshake wrote it
/// once per session.
fn client_of(ctx: &RequestContext<RoleServer>) -> audit::ClientInfo {
let declared = match lifecycle_of(ctx) {
Lifecycle::PerRequest => ctx.meta.client_info().map(|i| (i.name, i.version)),
Lifecycle::Handshake => ctx
.peer
.peer_info()
.map(|p| (p.client_info.name.clone(), p.client_info.version.clone())),
Lifecycle::OutOfContract => None,
};
let (name, version) = declared.unzip();
audit::ClientInfo {
name: name.as_deref().map(capped),
version: version.as_deref().map(capped),
principal: None,
work_context: None,
}
}
/// The request cap this build pinned before it was derived from policy,
/// and the floor it never goes below.
const MAX_REQUEST_BODY_FLOOR: usize = 4 * 1024 * 1024;
/// The largest request this build will buffer, whatever the policy says.
///
/// A request is a POST body over http and one newline-delimited frame over
/// stdio; the cap bounds both. The transport collects one before anything
/// inspects it, so the cap is a memory bound first and an attachment
/// allowance second. 64 MiB carries
/// every decoded `max_attachment_bytes` up to ~47 MiB, far above any
/// plausible Bugzilla attachment limit, while keeping the bound finite for
/// the values that are not a considered number at all — an "unlimited"
/// spelled as a huge integer, or a typo with too many digits.
const MAX_REQUEST_BODY_CEILING: usize = 64 * 1024 * 1024;
/// The transport's request cap — the POST body over http, one
/// newline-delimited frame over stdio — for a policy whose decoded
/// attachment ceiling is `max_attachment_bytes` (`0` = no policy cap).
///
/// Reached through `BugWarden::request_body_cap`, which is what both
/// transports call: this is one number with two enforcement points, not a
/// per-transport limit (#234).
///
/// Sized so that every upload the guard would allow fits through the
/// transport: `add_attachment` takes its payload base64-encoded, which
/// expands the decoded bytes by 4/3 (`ceil(n / 3) * 4`, padding included),
/// and 1 MiB of headroom covers the JSON-RPC envelope plus the call's other
/// arguments — `file_name`, `summary`, `content_type`, `comment`. The
/// expansion assumes canonical unwrapped base64, which is what MCP clients
/// send; an encoder that wraps its output at a line length spends another
/// ~1.4% on separators, eroding that headroom for decoded caps above
/// roughly 30 MiB.
///
/// Clamped at both ends, and the clamps are the point:
///
/// * never below `MAX_REQUEST_BODY_FLOOR` — the value this build served
/// before the cap was derived, so non-attachment traffic and the
/// transport's memory bound are unchanged by the derivation, and a policy
/// that lowers `max_attachment_bytes` cannot shrink the body cap under
/// ordinary requests. `0` returns the floor too: it means "the guard
/// imposes no attachment ceiling", not "the transport imposes no memory
/// bound";
/// * never above `MAX_REQUEST_BODY_CEILING` — an unbounded request is an
/// unbounded-memory lever for anyone who can reach the port (http) or
/// the pipe (stdio), and no policy value may hand that out, whether it is
/// `0` or a number so large the derivation would saturate. The honest
/// consequence: a policy cap above ~47 MiB decoded is NOT honored on
/// either transport, exactly as `0` is not honored as "unlimited". Both
/// are deliberate, and recorded in DESIGN.md.
///
/// Saturating throughout: `max_attachment_bytes` is operator input and may
/// be `u64::MAX`, where the derivation must clamp rather than panic in
/// debug or wrap in release.
fn max_request_body_bytes(max_attachment_bytes: u64) -> usize {
if max_attachment_bytes == 0 {
return MAX_REQUEST_BODY_FLOOR;
}
let needed = max_attachment_bytes
.div_ceil(3)
.saturating_mul(4)
.saturating_add(1024 * 1024);
usize::try_from(needed)
.unwrap_or(usize::MAX)
.clamp(MAX_REQUEST_BODY_FLOOR, MAX_REQUEST_BODY_CEILING)
}
/// Which lifecycle a request belongs to.
///
/// Decided on the revision the request DECLARES in its own `_meta`, never
/// on the session's negotiated one: a 2026-07-28 session's ordinary
/// requests carry no `_meta` at all and belong to [`Lifecycle::Handshake`],
/// where their peer identity is the real one.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum Lifecycle {
/// No per-request declaration. Usually an `initialize` handshake stands
/// behind the request, and `peer_info` then names the client this server
/// greeted. A session served directly lands here too, with no
/// `peer_info` at all — nothing was greeted, so the identity is absent
/// rather than wrong.
Handshake,
/// The 2026-07-28 handshake-free lifecycle: the request carries its own
/// revision and client capabilities, optionally its own identity, and
/// over http no session exists to carry them instead.
PerRequest,
/// A per-request declaration this build will not serve on that
/// lifecycle: a revision below `2026-07-28`, which negotiates through
/// the handshake instead and so mixes two contracts, or one at or above
/// it that this build does not serve at all. Refused either way, and
/// nothing in such a request can be attributed.
OutOfContract,
}
/// The [`Lifecycle`] of `ctx`.
///
/// One decision shared by `call_tool`, `list_tools` and [`client_of`], so
/// what gets served and who gets recorded cannot diverge by call-site
/// ordering.
///
/// rmcp 3.1.4 routes on `_meta` shape, not on the negotiated revision and
/// not on [`SUPPORTED_PROTOCOL_VERSIONS`]: `is_legacy_request`
/// (`transport/streamable_http_server/tower.rs`) sends a POST down the
/// stateless path when the request's revision is `2026-07-28` or newer, or
/// when a non-`initialize` request carries BOTH
/// `io.modelcontextprotocol/protocolVersion` and `…/clientCapabilities`.
/// That second shape is why the refusal arm still exists after adoption: a
/// pre-2026 revision sent with both keys reaches the handler with no
/// `initialize` behind it, and rmcp synthesises its peer with the SDK's own
/// build identity as `client_info` — a client the server never spoke to.
/// The arm is broader than the routing on purpose: a sub-2026 declaration
/// lacking `clientCapabilities` takes the session path, so it fires on
/// requests that DID complete a handshake too.
///
/// The `contains` clause is defence in depth: rmcp refuses a `_meta`
/// revision outside `supported_protocol_versions()` first
/// (`handler/server.rs`, -32022), but every rmcp version comparison is
/// lexical and this repo has already been bitten by a fabricated
/// `"9999-01-01"` outranking every real revision.
fn lifecycle_of(ctx: &RequestContext<RoleServer>) -> Lifecycle {
match ctx.meta.protocol_version() {
None => Lifecycle::Handshake,
Some(v)
if v.as_str() >= ProtocolVersion::V_2026_07_28.as_str()
&& SUPPORTED_PROTOCOL_VERSIONS.contains(&v) =>
{
Lifecycle::PerRequest
}
Some(_) => Lifecycle::OutOfContract,
}
}
/// The uniform refusal for a [`Lifecycle::OutOfContract`] request. Names no
/// tool, bug or policy — it states which declarations this build serves on
/// that lifecycle, and both halves of that are public anyway:
/// `server/discover` advertises the served list and rmcp's own -32022 quotes
/// it back.
///
/// Covers BOTH arms the predicate refuses, which an earlier wording did not:
/// a revision below the threshold, and one at or above it that this build
/// does not serve — the `"9999-01-01"` case, reachable here only if rmcp's
/// -32022 ever stops running first, which is the scenario the `contains`
/// clause exists for.
fn mixed_lifecycle_refused() -> McpError {
McpError::invalid_request(
"a per-request protocol declaration is served only for a revision this \
server serves at 2026-07-28 or later; earlier revisions negotiate \
through the initialize handshake",
None,
)
}
/// How much of the tool surface one request's bearer credential reaches.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum Reach {
/// Every routed tool: scope enforcement is off, or the write scope.
Everything,
/// The read tools only — the complement of [`WRITE_TOOLS`], which is the
/// same set read-only mode removes from the router (I13), so the bearer
/// split cannot drift from the policy one.
ReadOnly,
/// Nothing at all. Only reachable when scopes are enforced and a request
/// still carries none, i.e. the gate did not run.
Nothing,
}
/// Whether a read-scope credential reaches `tool`.
fn read_scope_serves(tool: &str) -> bool {
!WRITE_TOOLS.contains(&tool)
}
/// The refusal for a tool this request's credential does not reach.
///
/// Byte-identical to what `ToolRouter` answers for a name it does not route
/// (rmcp 3.1 `handler/server/router/tool.rs`), so a scope-hidden tool is
/// indistinguishable from one that does not exist — the same
/// indistinguishability read-only mode already gives every caller (I13), and
/// no oracle about which tools the deployment serves (I2).
fn tool_not_found() -> McpError {
McpError::invalid_params("tool not found", None)
}
/// The reply a tool handler that panicked is answered with (issue #253).
///
/// Fixed text, no `data`, the same under every lifecycle and every fail
/// mode: the panic payload is client-reachable state formatted by whatever
/// code panicked, so nothing derived from it may be echoed — not a message,
/// not a type name, not a length (I12). It names no tool, bug or policy, so
/// it is no oracle either. `-32603` because the failure is the server's, and
/// a JSON-RPC error rather than a tool-level `isError` result because the
/// call produced no result at all.
fn handler_panicked() -> McpError {
McpError::internal_error("the server failed to handle this request", None)
}
/// Polls `F` inside [`std::panic::catch_unwind`], turning a panicking poll
/// into an `Err` for the caller instead of an unwind past it.
///
/// Needed because rmcp answers a request from inside a task it spawns and
/// whose `JoinHandle` it drops (`service.rs`), and it catches nothing: a
/// handler that panics unwinds that task, `sink.send` never runs, and the
/// request is answered by nobody while the session stays up. See "rmcp trap
/// — a panicking handler answers nothing" in DESIGN.md.
///
/// The inner future is polled **in place**, not re-spawned: rmcp scopes
/// `ORIGINATING_REQUEST` around every request and bugwarden-core's
/// `UPSTREAM` is scoped around the audited dispatch, and a nested task
/// would lose whichever of those is in scope.
///
/// `Pin<Box<F>>` is `Unpin` whatever `F` is, so the projection needs no
/// `unsafe` (the workspace forbids it). A state machine that panicked
/// mid-`poll` is left poisoned; this wrapper never polls it again and only
/// drops it, which is sound.
struct CatchUnwind<F> {
inner: Pin<Box<F>>,
}
impl<F: Future> CatchUnwind<F> {
fn new(inner: F) -> Self {
Self {
inner: Box::pin(inner),
}
}
}
impl<F: Future> Future for CatchUnwind<F> {
type Output = Result<F::Output, Box<dyn std::any::Any + Send>>;
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
let inner = &mut self.get_mut().inner;
// `AssertUnwindSafe`: the guard policy is immutable behind an `Arc`,
// the audit sink's mutex is never HELD while the handler is polled,
// and the per-request cell's lock tolerates poison — so nothing
// observably half-updated survives the unwind (DESIGN.md).
match std::panic::catch_unwind(AssertUnwindSafe(|| inner.as_mut().poll(cx))) {
Ok(Poll::Pending) => Poll::Pending,
Ok(Poll::Ready(value)) => Poll::Ready(Ok(value)),
Err(payload) => Poll::Ready(Err(payload)),
}
}
}
/// Total elapsed milliseconds since `started`, saturating.
fn elapsed_ms(started: Instant) -> u64 {
u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX)
}
/// A `Write` that keeps the length and throws the bytes away.
///
/// `download_attachment` can serialize megabytes here, on the async worker,
/// purely to be measured — buffering that would be a multi-MB alloc and copy
/// per call for a number.
struct ByteCounter(u64);
impl std::io::Write for ByteCounter {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
self.0 += buf.len() as u64;
Ok(buf.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
/// Serialized size of the payload the client is shown, in bytes (issue #145):
/// the compact JSON of the result's `content` array — block framing included,
/// so it is more than the length of the text inside.
///
/// The array, NOT the whole `CallToolResult`: `resultType` is cleared
/// downstream for peers on an older revision, so sizing the enclosing object
/// would make the number move with the negotiated protocol version instead of
/// with the payload, and the point of the field is to compare payload sizes
/// across calls. `isError` is excluded with it — a constant, carrying nothing.
///
/// `None` where there is nothing to measure: a protocol error is a JSON-RPC
/// `error`, not a result, and the response variants this build does not serve
/// carry no completed result at all. Also `None` if serialization fails, which
/// it cannot for a `ContentBlock` — no float, no non-string key — but an
/// unmeasured record is the right answer if that ever changes.
///
/// Counts rather than buffers (see `ByteCounter`); paid only when
/// auditing is on.
fn response_bytes(result: &Result<CallToolResponse, McpError>) -> Option<u64> {
let Ok(CallToolResponse::Complete(complete)) = result else {
return None;
};
// Structured output would be part of the payload — and for a client that
// prefers it, the whole of it — so measuring `content` alone would
// under-report. Nothing returns `Json<T>` today; this is the tripwire for
// the first tool that does.
debug_assert!(
complete.structured_content.is_none(),
"response_bytes measures `content` only: teach it structuredContent first"
);
let mut counter = ByteCounter(0);
serde_json::to_writer(&mut counter, &complete.content)
.ok()
.map(|()| counter.0)
}
/// Persist one audit record without blocking the async worker: the sink
/// is synchronous by design (that is what makes "persisted before the
/// response is returned" a guarantee), so the write moves to the blocking
/// pool and is awaited — the response still cannot overtake it. A
/// panicked write counts as a failed one.
async fn record_event(
audit: &Arc<AuditState>,
kind: audit::AuditEventKind,
session: audit::SessionInfo,
) -> Result<u64, ()> {
let state = Arc::clone(audit);
match tokio::task::spawn_blocking(move || state.sink.record(kind, session)).await {
Ok(Ok(seq)) => Ok(seq),
Ok(Err(_)) | Err(_) => Err(()),
}
}
/// Assemble the `bug_info` envelope, re-classifying every body before it is
/// served.
///
/// The Read verdict came from the classification fetch; the body comes from a
/// second, later request. Between the two, a bug can be embargoed, moved into
/// a group, or otherwise become something the policy would refuse — and the
/// server's key is privileged enough to keep returning it. So the body is put
/// back through the guard and served on its OWN verdict, never on a verdict
/// about an earlier version of itself. `download_attachment` already re-checks
/// its blob response for the same reason.
///
/// This costs no upstream request: the full fetch returns a superset of the
/// classification fields, so the re-check reads what is already in hand. If
/// the body somehow lacks a field the policy consults, classification fails
/// closed on it (I4) rather than assuming the earlier verdict still holds.
///
/// A downgrade is honoured rather than refused: a body that now classifies
/// summary-only is served as a summary view, matching what a fresh call would
/// return. Anything that no longer classifies at all becomes the uniform
/// restricted entry, byte-identical to a bug that never existed (I2).
///
/// `caller` is the SAME identity the up-front assessment used — resolved
/// once per tool call and threaded here, so a bug the assessment granted on
/// the caller's authorship cannot be dropped by a re-check running without
/// that identity.
fn assemble_bug_info(
guard: &Guard,
ids: &[u64],
assessments: &BTreeMap<u64, (Access, Value)>,
full: &BTreeMap<u64, Value>,
caller: Option<&str>,
) -> Value {
let mut bugs: Vec<Value> = Vec::new();
let mut restricted: Vec<Value> = Vec::new();
for id in ids {
let served = match assessments.get(id) {
Some((access, _)) if access.allows(Capability::Read) => full
.get(id)
// Absent from the body response => fail closed (I4).
.and_then(|body| {
let (kept, dropped) = guard.filter_bug_list(vec![body.clone()], caller);
if !dropped.is_empty() {
// The verdict flipped between the two fetches — the
// anomaly this re-check exists for. Server-side only
// (I3): the client just sees the uniform denial.
tracing::info!(id, "bug_info: body no longer passes the guard; refusing");
}
kept.into_iter().next()
}),
// Summary grants are served from the classification objects the
// verdict was made on, so they carry no second-fetch window.
Some((access, meta)) if access.allows(Capability::Summary) => {
Some(Guard::summary_view(meta))
}
_ => None,
};
match served {
Some(bug) => bugs.push(bug),
None => restricted.push(json!({ "id": id, "note": Guard::denial(*id) })),
}
}
json!({ "bugs": bugs, "restricted": restricted })
}
/// Refuse a call naming more distinct bug ids than the guard will classify.
///
/// The bound itself is [`Guard::MAX_ASSESS_IDS`], defined next to the loop it
/// bounds. Refusing here rather than letting the guard silently deny the
/// excess turns a confusing partial answer into a clear one, and the check
/// reads only the request — never a verdict — so it discloses nothing about
/// any bug (I1/I2).
fn too_many_ids(ids: &[u64]) -> Option<CallToolResult> {
let distinct = ids.iter().collect::<BTreeSet<_>>().len();
(distinct > Guard::MAX_ASSESS_IDS).then(|| {
err_text(format!(
"At most {} bug ids may be named in one call, got {distinct}",
Guard::MAX_ASSESS_IDS
))
})
}
/// The head of a client-sized id array that belongs on a tracing line: at
/// most [`Guard::MAX_ASSESS_IDS`], the bound `too_many_ids` refuses past.
/// Log it beside a `_len` count of the whole array (#240, #258).
fn logged_ids(ids: &[u64]) -> &[u64] {
&ids[..ids.len().min(Guard::MAX_ASSESS_IDS)]
}
/// Keep the first `head` and last `tail` items of a list, dropping the
/// middle. Returns the kept items and how many were omitted.
///
/// One helper for `bug_history` (issue #142) and `bug_comments`, so an
/// edit cannot silently fork the two tools' truncation semantics.
///
/// Both callers run it on the post-scrub list (I14), so `omitted` counts only
/// items the client may see and a dropped item cannot leak by arithmetic
/// (I3). An absent side keeps nothing from that side once the other one is
/// set; `head + tail` reaching the length keeps everything, overlap counted
/// once. `(None, None)` keeps everything — no window asked for is not a
/// window of nothing — though both handlers gate that call away first.
fn window_list<T>(items: Vec<T>, head: Option<u32>, tail: Option<u32>) -> (Vec<T>, usize) {
if head.is_none() && tail.is_none() {
return (items, 0);
}
let len = items.len();
let head = head.unwrap_or(0) as usize;
let tail = tail.unwrap_or(0) as usize;
let keep = head.saturating_add(tail).min(len);
let omitted = len - keep;
if omitted == 0 {
return (items, 0);
}
// Past that return `keep < len`, which a saturated sum could not be, so
// `head + tail < len` exactly: the skip below cannot underflow and the
// two ends cannot overlap. Every index here is derived from `keep`, so
// the underflow safety does not depend on the caller clamping anything.
let mut out = Vec::with_capacity(keep);
let mut iter = items.into_iter();
out.extend(iter.by_ref().take(head));
out.extend(iter.skip(len - head - tail));
(out, omitted)
}
/// Advisory attached to a quicksearch envelope when the query is nothing but
/// bug ids (comma/whitespace-separated numbers, each optionally prefixed
/// with '#').
///
/// With a non-empty `status` the tool prefixes it to the query, so upstream
/// content-matches the whole expression and a client holding an exact set of
/// ids is better served by `bug_info` — this note says so. With an empty
/// `status` the query goes upstream bare, and Bugzilla routes a bare query
/// of nothing but numbers to an exact id lookup instead; the note still
/// steers to `bug_info` there, with wording that is true on that path. A
/// query naming more distinct ids than [`Guard::MAX_ASSESS_IDS`] gets
/// steering that mentions the per-call cap and batching — the cap is already
/// public in bug_info's refusal text and parameter doc — so the advice does
/// not walk the client straight into a refusal.
///
/// The note is computed from the CLIENT'S OWN REQUEST ALONE (the query and
/// status strings): never from search results, guard verdicts, or anything
/// upstream said. That keeps it off the oracle surface — its presence and
/// wording tell the client nothing it did not already know (I2/I3) — and
/// the `bugs` array it accompanies is byte-identical to what the same
/// request returns without it. The query is NOT rerouted: a client genuinely
/// searching for a number still gets the search it asked for.
fn id_list_advisory(query: &str, status: &str) -> Option<String> {
let mut ids: BTreeSet<&str> = BTreeSet::new();
for token in query.split(|c: char| c == ',' || c.is_whitespace()) {
if token.is_empty() {
continue;
}
let digits = token.strip_prefix('#').unwrap_or(token);
if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
// Distinct ids, not distinct spellings: "07" names bug 7.
let canonical = digits.trim_start_matches('0');
ids.insert(if canonical.is_empty() { "0" } else { canonical });
}
if ids.is_empty() {
return None;
}
let semantics = if status.is_empty() {
"This query is only bug ids and, because status is empty, it is \
passed to Bugzilla bare; Bugzilla treats a bare query of nothing \
but numbers as an exact id lookup, not a content search."
} else {
"This query is only bug ids, but quicksearch matches bug text, so a \
number also matches bugs that merely mention it."
};
let steer = if ids.len() > Guard::MAX_ASSESS_IDS {
format!(
"For an exact set of known ids call bug_info with its bug_ids \
array, at most {} ids per call — batch a longer list across \
calls: every requested id comes back either as a bug or under \
'restricted'.",
Guard::MAX_ASSESS_IDS
)
} else {
"For an exact set of known ids call bug_info with its bug_ids array: \
every requested id comes back either as a bug or under 'restricted'."
.to_string()
};
Some(format!("{semantics} {steer}"))
}
/// Cap on named entries in one discovery call
/// ([`BugzillaProductsParams::products`], [`BugFieldsParams::field_names`]):
/// the catalog of a large instance is hundreds of KB and lands verbatim in
/// the model's context, so the detail path is capped rather than left
/// unbounded (I16).
const MAX_DISCOVERY_NAMES: usize = 5;
/// Project one `/rest/product` response object to the catalog shape
/// (`bugzilla_products` with no `products` named): `{id, name}` only.
fn project_product_catalog(envelope: &Value) -> Vec<Value> {
envelope
.get("products")
.and_then(Value::as_array)
.map(|products| {
products
.iter()
.map(|p| {
json!({
"id": p.get("id").cloned().unwrap_or(Value::Null),
"name": p.get("name").cloned().unwrap_or(Value::Null),
})
})
.collect()
})
.unwrap_or_default()
}
/// Project one `/rest/product` response object to the detail shape
/// (`bugzilla_products` with `products` named). `default_assigned_to` and
/// `default_qa_contact` are account emails and are stripped by never being
/// selected — an enforced omission, not an unenforced `include_fields`
/// request (I16).
fn project_product_detail(envelope: &Value) -> Vec<Value> {
envelope
.get("products")
.and_then(Value::as_array)
.map(|products| products.iter().map(project_one_product).collect())
.unwrap_or_default()
}
fn project_one_product(p: &Value) -> Value {
fn named_list(p: &Value, key: &str) -> Vec<Value> {
p.get(key)
.and_then(Value::as_array)
.map(|items| {
items
.iter()
.map(|i| {
json!({
"name": i.get("name").cloned().unwrap_or(Value::Null),
"is_active": i.get("is_active").cloned().unwrap_or(Value::Null),
})
})
.collect()
})
.unwrap_or_default()
}
let components: Vec<Value> = p
.get("components")
.and_then(Value::as_array)
.map(|cs| {
cs.iter()
.map(|c| {
json!({
"name": c.get("name").cloned().unwrap_or(Value::Null),
"description": c.get("description").cloned().unwrap_or(Value::Null),
"is_active": c.get("is_active").cloned().unwrap_or(Value::Null),
})
})
.collect()
})
.unwrap_or_default();
json!({
"name": p.get("name").cloned().unwrap_or(Value::Null),
"description": p.get("description").cloned().unwrap_or(Value::Null),
"is_active": p.get("is_active").cloned().unwrap_or(Value::Null),
"default_milestone": p.get("default_milestone").cloned().unwrap_or(Value::Null),
"has_unconfirmed": p.get("has_unconfirmed").cloned().unwrap_or(Value::Null),
"components": components,
"versions": named_list(p, "versions"),
"milestones": named_list(p, "milestones"),
})
}
/// The fields common to both `bug_fields` shapes — everything but `values`.
fn project_field_common(f: &Value) -> Value {
let has_values = f
.get("values")
.and_then(Value::as_array)
.is_some_and(|v| !v.is_empty());
json!({
"name": f.get("name").cloned().unwrap_or(Value::Null),
"display_name": f.get("display_name").cloned().unwrap_or(Value::Null),
"type": f.get("type").cloned().unwrap_or(Value::Null),
"is_custom": f.get("is_custom").cloned().unwrap_or(Value::Null),
"is_mandatory": f.get("is_mandatory").cloned().unwrap_or(Value::Null),
"is_on_bug_entry": f.get("is_on_bug_entry").cloned().unwrap_or(Value::Null),
"visibility_field": f.get("visibility_field").cloned().unwrap_or(Value::Null),
"visibility_values": f.get("visibility_values").cloned().unwrap_or(Value::Null),
"has_values": json!(has_values),
})
}
/// Project the `/rest/field/bug` catalog response (`bug_fields` with no
/// `field_names`): every field via [`project_field_common`] — never
/// `values`, which is what makes the catalog cheap — optionally filtered to
/// fields Bugzilla marks `is_on_bug_entry`.
fn project_field_catalog(envelope: &Value, on_bug_entry_only: bool) -> Vec<Value> {
envelope
.get("fields")
.and_then(Value::as_array)
.map(|fields| {
fields
.iter()
.filter(|f| {
!on_bug_entry_only
|| f.get("is_on_bug_entry")
.and_then(Value::as_bool)
.unwrap_or(false)
})
.map(project_field_common)
.collect()
})
.unwrap_or_default()
}
/// Project one `can_change_to` entry (only Bugzilla's `bug_status` field
/// carries these) to `{name, comment_required}`; `comment_required` is
/// omitted when the upstream entry does not carry it.
fn project_can_change_to(c: &Value) -> Option<Value> {
let name = c.get("name")?.clone();
let mut obj = serde_json::Map::new();
obj.insert("name".to_string(), name);
if let Some(comment_required) = c.get("comment_required") {
obj.insert("comment_required".to_string(), comment_required.clone());
}
Some(Value::Object(obj))
}
/// Project one legal value of a `/rest/field/bug/{name}` response to
/// `{name}`, plus `is_open` and `can_change_to` when the upstream value
/// carries them (only `bug_status` does). Absent keys are omitted, never
/// `null`, so non-workflow fields stay as cheap as before.
fn project_field_value(v: &Value) -> Option<Value> {
let name = v.get("name")?.clone();
let mut obj = serde_json::Map::new();
obj.insert("name".to_string(), name);
if let Some(is_open) = v.get("is_open") {
obj.insert("is_open".to_string(), is_open.clone());
}
if let Some(list) = v.get("can_change_to").and_then(Value::as_array) {
let projected: Vec<Value> = list.iter().filter_map(project_can_change_to).collect();
obj.insert("can_change_to".to_string(), Value::Array(projected));
}
Some(Value::Object(obj))
}
/// Project one `/rest/field/bug/{name}` response to the detail shape
/// (`bug_fields` with `field_names` named): [`project_field_common`] plus
/// `values`, each reduced to [`project_field_value`].
fn project_field_detail(f: &Value) -> Value {
let mut obj = project_field_common(f);
let values: Vec<Value> = f
.get("values")
.and_then(Value::as_array)
.map(|vs| vs.iter().filter_map(project_field_value).collect())
.unwrap_or_default();
if let Value::Object(map) = &mut obj {
map.insert("values".to_string(), Value::Array(values));
}
obj
}
/// Project a bug object to the requested field set, preserving the
/// `_redacted` marker when present.
fn project_fields(bug: &Value, fields: &BTreeSet<String>) -> Value {
let mut out = serde_json::Map::new();
if let Some(obj) = bug.as_object() {
for field in fields {
if let Some(v) = obj.get(field) {
out.insert(field.clone(), v.clone());
}
}
if let Some(marker) = obj.get("_redacted") {
out.insert("_redacted".to_string(), marker.clone());
}
}
Value::Object(out)
}
/// Fields `bugs_quicksearch` will group by: a HAND-PICKED subset of the
/// [`CLASSIFY_FIELDS`] ∩ [`SUMMARY_FIELDS`] intersection, not the whole of it
/// — low-cardinality enum-ish fields only. That membership is what makes
/// grouping free of policy consequences: the value is already fetched
/// whatever the caller's `include_fields` says, so no extra upstream field is
/// pulled, and a summary-redacted row carries all six, so it buckets like any
/// other row instead of needing one of its own.
///
/// Membership is NOT the I14 argument — the intersection also holds `summary`
/// (which routinely reads "regression from bug 12345") and `id`. What keeps a
/// header clean is ordering: [`group_bugs`] runs after `scrub_bug_links`. Do
/// not read the two membership assertions in the tests as a licence to extend
/// this list; grouping by a free-text field is also pointless.
///
/// [`SUMMARY_FIELDS`]: bugwarden_core::guard::SUMMARY_FIELDS
const GROUP_BY_FIELDS: &[&str] = &[
"product",
"component",
"status",
"resolution",
"severity",
"priority",
];
/// Parse `group_by` into a deduplicated key list, or explain why not. An
/// unknown name is refused rather than skipped: silently ungrouping a typo'd
/// request looks like the feature is broken.
///
/// Caller order is kept, but it is NOT observable: serde_json is built
/// without `preserve_order`, so its Map is a BTreeMap and a group header
/// serializes with its keys sorted (`bugs` first, alphabetically). Hence
/// `group_by=status,product` and `group_by=product,status` return identical
/// bytes. Do not promise ordering in the tool description on the strength of
/// this Vec.
fn parse_group_by(spec: &str) -> Result<Vec<&'static str>, String> {
let mut keys: Vec<&'static str> = Vec::new();
for raw in spec.split(',') {
let name = raw.trim();
if name.is_empty() {
continue;
}
let Some(known) = GROUP_BY_FIELDS.iter().find(|f| **f == name) else {
return Err(format!(
"group_by: unknown field '{name}'; valid fields are {}",
GROUP_BY_FIELDS.join(", ")
));
};
if !keys.contains(known) {
keys.push(known);
}
}
// A spec naming nothing at all is "no grouping", not an error: `""` is
// what a client filling in every declared param sends, and the sibling
// include_fields reads an empty segment the same forgiving way. A typo
// is still refused above — that is a name, not an absence.
Ok(keys)
}
/// Bucket `rows` by their values for `keys`, hoisting those values out of
/// every row into the group header — the whole point, one `"product": "P"`
/// per group instead of one per bug.
///
/// Runs on the SERVED projection, after link scrubbing: a grouped field is
/// forced into the projection (see the handler), so a value can only reach
/// a group header by the same path that would have put it on the bug, and
/// grouping never surfaces a field the projection dropped. A row missing
/// the field buckets under `null`, which keeps every header the same shape
/// — `project_fields` omits absent keys, so this is not only the redacted
/// case.
///
/// Group order, and bug order within a group, are first appearance — the
/// envelope is a deterministic function of the served window, so grouping
/// cannot become a channel that reorders results by verdict, and a group
/// exists only where a served bug put it (no empty buckets, I3). Sorting
/// the groups instead would be the easy mutation here; the unit test picks
/// a fixture whose first-appearance order differs from its sorted one.
fn group_bugs(rows: Vec<Value>, keys: &[&str]) -> Vec<Value> {
let mut sigs: Vec<Vec<Value>> = Vec::new();
let mut buckets: Vec<Vec<Value>> = Vec::new();
for mut row in rows {
let sig: Vec<Value> = keys
.iter()
.map(|k| row.get(*k).cloned().unwrap_or(Value::Null))
.collect();
if let Some(obj) = row.as_object_mut() {
for k in keys {
obj.remove(*k);
}
}
// Linear scan: `keys` is at most six wide and the group count is
// bounded by the served window, which Guard::MAX_SEARCH_WINDOW
// caps at 1000 regardless of what `limit` asked for.
match sigs.iter().position(|s| *s == sig) {
Some(i) => buckets[i].push(row),
None => {
sigs.push(sig);
buckets.push(vec![row]);
}
}
}
sigs.into_iter()
.zip(buckets)
.map(|(sig, bugs)| {
let mut group = serde_json::Map::new();
for (k, v) in keys.iter().zip(sig) {
group.insert((*k).to_string(), v);
}
group.insert("bugs".to_string(), Value::Array(bugs));
Value::Object(group)
})
.collect()
}
/// Drop every `*_detail` key from a bug object: those repeat the plain
/// account fields with real_name/id/email expanded — measured at 44-48%
/// of a full bug's serialized size.
fn strip_detail_fields(bug: &mut Value) {
if let Some(obj) = bug.as_object_mut() {
obj.retain(|k, _| !k.ends_with("_detail"));
}
}
/// Cap each comment's `text` at `max_chars` CHARACTERS (not bytes —
/// comment text is unicode). A capped comment gains a sibling
/// `text_truncated` marker with shown/total counts — never an in-text
/// marker, which would be mistaken for comment content. Returns whether
/// any comment was capped.
fn cap_comment_text(comments: &mut [Value], max_chars: u32) -> bool {
let mut any = false;
for comment in comments.iter_mut() {
let Some(obj) = comment.as_object_mut() else {
continue;
};
let Some(text) = obj.get("text").and_then(Value::as_str) else {
continue;
};
let total = text.chars().count();
let cap = max_chars as usize;
if total > cap {
let shown: String = text.chars().take(cap).collect();
obj.insert("text".to_string(), Value::String(shown));
obj.insert(
"text_truncated".to_string(),
json!({ "shown_chars": cap, "total_chars": total }),
);
any = true;
}
}
any
}
/// Add `"comment": {"body": ...}` to a `Bug.update` payload when a comment is
/// set. This is the UPDATE shape only — `Bug.add_attachment` takes `comment`
/// as a plain string and must not use this helper.
fn attach_comment(payload: &mut serde_json::Map<String, Value>, comment: &str) {
if !comment.is_empty() {
payload.insert("comment".to_string(), json!({ "body": comment }));
}
}
/// Build an `{"add": [..], "remove": [..]}` change object; `None` when there
/// is no change.
fn dep_change(add: Option<&Vec<u64>>, remove: Option<&Vec<u64>>) -> Option<Value> {
let mut obj = serde_json::Map::new();
if let Some(a) = add.filter(|v| !v.is_empty()) {
obj.insert("add".to_string(), json!(a));
}
if let Some(r) = remove.filter(|v| !v.is_empty()) {
obj.insert("remove".to_string(), json!(r));
}
if obj.is_empty() {
None
} else {
Some(Value::Object(obj))
}
}
/// [`dep_change`] for string lists (`keywords`, `see_also`): build an
/// `{"add": [..], "remove": [..]}` change object; `None` when there is no
/// change. Empty strings are dropped like the scalar params drop them, and
/// a side that is absent, empty, or becomes empty is omitted. The
/// replace-all `set` variant is never produced — a stale view of the list
/// would silently wipe entries added since it was read.
fn list_change(add: Option<&Vec<String>>, remove: Option<&Vec<String>>) -> Option<Value> {
fn clean(list: Option<&Vec<String>>) -> Vec<&str> {
list.into_iter()
.flatten()
.map(String::as_str)
.filter(|s| !s.is_empty())
.collect()
}
let mut obj = serde_json::Map::new();
let add = clean(add);
if !add.is_empty() {
obj.insert("add".to_string(), json!(add));
}
let remove = clean(remove);
if !remove.is_empty() {
obj.insert("remove".to_string(), json!(remove));
}
if obj.is_empty() {
None
} else {
Some(Value::Object(obj))
}
}
/// Decoded byte length of a base64 payload, counting the `=` padding out.
///
/// Used to re-check an attachment against the size cap without decoding the
/// blob. Whitespace inside the payload (some encoders wrap lines) is ignored,
/// so a wrapped body is not mistaken for a larger one.
fn decoded_len(blob: &str) -> u64 {
let chars = blob.chars().filter(|c| !c.is_whitespace()).count() as u64;
let padding = blob
.trim_end()
.chars()
.rev()
.take_while(|&c| c == '=')
.count() as u64;
// 4 encoded chars carry 3 bytes; an unpadded tail of 2 or 3 chars carries
// 1 or 2 bytes respectively.
let full = chars / 4 * 3;
let tail = (chars % 4).saturating_sub(1);
(full + tail).saturating_sub(padding)
}
/// Upload-side size gate: `Some(refusal)` when the DECODED size of a base64
/// payload exceeds a non-zero cap.
///
/// The same ceiling `download_attachment` enforces, applied to what comes IN:
/// an operator who capped what may leave has not agreed to unbounded uploads
/// through the same server. Measured on the decoded size, so base64 expansion
/// cannot shrink the cap by a third. The refusal says the payload is too big
/// but not by how much: `max_attachment_bytes` is not I1-disclosable, on this
/// path exactly as on the download path.
fn upload_size_refusal(cap: u64, data: &str) -> Option<String> {
(cap > 0 && decoded_len(data) > cap)
.then(|| "Attachment exceeds the size limit of this server".to_string())
}
/// Whether attachment content of this media type may be returned as MCP
/// image content rather than an opaque blob resource.
///
/// The media type comes from whoever uploaded the attachment, so this is a
/// strict allowlist of raster formats, not a `image/` prefix test. Anything
/// else — notably `image/svg+xml`, which is script-bearing markup in a client
/// webview — travels as a blob resource, keeping attacker-chosen bytes out of
/// the model's image channel and out of client image renderers.
fn is_inline_image(mime: &str) -> bool {
let base = mime
.split(';')
.next()
.unwrap_or("")
.trim()
.to_ascii_lowercase();
matches!(
base.as_str(),
"image/png" | "image/jpeg" | "image/gif" | "image/webp" | "image/bmp"
)
}
/// Whether attachment content of this media type may be windowed as text by
/// `download_attachment`'s head_lines/tail_lines/max_chars parameters.
///
/// Same base-type extraction as [`is_inline_image`], and the media type is
/// just as uploader-chosen: windowing decodes the payload as lossy UTF-8 and
/// never trusts it to be well-formed text, so a "text/plain" coredump of
/// near-bytes yields replacement characters in the served output, never a
/// panic. The `application/*` entries are a fixed allowlist, exactly as
/// conservative as the raster one.
fn is_windowable_text(mime: &str) -> bool {
let base = mime
.split(';')
.next()
.unwrap_or("")
.trim()
.to_ascii_lowercase();
base.starts_with("text/")
|| matches!(
base.as_str(),
"application/json"
| "application/xml"
| "application/javascript"
| "application/x-sh"
| "application/x-ndjson"
| "application/yaml"
)
}
/// Decode a base64 attachment payload to text for windowing. `None` on
/// invalid base64 — the caller then serves the payload exactly as the
/// un-windowed path would; it already passed both attachment gates, so the
/// fallback gives up nothing.
///
/// Whitespace is stripped first (`decoded_len` ignores it for the same
/// reason: some encoders wrap lines). The decoded bytes are not trusted to
/// be UTF-8: lossy conversion, never a panic.
fn decode_attachment_text(blob: &str) -> Option<String> {
let cleaned: String = blob.chars().filter(|c| !c.is_whitespace()).collect();
let bytes = general_purpose::STANDARD.decode(cleaned).ok()?;
Some(String::from_utf8_lossy(&bytes).into_owned())
}
/// Unconditional ceiling on the characters a windowed attachment may put in
/// the model's text channel — it applies with no caller `max_chars` and with
/// no operator byte cap.
///
/// `max_attachment_bytes` bounds DECODED BYTES, but the windowed serve is a
/// JSON text block: JSON escapes every C0 byte as `\u00XX` (6 chars) and
/// lossy UTF-8 turns every invalid byte into U+FFFD (3 bytes). `content_type`
/// is uploader-chosen and never validated, so 2 MiB of `0x01` labelled
/// `text/plain` is ONE `lines()` line — `head_lines: 1` takes the serve-whole
/// branch and would emit ~12 MiB of escapes, six times the cap it passed.
/// 200k chars is well past any payload a model can use and still bounds the
/// worst case (all-C0) at ~1.2 MiB of JSON.
const MAX_WINDOW_CHARS: usize = 200_000;
/// Decoded attachment text after windowing, plus the accounting the summary
/// block's `truncation` object carries.
struct WindowedText {
text: String,
total_lines: usize,
/// Lines present in the SERVED text — counted AFTER the char cap, so a
/// caller computing `total_lines - shown_lines` never reads 0 omitted
/// over a payload the cap mostly dropped. A line the cap cut short still
/// counts as shown.
shown_lines: usize,
truncated_chars: bool,
}
impl WindowedText {
/// Whether windowing actually removed content — the bar for the audit
/// stream's `attachment_window` redaction note.
fn cut_anything(&self) -> bool {
self.shown_lines < self.total_lines || self.truncated_chars
}
}
/// Apply `download_attachment`'s windowing to decoded text: `head_lines` /
/// `tail_lines` select lines of the FULL text, then a character cap — the
/// smaller of the caller's `max_chars` and [`MAX_WINDOW_CHARS`], which binds
/// even when the caller named none. Unicode-safe throughout — the line
/// windows never split a line and the char cap never splits a character.
fn window_text(
full: &str,
head_lines: Option<u32>,
tail_lines: Option<u32>,
max_chars: Option<u32>,
) -> WindowedText {
let total_lines = full.lines().count();
let head = head_lines.unwrap_or(0) as usize;
let tail = tail_lines.unwrap_or(0) as usize;
// No line window asked for, or the two windows already cover the whole
// text: serve it whole. Both conditions are needed — a zero-line text
// makes the second true for a caller who asked for neither.
let windowed = if (head_lines.is_none() && tail_lines.is_none())
|| head.saturating_add(tail) >= total_lines
{
full.to_owned()
} else {
let lines: Vec<&str> = full.lines().collect();
let mut out = lines[..head].join("\n");
if head > 0 && tail > 0 {
out.push('\n');
}
out.push_str(&lines[total_lines - tail..].join("\n"));
out
};
let cap = max_chars.map_or(MAX_WINDOW_CHARS, |max| (max as usize).min(MAX_WINDOW_CHARS));
let (text, truncated_chars) = if windowed.chars().count() > cap {
(windowed.chars().take(cap).collect(), true)
} else {
(windowed, false)
};
WindowedText {
// Counted on the FINAL text: the char cap can drop lines the line
// window kept.
shown_lines: text.lines().count(),
text,
total_lines,
truncated_chars,
}
}
fn default_status() -> String {
"ALL".to_string()
}
fn default_include_fields() -> String {
"id,product,component,assigned_to,status,resolution,summary,last_change_time".to_string()
}
fn default_limit() -> u32 {
50
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct BugInfoParams {
/// Bug ids to fetch. At most 25 distinct ids per call.
pub bug_ids: Vec<u64>,
/// Comma-separated list of fields to return for each bug (`id` is
/// always included). Applied after policy checks, so a field the
/// policy redacts is never reachable by naming it here. Mutually
/// exclusive with `detail`.
#[serde(default)]
pub include_fields: Option<String>,
/// Set to false to drop every `*_detail` field (the duplicated
/// real_name/id/email expansion next to each plain account field).
/// Mutually exclusive with `include_fields`.
#[serde(default)]
pub detail: Option<bool>,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct BugHistoryParams {
/// Bug id.
pub id: u64,
/// Only return history newer than this date.
#[serde(default)]
pub new_since: Option<DateTime<Utc>>,
/// Keep only the first N history entries.
#[serde(default)]
pub head: Option<u32>,
/// Keep only the last N history entries.
#[serde(default)]
pub tail: Option<u32>,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct BugCommentsParams {
/// Bug id.
pub id: u64,
/// Include private comments (subject to server policy).
#[serde(default)]
pub include_private: bool,
/// Only return comments newer than this date.
#[serde(default)]
pub new_since: Option<DateTime<Utc>>,
/// Keep the first N comments (comment 0 is the report — keep it in
/// the window on large threads).
#[serde(default)]
pub head: Option<u32>,
/// Keep the last N comments.
#[serde(default)]
pub tail: Option<u32>,
/// Cap each comment's text at this many characters. A capped comment
/// carries a `text_truncated` marker with shown/total character counts.
#[serde(default)]
pub max_comment_chars: Option<u32>,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct QuicksearchParams {
/// Query in bugzilla quicksearch syntax. Under any non-empty status
/// (the default is ALL) this is content matching, not id lookup: a
/// number in the query is matched as text and returns bugs that merely
/// mention that number. With an empty status the query is sent to
/// bugzilla bare, and a bare query of nothing but numbers is an exact
/// id lookup. For an exact set of known bug ids use the bug_info tool
/// instead.
pub query: String,
/// Status filter (e.g., ALL, OPEN, CLOSED), prefixed to the query.
/// Empty means bugzilla's default search over open bugs — and a bare
/// query of nothing but numbers then becomes an exact id lookup.
#[serde(default = "default_status")]
pub status: String,
/// Comma-separated list of fields to return for each bug.
#[serde(default = "default_include_fields")]
pub include_fields: String,
/// Maximum number of bugs to return.
#[serde(default = "default_limit")]
pub limit: u32,
/// Offset into the result list (for pagination).
#[serde(default)]
pub offset: u32,
/// Optional comma-separated list of fields to group the results by:
/// product, component, status, resolution, severity, priority. Omit it
/// (or send an empty string) for a flat `{"bugs": [...]}` response.
/// Given, the response is `{"groups": [{"bugs": [...], <field>:
/// <value>, ...}]}` and each grouped field is reported once per group
/// instead of once per bug. Listing the same field twice changes
/// nothing, and the order fields are listed in does not matter.
/// Unknown field names are rejected.
#[serde(default)]
pub group_by: Option<String>,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct CreateBugParams {
/// Product to file against.
pub product: String,
/// Component within the product.
pub component: String,
/// One-line summary.
pub summary: String,
/// Version of the product the bug is against.
pub version: String,
/// Longer description; becomes the first comment.
#[serde(default)]
pub description: String,
/// Severity (instance-specific vocabulary, e.g. "normal").
#[serde(default)]
pub severity: Option<String>,
/// Priority (instance-specific vocabulary, e.g. "P3").
#[serde(default)]
pub priority: Option<String>,
/// Operating system the bug applies to.
#[serde(default)]
pub op_sys: Option<String>,
/// Platform/architecture the bug applies to.
#[serde(default)]
pub platform: Option<String>,
/// Keywords to set on the new bug.
#[serde(default)]
pub keywords: Vec<String>,
/// Groups to restrict the new bug to.
#[serde(default)]
pub groups: Vec<String>,
/// Custom fields, e.g. {"cf_fixed_in": "1.2.3"}. Keys must start with
/// 'cf_'.
#[serde(default)]
pub custom_fields: Option<JsonObject>,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct AddAttachmentParams {
/// Bug id to attach to.
pub bug_id: u64,
/// Attachment content, base64-encoded.
pub data: String,
/// File name shown in Bugzilla.
pub file_name: String,
/// Short description of the attachment.
pub summary: String,
/// MIME type, e.g. "text/plain".
pub content_type: String,
/// Comment to add alongside the attachment.
#[serde(default)]
pub comment: String,
/// Mark the attachment private.
#[serde(default)]
pub is_private: bool,
/// Mark the attachment as a patch.
#[serde(default)]
pub is_patch: bool,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct AddCommentParams {
/// Bug id to comment on.
pub bug_id: u64,
/// Comment text.
pub comment: String,
/// Make the comment private.
#[serde(default)]
pub is_private: bool,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct UpdateBugStatusParams {
/// Bug ID to update.
pub bug_id: u64,
/// New status.
pub status: String,
/// Resolution. Bugzilla requires this when the target status is
/// closing and the bug has none, and clears it automatically when
/// the target status is open.
#[serde(default)]
pub resolution: Option<String>,
/// Optional comment explaining the change.
#[serde(default)]
pub comment: String,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct AssignBugParams {
/// Bug ID to assign.
pub bug_id: u64,
/// Email address of the assignee.
pub assignee: String,
/// Optional comment explaining the assignment.
#[serde(default)]
pub comment: String,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct UpdateBugFieldsParams {
/// Bug ID to update.
pub bug_id: u64,
/// Priority (e.g., urgent, high, medium, low, unspecified).
#[serde(default)]
pub priority: Option<String>,
/// Severity (e.g., urgent, high, medium, low, unspecified).
#[serde(default)]
pub severity: Option<String>,
/// Resolution (e.g., FIXED, WONTFIX, NOTABUG, DUPLICATE) - only for
/// closed bugs.
#[serde(default)]
pub resolution: Option<String>,
/// New one-line summary (retitles the bug).
#[serde(default)]
pub summary: Option<String>,
/// URL the bug relates to (the bug's "URL" field).
#[serde(default)]
pub url: Option<String>,
/// Status whiteboard text.
#[serde(default)]
pub whiteboard: Option<String>,
/// Version of the product the bug is against (instance-specific
/// vocabulary).
#[serde(default)]
pub version: Option<String>,
/// Target milestone (instance-specific vocabulary).
#[serde(default)]
pub target_milestone: Option<String>,
/// Keywords to add.
#[serde(default)]
pub keywords_add: Option<Vec<String>>,
/// Keywords to remove.
#[serde(default)]
pub keywords_remove: Option<Vec<String>>,
/// See Also entries to add; each value is a bug URL.
#[serde(default)]
pub see_also_add: Option<Vec<String>>,
/// See Also entries to remove; each value is a bug URL.
#[serde(default)]
pub see_also_remove: Option<Vec<String>>,
/// Custom fields, e.g. {"cf_fixed_in": "1.2.3"}. Keys must start with
/// 'cf_'.
#[serde(default)]
pub custom_fields: Option<JsonObject>,
/// Optional comment explaining the changes.
#[serde(default)]
pub comment: String,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct UpdateBugDependenciesParams {
/// Bug ID to update.
pub bug_id: u64,
/// List of bug IDs this bug should block.
#[serde(default)]
pub blocks_add: Option<Vec<u64>>,
/// List of bug IDs to remove from blocks.
#[serde(default)]
pub blocks_remove: Option<Vec<u64>>,
/// List of bug IDs this bug should depend on.
#[serde(default)]
pub depends_on_add: Option<Vec<u64>>,
/// List of bug IDs to remove from depends_on.
#[serde(default)]
pub depends_on_remove: Option<Vec<u64>>,
/// Optional comment explaining the changes.
#[serde(default)]
pub comment: String,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct AddCcParams {
/// Bug ID.
pub bug_id: u64,
/// Email address to add to the CC list.
pub cc_email: String,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct MarkAsDuplicateParams {
/// Bug ID to mark as duplicate.
pub bug_id: u64,
/// Bug ID this is a duplicate of.
pub duplicate_of: u64,
/// Optional comment (default: auto-generated).
#[serde(default)]
pub comment: String,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct ListAttachmentsParams {
/// Bug ID.
pub bug_id: u64,
/// Include metadata of private attachments (subject to server policy).
#[serde(default)]
pub include_private: bool,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct DownloadAttachmentParams {
/// Attachment id (as returned by list_attachments).
pub attachment_id: u64,
/// Allow downloading a private attachment (subject to server policy).
#[serde(default)]
pub include_private: bool,
/// Text-ish attachments only: return the first this many lines of the
/// decoded content.
#[serde(default)]
pub head_lines: Option<u32>,
/// Text-ish attachments only: return the last this many lines of the
/// decoded content, appended after the head window.
#[serde(default)]
pub tail_lines: Option<u32>,
/// Text-ish attachments only: cap the returned content at this many
/// characters, applied after any line windowing.
#[serde(default)]
pub max_chars: Option<u32>,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct BugUrlParams {
/// Bug ID.
pub bug_id: u64,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct SummarizeBugParams {
/// Bug id whose comments should be summarized.
pub id: u64,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct BugzillaProductsParams {
/// Product names to fetch detail for (max 5): components, versions,
/// milestones. Omit for the catalog of enterable product names only.
#[serde(default)]
pub products: Vec<String>,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct BugFieldsParams {
/// Field names to fetch full detail for, including legal values (max
/// 5). Omit for the catalog, which never carries legal values.
#[serde(default)]
pub field_names: Vec<String>,
/// Restrict the catalog to fields Bugzilla marks shown on bug entry.
/// Ignored when `field_names` is set.
#[serde(default)]
pub on_bug_entry_only: bool,
}
/// `format` values a Gemini/Vertex client accepts in a tool's `inputSchema`.
///
/// A keep-list, not a deny-list: schemars also emits `uint64` / `uint32` for
/// Rust integer widths, which Vertex does not recognize. Dropping them loses
/// no constraint the deserializer doesn't already enforce — `type: integer`
/// plus `minimum: 0` carries the non-negativity, and the actual width is
/// enforced by serde at deserialization, never by the advertised schema.
/// `enum` is a dead keep-list entry: no served schema emits it, and it is
/// absent from Vertex's `Schema.format` documentation. It is not
/// load-bearing.
const PORTABLE_FORMATS: &[&str] = &["date-time", "int32", "int64", "float", "double", "enum"];
/// Rewrites a schemars-generated `inputSchema` node in place so a
/// Gemini/Vertex client accepts it.
///
/// schemars renders `Option<T>` as `"type": [T, "null"]`. A field that
/// also carries `#[serde(default)]` gets a stray `"default": null`.
/// Vertex's client rewrites that union into
/// `anyOf: [{...}, {"type": "null"}]` while keeping `description` /
/// `format` / `default` as *siblings* of `anyOf` — a shape Vertex itself
/// then refuses ("when using any_of, it must be the only field set").
/// Collapsing the union to a plain type before it ever reaches the client
/// starves that rewrite of its input.
///
/// Recurses into every draft 2020-12 subschema position a `#[tool]` param
/// struct can emit: `properties`, `items` (object or array form),
/// object-form `additionalProperties`, `patternProperties` values, and
/// `prefixItems`. Boolean `additionalProperties` is not a subschema and is
/// left alone. Keep the walk in lockstep with the test helper
/// `schema_portability_error`.
fn portable_schema(node: &mut Value) {
let Value::Object(obj) = node else {
return;
};
if let Some(Value::Array(types)) = obj.get_mut("type") {
types.retain(|t| t.as_str() != Some("null"));
if types.len() == 1 {
let only = types.remove(0);
obj.insert("type".to_string(), only);
}
}
if obj.get("default") == Some(&Value::Null) {
obj.remove("default");
}
if let Some(format) = obj.get("format").and_then(Value::as_str) {
if !PORTABLE_FORMATS.contains(&format) {
obj.remove("format");
}
}
// Recursion arms: keep in lockstep with `schema_portability_error`.
if let Some(properties) = obj.get_mut("properties").and_then(Value::as_object_mut) {
for prop in properties.values_mut() {
portable_schema(prop);
}
}
if let Some(items) = obj.get_mut("items") {
match items {
Value::Array(elems) => {
for item in elems {
portable_schema(item);
}
}
other => portable_schema(other),
}
}
if let Some(additional) = obj.get_mut("additionalProperties") {
if additional.is_object() {
portable_schema(additional);
}
}
if let Some(patterns) = obj
.get_mut("patternProperties")
.and_then(Value::as_object_mut)
{
for schema in patterns.values_mut() {
portable_schema(schema);
}
}
if let Some(Value::Array(prefix)) = obj.get_mut("prefixItems") {
for item in prefix {
portable_schema(item);
}
}
}
/// The MCP server: guard policy, Bugzilla client, and the pruned tool
/// router (I13). Construct with [`BugWarden::new`]; serve over any rmcp
/// transport.
#[derive(Clone)]
pub struct BugWarden {
cfg: Arc<Cli>,
guard: Arc<Guard>,
bz: Arc<BugzillaClient>,
tool_router: ToolRouter<Self>,
/// Who holds the Bugzilla API key — resolved exactly once, at
/// construction. In `Server` custody the running server owns the key
/// `String`; it is never re-read from disk per request.
key_custody: KeyCustody,
/// Audit wiring; `None` runs the exact pre-audit request path.
audit: Option<Arc<AuditState>>,
/// Whether every request must carry a bearer [`Scope`]. On for
/// authenticated http only: stdio has no per-request credential, and
/// `--insecure-no-auth` issues none.
enforce_scopes: bool,
}
impl BugWarden {
/// Build the server, pruning the tool router per policy (I13).
///
/// Errors when `global.disabled_tools` names a tool that does not exist:
/// `ToolRouter::remove_route` silently no-ops on unknown names, so a typo
/// would otherwise leave the tool exposed while the operator believes it
/// disabled — the policy format's "typos are hard startup errors, never
/// silent fail-open" rule applies here too.
///
/// Also resolves API key custody ([`Cli::resolve_key_custody`]) — here,
/// on every construction path, so a missing or unreadable key fails at
/// startup rather than at the first request.
pub fn new(cfg: Arc<Cli>, guard: Arc<Guard>, bz: Arc<BugzillaClient>) -> anyhow::Result<Self> {
let key_custody = cfg.resolve_key_custody()?;
// Identity collapses under server-held http custody: every client
// authenticates — and therefore whoamis — as the server's service
// account. A policy written for per-request custody, where
// `created_by_me` meant each caller's own reports, silently changes
// meaning, so say it out loud at startup.
if cfg.transport == Transport::Http
&& matches!(key_custody, KeyCustody::Server(_))
&& guard.policy.needs_identity()
{
tracing::warn!(
"the policy consults created_by_me, but in server-held key mode every \
client resolves to the service account that owns the key: \
created_by_me describes that one account's bug reports for ALL \
clients, never an individual caller's"
);
}
// A declared login names the account owning the SERVER's key
// (A5, plans/ISSUE_WHOAMI_IDENTITY.md). Per-request custody holds
// no server-side key at all — each caller supplies their own — so
// there is nothing the declared login could describe, and unlike
// `whoami` (which stays per-caller-correct there and only warns)
// this is a hard startup error rather than a warning.
if matches!(key_custody, KeyCustody::PerRequest)
&& guard.policy.global.identity_source == IdentitySource::Declared
&& guard.policy.needs_identity()
{
anyhow::bail!(
"guard policy sets global.identity_source = \"declared\", but this server \
holds no API key of its own under per-request key custody: a declared \
login names the account owning the SERVER's key, and there is no such \
key here for it to describe. Use identity_source = \"whoami\" (verified \
per caller) instead, or run with a server-held key (--api-key / \
--api-key-file)"
);
}
let mut tool_router = Self::tool_router();
// One normalization pass covers both `list_tools` and `get_tool`,
// since both read from the same routed `Tool::input_schema`.
for route in tool_router.map.values_mut() {
let mut schema = Value::Object((*route.attr.input_schema).clone());
portable_schema(&mut schema);
if let Value::Object(obj) = schema {
route.attr.input_schema = Arc::new(obj);
}
}
// Validate disabled_tools against the FULL router, before any route
// removal: a write-tool name stays a valid entry even when read-only
// mode removes that route first.
for name in &guard.policy.global.disabled_tools {
if !tool_router.has_route(name) {
let known = tool_router
.list_all()
.iter()
.map(|t| t.name.to_string())
.collect::<Vec<_>>()
.join(", ");
anyhow::bail!(
"policy global.disabled_tools names unknown tool \"{name}\" \
(known tools: {known})"
);
}
}
if guard.policy.global.read_only {
for name in WRITE_TOOLS {
tracing::info!(tool = name, "read-only mode: removing write tool");
tool_router.remove_route(name);
}
}
for name in &guard.policy.global.disabled_tools {
tracing::info!(tool = %name, "policy: removing disabled tool");
tool_router.remove_route(name);
}
if !guard.policy.global.allow_discovery {
for name in DISCOVERY_TOOLS {
tracing::info!(tool = name, "discovery disabled: removing tool");
tool_router.remove_route(name);
}
}
Ok(Self {
cfg,
guard,
bz,
tool_router,
key_custody,
audit: None,
enforce_scopes: false,
})
}
/// Enable auditing. Separate from [`BugWarden::new`] so existing
/// construction sites (and the tests that rely on them) stay valid
/// with auditing off.
pub fn with_audit(mut self, audit: Arc<AuditState>) -> Self {
self.audit = Some(audit);
self
}
/// Require every request to carry a bearer [`Scope`], and serve a
/// read-scope caller the read tools only.
///
/// Separate from [`BugWarden::new`] for the same reason
/// [`BugWarden::with_audit`] is: whether a request carries a credential
/// is a property of the transport it arrives on, not of the policy the
/// server enforces. `main` turns it on for an authenticated http
/// listener and leaves it off for stdio and for `--insecure-no-auth`.
#[must_use]
pub fn with_scope_enforcement(mut self, enforce: bool) -> Self {
self.enforce_scopes = enforce;
self
}
/// The streamable-HTTP transport configuration this server is served
/// with.
///
/// A method on the server rather than a free function taking the cap,
/// so the transport is sized from the very policy this instance
/// enforces: the deployment and its integration tests cannot end up
/// deriving the limit from a different value than the guard uses, and
/// there is one place where the policy field is read. Call it before
/// the server moves into the service closure.
///
/// These rmcp 3.1 defaults are set by name rather than inherited,
/// because inheriting them changes how a deployment behaves without
/// anyone choosing it:
///
/// * `allowed_hosts` defaults to loopback only — a DNS-rebinding
/// defence for MCP servers a browser can reach on `localhost`.
/// bugwarden is reached by MCP clients at whatever address the
/// operator bound and named, so that default would refuse every
/// deployment not addressed as `localhost`, containers included.
/// Disabled deliberately when the operator names no host: the access
/// control is the bearer gate in front of this service
/// ([`crate::http_auth`]): a rebound browser reaches it with no
/// token and is turned away there, whatever `Host` it sent. That
/// argument lapses under `--insecure-no-auth`, which admits every
/// caller — there `--allowed-hosts` / `MCP_ALLOWED_HOSTS` is how the
/// operator puts the `Host` check back. Naming an authority only
/// ever narrows what the disabled state serves (I9).
/// * `max_request_body_bytes` is a POST cap with no rmcp 2.2
/// equivalent, worth keeping as a memory bound — but it also ceilings
/// `add_attachment`, so a fixed value silently overrides the
/// operator's `global.max_attachment_bytes`: at the SDK's 4 MiB,
/// base64 expansion alone put every decoded cap above ~3 MiB out of
/// reach, and the upload the operator had permitted was refused by
/// the transport (issue #52). It is therefore derived from this
/// server's policy by `request_body_cap` — `ceil(cap / 3) * 4`
/// for the encoding plus 1 MiB of framing headroom, clamped to
/// `MAX_REQUEST_BODY_FLOOR` (4 MiB, which `0` also returns) and
/// `MAX_REQUEST_BODY_CEILING` (64 MiB, so no policy value can ask
/// this transport to buffer without bound). Derived, not inherited:
/// an SDK bump still must not move an operator-visible limit. This is
/// the http half of one request cap; `stdio_transport` enforces the
/// same number on a stdio frame (#234).
///
/// A body over that cap is refused by rmcp's tower layer with a bare
/// `413`, which reaches neither `call_tool` nor the guard — so it
/// leaves NO audit record and names neither the tool nor the caller,
/// the same unrecordability as a bearer refusal, which is turned away
/// by the layer in front of this service ([`crate::http_auth`]).
/// An operator diagnosing a 413 therefore has to compare the request
/// body's size with the cap derived here from
/// `global.max_attachment_bytes`, since nothing on the server side will
/// have recorded the attempt — though rmcp's own refusal body prints
/// the number outright. That the boundary is observable to an
/// unauthenticated client, and what it discloses, is recorded in
/// DESIGN.md under "rmcp 3.1 usage notes".
///
/// `allowed_origins` is the browser-facing sibling of `allowed_hosts`
/// and the same reasoning covers it, but it is left inherited: its
/// empty default IS the disabled state, so naming it would assert
/// nothing. Anything that changes the `allowed_hosts` call below should
/// decide this one too rather than leave it behind. The remaining
/// fields, and why each stays inherited, are inventoried in DESIGN.md
/// under "rmcp 3.1 usage notes"; the caller in `main` adds
/// `cancellation_token` so a SIGINT or SIGTERM reaches the live
/// transport (issue #114).
///
/// Errors when `--allowed-hosts` / `MCP_ALLOWED_HOSTS` names an entry
/// that is not a matchable Host authority: that is a typo, and rmcp
/// would turn validation on with nothing matchable. Emits one info line
/// stating whether Host validation is on or off and, when on, the
/// resolved list (hosts only, never key material — I12). Stdio never
/// calls this, so it has no Host check.
pub fn http_server_config(&self) -> anyhow::Result<StreamableHttpServerConfig> {
let config = StreamableHttpServerConfig::default()
.disable_allowed_hosts()
.with_max_request_body_bytes(self.request_body_cap());
// `checked_allowed_hosts` is what decides on/off: an empty list is
// the disabled state; an unparsable entry is a startup error so it
// cannot leave validation on with nothing matchable (see config.rs).
match self.cfg.checked_allowed_hosts()? {
None => {
tracing::info!("Host validation: off");
Ok(config)
}
Some(hosts) => {
tracing::info!("Host validation: on; allowed hosts: {}", hosts.join(", "));
Ok(config.with_allowed_hosts(hosts.iter().copied()))
}
}
}
/// The largest request this server buffers, derived from its own guard
/// policy by [`max_request_body_bytes`].
///
/// The single read of `global.max_attachment_bytes` for both
/// transports: [`Self::http_server_config`] hands it to rmcp's tower
/// layer and [`Self::stdio_transport`] to [`BoundedLines`], so the two
/// cannot size their buffers from different numbers (#234).
fn request_body_cap(&self) -> usize {
max_request_body_bytes(self.guard.policy.global.max_attachment_bytes)
}
/// The stdio transport pair to serve, in place of rmcp's bare
/// `stdio()`.
///
/// Stdin is wrapped in [`BoundedLines`] at `request_body_cap`:
/// rmcp reads a frame into an unbounded `Vec` (#234), so without this
/// the transport buffers whatever a peer writes before a newline.
/// Stdout is untouched — it carries this server's own output, which the
/// `FramedWrite` half already frames.
///
/// An over-cap frame closes the transport rather than being skipped,
/// and `main` turns that into exit 1 through
/// [`BoundedLines::over_cap`]. Stdio has no equivalent of the http
/// `413`: nothing answers the peer, because the request id was inside
/// the frame that was never parsed.
pub fn stdio_transport(&self) -> (BoundedLines<tokio::io::Stdin>, tokio::io::Stdout) {
(
BoundedLines::new(tokio::io::stdin(), self.request_body_cap()),
tokio::io::stdout(),
)
}
/// Turn a silent identity blackout into a loud startup failure.
///
/// `Guard::resolve_caller` maps every `whoami` failure to `None`, and
/// I4 then denies every classification a `created_by_me` rule is
/// consulted for (see DESIGN.md, "Identity resolution") — correct, but
/// invisible: the server starts, looks healthy, and blacks out
/// silently. This probes the SAME endpoint once, before the server
/// serves a single tool call, so a deployment missing it (stock
/// Bugzilla Core v1 does not define `/rest/whoami`) fails to start
/// with a reason instead of failing open-looking-closed.
///
/// Deliberately kept OUT of [`BugWarden::new`], which is sync and is
/// the construction path every integration test drives: moving the
/// probe there would give every one of those tests an unwanted
/// `whoami` call to account for. Call this once, after `new`, before
/// opening the audit sink (a failed preflight must not create or
/// rotate an audit file).
///
/// Behaviour, by key custody:
/// - the policy consults no identity (`!Policy::needs_identity()`):
/// `Ok(())` with ZERO upstream requests — the laziness contract
/// (DESIGN.md) now covers startup too, not just tool calls;
/// - [`KeyCustody::Server`]: one `GET /rest/whoami`. Success logs the
/// resolved login at `info!` and returns `Ok(())`; failure bails,
/// naming the endpoint, that stock Bugzilla Core v1 does not define
/// it, and that starting anyway would deny every access
/// classification the policy's identity rules reach. The sanitized
/// client error (I12: `whoami` already strips the URL) is attached
/// as the error's source;
/// - [`KeyCustody::PerRequest`]: there is no server-held key to probe
/// with — each caller authenticates, and therefore resolves
/// identity, on their own request. `warn!` the same compatibility
/// statement and return `Ok(())`: per-request custody stays
/// correct per call (DESIGN.md, "Identity resolution"), it is only
/// unverifiable up front.
///
/// `global.identity_source = "declared"` (see DESIGN.md) replaces the
/// probe with one `GET /rest/valid_login?login=<identity_login>`: a
/// `true` result verifies the server's key at startup so
/// `Guard::resolve_caller` never looks it up again per call; `false`
/// bails naming the login the key does NOT authenticate as (fail
/// closed, distinct from a transport failure so the operator does not
/// mistake a wrong login for a broken key); a transport/parse error
/// bails naming the endpoint, the same as the `whoami` arm.
/// [`BugWarden::new`] already refuses to construct a declared-identity
/// server under per-request custody (A5), so that combination cannot
/// reach this method — the match below still names it explicitly
/// rather than relying on that invariant silently.
pub async fn preflight(&self) -> anyhow::Result<()> {
use anyhow::Context as _;
if !self.guard.policy.needs_identity() {
return Ok(());
}
match self.guard.policy.global.identity_source {
IdentitySource::Whoami => match &self.key_custody {
KeyCustody::Server(key) => match self.bz.whoami(key).await {
Ok(login) => {
tracing::info!(login, "identity endpoint verified");
Ok(())
}
Err(err) => Err(err).context(
"GET /rest/whoami failed during startup preflight; the guard policy \
consults created_by_me, which needs this endpoint to resolve the \
caller's identity. Stock Bugzilla Core v1 does not define /rest/whoami \
(it is a fork/BMO extension) — if this deployment does not have it, \
starting anyway would deny every access classification the policy's \
identity rules reach (I4). Confirm the endpoint exists, set \
global.identity_source = \"declared\" instead, or remove the \
created_by_me criteria from the policy",
),
},
KeyCustody::PerRequest => {
tracing::warn!(
"the policy consults created_by_me, but per-request key custody holds \
no server-side key to verify /rest/whoami with at startup; each \
caller's identity is still resolved per call, but an unreachable \
endpoint on this deployment will only surface as a denial once a \
tool is called — stock Bugzilla Core v1 does not define /rest/whoami \
(it is a fork/BMO extension)"
);
Ok(())
}
},
IdentitySource::Declared => {
let key = match &self.key_custody {
KeyCustody::Server(key) => key,
// BugWarden::new bails on this combination before a
// server is ever constructed (A5); fail closed rather
// than trust that invariant silently if it is ever
// reordered.
KeyCustody::PerRequest => anyhow::bail!(
"guard policy sets global.identity_source = \"declared\" under \
per-request key custody, which holds no server-side key for the \
declared login to describe; this should have been rejected at \
server construction"
),
};
// Policy::validate requires a non-blank login whenever
// identity_source = "declared"; needs_identity() already
// confirmed a rule consults it, so this is always Some.
let login = self
.guard
.policy
.global
.identity_login
.as_deref()
.unwrap_or_default();
match self.bz.valid_login(key, login).await {
Ok(true) => {
tracing::info!(login, "declared identity verified");
Ok(())
}
Ok(false) => anyhow::bail!(
"GET /rest/valid_login says the configured API key does not \
authenticate as \"{login}\" (global.identity_login); Bugzilla \
compares logins case-sensitively, so check for a case mismatch as \
well as a wrong account. Fail closed: starting anyway would deny \
every access classification the policy's identity rules reach (I4)",
),
Err(err) => Err(err).context(format!(
"GET /rest/valid_login failed during startup preflight while \
verifying the declared login \"{login}\" (global.identity_login)",
)),
}
}
}
}
/// What this request's bearer credential reaches.
///
/// Fails closed on a missing scope: the gate wraps the whole router, so
/// a served request always carries one, and its absence means the
/// listener was not wired the way this server was configured. Logged
/// once per process rather than per request — the branch is unreachable
/// through `main`, and a per-request line would be a log-volume lever if
/// it ever became reachable.
fn reach(&self, ctx: &RequestContext<RoleServer>) -> Reach {
if !self.enforce_scopes {
return Reach::Everything;
}
match http_auth::scope_of(ctx) {
Some(Scope::Write) => Reach::Everything,
Some(Scope::Read) => Reach::ReadOnly,
None => {
static WARNED: std::sync::Once = std::sync::Once::new();
WARNED.call_once(|| {
tracing::error!(
"a request reached the handler with no bearer scope while scope \
enforcement is on; serving nothing. The http listener is not \
wired through the authentication layer"
);
});
Reach::Nothing
}
}
}
/// Whether this request's credential reaches `tool` at all.
fn reaches(&self, tool: &str, ctx: &RequestContext<RoleServer>) -> bool {
match self.reach(ctx) {
Reach::Everything => true,
Reach::ReadOnly => read_scope_serves(tool),
Reach::Nothing => false,
}
}
/// Dispatch one call through the router, answering a panicking handler
/// with [`handler_panicked`] instead of leaving the request unanswered
/// (issue #253; the mechanism is on [`CatchUnwind`]).
///
/// The single dispatch site: `call_tool` reaches the router only through
/// here, audited or not, so both paths answer a panic identically and
/// neither can drift.
///
/// It cannot turn a failure into a success — the only reply it invents
/// is an internal error for a call that produced no reply at all — and
/// it changes nothing about a handler that returns normally.
async fn dispatch(
&self,
request: CallToolRequestParams,
context: RequestContext<RoleServer>,
) -> Result<CallToolResponse, McpError> {
// Kept before `request` is consumed, for the recovery line alone.
let tool = request.name.clone();
let dispatch = self
.tool_router
.call(ToolCallContext::new(self, request, context));
match CatchUnwind::new(dispatch).await {
Ok(result) => result,
// The payload is dropped unformatted: see `handler_panicked`.
// In the shipped binary [`crate::panic_hook`] has already
// reported the panic's location and thread; this line is what
// ties it to a tool and to a request that WAS answered. WARN,
// not ERROR: a client can trigger a handler panic on demand,
// and the ERROR an operator is paged on is the hook's line for
// the process's FIRST panic (#270).
Err(_payload) => {
tracing::warn!(
tool = ?Capped(&tool),
"tool handler panicked; the request was answered with an internal error"
);
Err(handler_panicked())
}
}
}
/// The session an audit record belongs to. http: the id the transport
/// minted, stamped into the request extensions by
/// [`AuditedSessionManager`](crate::http_session::AuditedSessionManager),
/// plus the remote peer address when the listener was built with
/// connect-info. stdio: the process-scoped session id from
/// [`AuditState`].
///
/// Never the `mcp-session-id` request header (issue #180): it does not
/// exist yet on `initialize`, and on the stateless path it is
/// unvalidated client text.
fn session_info(
&self,
ctx: &RequestContext<RoleServer>,
audit: &AuditState,
) -> audit::SessionInfo {
match self.cfg.transport {
Transport::Stdio => audit::SessionInfo {
id: Some(audit.stdio_session_id.clone()),
transport: TransportKind::Stdio,
remote: None,
},
Transport::Http => audit::SessionInfo {
id: ctx
.extensions
.get::<crate::http_session::TransportSessionId>()
.map(|session| session.0.to_string()),
transport: TransportKind::Http,
remote: ctx
.extensions
.get::<axum::http::request::Parts>()
.and_then(|p| {
p.extensions
.get::<axum::extract::ConnectInfo<std::net::SocketAddr>>()
})
.map(|ci| ci.0.to_string()),
},
}
}
/// Resolve the Bugzilla API key for the current request.
///
/// Server custody (stdio, or http server-held mode): the key resolved at
/// startup — the per-request header is never consulted, so a request that
/// carries one is served with the server's key and the header value is
/// never read. Per-request custody (http): from the configured
/// (lowercased) header of the underlying HTTP request; a missing key is a
/// protocol error (`McpError::invalid_request`), not a tool error.
fn api_key(&self, ctx: &RequestContext<RoleServer>) -> Result<String, McpError> {
match &self.key_custody {
KeyCustody::Server(key) => Ok(key.clone()),
KeyCustody::PerRequest => {
let header_name = self.cfg.api_key_header.to_lowercase();
ctx.extensions
.get::<axum::http::request::Parts>()
.and_then(|parts| parts.headers.get(header_name.as_str()))
.and_then(|v| v.to_str().ok())
.filter(|v| !v.is_empty())
.map(str::to_owned)
.ok_or_else(|| {
McpError::invalid_request(
format!("`{}` header is required", self.cfg.api_key_header),
None,
)
})
}
}
}
/// Guard assessment for the given ids (fail closed, I4). `caller` is
/// the identity resolved once at the tool entry
/// (`Guard::resolve_caller`) — never resolved here, so a tool call
/// costs at most one whoami lookup however many assessments it runs.
async fn assess(
&self,
key: &str,
ids: &[u64],
caller: Option<&str>,
) -> BTreeMap<u64, (Access, Value)> {
self.guard.assess(&self.bz, key, ids, caller).await
}
/// Assess a single bug id and require `cap`. Returns `Some(denial)` when
/// the operation must be refused; the denial text is uniform (I2). The
/// verdict — either way — and its deciding rule are noted on the
/// request's audit cell; noting changes nothing the client sees.
async fn deny_unless(
&self,
key: &str,
id: u64,
cap: Capability,
caller: Option<&str>,
ctx: &RequestContext<RoleServer>,
) -> Option<CallToolResult> {
let assessments = self.assess(key, &[id], caller).await;
let entry = assessments.get(&id);
let allowed = entry.is_some_and(|(access, _)| access.allows(cap));
if let Some(cell) = audit_cell(ctx) {
let verdict = if allowed {
Verdict::Served
} else {
Verdict::Denied
};
match entry {
Some((access, _)) => cell.note_verdict_rule(verdict, access_rule(access)),
None => cell.note_verdict(verdict),
}
}
if allowed {
None
} else {
tracing::info!(bug_id = id, capability = ?cap, "guard denied operation");
Some(err_text(Guard::denial(id)))
}
}
}
#[tool_router]
impl BugWarden {
#[tool(
description = "Returns the entire information for one or more bugzilla bug ids. Bugs that are not accessible through this server are listed under 'restricted'. To reduce response size, pass include_fields (comma-separated; id is always included) to project each bug to the named fields, or detail=false to drop the duplicated *_detail fields; the two options are mutually exclusive.",
annotations(read_only_hint = true, open_world_hint = true)
)]
async fn bug_info(
&self,
Parameters(p): Parameters<BugInfoParams>,
ctx: RequestContext<RoleServer>,
) -> Result<CallToolResult, McpError> {
// Deduplicate while preserving request order.
let mut seen = BTreeSet::new();
let ids: Vec<u64> = p
.bug_ids
.iter()
.copied()
.filter(|id| seen.insert(*id))
.collect();
// The first `MAX_ASSESS_IDS` distinct ids: exactly the set served,
// or the head of a call refused whole (`too_many_ids`); the count
// is the raw array's, which is client-sized (#240).
tracing::info!(
bug_ids_len = p.bug_ids.len(),
bug_ids = ?logged_ids(&ids),
"tool: bug_info"
);
let key = self.api_key(&ctx)?;
if ids.is_empty() {
note_refused(&ctx);
return Ok(err_text("At least one bug id must be provided"));
}
if let Some(refusal) = too_many_ids(&ids) {
note_refused(&ctx);
return Ok(refusal);
}
if p.include_fields.is_some() && p.detail.is_some() {
note_refused(&ctx);
return Ok(err_text("include_fields and detail are mutually exclusive"));
}
// The projection, parsed once. `id` is forced in (same as
// bugs_quicksearch): without it the envelope's `served` set and the
// client's bookkeeping both break.
let projection: Option<BTreeSet<String>> = p.include_fields.as_ref().map(|csv| {
csv.split(',')
.map(|f| f.trim().to_string())
.filter(|f| !f.is_empty())
.chain(std::iter::once("id".to_string()))
.collect()
});
// Resolved at most once per tool call and threaded to EVERY
// classification below — the assessment, the assemble re-check and
// the link disclosure — so one verdict cannot be made with the
// identity and another without it.
let caller = self.guard.resolve_caller(&self.bz, &key).await;
let assessments = self.assess(&key, &ids, caller.as_deref()).await;
// Full fetch only for Read-granted ids.
let read_ids: Vec<u64> = ids
.iter()
.copied()
.filter(|id| {
assessments
.get(id)
.is_some_and(|(access, _)| access.allows(Capability::Read))
})
.collect();
let mut full: BTreeMap<u64, Value> = BTreeMap::new();
if !read_ids.is_empty() {
match self.bz.get_bugs(&key, &read_ids, None).await {
Ok(envelope) => {
if let Some(arr) = envelope.get("bugs").and_then(Value::as_array) {
for bug in arr {
if let Some(id) = bug.get("id").and_then(Value::as_u64) {
full.insert(id, bug.clone());
}
}
}
}
Err(e) => {
// Server-side only. Bugzilla's own message names the bug
// and says whether it exists ("Bug #N does not exist."),
// so forwarding it would hand the client the very
// distinction I2 exists to erase. The ids simply stay
// absent from `full` and fall to the uniform denial
// below, which is also what a fetch failure should look
// like (I4) — one bad body no longer voids the call.
tracing::warn!(error = %e, "bug_info: body fetch failed");
}
}
}
// The bodies name other bugs through blocks/depends_on/dupe_of/
// see_also/url. Those ids come from Bugzilla, not the client, so one
// batched assessment covers them all; anything that fails to earn a
// summary is removed before the body is served (I2, and the same bar
// update_bug_dependencies applies before WRITING such a link).
let mut envelope =
assemble_bug_info(&self.guard, &ids, &assessments, &full, caller.as_deref());
// Projection is the last step before link scrubbing and serving: it
// runs on the bodies the guard just re-checked (I4/I8), so a field
// the client did not ask for is never assessed and never served —
// and a link field the projection drops costs no disclosure
// assessment below.
if projection.is_some() || p.detail == Some(false) {
if let Some(bugs) = envelope.get_mut("bugs").and_then(Value::as_array_mut) {
for bug in bugs.iter_mut() {
if let Some(fields) = &projection {
*bug = project_fields(bug, fields);
} else {
strip_detail_fields(bug);
}
}
}
}
let base_url = self.bz.base_url();
// Only ids actually SERVED in this envelope are already answered for.
// A requested id that was denied must not be whitelisted: asking
// about a hidden bug would then reveal it through the links of one
// the client may read.
let served: BTreeSet<u64> = envelope
.get("bugs")
.and_then(Value::as_array)
.map(|bugs| bugs.iter().filter_map(|b| b["id"].as_u64()).collect())
.unwrap_or_default();
let named: BTreeSet<u64> = envelope
.get("bugs")
.and_then(Value::as_array)
.map(|bugs| {
bugs.iter()
.flat_map(|b| Guard::linked_bug_ids(b, base_url))
.filter(|id| !served.contains(id))
.collect()
})
.unwrap_or_default();
// Always assess, even with nothing to assess: skipping the call when
// every link is hidden would make "no links" and "links, all hidden"
// cost different round trips (I2). download_attachment pads the same
// way.
let mut allowed = self
.guard
.disclosable(&self.bz, &key, &named, caller.as_deref())
.await;
allowed.extend(served);
if let Some(bugs) = envelope.get_mut("bugs").and_then(Value::as_array_mut) {
for bug in bugs.iter_mut() {
Guard::scrub_bug_links(bug, base_url, &allowed);
}
}
if let Some(cell) = audit_cell(&ctx) {
// One record for the whole call: per-id verdicts merge worst-
// wins. The envelope is the truth (a re-check flip lands a
// granted id under `restricted`); rules come from the
// assessments where the variant matches the outcome. A
// `restricted` entry is a Denied the client was told about;
// the suppressed set is the scrubbed LINK ids — the ones the
// client cannot see at all (I14).
if let Some(bugs) = envelope.get("bugs").and_then(Value::as_array) {
for bug in bugs {
let redacted = bug.get("_redacted").is_some();
let verdict = if redacted {
Verdict::ServedFiltered
} else {
Verdict::Served
};
let rule = bug
.get("id")
.and_then(Value::as_u64)
.and_then(|id| assessments.get(&id));
match rule {
Some((Access::Granted { rule, .. }, _)) => {
cell.note_verdict_rule(verdict, rule);
}
_ => cell.note_verdict(verdict),
}
if redacted {
cell.note_redacted("summary_view");
}
}
}
if let Some(restricted) = envelope.get("restricted").and_then(Value::as_array) {
for entry in restricted {
let rule = entry
.get("id")
.and_then(Value::as_u64)
.and_then(|id| assessments.get(&id));
match rule {
Some((Access::Denied { rule }, _)) => {
cell.note_verdict_rule(Verdict::Denied, rule);
}
_ => cell.note_verdict(Verdict::Denied),
}
}
}
let hidden_links: Vec<u64> = named.difference(&allowed).copied().collect();
if !hidden_links.is_empty() {
cell.note_suppressed(hidden_links);
}
}
Ok(ok_json(envelope))
}
#[tool(
description = "Returns the history of given bug id. new_since allows filtering history newer than the given date. head/tail keep only the first/last N entries (head + tail >= the entry count keeps everything); with either set the response is an envelope {\"history\": [...], \"truncation\": {\"omitted_entries\": N, \"shown_entries\": M}} instead of the bare array.",
annotations(read_only_hint = true, open_world_hint = true)
)]
async fn bug_history(
&self,
Parameters(p): Parameters<BugHistoryParams>,
ctx: RequestContext<RoleServer>,
) -> Result<CallToolResult, McpError> {
tracing::info!(id = p.id, new_since = ?p.new_since, head = ?p.head, tail = ?p.tail, "tool: bug_history");
let key = self.api_key(&ctx)?;
let caller = self.guard.resolve_caller(&self.bz, &key).await;
if let Some(denied) = self
.deny_unless(&key, p.id, Capability::History, caller.as_deref(), &ctx)
.await
{
return Ok(denied);
}
match self.bz.bug_history(&key, p.id, p.new_since).await {
Ok(history) => {
// Dependency, duplicate and see_also changes carry the ids of
// OTHER bugs in their added/removed values, so history is a
// way to read out the existence of bugs the policy hides.
let base_url = self.bz.base_url();
let named = Guard::history_bug_ids(&history, base_url);
let disclosable = self
.guard
.disclosable(&self.bz, &key, &named, caller.as_deref())
.await;
if let Some(cell) = audit_cell(&ctx) {
let hidden: Vec<u64> = named.difference(&disclosable).copied().collect();
if !hidden.is_empty() {
cell.note_suppressed(hidden);
}
}
let scrubbed = Guard::scrub_history(history, base_url, &disclosable);
if p.head.is_none() && p.tail.is_none() {
return Ok(ok_json(scrubbed));
}
// The window closes over the scrubbed list (I14), so a
// dropped entry consumes no slot and names nothing.
let entries = match scrubbed {
Value::Array(entries) => entries,
// A non-array history from upstream is served unchanged:
// a presentation control must not drop an odd shape.
other => return Ok(ok_json(other)),
};
let (shown, omitted) = window_list(entries, p.head, p.tail);
if omitted > 0 {
if let Some(cell) = audit_cell(&ctx) {
cell.note_redacted_client_window("history_window");
}
}
let shown_len = shown.len();
Ok(ok_json(json!({
"history": shown,
"truncation": {
"omitted_entries": omitted,
"shown_entries": shown_len,
},
})))
}
Err(e) => {
tracing::warn!(id = p.id, error = %e, "bug_history: fetch failed");
Ok(err_text("Failed to fetch bug history"))
}
}
}
#[tool(
description = "Returns the comments of given bug id. Private comments are not included by default but can be explicitly requested (subject to server policy). new_since allows filtering comments newer than the given date. For large threads, window with head (first N — comment 0 is the report) and tail (last N), and/or cap each comment's text with max_comment_chars characters; when any of the three is set, the response is an object {comments, truncation: {omitted_comments, shown_comments}} instead of a bare array.",
annotations(read_only_hint = true, open_world_hint = true)
)]
async fn bug_comments(
&self,
Parameters(p): Parameters<BugCommentsParams>,
ctx: RequestContext<RoleServer>,
) -> Result<CallToolResult, McpError> {
tracing::info!(
id = p.id,
include_private = p.include_private,
new_since = ?p.new_since,
"tool: bug_comments"
);
let key = self.api_key(&ctx)?;
let caller = self.guard.resolve_caller(&self.bz, &key).await;
if let Some(denied) = self
.deny_unless(&key, p.id, Capability::Comments, caller.as_deref(), &ctx)
.await
{
return Ok(denied);
}
match self.bz.bug_comments(&key, p.id, p.new_since).await {
Ok(comments) => {
let total = comments.len();
let filtered = self.guard.filter_comments(comments, p.include_private);
// Bugzilla writes "*** Bug N has been marked as a duplicate
// of this bug ***" itself, so a hidden bug can name itself in
// the comments of one the client may read (I2).
// LOAD-BEARING: harvest from the FILTERED list, never the raw
// one. A dropped private comment is already counted id-less
// below; naming its marker id too would put one comment in
// both tallies, which the record ships as two disjoint
// fields for a reader to add (#68).
let named = Guard::duplicate_marker_ids(&filtered);
let disclosable = self
.guard
.disclosable(&self.bz, &key, &named, caller.as_deref())
.await;
if let Some(cell) = audit_cell(&ctx) {
// Dropped private comments have no bug id: count only.
// Scrubbed duplicate-marker ids are the hidden bugs.
cell.note_suppressed_count((total - filtered.len()) as u64);
let hidden: Vec<u64> = named.difference(&disclosable).copied().collect();
if !hidden.is_empty() {
cell.note_suppressed(hidden);
}
}
let scrubbed = Guard::scrub_duplicate_markers(filtered, &disclosable);
// Windowing runs LAST, on the post-filter, post-scrub list
// (I5/I14): omitted_comments counts only comments the client
// may see, so it can never leak that a private comment
// exists. Without any windowing param the response stays the
// bare array it always was.
let windowing =
p.head.is_some() || p.tail.is_some() || p.max_comment_chars.is_some();
if !windowing {
return Ok(ok_json(Value::Array(scrubbed)));
}
let (mut comments, omitted) = if p.head.is_some() || p.tail.is_some() {
window_list(scrubbed, p.head, p.tail)
} else {
// max_comment_chars alone caps texts; it does not
// open a window.
(scrubbed, 0)
};
let capped = p
.max_comment_chars
.is_some_and(|cap| cap_comment_text(&mut comments, cap));
if omitted > 0 || capped {
if let Some(cell) = audit_cell(&ctx) {
cell.note_redacted_client_window("comments_window");
}
}
let shown = comments.len();
Ok(ok_json(json!({
"comments": comments,
"truncation": {
"omitted_comments": omitted,
"shown_comments": shown,
},
})))
}
Err(e) => {
tracing::warn!(id = p.id, error = %e, "bug_comments: fetch failed");
Ok(err_text("Failed to fetch bug comments"))
}
}
}
#[tool(
description = "Search bugs by CONTENT using bugzilla's quicksearch syntax (full-text matching over bug summaries and text). The status filter is prefixed to the query expression, and under any non-empty status (the default is ALL) a number in the query is matched as text like any other word — it returns bugs that merely MENTION that number, not an id lookup. The one exception: an empty status sends the query to bugzilla bare, and bugzilla treats a bare query of nothing but numbers as an exact id lookup. Either way, for an exact set of known bug ids call bug_info with its bug_ids array instead: every requested id comes back either as a bug or under 'restricted', so an inaccessible id is reported rather than silently missing from a search.\n\nTo reduce the token limit & response time, only returns a subset of fields for each bug. The user can query full details of each bug using the bug_info tool. Returns the top-level bug data envelope containing the matched bugs.\n\nSet group_by to a comma-separated list of product, component, status, resolution, severity or priority to get {'groups': [{'bugs': [...], <field>: <value>, ...}]} instead of a flat bug list: each grouped field is then reported once per group rather than once per bug. Worth doing only when the results share few distinct values for those fields — grouping a heterogeneous result set by several fields at once makes the response LARGER, since a group holding one bug costs more than the fields it saved. Omit group_by for the flat response.",
annotations(read_only_hint = true, open_world_hint = true)
)]
async fn bugs_quicksearch(
&self,
Parameters(p): Parameters<QuicksearchParams>,
ctx: RequestContext<RoleServer>,
) -> Result<CallToolResult, McpError> {
tracing::info!(
query = ?Capped(&p.query),
status = ?Capped(&p.status),
include_fields = ?Capped(&p.include_fields),
limit = p.limit,
offset = p.offset,
group_by = ?Capped(p.group_by.as_deref().unwrap_or("")),
"tool: bugs_quicksearch"
);
// Validated before the upstream call: a malformed projection should
// not cost a Bugzilla round-trip. The text names only what the
// client itself sent, so it is not an oracle (I2/I3).
let group_keys = match p.group_by.as_deref().map(parse_group_by).transpose() {
Ok(keys) => keys.filter(|k| !k.is_empty()),
Err(msg) => {
note_refused(&ctx);
return Ok(err_text(msg));
}
};
let key = self.api_key(&ctx)?;
// Requested projection; "id" is always part of it, and so is anything
// grouped: a grouped field is served (in the group header), so it has
// to travel the same projection and link-scrub path as any other
// served field rather than being read out of the pre-projection row.
let requested: BTreeSet<String> = p
.include_fields
.split(',')
.map(|f| f.trim().to_string())
.filter(|f| !f.is_empty())
.chain(std::iter::once("id".to_string()))
.chain(group_keys.iter().flatten().map(|k| (*k).to_string()))
.collect();
// Fetch requested ∪ CLASSIFY_FIELDS so the guard can classify.
let mut fetch = requested.clone();
fetch.extend(CLASSIFY_FIELDS.split(',').map(|f| f.trim().to_string()));
let fetch_fields = fetch.into_iter().collect::<Vec<_>>().join(",");
// limit/offset address the bugs the client may see; the guard scans
// and filters upstream rows to fill that window (I2/I3).
let caller = self.guard.resolve_caller(&self.bz, &key).await;
// `scanned`/`dropped` are the scan's accounting for the audit
// record only (issue #29); the response is built from `kept`
// alone, so it stays byte-identical whatever they hold (I3).
let SearchWindow {
bugs: kept,
scanned,
dropped,
} = match self
.guard
.quicksearch_window(
&self.bz,
&key,
&SearchRequest {
query: &p.query,
status: &p.status,
include_fields: &fetch_fields,
limit: p.limit,
offset: p.offset,
},
caller.as_deref(),
)
.await
{
Ok(window) => window,
// Uniform: a failing search never says which bug or why (I2).
Err(e) => {
tracing::warn!(error = %e, "bugs_quicksearch: upstream search failed");
return Ok(err_text("Search failed"));
}
};
let mut projected: Vec<Value> = kept
.iter()
.map(|bug| project_fields(bug, &requested))
.collect();
// The client chooses the projection, so it can ask for depends_on,
// blocks, dupe_of or see_also here and read out the ids of bugs the
// policy hides — wholesale, rather than one guessed id at a time
// (I14). Same treatment as bug_info.
let base_url = self.bz.base_url();
let served: BTreeSet<u64> = projected.iter().filter_map(|b| b["id"].as_u64()).collect();
let named: BTreeSet<u64> = projected
.iter()
.flat_map(|b| Guard::linked_bug_ids(b, base_url))
.filter(|id| !served.contains(id))
.collect();
let mut allowed = self
.guard
.disclosable(&self.bz, &key, &named, caller.as_deref())
.await;
allowed.extend(served);
for bug in projected.iter_mut() {
Guard::scrub_bug_links(bug, base_url, &allowed);
}
if let Some(cell) = audit_cell(&ctx) {
cell.note_verdict(Verdict::Served);
// The window scan's own accounting (issue #29): rows examined
// and rows dropped by verdict. A dropping scan upgrades the
// verdict to served_filtered inside note_scan; the dropped
// ids ride the existing suppressed-ids machinery below —
// config gate, suppressed_ids_count, and the BTreeSet union
// with the I14-scrubbed link ids (overlap dedupes, a feature).
cell.note_scan(u64::from(scanned), dropped.len() as u64);
if !dropped.is_empty() {
cell.note_suppressed(dropped.iter().copied());
}
// What this site itself sees is the served projection —
// redacted rows and the linked ids I14 scrubbed out of them.
for bug in &projected {
if bug.get("_redacted").is_some() {
cell.note_redacted("summary_view");
}
}
let hidden: Vec<u64> = named.difference(&allowed).copied().collect();
if !hidden.is_empty() {
cell.note_suppressed(hidden);
}
}
// Grouping is the last step, after the audit block above has already
// read the flat projection: the audit record of a call is then
// identical whether or not the client asked for groups.
let mut envelope = match &group_keys {
Some(keys) => json!({ "groups": group_bugs(projected, keys) }),
None => json!({ "bugs": projected }),
};
// Steering only, computed from the request the client itself sent
// (query and status) — never from results or verdicts (see
// id_list_advisory).
if let Some(note) = id_list_advisory(&p.query, &p.status) {
envelope["note"] = json!(note);
}
Ok(ok_json(envelope))
}
#[tool(
description = "File a new bug. The bug is checked against server policy AS DESCRIBED before it is created, so a product or component the policy withholds cannot be filed into either. Accepts custom 'cf_*' fields for products with mandatory entry fields. Returns the new bug id on success.",
annotations(
read_only_hint = false,
destructive_hint = false,
idempotent_hint = false,
open_world_hint = true
)
)]
async fn create_bug(
&self,
Parameters(p): Parameters<CreateBugParams>,
ctx: RequestContext<RoleServer>,
) -> Result<CallToolResult, McpError> {
tracing::info!(
product = ?Capped(&p.product),
component = ?Capped(&p.component),
custom_field_count = p.custom_fields.as_ref().map_or(0, |cf| cf.len()),
"tool: create_bug"
);
let key = self.api_key(&ctx)?;
let mut payload = serde_json::Map::new();
payload.insert("product".to_string(), json!(p.product));
payload.insert("component".to_string(), json!(p.component));
payload.insert("summary".to_string(), json!(p.summary));
payload.insert("version".to_string(), json!(p.version));
if !p.description.is_empty() {
payload.insert("description".to_string(), json!(p.description));
}
for (field, value) in [
("severity", p.severity),
("priority", p.priority),
("op_sys", p.op_sys),
("platform", p.platform),
] {
if let Some(v) = value {
payload.insert(field.to_string(), json!(v));
}
}
if !p.keywords.is_empty() {
payload.insert("keywords".to_string(), json!(p.keywords));
}
if !p.groups.is_empty() {
payload.insert("groups".to_string(), json!(p.groups));
}
if let Some(custom_fields) = &p.custom_fields {
// I7: only cf_* keys may pass through, same gate as
// update_bug_fields. This early error is safe even though the
// create refusal must otherwise stay padded and uniform: its
// outcome is a pure function of the client's own key names, not
// of policy or upstream state, so it discloses nothing about
// either.
for k in custom_fields.keys() {
if !k.starts_with("cf_") {
note_refused(&ctx);
return Ok(err_text(format!(
"Invalid custom field '{k}': custom field names must start with 'cf_'"
)));
}
}
for (k, v) in custom_fields {
payload.insert(k.clone(), v.clone());
}
}
let payload = Value::Object(payload);
// No bug exists yet, so the bug AS REQUESTED is what the policy
// judges: the rules that decide what may be seen decide what may be
// filed. The refusal names no rule (I1) and — crucially — is the
// SAME text, after the SAME single upstream request, whether the
// policy or Bugzilla refused. Two distinguishable refusals would be
// a free policy oracle: send a request Bugzilla is guaranteed to
// reject (an invalid `version`, say) and read the policy off which
// refusal comes back, with nothing created and nothing logged
// upstream. So the refused path burns one classification call
// against bug id 0 — which never exists and creates nothing —
// exactly as download_attachment pads its metadata-miss path, so
// both failure paths cost one upstream request. Honestly residual:
// a SUCCESSFUL create is still distinguishable (it returns the new
// bug id — that is the tool working), so a client willing to file a
// real, attributable bug in an allowed product can still confirm
// that product is allowed; and the padding equalizes the request
// COUNT, not the upstream handler's exact latency (a GET classify
// vs a rejected POST), the same residual the download path accepts.
if !self.guard.may_create(&payload) {
// No whoami on the create path, ever: the create gate forces
// created_by_me itself, and the padding classify against bug id
// 0 decides nothing — caller identity is deliberately None so
// the refused path keeps costing exactly one upstream request.
let _ = self.assess(&key, &[0], None).await;
tracing::info!(product = ?Capped(&p.product), "guard denied bug creation");
note_refused(&ctx);
return Ok(err_text(Guard::create_denial()));
}
if let Some(cell) = audit_cell(&ctx) {
// may_create judges the request as a whole and names no rule.
cell.note_verdict(Verdict::Served);
}
match self.bz.create_bug(&key, payload).await {
Ok(v) => Ok(ok_json(v)),
Err(e) => {
// Uniform with the policy refusal above (see that comment);
// Bugzilla's message is logged server-side only — it can say
// whether a product or component exists.
tracing::warn!(error = %e, "create_bug: upstream refused");
Ok(err_text(Guard::create_denial()))
}
}
}
#[tool(
description = "Attach a file to a bug. Content must be base64-encoded. Subject to server policy on the target bug and to the server's attachment size limit.",
annotations(
read_only_hint = false,
destructive_hint = false,
idempotent_hint = false,
open_world_hint = true
)
)]
async fn add_attachment(
&self,
Parameters(p): Parameters<AddAttachmentParams>,
ctx: RequestContext<RoleServer>,
) -> Result<CallToolResult, McpError> {
tracing::info!(
bug_id = p.bug_id,
file_name = ?Capped(&p.file_name),
is_private = p.is_private,
"tool: add_attachment"
);
let key = self.api_key(&ctx)?;
let caller = self.guard.resolve_caller(&self.bz, &key).await;
if let Some(denied) = self
.deny_unless(&key, p.bug_id, Capability::Attach, caller.as_deref(), &ctx)
.await
{
return Ok(denied);
}
let cap = self.guard.policy.global.max_attachment_bytes;
if let Some(refusal) = upload_size_refusal(cap, &p.data) {
note_refused(&ctx);
return Ok(err_text(refusal));
}
let mut payload = serde_json::Map::new();
payload.insert("ids".to_string(), json!([p.bug_id]));
payload.insert("data".to_string(), json!(p.data));
payload.insert("file_name".to_string(), json!(p.file_name));
payload.insert("summary".to_string(), json!(p.summary));
payload.insert("content_type".to_string(), json!(p.content_type));
payload.insert("is_patch".to_string(), json!(p.is_patch));
payload.insert("is_private".to_string(), json!(p.is_private));
// Bug.add_attachment takes `comment` as a PLAIN string — unlike
// Bug.update's `{"comment": {"body": ...}}` shape, so the update
// helper (attach_comment) must not be reused here.
if !p.comment.is_empty() {
payload.insert("comment".to_string(), json!(p.comment));
}
let payload = Value::Object(payload);
match self.bz.add_attachment(&key, p.bug_id, payload).await {
Ok(v) => Ok(ok_json(v)),
Err(e) => {
tracing::warn!(bug_id = p.bug_id, error = %e, "add_attachment: upstream refused");
Ok(err_text("Failed to add attachment"))
}
}
}
#[tool(
description = "Add a comment to a bug. It can optionally be private. If success, returns the created comment id.",
annotations(
read_only_hint = false,
destructive_hint = false,
idempotent_hint = false,
open_world_hint = true
)
)]
async fn add_comment(
&self,
Parameters(p): Parameters<AddCommentParams>,
ctx: RequestContext<RoleServer>,
) -> Result<CallToolResult, McpError> {
tracing::info!(
bug_id = p.bug_id,
is_private = p.is_private,
comment_len = p.comment.len(),
"tool: add_comment"
);
let key = self.api_key(&ctx)?;
let caller = self.guard.resolve_caller(&self.bz, &key).await;
if let Some(denied) = self
.deny_unless(&key, p.bug_id, Capability::Comment, caller.as_deref(), &ctx)
.await
{
return Ok(denied);
}
match self
.bz
.add_comment(&key, p.bug_id, &p.comment, p.is_private)
.await
{
Ok(result) => Ok(ok_json(result)),
Err(e) => Ok(err_text(format!("Failed to create a comment\n{e}"))),
}
}
#[tool(
description = "Update the status of a bug. Optionally add a comment explaining the status change.\n\nStatuses and resolutions are instance-defined; use bug_fields with field_names: [\"bug_status\"] to list them along with their openness and legal transitions where the operator has enabled discovery. A closing status needs a resolution unless the bug already has one; Bugzilla clears the resolution itself when moving to an open status.",
annotations(
read_only_hint = false,
destructive_hint = true,
idempotent_hint = false,
open_world_hint = true
)
)]
async fn update_bug_status(
&self,
Parameters(p): Parameters<UpdateBugStatusParams>,
ctx: RequestContext<RoleServer>,
) -> Result<CallToolResult, McpError> {
tracing::info!(
bug_id = p.bug_id,
status = ?Capped(&p.status),
resolution = ?p.resolution.as_deref().map(Capped),
"tool: update_bug_status"
);
let key = self.api_key(&ctx)?;
let caller = self.guard.resolve_caller(&self.bz, &key).await;
if let Some(denied) = self
.deny_unless(&key, p.bug_id, Capability::Status, caller.as_deref(), &ctx)
.await
{
return Ok(denied);
}
let mut payload = serde_json::Map::new();
payload.insert("status".to_string(), json!(p.status));
if let Some(resolution) = p.resolution.as_deref().filter(|r| !r.is_empty()) {
payload.insert("resolution".to_string(), json!(resolution));
}
attach_comment(&mut payload, &p.comment);
match self
.bz
.update_bug(&key, p.bug_id, Value::Object(payload))
.await
{
Ok(result) => Ok(ok_json(result)),
Err(e) => Ok(err_text(format!("Failed to update bug status\n{e}"))),
}
}
#[tool(
description = "Assign a bug to a user. Optionally add a comment.",
annotations(
read_only_hint = false,
destructive_hint = true,
idempotent_hint = false,
open_world_hint = true
)
)]
async fn assign_bug(
&self,
Parameters(p): Parameters<AssignBugParams>,
ctx: RequestContext<RoleServer>,
) -> Result<CallToolResult, McpError> {
tracing::info!(
bug_id = p.bug_id,
assignee = ?Capped(&p.assignee),
"tool: assign_bug"
);
let key = self.api_key(&ctx)?;
let caller = self.guard.resolve_caller(&self.bz, &key).await;
if let Some(denied) = self
.deny_unless(&key, p.bug_id, Capability::Assign, caller.as_deref(), &ctx)
.await
{
return Ok(denied);
}
let mut payload = serde_json::Map::new();
payload.insert("assigned_to".to_string(), json!(p.assignee));
attach_comment(&mut payload, &p.comment);
match self
.bz
.update_bug(&key, p.bug_id, Value::Object(payload))
.await
{
Ok(result) => Ok(ok_json(result)),
Err(e) => Ok(err_text(format!("Failed to assign bug\n{e}"))),
}
}
#[tool(
description = "Update bug fields: priority, severity, resolution, summary, url, whiteboard, version, target_milestone, keywords (add/remove), see_also (add/remove; values are bug URLs), and custom 'cf_*' fields. All fields are optional, but at least one must be specified. Empty strings and empty lists are ignored; clearing a field is not supported. Custom field names must start with 'cf_' (e.g. {\"cf_fixed_in\": \"1.2.3\"}).",
annotations(
read_only_hint = false,
destructive_hint = true,
idempotent_hint = false,
open_world_hint = true
)
)]
async fn update_bug_fields(
&self,
Parameters(p): Parameters<UpdateBugFieldsParams>,
ctx: RequestContext<RoleServer>,
) -> Result<CallToolResult, McpError> {
// priority/severity/resolution are benign instance vocabulary and
// stay value-logged (capped, #240); every other param is logged as
// presence or count only — summaries, whiteboards and URLs can carry
// embargoed content, and the server log must not become a content
// sink (the audit stream's boundary rule, applied to tracing).
tracing::info!(
bug_id = p.bug_id,
priority = ?p.priority.as_deref().map(Capped),
severity = ?p.severity.as_deref().map(Capped),
resolution = ?p.resolution.as_deref().map(Capped),
summary = p.summary.is_some(),
url = p.url.is_some(),
whiteboard = p.whiteboard.is_some(),
version = p.version.is_some(),
target_milestone = p.target_milestone.is_some(),
keywords_add = p.keywords_add.as_ref().map_or(0, Vec::len),
keywords_remove = p.keywords_remove.as_ref().map_or(0, Vec::len),
see_also_add = p.see_also_add.as_ref().map_or(0, Vec::len),
see_also_remove = p.see_also_remove.as_ref().map_or(0, Vec::len),
custom_field_count = p.custom_fields.as_ref().map_or(0, |cf| cf.len()),
"tool: update_bug_fields"
);
let mut payload = serde_json::Map::new();
for (field, value) in [
("priority", &p.priority),
("severity", &p.severity),
("resolution", &p.resolution),
("summary", &p.summary),
("url", &p.url),
("whiteboard", &p.whiteboard),
("version", &p.version),
("target_milestone", &p.target_milestone),
] {
if let Some(v) = value.as_deref().filter(|s| !s.is_empty()) {
payload.insert(field.to_string(), json!(v));
}
}
if let Some(keywords) = list_change(p.keywords_add.as_ref(), p.keywords_remove.as_ref()) {
payload.insert("keywords".to_string(), keywords);
}
if let Some(see_also) = list_change(p.see_also_add.as_ref(), p.see_also_remove.as_ref()) {
payload.insert("see_also".to_string(), see_also);
}
if let Some(custom_fields) = &p.custom_fields {
// I7: only cf_* keys may pass through the generic updater. Error
// without calling Bugzilla otherwise.
for k in custom_fields.keys() {
if !k.starts_with("cf_") {
note_refused(&ctx);
return Ok(err_text(format!(
"Invalid custom field '{k}': custom field names must start with 'cf_'"
)));
}
}
for (k, v) in custom_fields {
payload.insert(k.clone(), v.clone());
}
}
if payload.is_empty() {
note_refused(&ctx);
return Ok(err_text("At least one field must be specified"));
}
attach_comment(&mut payload, &p.comment);
// I8/I14: a see_also entry that points at THIS Bugzilla is a bug-id
// link, so writing one is judged like the other link-writing paths
// (update_bug_dependencies, mark_as_duplicate/I11): the bug being
// updated needs `fields`, and every LOCAL see_also target must allow
// at least `summary` before the PUT. Without this, a see_also change
// would write links into policy-denied bugs — Bugzilla records the
// reciprocal entry on the target — and leak their existence through
// the difference between its success and "does not exist" responses
// (I2). Entries for other trackers carry no local bug id and are
// somebody else's to disclose; they pass through unassessed.
let base_url = self.bz.base_url();
let mut ids: Vec<u64> = vec![p.bug_id];
for list in [&p.see_also_add, &p.see_also_remove].into_iter().flatten() {
ids.extend(
list.iter()
.filter_map(|entry| Guard::see_also_local_id(entry, base_url)),
);
}
let mut seen = BTreeSet::new();
ids.retain(|id| seen.insert(*id));
if let Some(refusal) = too_many_ids(&ids) {
note_refused(&ctx);
return Ok(refusal);
}
let key = self.api_key(&ctx)?;
let caller = self.guard.resolve_caller(&self.bz, &key).await;
let cell = audit_cell(&ctx);
let assessments = self.assess(&key, &ids, caller.as_deref()).await;
let note = |id: u64, verdict: Verdict| {
if let Some(cell) = &cell {
match assessments.get(&id) {
Some((access, _)) => cell.note_verdict_rule(verdict, access_rule(access)),
None => cell.note_verdict(verdict),
}
}
};
let fields_ok = assessments
.get(&p.bug_id)
.is_some_and(|(access, _)| access.allows(Capability::Fields));
if !fields_ok {
tracing::info!(bug_id = p.bug_id, "guard denied operation");
note(p.bug_id, Verdict::Denied);
return Ok(err_text(Guard::denial(p.bug_id)));
}
for &id in ids.iter().filter(|&&id| id != p.bug_id) {
let target_ok = assessments
.get(&id)
.is_some_and(|(access, _)| access.allows(Capability::Summary));
if !target_ok {
tracing::info!(bug_id = id, "guard denied see_also target");
note(id, Verdict::Denied);
return Ok(err_text(Guard::denial(id)));
}
}
note(p.bug_id, Verdict::Served);
match self
.bz
.update_bug(&key, p.bug_id, Value::Object(payload))
.await
{
Ok(result) => Ok(ok_json(result)),
Err(e) => Ok(err_text(format!("Failed to update bug fields\n{e}"))),
}
}
#[tool(
description = "Update bug dependency relationships (blocks/depends_on). At least one change must be specified. Optionally add a comment explaining the changes.",
annotations(
read_only_hint = false,
destructive_hint = true,
idempotent_hint = false,
open_world_hint = true
)
)]
async fn update_bug_dependencies(
&self,
Parameters(p): Parameters<UpdateBugDependenciesParams>,
ctx: RequestContext<RoleServer>,
) -> Result<CallToolResult, McpError> {
// Four client-sized arrays sharing one `MAX_ASSESS_IDS` budget,
// and `too_many_ids` refuses only further down: a count plus a head
// no longer than that budget keeps the line bounded by its fields
// (#258).
tracing::info!(
bug_id = p.bug_id,
blocks_add_len = p.blocks_add.as_ref().map_or(0, Vec::len),
blocks_add = ?p.blocks_add.as_deref().map(logged_ids),
blocks_remove_len = p.blocks_remove.as_ref().map_or(0, Vec::len),
blocks_remove = ?p.blocks_remove.as_deref().map(logged_ids),
depends_on_add_len = p.depends_on_add.as_ref().map_or(0, Vec::len),
depends_on_add = ?p.depends_on_add.as_deref().map(logged_ids),
depends_on_remove_len = p.depends_on_remove.as_ref().map_or(0, Vec::len),
depends_on_remove = ?p.depends_on_remove.as_deref().map(logged_ids),
"tool: update_bug_dependencies"
);
let mut payload = serde_json::Map::new();
if let Some(blocks) = dep_change(p.blocks_add.as_ref(), p.blocks_remove.as_ref()) {
payload.insert("blocks".to_string(), blocks);
}
if let Some(depends_on) =
dep_change(p.depends_on_add.as_ref(), p.depends_on_remove.as_ref())
{
payload.insert("depends_on".to_string(), depends_on);
}
if payload.is_empty() {
note_refused(&ctx);
return Ok(err_text("At least one dependency change must be specified"));
}
attach_comment(&mut payload, &p.comment);
// I8: every bug id in the payload — the bug being updated AND every
// dependency target — must pass guard assessment before the PUT.
// Without this, blocks/depends_on changes would write links into
// policy-denied bugs and leak their existence through the difference
// between Bugzilla's success and "does not exist" responses (I2).
// Targets require at least `summary`, mirroring how I11 treats
// `duplicate_of`.
let mut ids: Vec<u64> = vec![p.bug_id];
for list in [
&p.blocks_add,
&p.blocks_remove,
&p.depends_on_add,
&p.depends_on_remove,
]
.into_iter()
.flatten()
{
ids.extend(list.iter().copied());
}
let mut seen = BTreeSet::new();
ids.retain(|id| seen.insert(*id));
if let Some(refusal) = too_many_ids(&ids) {
note_refused(&ctx);
return Ok(refusal);
}
let key = self.api_key(&ctx)?;
let caller = self.guard.resolve_caller(&self.bz, &key).await;
let cell = audit_cell(&ctx);
let assessments = self.assess(&key, &ids, caller.as_deref()).await;
let note = |id: u64, verdict: Verdict| {
if let Some(cell) = &cell {
match assessments.get(&id) {
Some((access, _)) => cell.note_verdict_rule(verdict, access_rule(access)),
None => cell.note_verdict(verdict),
}
}
};
let deps_ok = assessments
.get(&p.bug_id)
.is_some_and(|(access, _)| access.allows(Capability::Deps));
if !deps_ok {
tracing::info!(bug_id = p.bug_id, "guard denied operation");
note(p.bug_id, Verdict::Denied);
return Ok(err_text(Guard::denial(p.bug_id)));
}
for &id in ids.iter().filter(|&&id| id != p.bug_id) {
let target_ok = assessments
.get(&id)
.is_some_and(|(access, _)| access.allows(Capability::Summary));
if !target_ok {
tracing::info!(bug_id = id, "guard denied dependency target");
note(id, Verdict::Denied);
return Ok(err_text(Guard::denial(id)));
}
}
note(p.bug_id, Verdict::Served);
match self
.bz
.update_bug(&key, p.bug_id, Value::Object(payload))
.await
{
Ok(result) => Ok(ok_json(result)),
Err(e) => Ok(err_text(format!("Failed to update bug dependencies\n{e}"))),
}
}
#[tool(
description = "Add an email address to the CC list of a bug.",
annotations(
read_only_hint = false,
destructive_hint = false,
idempotent_hint = true,
open_world_hint = true
)
)]
async fn add_cc_to_bug(
&self,
Parameters(p): Parameters<AddCcParams>,
ctx: RequestContext<RoleServer>,
) -> Result<CallToolResult, McpError> {
tracing::info!(
bug_id = p.bug_id,
cc_email = ?Capped(&p.cc_email),
"tool: add_cc_to_bug"
);
let key = self.api_key(&ctx)?;
let caller = self.guard.resolve_caller(&self.bz, &key).await;
if let Some(denied) = self
.deny_unless(&key, p.bug_id, Capability::Cc, caller.as_deref(), &ctx)
.await
{
return Ok(denied);
}
let payload = json!({ "cc": { "add": [p.cc_email] } });
match self.bz.update_bug(&key, p.bug_id, payload).await {
Ok(result) => Ok(ok_json(result)),
Err(e) => Ok(err_text(format!("Failed to add CC\n{e}"))),
}
}
#[tool(
description = "Mark a bug as a duplicate of another bug. Bugzilla applies its configured duplicate status and the DUPLICATE resolution.",
annotations(
read_only_hint = false,
destructive_hint = true,
idempotent_hint = false,
open_world_hint = true
)
)]
async fn mark_as_duplicate(
&self,
Parameters(p): Parameters<MarkAsDuplicateParams>,
ctx: RequestContext<RoleServer>,
) -> Result<CallToolResult, McpError> {
tracing::info!(
bug_id = p.bug_id,
duplicate_of = p.duplicate_of,
"tool: mark_as_duplicate"
);
let key = self.api_key(&ctx)?;
// I11: `status` on bug_id AND at least `summary` on duplicate_of.
let ids = if p.bug_id == p.duplicate_of {
vec![p.bug_id]
} else {
vec![p.bug_id, p.duplicate_of]
};
let caller = self.guard.resolve_caller(&self.bz, &key).await;
let cell = audit_cell(&ctx);
let assessments = self.assess(&key, &ids, caller.as_deref()).await;
let note = |id: u64, verdict: Verdict| {
if let Some(cell) = &cell {
match assessments.get(&id) {
Some((access, _)) => cell.note_verdict_rule(verdict, access_rule(access)),
None => cell.note_verdict(verdict),
}
}
};
let status_ok = assessments
.get(&p.bug_id)
.is_some_and(|(access, _)| access.allows(Capability::Status));
if !status_ok {
note(p.bug_id, Verdict::Denied);
return Ok(err_text(Guard::denial(p.bug_id)));
}
let duplicate_ok = assessments
.get(&p.duplicate_of)
.is_some_and(|(access, _)| access.allows(Capability::Summary));
if !duplicate_ok {
note(p.duplicate_of, Verdict::Denied);
return Ok(err_text(Guard::denial(p.duplicate_of)));
}
note(p.bug_id, Verdict::Served);
let comment = if p.comment.is_empty() {
format!("Marking as duplicate of bug {}", p.duplicate_of)
} else {
p.comment.clone()
};
// Setting dupe_of alone is enough: Bugzilla's set_dup_id applies the
// instance's duplicate_or_move_bug_status and resolution DUPLICATE.
let mut payload = serde_json::Map::new();
payload.insert("dupe_of".to_string(), json!(p.duplicate_of));
attach_comment(&mut payload, &comment);
match self
.bz
.update_bug(&key, p.bug_id, Value::Object(payload))
.await
{
Ok(result) => Ok(ok_json(result)),
Err(e) => Ok(err_text(format!("Failed to mark as duplicate\n{e}"))),
}
}
#[tool(
description = "List the metadata of all attachments of a bug. Attachment data itself is excluded. Metadata of private attachments is not included by default but can be explicitly requested (subject to server policy).",
annotations(read_only_hint = true, open_world_hint = true)
)]
async fn list_attachments(
&self,
Parameters(p): Parameters<ListAttachmentsParams>,
ctx: RequestContext<RoleServer>,
) -> Result<CallToolResult, McpError> {
tracing::info!(
bug_id = p.bug_id,
include_private = p.include_private,
"tool: list_attachments"
);
let key = self.api_key(&ctx)?;
let caller = self.guard.resolve_caller(&self.bz, &key).await;
if let Some(denied) = self
.deny_unless(
&key,
p.bug_id,
Capability::Attachments,
caller.as_deref(),
&ctx,
)
.await
{
return Ok(denied);
}
match self.bz.attachments(&key, p.bug_id).await {
Ok(attachments) => {
// Private attachment metadata is gated by the same double
// opt-in as private comments (I5): the policy must allow it
// AND the call must ask for it.
let items = match attachments {
Value::Array(items) => items,
// Unexpected envelope shape: fail closed (I4) rather
// than passing unfiltered metadata through.
_ => Vec::new(),
};
let total = items.len();
let filtered = self.guard.filter_attachments(items, p.include_private);
if let Some(cell) = audit_cell(&ctx) {
// Filtered private attachments have no bug id of
// their own: count only.
cell.note_suppressed_count((total - filtered.len()) as u64);
}
Ok(ok_json(Value::Array(filtered)))
}
Err(e) => Ok(err_text(format!(
"Failed to fetch bug attachments\nReason: {e}"
))),
}
}
#[tool(
description = "Download the content of a single attachment by its attachment id (see list_attachments). Raster images are returned as image content, everything else as a base64 blob resource. For text-ish content types (any text/* plus a small application/* allowlist: json, xml, javascript, x-sh, x-ndjson, yaml), the optional head_lines / tail_lines / max_chars parameters instead return the decoded content windowed — first and/or last lines of the full text, the result then capped in characters — as a plain text block, with truncation counts added to the JSON summary; on any other content type the parameters are ignored (noted in the summary) and the attachment is served normally. Subject to server policy: a size limit applies, and private attachments must be requested explicitly.",
annotations(read_only_hint = true, open_world_hint = true)
)]
async fn download_attachment(
&self,
Parameters(p): Parameters<DownloadAttachmentParams>,
ctx: RequestContext<RoleServer>,
) -> Result<CallToolResult, McpError> {
tracing::info!(
attachment_id = p.attachment_id,
include_private = p.include_private,
"tool: download_attachment"
);
let key = self.api_key(&ctx)?;
// Metadata first, never the blob: it names the owning bug for guard
// assessment and feeds the private/size gates, all BEFORE any content
// is pulled (I8). Every refusal on this path is the uniform
// attachment denial (I2) — a fetch error, an unknown id, a denied
// owning bug and a private attachment must be indistinguishable.
let meta = match self.bz.attachment_meta(&key, p.attachment_id).await {
Ok(Some(meta)) => Some(meta),
Ok(None) => None,
Err(e) => {
tracing::debug!(
attachment_id = p.attachment_id,
error = %e,
"attachment metadata fetch failed"
);
None
}
};
// Bug id 0 never exists, so the classification below is a pure
// constant-cost stand-in when there is no metadata. Without it the
// "no metadata" paths would issue one upstream request and the
// "metadata found" paths two, letting a client time its calls to
// learn which attachment ids exist — the very oracle the uniform
// denial closes (I2).
let assess_id = meta
.as_ref()
.and_then(|m| m.get("bug_id"))
.and_then(Value::as_u64)
.unwrap_or(0);
// Resolved whatever `assess_id` turned out to be: the whoami count
// is a function of the policy alone, never of what the metadata
// fetch found (I2).
let caller = self.guard.resolve_caller(&self.bz, &key).await;
let assessments = self.assess(&key, &[assess_id], caller.as_deref()).await;
let allowed = assessments
.get(&assess_id)
.is_some_and(|(access, _)| access.allows(Capability::Attachments));
if let Some(cell) = audit_cell(&ctx) {
// Every uniform-denial path below upgrades this to Denied;
// the padding assessment against bug id 0 decides nothing,
// so no rule is attached there.
match assessments.get(&assess_id) {
_ if assess_id == 0 => cell.note_verdict(Verdict::Denied),
Some((access, _)) => cell.note_verdict_rule(
if allowed {
Verdict::Served
} else {
Verdict::Denied
},
access_rule(access),
),
None => cell.note_verdict(Verdict::Denied),
}
}
let Some(meta) = meta.filter(|_| allowed && assess_id != 0) else {
// Missing metadata, missing bug id, or a denied owning bug: one
// uniform denial. The bug-level denial text is deliberately NOT
// reused — it would confirm which bug owns the attachment.
return Ok(err_text(Guard::attachment_denial(p.attachment_id)));
};
// Withheld content after a granted assessment is still the guard
// deciding: every refusal below upgrades the noted verdict to
// Denied (the note changes no response byte).
let note_denied = || {
if let Some(cell) = audit_cell(&ctx) {
cell.note_verdict(Verdict::Denied);
}
};
if let Some(refusal) = self.guard.attachment_gate(&meta, p.include_private) {
note_denied();
return Ok(err_text(refusal));
}
let attachment = match self.bz.attachment_data(&key, p.attachment_id).await {
Ok(Some(att)) => att,
// A failed blob fetch gets the same uniform denial as an unknown
// id: the upstream status and message would otherwise distinguish
// the two and disclose server detail.
Ok(None) => {
note_denied();
return Ok(err_text(Guard::attachment_denial(p.attachment_id)));
}
Err(e) => {
tracing::debug!(
attachment_id = p.attachment_id,
error = %e,
"attachment data fetch failed"
);
note_denied();
return Ok(err_text(Guard::attachment_denial(p.attachment_id)));
}
};
// The gate ran on the metadata response; the bytes come from a second,
// later request. Re-run it on what actually arrived and re-check the
// owning bug, so an attachment that turns private (or moves to another
// bug) between the two calls cannot be served.
if attachment.get("bug_id").and_then(Value::as_u64) != Some(assess_id) {
note_denied();
return Ok(err_text(Guard::attachment_denial(p.attachment_id)));
}
if let Some(refusal) = self.guard.attachment_gate(&attachment, p.include_private) {
note_denied();
return Ok(err_text(refusal));
}
let Some(blob) = attachment
.get("data")
.and_then(Value::as_str)
.map(str::to_owned)
else {
note_refused(&ctx);
return Ok(err_text("Attachment has no data"));
};
// Defense in depth: the size gate trusted the upstream-REPORTED size;
// re-check the payload itself so a wrong or lying `size` cannot
// smuggle an oversized blob past the cap. The decoded length is
// computed exactly (4 base64 chars per 3 bytes, minus the padding)
// so the cap stays inclusive, matching the metadata gate.
let cap = self.guard.policy.global.max_attachment_bytes;
if cap > 0 && decoded_len(&blob) > cap {
note_denied();
return Ok(err_text(format!(
"Attachment {} exceeds the size limit of this server",
p.attachment_id
)));
}
let mime = attachment
.get("content_type")
.and_then(Value::as_str)
.unwrap_or("application/octet-stream")
.to_string();
let file_name = attachment
.get("file_name")
.and_then(Value::as_str)
.unwrap_or("attachment")
.to_string();
let mut summary = json!({
"id": p.attachment_id,
"bug_id": assess_id,
"file_name": file_name,
"content_type": mime,
"size": attachment.get("size").cloned().unwrap_or(Value::Null),
});
// Windowing is a presentation concern and runs LAST, on the decoded
// text of a payload that already passed both attachment gates and the
// size re-check (I8) — it never widens what may be fetched, and it is
// never consulted on a denial path (I2). It applies only to text-ish
// content the client asked to window: params on an image or binary
// payload are noted as ignored rather than errored, and no params at
// all is byte-identical to the un-windowed response.
let windowed = if p.head_lines.is_some() || p.tail_lines.is_some() || p.max_chars.is_some()
{
if is_windowable_text(&mime) {
match decode_attachment_text(&blob) {
Some(full) => Some(window_text(&full, p.head_lines, p.tail_lines, p.max_chars)),
// Non-canonical base64: the whole payload ships as a blob.
// Say so, or a caller that asked for three lines cannot
// tell its window was dropped on the floor.
None => {
summary["windowing_ignored"] = json!("payload is not valid base64");
None
}
}
} else {
summary["windowing_ignored"] = json!("not a text content type");
None
}
} else {
None
};
let content = match windowed {
Some(window) => {
if window.cut_anything() {
if let Some(cell) = audit_cell(&ctx) {
cell.note_redacted_client_window("attachment_window");
}
}
summary["truncation"] = json!({
"total_lines": window.total_lines,
"shown_lines": window.shown_lines,
"truncated_chars": window.truncated_chars,
});
ContentBlock::text(window.text)
}
None if is_inline_image(&mime) => ContentBlock::image(blob, mime),
None => {
// The uri carries only the attachment id: `file_name` is chosen by
// whoever uploaded the attachment and may contain `../`, control
// characters, or query/fragment syntax. It is reported in the
// summary block above, where it is inert.
ContentBlock::resource(ResourceContents::BlobResourceContents {
uri: format!("bugzilla://attachment/{}", p.attachment_id),
mime_type: Some(mime),
blob,
meta: None,
})
}
};
Ok(CallToolResult::success(vec![
ContentBlock::text(serde_json::to_string(&summary).unwrap_or_default()),
content,
]))
}
#[tool(
description = "Returns the bug url",
annotations(read_only_hint = true, open_world_hint = false)
)]
fn bug_url(&self, Parameters(p): Parameters<BugUrlParams>) -> Result<CallToolResult, McpError> {
tracing::info!(bug_id = p.bug_id, "tool: bug_url");
// I8 exception: computes a URL string locally, contacts nothing.
let url = format!("{}/show_bug.cgi?id={}", self.bz.base_url(), p.bug_id);
Ok(ok_json(json!({ "url": url })))
}
#[tool(
description = "Returns comprehensive bugzilla server information (url, version, extensions, timezone, time, parameters).",
annotations(read_only_hint = true, open_world_hint = true)
)]
async fn bugzilla_server_info(
&self,
ctx: RequestContext<RoleServer>,
) -> Result<CallToolResult, McpError> {
tracing::info!("tool: bugzilla_server_info");
let key = self.api_key(&ctx)?;
match self.bz.server_info(&key).await {
Ok(info) => Ok(ok_json(info)),
Err(e) => Ok(err_text(format!(
"Failed to fetch bugzilla server info\nReason: {e}"
))),
}
}
#[tool(
description = "Lists this Bugzilla instance's products, or fetches detail for up to 5 named products (components, versions, milestones). Returned exactly as Bugzilla reports it to this server's key — never filtered by this server's guard policy. Only present when the operator enabled global.allow_discovery.",
annotations(read_only_hint = true, open_world_hint = true)
)]
async fn bugzilla_products(
&self,
Parameters(p): Parameters<BugzillaProductsParams>,
ctx: RequestContext<RoleServer>,
) -> Result<CallToolResult, McpError> {
tracing::info!(product_count = p.products.len(), "tool: bugzilla_products");
let key = self.api_key(&ctx)?;
if p.products.len() > MAX_DISCOVERY_NAMES {
note_refused(&ctx);
return Ok(err_text(format!(
"At most {MAX_DISCOVERY_NAMES} products per call"
)));
}
if p.products.is_empty() {
let ids = match self.bz.enterable_product_ids(&key).await {
Ok(ids) => ids,
Err(e) => return Ok(err_text(format!("Failed to fetch products\nReason: {e}"))),
};
if ids.is_empty() {
return Ok(ok_json(json!({ "products": [] })));
}
match self
.bz
.products(&key, &ids, &[], Some(&["id", "name"]))
.await
{
Ok(v) => Ok(ok_json(json!({ "products": project_product_catalog(&v) }))),
Err(e) => Ok(err_text(format!("Failed to fetch products\nReason: {e}"))),
}
} else {
let names: Vec<&str> = p.products.iter().map(String::as_str).collect();
match self.bz.products(&key, &[], &names, None).await {
Ok(v) => Ok(ok_json(json!({ "products": project_product_detail(&v) }))),
Err(e) => Ok(err_text(format!("Failed to fetch products\nReason: {e}"))),
}
}
}
#[tool(
description = "Lists this Bugzilla instance's bug fields (without legal values), or fetches detail for up to 5 named fields including their legal values. Returned exactly as Bugzilla reports it to this server's key — never filtered by this server's guard policy. Only present when the operator enabled global.allow_discovery.",
annotations(read_only_hint = true, open_world_hint = true)
)]
async fn bug_fields(
&self,
Parameters(p): Parameters<BugFieldsParams>,
ctx: RequestContext<RoleServer>,
) -> Result<CallToolResult, McpError> {
tracing::info!(
field_count = p.field_names.len(),
on_bug_entry_only = p.on_bug_entry_only,
"tool: bug_fields"
);
let key = self.api_key(&ctx)?;
if p.field_names.len() > MAX_DISCOVERY_NAMES {
note_refused(&ctx);
return Ok(err_text(format!(
"At most {MAX_DISCOVERY_NAMES} field names per call"
)));
}
if p.field_names.is_empty() {
match self.bz.bug_fields(&key, None).await {
Ok(v) => Ok(ok_json(json!({
"fields": project_field_catalog(&v, p.on_bug_entry_only)
}))),
Err(e) => Ok(err_text(format!("Failed to fetch bug fields\nReason: {e}"))),
}
} else {
let mut fields = Vec::with_capacity(p.field_names.len());
for name in &p.field_names {
let v = match self.bz.bug_fields(&key, Some(name)).await {
Ok(v) => v,
Err(e) => {
return Ok(err_text(format!("Failed to fetch bug fields\nReason: {e}")))
}
};
match v
.get("fields")
.and_then(Value::as_array)
.and_then(|f| f.first())
{
Some(field) => fields.push(project_field_detail(field)),
None => {
return Ok(err_text(format!(
"Failed to fetch bug fields\nReason: no such field '{name}'"
)))
}
}
}
Ok(ok_json(json!({ "fields": fields })))
}
}
#[tool(
description = "Access the documentation of the bugzilla quicksearch syntax. LLM can learn using this tool. Response is in HTML. Note: through this server's bugs_quicksearch the status filter is prefixed to the query, so under any non-empty status (the default is ALL) a number in the query is content-matched as text; the syntax page's jump-to-bug-number shortcut applies only when status is empty and the query is nothing but numbers. Look up known bug ids with the bug_info tool.",
annotations(read_only_hint = true, open_world_hint = true)
)]
async fn quicksearch_syntax(&self) -> Result<CallToolResult, McpError> {
tracing::info!("tool: quicksearch_syntax");
match self.bz.quicksearch_syntax_html().await {
Ok(html) => Ok(CallToolResult::success(vec![ContentBlock::text(html)])),
Err(e) => Ok(err_text(format!(
"Failed to fetch quicksearch documentation: {e}"
))),
}
}
#[tool(
description = "Returns information about this MCP server instance (name, version, bugzilla server, transport) and a summary of the active guard policy.",
annotations(read_only_hint = true, open_world_hint = false)
)]
fn mcp_server_info(&self) -> Result<CallToolResult, McpError> {
tracing::info!("tool: mcp_server_info");
// I1: expose only rule count, default_action, min_bug_age_days,
// read_only, and disabled tool names — never rule names or match
// criteria.
let policy = &self.guard.policy;
Ok(ok_json(json!({
// The same two constants the handshake sends, so a client
// cannot be told one identity by `initialize` and another by
// this tool.
"name": SERVER_NAME,
"version": SERVER_VERSION,
"bugzilla_server": self.bz.base_url(),
"transport": match self.cfg.transport {
Transport::Http => "http",
Transport::Stdio => "stdio",
},
"policy": {
"rule_count": policy.rules.len(),
"default_action": action_name(policy.default_action),
"min_bug_age_days": policy.global.min_bug_age_days,
"read_only": policy.global.read_only,
"disabled_tools": policy.global.disabled_tools,
},
})))
}
#[tool(
description = "Summarizes all the comments of a bug. Returns a prompt to be used for summarization.",
annotations(read_only_hint = true, open_world_hint = true)
)]
async fn summarize_bug(
&self,
Parameters(p): Parameters<SummarizeBugParams>,
ctx: RequestContext<RoleServer>,
) -> Result<CallToolResult, McpError> {
tracing::info!(id = p.id, "tool: summarize_bug");
let key = self.api_key(&ctx)?;
let caller = self.guard.resolve_caller(&self.bz, &key).await;
if let Some(denied) = self
.deny_unless(&key, p.id, Capability::Comments, caller.as_deref(), &ctx)
.await
{
return Ok(denied);
}
let comments = match self.bz.bug_comments(&key, p.id, None).await {
Ok(comments) => comments,
Err(e) => {
return Ok(err_text(format!("Summarize Comments Failed\nReason: {e}")));
}
};
let total = comments.len();
let comments = self.guard.filter_comments(comments, false);
// Same scrub as bug_comments: otherwise a client that would be
// scrubbed there just asks for a summary instead (I14).
// LOAD-BEARING, as in bug_comments: `comments` is already the
// FILTERED list here. Harvesting marker ids from the pre-filter one
// would count a dropped private comment in both tallies, which the
// record ships as two disjoint fields for a reader to add (#68).
let named = Guard::duplicate_marker_ids(&comments);
let disclosable = self
.guard
.disclosable(&self.bz, &key, &named, caller.as_deref())
.await;
if let Some(cell) = audit_cell(&ctx) {
cell.note_suppressed_count((total - comments.len()) as u64);
let hidden: Vec<u64> = named.difference(&disclosable).copied().collect();
if !hidden.is_empty() {
cell.note_suppressed(hidden);
}
}
let comments = Guard::scrub_duplicate_markers(comments, &disclosable);
let comments_json = serde_json::to_string(&comments).unwrap_or_else(|_| "[]".to_string());
let prompt = format!(
"You are an expert in summarizing bugzilla comments.\n\
Rules to follow:\n\
- Summary must be well structured & eye catching\n\
- Mention usernames & dates wherever relevant.\n\
- date field must be in human readable format\n\
- Usernames must be bold italic (***username***) dates must be bold (**date**)\n\
\n\
Comments Data:\n\
{comments_json}"
);
Ok(CallToolResult::success(vec![ContentBlock::text(prompt)]))
}
}
// Hand-written handler impl. `call_tool`, `list_tools` and `get_tool` keep
// the exact semantics the `#[tool_handler]` macro would generate for them;
// hand-writing exists so `call_tool` can own the audit record and
// `initialize` the initialize record. Dispatch through `self.tool_router`
// is load-bearing: the instance router has the write tools / disabled
// tools removed (I13); the macro default, `Self::tool_router()`, would
// rebuild an unpruned router. `list_tools` is not audited: no event kind
// exists for a listing — deliberate, and schema v2's growth rule would let
// one be added without a version bump.
impl ServerHandler for BugWarden {
async fn initialize(
&self,
request: InitializeRequestParams,
context: RequestContext<RoleServer>,
) -> Result<InitializeResult, McpError> {
// Negotiate before anything stores or records it: both the record
// and `peer_info` must name the revision the session SPEAKS, not
// the one the client asked for. Echo a requested revision this
// build serves, keep the server default otherwise — the same shape
// the SDK's own negotiation has, tested against
// `SUPPORTED_PROTOCOL_VERSIONS` rather than the wider set of
// revisions the SDK merely knows about.
let mut info = self.get_info();
if SUPPORTED_PROTOCOL_VERSIONS.contains(&request.protocol_version) {
info.protocol_version = request.protocol_version.clone();
} else {
tracing::warn!(
client_requested = ?Capped(request.protocol_version.as_str()),
server_fallback = %info.protocol_version,
"client requested unsupported protocol version; falling back to server default"
);
}
// The default handler's peer bookkeeping, replicated — with the
// NEGOTIATED revision substituted. rmcp corrects `peer_info` to the
// negotiated value only on its own handshake path
// (`service/server.rs`, and `NegotiatingStatelessHttpService` for
// stateless http); a SECOND `initialize` mid-session reaches this
// handler through `serve_inner` with no correction behind it and no
// re-init gate in front of it. Storing the request verbatim let a
// stdio client raise its own session's `protocol_version()` to any
// string it liked — `ProtocolVersion` deserializes unknown values
// through — while the answer it was handed said otherwise.
let mut negotiated = request.clone();
negotiated.protocol_version = info.protocol_version.clone();
context.peer.set_peer_info(negotiated);
if let Some(audit) = &self.audit {
// Every `initialize` is recorded, unconditionally — no
// configuration knob: a stream that could omit session
// starts could not anchor its tool records to a client. Over
// http a 2026-07-28 `initialize` takes the stateless route
// and opens no session, so the anchor is written with no id;
// unconditional is still the invariant, and joins are on id
// equality, so a record with no id matches nothing rather than
// the wrong thing.
let event = audit::AuditEventKind::Initialize(audit::InitializeEvent {
client: audit::ClientInfo {
name: Some(capped(&request.client_info.name)),
version: Some(capped(&request.client_info.version)),
principal: None,
work_context: None,
},
protocol_version: Some(info.protocol_version.as_str().to_string()),
});
let session = self.session_info(&context, audit);
if record_event(audit, event, session).await.is_err()
&& audit.fail_mode == FailMode::ClosedAll
{
// Only ClosedAll refuses the handshake: initialize is
// neither a write nor a guard verdict, so the other two
// modes proceed and let the gap marker account for the
// loss.
return Err(McpError::internal_error("audit unavailable", None));
}
}
Ok(info)
}
/// The revisions this handler serves, narrowing the SDK's default of
/// every revision it knows (see `SUPPORTED_PROTOCOL_VERSIONS`). The
/// SDK consults this on the handshake, on the stateless request path
/// and in `server/discover`, so the whole surface narrows with it.
fn supported_protocol_versions(&self) -> Cow<'static, [ProtocolVersion]> {
Cow::Borrowed(SUPPORTED_PROTOCOL_VERSIONS)
}
async fn call_tool(
&self,
request: CallToolRequestParams,
mut context: RequestContext<RoleServer>,
) -> Result<CallToolResponse, McpError> {
let started = Instant::now();
if lifecycle_of(&context) == Lifecycle::OutOfContract {
// Recorded before it is refused, like every other turned-away
// call: a stream that went quiet for a whole request class
// could not be read as a complete account. `client` is absent
// by policy, not ignorance: on the stateless path `peer_info`
// is rmcp's placeholder, in-session it is the real handshake
// identity, and one refusal class must not be recorded two
// ways. `session.id` is still present when there is one:
// this refusal is broader than rmcp's stateless routing (see
// `lifecycle_of`), so a declaration that took the
// session branch arrives with its real id stamped, while a
// stateless one had no session to stamp (#180).
if let Some(audit) = self.audit.clone() {
let event = audit::AuditEventKind::ToolCall(Box::new(audit::ToolCallEvent {
client: audit::ClientInfo {
name: None,
version: None,
principal: None,
work_context: None,
},
trace: None,
request: audit::RequestInfo {
tool: capped(&request.name),
id: Some(capped(&context.id.to_string())),
params: allowlisted(request.arguments.as_ref()),
},
guard: None,
upstream: None,
outcome: audit::OutcomeInfo {
class: audit::OutcomeClass::Error,
duration_ms: elapsed_ms(started),
// A protocol error, so no result to size.
response_bytes: None,
},
}));
let session = self.session_info(&context, &audit);
let _ = record_event(&audit, event, session).await;
}
return Err(mixed_lifecycle_refused());
}
// Bearer scope gate (issue #32): a tool this credential does not
// reach is refused as unrouted, so the read scope hides the write
// tools exactly the way read-only mode hides them (I13). Decided
// here, before dispatch and before any upstream request.
let reachable = self.reaches(&request.name, &context);
// Auditing off: what the macro-generated dispatch answers, plus the
// panic recovery `dispatch` adds to both paths alike (#253).
let Some(audit) = self.audit.clone() else {
if !reachable {
return Err(tool_not_found());
}
return self.dispatch(request, context).await;
};
// Raw: the router, the fail-mode gate and `WRITE_TOOLS` must match
// the name the client actually sent. Every record site below caps
// it instead (#243).
let tool = request.name.to_string();
let session = self.session_info(&context, &audit);
let client = client_of(&context);
// Record-only cap: rmcp answers off the id it parsed, so nothing
// here can shorten the reply's (#248).
let request_id = Some(capped(&context.id.to_string()));
// Allowlist BEFORE dispatch: the record needs the params after
// the router has consumed the request, and allowlisting first
// avoids cloning free-text and blob values that would be reduced
// to a byte length anyway.
let params = allowlisted(request.arguments.as_ref());
// Trace enrichment (issue #28), extracted BEFORE the router
// consumes `request`. The rmcp trap: over every serialized
// transport the wire `params._meta` (SEP-414) does NOT arrive in
// `CallToolRequestParams.meta` — the SDK's custom `Request`
// deserializer strips `_meta` out of the params before the
// params struct sees it, parks it in the request extensions, and
// the serve loop hands it to the handler as `context.meta`. The
// params-struct field is populated only by in-process callers
// that never serialize. Read both, params struct first, so the
// value is found wherever the SDK put it. Strictly validated,
// fail-to-absent, never logged; the value enriches the record
// only — it must never influence the guard, the refusal
// decision, or the response (I15).
let trace = request
.meta
.as_ref()
.and_then(|m| m.get_traceparent())
.or_else(|| context.meta.get_traceparent())
.and_then(audit::TraceContext::from_traceparent);
// Pre-dispatch gate: a sink already in failure holds back further
// unaudited work, scoped by the fail mode. The refusal is
// recorded best-effort — succeed or not, the call is refused:
// the gate exists to stop unaudited work, not to retry its way
// open. The refusal depends on the tool name alone, never on
// anything the guard decided (the guard never ran).
// Both guards keep the gate off names the router would not have
// dispatched anyway. Without them the gate answers a name with its
// own text where a healthy sink answers "tool not found", so sink
// health — and, through it, whether the deployment serves a tool at
// all — is readable off which refusal comes back: `reachable` covers
// a write tool the caller's scope hides, `has_route` an unknown name
// and a tool this policy removed (read-only, disabled_tools,
// allow_discovery). A name the gate never sees is refused as
// unrouted below, identically under every fail mode.
if reachable
&& self.tool_router.has_route(&tool)
&& audit.sink.failing()
&& gate_applies(audit.fail_mode, &tool)
{
let event = audit::AuditEventKind::ToolCall(Box::new(audit::ToolCallEvent {
client,
trace: trace.clone(),
// Capped like every record site, though the `has_route`
// above means this one only ever sees a served name.
request: audit::RequestInfo {
tool: capped(&tool),
id: request_id,
params,
},
guard: Some(audit::GuardInfo {
verdict: Verdict::Refused,
rule: None,
policy_hash: audit.policy_hash.clone(),
suppressed_ids_count: 0,
suppressed_other_count: 0,
suppressed_ids: Vec::new(),
redacted_fields: Vec::new(),
// The guard never ran, so no window scan did either.
scan: None,
}),
upstream: None,
outcome: audit::OutcomeInfo {
class: audit::OutcomeClass::Refused,
duration_ms: elapsed_ms(started),
// The refusal is built below, after this record: the
// gate must not spend a serialization to size a text
// whose length is fixed by `audit_refusal` anyway.
response_bytes: None,
},
}));
let _ = record_event(&audit, event, session).await;
return Ok(audit_refusal(&tool).into());
}
let cell = Arc::new(AuditCell::default());
context.extensions.insert(Arc::clone(&cell));
// Upstream accounting for the whole dispatch (issue #118). Scoped
// here, not per handler: a handler cannot see the guard's classify
// fetches, `resolve_caller`'s whoami, the search window's chunk
// scan, or create_bug's padding classify, so only the client's own
// choke point counts them all.
let stats = Arc::new(UpstreamStats::default());
// Recorded like any other call, refused or not: exactly one record,
// with no guard verdict and no upstream leg, the same shape an
// unknown tool name produces.
let result = if reachable {
// `with_upstream_stats` scopes the task local OUTSIDE the
// catcher, so a handler that panics still had it — and the legs
// it managed to make are counted into the record below (#253).
let dispatch = self.dispatch(request, context);
with_upstream_stats(Arc::clone(&stats), dispatch).await
} else {
Err(tool_not_found())
};
// Exactly one record per call, whatever `result` is — including
// an unknown tool or another protocol error from the router.
// Zero requests leaves the block absent: "Bugzilla was never
// contacted" is what its absence means, so it must not be a zero.
if stats.requests() > 0 {
cell.note_upstream(audit::UpstreamInfo {
requests: stats.requests(),
status: stats.status(),
latency_ms: stats.latency_ms(),
});
}
let upstream = cell.take_upstream();
let guard = cell.into_guard_info(audit.policy_hash.as_deref(), audit.sink.suppressed_ids());
let guard_verdict = guard.as_ref().map(|g| g.verdict);
let class = match &result {
// A guard denial is a well-formed response (the uniform
// denial text): its `is_error` marker classifies the
// content, not the call, so the outcome stays `ok` and the
// denial lives in guard.verdict. Only a completed call
// carries that marker; the other `CallToolResponse` variants
// belong to revisions this build does not serve.
Ok(CallToolResponse::Complete(r))
if r.is_error == Some(true) && guard_verdict != Some(Verdict::Denied) =>
{
audit::OutcomeClass::Refused
}
Ok(_) => audit::OutcomeClass::Ok,
Err(_) => audit::OutcomeClass::Error,
};
let event = audit::AuditEventKind::ToolCall(Box::new(audit::ToolCallEvent {
client,
trace,
request: audit::RequestInfo {
tool: capped(&tool),
id: request_id,
params,
},
guard,
upstream,
outcome: audit::OutcomeInfo {
class,
duration_ms: elapsed_ms(started),
// Sizes the DISPATCHED result. A record that persisted but
// whose `record_event` still failed (fsync, or an export
// refusal after the file write) can be followed by a
// fail-mode arm below swapping the response for a refusal,
// leaving this describing the result the client did not
// get — exactly as `class` above already does.
response_bytes: response_bytes(&result),
},
}));
match record_event(&audit, event, session).await {
// Persisted before the response is returned.
Ok(_) => result,
Err(()) => match (audit.fail_mode, &result) {
// A protocol error from the router stands under every
// mode: the record was attempted, an unknown tool is
// unknown with or without auditing, and swapping the
// error for a tool-level refusal would CREATE a
// distinguisher that exists only during an audit outage.
// A recovered panic (#253) arrives here as one of those
// errors and stands for the same reasons: the handler
// produced no result, so there is nothing a refusal could
// withhold, and its text is fixed under every fail mode.
(_, Err(_)) => result,
// The sink has rate-limit-logged the failure; the gap
// marker accounts for the loss after recovery.
(FailMode::Open, _) => result,
(FailMode::ClosedWritesDenials, _)
if WRITE_TOOLS.contains(&tool.as_str())
|| matches!(
guard_verdict,
Some(Verdict::Denied | Verdict::Refused | Verdict::ServedFiltered)
) =>
{
Ok(audit_refusal(&tool).into())
}
// A read the guard fully allowed serves unaudited.
(FailMode::ClosedWritesDenials, _) => result,
(FailMode::ClosedAll, _) => Ok(audit_refusal(&tool).into()),
},
}
}
async fn list_tools(
&self,
_request: Option<PaginatedRequestParams>,
context: RequestContext<RoleServer>,
) -> Result<ListToolsResult, McpError> {
// Refused on the same terms as a tool call: the listing is pruned
// per deployment (I13), so answering one of these would disclose
// which tools this policy removed. Unrecorded, as
// every listing is — the schema has no event kind for one.
if lifecycle_of(&context) == Lifecycle::OutOfContract {
return Err(mixed_lifecycle_refused());
}
// Filtered, not listed-then-refused: the write tools are simply not
// there for a read-scope credential, which is I13's shape applied to
// the caller instead of the deployment.
let mut tools = self.tool_router.list_all();
match self.reach(&context) {
Reach::Everything => {}
Reach::ReadOnly => tools.retain(|tool| read_scope_serves(&tool.name)),
Reach::Nothing => tools.clear(),
}
// The SEP-2549 hints are ours to gate: rmcp 3.1.4 strips
// `resultType` for a legacy peer and leaves these two alone. The
// predicate is HAND-COPIED from its `sep_2322_supported` — no shared
// constant, no test pinning the agreement — so re-read both on an
// rmcp bump. `None`, meaning no request revision and no session, is
// legacy.
let serves_cache_hints = context
.protocol_version()
.is_some_and(|v| v.as_str() >= ProtocolVersion::V_2026_07_28.as_str());
Ok(ListToolsResult {
tools,
result_type: Some(ResultType::COMPLETE),
meta: None,
next_cursor: None,
// `Private`/immediately stale because this listing is pruned
// per deployment (I13) and per credential (the bearer scope
// above), and `CacheScope::default()` is `Public`. Both
// fields stay written by name.
ttl_ms: serves_cache_hints.then_some(0),
cache_scope: serves_cache_hints.then_some(CacheScope::Private),
})
}
fn get_tool(&self, name: &str) -> Option<Tool> {
self.tool_router.get(name).cloned()
}
fn get_info(&self) -> ServerInfo {
ServerInfo::new(ServerCapabilities::builder().enable_tools().build())
.with_protocol_version(DEFAULT_PROTOCOL_VERSION)
.with_server_info(server_identity())
.with_instructions(
"MCP server for Bugzilla. Provides tools to search bugs \
(bugs_quicksearch), read bug details, comments, history, and \
attachment metadata, and — unless running read-only — update \
bugs (comments, status, fields, assignee, CC, dependencies, \
duplicates). Access is governed by an operator-controlled \
policy: some bugs may be unavailable or redacted to a summary \
view (marked with '_redacted'), and some operations may be \
refused. A reply that a bug 'is not accessible through this \
server' is final; it does not indicate whether the bug \
exists, and retrying will not help."
.to_string(),
)
}
}
#[cfg(test)]
mod tests {
/// Policy denying anything in an embargo group, allowing the rest.
fn embargo_guard() -> Guard {
Guard {
policy: Policy::from_toml_str(concat!(
"default_action = \"allow\"\n",
"[[rule]]\nname = \"embargo\"\naction = \"deny\"\n",
"[rule.match]\ngroups = [\"embargo*\"]\n",
))
.expect("policy parses"),
}
}
fn granted(caps: &[Capability]) -> Access {
Access::Granted {
caps: caps.iter().copied().collect(),
rule: "test".into(),
}
}
fn body(id: u64, groups: &[&str]) -> Value {
json!({
"id": id,
"summary": "a bug",
"groups": groups,
"product": "P",
"component": "C",
"status": "NEW",
"creation_time": "2020-01-01T00:00:00Z",
"depends_on": [1, 2],
})
}
#[test]
fn bug_info_serves_a_body_that_still_passes() {
let g = embargo_guard();
let assessments = BTreeMap::from([(7u64, (granted(&[Capability::Read]), Value::Null))]);
let full = BTreeMap::from([(7u64, body(7, &[]))]);
let out = assemble_bug_info(&g, &[7], &assessments, &full, None);
assert_eq!(out["bugs"].as_array().unwrap().len(), 1);
assert_eq!(out["bugs"][0]["id"], json!(7));
// The full body is served intact, not a redacted view.
assert_eq!(out["bugs"][0]["depends_on"], json!([1, 2]));
assert!(out["restricted"].as_array().unwrap().is_empty());
}
#[test]
fn bug_info_refuses_a_body_embargoed_after_the_verdict() {
// The race the re-check exists for: classification said Read, then the
// bug was moved into an embargo group before the body was fetched.
// The server's key is privileged enough that Bugzilla still returns
// it, so only the re-check can catch this.
let g = embargo_guard();
let assessments = BTreeMap::from([(7u64, (granted(&[Capability::Read]), Value::Null))]);
let full = BTreeMap::from([(7u64, body(7, &["embargo-security"]))]);
let out = assemble_bug_info(&g, &[7], &assessments, &full, None);
assert!(
out["bugs"].as_array().unwrap().is_empty(),
"a body that no longer passes must not be served"
);
assert_eq!(out["restricted"][0]["id"], json!(7));
assert_eq!(out["restricted"][0]["note"], json!(Guard::denial(7)));
}
#[test]
fn bug_info_denial_is_identical_for_hidden_missing_and_nonexistent() {
// I2: whichever way a bug fails to be served, the client sees the
// same bytes. Here: re-check refusal, body absent from the response
// (fetch failure or upstream omission), and a policy denial.
let g = embargo_guard();
let assessments = BTreeMap::from([
(1u64, (granted(&[Capability::Read]), Value::Null)),
(2u64, (granted(&[Capability::Read]), Value::Null)),
(
3u64,
(
Access::Denied {
rule: "embargo".into(),
},
Value::Null,
),
),
]);
// 1 turns out embargoed, 2 never arrived, 3 was denied up front.
let full = BTreeMap::from([(1u64, body(1, &["embargo-x"]))]);
let out = assemble_bug_info(&g, &[1, 2, 3], &assessments, &full, None);
assert!(out["bugs"].as_array().unwrap().is_empty());
let entries = out["restricted"].as_array().unwrap();
assert_eq!(entries.len(), 3);
for (entry, id) in entries.iter().zip([1u64, 2, 3]) {
assert_eq!(entry["id"], json!(id));
assert_eq!(
entry["note"],
json!(Guard::denial(id)),
"every failure mode yields the same note"
);
assert_eq!(
entry.as_object().unwrap().len(),
2,
"no extra field distinguishes the cases"
);
}
}
#[test]
fn bug_info_downgrades_to_a_summary_view_when_the_body_only_earns_summary() {
// A body that now classifies summary-only is served as the summary
// view a fresh call would return — not refused, not served in full.
let g = Guard {
policy: Policy::from_toml_str(concat!(
"default_action = \"allow\"\n",
"[[rule]]\nname = \"peek\"\naction = \"restrict\"\n",
"capabilities = [\"summary\"]\n",
"[rule.match]\ngroups = [\"internal\"]\n",
))
.expect("policy parses"),
};
let assessments = BTreeMap::from([(9u64, (granted(&[Capability::Read]), Value::Null))]);
let full = BTreeMap::from([(9u64, body(9, &["internal"]))]);
let out = assemble_bug_info(&g, &[9], &assessments, &full, None);
let served = &out["bugs"][0];
assert_eq!(served["_redacted"], json!(true), "served redacted");
assert!(
served.get("depends_on").is_none(),
"the full body must not survive a downgrade: {served}"
);
}
#[test]
fn bug_info_refuses_a_body_that_now_grants_neither_read_nor_summary() {
// The third outcome of the re-check: a body that still classifies,
// but to a grant that conveys no way to view it at all.
let g = Guard {
policy: Policy::from_toml_str(concat!(
"default_action = \"allow\"\n",
"[[rule]]\nname = \"comments-only\"\naction = \"restrict\"\n",
"capabilities = [\"comments\"]\n",
"[rule.match]\ngroups = [\"internal\"]\n",
))
.expect("policy parses"),
};
let assessments = BTreeMap::from([(3u64, (granted(&[Capability::Read]), Value::Null))]);
let full = BTreeMap::from([(3u64, body(3, &["internal"]))]);
let out = assemble_bug_info(&g, &[3], &assessments, &full, None);
assert!(out["bugs"].as_array().unwrap().is_empty());
assert_eq!(out["restricted"][0]["note"], json!(Guard::denial(3)));
}
#[test]
fn bug_info_mixes_served_and_restricted_in_request_order() {
// A realistic call: one body survives the re-check, one does not, one
// was denied up front. Order follows the request, and every id is
// accounted for exactly once.
let g = embargo_guard();
let assessments = BTreeMap::from([
(5u64, (granted(&[Capability::Read]), Value::Null)),
(6u64, (granted(&[Capability::Read]), Value::Null)),
(
8u64,
(
Access::Denied {
rule: "embargo".into(),
},
Value::Null,
),
),
]);
let full = BTreeMap::from([(5u64, body(5, &[])), (6u64, body(6, &["embargo-late"]))]);
let out = assemble_bug_info(&g, &[5, 6, 8], &assessments, &full, None);
assert_eq!(out["bugs"].as_array().unwrap().len(), 1);
assert_eq!(out["bugs"][0]["id"], json!(5));
let restricted = out["restricted"].as_array().unwrap();
assert_eq!(
restricted
.iter()
.map(|r| r["id"].as_u64().unwrap())
.collect::<Vec<_>>(),
vec![6, 8],
"restricted follows request order"
);
}
#[test]
fn bug_info_summary_grants_are_served_from_the_classified_object() {
// Summary-only grants never involve the second fetch, so there is no
// window to re-check: the object the verdict was made on is served.
let g = embargo_guard();
let meta = body(4, &[]);
let assessments = BTreeMap::from([(4u64, (granted(&[Capability::Summary]), meta.clone()))]);
let out = assemble_bug_info(&g, &[4], &assessments, &BTreeMap::new(), None);
assert_eq!(out["bugs"][0]["id"], json!(4));
assert_eq!(out["bugs"][0]["_redacted"], json!(true));
}
#[test]
fn id_cap_counts_distinct_ids_and_reads_only_the_request() {
// The refusal must depend on how many DISTINCT bugs were named and on
// nothing else — never on which bugs they are (I1/I2).
let at_cap: Vec<u64> = (1..=Guard::MAX_ASSESS_IDS as u64).collect();
assert!(
too_many_ids(&at_cap).is_none(),
"exactly the bound is allowed"
);
let over: Vec<u64> = (1..=Guard::MAX_ASSESS_IDS as u64 + 1).collect();
let refusal = too_many_ids(&over).expect("over the bound must be refused");
let text = format!("{:?}", refusal.content);
assert!(
text.contains(&Guard::MAX_ASSESS_IDS.to_string()),
"refusal states the limit: {text}"
);
// Repeats cost one fetch, so they must not count towards the bound.
let repeated = vec![7u64; Guard::MAX_ASSESS_IDS * 4];
assert!(
too_many_ids(&repeated).is_none(),
"naming one bug many times is one bug"
);
assert!(too_many_ids(&[]).is_none());
}
#[test]
fn id_list_advisory_fires_on_pure_id_lists_only() {
// Pure id lists: commas and/or whitespace, optional '#' per id.
assert!(id_list_advisory("123456", "ALL").is_some());
assert!(id_list_advisory("#123456", "ALL").is_some());
assert!(id_list_advisory("111, 222,333", "ALL").is_some());
assert!(id_list_advisory("#111 #222\t333,", "ALL").is_some());
// Anything else is a content search and gets no note.
assert!(id_list_advisory("", "ALL").is_none());
assert!(id_list_advisory(" ,\t", "ALL").is_none());
assert!(id_list_advisory("#", "ALL").is_none());
assert!(id_list_advisory("kernel crash 123", "ALL").is_none());
assert!(id_list_advisory("123x", "ALL").is_none());
assert!(id_list_advisory("product:openSUSE 42", "ALL").is_none());
}
#[test]
fn id_list_advisory_wording_tracks_status_and_id_count() {
// Non-empty status: the tool prefixes it, upstream content-matches.
let prefixed = id_list_advisory("101, 102", "ALL").expect("note");
assert!(prefixed.contains("matches bug text"), "{prefixed}");
assert!(!prefixed.contains("id lookup"), "{prefixed}");
// Empty status: the query goes upstream bare, where an all-number
// query is an exact id lookup — no content-matching claim there.
let bare = id_list_advisory("101, 102", "").expect("note");
assert!(bare.contains("exact id lookup"), "{bare}");
assert!(!bare.contains("matches bug text"), "{bare}");
assert!(bare.contains("bug_info"), "{bare}");
// Over bug_info's per-call cap the steering mentions batching
// instead of walking the client into the too_many_ids refusal; the
// cap value is already public in that refusal's own text.
let over: Vec<String> = (1..=Guard::MAX_ASSESS_IDS as u64 + 1)
.map(|i| i.to_string())
.collect();
let long = id_list_advisory(&over.join(" "), "ALL").expect("note");
assert!(
long.contains(&format!("at most {} ids", Guard::MAX_ASSESS_IDS)),
"{long}"
);
assert!(long.contains("batch"), "{long}");
// At the cap — and for repeated spellings of one id — no batching
// talk: the count is of distinct ids, like the refusal it averts.
let at_cap: Vec<String> = (1..=Guard::MAX_ASSESS_IDS as u64)
.map(|i| i.to_string())
.collect();
let short = id_list_advisory(&at_cap.join(" "), "ALL").expect("note");
assert!(!short.contains("batch"), "{short}");
let repeats = id_list_advisory(&"7 07 #7 ".repeat(30), "ALL").expect("note");
assert!(!repeats.contains("batch"), "{repeats}");
}
use super::*;
use crate::pinned_cli::pinned;
use bugwarden_core::policy::Policy;
fn parts(policy: &str) -> (Arc<Cli>, Arc<Guard>, Arc<BugzillaClient>) {
let cfg: Arc<Cli> = Arc::new(pinned(&[
"bugwarden",
"--bugzilla-server",
"https://bugzilla.example.com",
"--transport",
"stdio",
"--api-key",
"test-key",
]));
let guard = Arc::new(Guard {
policy: Policy::from_toml_str(policy).expect("test policy must parse"),
});
let bz = Arc::new(
BugzillaClient::new("https://bugzilla.example.com", false, USER_AGENT)
.expect("client must build"),
);
(cfg, guard, bz)
}
#[test]
fn new_rejects_unknown_disabled_tool() {
// A typo in disabled_tools must be a hard startup error, never a
// silent fail-open leaving the tool exposed.
let (cfg, guard, bz) = parts("[global]\ndisabled_tools = [\"no_such_tool\"]\n");
let err = match BugWarden::new(cfg, guard, bz) {
Err(e) => e,
Ok(_) => panic!("unknown disabled tool must be a startup error"),
};
let msg = format!("{err:#}");
assert!(msg.contains("no_such_tool"), "unexpected error: {msg}");
assert!(msg.contains("unknown tool"), "unexpected error: {msg}");
}
#[test]
fn new_fails_at_construction_for_stdio_without_a_key() {
// Key custody is resolved by BugWarden::new on every construction
// path: stdio without any key source must fail at startup, never at
// the first request. main.rs no longer duplicates this bail.
// No key source in the argv, and the pin keeps the environment
// from supplying one.
let cli: Cli = pinned(&[
"bugwarden",
"--bugzilla-server",
"https://bugzilla.example.com",
"--transport",
"stdio",
]);
let guard = Arc::new(Guard {
policy: Policy::from_toml_str("").expect("empty policy parses"),
});
let bz = Arc::new(
BugzillaClient::new("https://bugzilla.example.com", false, USER_AGENT)
.expect("client must build"),
);
let err = match BugWarden::new(Arc::new(cli), guard, bz) {
Err(e) => e,
Ok(_) => panic!("stdio without a key must fail at construction"),
};
let msg = format!("{err:#}");
assert!(msg.contains("--transport stdio requires"), "{msg}");
}
#[test]
fn server_held_custody_with_identity_policy_warns_at_startup() {
// Under server-held http custody every client whoamis as the service
// account that owns the key, so a created_by_me policy written for
// per-request custody silently changes meaning — construction must
// say so. A policy that never consults identity stays quiet.
use std::io::Write as _;
let mut file = tempfile::NamedTempFile::new().expect("temp key file");
file.write_all(b"srv-key\n").expect("write key file");
let identity_policy = concat!(
"[[rule]]\nname = \"own-reports\"\naction = \"allow\"\n",
"[rule.match]\ncreated_by_me = true\n",
);
for (policy, expect_warn) in [(identity_policy, true), ("", false)] {
let mut cli: Cli = pinned(&[
"bugwarden",
"--bugzilla-server",
"https://bugzilla.example.com",
"--transport",
"http",
]);
cli.api_key_file = Some(file.path().to_path_buf());
let guard = Arc::new(Guard {
policy: Policy::from_toml_str(policy).expect("test policy must parse"),
});
let bz = Arc::new(
BugzillaClient::new("https://bugzilla.example.com", false, USER_AGENT)
.expect("client must build"),
);
let (server, logs) =
crate::testlog::capture_logs(|| BugWarden::new(Arc::new(cli), guard, bz));
drop(server.expect("server must build"));
// The `false` arm asserts an absence; as_str has already
// rejected a silent capture, so this is "not emitted" not
// "nothing captured".
crate::testlog::assert_captured(&logs);
let text = logs.as_str();
assert_eq!(
text.contains("created_by_me describes that one account"),
expect_warn,
"startup logs: {text}"
);
}
}
#[test]
fn new_rejects_declared_identity_under_per_request_custody() {
// A5: a declared login names the account owning the SERVER's key.
// Per-request http custody holds no server-side key at all, so
// there is nothing for the declared login to describe — this must
// be a hard startup error, unlike whoami's warn-and-continue.
// No key source in the argv, and none from the environment: custody
// stays per-request.
let cli: Cli = pinned(&[
"bugwarden",
"--bugzilla-server",
"https://bugzilla.example.com",
"--transport",
"http",
]);
let guard = Arc::new(Guard {
policy: Policy::from_toml_str(concat!(
"[global]\n",
"identity_source = \"declared\"\n",
"identity_login = \"svc@example.com\"\n",
"[[rule]]\nname = \"mine\"\naction = \"allow\"\n",
"[rule.match]\ncreated_by_me = true\n",
))
.expect("test policy must parse"),
});
let bz = Arc::new(
BugzillaClient::new("https://bugzilla.example.com", false, USER_AGENT)
.expect("client must build"),
);
let err = match BugWarden::new(Arc::new(cli), guard, bz) {
Err(e) => e,
Ok(_) => panic!("declared identity under per-request custody must fail to build"),
};
let msg = format!("{err:#}");
assert!(msg.contains("declared"), "{msg}");
assert!(msg.contains("per-request"), "{msg}");
}
#[test]
fn new_allows_declared_identity_under_server_held_custody() {
// The companion positive case: a server-held key is exactly what a
// declared login describes, so construction must succeed.
let cli: Cli = pinned(&[
"bugwarden",
"--bugzilla-server",
"https://bugzilla.example.com",
"--transport",
"stdio",
"--api-key",
"test-key",
]);
let guard = Arc::new(Guard {
policy: Policy::from_toml_str(concat!(
"[global]\n",
"identity_source = \"declared\"\n",
"identity_login = \"svc@example.com\"\n",
"[[rule]]\nname = \"mine\"\naction = \"allow\"\n",
"[rule.match]\ncreated_by_me = true\n",
))
.expect("test policy must parse"),
});
let bz = Arc::new(
BugzillaClient::new("https://bugzilla.example.com", false, USER_AGENT)
.expect("client must build"),
);
BugWarden::new(Arc::new(cli), guard, bz).expect("server must build");
}
#[test]
fn advertised_protocol_versions_are_the_ones_this_build_serves() {
let (cfg, guard, bz) = parts("");
let server = BugWarden::new(cfg, guard, bz).expect("server must build");
assert_eq!(
server.supported_protocol_versions().as_ref(),
SUPPORTED_PROTOCOL_VERSIONS,
"the handler must advertise this build's list, never inherit the SDK's"
);
assert!(
SUPPORTED_PROTOCOL_VERSIONS.contains(&ProtocolVersion::V_2026_07_28),
"the handshake-free revision is served: its records name the calling \
client from the request's own `_meta`, never rmcp's synthesised \
peer (issue #34 stage 2)"
);
assert!(
SUPPORTED_PROTOCOL_VERSIONS.contains(&DEFAULT_PROTOCOL_VERSION),
"the negotiation fallback must itself be a revision this build serves"
);
assert_eq!(
server.get_info().protocol_version,
DEFAULT_PROTOCOL_VERSION,
"the advertised default is pinned here, not taken from the SDK's LATEST"
);
for version in SUPPORTED_PROTOCOL_VERSIONS {
assert!(
ProtocolVersion::KNOWN_VERSIONS.contains(version),
"the SDK cannot serve {version}, which this build advertises"
);
}
}
#[test]
fn new_removes_valid_disabled_tool_i13() {
let (cfg, guard, bz) = parts("[global]\ndisabled_tools = [\"update_bug_dependencies\"]\n");
let server = BugWarden::new(cfg, guard, bz).expect("valid tool name must be accepted");
assert!(!server.tool_router.has_route("update_bug_dependencies"));
assert!(server.tool_router.has_route("bug_info"));
}
#[test]
fn new_read_only_removes_write_tools_and_accepts_disabled_write_tool() {
// A write-tool name stays a valid disabled_tools entry even though
// read-only mode removes its route first (validation runs against the
// full router).
let (cfg, guard, bz) =
parts("[global]\nread_only = true\ndisabled_tools = [\"add_comment\"]\n");
let server = BugWarden::new(cfg, guard, bz).expect("write tool name must stay valid");
for name in WRITE_TOOLS {
assert!(
!server.tool_router.has_route(name),
"write tool {name} must be removed in read-only mode (I13)"
);
}
assert!(server.tool_router.has_route("bug_info"));
}
#[test]
fn strip_detail_fields_drops_only_detail_keys() {
let mut bug = json!({
"id": 7,
"assigned_to": "dev@example.com",
"assigned_to_detail": {"id": 5, "real_name": "Dev"},
"cc_detail": [],
"summary": "plain",
});
strip_detail_fields(&mut bug);
let obj = bug.as_object().expect("bug object");
assert!(obj.contains_key("assigned_to"));
assert!(!obj.contains_key("assigned_to_detail"));
assert!(!obj.contains_key("cc_detail"));
assert!(obj.contains_key("summary"));
assert!(obj.contains_key("id"));
}
#[test]
fn strip_detail_fields_leaves_non_objects_alone() {
let mut not_an_object = json!("just a string");
strip_detail_fields(&mut not_an_object);
assert_eq!(not_an_object, json!("just a string"));
}
#[test]
fn parse_group_by_dedupes_and_reads_an_empty_spec_as_no_grouping() {
assert_eq!(
parse_group_by(" status , product ,status").expect("valid spec"),
vec!["status", "product"],
"a repeat is not a second key"
);
// Not an error: `""` is what a client filling in every declared
// param sends, and the handler maps an empty key list to the flat
// response rather than failing the whole search.
for spec in ["", " ", " , ", ",,"] {
assert_eq!(
parse_group_by(spec).expect("an empty spec is not a refusal"),
Vec::<&str>::new(),
"spec {spec:?}"
);
}
}
#[test]
fn parse_group_by_refuses_an_unknown_name() {
// Silently ignoring a typo would look like the feature is broken.
let err = parse_group_by("product,assigned_to").expect_err("unknown field");
assert!(err.contains("assigned_to"), "names what the caller sent");
assert!(err.contains("product"), "lists the vocabulary");
// Case-sensitive on purpose: the error lists the vocabulary, so a
// wrong case self-corrects on the next call.
assert!(parse_group_by("Product").is_err());
}
#[test]
fn group_by_vocabulary_is_fetched_and_survives_redaction() {
// A change detector first: the two memberships below hold for the
// whole CLASSIFY ∩ SUMMARY intersection, `summary` and `id`
// included, so they license nothing on their own. The list is
// hand-picked and adding to it is a decision, not a refactor.
assert_eq!(
GROUP_BY_FIELDS,
[
"product",
"component",
"status",
"resolution",
"severity",
"priority"
]
);
// The invariant that makes grouping free of policy consequences: every
// key is already classified (no extra upstream field) and already on a
// summary-redacted row (so it groups like any other row).
for f in GROUP_BY_FIELDS {
assert!(
bugwarden_core::client::CLASSIFY_FIELDS
.split(',')
.any(|c| c == *f),
"{f} must be in CLASSIFY_FIELDS"
);
assert!(
bugwarden_core::guard::SUMMARY_FIELDS.contains(f),
"{f} must be in SUMMARY_FIELDS"
);
}
}
#[test]
fn group_bugs_hoists_keys_and_orders_by_first_appearance() {
// B before A on purpose: first appearance and sorted order disagree
// here, so sorting the groups fails this test instead of passing it.
let rows = vec![
json!({ "id": 1, "product": "B", "status": "NEW" }),
json!({ "id": 2, "product": "A", "status": "NEW" }),
json!({ "id": 3, "product": "B", "status": "NEW" }),
];
let groups = group_bugs(rows, &["product"]);
assert_eq!(
groups,
vec![
json!({ "product": "B", "bugs": [
{ "id": 1, "status": "NEW" },
{ "id": 3, "status": "NEW" },
]}),
json!({ "product": "A", "bugs": [{ "id": 2, "status": "NEW" }]}),
],
"grouped keys leave the rows, and B precedes A because bug 1 did"
);
}
#[test]
fn group_headers_serialize_with_keys_sorted_and_bugs_first() {
// serde_json has no `preserve_order` here, so its Map is a BTreeMap:
// the caller's group_by order never reaches the wire. Pinned because
// the tool description promises clients a concrete shape, and two
// specs differing only in order must not look like two features.
let rows = vec![json!({ "id": 1, "product": "P", "status": "NEW" })];
let by_one = serde_json::to_string(&group_bugs(rows.clone(), &["status", "product"]))
.expect("serializable");
assert_eq!(
by_one,
r#"[{"bugs":[{"id":1}],"product":"P","status":"NEW"}]"#
);
assert_eq!(
by_one,
serde_json::to_string(&group_bugs(rows, &["product", "status"])).expect("serializable"),
"group_by order is not observable; do not promise it"
);
}
#[test]
fn group_bugs_buckets_a_missing_key_under_null() {
// `project_fields` omits absent keys, so a row can reach here without
// one; every header still has to be the same shape.
let rows = vec![json!({ "id": 1 }), json!({ "id": 2, "product": "A" })];
let groups = group_bugs(rows, &["product"]);
assert_eq!(
groups,
vec![
json!({ "product": null, "bugs": [{ "id": 1 }]}),
json!({ "product": "A", "bugs": [{ "id": 2 }]}),
]
);
}
#[test]
fn cap_comment_text_counts_chars_not_bytes() {
let mut comments = vec![json!({ "id": 1, "text": "日本語".repeat(40) })];
assert!(cap_comment_text(&mut comments, 100));
assert_eq!(comments[0]["text"].as_str().unwrap().chars().count(), 100);
assert_eq!(
comments[0]["text_truncated"],
json!({ "shown_chars": 100, "total_chars": 120 })
);
}
#[test]
fn cap_comment_text_leaves_short_comments_and_non_objects_alone() {
let mut comments = vec![
json!({ "id": 1, "text": "short" }),
json!("not an object"),
json!({ "id": 2 }),
];
assert!(!cap_comment_text(&mut comments, 100));
assert!(comments[0].get("text_truncated").is_none());
}
/// `n` list items, told apart by a sequence number.
fn seq_items(n: u64) -> Vec<Value> {
(0..n).map(|n| json!({ "seq": n })).collect()
}
fn seqs(shown: &[Value]) -> Vec<u64> {
shown.iter().map(|e| e["seq"].as_u64().unwrap()).collect()
}
#[test]
fn window_list_with_neither_param_keeps_everything() {
// Unreachable from either handler (both gate it away), but the
// merged helper must not read "no window" as "window of nothing".
let (shown, omitted) = window_list(seq_items(5), None, None);
assert_eq!(seqs(&shown), vec![0, 1, 2, 3, 4]);
assert_eq!(omitted, 0);
}
#[test]
fn window_list_head_keeps_the_first_entries() {
let (shown, omitted) = window_list(seq_items(5), Some(2), None);
assert_eq!(seqs(&shown), vec![0, 1]);
assert_eq!(omitted, 3);
}
#[test]
fn window_list_tail_keeps_the_last_entries() {
let (shown, omitted) = window_list(seq_items(5), None, Some(2));
assert_eq!(seqs(&shown), vec![3, 4]);
assert_eq!(omitted, 3);
}
#[test]
fn window_list_head_and_tail_keep_both_ends() {
let (shown, omitted) = window_list(seq_items(5), Some(1), Some(2));
assert_eq!(seqs(&shown), vec![0, 3, 4]);
assert_eq!(omitted, 2);
let (shown, omitted) = window_list(seq_items(6), Some(1), Some(2));
assert_eq!(seqs(&shown), vec![0, 4, 5]);
assert_eq!(omitted, 3);
}
#[test]
fn window_list_overlapping_windows_omit_nothing() {
// head + tail >= len keeps everything; the overlap is not
// double-counted and the envelope reports zeros.
for (head, tail) in [(3, 3), (5, 0), (0, 5), (2, 3), (10, 1)] {
let (shown, omitted) = window_list(seq_items(5), Some(head), Some(tail));
assert_eq!(
seqs(&shown),
vec![0, 1, 2, 3, 4],
"head={head} tail={tail} covers all five entries"
);
assert_eq!(omitted, 0, "head={head} tail={tail} omits nothing");
}
let (shown, omitted) = window_list(seq_items(3), Some(3), Some(3));
assert_eq!(seqs(&shown), vec![0, 1, 2], "no duplicated middle");
assert_eq!(omitted, 0);
}
#[test]
fn window_list_at_the_length_omits_nothing() {
let (shown, omitted) = window_list(seq_items(5), Some(5), None);
assert_eq!(seqs(&shown), vec![0, 1, 2, 3, 4]);
assert_eq!(omitted, 0);
let (shown, omitted) = window_list(seq_items(5), None, Some(6));
assert_eq!(seqs(&shown), vec![0, 1, 2, 3, 4]);
assert_eq!(omitted, 0);
}
#[test]
fn window_list_zero_windows_omit_everything() {
// A zero is a window of zero items, never "no limit"; absent is the
// only "no limit", and an absent side contributes nothing.
for (head, tail) in [(Some(0), Some(0)), (Some(0), None), (None, Some(0))] {
let (shown, omitted) = window_list(seq_items(5), head, tail);
assert!(shown.is_empty(), "head={head:?} tail={tail:?}");
assert_eq!(omitted, 5, "head={head:?} tail={tail:?}");
}
}
#[test]
fn window_list_huge_values_keep_everything_without_overflowing() {
for (head, tail) in [
(Some(u32::MAX), Some(u32::MAX)),
(Some(u32::MAX), None),
(None, Some(u32::MAX)),
] {
let (shown, omitted) = window_list(seq_items(5), head, tail);
assert_eq!(
seqs(&shown),
vec![0, 1, 2, 3, 4],
"head={head:?} tail={tail:?}"
);
assert_eq!(omitted, 0, "head={head:?} tail={tail:?}");
}
}
#[test]
fn window_list_over_an_empty_list_omits_nothing() {
for (head, tail) in [
(Some(3), Some(3)),
(Some(0), Some(0)),
(None, None),
(None, Some(2)),
] {
let (shown, omitted) = window_list(Vec::<Value>::new(), head, tail);
assert!(shown.is_empty(), "head={head:?} tail={tail:?}");
assert_eq!(omitted, 0, "head={head:?} tail={tail:?}");
}
}
#[test]
fn window_list_matches_the_reference_model_exhaustively() {
// The example tests above sample; this one proves. Every
// (len, head, tail) triple below is checked against a naive model, so
// an edit to the index arithmetic cannot pass by matching whichever
// cases someone happened to write down.
fn model(len: u64, head: Option<u32>, tail: Option<u32>) -> (Vec<u64>, usize) {
let all: Vec<u64> = (0..len).collect();
if head.is_none() && tail.is_none() {
return (all, 0);
}
let head = head.unwrap_or(0) as usize;
let tail = tail.unwrap_or(0) as usize;
if head.saturating_add(tail) >= all.len() {
return (all, 0);
}
let kept: Vec<u64> = all[..head]
.iter()
.chain(&all[all.len() - tail..])
.copied()
.collect();
let omitted = all.len() - kept.len();
(kept, omitted)
}
let sides = [
None,
Some(0),
Some(1),
Some(2),
Some(3),
Some(5),
Some(7),
Some(8),
Some(u32::MAX - 1),
Some(u32::MAX),
];
for len in 0..9u64 {
for head in sides {
for tail in sides {
let (shown, omitted) = window_list(seq_items(len), head, tail);
assert_eq!(
(seqs(&shown), omitted),
model(len, head, tail),
"len={len} head={head:?} tail={tail:?}"
);
}
}
}
}
#[test]
fn read_only_delists_create_bug_and_add_attachment_i13() {
// The filing tools are writes: read-only mode must remove them from
// the LISTING — call-time capability stripping alone is not I13.
let (cfg, guard, bz) = parts("[global]\nread_only = true\n");
let server = BugWarden::new(cfg, guard, bz).expect("server builds");
assert!(!server.tool_router.has_route("create_bug"));
assert!(!server.tool_router.has_route("add_attachment"));
// A default build serves both.
let (cfg, guard, bz) = parts("");
let server = BugWarden::new(cfg, guard, bz).expect("server builds");
assert!(server.tool_router.has_route("create_bug"));
assert!(server.tool_router.has_route("add_attachment"));
}
#[test]
fn write_tools_agrees_with_every_tool_read_only_hint() {
// Two descriptions of the same fact live in this file: `WRITE_TOOLS`,
// which read-only mode removes from the router (I13) and which the
// bearer read scope hides from one caller (#32), and each tool's own
// `read_only_hint` annotation, which is what an MCP client is told.
// They must agree tool for tool, or a tool added to one and forgotten
// in the other would be advertised read-only while the read scope
// still refuses it — or, worse, hidden from the annotation and served
// to a read token.
let (cfg, guard, bz) = parts("[global]\nallow_discovery = true\n");
let server = BugWarden::new(cfg, guard, bz).expect("server builds");
for tool in server.tool_router.list_all() {
let hinted_read_only = tool
.annotations
.as_ref()
.and_then(|a| a.read_only_hint)
.unwrap_or(false);
assert_eq!(
read_scope_serves(&tool.name),
hinted_read_only,
"{} is in WRITE_TOOLS xor annotated read_only_hint = true",
tool.name
);
}
}
#[test]
fn read_scope_serves_exactly_the_non_write_tools() {
for name in WRITE_TOOLS {
assert!(!read_scope_serves(name), "{name}");
}
for name in ["bug_info", "bugs_quicksearch", "bug_url", "mcp_server_info"] {
assert!(read_scope_serves(name), "{name}");
}
}
#[test]
fn discovery_tools_absent_by_default_present_when_enabled_i16() {
let (cfg, guard, bz) = parts("");
let server = BugWarden::new(cfg, guard, bz).expect("server builds");
for name in DISCOVERY_TOOLS {
assert!(
!server.tool_router.has_route(name),
"discovery tool {name} must be absent by default (I16)"
);
}
let (cfg, guard, bz) = parts("[global]\nallow_discovery = true\n");
let server = BugWarden::new(cfg, guard, bz).expect("server builds");
for name in DISCOVERY_TOOLS {
assert!(
server.tool_router.has_route(name),
"discovery tool {name} must be present under allow_discovery = true"
);
}
}
#[test]
fn disabled_tools_names_a_discovery_tool_with_discovery_off() {
// A discovery tool name stays a valid disabled_tools entry even
// though discovery-off removes its route first (validation runs
// against the full router, same ordering as read-only/I13).
let (cfg, guard, bz) = parts("[global]\ndisabled_tools = [\"bug_fields\"]\n");
let server = BugWarden::new(cfg, guard, bz).expect("discovery tool name must stay valid");
assert!(!server.tool_router.has_route("bug_fields"));
assert!(!server.tool_router.has_route("bugzilla_products"));
}
#[test]
fn get_tool_serves_the_pruned_instance_router_i13() {
// The definition lookup must consult the INSTANCE router `new`
// pruned, not a freshly built default one: a stripped tool has no
// definition to serve, exactly as it has no route to call.
let (cfg, guard, bz) =
parts("[global]\nread_only = true\ndisabled_tools = [\"bug_history\"]\n");
let server = BugWarden::new(cfg, guard, bz).expect("server builds");
assert!(
server.get_tool("add_comment").is_none(),
"a write tool stripped by read-only mode has no definition (I13)"
);
assert!(
server.get_tool("bug_history").is_none(),
"a policy-disabled tool has no definition (I13)"
);
assert!(
server.get_tool("bug_info").is_some(),
"a live tool keeps its definition"
);
}
/// Draft 2020-12 (and leftover draft-07) keywords that hold a subschema
/// and that [`portable_schema`] does not recurse into. Their presence in
/// a served schema fails this walk, proving they do not occur today.
const UNVISITED_SUBSCHEMA_KEYWORDS: &[&str] = &[
"anyOf",
"oneOf",
"allOf",
"$ref",
"$defs",
"definitions",
"not",
"if",
"then",
"else",
"contains",
"propertyNames",
"dependentSchemas",
"additionalItems",
"unevaluatedItems",
"unevaluatedProperties",
"contentSchema",
"$dynamicRef",
];
/// Recursive walk mirroring [`portable_schema`]'s own recursion, so the
/// assertion covers the same nodes the normalization pass touches.
///
/// Returns the first portability error instead of panicking so a canary
/// can assert that a planted dirty node is visited.
fn schema_portability_error(node: &Value, tool: &str) -> Option<String> {
let Value::Object(obj) = node else {
return None;
};
if matches!(obj.get("type"), Some(Value::Array(_))) {
return Some(format!("{tool}: schema still has a type union: {obj:?}"));
}
if obj.get("default") == Some(&Value::Null) {
return Some(format!(
"{tool}: schema still advertises \"default\": null: {obj:?}"
));
}
if let Some(format) = obj.get("format").and_then(Value::as_str) {
if !PORTABLE_FORMATS.contains(&format) {
return Some(format!(
"{tool}: schema advertises non-portable format {format:?}: {obj:?}"
));
}
}
for key in UNVISITED_SUBSCHEMA_KEYWORDS {
if obj.contains_key(*key) {
return Some(format!("{tool}: schema uses unsupported {key}: {obj:?}"));
}
}
// Recursion arms: keep in lockstep with `portable_schema`.
if let Some(properties) = obj.get("properties").and_then(Value::as_object) {
for prop in properties.values() {
if let Some(err) = schema_portability_error(prop, tool) {
return Some(err);
}
}
}
if let Some(items) = obj.get("items") {
match items {
Value::Array(elems) => {
for item in elems {
if let Some(err) = schema_portability_error(item, tool) {
return Some(err);
}
}
}
other => {
if let Some(err) = schema_portability_error(other, tool) {
return Some(err);
}
}
}
}
if let Some(additional) = obj.get("additionalProperties") {
if additional.is_object() {
if let Some(err) = schema_portability_error(additional, tool) {
return Some(err);
}
}
}
if let Some(patterns) = obj.get("patternProperties").and_then(Value::as_object) {
for schema in patterns.values() {
if let Some(err) = schema_portability_error(schema, tool) {
return Some(err);
}
}
}
if let Some(Value::Array(prefix)) = obj.get("prefixItems") {
for item in prefix {
if let Some(err) = schema_portability_error(item, tool) {
return Some(err);
}
}
}
None
}
fn assert_schema_is_client_portable(node: &Value, tool: &str) {
if let Some(err) = schema_portability_error(node, tool) {
panic!("{err}");
}
}
#[test]
fn served_tool_schemas_are_client_portable() {
let (cfg, guard, bz) = parts("[global]\nallow_discovery = true\n");
let server = BugWarden::new(cfg, guard, bz).expect("server builds");
for tool in server.tool_router.list_all() {
assert_schema_is_client_portable(
&Value::Object((*tool.input_schema).clone()),
&tool.name,
);
}
}
/// SEP-2243's schema annotation, byte-exact as rmcp 3.1.4 reads it —
/// `schema.get("x-mcp-header")` in `transport/common/mcp_headers.rs`
/// (`param_header_annotations`, `validate_param_header_annotations`,
/// `reject_nested_annotations`). Upstream exports no constant for the
/// spelling, so this is our own copy: re-grep the vendored tree on every
/// rmcp bump.
const X_MCP_HEADER: &str = "x-mcp-header";
/// The forbidden annotation anywhere in a serialized tool definition, as
/// `Some(reason)`.
///
/// A substring check, not a structural walk: rmcp's read is a byte-exact
/// map `get`, serde_json emits map keys verbatim, and no character of
/// this key is ever escaped — so substring-absence proves key-absence at
/// EVERY depth (`properties`, `items`, `$defs`, output schema, prose)
/// with no recursion arms to keep in lockstep, which is the maintenance
/// surface `schema_portability_error` above has to carry.
///
/// Deliberately broader than rmcp's functional read, which in 3.1.4 is
/// top-level `properties` and string-valued only: the key is banned
/// everywhere, at any value type, because upstream's depth rules are
/// version-specific. It will also trip on the substring appearing in a
/// description or a property name — accepted; a tripwire fails loud and
/// a human looks.
fn x_mcp_header_smell(name: &str, tool_json: &Value) -> Option<String> {
let serialized = tool_json.to_string();
serialized.contains(X_MCP_HEADER).then(|| {
format!("{name}: a served tool definition authors {X_MCP_HEADER}: {serialized}")
})
}
#[test]
fn no_served_tool_authors_an_x_mcp_header_annotation() {
// `x-mcp-header` is not a server switch. The client transport
// promotes an annotated tool parameter into an `Mcp-Param-*` header
// and the server cross-checks it, so nothing happens unless WE
// author the key in our own schemas: "never enable" reduces to
// "never author". #34 calls it I10's mirror — I10 stops a request
// header reaching a result; this would let a tool argument reach the
// transport layer.
//
// Two dynamic loops, no hardcoded tool list. The raw router is the
// unpruned superset every deployment prunes FROM and takes no
// config, so it is immune to the ambient environment; the built
// server is re-read through `get_tool`, the exact door rmcp's
// `Mcp-Param-*` validation opens (`tower.rs::tool_schema`), and is
// the only loop that would see an annotation injected at
// construction.
let mut unpruned = Vec::new();
for tool in BugWarden::tool_router().list_all() {
let json = serde_json::to_value(&tool).expect("a Tool serializes");
if let Some(err) = x_mcp_header_smell(&tool.name, &json) {
panic!("{err}");
}
unpruned.push(tool.name.to_string());
}
let (cfg, guard, bz) = parts("[global]\nallow_discovery = true\n");
let server = BugWarden::new(cfg, guard, bz).expect("server builds");
let mut served = Vec::new();
for tool in server.tool_router.list_all() {
let definition = server
.get_tool(&tool.name)
.unwrap_or_else(|| panic!("{}: a listed tool has a definition", tool.name));
for json in [
serde_json::to_value(&tool).expect("a Tool serializes"),
serde_json::to_value(&definition).expect("a Tool serializes"),
] {
if let Some(err) = x_mcp_header_smell(&tool.name, &json) {
panic!("{err}");
}
}
served.push(tool.name.to_string());
}
// `list_all` sorts by name, so these compare as sets. Binding them
// makes a tool added later behind a new opt-in knob break loudly
// instead of being skipped by the maximal build.
assert_eq!(
unpruned, served,
"the maximal deployment must serve the whole unpruned router"
);
// A floor, so an empty enumeration is not a silent pass.
for required in ["bug_info", "create_bug"] {
assert!(
served.iter().any(|name| name == required),
"{required} must be among the walked tools: {served:?}"
);
}
}
#[test]
fn the_x_mcp_header_tripwire_fires_at_every_planted_position() {
// The canary for the walk above, mirroring
// `mirror_walk_rejects_a_dirty_node_under_every_new_draft_position`:
// with zero annotations served, a gutted or inverted check would
// pass that walk forever. rmcp's real read site, a depth its own
// nested-reject never reaches, a position `portable_schema` never
// visits, and prose — the last pinning the over-trip as deliberate.
let planted = [
(
"top-level properties (rmcp's own read site)",
json!({
"inputSchema": {
"properties": { "p": { "type": "string", "x-mcp-header": "X-Canary" } }
}
}),
),
(
"nested under items -> properties",
json!({
"inputSchema": {
"properties": {
"p": {
"type": "array",
"items": {
"properties": {
"q": { "type": "string", "x-mcp-header": "X-Canary" }
}
}
}
}
}
}),
),
(
"$defs",
json!({
"inputSchema": {
"$defs": {
"Nested": {
"properties": {
"p": { "type": "string", "x-mcp-header": "X-Canary" }
}
}
}
}
}),
),
(
"a description",
json!({ "description": "set x-mcp-header to promote this parameter" }),
),
];
let missed: Vec<_> = planted
.iter()
.filter(|(_, tool)| x_mcp_header_smell("planted", tool).is_none())
.map(|(label, _)| *label)
.collect();
assert!(
missed.is_empty(),
"the tripwire missed {}; a planted annotation was not rejected",
missed.join(", ")
);
}
/// Non-portable node planted under each newly covered keyword. Live
/// `#[tool]` structs do not emit these positions today (the only
/// `additionalProperties` in served schemas is boolean `true`), so a
/// deleted recursion arm would not fail `served_tool_schemas_are_client_portable`.
fn planted_nonportable() -> Value {
json!({
"type": ["integer", "null"],
"default": null,
"format": "uint64",
})
}
struct SchemaCanary {
label: &'static str,
schema: Value,
child: fn(&Value) -> &Value,
}
fn new_position_canaries() -> [SchemaCanary; 4] {
[
SchemaCanary {
label: "additionalProperties",
schema: json!({ "additionalProperties": planted_nonportable() }),
child: |s| &s["additionalProperties"],
},
SchemaCanary {
label: "patternProperties",
schema: json!({ "patternProperties": { "^cf_": planted_nonportable() } }),
child: |s| &s["patternProperties"]["^cf_"],
},
SchemaCanary {
label: "prefixItems",
schema: json!({ "prefixItems": [planted_nonportable()] }),
child: |s| &s["prefixItems"][0],
},
SchemaCanary {
label: "array-form items",
schema: json!({ "items": [planted_nonportable()] }),
child: |s| &s["items"][0],
},
]
}
fn assert_node_normalized(label: &str, node: &Value) {
assert_eq!(
node.get("type"),
Some(&json!("integer")),
"{label}: type union was not collapsed: {node:?}"
);
assert_ne!(
node.get("default"),
Some(&Value::Null),
"{label}: default null was not dropped: {node:?}"
);
assert!(
node.get("format").is_none(),
"{label}: non-portable format was not dropped: {node:?}"
);
}
fn canary(label: &'static str) -> SchemaCanary {
new_position_canaries()
.into_iter()
.find(|c| c.label == label)
.unwrap_or_else(|| panic!("missing canary {label}"))
}
fn assert_portable_schema_normalizes(label: &'static str) {
let canary = canary(label);
let mut schema = canary.schema;
portable_schema(&mut schema);
assert_node_normalized(canary.label, (canary.child)(&schema));
}
#[test]
fn portable_schema_normalizes_additional_properties() {
assert_portable_schema_normalizes("additionalProperties");
}
#[test]
fn portable_schema_normalizes_pattern_properties() {
assert_portable_schema_normalizes("patternProperties");
}
#[test]
fn portable_schema_normalizes_prefix_items() {
assert_portable_schema_normalizes("prefixItems");
}
#[test]
fn portable_schema_normalizes_array_form_items() {
assert_portable_schema_normalizes("array-form items");
}
#[test]
fn mirror_walk_rejects_a_dirty_node_under_every_new_draft_position() {
let missed: Vec<_> = new_position_canaries()
.into_iter()
.filter(|c| schema_portability_error(&c.schema, c.label).is_none())
.map(|c| c.label)
.collect();
assert!(
missed.is_empty(),
"schema_portability_error must visit {}; \
the planted non-portable node was not rejected",
missed.join(", ")
);
}
#[test]
fn boolean_additional_properties_is_not_a_subschema() {
let mut schema = json!({
"type": "object",
"additionalProperties": true,
});
portable_schema(&mut schema);
assert_eq!(schema["additionalProperties"], true);
assert_schema_is_client_portable(&schema, "boolean-additionalProperties");
}
#[test]
fn bug_comments_new_since_is_a_plain_date_time() {
let (cfg, guard, bz) = parts("[global]\nallow_discovery = true\n");
let server = BugWarden::new(cfg, guard, bz).expect("server builds");
let tool = server
.get_tool("bug_comments")
.expect("bug_comments is routed");
let new_since = tool.input_schema["properties"]["new_since"].clone();
assert_eq!(
new_since,
json!({
"description": "Only return comments newer than this date.",
"format": "date-time",
"type": "string",
}),
"new_since must be a plain nullable-free date-time string"
);
}
#[test]
fn optional_params_still_accept_an_explicit_null() {
// The advertised schema got stricter; what serde accepts did not.
let value = json!({"id": 1, "new_since": null});
serde_json::from_value::<BugCommentsParams>(value)
.expect("an explicit null for an Option field must still deserialize");
}
#[test]
fn decoded_len_counts_decoded_bytes_exactly() {
assert_eq!(decoded_len(""), 0);
assert_eq!(decoded_len("YQ=="), 1); // "a"
assert_eq!(decoded_len("YWI="), 2); // "ab"
assert_eq!(decoded_len("YWJj"), 3); // "abc"
assert_eq!(decoded_len("YWJjZA"), 4); // "abcd", unpadded tail
assert_eq!(decoded_len("YWJj\nZGVm"), 6); // wrapped lines do not inflate
}
#[test]
fn upload_size_cap_measures_decoded_not_encoded_bytes() {
// 100 decoded bytes encode to 136 base64 chars: the cap must judge
// the former, or encoding overhead would shrink every operator cap
// by a third.
let data = "AAAA".repeat(33) + "AA==";
assert_eq!(data.len(), 136);
assert_eq!(decoded_len(&data), 100);
assert!(
upload_size_refusal(100, &data).is_none(),
"136 encoded chars must not count against a 100-byte cap"
);
assert!(upload_size_refusal(99, &data).is_some());
}
#[test]
fn upload_size_cap_zero_disables_and_refusal_names_no_number() {
let data = "AAAA".repeat(33) + "AA==";
assert!(upload_size_refusal(0, &data).is_none(), "0 removes the cap");
// The refusal must not disclose the configured cap or the payload's
// size — max_attachment_bytes is not I1-disclosable.
let refusal = upload_size_refusal(99, &data).expect("over the cap");
assert!(
!refusal.contains(|c: char| c.is_ascii_digit()),
"refusal leaks a number: {refusal}"
);
}
#[test]
fn inline_image_allowlist_excludes_svg_and_unknown_types() {
assert!(is_inline_image("image/png"));
assert!(is_inline_image("IMAGE/PNG"));
assert!(is_inline_image("image/jpeg; charset=binary"));
// Script-bearing or unverified media types must travel as blobs.
assert!(!is_inline_image("image/svg+xml"));
assert!(!is_inline_image("image/svg+xml; charset=utf-8"));
assert!(!is_inline_image("text/html"));
assert!(!is_inline_image("application/octet-stream"));
assert!(!is_inline_image("image/"));
assert!(!is_inline_image("imagexpng"));
}
#[test]
fn windowable_text_covers_text_prefix_and_the_application_allowlist() {
assert!(is_windowable_text("text/plain"));
assert!(is_windowable_text("TEXT/PLAIN"));
assert!(is_windowable_text("text/html; charset=utf-8"));
assert!(is_windowable_text("application/json"));
assert!(is_windowable_text("application/xml"));
assert!(is_windowable_text("application/javascript"));
assert!(is_windowable_text("application/x-sh"));
assert!(is_windowable_text("application/x-ndjson"));
assert!(is_windowable_text("application/yaml; charset=utf-8"));
// Everything else keeps the blob path: near-misses included.
assert!(!is_windowable_text("application/octet-stream"));
assert!(!is_windowable_text("image/png"));
assert!(!is_windowable_text("application/json5"));
// Script-bearing (see is_inline_image): a future "allowlist *+xml"
// refactor must not sweep svg into the text channel.
assert!(!is_windowable_text("image/svg+xml"));
assert!(!is_windowable_text("textile"));
assert!(!is_windowable_text("text"));
assert!(!is_windowable_text(""));
}
#[test]
fn decode_attachment_text_tolerates_wrapping_and_lossy_utf8() {
assert_eq!(decode_attachment_text("YWJj"), Some("abc".to_string()));
// Wrapped base64 decodes exactly as the unwrapped payload.
assert_eq!(
decode_attachment_text("YWJj\nZGU="),
Some("abcde".to_string())
);
// Invalid UTF-8 becomes replacement characters, never a panic.
assert_eq!(decode_attachment_text("gA=="), Some("\u{FFFD}".to_string()));
// Invalid base64 falls back to the un-windowed path.
assert_eq!(decode_attachment_text("%%%not-base64%%%"), None);
}
#[test]
fn window_text_head_plus_tail_selects_lines_of_the_full_text() {
let full = "l1\nl2\nl3\nl4\nl5\nl6\nl7\nl8\nl9\nl10";
let w = window_text(full, Some(2), Some(2), None);
assert_eq!(w.text, "l1\nl2\nl9\nl10");
assert_eq!(w.total_lines, 10);
assert_eq!(w.shown_lines, 4);
assert!(!w.truncated_chars);
assert!(w.cut_anything(), "lines were dropped");
// Head only, tail only.
assert_eq!(window_text(full, Some(1), None, None).text, "l1");
assert_eq!(window_text(full, None, Some(1), None).text, "l10");
}
#[test]
fn window_text_covering_windows_serve_the_text_whole() {
let full = "a\nb\nc";
for (head, tail) in [(Some(2), Some(1)), (Some(10), None), (None, Some(3))] {
let w = window_text(full, head, tail, None);
assert_eq!(w.text, full);
assert_eq!(w.shown_lines, 3);
assert!(!w.cut_anything(), "nothing was cut: no audit note");
}
}
#[test]
fn window_text_max_chars_alone_caps_the_full_text() {
let full = "hello world\nsecond line";
let w = window_text(full, None, None, Some(5));
assert_eq!(w.text, "hello");
assert_eq!(w.total_lines, 2);
// Counted on the served fragment, not on the pre-cap text: a caller
// subtracting shown from total must not read "nothing omitted".
assert_eq!(w.shown_lines, 1);
assert!(w.truncated_chars);
assert!(w.cut_anything(), "characters were dropped");
// A cap beyond the text cuts nothing and stays silent.
let w = window_text(full, None, None, Some(1000));
assert_eq!(w.text, full);
assert!(!w.cut_anything());
}
#[test]
fn window_text_max_chars_counts_characters_never_bytes() {
// Four 2-byte chars: a byte-oriented cap would split one.
let full = "éééé";
let w = window_text(full, None, None, Some(2));
assert_eq!(w.text, "éé");
assert!(w.truncated_chars);
// The char cap applies AFTER the line window.
let w = window_text("ab\ncd\nef\ngh", Some(1), Some(1), Some(3));
assert_eq!(w.text, "ab\n");
// The cap ate the tail line the window had selected.
assert_eq!(w.shown_lines, 1);
assert!(w.truncated_chars);
}
#[test]
fn window_text_shown_lines_counts_the_served_text_after_the_cap() {
let full = "l1\nl2\nl3\nl4\nl5";
// max_chars alone: eight chars is two whole lines plus a fragment.
let w = window_text(full, None, None, Some(8));
assert_eq!(w.text, "l1\nl2\nl3");
assert_eq!(w.total_lines, 5);
assert_eq!(w.shown_lines, 3);
assert!(w.cut_anything());
// A cap landing exactly on a newline drops the empty trailing line.
let w = window_text(full, None, None, Some(6));
assert_eq!(w.text, "l1\nl2\n");
assert_eq!(w.shown_lines, 2);
}
#[test]
fn window_text_ceiling_binds_without_and_below_any_caller_cap() {
// One line, no caller cap: only MAX_WINDOW_CHARS stands between the
// text channel and the whole decoded payload.
let full = "x".repeat(MAX_WINDOW_CHARS + 10);
let w = window_text(&full, Some(1), None, None);
assert_eq!(w.text.chars().count(), MAX_WINDOW_CHARS);
assert!(w.truncated_chars);
assert!(w.cut_anything(), "the ceiling cut content: audit it");
// A caller cap above the ceiling cannot raise it; one below wins.
assert_eq!(
window_text(&full, None, None, Some(u32::MAX))
.text
.chars()
.count(),
MAX_WINDOW_CHARS
);
let w = window_text(&full, None, None, Some(5));
assert_eq!(w.text, "xxxxx");
assert!(w.truncated_chars);
}
#[test]
fn window_text_handles_empty_and_lineless_text() {
let w = window_text("", Some(1), Some(1), Some(10));
assert_eq!(w.text, "");
assert_eq!(w.total_lines, 0);
assert!(!w.cut_anything());
// A trailing newline does not invent an eleventh line.
let w = window_text("a\nb\n", Some(1), None, None);
assert_eq!(w.total_lines, 2);
assert_eq!(w.text, "a");
}
// ---------- audit wiring ----------
#[test]
fn audit_refusal_map_covers_every_tool_in_the_full_router() {
// Iterated over the FULL (unpruned) router on purpose: a new tool
// must get its uniform refusal text before it can ship, or an
// audit outage would answer it with the generic fallback — a
// brand-new fingerprint.
let tools = BugWarden::tool_router().list_all();
assert!(!tools.is_empty(), "the full router lists the tool surface");
for tool in tools {
assert!(
audit_refusal_text(&tool.name).is_some(),
"tool {} has no audit refusal mapping — add its uniform \
failure text to audit_refusal_text",
tool.name
);
}
assert_eq!(
audit_refusal_text("no_such_tool"),
None,
"unknown names take the generic fallback"
);
}
#[test]
fn allowlist_reduces_free_text_to_length_only() {
let args = json!({
"bug_id": 7,
"is_private": true,
"comment": "definitely secret text",
"custom_fields": { "cf_fixed_in": "also withheld" },
});
let Value::Object(obj) = args else {
unreachable!()
};
let params = allowlisted(Some(&obj));
assert_eq!(params["bug_id"], json!(7));
assert_eq!(params["is_private"], json!(true));
let comment_len = serde_json::to_vec(&json!("definitely secret text"))
.unwrap()
.len();
assert_eq!(params["comment"], json!({ "_len": comment_len }));
let flat = serde_json::to_string(¶ms).unwrap();
assert!(!flat.contains("secret"), "free text leaked: {flat}");
assert!(!flat.contains("withheld"), "custom field leaked: {flat}");
}
#[test]
fn allowlist_truncates_long_strings_at_1024_chars() {
let obj: JsonObject = serde_json::from_value(json!({ "query": "q".repeat(5000) })).unwrap();
let params = allowlisted(Some(&obj));
assert_eq!(params["query"].as_str().unwrap().chars().count(), 1024);
// Strings inside allowlisted lists are capped the same way.
let obj: JsonObject =
serde_json::from_value(json!({ "keywords": ["k".repeat(2000)] })).unwrap();
let params = allowlisted(Some(&obj));
assert_eq!(params["keywords"][0].as_str().unwrap().len(), 1024);
// #211: and inside an object, at the top level or under a list —
// the client picks the shape, so the shape must not pick the bound.
let obj: JsonObject = serde_json::from_value(json!({
"groups": { "g": "o".repeat(3000) },
"keywords": [{ "k": [{ "deep": "d".repeat(3000) }] }],
}))
.unwrap();
let params = allowlisted(Some(&obj));
assert_eq!(params["groups"]["g"].as_str().unwrap().len(), 1024);
assert_eq!(
params["keywords"][0]["k"][0]["deep"]
.as_str()
.unwrap()
.len(),
1024
);
// A short value is passed through untouched.
let obj: JsonObject = serde_json::from_value(json!({ "query": "kernel" })).unwrap();
assert_eq!(allowlisted(Some(&obj))["query"], json!("kernel"));
}
#[test]
fn a_param_value_and_a_client_name_share_one_1024_char_boundary() {
// #191: the param leaf and the client identity are the same rule
// for the same reason, and only this row says so — it fails the
// moment one of them grows its own count again. Multi-byte on
// purpose: an ASCII probe cannot tell a char cap from a byte one.
// That both identity sites reach `capped` is
// `both_recording_sites_cap_a_declared_client_identity`.
let recorded_param = |s: &str| {
let obj: JsonObject = serde_json::from_value(json!({ "query": s })).unwrap();
allowlisted(Some(&obj))["query"]
.as_str()
.expect("an allowlisted string is recorded as a string")
.to_string()
};
let over = "é".repeat(PARAM_VALUE_MAX_CHARS + 1);
assert_eq!(
recorded_param(&over).chars().count(),
PARAM_VALUE_MAX_CHARS,
"1025 chars recorded as a param come back at exactly the cap"
);
assert_eq!(
recorded_param(&over),
capped(&over),
"the same 1025 chars recorded as a client name come back identical"
);
let at_cap = "é".repeat(PARAM_VALUE_MAX_CHARS);
assert_eq!(
recorded_param(&at_cap),
at_cap,
"at the cap, nothing is cut"
);
assert_eq!(capped(&at_cap), at_cap, "and neither path cuts it");
}
#[test]
fn a_tracing_field_cuts_at_the_audited_1024_char_boundary() {
// #240: `capped` delegates to `Capped`, so pinning the wrapper
// either side of the boundary — not just over it — is what holds
// the tracing path to the record's cap. Multi-byte on purpose: an
// ASCII probe cannot tell a char cap from a byte one.
for chars in [
PARAM_VALUE_MAX_CHARS - 1,
PARAM_VALUE_MAX_CHARS,
PARAM_VALUE_MAX_CHARS + 1,
] {
let value = "é".repeat(chars);
assert_eq!(
Capped(&value).as_str().chars().count(),
chars.min(PARAM_VALUE_MAX_CHARS),
"{chars} chars must be cut at the cap in chars, never at a byte boundary"
);
}
// Silently, with no marker of its own, as an audited string is cut.
let over = "z".repeat(PARAM_VALUE_MAX_CHARS + 1);
assert_eq!(Capped(&over).as_str(), "z".repeat(PARAM_VALUE_MAX_CHARS));
// The FORMATTING is pinned here too, not just the slice: a
// `Debug` that handed `self.0` to the formatter raw would leave
// `as_str` with no reader on this path, and the sink would hide
// the difference on stderr.
assert_eq!(
format!("{:?}", Capped(&over)),
format!("\"{}\"", "z".repeat(CAPPED_DEBUG_MAX_CHARS)),
"an over-cap value renders as the budget's worth of it, quoted"
);
// Debug keeps the shape a `?p.resolution` field printed uncapped.
assert_eq!(format!("{:?}", Some(Capped("FIXED"))), "Some(\"FIXED\")");
// And it is the ONLY rendering a site can reach (#278): quoted,
// with the two characters that could hide the closing quote
// escaped, so a value cannot forge a later field on its line.
assert_eq!(
format!("{:?}", Capped(r#"evil status=HACKED "\"#)),
r#""evil status=HACKED \"\\""#
);
}
#[test]
fn a_quoted_tracing_field_is_never_cut_open_by_the_sink() {
// #278: the quote only marks a boundary if it SURVIVES. The sink
// cuts a field at PARAM_VALUE_MAX_CHARS rendered characters and
// cannot see that one is open, so `Capped` closes its own value
// inside a tighter budget of its own.
let at = "a".repeat(CAPPED_DEBUG_MAX_CHARS);
assert_eq!(
format!("{:?}", Capped(&at)),
format!("\"{at}\""),
"at the budget the value is whole and both quotes are there"
);
// One character over: the CHARACTER goes, never the quote.
let over = "a".repeat(CAPPED_DEBUG_MAX_CHARS + 1);
assert_eq!(format!("{:?}", Capped(&over)), format!("\"{at}\""));
// The widest shape a site wraps this in must still clear the
// sink's budget, which is where the eight comes from.
let wrapped = format!("{:?}", Some(Capped(&over)));
assert_eq!(
wrapped.chars().count(),
PARAM_VALUE_MAX_CHARS,
"`Some(\"` + the budget + `\")` is the sink's cap exactly: {wrapped}"
);
assert!(wrapped.ends_with("\")"), "and it closes: {wrapped}");
assert!(
format!("{:?}", Capped(&over)).chars().count() < PARAM_VALUE_MAX_CHARS,
"a bare one is well inside it"
);
// A multi-character escape is written whole or dropped whole: an
// ESC is six characters, so it does not fit the last five and the
// rendering stops before it rather than mid-escape.
let tight = format!("{}\u{1b}b", "a".repeat(CAPPED_DEBUG_MAX_CHARS - 5));
assert_eq!(
format!("{:?}", Capped(&tight)),
format!("\"{}\"", "a".repeat(CAPPED_DEBUG_MAX_CHARS - 5)),
"no half escape, and no character reordered past the one that did not fit"
);
// Six left is exactly enough for it.
let fits = format!("{}\u{1b}", "a".repeat(CAPPED_DEBUG_MAX_CHARS - 6));
assert_eq!(
format!("{:?}", Capped(&fits)),
format!("\"{}\\u{{1b}}\"", "a".repeat(CAPPED_DEBUG_MAX_CHARS - 6))
);
// Under the budget the rendering is `str`'s own, byte for byte —
// apostrophe raw, double quote and backslash escaped, control
// characters and the line separators in `Debug`'s spellings.
for probe in [
"it's a \"quoted\" \\ path",
"a\u{1b}b\u{7}c",
"\r\n\t\0",
"a\u{2028}b\u{2029}c",
"é🦀e\u{301}",
"",
] {
assert_eq!(
format!("{:?}", Capped(probe)),
format!("{probe:?}"),
"under the budget `Capped` is `str`'s own `Debug`: {probe:?}"
);
}
}
#[test]
fn an_over_cap_key_never_becomes_a_key_in_a_recorded_map() {
// #220: the key half of #211's value cap. Multi-byte on purpose —
// an ASCII probe cannot tell a char cap from a byte one.
let over = "é".repeat(PARAM_VALUE_MAX_CHARS + 1);
let mut args = JsonObject::new();
args.insert(over.clone(), json!("v"));
let params = allowlisted(Some(&args));
assert!(!params.contains_key(&over), "the key is not a map key");
let Some([marker]) = params
.get(OVERLONG_KEYS)
.and_then(|v| v.as_array())
.map(Vec::as_slice)
else {
panic!("one over-cap key, one entry under {OVERLONG_KEYS}");
};
assert_eq!(
marker["prefix"],
json!(capped(&over)),
"the marker carries capped(key) — which param was oversized is \
the diagnostic params exist for; a bare length would lose it"
);
assert_eq!(marker["key_chars"], json!(PARAM_VALUE_MAX_CHARS + 1));
assert!(
marker["value"].get("_len").is_some(),
"no PARAM_ALLOWLIST entry is over-cap, so an over-cap top-level \
key is non-allowlisted and its value already reduced"
);
// Where the routing boundary sits, at both levels: the cap counts
// CHARS, not bytes, and at the cap is under it. Without these a
// byte-length test (600 é = 1200 bytes) and a `>=` boundary
// both pass the probe above.
let under = "é".repeat(600);
let at_cap = "z".repeat(PARAM_VALUE_MAX_CHARS);
let over_by_one = "z".repeat(PARAM_VALUE_MAX_CHARS + 1);
let mut args = JsonObject::new();
for key in [&under, &at_cap, &over_by_one] {
args.insert(key.clone(), json!(1));
}
let top = allowlisted(Some(&args));
let nested = truncated(&Value::Object(args));
let top_keys: Vec<String> = top.keys().cloned().collect();
let nested_keys: Vec<String> = nested
.as_object()
.expect("truncated keeps an object an object")
.keys()
.cloned()
.collect();
for (level, keys) in [("top level", top_keys), ("nested", nested_keys)] {
assert!(
keys.contains(&under),
"{level}: 600 chars in 1200 bytes stays a map key — the cap \
counts chars, and only the REPORT counting them is not enough"
);
assert!(
keys.contains(&at_cap),
"{level}: exactly at the cap is not over it"
);
assert!(
!keys.contains(&over_by_one),
"{level}: one char over the cap is routed"
);
}
}
#[test]
fn two_over_cap_keys_sharing_a_prefix_stay_two_entries() {
// The reason #211 refused to truncate keys: as MAP keys these
// collide last-wins, and a record that misreports which key was
// sent is worse than a large one. As array elements they cannot.
let shared = "z".repeat(PARAM_VALUE_MAX_CHARS);
let mut nested = JsonObject::new();
nested.insert(format!("{shared}-a"), json!("x"));
nested.insert(format!("{shared}--bb"), json!("y"));
let recorded = truncated(&Value::Object(nested));
let entries = recorded
.get(OVERLONG_KEYS)
.and_then(|v| v.as_array())
.unwrap_or_else(|| panic!("both keys are recorded under {OVERLONG_KEYS}"));
assert_eq!(entries.len(), 2, "collapsing them would leave one");
let mut lengths: Vec<u64> = entries
.iter()
.map(|e| e["key_chars"].as_u64().unwrap())
.collect();
lengths.sort_unstable();
assert_eq!(
lengths,
vec![
PARAM_VALUE_MAX_CHARS as u64 + 2,
PARAM_VALUE_MAX_CHARS as u64 + 4
],
"each keeps its own true length behind the identical prefix"
);
}
#[test]
fn a_client_key_named_like_the_marker_cannot_forge_it() {
// The reserved name is only ever server-written: a client key
// spelled the same routes into the array with its true (short)
// key_chars. Odd-looking, but no collision and no lie.
let mut nested = JsonObject::new();
nested.insert(OVERLONG_KEYS.to_string(), json!("mine"));
let recorded = truncated(&Value::Object(nested));
let Some([entry]) = recorded
.get(OVERLONG_KEYS)
.and_then(|v| v.as_array())
.map(Vec::as_slice)
else {
panic!("the client's own key is routed, not passed through");
};
assert_eq!(entry["key_chars"], json!(OVERLONG_KEYS.chars().count()));
assert_eq!(entry["value"], json!("mine"));
}
#[test]
fn a_client_sent_len_marker_cannot_forge_the_reduction() {
// #241: `_len` means "content withheld by the server". Under an
// allowlisted key the value is copied verbatim, so without the
// reservation `{"_len": 5}` records byte-identically to the
// reduction a NON-allowlisted key gets, and only PARAM_ALLOWLIST
// — which the record does not carry and which moves between
// builds — told them apart.
let obj: JsonObject = serde_json::from_value(json!({
"groups": { RESERVED_LEN: 5 },
"custom_fields": { RESERVED_LEN: 5 },
"keywords": { RESERVED_LEN: { RESERVED_LEN: 1 } },
}))
.unwrap();
let params = allowlisted(Some(&obj));
// Allowlisted: the client's key is routed, exactly as a client key
// spelled `_overlong_keys` already was (#220).
assert!(
params["groups"].get(RESERVED_LEN).is_none(),
"a verbatim {RESERVED_LEN} would be indistinguishable from the reduction"
);
let Some([entry]) = params["groups"]
.get(OVERLONG_KEYS)
.and_then(|v| v.as_array())
.map(Vec::as_slice)
else {
panic!("the client's own {RESERVED_LEN} key belongs under {OVERLONG_KEYS}");
};
assert_eq!(entry["prefix"], json!(RESERVED_LEN));
assert_eq!(entry["key_chars"], json!(RESERVED_LEN.chars().count()));
assert_eq!(entry["value"], json!(5), "the value itself is not lost");
// Non-allowlisted: the reduction is untouched, and it is the ONLY
// way `_len` reaches a record — same name, one definition.
let len = serde_json::to_vec(&json!({ RESERVED_LEN: 5 }))
.unwrap()
.len();
assert_eq!(params["custom_fields"], json!({ RESERVED_LEN: len }));
// Routed at every depth, and by the NAME alone: this one's value
// is an object, whose own `_len` key is routed while the value is
// recorded.
assert!(
params["keywords"].get(RESERVED_LEN).is_none(),
"nested: same rule"
);
let Some([nested]) = params["keywords"]
.get(OVERLONG_KEYS)
.and_then(|v| v.as_array())
.map(Vec::as_slice)
else {
panic!("the nested {RESERVED_LEN} key belongs under {OVERLONG_KEYS}");
};
assert_eq!(nested["key_chars"], json!(RESERVED_LEN.chars().count()));
assert_eq!(
nested["value"],
json!({ OVERLONG_KEYS: [{
"prefix": RESERVED_LEN,
"key_chars": RESERVED_LEN.chars().count(),
"value": 1,
}] }),
"the client's nested key is routed too, one level further down"
);
}
// The fail-mode scope tests below drive a REAL sink into failure via
// its cfg(test) injection hook, which integration tests cannot reach;
// everything else is the production path: a real MCP session over an
// in-memory duplex transport against a wiremock Bugzilla.
use crate::audit::{AuditConfig, AuditEvent, AuditEventKind, AuditSink};
use rmcp::service::{RoleClient, RunningService};
use rmcp::ServiceExt as _;
use wiremock::matchers::{method, path, query_param};
use wiremock::{Mock, MockServer, ResponseTemplate};
fn audit_state(
dir: &std::path::Path,
fail_mode: FailMode,
) -> (Arc<AuditState>, std::path::PathBuf) {
let path = dir.join("audit.jsonl");
let sink = AuditSink::open(AuditConfig {
path: Some(path.clone()),
fsync: false,
fail_mode: None,
rotate_max_bytes: 0,
rotate_keep: 8,
suppressed_ids: true,
})
.expect("audit sink must open");
(Arc::new(AuditState::new(sink, fail_mode, None)), path)
}
fn read_audit_events(path: &std::path::Path) -> Vec<AuditEvent> {
let s = std::fs::read_to_string(path).expect("audit file must be readable");
s.lines()
.filter(|l| !l.is_empty())
.map(|l| serde_json::from_str(l).expect("every audit line must parse"))
.collect()
}
/// Serve a [`BugWarden`] against `mock` over an in-memory duplex
/// transport, optionally audited, and connect an MCP client.
async fn mcp_client(
policy: &str,
mock_uri: &str,
audit: Option<Arc<AuditState>>,
) -> RunningService<RoleClient, ()> {
mcp_client_routed(policy, mock_uri, audit, false).await
}
/// [`mcp_client`], optionally with [`panic_probe_route`] added to the
/// router. The probe goes in through rmcp's own extension point, so no
/// production path gains a test-only branch to reach a panic with.
async fn mcp_client_routed(
policy: &str,
mock_uri: &str,
audit: Option<Arc<AuditState>>,
panic_probe: bool,
) -> RunningService<RoleClient, ()> {
let cfg: Arc<Cli> = Arc::new(pinned(&[
"bugwarden",
"--bugzilla-server",
mock_uri,
"--transport",
"stdio",
"--api-key",
"test-key",
]));
let guard = Arc::new(Guard {
policy: Policy::from_toml_str(policy).expect("test policy must parse"),
});
let bz =
Arc::new(BugzillaClient::new(mock_uri, false, USER_AGENT).expect("client must build"));
let mut server = BugWarden::new(cfg, guard, bz).expect("server must build");
if let Some(audit) = audit {
server = server.with_audit(audit);
}
if panic_probe {
server.tool_router.add_route(panic_probe_route());
}
let (client_io, server_io) = tokio::io::duplex(1 << 16);
tokio::spawn(async move {
if let Ok(running) = server.serve(server_io).await {
let _ = running.waiting().await;
}
});
bounded("the MCP handshake", ().serve(client_io))
.await
.expect("MCP handshake must succeed")
}
/// Bound on every request these tests await — over the duplex
/// transport and in process alike.
///
/// rmcp 3.1.4 sends with `PeerRequestOptions::default()`, whose
/// `timeout` is `None` (`service.rs`), and its `initialize` handshake
/// reads the response off the transport with no deadline either. A
/// request the server never answers therefore waits forever: it hangs
/// its test, this whole binary, and every binary cargo queues behind
/// it. That failure is the one this module hunts (#253, #254), so it
/// must fail ONE test rather than decide how long the run takes. A
/// direct `ServerHandler::call_tool` await is bounded for the same
/// reason: it reaches the same dispatch, and the mutants that hung the
/// suite were inside it. So is a hand-sent second `initialize` — over
/// a live session that is an ordinary request, answered or not.
///
/// 30s is far above anything legitimate here — no test delays a mock
/// and every upstream is loopback or absent — and it is the number
/// `tests/common/deadline.rs` gives the integration harnesses. Two
/// definitions of it exist, not one: a known duplication, not a
/// necessity — a `#[cfg(test)]` module in the library cannot `use`
/// an integration-test file, but it can include one by `#[path]`,
/// as `lib.rs` does for `pinned_cli` and `refused` (#280). Until
/// that is done nothing but this sentence keeps the two equal: move
/// one and move the other; the reasoning for the value itself lives
/// in that file.
const CALL_DEADLINE: std::time::Duration = std::time::Duration::from_secs(30);
/// Await `fut` under [`CALL_DEADLINE`], panicking if it does not
/// resolve. `what` names the request, because the panic is all a
/// reader of a CI log gets.
async fn bounded<T>(what: &str, fut: impl std::future::Future<Output = T>) -> T {
tokio::time::timeout(CALL_DEADLINE, fut)
.await
.unwrap_or_else(|_| panic!("{what} was not answered within {CALL_DEADLINE:?}"))
}
/// Call `tool` over the session, under [`CALL_DEADLINE`].
///
/// The bound carries weight wherever this follows a call that panicked:
/// "the session survives" is a claim that a reply ARRIVES, so an
/// unbounded await would turn a missing one into a hang rather than
/// the failure it is.
async fn call(
client: &RunningService<RoleClient, ()>,
tool: &str,
args: Value,
) -> CallToolResult {
let Value::Object(args) = args else {
panic!("tool arguments must be a JSON object");
};
bounded(
&format!("the {tool} call"),
client.call_tool(CallToolRequestParams::new(tool.to_string()).with_arguments(args)),
)
.await
.expect("tool call must not be a protocol error")
}
fn text_of(result: &CallToolResult) -> String {
result
.content
.iter()
.filter_map(|c| c.as_text())
.map(|t| t.text.as_str())
.collect()
}
fn is_error(result: &CallToolResult) -> bool {
result.is_error == Some(true)
}
fn world_bug(id: u64) -> Value {
json!({
"id": id,
"summary": "a plain bug",
"product": "openSUSE",
"component": "Kernel",
"status": "NEW",
"severity": "normal",
"priority": "P3",
"keywords": [],
"groups": [],
"whiteboard": "",
"creation_time": "2020-01-01T00:00:00Z",
})
}
/// Classify/full fetches for bug 7, the empty id=0 padding fetch, an
/// empty history, and a comment thread with one private comment.
async fn mount_bug7(mock: &MockServer) {
Mock::given(method("GET"))
.and(path("/rest/bug"))
.and(query_param("id", "0"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({ "bugs": [] })))
.mount(mock)
.await;
Mock::given(method("GET"))
.and(path("/rest/bug"))
.and(query_param("id", "7"))
.respond_with(
ResponseTemplate::new(200).set_body_json(json!({ "bugs": [world_bug(7)] })),
)
.mount(mock)
.await;
Mock::given(method("GET"))
.and(path("/rest/bug/7/history"))
.respond_with(
ResponseTemplate::new(200).set_body_json(json!({ "bugs": [{ "history": [] }] })),
)
.mount(mock)
.await;
Mock::given(method("GET"))
.and(path("/rest/bug/7/comment"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"bugs": { "7": { "comments": [
{ "id": 1, "bug_id": 7, "is_private": false, "text": "public" },
{ "id": 2, "bug_id": 7, "is_private": true, "text": "private" },
] } }
})))
.mount(mock)
.await;
}
#[tokio::test]
async fn closed_all_gates_pre_dispatch_once_the_sink_is_failing() {
let mock = MockServer::start().await;
mount_bug7(&mock).await;
let dir = tempfile::tempdir().unwrap();
let (audit, audit_path) = audit_state(dir.path(), FailMode::ClosedAll);
let client = mcp_client("", &mock.uri(), Some(Arc::clone(&audit))).await;
// Healthy sink: a granted read serves and is recorded.
let served = call(&client, "bug_history", json!({ "id": 7 })).await;
assert!(!is_error(&served), "healthy sink must serve");
audit.sink.set_fail_writes(true);
// The outage is discovered ON the response path: the tool ran
// (upstream was contacted), the record failed, and the response
// became the mapped refusal — the write sits on the response path.
let discovered = call(&client, "bug_history", json!({ "id": 7 })).await;
assert!(is_error(&discovered));
assert_eq!(text_of(&discovered), "Failed to fetch bug history");
let after_discovery = mock.received_requests().await.unwrap().len();
// From now on the gate closes BEFORE dispatch: no new upstream
// request, same refusal text.
let gated = call(&client, "bug_history", json!({ "id": 7 })).await;
assert!(is_error(&gated));
assert_eq!(text_of(&gated), "Failed to fetch bug history");
assert_eq!(
mock.received_requests().await.unwrap().len(),
after_discovery,
"the pre-dispatch gate must not contact upstream"
);
// On disk: only the healthy-phase records made it.
let events = read_audit_events(&audit_path);
assert_eq!(events.len(), 2, "initialize + the served call");
}
#[tokio::test]
async fn closed_writes_denials_scopes_refusal_to_writes_and_non_clean_reads() {
let mock = MockServer::start().await;
mount_bug7(&mock).await;
Mock::given(method("POST"))
.and(path("/rest/bug/7/comment"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({ "id": 11 })))
.expect(0)
.mount(&mock)
.await;
let dir = tempfile::tempdir().unwrap();
let (audit, audit_path) = audit_state(dir.path(), FailMode::ClosedWritesDenials);
// Failing from the very start; the initialize record is lost but
// the handshake proceeds (only closed_all refuses it).
audit.sink.set_fail_writes(true);
let client = mcp_client("", &mock.uri(), Some(Arc::clone(&audit))).await;
// A read the guard fully allows serves unaudited.
let served = call(&client, "bug_history", json!({ "id": 7 })).await;
assert!(!is_error(&served), "a clean read must serve unaudited");
// A write refuses BEFORE any upstream request (the sink is
// already failing, so the gate closes pre-dispatch: no classify,
// no POST).
let before_write = mock.received_requests().await.unwrap().len();
let write = call(
&client,
"add_comment",
json!({ "bug_id": 7, "comment": "hi" }),
)
.await;
assert!(is_error(&write));
assert_eq!(text_of(&write), "Failed to create a comment");
assert_eq!(
mock.received_requests().await.unwrap().len(),
before_write,
"a gated write must not contact upstream at all"
);
// A read whose guard suppressed something (a private comment
// filtered out) refuses.
let filtered = call(&client, "bug_comments", json!({ "id": 7 })).await;
assert!(is_error(&filtered));
assert_eq!(text_of(&filtered), "Failed to fetch bug comments");
// Recovery: the unaudited work shows up as the gap marker's drop
// count — initialize, the served read, the gated write's refusal
// record, and the filtered read.
audit.sink.set_fail_writes(false);
let again = call(&client, "bug_history", json!({ "id": 7 })).await;
assert!(!is_error(&again));
let events = read_audit_events(&audit_path);
assert_eq!(events.len(), 2, "gap marker + the recovered call");
match &events[0].kind {
AuditEventKind::AuditGap(gap) => assert_eq!(gap.dropped, 4),
other => panic!("expected audit_gap first after recovery, got {other:?}"),
}
}
#[tokio::test]
async fn responses_byte_identical_with_audit_off_on_and_failing_open() {
// The same call sequence — a denial, a filtered read, a write —
// against one upstream, through three servers: audit off, audit
// on, audit failing under fail_mode = open. The client-visible
// results must serialize byte-identically (I15).
let policy = concat!(
"[[rule]]\nname = \"hide-secret\"\naction = \"deny\"\n",
"[rule.match]\nproducts = [\"Secret*\"]\n",
);
let mock = MockServer::start().await;
mount_bug7(&mock).await;
let mut secret = world_bug(99);
secret["product"] = json!("SecretSauce");
Mock::given(method("GET"))
.and(path("/rest/bug"))
.and(query_param("id", "99"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({ "bugs": [secret] })))
.mount(&mock)
.await;
Mock::given(method("POST"))
.and(path("/rest/bug/7/comment"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({ "id": 11 })))
.mount(&mock)
.await;
let client_off = mcp_client(policy, &mock.uri(), None).await;
let dir_on = tempfile::tempdir().unwrap();
let (audit_on, _) = audit_state(dir_on.path(), FailMode::Open);
let client_on = mcp_client(policy, &mock.uri(), Some(audit_on)).await;
let dir_fail = tempfile::tempdir().unwrap();
let (audit_fail, fail_path) = audit_state(dir_fail.path(), FailMode::Open);
audit_fail.sink.set_fail_writes(true);
let client_fail = mcp_client(policy, &mock.uri(), Some(Arc::clone(&audit_fail))).await;
let sequence = [
("bug_info", json!({ "bug_ids": [7, 99] })),
("bug_comments", json!({ "id": 7 })),
("add_comment", json!({ "bug_id": 7, "comment": "hello" })),
];
for (tool, args) in sequence {
let off = call(&client_off, tool, args.clone()).await;
let on = call(&client_on, tool, args.clone()).await;
let failing = call(&client_fail, tool, args).await;
let off = serde_json::to_string(&off).unwrap();
assert_eq!(
off,
serde_json::to_string(&on).unwrap(),
"auditing on must not change {tool}"
);
assert_eq!(
off,
serde_json::to_string(&failing).unwrap(),
"a failing-open sink must not change {tool}"
);
}
// fail-open accounts for the outage in the stream itself.
audit_fail.sink.set_fail_writes(false);
let _ = call(&client_fail, "bug_comments", json!({ "id": 7 })).await;
let events = read_audit_events(&fail_path);
assert!(
events
.iter()
.any(|e| matches!(e.kind, AuditEventKind::AuditGap(_))),
"a gap marker must follow recovery"
);
}
// ---------- trace enrichment (issue #28) ----------
/// The canonical W3C `traceparent` example; its ids.
const TRACEPARENT: &str = "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01";
const TRACE_ID: &str = "0af7651916cd43dd8448eb211c80319c";
const SPAN_ID: &str = "b7ad6b7169203331";
/// Like [`call`], but with `traceparent` in the request's `_meta`.
/// The duplex transport serde-roundtrips the bytes, so this exercises
/// the wire `params._meta` shape, not an in-process shortcut.
async fn call_with_traceparent(
client: &RunningService<RoleClient, ()>,
tool: &str,
args: Value,
traceparent: &str,
) -> CallToolResult {
let Value::Object(args) = args else {
panic!("tool arguments must be a JSON object");
};
let mut params = CallToolRequestParams::new(tool.to_string()).with_arguments(args);
let mut meta = rmcp::model::RequestMetaObject::new();
meta.set_traceparent(traceparent);
params.meta = Some(meta);
bounded(&format!("the traced {tool} call"), client.call_tool(params))
.await
.expect("tool call must not be a protocol error")
}
/// The `tool_call` payloads of `events`, in file order.
fn tool_calls(events: &[AuditEvent]) -> Vec<&audit::ToolCallEvent> {
events
.iter()
.filter_map(|e| match &e.kind {
AuditEventKind::ToolCall(ev) => Some(ev.as_ref()),
_ => None,
})
.collect()
}
fn assert_trace_is_canonical(trace: Option<&audit::TraceContext>) {
let trace = trace.expect("the record must carry the sent trace ids");
assert_eq!(trace.trace_id, TRACE_ID);
assert_eq!(trace.span_id, SPAN_ID);
}
#[tokio::test]
async fn scope_enforcement_without_a_scope_serves_nothing() {
// Defense in depth for the one branch `main` cannot reach: scopes
// enforced, but the request carries none because it arrived over a
// transport with no HTTP request behind it. Fail closed — an empty
// listing and no reachable tool — rather than assume the listener was
// wired the way this server was configured.
// No upstream fixture: nothing may reach Bugzilla, and the call
// below is refused before any tool body runs.
let (cfg, guard, bz) = parts("");
let server = BugWarden::new(cfg, guard, bz)
.expect("server must build")
.with_scope_enforcement(true);
let (client_io, server_io) = tokio::io::duplex(1 << 16);
tokio::spawn(async move {
if let Ok(running) = server.serve(server_io).await {
let _ = running.waiting().await;
}
});
let client: RunningService<RoleClient, ()> =
bounded("the MCP handshake", ().serve(client_io))
.await
.expect("MCP handshake must succeed");
assert!(
bounded("tools/list", client.list_all_tools())
.await
.expect("tools/list must succeed")
.is_empty(),
"a request with no scope must be offered nothing"
);
let refused = bounded(
"the scope-refused bug_info call",
client.call_tool(
CallToolRequestParams::new("bug_info".to_string()).with_arguments(
json!({ "bug_ids": [7] })
.as_object()
.expect("object")
.clone(),
),
),
)
.await
.expect_err("a request with no scope must reach no tool");
let unknown = bounded(
"the no_such_tool_at_all call",
client.call_tool(CallToolRequestParams::new(
"no_such_tool_at_all".to_string(),
)),
)
.await
.expect_err("an unknown tool is an error");
assert_eq!(refused.to_string(), unknown.to_string());
}
#[tokio::test]
async fn a_scope_refused_call_is_recorded_like_an_unknown_tool() {
// Rejection at the transport layer cannot be audited (issue #32
// constraint 5), but a scope refusal happens inside `call_tool` and
// therefore can be: exactly ONE record, no guard verdict, no upstream
// leg — the same shape an unknown tool name produces.
let dir = tempfile::tempdir().unwrap();
let (audit, audit_path) = audit_state(dir.path(), FailMode::Open);
let (cfg, guard, bz) = parts("");
let server = BugWarden::new(cfg, guard, bz)
.expect("server must build")
.with_audit(audit)
.with_scope_enforcement(true);
let (client_io, server_io) = tokio::io::duplex(1 << 16);
tokio::spawn(async move {
if let Ok(running) = server.serve(server_io).await {
let _ = running.waiting().await;
}
});
let client: RunningService<RoleClient, ()> =
bounded("the MCP handshake", ().serve(client_io))
.await
.expect("MCP handshake must succeed");
bounded(
"the scope-refused add_comment call",
client.call_tool(CallToolRequestParams::new("add_comment".to_string())),
)
.await
.expect_err("the call must be refused");
drop(client);
let events = read_audit_events(&audit_path);
let calls = tool_calls(&events);
assert_eq!(calls.len(), 1, "exactly one record: {events:?}");
assert_eq!(calls[0].request.tool, "add_comment");
assert_eq!(calls[0].guard, None, "the guard never ran");
assert_eq!(calls[0].upstream, None, "Bugzilla was never contacted");
assert_eq!(calls[0].outcome.class, audit::OutcomeClass::Error);
assert_eq!(
calls[0].client.principal, None,
"a deployment credential is not a caller (#32)"
);
assert_eq!(
calls[0].client.work_context, None,
"and it binds the call to no work item either (#34)"
);
}
#[tokio::test]
async fn traceparent_in_meta_lands_in_the_tool_record() {
let mock = MockServer::start().await;
mount_bug7(&mock).await;
let dir = tempfile::tempdir().unwrap();
let (audit, audit_path) = audit_state(dir.path(), FailMode::Open);
let client = mcp_client("", &mock.uri(), Some(audit)).await;
let plain = call(&client, "bug_history", json!({ "id": 7 })).await;
let traced =
call_with_traceparent(&client, "bug_history", json!({ "id": 7 }), TRACEPARENT).await;
// Record enrichment only (I15): the response must not change.
assert_eq!(
serde_json::to_string(&plain).unwrap(),
serde_json::to_string(&traced).unwrap(),
"a traceparent must not change the response"
);
let events = read_audit_events(&audit_path);
let calls = tool_calls(&events);
assert_eq!(calls.len(), 2);
assert_eq!(calls[0].trace, None, "no meta means no trace field");
assert_trace_is_canonical(calls[1].trace.as_ref());
}
#[tokio::test]
async fn malformed_traceparent_leaves_the_record_without_trace() {
let mock = MockServer::start().await;
mount_bug7(&mock).await;
let dir = tempfile::tempdir().unwrap();
let (audit, audit_path) = audit_state(dir.path(), FailMode::Open);
let client = mcp_client("", &mock.uri(), Some(audit)).await;
let plain = call(&client, "bug_history", json!({ "id": 7 })).await;
// Uppercase hex is invalid per W3C; the strict parser records
// nothing rather than a value it could not fully validate.
let upper = "00-0AF7651916CD43DD8448EB211C80319C-B7AD6B7169203331-01";
let traced = call_with_traceparent(&client, "bug_history", json!({ "id": 7 }), upper).await;
assert_eq!(
serde_json::to_string(&plain).unwrap(),
serde_json::to_string(&traced).unwrap(),
"a malformed traceparent must not change the response"
);
// Absent means absent bytes: no serialized line carries a trace
// key at all — and none echoes the rejected value anywhere.
let raw = std::fs::read_to_string(&audit_path).expect("audit file must be readable");
assert!(
!raw.contains("\"trace\""),
"a malformed traceparent must leave no trace field: {raw}"
);
assert!(
!raw.contains("0AF76519"),
"the rejected value must never reach the file: {raw}"
);
assert_eq!(tool_calls(&read_audit_events(&audit_path)).len(), 2);
}
#[tokio::test]
async fn traceparent_on_a_denied_call_enriches_but_never_influences() {
// Correlation matters most on denials — and the trace ids must
// never leak into the denial itself.
let policy = concat!(
"[[rule]]\nname = \"hide-secret\"\naction = \"deny\"\n",
"[rule.match]\nproducts = [\"Secret*\"]\n",
);
let mock = MockServer::start().await;
mount_bug7(&mock).await;
let mut secret = world_bug(99);
secret["product"] = json!("SecretSauce");
Mock::given(method("GET"))
.and(path("/rest/bug"))
.and(query_param("id", "99"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({ "bugs": [secret] })))
.mount(&mock)
.await;
let dir = tempfile::tempdir().unwrap();
let (audit, audit_path) = audit_state(dir.path(), FailMode::Open);
let client = mcp_client(policy, &mock.uri(), Some(audit)).await;
let denied =
call_with_traceparent(&client, "bug_comments", json!({ "id": 99 }), TRACEPARENT).await;
assert!(is_error(&denied), "the hidden bug must be denied");
assert_eq!(
text_of(&denied),
"Bug 99 is not accessible through this server",
"the denial must be the uniform text (I2), traceparent or not"
);
let calls_events = read_audit_events(&audit_path);
let calls = tool_calls(&calls_events);
assert_eq!(calls.len(), 1);
let guard = calls[0].guard.as_ref().expect("a denial has a guard");
assert_eq!(guard.verdict, Verdict::Denied);
assert_trace_is_canonical(calls[0].trace.as_ref());
}
#[tokio::test]
async fn failing_sink_gate_record_carries_the_trace() {
// The pre-dispatch fail-closed gate builds its own record; it
// must carry the trace too, not only the post-dispatch site.
let mock = MockServer::start().await;
mount_bug7(&mock).await;
let dir = tempfile::tempdir().unwrap();
let (audit, audit_path) = audit_state(dir.path(), FailMode::ClosedAll);
let client = mcp_client("", &mock.uri(), Some(Arc::clone(&audit))).await;
// Discover the outage on the response path, then lift the
// injected fault: the sink still counts as failing (records were
// dropped), so the next call takes the pre-dispatch gate — and
// its best-effort record now persists.
audit.sink.set_fail_writes(true);
let discovered = call(&client, "bug_history", json!({ "id": 7 })).await;
assert!(is_error(&discovered));
audit.sink.set_fail_writes(false);
let before_gated = mock.received_requests().await.unwrap().len();
let gated =
call_with_traceparent(&client, "bug_history", json!({ "id": 7 }), TRACEPARENT).await;
assert!(is_error(&gated));
assert_eq!(text_of(&gated), "Failed to fetch bug history");
assert_eq!(
mock.received_requests().await.unwrap().len(),
before_gated,
"the gated call must not contact upstream"
);
let events = read_audit_events(&audit_path);
let calls = tool_calls(&events);
assert_eq!(calls.len(), 1, "only the gate record reached the file");
let guard = calls[0].guard.as_ref().expect("the gate records a guard");
assert_eq!(guard.verdict, Verdict::Refused);
assert_trace_is_canonical(calls[0].trace.as_ref());
}
// ---- a panicking tool handler (issue #253) ----------------------------
/// The panic payload. Must reach neither the reply nor a tracing field.
const PANIC_MARKER: &str = "panic-probe-payload-b9f1c2";
/// A waker that counts what it was told. It is what separates a `poll`
/// that hands the caller's `Context` down from one that substitutes a
/// context of its own: with a no-op waker on both sides the two are
/// indistinguishable by hand-polling, and in production the difference
/// only shows up as a future that is never woken again.
#[derive(Default)]
struct CountingWaker {
wakes: std::sync::atomic::AtomicUsize,
}
impl CountingWaker {
fn wakes(&self) -> usize {
self.wakes.load(std::sync::atomic::Ordering::SeqCst)
}
}
impl std::task::Wake for CountingWaker {
fn wake(self: Arc<Self>) {
self.wake_by_ref();
}
fn wake_by_ref(self: &Arc<Self>) {
self.wakes.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
}
}
/// Poll once with `waker`. The wrapper is `Unpin`, so the pin is free.
fn poll_once<F: Future>(
fut: &mut CatchUnwind<F>,
waker: &std::task::Waker,
) -> Poll<Result<F::Output, Box<dyn std::any::Any + Send>>> {
let mut cx = Context::from_waker(waker);
Pin::new(fut).poll(&mut cx)
}
fn counting_waker() -> (Arc<CountingWaker>, std::task::Waker) {
let counter = Arc::new(CountingWaker::default());
let waker = std::task::Waker::from(Arc::clone(&counter));
(counter, waker)
}
async fn panics() -> u8 {
panic!("{PANIC_MARKER}")
}
#[test]
fn catch_unwind_hands_a_ready_value_straight_back() {
let (_, waker) = counting_waker();
let mut fut = CatchUnwind::new(std::future::ready(7u8));
let Poll::Ready(Ok(value)) = poll_once(&mut fut, &waker) else {
panic!("a ready future polls Ready(Ok(..))");
};
assert_eq!(value, 7, "the value passes through unchanged");
}
#[test]
fn catch_unwind_turns_a_panicking_poll_into_err() {
let (_, waker) = counting_waker();
let mut fut = CatchUnwind::new(panics());
let Poll::Ready(Err(payload)) = poll_once(&mut fut, &waker) else {
panic!("a panicking poll polls Ready(Err(..))");
};
// The payload is handed back intact — deciding what may be done
// with it belongs to the caller, which drops it unformatted.
let message = payload
.downcast_ref::<String>()
.expect("a formatted panic message is a String");
assert_eq!(message, PANIC_MARKER);
}
#[test]
fn catch_unwind_passes_pending_and_the_callers_waker_through() {
let (counter, waker) = counting_waker();
let mut polls = 0u8;
let inner = std::future::poll_fn(move |cx: &mut Context<'_>| {
polls += 1;
if polls == 1 {
cx.waker().wake_by_ref();
Poll::Pending
} else {
Poll::Ready(11u8)
}
});
let mut fut = CatchUnwind::new(inner);
assert!(
poll_once(&mut fut, &waker).is_pending(),
"Pending must pass through, not become a value"
);
assert_eq!(
counter.wakes(),
1,
"the inner future must be polled with the CALLER's context: a \
substituted waker strands it forever in production"
);
let Poll::Ready(Ok(value)) = poll_once(&mut fut, &waker) else {
panic!("the value arrives on the second poll");
};
assert_eq!(value, 11);
}
/// The probe's tool name.
const PANIC_PROBE: &str = "panic_probe";
/// The reply text a recovered panic is answered with, spelled out here
/// so a reword of the production constant has to be deliberate: this
/// string goes to clients.
const PANIC_REPLY: &str = "the server failed to handle this request";
/// A base URL nothing dials: the probe panics before it could, and the
/// follow-up call (`mcp_server_info`) contacts nothing either. Keeping
/// wiremock out makes these tests about the dispatch boundary alone.
///
/// Bare by the rule in `tests/common/refused.rs` (#280): pointed at
/// an address that black-holes connections these rows still pass in
/// milliseconds, so no client here ever connects and there is no
/// wait for the probe to buy out. A row that does route a call
/// upstream takes `crate::refused::refused_base_url` instead.
const NO_BUGZILLA: &str = "http://127.0.0.1:1";
/// A handler that panics on every call. A free `fn` rather than a
/// closure so it satisfies `new_dyn`'s `for<'a> Fn(..) -> BoxFuture<'a,
/// ..>` bound by lifetime elision.
fn panic_probe_handler(
_context: ToolCallContext<'_, BugWarden>,
) -> std::pin::Pin<
Box<dyn std::future::Future<Output = Result<CallToolResponse, McpError>> + Send + '_>,
> {
Box::pin(async { panic!("{PANIC_MARKER}") })
}
fn panic_probe_route() -> rmcp::handler::server::router::tool::ToolRoute<BugWarden> {
let Value::Object(schema) = json!({ "type": "object", "properties": {} }) else {
panic!("an input schema is a JSON object");
};
rmcp::handler::server::router::tool::ToolRoute::new_dyn(
Tool::new(
PANIC_PROBE,
"test-only handler that always panics",
Arc::new(schema),
),
panic_probe_handler,
)
}
/// Call the probe and return the protocol error the request must be
/// answered with. Bounded like every request here, and here the bound
/// is the point: a panicking handler that answers nothing is the exact
/// defect under test.
async fn panic_probe_error(client: &RunningService<RoleClient, ()>) -> McpError {
let answered = bounded(
"the panic probe",
client.call_tool(CallToolRequestParams::new(PANIC_PROBE.to_string())),
)
.await;
match answered {
Err(rmcp::service::ServiceError::McpError(err)) => err,
other => panic!("a recovered panic is a protocol error, got {other:?}"),
}
}
/// The reply is fixed, carries no `data`, and nothing derived from the
/// payload survives into it.
fn assert_panic_reply(err: &McpError) {
assert_eq!(err.code, ErrorCode::INTERNAL_ERROR);
assert_eq!(err.code.0, -32603, "the JSON-RPC internal error code");
assert_eq!(err.message, PANIC_REPLY);
assert!(err.data.is_none(), "no data field: {:?}", err.data);
let wire = serde_json::to_string(err).expect("an error serializes");
assert!(
!wire.contains(PANIC_MARKER),
"the panic payload must not reach the client: {wire}"
);
}
#[test]
fn the_panic_reply_carries_no_data_key_on_the_wire() {
// Absent, not null. `ErrorData::data` is
// `skip_serializing_if = "Option::is_none"`, and serde reads a JSON
// `null` back into `None` — so a `Some(Value::Null)` would ship
// `"data":null` and still look absent to the client-side assertion
// above. Checked on the serialized server-side value instead.
let wire = serde_json::to_value(handler_panicked()).expect("an error serializes");
let object = wire.as_object().expect("a JSON-RPC error is an object");
let keys: Vec<&str> = object.keys().map(String::as_str).collect();
assert_eq!(keys, ["code", "message"], "no `data` key: {wire}");
assert_eq!(object["code"], json!(-32603));
assert_eq!(object["message"], json!(PANIC_REPLY));
}
#[tokio::test]
async fn a_panicking_handler_is_answered_and_recorded_once() {
let dir = tempfile::tempdir().unwrap();
let (audit, audit_path) = audit_state(dir.path(), FailMode::Open);
let client = mcp_client_routed("", NO_BUGZILLA, Some(Arc::clone(&audit)), true).await;
let err = panic_probe_error(&client).await;
assert_panic_reply(&err);
// The session survives the unwind: the next call is served.
let served = call(&client, "mcp_server_info", json!({})).await;
assert!(!is_error(&served), "the session must keep serving");
let events = read_audit_events(&audit_path);
let calls = tool_calls(&events);
assert_eq!(calls.len(), 2, "one record each, in order, with no gap");
assert_eq!(calls[0].request.tool, PANIC_PROBE);
assert_eq!(calls[0].outcome.class, audit::OutcomeClass::Error);
assert_eq!(
calls[0].outcome.response_bytes, None,
"a protocol error has no result to size"
);
assert_eq!(calls[1].request.tool, "mcp_server_info");
assert_eq!(calls[1].outcome.class, audit::OutcomeClass::Ok);
}
#[tokio::test]
async fn a_panicking_handler_answers_the_same_with_auditing_off() {
// The `audit: None` constructor path: a different dispatch site,
// and it must answer byte-identically to the audited one.
let client = mcp_client_routed("", NO_BUGZILLA, None, true).await;
let err = panic_probe_error(&client).await;
assert_panic_reply(&err);
let served = call(&client, "mcp_server_info", json!({})).await;
assert!(!is_error(&served), "the session must keep serving");
}
#[test]
fn the_panic_recovery_line_names_the_tool_and_never_the_payload() {
// A current-thread runtime, whose `block_on` runs inside the
// capture: `capture_logs` is thread-local and the recovery line is
// emitted from the rmcp handler task, which this flavour runs on the
// capturing thread.
//
// No panic hook is installed here: this binary's other tests need
// std's default one to report their own panics. So the panic's own
// `thread 'name' (tid) panicked at file:line:col:` line goes to the
// real stderr and is invisible to this capture, and these
// assertions say nothing about it. What the shipped binary reports
// instead is `panic_hook`'s line, pinned by `tests/panic_hook.rs`
// in a child process of its own (#270).
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("a current-thread runtime");
let (_, logs) = crate::testlog::capture_logs(|| {
rt.block_on(async {
let client = mcp_client_routed("", NO_BUGZILLA, None, true).await;
assert_panic_reply(&panic_probe_error(&client).await);
});
});
// Scoped to the recovery line itself, not to the capture. The
// capture runs at TRACE and rmcp's own "new event" / "received
// request" lines quote the whole request, tool name included, so a
// whole-capture search would pass with the field deleted.
let captured = logs.as_str();
let line = captured
.lines()
.find(|line| line.contains("tool handler panicked"))
.unwrap_or_else(|| panic!("the recovery line must be logged: {captured}"));
assert!(
line.contains(&format!("tool={PANIC_PROBE:?}")),
"the recovery line must name the tool in its own quoted field: {line}"
);
assert!(
line.contains("WARN"),
"a per-request failure a client can repeat at will is a WARN, not an \
ERROR an operator is paged on (#270): {line}"
);
assert!(
!line.contains("ERROR"),
"the level flipped to WARN, so nothing on this line still says ERROR: {line}"
);
logs.assert_not_contains(PANIC_MARKER);
}
/// A `RequestContext` carrying the HTTP request parts a served request
/// would carry, with `scope` attached the way the bearer layer attaches
/// it. The only way to reach [`Reach::ReadOnly`] without a live listener.
fn context_with_scope(
peer: rmcp::service::Peer<RoleServer>,
scope: Option<Scope>,
) -> RequestContext<RoleServer> {
let (mut parts, ()) = axum::http::Request::new(()).into_parts();
if let Some(scope) = scope {
parts.extensions.insert(scope);
}
let mut context = RequestContext::<RoleServer>::new(RequestId::Number(1), peer);
context.extensions.insert(parts);
context
}
/// A peer for a hand-built context, minted by serving a clone over a
/// duplex (the SDK exposes no other constructor). No tool call flows
/// through that session.
async fn peer_of(server: &BugWarden) -> rmcp::service::Peer<RoleServer> {
let (client_io, server_io) = tokio::io::duplex(1 << 16);
let serving = server.clone();
let task = tokio::spawn(async move { serving.serve(server_io).await });
let _client = bounded("the MCP handshake", ().serve(client_io))
.await
.expect("MCP handshake must succeed");
task.await
.expect("serve task must not panic")
.expect("server must serve")
.peer()
.clone()
}
/// The `_meta` a 2026-07-28 request carries: the two keys rmcp's
/// `missing_required_keys` demands, plus a `clientInfo` when one is
/// declared. The identity goes in as raw JSON under the literal wire
/// key, so a MALFORMED declaration is expressible — the shape no rmcp
/// client builds and the one `client_of` must fail to absent on.
fn per_request_meta(version: ProtocolVersion, client: Option<Value>) -> RequestMetaObject {
let mut meta = RequestMetaObject::new();
meta.set_protocol_version(version);
meta.set_client_capabilities(ClientCapabilities::default());
if let Some(client) = client {
meta.insert("io.modelcontextprotocol/clientInfo".to_string(), client);
}
meta
}
/// `bug_url` for bug 7 — the one tool that contacts nothing, so a
/// direct call needs no upstream and records exactly one event.
fn local_call() -> CallToolRequestParams {
let Value::Object(args) = json!({ "bug_id": 7 }) else {
panic!("tool arguments must be a JSON object");
};
CallToolRequestParams::new("bug_url".to_string()).with_arguments(args)
}
#[tokio::test]
async fn a_per_request_identity_comes_from_meta_and_never_from_the_peer() {
// The trap #34 §3(c) names, sharpened to the line that decides it.
// The peer here is a REAL handshaken one whose stored `client_info`
// IS `Implementation::default()` — rmcp's own crate name and
// version, exactly the value the http handshake-free path
// synthesises (`peer_info_for_stateless_request`). So `client_of`
// reaching for `peer_info()`, or for the convenience
// `ctx.client_info()` — which falls back to it unless
// `request_metadata_required()`, never set over streamable http —
// records `rmcp` on every row below. The precondition is asserted,
// not assumed: were rmcp to stop seeding that value, this test
// would go on passing while proving nothing.
let dir = tempfile::tempdir().unwrap();
let (audit, audit_path) = audit_state(dir.path(), FailMode::Open);
let (cfg, guard, bz) = parts("");
let server = BugWarden::new(cfg, guard, bz)
.expect("server must build")
.with_audit(Arc::clone(&audit));
let peer = peer_of(&server).await;
assert_eq!(
peer.peer_info()
.expect("the duplex handshake stores peer info")
.client_info
.name,
Implementation::default().name,
"the sharpener needs a peer whose stored identity is the placeholder"
);
// (row, the declared `clientInfo`, the name the record must carry)
let rows: [(&str, Option<Value>, Option<&str>); 4] = [
("no clientInfo at all", None, None),
("a malformed clientInfo", Some(json!("banana")), None),
(
"a clientInfo missing its version",
Some(json!({ "name": "half" })),
None,
),
(
"a well-formed clientInfo",
Some(json!({ "name": "wire-client", "version": "9.9" })),
Some("wire-client"),
),
];
for (row, client, _) in &rows {
let mut context = RequestContext::<RoleServer>::new(RequestId::Number(1), peer.clone());
context.meta = per_request_meta(ProtocolVersion::V_2026_07_28, client.clone());
bounded(
&format!("{row}: the in-process call"),
ServerHandler::call_tool(&server, local_call(), context),
)
.await
.unwrap_or_else(|e| panic!("{row}: a per-request call is served: {e}"));
}
let events = read_audit_events(&audit_path);
let calls = tool_calls(&events);
assert_eq!(
calls.len(),
rows.len(),
"one record per call, in call order"
);
for (call, (row, _, expected)) in calls.iter().zip(rows) {
assert_eq!(
call.client.name.as_deref(),
expected,
"{row}: the record must name the request's own client, or nobody"
);
// All-or-nothing: `decode_value` yields neither field or both,
// so a half-declared identity is never half-recorded.
assert_eq!(
call.client.version.is_some(),
expected.is_some(),
"{row}: name and version must be absent or present together"
);
}
}
/// A peer from a real handshake in which the client called itself
/// `name`, so a recorded identity can be told apart from the SDK's
/// placeholder AND from anything the request smuggled.
async fn peer_greeted_as(server: &BugWarden, name: &str) -> rmcp::service::Peer<RoleServer> {
let (client_io, server_io) = tokio::io::duplex(1 << 16);
let serving = server.clone();
let task = tokio::spawn(async move { serving.serve(server_io).await });
let _client = bounded(
"the MCP handshake",
InitializeRequestParams::new(
ClientCapabilities::default(),
Implementation::new(name.to_owned(), "1"),
)
.serve(client_io),
)
.await
.expect("MCP handshake must succeed");
task.await
.expect("serve task must not panic")
.expect("server must serve")
.peer()
.clone()
}
#[tokio::test]
async fn a_handshake_identity_is_never_displaced_by_a_smuggled_meta_client() {
// The other side of the same trap, and the one a per-request test
// cannot reach. `_meta` carrying `clientInfo` but NO
// `protocolVersion` stays on the handshake arm — `lifecycle_of`
// reads the version key alone — yet it is a shape rmcp will happily
// deliver over the wire. `ctx.client_info()` prefers `_meta` before
// falling back to the peer, so a handshake arm written with the
// convenience accessor records the SMUGGLED identity while every
// ordinary session keeps working: it survives the whole suite
// otherwise. The peer is greeted under a distinct name so the two
// candidate answers cannot be confused.
let dir = tempfile::tempdir().unwrap();
let (audit, audit_path) = audit_state(dir.path(), FailMode::Open);
let (cfg, guard, bz) = parts("");
let server = BugWarden::new(cfg, guard, bz)
.expect("server must build")
.with_audit(Arc::clone(&audit));
let peer = peer_greeted_as(&server, "greeted-client").await;
let mut context = RequestContext::<RoleServer>::new(RequestId::Number(1), peer);
let mut meta = RequestMetaObject::new();
meta.insert(
"io.modelcontextprotocol/clientInfo".to_string(),
json!({ "name": "smuggled-client", "version": "9.9" }),
);
context.meta = meta;
assert!(
context.meta.protocol_version().is_none(),
"no version key, so this request belongs to the handshake arm"
);
bounded(
"the in-process call",
ServerHandler::call_tool(&server, local_call(), context),
)
.await
.expect("an ordinary in-session call is served");
let events = read_audit_events(&audit_path);
let calls = tool_calls(&events);
assert_eq!(calls.len(), 1, "exactly the direct call is recorded");
assert_eq!(
calls[0].client.name.as_deref(),
Some("greeted-client"),
"the handshake arm names the client the server GREETED, never one \
the request smuggled into `_meta`"
);
assert_eq!(calls[0].client.version.as_deref(), Some("1"));
}
#[tokio::test]
async fn a_stdio_per_request_call_keeps_the_process_session_id() {
// The deliberate stdio/http asymmetry, which was documented and
// pinned by nothing. Over http the per-request path records no
// `session.id` because the only candidate is a forgeable request
// header; over stdio the id is minted from the process, so there is
// nothing to forge and the record keeps it. Scoping the absence to
// http is the whole decision, and dropping the id here — the
// obvious "make both paths agree" edit — survived the entire suite
// before this row.
let dir = tempfile::tempdir().unwrap();
let (audit, audit_path) = audit_state(dir.path(), FailMode::Open);
let (cfg, guard, bz) = parts("");
assert_eq!(
cfg.transport,
Transport::Stdio,
"the asymmetry only exists on a stdio deployment"
);
let server = BugWarden::new(cfg, guard, bz)
.expect("server must build")
.with_audit(Arc::clone(&audit));
let peer = peer_of(&server).await;
let mut context = RequestContext::<RoleServer>::new(RequestId::Number(1), peer);
context.meta = per_request_meta(
ProtocolVersion::V_2026_07_28,
Some(json!({ "name": "stdio-client", "version": "1" })),
);
bounded(
"the in-process call",
ServerHandler::call_tool(&server, local_call(), context),
)
.await
.expect("a per-request call is served over stdio too");
let events = read_audit_events(&audit_path);
let calls: Vec<&AuditEvent> = events
.iter()
.filter(|e| matches!(e.kind, AuditEventKind::ToolCall(_)))
.collect();
assert_eq!(calls.len(), 1, "exactly the direct call is recorded");
assert_eq!(
calls[0].session.transport,
audit::TransportKind::Stdio,
"the row is only meaningful on the stdio transport"
);
assert_eq!(
calls[0].session.id.as_deref(),
Some(audit.stdio_session_id.as_str()),
"a stdio per-request call keeps the process-scoped id: it is minted, \
not read off a request, so the http rationale does not apply"
);
}
#[tokio::test]
async fn both_recording_sites_cap_a_declared_client_identity() {
// A client name is client-controlled text headed for an append-only
// file, so it is capped exactly like an audited parameter value.
// Both sites, because one helper covers both and because the
// per-request one is the reason the cap is needed at all: the
// handshake wrote its string once per session, `_meta` repeats it
// into every record of every call.
let long_name = "n".repeat(PARAM_VALUE_MAX_CHARS * 3);
let long_version = "v".repeat(PARAM_VALUE_MAX_CHARS + 1);
let dir = tempfile::tempdir().unwrap();
let (audit, audit_path) = audit_state(dir.path(), FailMode::Open);
let (cfg, guard, bz) = parts("");
let server = BugWarden::new(cfg, guard, bz)
.expect("server must build")
.with_audit(Arc::clone(&audit));
let (client_io, server_io) = tokio::io::duplex(1 << 16);
let serving = server.clone();
let task = tokio::spawn(async move { serving.serve(server_io).await });
let _client = bounded(
"the MCP handshake",
InitializeRequestParams::new(
ClientCapabilities::default(),
Implementation::new(long_name.clone(), long_version.clone()),
)
.serve(client_io),
)
.await
.expect("MCP handshake must succeed");
let peer = task
.await
.expect("serve task must not panic")
.expect("server must serve")
.peer()
.clone();
let mut context = RequestContext::<RoleServer>::new(RequestId::Number(1), peer);
context.meta = per_request_meta(
ProtocolVersion::V_2026_07_28,
Some(json!({ "name": long_name, "version": long_version })),
);
bounded(
"the in-process call",
ServerHandler::call_tool(&server, local_call(), context),
)
.await
.expect("a per-request call is served");
let events = read_audit_events(&audit_path);
let recorded: Vec<&audit::ClientInfo> = events
.iter()
.map(|e| match &e.kind {
AuditEventKind::Initialize(ev) => &ev.client,
AuditEventKind::ToolCall(ev) => &ev.client,
AuditEventKind::AuditGap(_) => panic!("no gap may occur here"),
})
.collect();
assert_eq!(recorded.len(), 2, "the handshake and the call are recorded");
for client in recorded {
assert_eq!(
client.name.as_deref().map(|n| n.chars().count()),
Some(PARAM_VALUE_MAX_CHARS),
"an over-long name is truncated to the cap, not stored whole"
);
assert_eq!(
client.version.as_deref().map(|v| v.chars().count()),
Some(PARAM_VALUE_MAX_CHARS),
"the version is capped too — it is client text on the same terms"
);
}
}
#[tokio::test]
async fn every_record_site_caps_a_client_chosen_tool_name() {
// #243: a call the router will not serve is recorded like any
// other, so `request.tool` is client text of any length — the
// channel #191 closed for a declared identity and #211 for a
// parameter value. The BOUNDARY is pinned here because only a unit
// test sees `PARAM_VALUE_MAX_CHARS`: at the cap is under it, one
// char over is cut, and 600 multi-byte chars in 1200 bytes stay
// whole — a 4x-cap probe alone survives a byte cap; the at-cap row
// pins which side the boundary falls, not a mutant (a `>=` in
// `capped()` cuts an at-cap value to itself). The last row is the
// out-of-contract site, which reads `request.name` rather than the
// routing copy: same channel, one site over, and it records before
// it refuses.
let over = "z".repeat(PARAM_VALUE_MAX_CHARS * 4);
let at_cap = "z".repeat(PARAM_VALUE_MAX_CHARS);
let one_over = "z".repeat(PARAM_VALUE_MAX_CHARS + 1);
let multibyte = "é".repeat(600);
let cut: String = over.chars().take(PARAM_VALUE_MAX_CHARS).collect();
let dir = tempfile::tempdir().unwrap();
let (audit, audit_path) = audit_state(dir.path(), FailMode::Open);
let (cfg, guard, bz) = parts("");
let server = BugWarden::new(cfg, guard, bz)
.expect("server must build")
.with_audit(Arc::clone(&audit));
let peer = peer_of(&server).await;
let rows: [(&str, &str, bool, &str); 5] = [
(
&over,
&cut,
false,
"an over-cap unknown name is cut to the cap",
),
(&at_cap, &at_cap, false, "exactly at the cap is not over it"),
(&one_over, &cut, false, "one char over the cap is cut"),
(
&multibyte,
&multibyte,
false,
"600 chars in 1200 bytes stays whole: the cap counts chars",
),
(&over, &cut, true, "the out-of-contract site caps it too"),
];
for (id, (sent, _, out_of_contract, _)) in rows.iter().enumerate() {
let mut context = RequestContext::<RoleServer>::new(
RequestId::Number(i64::try_from(id).expect("five rows")),
peer.clone(),
);
if *out_of_contract {
context.meta = per_request_meta(ProtocolVersion::V_2025_06_18, None);
}
bounded(
&format!("the {sent} call"),
ServerHandler::call_tool(
&server,
CallToolRequestParams::new((*sent).to_string()),
context,
),
)
.await
.expect_err("refused either way, and the refusal is not what moves");
}
let events = read_audit_events(&audit_path);
let calls = tool_calls(&events);
assert_eq!(
calls.len(),
rows.len(),
"one record per call, refused or not"
);
for (call, (sent, want, _, why)) in calls.iter().zip(rows) {
let got = call.request.tool.as_str();
assert!(
got == want,
"{why}: recorded {} chars of a {}-char name",
got.chars().count(),
sent.chars().count()
);
}
}
#[tokio::test]
async fn every_record_site_caps_a_client_chosen_request_id() {
// #248: `request.id` is the JSON-RPC id the CLIENT picked, and a
// string one arrives off the wire at any length — the channel
// #243 closed for the tool name, one field over. The BOUNDARY is
// pinned here because only a unit test sees
// `PARAM_VALUE_MAX_CHARS`: an over-cap id is cut to EXACTLY the
// cap (an off-by-one dies on it), one at the cap is left alone,
// and 600 multi-byte chars in 1200 bytes stay whole, which a byte
// cap would cut to 512. The last row is the out-of-contract site,
// which builds its own `RequestInfo` and records before it
// refuses.
let over = "z".repeat(PARAM_VALUE_MAX_CHARS * 4);
let at_cap = "z".repeat(PARAM_VALUE_MAX_CHARS);
let one_over = "z".repeat(PARAM_VALUE_MAX_CHARS + 1);
let multibyte = "é".repeat(600);
let cut: String = over.chars().take(PARAM_VALUE_MAX_CHARS).collect();
let dir = tempfile::tempdir().unwrap();
let (audit, audit_path) = audit_state(dir.path(), FailMode::Open);
let (cfg, guard, bz) = parts("");
let server = BugWarden::new(cfg, guard, bz)
.expect("server must build")
.with_audit(Arc::clone(&audit));
let peer = peer_of(&server).await;
let rows: [(&str, &str, bool, &str); 5] = [
(&over, &cut, false, "an over-cap id is cut to the cap"),
(&at_cap, &at_cap, false, "exactly at the cap is not over it"),
(&one_over, &cut, false, "one char over the cap is cut"),
(
&multibyte,
&multibyte,
false,
"600 chars in 1200 bytes stays whole: the cap counts chars",
),
(&over, &cut, true, "the out-of-contract site caps it too"),
];
for (sent, _, out_of_contract, _) in rows {
let mut context =
RequestContext::<RoleServer>::new(RequestId::String(Arc::from(sent)), peer.clone());
if out_of_contract {
context.meta = per_request_meta(ProtocolVersion::V_2025_06_18, None);
}
bounded(
"the no_such_tool call",
ServerHandler::call_tool(
&server,
CallToolRequestParams::new("no_such_tool".to_string()),
context,
),
)
.await
.expect_err("refused either way, and the refusal is not what moves");
}
let events = read_audit_events(&audit_path);
let calls = tool_calls(&events);
assert_eq!(
calls.len(),
rows.len(),
"one record per call, refused or not"
);
for (call, (sent, want, _, why)) in calls.iter().zip(rows) {
assert!(
call.request.id.as_deref() == Some(want),
"{why}: recorded {:?} chars of a {}-char id",
call.request.id.as_deref().map(|id| id.chars().count()),
sent.chars().count()
);
}
}
#[tokio::test]
async fn an_object_valued_param_reaches_a_record_and_is_capped_there() {
// #211's reachability half, pinned: the `Object` arm is not dead
// code. Two independent reasons it is reachable, and this call
// exercises both — no tool params struct carries
// `deny_unknown_fields`, so serde IGNORES the off-schema `groups`
// and the call SUCCEEDS; and `allowlisted` runs on the raw
// arguments BEFORE dispatch, so a struct that did reject them
// would still be recorded. Asserting the success is the point:
// a refused call would prove nothing about the served path.
let long = "z".repeat(PARAM_VALUE_MAX_CHARS * 4);
let dir = tempfile::tempdir().unwrap();
let (audit, audit_path) = audit_state(dir.path(), FailMode::Open);
let (cfg, guard, bz) = parts("");
let server = BugWarden::new(cfg, guard, bz)
.expect("server must build")
.with_audit(Arc::clone(&audit));
let peer = peer_of(&server).await;
let context = RequestContext::<RoleServer>::new(RequestId::Number(1), peer);
let Value::Object(args) = json!({
"bug_id": 7,
"groups": { "g": long },
}) else {
panic!("tool arguments must be a JSON object");
};
bounded(
"the bug_url call",
ServerHandler::call_tool(
&server,
CallToolRequestParams::new("bug_url".to_string()).with_arguments(args),
context,
),
)
.await
.expect("an off-schema object key is ignored, not rejected");
let events = read_audit_events(&audit_path);
let calls: Vec<&audit::ToolCallEvent> = events
.iter()
.filter_map(|e| match &e.kind {
AuditEventKind::ToolCall(ev) => Some(&**ev),
_ => None,
})
.collect();
let [call] = calls.as_slice() else {
panic!("exactly one tool-call record, got {}", calls.len());
};
assert_eq!(
call.outcome.class,
audit::OutcomeClass::Ok,
"the served path, not a refusal"
);
assert_eq!(
call.request.params["groups"]["g"]
.as_str()
.map(|s| s.chars().count()),
Some(PARAM_VALUE_MAX_CHARS),
"a string under an object-valued allowlisted param is capped like any other"
);
// #220: an ordinary record grows no marker. Asserted here because
// this is the one served record with no over-cap key at either
// level; without it an unconditional push is invisible.
assert!(
!call.request.params.contains_key(OVERLONG_KEYS),
"top level: {OVERLONG_KEYS} appears only when a key is over-cap"
);
assert!(
call.request.params["groups"].get(OVERLONG_KEYS).is_none(),
"nested: same, under an allowlisted object"
);
}
#[tokio::test]
async fn an_over_cap_param_key_reaches_no_record_at_either_level() {
// #220, on the served path #211 pinned: the same call carries an
// over-cap key top level (allowlisted's `(key, _)`) and under an
// allowlisted object (truncated's Object arm), plus at each level
// two more sharing a 1024-char prefix. One assert per level, so
// half a fix fails; the prefix pairs kill truncate-and-collide.
let shared = "z".repeat(PARAM_VALUE_MAX_CHARS);
let huge = "z".repeat(PARAM_VALUE_MAX_CHARS * 4);
let a = format!("{shared}-a");
let bb = format!("{shared}--bb");
let mut nested = JsonObject::new();
for key in [&huge, &a, &bb] {
nested.insert(key.clone(), json!(1));
}
let mut args = JsonObject::new();
args.insert("bug_id".to_string(), json!(7));
args.insert("groups".to_string(), Value::Object(nested));
for key in [&huge, &a, &bb] {
args.insert(key.clone(), json!(1));
}
let dir = tempfile::tempdir().unwrap();
let (audit, audit_path) = audit_state(dir.path(), FailMode::Open);
let (cfg, guard, bz) = parts("");
let server = BugWarden::new(cfg, guard, bz)
.expect("server must build")
.with_audit(Arc::clone(&audit));
let peer = peer_of(&server).await;
let context = RequestContext::<RoleServer>::new(RequestId::Number(1), peer);
bounded(
"the bug_url call",
ServerHandler::call_tool(
&server,
CallToolRequestParams::new("bug_url".to_string()).with_arguments(args),
context,
),
)
.await
.expect("off-schema keys are ignored, not rejected");
let events = read_audit_events(&audit_path);
let calls: Vec<&audit::ToolCallEvent> = events
.iter()
.filter_map(|e| match &e.kind {
AuditEventKind::ToolCall(ev) => Some(&**ev),
_ => None,
})
.collect();
let [call] = calls.as_slice() else {
panic!("exactly one tool-call record, got {}", calls.len());
};
assert_eq!(
call.outcome.class,
audit::OutcomeClass::Ok,
"the served path, not a refusal"
);
assert_eq!(
call.request.params.get("bug_id"),
Some(&json!(7)),
"the legitimate params of an oversized call are still recorded"
);
/// Every map key at or below `value`, at any depth.
fn keys(value: &Value, out: &mut Vec<String>) {
match value {
Value::Object(fields) => {
for (key, value) in fields {
out.push(key.clone());
keys(value, out);
}
}
Value::Array(items) => items.iter().for_each(|v| keys(v, out)),
_ => {}
}
}
let over_cap = |names: &[String]| -> usize {
names
.iter()
.filter(|n| n.chars().count() > PARAM_VALUE_MAX_CHARS)
.count()
};
let top: Vec<String> = call.request.params.keys().cloned().collect();
assert_eq!(over_cap(&top), 0, "top level: allowlisted's key rule");
let mut deep = Vec::new();
for value in call.request.params.values() {
keys(value, &mut deep);
}
assert_eq!(over_cap(&deep), 0, "nested: truncated's Object arm");
let faithful = |entries: &Value, level: &str| {
let entries = entries
.as_array()
.unwrap_or_else(|| panic!("{level}: _overlong_keys is an array"));
assert_eq!(
entries.len(),
3,
"{level}: three over-cap keys stay three entries"
);
for entry in entries {
assert_eq!(
entry["prefix"].as_str(),
Some(shared.as_str()),
"{level}: the marker carries capped(key), never the key"
);
}
let mut lengths: Vec<u64> = entries
.iter()
.map(|e| e["key_chars"].as_u64().expect("key_chars is a number"))
.collect();
lengths.sort_unstable();
assert_eq!(
lengths,
vec![
PARAM_VALUE_MAX_CHARS as u64 + 2,
PARAM_VALUE_MAX_CHARS as u64 + 4,
PARAM_VALUE_MAX_CHARS as u64 * 4
],
"{level}: two keys sharing the prefix keep their own lengths"
);
};
let recorded_under = |parent: &Value, level: &str| -> Value {
parent
.get(OVERLONG_KEYS)
.unwrap_or_else(|| {
panic!(
"{level}: over-cap keys belong under {OVERLONG_KEYS}, \
not truncated into map keys"
)
})
.clone()
};
let top_marker = recorded_under(
&Value::Object(call.request.params.clone().into_iter().collect()),
"top level",
);
let nested_marker = recorded_under(&call.request.params["groups"], "nested");
faithful(&top_marker, "top level");
faithful(&nested_marker, "nested");
assert!(
top_marker[0]["value"]["_len"].is_number(),
"top level: an over-cap key cannot be allowlisted, so its value \
is the _len reduction"
);
assert_eq!(
nested_marker[0]["value"],
json!(1),
"nested: the value keeps the level's own rule, truncated()"
);
}
#[tokio::test]
async fn a_meta_declaration_is_served_only_at_a_revision_this_build_serves() {
// The three-way predicate, at the handler, on one axis: the
// declared revision. BOTH conjuncts are load-bearing, and the name
// says so because an earlier one ("only a sub-2026 declaration is
// refused") was disproved by this test's own second row. A blanket
// refusal fails the served row, a blanket serve fails the refused
// ones, and dropping the `contains` guard fails the fabricated one
// — `"9999-01-01"` outranks 2026-07-28 lexically, which is how a
// version comparison alone lets an unserved revision through.
let (cfg, guard, bz) = parts("");
let server = BugWarden::new(cfg, guard, bz).expect("server must build");
let peer = peer_of(&server).await;
// (declared revision, whether the call is served)
let rows = [
("2025-11-25", false),
("9999-01-01", false),
("2026-07-28", true),
];
for (declared, served) in rows {
let version: ProtocolVersion =
serde_json::from_value(json!(declared)).expect("a wire revision parses");
let mut context = RequestContext::<RoleServer>::new(RequestId::Number(1), peer.clone());
context.meta = per_request_meta(version, None);
let answer = bounded(
&format!("{declared}: the in-process call"),
ServerHandler::call_tool(&server, local_call(), context),
)
.await;
assert_eq!(
answer.is_ok(),
served,
"{declared}: served={served}, got {answer:?}"
);
if let Err(error) = answer {
// One text for both refused rows: it must describe the
// fabricated-future one as well as the sub-2026 one, which
// a "below 2026-07-28" wording did not.
assert!(
error.message.contains("2026-07-28")
&& error.message.contains("a revision this server serves"),
"{declared}: the refusal must fit this row too: {error:?}"
);
}
}
// And a request declaring nothing keeps the handshake arm: the
// session's own revision never routes a request per-request.
let context = RequestContext::<RoleServer>::new(RequestId::Number(1), peer);
bounded(
"the in-process call",
ServerHandler::call_tool(&server, local_call(), context),
)
.await
.expect("an ordinary in-session call is served");
}
#[tokio::test]
async fn a_failing_sink_never_tells_a_read_scope_which_names_this_build_routes() {
// The audit gate answers a refused call with that TOOL's own failure
// text. Reached for a name the router would not have dispatched, it
// becomes an existence oracle that opens only while the sink is
// failing: a scope-hidden write tool, an unrouted-by-policy tool and
// an unknown name must all answer exactly as they do with a healthy
// sink. Driven under ClosedAll, which gates everything and is the
// http transport's default fail mode.
let dir = tempfile::tempdir().unwrap();
let (audit, _path) = audit_state(dir.path(), FailMode::ClosedAll);
// Discovery off (the default), so `bugzilla_products` is a KNOWN
// name this deployment does not route — the sibling of the scope
// case, and reachable by the read scope.
let (cfg, guard, bz) = parts("");
let server = BugWarden::new(cfg, guard, bz)
.expect("server must build")
.with_audit(Arc::clone(&audit))
.with_scope_enforcement(true);
let peer = peer_of(&server).await;
let answer = |name: &'static str| {
let server = server.clone();
let peer = peer.clone();
async move {
let context = context_with_scope(peer, Some(Scope::Read));
let params = CallToolRequestParams::new(name.to_string());
match bounded(
&format!("the {name} call"),
ServerHandler::call_tool(&server, params, context),
)
.await
{
Ok(ok) => format!("ok {ok:?}"),
Err(e) => format!("err {e}"),
}
}
};
let healthy = (
answer("add_comment").await,
answer("bugzilla_products").await,
answer("no_such_tool_at_all").await,
);
assert_eq!(healthy.0, healthy.1, "healthy sink: {healthy:?}");
assert_eq!(healthy.1, healthy.2, "healthy sink: {healthy:?}");
audit.sink.set_fail_writes(true);
// `failing()` turns true only after a record is actually dropped, so
// prime it: without this the first probe below still meets a
// not-yet-failing sink and the gate is skipped for the wrong reason.
let _ = answer("bug_url").await;
assert!(audit.sink.failing(), "the sink must be in failure");
let failing = (
answer("add_comment").await,
answer("bugzilla_products").await,
answer("no_such_tool_at_all").await,
);
assert_eq!(
failing.0, failing.2,
"a scope-hidden write tool must answer like an unknown name whatever \
the sink is doing"
);
assert_eq!(
failing.1, failing.2,
"a tool this policy does not route must answer like an unknown name too"
);
assert_eq!(
healthy, failing,
"sink health must not be readable off any of the three refusals"
);
}
#[tokio::test]
async fn in_process_params_meta_traceparent_enriches_the_record() {
// The params-struct arm of the trace extraction (`request.meta`,
// consulted before the `context.meta` fallback) is reachable ONLY
// by an in-process caller: over every serialized transport rmcp
// strips the wire `_meta` into the request extensions before the
// params struct is built, so `CallToolRequestParams.meta` is
// always `None` there and the duplex and streamable-http tests
// all pin the `context.meta` fallback instead. This test invokes
// `ServerHandler::call_tool` directly, with the traceparent in
// the params-struct meta and a hand-built `RequestContext` whose
// meta is empty — killing a mutant that reads only `context.meta`
// (behavior-preserving over every serialized transport, so no
// other test can).
let mock = MockServer::start().await;
mount_bug7(&mock).await;
let dir = tempfile::tempdir().unwrap();
let (audit, audit_path) = audit_state(dir.path(), FailMode::Open);
let cli: Cli = pinned(&[
"bugwarden",
"--bugzilla-server",
&mock.uri(),
"--transport",
"stdio",
"--api-key",
"test-key",
]);
let guard = Arc::new(Guard {
policy: Policy::from_toml_str("").expect("test policy must parse"),
});
let bz = Arc::new(
BugzillaClient::new(&mock.uri(), false, USER_AGENT).expect("client must build"),
);
let server = BugWarden::new(Arc::new(cli), guard, bz)
.expect("server must build")
.with_audit(Arc::clone(&audit));
// A genuine `Peer<RoleServer>` for the hand-built context, minted
// by serving a clone over a duplex (the SDK exposes no other
// constructor); no tool call flows through that session, so the
// direct call below writes the only tool_call record.
let (client_io, server_io) = tokio::io::duplex(1 << 16);
let serving = server.clone();
let server_task = tokio::spawn(async move { serving.serve(server_io).await });
let _client = bounded("the MCP handshake", ().serve(client_io))
.await
.expect("MCP handshake must succeed");
let running = server_task
.await
.expect("serve task must not panic")
.expect("server must serve");
let peer = running.peer().clone();
let Value::Object(args) = json!({ "id": 7 }) else {
panic!("tool arguments must be a JSON object");
};
let mut params = CallToolRequestParams::new("bug_history".to_string()).with_arguments(args);
let mut meta = rmcp::model::RequestMetaObject::new();
meta.set_traceparent(TRACEPARENT);
params.meta = Some(meta);
let context = RequestContext::<RoleServer>::new(RequestId::Number(1), peer);
assert!(
context.meta.get_traceparent().is_none(),
"the hand-built context must carry no meta — the params struct is the only source"
);
let result = bounded(
"the traced in-process call",
ServerHandler::call_tool(&server, params, context),
)
.await
.expect("the in-process call must not be a protocol error");
let CallToolResponse::Complete(result) = result else {
panic!("a served tool call completes; this build serves no other response kind")
};
assert!(!is_error(&result), "bug_history must succeed");
let events = read_audit_events(&audit_path);
let calls = tool_calls(&events);
assert_eq!(calls.len(), 1, "exactly the direct call is recorded");
assert_trace_is_canonical(calls[0].trace.as_ref());
}
#[tokio::test]
async fn a_2026_request_gets_private_immediately_stale_cache_hints() {
// The enabled half of the gate, from BOTH of its two sources —
// `RequestContext::protocol_version()` reads the request's own
// `_meta` first and the session's negotiated revision second.
//
// The second row is the one that carries weight. A mutant reading
// only `peer_info().protocol_version` survives every wire test:
// over http the handshake-free path's SYNTHESISED peer is stamped
// with the same 2026-07-28 the `_meta` names
// (`peer_info_for_stateless_request`), so the two sources agree
// there and the request half is never observed alone. A bare peer
// with no `peer_info` at all is the only shape that separates
// them, and rmcp really does hand one out — its stdio
// handshake-free lifecycle builds the peer with `None`
// (`service/server.rs`). Its wire-level companion is
// `a_per_request_listing_carries_the_cache_hints` in
// http_transport_wiremock.
let (cfg, guard, bz) = parts("");
let server = BugWarden::new(cfg, guard, bz).expect("server must build");
let negotiated = peer_of(&server).await;
negotiated.set_peer_info(
ClientInfo::default().with_protocol_version(ProtocolVersion::V_2026_07_28),
);
let session_row = RequestContext::<RoleServer>::new(RequestId::Number(1), negotiated);
assert!(
session_row.meta.protocol_version().is_none(),
"the session's revision, not the request's, must open the gate here"
);
let (client_io, _server_io) = tokio::io::duplex(1 << 16);
let running: RunningService<RoleServer, BugWarden> =
rmcp::service::serve_directly(server.clone(), client_io, None);
let bare = running.peer().clone();
assert!(
bare.peer_info().is_none(),
"the request row must have no session revision to fall back to"
);
let mut request_row = RequestContext::<RoleServer>::new(RequestId::Number(1), bare);
request_row.meta = per_request_meta(ProtocolVersion::V_2026_07_28, None);
for (row, context) in [("negotiated session", session_row), ("_meta", request_row)] {
let listed = bounded(
&format!("{row}: the in-process tools/list"),
ServerHandler::list_tools(&server, None, context),
)
.await
.expect("a 2026 listing is served");
// On the JSON, not the enum: the wire spelling is what a caching
// intermediary reads, and `CacheScope` renames lowercase.
let json = serde_json::to_value(&listed).expect("the listing must serialize");
assert_eq!(
json["cacheScope"],
json!("private"),
"{row}: a per-deployment, per-credential listing is never publicly cacheable"
);
assert_eq!(
json["ttlMs"],
json!(0),
"{row}: a memory-served listing is stale on arrival"
);
assert!(
!listed.tools.is_empty(),
"{row}: the hints must ride a real listing, not a refusal or an empty one"
);
}
}
#[tokio::test]
async fn cache_hints_stay_absent_below_2026_and_with_no_peer_info() {
// `skip_serializing_if` makes absence the only observable. The
// first row is every client alive today. The second is the `None`
// arm — no `_meta` revision and no session revision either, which
// rmcp's handshake-free lifecycle really does build
// (`service/server.rs`), though its own contract then mandates
// per-request `_meta` on every request that follows. Pinned
// anyway: a handler with no revision to read must fail toward the
// legacy wire shape, never toward emission.
let (cfg, guard, bz) = parts("");
let server = BugWarden::new(cfg, guard, bz).expect("server must build");
let negotiated = peer_of(&server).await;
assert_eq!(
negotiated
.peer_info()
.expect("the duplex handshake stores peer info")
.protocol_version,
DEFAULT_PROTOCOL_VERSION,
"the negotiated row must actually be pre-2026"
);
// No handshake, so no peer info at all — the shape rmcp hands a
// handler when it cannot name the caller's revision.
let (client_io, _server_io) = tokio::io::duplex(1 << 16);
let running: RunningService<RoleServer, BugWarden> =
rmcp::service::serve_directly(server.clone(), client_io, None);
let bare = running.peer().clone();
assert!(
bare.peer_info().is_none(),
"the bare row must carry no revision at all"
);
for (row, peer) in [
("negotiated 2025-11-25", negotiated),
("no peer info", bare),
] {
let context = RequestContext::<RoleServer>::new(RequestId::Number(1), peer);
let listed = bounded(
&format!("{row}: the in-process tools/list"),
ServerHandler::list_tools(&server, None, context),
)
.await
.expect("a legacy listing is served, not refused");
let json = serde_json::to_value(&listed).expect("the listing must serialize");
assert!(
json.get("ttlMs").is_none(),
"{row}: ttlMs must be absent from the wire"
);
assert!(
json.get("cacheScope").is_none(),
"{row}: cacheScope must be absent from the wire"
);
assert!(
!listed.tools.is_empty(),
"{row}: absence must be read off a real listing"
);
}
}
#[tokio::test]
async fn initialize_echoes_exactly_the_revisions_this_build_serves() {
// Both halves of the negotiation, in both directions. This
// handler tests the request against `SUPPORTED_PROTOCOL_VERSIONS`;
// the SDK negotiates again after the handler returns, taking the
// handler's value as its fallback — so a handler that echoed an
// unsupported request would make the SDK echo it too, and a
// handler that refused a supported one would silently downgrade
// every modern client.
//
// `"9999-01-01"` carries the refusing row because widening the
// list left no KNOWN-but-unserved revision: rmcp's
// `KNOWN_VERSIONS` now equals what this build serves. It is not a
// typo — `ProtocolVersion` deserializes an unknown string through
// and every rmcp comparison is lexical, so a fabricated future
// date outranks every real revision.
let mock = MockServer::start().await;
let dir = tempfile::tempdir().unwrap();
let (audit, audit_path) = audit_state(dir.path(), FailMode::Open);
let cli: Cli = pinned(&[
"bugwarden",
"--bugzilla-server",
&mock.uri(),
"--transport",
"stdio",
"--api-key",
"test-key",
]);
let guard = Arc::new(Guard {
policy: Policy::from_toml_str("").expect("test policy must parse"),
});
let bz = Arc::new(
BugzillaClient::new(&mock.uri(), false, USER_AGENT).expect("client must build"),
);
let server = BugWarden::new(Arc::new(cli), guard, bz)
.expect("server must build")
.with_audit(Arc::clone(&audit));
// (client name, requested revision, the revision the session must speak)
let rows = [
(
"fabricating-client",
"9999-01-01",
DEFAULT_PROTOCOL_VERSION.as_str(),
),
(
"modern-client",
ProtocolVersion::V_2026_07_28.as_str(),
ProtocolVersion::V_2026_07_28.as_str(),
),
];
for (name, requested, expected) in rows {
let (client_io, server_io) = tokio::io::duplex(1 << 16);
let serving = server.clone();
tokio::spawn(async move {
if let Ok(running) = serving.serve(server_io).await {
let _ = running.waiting().await;
}
});
let asked: ProtocolVersion =
serde_json::from_value(json!(requested)).expect("a wire revision parses");
// rmcp serves a `ClientInfo` as its own handler, so this value
// is what the client asks for on the wire.
let probe = InitializeRequestParams::new(
ClientCapabilities::default(),
Implementation::new(name, "1"),
)
.with_protocol_version(asked);
let client = bounded("the MCP handshake", probe.serve(client_io))
.await
.expect("the handshake must succeed");
assert_eq!(
client
.peer_info()
.expect("the server answers initialize")
.protocol_version
.as_str(),
expected,
"{requested}: the session must speak the negotiated revision"
);
let events = read_audit_events(&audit_path);
let recorded = events
.iter()
.find_map(|e| match &e.kind {
AuditEventKind::Initialize(ev) if ev.client.name.as_deref() == Some(name) => {
ev.protocol_version.clone()
}
_ => None,
})
.unwrap_or_else(|| panic!("{requested}: the handshake is recorded"));
assert_eq!(
recorded, expected,
"{requested}: the record names the revision the session speaks, \
not the one requested"
);
}
}
#[tokio::test]
async fn a_second_initialize_stores_only_what_the_server_answered() {
// rmcp corrects `peer_info` to the negotiated revision only on its
// own handshake path, and gates re-initialization nowhere: a second
// `initialize` lands in this handler through `serve_inner`, so
// whatever it stores is final. Storing the request verbatim let a
// client set its own session's `protocol_version()` — which the
// response shaping and, from #34 stage 2, per-request identity all
// read — to a revision this build refuses, or to a string no
// revision has ever had. The invariant is not that a second
// `initialize` cannot move the session (a supported one does, in
// the last row below): it is that it can only move it to what the
// server ANSWERED. Driven over a real duplex session because the
// defect is the SECOND request, not the handler in isolation.
let (cfg, guard, bz) = parts("");
let server = BugWarden::new(cfg, guard, bz).expect("server must build");
let (client_io, server_io) = tokio::io::duplex(1 << 16);
let serving = server.clone();
let server_task = tokio::spawn(async move { serving.serve(server_io).await });
let client = bounded("the MCP handshake", ().serve(client_io))
.await
.expect("the handshake must succeed");
// Held, not dropped: this is the live session's own peer, and it is
// the only way to read back what the handler stored.
let running = server_task
.await
.expect("serve task must not panic")
.expect("server must serve");
let stored = || {
running
.peer()
.peer_info()
.expect("the handshake stores peer info")
.protocol_version
.clone()
};
assert_eq!(
stored(),
DEFAULT_PROTOCOL_VERSION,
"the session starts here"
);
// "9999-01-01" is not a typo: `ProtocolVersion` deserializes an
// unknown string through, and every version comparison in rmcp is
// lexical, so a fabricated future date outranks every real
// revision. Since #34 stage 2 it is the ONLY refusable probe left —
// 2026-07-28 is served, and moves the session in the last row.
let asked: ProtocolVersion =
serde_json::from_value(json!("9999-01-01")).expect("a wire revision parses");
let second =
client
.peer()
.send_request(ClientRequest::InitializeRequest(InitializeRequest::new(
ClientInfo::default().with_protocol_version(asked),
)));
let answered = bounded("the second initialize", second)
.await
.expect("a second initialize is answered, not dropped");
let ServerResult::InitializeResult(answered) = answered else {
panic!("initialize answers with an InitializeResult")
};
assert_eq!(
answered.protocol_version, DEFAULT_PROTOCOL_VERSION,
"a fabricated revision must be answered with the negotiated one"
);
// The half that was wrong: the answer already said 2025-11-25
// while the stored value said otherwise, so no client-visible
// assertion could catch it.
assert_eq!(
stored(),
DEFAULT_PROTOCOL_VERSION,
"the stored revision must be the negotiated one, not the requested one"
);
// The other direction, and the only place the store is observable
// at all: rmcp overwrites whatever this handler put there on the
// FIRST handshake, so every assertion above would also hold with no
// store at all. A second `initialize` naming a DIFFERENT supported
// revision legitimately moves the session — that is what
// `set_peer_info` is for — and the failure to catch here is the
// mirror of the one fixed: stored disagreeing with answered. Both
// ends of the served range, so a list that lost either end fails.
for probe in [ProtocolVersion::V_2024_11_05, ProtocolVersion::V_2026_07_28] {
let again = client.peer().send_request(ClientRequest::InitializeRequest(
InitializeRequest::new(
InitializeRequestParams::new(
ClientCapabilities::default(),
Implementation::new("renegotiating-client", "9.9"),
)
.with_protocol_version(probe.clone()),
),
));
let answered = bounded(&format!("{probe}: the renegotiating initialize"), again)
.await
.expect("a supported renegotiation is answered");
let ServerResult::InitializeResult(answered) = answered else {
panic!("initialize answers with an InitializeResult")
};
assert_eq!(
answered.protocol_version, probe,
"{probe}: a supported revision is echoed, so the session really does move"
);
assert_eq!(
stored(),
answered.protocol_version,
"{probe}: the stored revision must equal the one the server answered"
);
let stored_client = running
.peer()
.peer_info()
.expect("the handshake stores peer info")
.client_info
.clone();
// The identity is re-declared wholesale, so it must follow — and
// it must not be the SDK's own build identity, which is what
// `Implementation::default()` is and what #34 §3(c) calls the
// plausible wrong value an audit trail can least afford.
assert_eq!(stored_client.name, "renegotiating-client");
assert_eq!(stored_client.version, "9.9");
assert_ne!(
stored_client.name,
Implementation::default().name,
"{probe}: the stored client must never degrade to the SDK placeholder"
);
}
}
#[test]
fn mcp_server_info_reports_the_identity_the_handshake_advertises() {
let (cfg, guard, bz) = parts("");
let server = BugWarden::new(cfg, guard, bz).expect("server must build");
// The values, not just the constants: comparing `advertised.name`
// to `SERVER_NAME` alone would hold for any value either of them
// took, so an empty or renamed constant would pass.
let advertised = server.get_info().server_info;
assert_eq!(advertised.name, "bugwarden");
assert_eq!(advertised.version, env!("CARGO_PKG_VERSION"));
// `ServerInfo::new` seeds `server_info` with
// `Implementation::from_build_env()`, whose `env!`s expand inside
// rmcp, so `get_info` starts from the SDK's identity and only the
// explicit `with_server_info` displaces it: dropping that call, or
// refactoring back to the constructor, must fail here (issue #53).
assert_ne!(
advertised.name,
Implementation::from_build_env().name,
"the handshake must not borrow the SDK's crate name"
);
let reported: Value = serde_json::from_str(&text_of(
&server.mcp_server_info().expect("the tool answers"),
))
.expect("the tool returns JSON");
assert_eq!(
reported["name"], advertised.name,
"the tool and the handshake must not name two different servers"
);
assert_eq!(reported["version"], advertised.version);
}
#[test]
fn rmcp_default_still_names_the_sdk() {
// A pin on the SDK, not on this build, and the reason inheriting
// its identity is no substitute for building one: `Default` is
// `from_build_env()`, which expands its `env!`s inside rmcp. If a
// future rmcp changes that, this is the notice to rewrite the trap
// note in DESIGN.md — nothing here is wrong when it fails.
assert_eq!(
Implementation::default().name,
Implementation::from_build_env().name
);
assert_ne!(Implementation::default().name, SERVER_NAME);
}
#[tokio::test]
async fn the_handshake_names_this_build_over_a_real_session() {
// Asserted on a served session rather than on `get_info()` alone:
// this is the field as a client reads it, which means it survived
// serialization into `ServerPeerInfo.server_info` — `Option` on
// that side, so "no identity at all" is a shape the wire allows.
let (cfg, guard, bz) = parts("");
let server = BugWarden::new(cfg, guard, bz).expect("server must build");
let (client_io, server_io) = tokio::io::duplex(1 << 16);
tokio::spawn(async move {
if let Ok(running) = server.serve(server_io).await {
let _ = running.waiting().await;
}
});
let client = bounded("the MCP handshake", ().serve(client_io))
.await
.expect("the handshake must succeed");
let advertised = client
.peer_info()
.expect("the server answers initialize")
.server_info
.clone()
.expect("the handshake carries a serverInfo");
// The name is spelled out — it is meant to be stable, so a package
// rename should fail here and be noticed — while the version is
// read from the environment, since it moves every release.
assert_eq!(
advertised.name, "bugwarden",
"a client must be told which server answered, not which SDK it was built on"
);
assert_eq!(advertised.version, env!("CARGO_PKG_VERSION"));
// `title` is the field a client DISPLAYS in preference to `name`,
// so an SDK identity parked there would reproduce #53 in the only
// place a human looks, while every assertion above still passed.
assert_eq!(
advertised.title, None,
"nothing may be displayed in place of the name asserted here"
);
}
#[tokio::test]
async fn bugzilla_requests_name_this_build_and_never_the_library() {
// Driven through `bugzilla_client`, the constructor `main` calls, so
// an identity lost between the constant and the wiring fails here
// instead of reaching Bugzilla as an anonymous request (issue #55).
let mock = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/version"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({ "version": "5.0.4" })))
.mount(&mock)
.await;
let cli: Cli = pinned(&[
"bugwarden",
"--bugzilla-server",
&mock.uri(),
"--transport",
"stdio",
"--api-key",
"test-key",
]);
let bz = bugzilla_client(&cli).expect("client must build");
bz.version("test-key").await.expect("version must parse");
let requests = mock.received_requests().await.expect("recording enabled");
let agent = requests
.first()
.expect("the request reached the mock")
.headers
.get("user-agent")
.and_then(|v| v.to_str().ok())
.unwrap_or_default()
.to_string();
// The name and the repository are spelled out rather than read
// back from the same `env!`s the code reads: comparing the header
// to the manifest it was built from would agree with any value the
// manifest took, including a repository pointing somewhere else.
// Only the version comes from the environment, since it moves every
// release and pinning it would fail on each bump.
assert_eq!(
agent,
format!(
"bugwarden/{} (+https://github.com/plusky/bugwarden)",
env!("CARGO_PKG_VERSION")
),
"Bugzilla's operator must be able to tell what reached them"
);
assert!(
!agent.contains("bugwarden-core"),
"the library crate must never be the name in Bugzilla's access log: {agent}"
);
// The two directions of the same identity: what a client is told in
// the handshake and what Bugzilla is told on every request are one
// build, and cannot drift into naming two.
let identity = server_identity();
assert!(
agent.starts_with(&format!("{}/{}", identity.name, identity.version)),
"the wire identity must match the handshake's {identity:?}: {agent}"
);
}
#[test]
fn no_policy_cap_still_bounds_the_request_body() {
// `0` means the guard imposes no attachment ceiling. It must NOT
// mean an unbounded POST body: the transport buffers what it
// accepts, so an unbounded body is an unbounded-memory lever for
// anyone who can reach the port.
assert_eq!(max_request_body_bytes(0), 4 * 1024 * 1024);
}
#[test]
fn the_default_attachment_cap_leaves_the_body_cap_at_the_floor() {
// 2 MiB decoded expands to ~2.67 MiB encoded plus 1 MiB framing —
// still under the floor, so the default policy serves exactly the
// 4 MiB this build served before the cap was derived.
let default_cap = Policy::default().global.max_attachment_bytes;
assert_eq!(default_cap, 2 * 1024 * 1024, "the default policy moved");
assert_eq!(max_request_body_bytes(default_cap), MAX_REQUEST_BODY_FLOOR);
}
#[test]
fn a_raised_attachment_cap_raises_the_body_cap() {
// The bug in #52: at 3 MiB decoded the base64 payload alone is
// 4 MiB, so the old pin refused an upload the policy permitted.
let cap = 3 * 1024 * 1024;
let derived = max_request_body_bytes(cap);
assert!(
derived > MAX_REQUEST_BODY_FLOOR,
"a cap the floor cannot carry must raise it: {derived}"
);
assert_eq!(derived, 3 * 1024 * 1024 / 3 * 4 + 1024 * 1024);
}
#[test]
fn the_encoded_size_rounds_up_to_the_base64_quantum() {
// A cap that is not a multiple of 3: base64 pads the trailing group
// out to four characters, so the encoded size rounds UP. Truncating
// division would size the transport one quantum short of the
// largest upload the policy permits — the #52 bug in miniature, and
// invisible to any test whose cap divides by 3.
let cap = 3 * 1024 * 1024 + 1;
assert_eq!(
max_request_body_bytes(cap),
(1024 * 1024 + 1) * 4 + 1024 * 1024
);
}
#[test]
fn a_cap_past_the_ceiling_clamps_to_it() {
// 47.25 MiB decoded is the largest cap the ceiling can carry:
// ceil(n / 3) * 4 + 1 MiB lands exactly on 64 MiB. Just under it
// the derivation is still doing arithmetic, not clamping; just over
// it the memory bound takes precedence over the policy's wish.
const EXACTLY_THE_CEILING: u64 = 49_545_216;
assert_eq!(
max_request_body_bytes(EXACTLY_THE_CEILING),
MAX_REQUEST_BODY_CEILING
);
assert_eq!(
max_request_body_bytes(EXACTLY_THE_CEILING - 3),
MAX_REQUEST_BODY_CEILING - 4,
"below the ceiling the cap must still follow the policy"
);
assert_eq!(
max_request_body_bytes(EXACTLY_THE_CEILING + 3),
MAX_REQUEST_BODY_CEILING
);
}
#[test]
fn an_enormous_attachment_cap_clamps_instead_of_overflowing() {
// Operator input, so u64::MAX is reachable — an "unlimited" spelled
// as a huge number, or a typo. Neither may become an unbounded POST
// body: the transport buffers what it accepts before any guard
// runs, so the memory bound has to survive every input. The
// arithmetic saturates on the way (at three quarters of the u64
// range the framing headroom overflows; at u64::MAX the base64
// expansion does) rather than panicking in debug or wrapping to a
// tiny cap in release, and the clamp then lands on the ceiling.
assert_eq!(
max_request_body_bytes(u64::MAX / 4 * 3),
MAX_REQUEST_BODY_CEILING
);
assert_eq!(max_request_body_bytes(u64::MAX), MAX_REQUEST_BODY_CEILING);
}
fn server_with_attachment_cap(max_attachment_bytes: u64) -> BugWarden {
let cli: Cli = pinned(&[
"bugwarden",
"--bugzilla-server",
"https://bugzilla.example.com",
"--transport",
"http",
]);
let mut policy = Policy::default();
policy.global.max_attachment_bytes = max_attachment_bytes;
let guard = Arc::new(Guard { policy });
let bz = Arc::new(
BugzillaClient::new("https://bugzilla.example.com", false, USER_AGENT)
.expect("client must build"),
);
BugWarden::new(Arc::new(cli), guard, bz).expect("server must build")
}
#[test]
fn both_transports_are_sized_from_the_same_request_cap() {
// The seam #234 added: one derivation, two enforcement points. A
// stdio cap written as its own constant would pass every other test
// in this file and drift from the http one on the next policy
// change.
for max_attachment_bytes in [0, 2 * 1024 * 1024, 8 * 1024 * 1024, u64::MAX] {
let server = server_with_attachment_cap(max_attachment_bytes);
let cap = server.request_body_cap();
assert_eq!(
cap,
max_request_body_bytes(max_attachment_bytes),
"the shared cap must be the derivation, at {max_attachment_bytes}"
);
let (config, _logs) = crate::testlog::capture_logs(|| server.http_server_config());
let config = config.expect("no allowed hosts, so no startup error");
assert_eq!(
config.max_request_body_bytes, cap,
"http must serve the shared cap, at {max_attachment_bytes}"
);
let (stdin, _stdout) = server.stdio_transport();
assert_eq!(
stdin.cap(),
cap,
"stdio must serve the shared cap, at {max_attachment_bytes}"
);
}
}
fn http_server_with_hosts(hosts: Vec<String>) -> BugWarden {
http_server_with(hosts, None)
}
fn http_server_with(hosts: Vec<String>, api_key: Option<String>) -> BugWarden {
let mut cli: Cli = pinned(&[
"bugwarden",
"--bugzilla-server",
"https://bugzilla.example.com",
"--transport",
"http",
]);
cli.api_key = api_key;
cli.allowed_hosts = hosts;
let guard = Arc::new(Guard {
policy: Policy::default(),
});
let bz = Arc::new(
BugzillaClient::new("https://bugzilla.example.com", false, USER_AGENT)
.expect("client must build"),
);
BugWarden::new(Arc::new(cli), guard, bz).expect("server must build")
}
fn host_validation_line(logs: &crate::testlog::Captured) -> &str {
logs.as_str()
.lines()
.find(|line| line.contains("Host validation:"))
.unwrap_or_else(|| panic!("no Host validation startup line in: {}", logs.as_str()))
}
#[test]
fn http_server_config_logs_host_validation_off_when_the_list_is_empty() {
// Deleting this info line is the #117 regression: a typo'd
// MCP_ALLOWED_HOSTS left validation silently off. The level is
// pinned so a debug/trace rewrite would hide it from the default
// subscriber.
let server = http_server_with_hosts(Vec::new());
let (config, logs) = crate::testlog::capture_logs(|| server.http_server_config());
let config = config.expect("empty is unset, not an error");
assert!(
config.allowed_hosts.is_empty(),
"validation off means rmcp's list is empty, got {:?}",
config.allowed_hosts
);
let line = host_validation_line(&logs);
assert!(
line.contains("INFO"),
"Host validation must be logged at info: {line}"
);
assert!(
line.contains("Host validation: off"),
"the off state must be named: {line}"
);
assert!(
!line.contains("allowed hosts:"),
"off must not print a list: {line}"
);
}
#[test]
fn http_server_config_logs_the_resolved_list_and_never_key_material_i12() {
// A canary in the config so logging the key itself fails this test.
// Debug of Cli redacts it; the raw value must not appear (I12).
let server = http_server_with(
vec!["mcp.example.org:8000".into(), "backup.example".into()],
Some("hush-key-i12".into()),
);
let (config, logs) = crate::testlog::capture_logs(|| server.http_server_config());
let config = config.expect("matchable hosts");
assert_eq!(
config.allowed_hosts,
["mcp.example.org:8000", "backup.example"]
);
let line = host_validation_line(&logs);
assert!(
line.contains("INFO"),
"Host validation must be logged at info: {line}"
);
crate::testlog::assert_logged(&logs, "Host validation: on");
crate::testlog::assert_logged(&logs, "mcp.example.org:8000");
crate::testlog::assert_logged(&logs, "backup.example");
logs.assert_not_contains("hush-key-i12");
}
#[test]
fn http_server_config_refuses_an_unparsable_allowed_hosts_entry() {
// The library path main.rs and the wiremock harness both call:
// deleting this refusal, or warning and handing the list to rmcp,
// must fail here.
let server = http_server_with_hosts(vec!["*".into()]);
let (result, _logs) = crate::testlog::capture_logs(|| server.http_server_config());
let err = result.expect_err("unparsable entries are a startup error");
let msg = format!("{err:#}");
assert!(msg.contains('*'), "the error must name the entry: {msg}");
// Refusal is before the info line, so the capture may be empty;
// asserting a negative on it would be the #97 vacuous form.
}
}