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}