Skip to main content

rmcp_server_kit/
diagnostics.rs

1//! Process-global diagnostic-exposure switches.
2//!
3//! Several types in this crate carry material that must never reach logs by
4//! accident: OAuth access tokens, JWT claim values, and MCP tool-call
5//! arguments. Their [`Debug`] implementations and the corresponding
6//! `tracing` call sites therefore redact by default.
7//!
8//! Redaction is nevertheless a debugging obstacle, so each category can be
9//! switched to plaintext. [`Debug::fmt`](std::fmt::Debug::fmt) receives only
10//! `&self` and a formatter -- it cannot see an
11//! [`ObservabilityConfig`](crate::config::ObservabilityConfig) -- so the
12//! switches live here as process-global atomics rather than as per-server
13//! state.
14//!
15//! # ⚠️ These switches are process-wide, not per-server
16//!
17//! A process hosting more than one `rmcp-server-kit` server shares one set of
18//! switches. Enabling a switch for one server enables it for **every**
19//! `rmcp-server-kit` `Debug` output and gated log site in the process. There
20//! is deliberately no per-server override: a `Debug` impl has no request or
21//! server context to key off.
22//!
23//! # ⚠️ Enabling a switch writes secrets to your logs
24//!
25//! Every switch defaults to the safe (redacted) state and is intended for
26//! short-lived local debugging. Never enable one in production.
27//!
28//! # Setting the switches
29//!
30//! Consumers loading TOML get this for free: the three
31//! `observability.log_*` keys are applied by
32//! [`init_tracing_from_config_strict`](crate::observability::init_tracing_from_config_strict).
33//! Builder-only consumers that never touch TOML can call
34//! [`set_diagnostic_exposure`](crate::diagnostics::set_diagnostic_exposure) directly.
35//!
36//! ```
37//! use rmcp_server_kit::diagnostics::{DiagnosticExposure, set_diagnostic_exposure};
38//!
39//! // Default is fully redacted.
40//! let exposure = DiagnosticExposure::default();
41//! set_diagnostic_exposure(&exposure);
42//! ```
43
44use std::sync::atomic::{AtomicBool, Ordering};
45
46/// Plaintext OAuth access tokens in `Debug` output.
47static PLAINTEXT_OAUTH_TOKENS: AtomicBool = AtomicBool::new(false);
48/// Plaintext JWT claim values in exchange/validation logs.
49static OAUTH_CLAIM_VALUES: AtomicBool = AtomicBool::new(false);
50/// Plaintext tool-call arguments and identity fields in `Debug` output.
51static TOOL_CALL_ARGUMENTS: AtomicBool = AtomicBool::new(false);
52/// Plaintext upstream OAuth error-response bodies in token-exchange logs.
53static UPSTREAM_ERROR_BODIES: AtomicBool = AtomicBool::new(false);
54
55/// Which categories of sensitive material may be rendered in plaintext.
56///
57/// Every field defaults to `false`, meaning **redacted**. Enabling a field
58/// causes secrets to appear in logs and [`Debug`] output for the entire
59/// process; see the [module docs](self) for the full warning.
60#[derive(Debug, Clone, Default)]
61#[non_exhaustive]
62#[allow(
63    clippy::struct_excessive_bools,
64    reason = "each field is an independent operator-facing opt-in switch; grouping them into sub-structs would complicate the public API and the TOML surface for no safety gain"
65)]
66pub struct DiagnosticExposure {
67    /// Render OAuth access tokens in plaintext instead of `[REDACTED]`.
68    ///
69    /// Affects the [`Debug`] implementation of
70    /// [`ExchangedToken`](crate::oauth::ExchangedToken).
71    pub plaintext_oauth_tokens: bool,
72    /// Render JWT claim values (`sub`, `aud`, `azp`, `iss`) in plaintext.
73    ///
74    /// These are identity, tenant, and deployment-topology identifiers and
75    /// may be personally identifying.
76    pub oauth_claim_values: bool,
77    /// Render tool-call arguments and identity fields in plaintext.
78    ///
79    /// Affects the [`Debug`] implementation of
80    /// [`ToolCallContext`](crate::tool_hooks::ToolCallContext). Tool
81    /// arguments routinely carry credentials supplied by the caller.
82    pub tool_call_arguments: bool,
83    /// Render the `error_description` returned by an authorization server on a
84    /// failed RFC 8693 token exchange.
85    ///
86    /// The value is free-form text chosen by the upstream server and may
87    /// reflect request parameters back, so it is redacted by default. Enable
88    /// only while debugging an exchange failure.
89    pub upstream_error_bodies: bool,
90}
91
92/// Apply `exposure` to the process-global diagnostic switches.
93///
94/// Call this before serving. TOML-driven consumers do not need to call it:
95/// [`init_tracing_from_config_strict`](crate::observability::init_tracing_from_config_strict)
96/// applies the `observability.log_*` keys automatically.
97///
98/// # ⚠️ Process-wide effect
99///
100/// This affects every `rmcp-server-kit` server in the process, not just the
101/// one you are about to start. See the [module docs](self).
102pub fn set_diagnostic_exposure(exposure: &DiagnosticExposure) {
103    // Relaxed is sufficient: each flag is an independent boolean that
104    // synchronizes no other data, and callers set them during startup before
105    // request handling begins.
106    PLAINTEXT_OAUTH_TOKENS.store(exposure.plaintext_oauth_tokens, Ordering::Relaxed);
107    OAUTH_CLAIM_VALUES.store(exposure.oauth_claim_values, Ordering::Relaxed);
108    TOOL_CALL_ARGUMENTS.store(exposure.tool_call_arguments, Ordering::Relaxed);
109    UPSTREAM_ERROR_BODIES.store(exposure.upstream_error_bodies, Ordering::Relaxed);
110}
111
112/// Whether OAuth access tokens may be rendered in plaintext.
113pub(crate) fn plaintext_oauth_tokens() -> bool {
114    PLAINTEXT_OAUTH_TOKENS.load(Ordering::Relaxed)
115}
116
117/// Whether JWT claim values may be rendered in plaintext.
118#[cfg_attr(
119    not(feature = "oauth"),
120    allow(
121        dead_code,
122        reason = "only consumed by the oauth module; kept unconditional so the \
123                  switch set is uniform across feature combinations"
124    )
125)]
126pub(crate) fn oauth_claim_values() -> bool {
127    OAUTH_CLAIM_VALUES.load(Ordering::Relaxed)
128}
129
130/// Whether tool-call arguments may be rendered in plaintext.
131pub(crate) fn tool_call_arguments() -> bool {
132    TOOL_CALL_ARGUMENTS.load(Ordering::Relaxed)
133}
134
135/// Whether upstream OAuth error-response bodies may be rendered in plaintext.
136#[cfg_attr(
137    not(feature = "oauth"),
138    allow(
139        dead_code,
140        reason = "only consumed by the oauth module; kept unconditional so the \
141                  switch set is uniform across feature combinations"
142    )
143)]
144pub(crate) fn upstream_error_bodies() -> bool {
145    UPSTREAM_ERROR_BODIES.load(Ordering::Relaxed)
146}
147
148/// Serializes tests that mutate the process-global switches and restores the
149/// prior state on drop.
150///
151/// The switches are process-global, so two tests toggling them concurrently
152/// would observe each other's writes. Every test that calls
153/// [`set_diagnostic_exposure`] must hold this guard for its whole body.
154///
155/// The lock is **not** reentrant: acquiring a second guard while one is held
156/// on the same thread deadlocks. Never nest guards.
157#[cfg(test)]
158pub(crate) struct ExposureTestGuard {
159    _lock: std::sync::MutexGuard<'static, ()>,
160    previous: DiagnosticExposure,
161}
162
163#[cfg(test)]
164impl ExposureTestGuard {
165    /// Acquire the global test lock and snapshot the current switch state.
166    pub(crate) fn acquire() -> Self {
167        static TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
168        let lock = TEST_LOCK
169            .lock()
170            .unwrap_or_else(std::sync::PoisonError::into_inner);
171        Self {
172            _lock: lock,
173            previous: DiagnosticExposure {
174                plaintext_oauth_tokens: plaintext_oauth_tokens(),
175                oauth_claim_values: oauth_claim_values(),
176                tool_call_arguments: tool_call_arguments(),
177                upstream_error_bodies: upstream_error_bodies(),
178            },
179        }
180    }
181}
182
183#[cfg(test)]
184impl Drop for ExposureTestGuard {
185    fn drop(&mut self) {
186        set_diagnostic_exposure(&self.previous);
187    }
188}
189
190#[cfg(test)]
191mod tests {
192    use super::{
193        DiagnosticExposure, ExposureTestGuard, oauth_claim_values, plaintext_oauth_tokens,
194        set_diagnostic_exposure, tool_call_arguments,
195    };
196
197    #[test]
198    fn default_exposure_is_fully_redacted() {
199        let _guard = ExposureTestGuard::acquire();
200        set_diagnostic_exposure(&DiagnosticExposure::default());
201
202        assert!(!plaintext_oauth_tokens(), "tokens must default to redacted");
203        assert!(!oauth_claim_values(), "claims must default to redacted");
204        assert!(!tool_call_arguments(), "arguments must default to redacted");
205    }
206
207    #[test]
208    fn each_switch_is_independently_settable() {
209        let _guard = ExposureTestGuard::acquire();
210
211        set_diagnostic_exposure(&DiagnosticExposure {
212            plaintext_oauth_tokens: true,
213            ..DiagnosticExposure::default()
214        });
215        assert!(plaintext_oauth_tokens());
216        assert!(!oauth_claim_values());
217        assert!(!tool_call_arguments());
218
219        set_diagnostic_exposure(&DiagnosticExposure {
220            oauth_claim_values: true,
221            ..DiagnosticExposure::default()
222        });
223        assert!(!plaintext_oauth_tokens());
224        assert!(oauth_claim_values());
225        assert!(!tool_call_arguments());
226
227        set_diagnostic_exposure(&DiagnosticExposure {
228            tool_call_arguments: true,
229            ..DiagnosticExposure::default()
230        });
231        assert!(!plaintext_oauth_tokens());
232        assert!(!oauth_claim_values());
233        assert!(tool_call_arguments());
234    }
235
236    #[test]
237    fn guard_restores_previous_state_on_drop() {
238        let guard = ExposureTestGuard::acquire();
239        set_diagnostic_exposure(&DiagnosticExposure {
240            plaintext_oauth_tokens: true,
241            oauth_claim_values: true,
242            tool_call_arguments: true,
243            upstream_error_bodies: true,
244        });
245        assert!(plaintext_oauth_tokens());
246        drop(guard);
247
248        let _reacquired = ExposureTestGuard::acquire();
249        assert!(
250            !plaintext_oauth_tokens() && !oauth_claim_values() && !tool_call_arguments(),
251            "dropping the guard must restore the pre-acquire state"
252        );
253    }
254}