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}