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