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