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}