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