Skip to main content

pmcp_server_toolkit/
policy.rs

1// Net-new code for Phase 128 plan 09 (E1 + E2).
2// The two escape hatches: a `RequestPolicy` governing what may LEAVE the server,
3// and a per-tool `ArgumentValidator` for rules a JSON Schema cannot express.
4
5//! The two explicitly-registered validation escape hatches (Phase 128, E1 + E2).
6//!
7//! A config-declared `inputSchema` covers rules about ONE value. Two classes of
8//! rule it cannot express get a Rust seam here, so no team has to wrap or fork a
9//! toolkit internal:
10//!
11//! - **E1 — [`RequestPolicy`]**: a rule about what may LEAVE the server (a PHI
12//!   policy, an endpoint allowlist, a per-session budget). It runs on both HTTP
13//!   surfaces, BEFORE the outgoing credential exists.
14//! - **E2 — [`ArgumentValidator`]**: a rule about a COMBINATION of values (a
15//!   parameter required only when another has a particular value, a code-list
16//!   lookup). It runs strictly AFTER the declared schema check, so it never sees
17//!   arguments the schema already refused.
18//!
19//! Both are registered on a [`ToolkitHooks`] value and handed to an assembly
20//! entry point as a parameter. There is no builder-field accumulation, and the
21//! reason is structural rather than stylistic: `ServerBuilderExt` is implemented
22//! for CORE's `pmcp::ServerBuilder`, whose fields are private, and a Rust
23//! extension trait cannot add a field to a foreign type. The two rejected
24//! alternatives are recorded on [`ToolkitHooks`].
25//!
26//! # What this module deliberately does NOT do
27//!
28//! It contains no schema logic. The declared-schema check, the placeholder
29//! character floor and the value-free refusal renderer all live in core
30//! `pmcp::server::schema_validation`; these hooks sit around them.
31
32use std::collections::{HashMap, HashSet};
33use std::fmt;
34use std::hash::{DefaultHasher, Hash, Hasher};
35use std::sync::{Arc, Mutex, OnceLock};
36
37use async_trait::async_trait;
38use serde_json::Value;
39
40use crate::config::ServerConfig;
41
42// -----------------------------------------------------------------------------
43// E1 — RequestPolicy
44// -----------------------------------------------------------------------------
45
46/// One outbound backend request, as it will be sent, handed to a
47/// [`RequestPolicy`] for inspection.
48///
49/// # The D-12 guarantee, stated as a guarantee
50///
51/// This struct has NO credential-bearing field, and it is constructed BEFORE the
52/// `HttpAuthProvider` runs on either HTTP surface. A policy implementation
53/// therefore cannot observe an outgoing credential — not because it is asked not
54/// to, but because the value it is handed was assembled before the credential
55/// existed. `tests/request_policy.rs`'s credential-scan row asserts this by
56/// searching every field for the configured secret.
57///
58/// Borrowed throughout (`&'a str`, `&'a [_]`): a server with no registered policy
59/// never constructs one, so the empty case adds no allocation.
60#[non_exhaustive]
61#[derive(Debug, Clone, Copy)]
62pub struct OutboundRequest<'a> {
63    /// The MCP tool whose `tools/call` produced this request.
64    ///
65    /// Both shipped surfaces name a tool: the curated single-call surface passes
66    /// the synthesized tool's own name, and the Code Mode surface passes the
67    /// label attached to the executor at synthesis (a script tool's `[[tools]]`
68    /// `name`, or `execute_code` for the generic Code Mode tool). On the Code
69    /// Mode surface ONE `tools/call` may produce many outbound requests, and all
70    /// of them carry that same label.
71    ///
72    /// Empty ONLY when a caller drives a connector directly rather than through a
73    /// synthesized handler — there is then no tool to name. A policy that keys on
74    /// the tool name should treat the empty string as "unattributed", never as a
75    /// tool called `""`.
76    pub tool: &'a str,
77
78    /// The HTTP method, upper-cased (`GET`, `POST`, ...).
79    pub method: &'a str,
80
81    /// The FULLY RESOLVED request target: every path placeholder substituted and
82    /// the configured base URL already joined on, with no query string appended.
83    ///
84    /// It is the resolved path and never the template, so an endpoint allowlist
85    /// sees the URL as it will be sent. The query pairs are carried separately in
86    /// [`Self::query`].
87    pub path: &'a str,
88
89    /// The query pairs that will be appended to [`Self::path`], EXCLUDING any
90    /// pair the auth provider contributes.
91    ///
92    /// An API-key-in-query credential is an auth contribution and is therefore
93    /// absent here by construction — that omission is the D-12 guarantee, not an
94    /// oversight.
95    ///
96    /// Populated on BOTH surfaces. On the Code Mode surface that required moving
97    /// the non-auth half of the remaining-body-to-query conversion above the hook
98    /// (Phase 128 plan 09); without that move a policy written to inspect query
99    /// pairs would have inspected an empty slice while the pairs that were about
100    /// to be sent still sat in [`Self::body`].
101    pub query: &'a [(String, String)],
102
103    /// The JSON request body, when one will be sent.
104    ///
105    /// `None` for a GET-like request, whose remaining fields have already been
106    /// converted into [`Self::query`] by the time the policy runs.
107    pub body: Option<&'a Value>,
108}
109
110impl<'a> OutboundRequest<'a> {
111    /// Construct an [`OutboundRequest`].
112    ///
113    /// A constructor rather than a struct literal because the type is
114    /// `#[non_exhaustive]`; this is also what lets an out-of-crate test build one
115    /// to exercise a policy in isolation.
116    #[must_use]
117    pub fn new(
118        tool: &'a str,
119        method: &'a str,
120        path: &'a str,
121        query: &'a [(String, String)],
122        body: Option<&'a Value>,
123    ) -> Self {
124        Self {
125            tool,
126            method,
127            path,
128            query,
129            body,
130        }
131    }
132}
133
134/// A [`RequestPolicy`]'s refusal of one outbound request.
135///
136/// # Residual: the message is authored by the policy, not by this phase
137///
138/// The message travels back to the MCP client. It is chosen by the policy
139/// implementation, so the toolkit cannot mechanically constrain it — a policy
140/// that interpolates the rejected value into its own message creates exactly the
141/// value-echo leak every toolkit-authored refusal in this phase avoids
142/// (T-128-40). Supply a FIXED string. Do not put a request value, a resolved
143/// path, or a credential in it.
144#[non_exhaustive]
145#[derive(Debug, Clone, PartialEq, Eq)]
146pub struct PolicyRefusal {
147    message: String,
148}
149
150impl PolicyRefusal {
151    /// Refuse the request with a fixed message.
152    ///
153    /// The message must not carry any byte of the rejected request — see the type
154    /// documentation for why the toolkit cannot enforce that for you.
155    #[must_use]
156    pub fn new(message: impl Into<String>) -> Self {
157        Self {
158            message: message.into(),
159        }
160    }
161
162    /// The policy-supplied message, verbatim.
163    #[must_use]
164    pub fn message(&self) -> &str {
165        &self.message
166    }
167}
168
169impl fmt::Display for PolicyRefusal {
170    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
171        f.write_str(&self.message)
172    }
173}
174
175impl std::error::Error for PolicyRefusal {}
176
177/// A rule about what may LEAVE the server, consulted before every outbound
178/// backend request on both HTTP surfaces (Phase 128, E1 / D-12).
179///
180/// # Where it runs
181///
182/// Between the base-URL join and the auth application, on the curated
183/// single-call surface (`http::HttpClient::execute`) and on the Code Mode /
184/// script-tool surface (`code_mode::HttpCodeExecutor::execute_request`). The
185/// position is what makes the D-12 guarantee structural: the request is fully
186/// assembled, and the credential does not exist yet. A refusal returns before
187/// the auth provider is called and before anything is sent.
188///
189/// # One invocation per LOGICAL request, not per wire attempt
190///
191/// The curated client's `send_with_retries` retries the ALREADY-BUILT request up
192/// to three times on a 5xx / connect / timeout. Those retries happen after the
193/// hook, so this trait gives exactly one invocation per logical outbound request.
194/// A policy counting requests against a rate budget is therefore counting LOGICAL
195/// requests; it will under-count wire attempts.
196///
197/// # What E1 does and does not govern
198///
199/// It governs the two HTTP egress surfaces named above. It does NOT intercept SQL
200/// connector traffic: a `SqlConnector` request is a statement plus bound
201/// parameters, not a method/path/query, so it needs a different seam and a
202/// different trait. A team writing a PHI policy must know that its coverage stops
203/// at HTTP egress.
204///
205/// Redirects are governed by construction rather than by this trait: the
206/// OpenAPI binary's shared client is built with
207/// `reqwest::redirect::Policy::none()`, so a redirect surfaces as a response the
208/// caller handles rather than as a hop inside the client that this hook never
209/// saw (T-128-39a). A client built elsewhere with reqwest's default
210/// redirect policy re-opens that gap — every hop after the first would be
211/// invisible here.
212///
213/// # Concurrency and latency
214///
215/// `check` takes `&self` and the trait requires `Send + Sync`, so ONE instance
216/// serves every concurrent request. Any per-session state is the
217/// implementation's own responsibility. `check` is `async` and the toolkit cannot
218/// bound third-party code: a slow policy delays the request path (T-128-44,
219/// accepted — the per-request time budget is a separate, deferred piece of work).
220///
221/// # Example
222///
223/// ```
224/// use pmcp_server_toolkit::{async_trait, OutboundRequest, PolicyRefusal, RequestPolicy};
225///
226/// struct AllowlistPrefix(&'static str);
227///
228/// #[async_trait]
229/// impl RequestPolicy for AllowlistPrefix {
230///     async fn check(&self, req: &OutboundRequest<'_>) -> Result<(), PolicyRefusal> {
231///         if req.path.starts_with(self.0) {
232///             Ok(())
233///         } else {
234///             // A FIXED message: it names the rule, never the request.
235///             Err(PolicyRefusal::new("outbound endpoint is not on the allowlist"))
236///         }
237///     }
238/// }
239/// ```
240#[async_trait]
241pub trait RequestPolicy: Send + Sync {
242    /// Allow or refuse one outbound backend request.
243    ///
244    /// # Errors
245    ///
246    /// Return [`PolicyRefusal`] to refuse. The request is then never
247    /// authenticated and never sent, and the refusal's message is surfaced to the
248    /// MCP client — so it must carry no byte of the rejected request.
249    async fn check(&self, req: &OutboundRequest<'_>) -> Result<(), PolicyRefusal>;
250}
251
252// -----------------------------------------------------------------------------
253// E2 — ArgumentValidator
254// -----------------------------------------------------------------------------
255
256/// An [`ArgumentValidator`]'s refusal of one `tools/call`.
257///
258/// Carries the same implementation-supplied-message residual as
259/// [`PolicyRefusal`]: the message reaches the MCP client and is authored by the
260/// validator, so it must be a FIXED string carrying no argument value
261/// (T-128-40).
262#[non_exhaustive]
263#[derive(Debug, Clone, PartialEq, Eq)]
264pub struct ArgumentRefusal {
265    message: String,
266}
267
268impl ArgumentRefusal {
269    /// Refuse the call with a fixed message.
270    #[must_use]
271    pub fn new(message: impl Into<String>) -> Self {
272        Self {
273            message: message.into(),
274        }
275    }
276
277    /// The validator-supplied message, verbatim.
278    #[must_use]
279    pub fn message(&self) -> &str {
280        &self.message
281    }
282}
283
284impl fmt::Display for ArgumentRefusal {
285    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
286        f.write_str(&self.message)
287    }
288}
289
290impl std::error::Error for ArgumentRefusal {}
291
292/// A per-tool rule about a COMBINATION of argument values, run strictly AFTER
293/// the declared `inputSchema` check (Phase 128, E2).
294///
295/// # Ordering is the contract
296///
297/// A validator NEVER sees arguments that failed the declared schema. It runs
298/// inside the same decorator, after the schema check returns `Ok` and before the
299/// inner handler. `tests/request_policy.rs`'s counting row fails if that order
300/// inverts.
301///
302/// It also survives the schema opt-out: with `[server.validation]
303/// enforce_input_schema = false` the decorator skips only the SCHEMA CHECK, and a
304/// registered validator still runs. Turning off one enforcement must never
305/// silently turn off another.
306///
307/// # Refuse-only: this signature cannot normalize
308///
309/// `validate` takes `&Value` and returns a refusal, so it cannot mutate the
310/// arguments. Normalizing an argument before dispatch — rewriting a code, casing
311/// a string — is therefore OUT OF SCOPE for this signature. That is a deliberate
312/// restriction, not an omission: a mutating validator has a different contract
313/// (idempotency, what the tool's published schema then describes, and whether
314/// the schema check should re-run on the rewritten value), and that contract is
315/// recorded as an open question rather than settled by the shape of a first
316/// signature.
317///
318/// # Example
319///
320/// ```
321/// use pmcp_server_toolkit::{ArgumentRefusal, ArgumentValidator};
322/// use serde_json::Value;
323///
324/// struct EndAfterStart;
325///
326/// impl ArgumentValidator for EndAfterStart {
327///     fn validate(&self, args: &Value) -> Result<(), ArgumentRefusal> {
328///         let start = args.get("start").and_then(Value::as_i64);
329///         let end = args.get("end").and_then(Value::as_i64);
330///         match (start, end) {
331///             (Some(s), Some(e)) if e < s => Err(ArgumentRefusal::new(
332///                 "`end` must not precede `start`",
333///             )),
334///             _ => Ok(()),
335///         }
336///     }
337/// }
338/// ```
339pub trait ArgumentValidator: Send + Sync {
340    /// Allow or refuse one call's already-schema-valid arguments.
341    ///
342    /// # Errors
343    ///
344    /// Return [`ArgumentRefusal`] to refuse. The inner handler is then never
345    /// invoked and no backend request is made.
346    fn validate(&self, args: &Value) -> Result<(), ArgumentRefusal>;
347}
348
349/// A tool-name to [`ArgumentValidator`] registry.
350///
351/// Registration is LAST-ONE-WINS, and a replacement is announced once by a
352/// `tracing::warn!` — a silently discarded validator is a rule the operator
353/// believes is enforced and is not.
354#[derive(Clone, Default)]
355pub struct ArgumentValidators {
356    map: HashMap<String, Arc<dyn ArgumentValidator>>,
357}
358
359impl ArgumentValidators {
360    /// An empty registry.
361    #[must_use]
362    pub fn new() -> Self {
363        Self::default()
364    }
365
366    /// Register `validator` for the tool named `tool`, REPLACING any validator
367    /// already registered under that name and warning once when it does.
368    pub fn insert(&mut self, tool: impl Into<String>, validator: Arc<dyn ArgumentValidator>) {
369        let tool = tool.into();
370        if self.map.contains_key(&tool) {
371            tracing::warn!(
372                target: "pmcp_server_toolkit::policy",
373                tool = %tool,
374                "an ArgumentValidator was already registered for this tool — the earlier one is \
375                 REPLACED and will never run"
376            );
377        }
378        self.map.insert(tool, validator);
379    }
380
381    /// The validator registered for `tool`, if any.
382    #[must_use]
383    pub fn get(&self, tool: &str) -> Option<Arc<dyn ArgumentValidator>> {
384        self.map.get(tool).map(Arc::clone)
385    }
386
387    /// Whether any validator is registered.
388    #[must_use]
389    pub fn is_empty(&self) -> bool {
390        self.map.is_empty()
391    }
392
393    /// How many validators are registered.
394    #[must_use]
395    pub fn len(&self) -> usize {
396        self.map.len()
397    }
398
399    /// Registered tool names, sorted, so the startup log is deterministic.
400    #[must_use]
401    pub fn names(&self) -> Vec<&str> {
402        let mut names: Vec<&str> = self.map.keys().map(String::as_str).collect();
403        names.sort_unstable();
404        names
405    }
406}
407
408impl fmt::Debug for ArgumentValidators {
409    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
410        f.debug_struct("ArgumentValidators")
411            .field("tools", &self.names())
412            .finish()
413    }
414}
415
416// -----------------------------------------------------------------------------
417// ToolkitHooks — the registration value
418// -----------------------------------------------------------------------------
419
420/// The registered E1 policy and E2 validators, passed as a PARAMETER to an
421/// assembly entry point.
422///
423/// # Why a parameter and not a builder field
424///
425/// `ServerBuilderExt` is implemented for CORE's `pmcp::ServerBuilder`, whose
426/// fields are private. A Rust extension trait cannot add a field to a foreign
427/// type, so a `with_request_policy(self) -> Self` on that trait would have
428/// nowhere to accumulate. Two alternatives were rejected: a newtype wrapper
429/// around `ServerBuilder` would force every existing `ServerBuilderExt` user to
430/// change type, and adding an extension-storage mechanism to core's
431/// `ServerBuilder` is a core API change this phase did not scope.
432///
433/// # Example
434///
435/// ```
436/// use std::sync::Arc;
437/// use pmcp_server_toolkit::{ArgumentRefusal, ArgumentValidator, ToolkitHooks};
438/// use serde_json::Value;
439///
440/// struct Never;
441/// impl ArgumentValidator for Never {
442///     fn validate(&self, _args: &Value) -> Result<(), ArgumentRefusal> {
443///         Err(ArgumentRefusal::new("this tool is disabled"))
444///     }
445/// }
446///
447/// let hooks = ToolkitHooks::default().with_argument_validator("get_line_status", Arc::new(Never));
448/// assert_eq!(hooks.validator_names(), vec!["get_line_status"]);
449/// ```
450#[derive(Clone, Default)]
451pub struct ToolkitHooks {
452    policy: Option<Arc<dyn RequestPolicy>>,
453    validators: ArgumentValidators,
454}
455
456impl ToolkitHooks {
457    /// No policy and no validators — the shape every pre-existing entry point
458    /// passes, so a server that registers neither behaves exactly as before.
459    #[must_use]
460    pub fn new() -> Self {
461        Self::default()
462    }
463
464    /// Register the E1 [`RequestPolicy`], replacing any already set.
465    #[must_use]
466    pub fn with_request_policy(mut self, policy: Arc<dyn RequestPolicy>) -> Self {
467        if self.policy.is_some() {
468            tracing::warn!(
469                target: "pmcp_server_toolkit::policy",
470                "a RequestPolicy was already registered — the earlier one is REPLACED and will \
471                 never run"
472            );
473        }
474        self.policy = Some(policy);
475        self
476    }
477
478    /// Register an E2 [`ArgumentValidator`] for one tool name (last one wins).
479    #[must_use]
480    pub fn with_argument_validator(
481        mut self,
482        tool: impl Into<String>,
483        validator: Arc<dyn ArgumentValidator>,
484    ) -> Self {
485        self.validators.insert(tool, validator);
486        self
487    }
488
489    /// The registered [`RequestPolicy`], if any.
490    #[must_use]
491    pub fn request_policy(&self) -> Option<Arc<dyn RequestPolicy>> {
492        self.policy.as_ref().map(Arc::clone)
493    }
494
495    /// The [`ArgumentValidator`] registered for `tool`, if any.
496    ///
497    /// Named `argument_validator_for` and NOT `validator_for`: the root crate's
498    /// `tests/v2_schema_tripwires.rs` scans every workspace source file for the
499    /// token `validator_for(` as the signature of a `jsonschema` validator being
500    /// constructed, and requires each site to declare its dialect policy. A method
501    /// with that name here — and every call to it — would fire that security
502    /// tripwire on code that has nothing to do with JSON Schema dialects, and the
503    /// only ways out would be to bloat a dialect allowlist with non-dialect entries
504    /// or to re-fire on every future call site. Measured: the short name failed the
505    /// tripwire with two UNKNOWN sites.
506    #[must_use]
507    pub fn argument_validator_for(&self, tool: &str) -> Option<Arc<dyn ArgumentValidator>> {
508        self.validators.get(tool)
509    }
510
511    /// Registered validator tool names, sorted.
512    #[must_use]
513    pub fn validator_names(&self) -> Vec<&str> {
514        self.validators.names()
515    }
516
517    /// Whether this value registers nothing at all — the `Default` shape.
518    ///
519    /// Read by the assembly paths so a hooks value that registers something can
520    /// be reported in the startup log, and so a path with no surface to apply a
521    /// policy to can say so rather than accept it silently.
522    #[must_use]
523    pub fn is_empty(&self) -> bool {
524        self.policy.is_none() && self.validators.is_empty()
525    }
526}
527
528impl fmt::Debug for ToolkitHooks {
529    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
530        f.debug_struct("ToolkitHooks")
531            .field("request_policy", &self.policy.is_some())
532            .field("validators", &self.validators)
533            .finish()
534    }
535}
536
537// -----------------------------------------------------------------------------
538// The once-at-startup enforcement log (Phase 128, D-07)
539// -----------------------------------------------------------------------------
540
541/// The severity one [`render_validation_report`] line is emitted at.
542#[derive(Debug, Clone, Copy, PartialEq, Eq)]
543pub enum ReportLevel {
544    /// An enforcement that is ON, or the absence of any opt-out.
545    Info,
546    /// An enforcement that is OFF, or a registration that cannot take effect.
547    Warn,
548}
549
550/// One rendered enforcement-report line.
551#[derive(Debug, Clone, PartialEq, Eq)]
552pub struct ReportLine {
553    /// Whether the line reports something ON or something OFF.
554    pub level: ReportLevel,
555    /// The rendered text, drawn from DECLARATIONS only.
556    pub text: String,
557}
558
559/// Render what this server actually enforces, as the lines
560/// [`emit_validation_report`] logs (Phase 128, D-07).
561///
562/// Separate from the emission so a test can assert the exact line FORMATS without
563/// installing a `tracing` subscriber — and so the documentation deliverable has one
564/// authority for what an operator will see.
565///
566/// # It carries no request data, by construction
567///
568/// Every line is built from `[server.validation]`, from
569/// [`ServerConfig::validation_report`] (itself built from `[[tools]]` declarations),
570/// and from which tool NAMES carry a registered validator. No argument value and no
571/// credential can reach it, because none is in scope here. That is the property
572/// that keeps the log from becoming the PHI channel SC-7 closed everywhere else.
573///
574/// # What it reports
575///
576/// In order: the effective `[server.validation]` policy; the always-on floor; one
577/// line per tool naming its enforced rules; one WARN per ACTIVE opt-out, or one
578/// INFO stating that none is active; the E1 policy registration state; and the E2
579/// validator registrations, with a WARN for any registered under a tool name the
580/// config does not declare.
581///
582/// An enforcement that is OFF is always stated. A log that listed only what is on
583/// would let an operator read silence as safety.
584#[must_use]
585pub fn render_validation_report(config: &ServerConfig, hooks: &ToolkitHooks) -> Vec<ReportLine> {
586    let report = config.validation_report();
587    let mut out = Vec::new();
588
589    let schema_check = if cfg!(feature = "input-validation") {
590        if report.enforce_input_schema {
591            "ON"
592        } else {
593            "OFF"
594        }
595    } else {
596        "OFF (feature)"
597    };
598    out.push(ReportLine {
599        level: if schema_check == "ON" {
600            ReportLevel::Info
601        } else {
602            ReportLevel::Warn
603        },
604        text: format!(
605            "input validation: schema_check={schema_check} default_max_length={} \
606             additional_properties={} strict={} tools={}",
607            report.default_max_length,
608            report.additional_properties,
609            report.strict,
610            report.tools.len()
611        ),
612    });
613
614    if !cfg!(feature = "input-validation") {
615        out.push(ReportLine {
616            level: ReportLevel::Warn,
617            text: "input validation: the `input-validation` feature is OFF, so NO tool's \
618                   arguments are checked against its declared inputSchema. It is in the \
619                   toolkit's default feature set — an unenforced build is an explicit opt-out."
620                .to_string(),
621        });
622    }
623
624    for tool in &report.tools {
625        let rules = if tool.rules.is_empty() {
626            "(none declared; only the always-on path-placeholder character floor and \
627             length cap apply)"
628                .to_string()
629        } else {
630            tool.rules.join("; ")
631        };
632        out.push(ReportLine {
633            level: ReportLevel::Info,
634            text: format!("input validation: tool '{}' enforces {rules}", tool.tool),
635        });
636    }
637
638    if report.opt_outs.is_empty() {
639        out.push(ReportLine {
640            level: ReportLevel::Info,
641            text: "input validation: no [server.validation] opt-out is active — every rule \
642                   this config can enforce is enforced."
643                .to_string(),
644        });
645    } else {
646        for opt_out in &report.opt_outs {
647            out.push(ReportLine {
648                level: ReportLevel::Warn,
649                text: format!("input validation: [server.validation] opt-out ACTIVE — {opt_out}"),
650            });
651        }
652    }
653
654    render_hooks_lines(config, hooks, &mut out);
655    out
656}
657
658/// The E1 / E2 registration half of [`render_validation_report`].
659///
660/// Its own function to keep the caller under the cognitive-complexity 25 gate. A
661/// validator registered for a tool name the config does not declare is a WARN and
662/// NOT an error: a typo must be visible, but failing hard on a name a later config
663/// edit will introduce is worse than a warning.
664fn render_hooks_lines(config: &ServerConfig, hooks: &ToolkitHooks, out: &mut Vec<ReportLine>) {
665    out.push(ReportLine {
666        level: ReportLevel::Info,
667        text: format!(
668            "input validation: E1 RequestPolicy registered={}",
669            hooks.request_policy().is_some()
670        ),
671    });
672
673    let names = hooks.validator_names();
674    if names.is_empty() {
675        out.push(ReportLine {
676            level: ReportLevel::Info,
677            text: "input validation: no E2 ArgumentValidator is registered".to_string(),
678        });
679        return;
680    }
681    out.push(ReportLine {
682        level: ReportLevel::Info,
683        text: format!(
684            "input validation: E2 ArgumentValidator registered for {}",
685            names.join(", ")
686        ),
687    });
688    for name in names {
689        if !config.tools.iter().any(|t| t.name == name) {
690            out.push(ReportLine {
691                level: ReportLevel::Warn,
692                text: format!(
693                    "input validation: an ArgumentValidator is registered for '{name}', which \
694                     this config declares no [[tools]] entry for — it will never run"
695                ),
696            });
697        }
698    }
699}
700
701/// Emit the enforcement report ONCE per server, at startup (Phase 128, D-07).
702///
703/// The ONE formatter, called from BOTH assembly sites:
704/// [`crate::ServerBuilderExt::try_tools_from_config_with`] and
705/// `pmcp-openapi-server`'s `build_server`. Two call sites rather than two
706/// formatters, because the OpenAPI binary reaches the free synthesizer directly and
707/// never goes through the builder path — a report emitted only there would be
708/// absent from the deployment that most needs it (T-128-42a).
709///
710/// This log is the mechanism for tracing a server whose previously-unenforced
711/// `enum` or `pattern` starts refusing calls. It is not optional polish.
712///
713/// # Emitted once per SERVER, not once per call
714///
715/// Deduplicated on a hash of the RENDERED lines plus the server name and version,
716/// so a process that reaches both assembly paths for the same server logs once
717/// while a process hosting two DIFFERENT servers logs for each. Two servers with a
718/// byte-identical config and identical registrations log once between them — an
719/// accepted and stated limitation, since there is nothing in the report that would
720/// differ.
721///
722/// # It carries no request data
723///
724/// Guaranteed by [`render_validation_report`], which has no argument value and no
725/// credential in scope.
726pub fn emit_validation_report(config: &ServerConfig, hooks: &ToolkitHooks) {
727    let lines = render_validation_report(config, hooks);
728    if !claim_report_emission(&config.server.name, &config.server.version, &lines) {
729        return;
730    }
731    for line in lines {
732        match line.level {
733            ReportLevel::Info => {
734                tracing::info!(target: "pmcp_server_toolkit::policy", "{}", line.text);
735            },
736            ReportLevel::Warn => {
737                tracing::warn!(target: "pmcp_server_toolkit::policy", "{}", line.text);
738            },
739        }
740    }
741}
742
743/// Claim the right to emit for this (server, report) pair, returning `false` when
744/// it was already claimed.
745fn claim_report_emission(name: &str, version: &str, lines: &[ReportLine]) -> bool {
746    static EMITTED: OnceLock<Mutex<HashSet<u64>>> = OnceLock::new();
747    let mut hasher = DefaultHasher::new();
748    name.hash(&mut hasher);
749    version.hash(&mut hasher);
750    for line in lines {
751        line.text.hash(&mut hasher);
752    }
753    let key = hasher.finish();
754    EMITTED
755        .get_or_init(|| Mutex::new(HashSet::new()))
756        .lock()
757        .map_or(true, |mut seen| seen.insert(key))
758}
759
760#[cfg(test)]
761mod tests {
762    use super::{
763        ArgumentRefusal, ArgumentValidator, ArgumentValidators, OutboundRequest, PolicyRefusal,
764        RequestPolicy, ToolkitHooks,
765    };
766    use serde_json::{json, Value};
767    use std::sync::atomic::{AtomicUsize, Ordering};
768    use std::sync::Arc;
769
770    struct Refuse(&'static str);
771
772    #[async_trait::async_trait]
773    impl RequestPolicy for Refuse {
774        async fn check(&self, _req: &OutboundRequest<'_>) -> Result<(), PolicyRefusal> {
775            Err(PolicyRefusal::new(self.0))
776        }
777    }
778
779    struct CountingValidator(Arc<AtomicUsize>);
780
781    impl ArgumentValidator for CountingValidator {
782        fn validate(&self, _args: &Value) -> Result<(), ArgumentRefusal> {
783            self.0.fetch_add(1, Ordering::SeqCst);
784            Ok(())
785        }
786    }
787
788    #[test]
789    fn outbound_request_constructor_exposes_every_field() {
790        let query = vec![("q".to_string(), "x".to_string())];
791        let body = json!({ "note": "n" });
792        let req = OutboundRequest::new("t", "GET", "https://h/p/1", &query, Some(&body));
793        assert_eq!(req.tool, "t");
794        assert_eq!(req.method, "GET");
795        assert_eq!(req.path, "https://h/p/1");
796        assert_eq!(req.query.len(), 1);
797        assert_eq!(req.body, Some(&body));
798    }
799
800    #[test]
801    fn refusals_display_their_own_message_verbatim() {
802        assert_eq!(PolicyRefusal::new("nope").to_string(), "nope");
803        assert_eq!(ArgumentRefusal::new("bad combo").to_string(), "bad combo");
804        assert_eq!(PolicyRefusal::new("nope").message(), "nope");
805        assert_eq!(ArgumentRefusal::new("bad combo").message(), "bad combo");
806    }
807
808    #[tokio::test]
809    async fn a_policy_refusal_carries_the_policy_message() {
810        let policy = Refuse("blocked by test policy");
811        let empty: Vec<(String, String)> = Vec::new();
812        let req = OutboundRequest::new("t", "GET", "https://h/p", &empty, None);
813        let err = policy.check(&req).await.expect_err("refuses");
814        assert_eq!(err.message(), "blocked by test policy");
815    }
816
817    #[test]
818    fn validator_registration_is_last_one_wins() {
819        let first = Arc::new(AtomicUsize::new(0));
820        let second = Arc::new(AtomicUsize::new(0));
821        let mut reg = ArgumentValidators::new();
822        assert!(reg.is_empty());
823        reg.insert("t", Arc::new(CountingValidator(Arc::clone(&first))));
824        reg.insert("t", Arc::new(CountingValidator(Arc::clone(&second))));
825        assert_eq!(reg.len(), 1);
826        reg.get("t")
827            .expect("registered")
828            .validate(&json!({}))
829            .expect("allows");
830        assert_eq!(
831            first.load(Ordering::SeqCst),
832            0,
833            "the replaced validator ran"
834        );
835        assert_eq!(second.load(Ordering::SeqCst), 1);
836    }
837
838    #[test]
839    fn default_hooks_register_nothing() {
840        let hooks = ToolkitHooks::default();
841        assert!(hooks.is_empty());
842        assert!(hooks.request_policy().is_none());
843        assert!(hooks.argument_validator_for("anything").is_none());
844        assert!(hooks.validator_names().is_empty());
845    }
846
847    #[test]
848    fn hooks_builder_records_both_kinds() {
849        let hooks = ToolkitHooks::new()
850            .with_request_policy(Arc::new(Refuse("x")))
851            .with_argument_validator(
852                "b",
853                Arc::new(CountingValidator(Arc::new(AtomicUsize::new(0)))),
854            )
855            .with_argument_validator(
856                "a",
857                Arc::new(CountingValidator(Arc::new(AtomicUsize::new(0)))),
858            );
859        assert!(!hooks.is_empty());
860        assert!(hooks.request_policy().is_some());
861        assert_eq!(hooks.validator_names(), vec!["a", "b"]);
862    }
863
864    #[test]
865    fn hooks_debug_never_renders_a_policy_body() {
866        let hooks = ToolkitHooks::new().with_request_policy(Arc::new(Refuse("secret-ish")));
867        let rendered = format!("{hooks:?}");
868        assert!(rendered.contains("request_policy: true"));
869        assert!(!rendered.contains("secret-ish"));
870    }
871}