Skip to main content

oauth_resource_server/
mcp.rs

1//! An integration helper for [Model Context Protocol](https://modelcontextprotocol.io)
2//! servers (feature `mcp`): per-tool scope (and claim) requirements, enforced before the
3//! MCP server sees the request. The rest of this crate is protocol-agnostic;
4//! this module is the one place that knows what a JSON-RPC `tools/call` looks
5//! like, and it depends on nothing MCP-specific (no MCP SDK — `serde_json`
6//! only).
7//!
8//! An MCP server exposes every tool on one endpoint, so a route-level scope
9//! ([`crate::http_layer::RequireScopes`]) cannot tell a read tool from a write
10//! tool. [`McpToolScopes`] reads the tool name from the request body instead.
11//! Behind the `tower` feature's `HttpAuthLayer`:
12//!
13//! ```
14//! use std::sync::Arc;
15//!
16//! use bytes::Bytes;
17//! use http::{Request, Response};
18//! use http_body_util::Full;
19//! use oauth_resource_server::OAuthValidator;
20//! use oauth_resource_server::http_layer::HttpAuthLayer;
21//! use oauth_resource_server::mcp::McpToolScopes;
22//! use tower::{ServiceBuilder, service_fn};
23//!
24//! # fn service(oauth: Arc<OAuthValidator>) {
25//! let tool_scopes = McpToolScopes::new()
26//!     .default(["mcp:read"])
27//!     .tool("write_document", ["mcp:write"])
28//!     .tool("delete_document", ["mcp:write", "mcp:admin"]);
29//! let mcp = ServiceBuilder::new()
30//!     // Outermost first: authenticate, then require each tool's scopes.
31//!     .layer(HttpAuthLayer::builder().oauth(oauth).build().unwrap())
32//!     .layer(tool_scopes)
33//!     .service(service_fn(|_request: Request<Full<Bytes>>| async {
34//!         // Your MCP server here.
35//!         Ok::<_, std::convert::Infallible>(Response::new(Full::new(Bytes::new())))
36//!     }));
37//! # let _ = mcp;
38//! # }
39//! ```
40//!
41//! Under axum (feature `axum`) it is a `route_layer` on the MCP endpoint,
42//! added before (so inside) the `AuthLayer`:
43//! `Router::new().nest_service("/mcp", mcp).route_layer(tool_scopes).route_layer(auth)`.
44//!
45//! # What it enforces
46//!
47//! For every request the authentication layer in front of it let through
48//! (it must sit behind the axum `AuthLayer` or the `tower` feature's
49//! `HttpAuthLayer`; without one it answers 500):
50//!
51//! | Request | Scopes required (all-of, on top of the layer's) |
52//! |---|---|
53//! | an empty body (nothing read: the `GET` event stream, `DELETE`, `Content-Length: 0`, a chunked body with no chunks) | the default |
54//! | a body, **whatever the method**, whose JSON-RPC message is `tools/call` for a configured tool | that tool's (not the default's) |
55//! | a body whose message is `tools/call` for any other tool | the default |
56//! | a body whose message is anything else (`initialize`, `tools/list`, a notification, a response) | the default |
57//! | a JSON-RPC **batch** (an array) | every scope any of its messages requires: all of them must be authorized, or none is served |
58//! | a body that is not JSON, or a `tools/call` without a readable string `params.name`, or a message with a repeated `method`, `params` or `params.name` | the **strictest** set: the default and every tool's scopes together |
59//! | a message with an object key `$serde_json::private::RawValue` or `$serde_json::private::Number` at any depth (see below) | the **strictest** set |
60//! | a body larger than the [limit](McpToolScopes::body_limit) | refused with 413, unread beyond the limit |
61//! | a body that fails while it is being read (a client disconnect, a transport error) | refused with a bare 400, logged at `warn` |
62//!
63//! Every request's body is read and classified, whatever the method — MCP's
64//! own transport sends JSON-RPC in `POST`s only, but another JSON-RPC server
65//! may read a `tools/call` from any request with a body — and whatever its
66//! headers or size hint claim: an empty body ends at once, and a size hint
67//! of exactly 0 is not taken as proof that no `tools/call` follows. A body
68//! of whitespace alone is not empty: it is unreadable, so the strictest set.
69//!
70//! A request whose credential lacks the scopes is refused with 403 and the
71//! authentication layer's own refusal (its `on_reject` body), with a
72//! `WWW-Authenticate` challenge naming the layer's scopes followed by the
73//! ones this request needed — the MCP authorization spec's per-operation
74//! challenge. A static token has no scopes, so it is refused the same way
75//! whenever scopes are required, unless
76//! [`static_token_bypasses_scopes`](McpToolScopes::static_token_bypasses_scopes).
77//! A request with no credential (an `optional` layer passed it through) gets
78//! the layer's own 401 when what it needs is not empty, and is served when
79//! it needs nothing — an anonymous `initialize` or a call to a public tool
80//! behind an empty default. Only when every request needs a scope (a
81//! non-empty default and no tool with an empty list) is it refused before
82//! its body is read.
83//! Anything served reaches the MCP server with its body byte-identical.
84//!
85//! # Claim requirements
86//!
87//! Next to scopes, the default and each tool can require claim values
88//! ([`default_claim`](McpToolScopes::default_claim),
89//! [`tool_claim`](McpToolScopes::tool_claim)): a clause names a top-level
90//! claim and the values that satisfy it (any one of them, matched as
91//! [`AuthorizedToken::has_claim_value`](crate::AuthorizedToken::has_claim_value)
92//! does), and every clause must hold. Wherever the table above says "scopes",
93//! read "scopes and clauses": a configured tool (named by
94//! [`tool`](McpToolScopes::tool) or [`tool_claim`](McpToolScopes::tool_claim))
95//! needs its own scopes and clauses only, never the default's; a batch needs
96//! every clause any of its messages needs; the strictest set holds every
97//! clause in the configuration. Clauses are deduplicated but never merged by
98//! claim name, since merging their values would widen access — so two tools
99//! needing `role` = `a` and `role` = `b` make an unclassifiable body need
100//! both.
101//!
102//! A missing claim value is refused exactly as a missing scope: 403, with a
103//! challenge naming the scopes only (RFC 6750 has no claim error), so no
104//! claim name or value ever enters `WWW-Authenticate`. A static token has no
105//! claims and is refused whenever a clause applies, unless
106//! [`static_token_bypasses_scopes`](McpToolScopes::static_token_bypasses_scopes).
107//!
108//! # Tool names are matched exactly
109//!
110//! A `tools/call` is matched to a [`tool`](McpToolScopes::tool) entry byte
111//! for byte on its JSON-decoded `params.name`: no case folding, trimming or
112//! Unicode normalization, and any name without an entry gets the default.
113//! That is only safe if the MCP server dispatches exactly as well. Register
114//! every tool under exactly the name the server dispatches on, and make sure
115//! the dispatcher does not normalize: one that also runs `Write_Document`
116//! or `write_document ` as `write_document` turns "an unconfigured tool gets
117//! the default" into a way around that tool's scopes.
118//!
119//! # Why fail closed on what it cannot read
120//!
121//! A body this layer cannot parse is still handed to the MCP server, whose
122//! parser may read it differently — and a `tools/call` that slipped past as
123//! "not a tool call" would skip its tool's requirement. So nothing this
124//! layer cannot classify with certainty is ever given less than the
125//! strictest set: an unreadable body, a `tools/call` with no readable tool
126//! name, and a message with a repeated member (which two JSON parsers can
127//! resolve to different values). So is a message holding one of serde_json's
128//! private token keys, `$serde_json::private::RawValue` or
129//! `$serde_json::private::Number`, anywhere inside it: with serde_json's
130//! `raw_value` (or `arbitrary_precision`) feature on in the server's build —
131//! axum turns `raw_value` on — `serde_json::Value` reads
132//! `{"$serde_json::private::RawValue":"<json>"}` as the JSON in that string,
133//! so a dispatcher parsing into `Value` would see a `tools/call` (or a whole
134//! batch) this layer saw as an ordinary object. A caller holding every scope loses nothing
135//! (the server answers a malformed body with its own error); everyone else
136//! is refused before it is parsed a second time. An over-limit body is
137//! refused rather than passed on, because it could only be passed unread.
138//!
139//! # Resource use
140//!
141//! The body is held once (reserved up front when its length is announced),
142//! and parsed in a single streaming pass that validates it fully but builds
143//! no document and copies no tool name: memory beyond the body itself stays
144//! small and does not grow with the number of messages in a batch or the
145//! length of a name.
146//!
147//! Reading the body has **no timeout of its own**: a client that trickles
148//! a body in slowly (slow-loris) holds the request open for as long as the
149//! server lets it. Set a read or request timeout on the server (hyper's
150//! `header_read_timeout`, a `tower_http::timeout` layer, or your proxy's),
151//! as for any endpoint that reads a body.
152//!
153//! # Privacy
154//!
155//! Nothing from the body — tool name, arguments, identifiers — is ever
156//! logged; refusals are logged (target `oauth_resource_server::http_layer`,
157//! as for [`crate::http_layer::RequireScopes`], with the same stable
158//! `auth.*` fields) with the request path and the configured scopes only —
159//! plus, for a requirement with claim clauses, a `required_claims` field
160//! holding the claim names, never a configured or presented value.
161//! Oversized and unreadable bodies are logged at `warn` with the path and
162//! the limit; those are not authentication decisions, so they carry no
163//! `auth.*` field and are not counted by the `metrics` feature.
164//!
165//! # Checking scopes in the tool handler instead
166//!
167//! The layers insert the validated [`crate::AuthorizedToken`] into the
168//! request's extensions. An MCP server framework that hands a tool handler
169//! the HTTP request parts (rmcp does, as an `Extension<http::request::Parts>`
170//! in the tool call's context) can check a scope there, per call:
171//!
172//! ```
173//! use http::request::Parts;
174//! use oauth_resource_server::AuthorizedToken;
175//!
176//! fn may_write(parts: &Parts) -> bool {
177//!     parts
178//!         .extensions
179//!         .get::<AuthorizedToken>()
180//!         .is_some_and(|token| token.require_scopes(&["mcp:write"]).is_ok())
181//! }
182//! ```
183//!
184//! That answers inside the protocol (a tool error), not with the 403 and
185//! challenge a client can re-authorize from; [`McpToolScopes`] does the
186//! latter.
187
188use std::future::Future;
189use std::pin::Pin;
190use std::sync::Arc;
191use std::task::{Context, Poll};
192
193use bytes::{Buf, Bytes};
194use http::{Request, Response, StatusCode};
195use http_body::Body;
196use serde::de::{DeserializeSeed, MapAccess, SeqAccess, Visitor};
197use serde::{Deserialize, Deserializer};
198use tracing::warn;
199
200use crate::http_layer::{
201    ClaimClause, InvalidScope, ScopeVerdict, checked_scopes, judge_scopes, scope_refusal,
202    scope_refusal_with_claims,
203};
204use crate::token::TokenRejection;
205
206/// The default [`McpToolScopes::body_limit`]: 1 MiB.
207pub const DEFAULT_BODY_LIMIT: usize = 1024 * 1024;
208/// The smallest [`McpToolScopes::body_limit`] allowed: 4 KiB.
209pub const MIN_BODY_LIMIT: usize = 4 * 1024;
210/// The largest [`McpToolScopes::body_limit`] allowed: 64 MiB.
211pub const MAX_BODY_LIMIT: usize = 64 * 1024 * 1024;
212
213/// Per-tool scope requirements for an MCP server's JSON-RPC endpoint, as a
214/// `tower::Layer`; see the [module docs](self) for what it enforces.
215///
216/// Built once at startup, with a builder-style API: every method takes and
217/// returns the value.
218///
219/// Tool names are matched **exactly** (byte for byte, after JSON
220/// decoding); see [`tool`](Self::tool)'s `# Security` section before
221/// relying on it.
222///
223/// # Panics
224///
225/// [`default`](Self::default) and [`tool`](Self::tool) panic on a scope that
226/// is not an RFC 6749 §3.3 scope-token (empty, or holding a space, `"`,
227/// `\`, a control or non-ASCII character) — no token can carry one — and
228/// [`body_limit`](Self::body_limit) outside
229/// [`MIN_BODY_LIMIT`]`..=`[`MAX_BODY_LIMIT`]; [`default_claim`](Self::default_claim)
230/// and [`tool_claim`](Self::tool_claim) panic on a blank claim name, no
231/// values, or a blank value. They are for literals in code; the `try_` forms
232/// ([`try_default`](Self::try_default), [`try_tool`](Self::try_tool),
233/// [`try_default_claim`](Self::try_default_claim),
234/// [`try_tool_claim`](Self::try_tool_claim),
235/// [`try_body_limit`](Self::try_body_limit)) return a [`McpScopesError`]
236/// instead, for settings read from configuration.
237#[derive(Clone, Debug)]
238pub struct McpToolScopes {
239    rules: Arc<Rules>,
240}
241
242/// What one kind of request needs: scopes (all-of) and claim clauses (all-of,
243/// each any-of within).
244#[derive(Clone, Debug, Default, PartialEq, Eq)]
245struct Requirement {
246    scopes: Vec<String>,
247    claims: Vec<ClaimClause>,
248}
249
250impl Requirement {
251    fn is_empty(&self) -> bool {
252        self.scopes.is_empty() && self.claims.is_empty()
253    }
254
255    /// Add `other`'s scopes and clauses, deduplicated in order. Clauses are
256    /// deduplicated by structural equality only, never merged by claim name:
257    /// merging two clauses' values would widen access.
258    fn absorb(&mut self, other: &Requirement) {
259        for scope in &other.scopes {
260            if !self.scopes.contains(scope) {
261                self.scopes.push(scope.clone());
262            }
263        }
264        for clause in &other.claims {
265            if !self.claims.contains(clause) {
266                self.claims.push(clause.clone());
267            }
268        }
269    }
270}
271
272#[derive(Clone, Debug)]
273struct Rules {
274    default: Requirement,
275    /// In the order configured. `tool` replaces an entry's scopes (moving it
276    /// to the end, as a replaced entry always did) and keeps its clauses;
277    /// `tool_claim` adds a clause to an entry, creating it (with no scopes)
278    /// at the end when there is none.
279    tools: Vec<(String, Requirement)>,
280    /// `default` followed by every tool's requirement, deduplicated.
281    strictest: Requirement,
282    /// Whether every request needs something, whatever its body: a
283    /// non-empty default and no tool with an empty requirement. Only then is
284    /// a request with no credential refused before its body is read.
285    always_scoped: bool,
286    body_limit: usize,
287    static_bypasses: bool,
288}
289
290impl Rules {
291    fn with_strictest(mut self) -> Self {
292        let mut all = self.default.clone();
293        for (_, requirement) in &self.tools {
294            all.absorb(requirement);
295        }
296        self.strictest = all;
297        self.always_scoped = !self.default.is_empty()
298            && self
299                .tools
300                .iter()
301                .all(|(_, requirement)| !requirement.is_empty());
302        self
303    }
304
305    fn for_tool(&self, name: &str) -> &Requirement {
306        self.tools
307            .iter()
308            .find(|(tool, _)| tool == name)
309            .map_or(&self.default, |(_, requirement)| requirement)
310    }
311
312    /// The requirement a request body carries (see the module docs' table),
313    /// folded message by message as the body is parsed: what is kept is one
314    /// flag per configured tool, never the messages themselves, so a batch of
315    /// any length costs no more memory than one call.
316    ///
317    /// An empty body (nothing at all: `Content-Length: 0`, a chunked body
318    /// with no chunks, a `GET`) needs the default; anything else that is not
319    /// a JSON object or array — whitespace alone included — the strictest
320    /// set.
321    fn for_body(&self, body: &[u8]) -> Requirement {
322        if body.is_empty() {
323            return self.default.clone();
324        }
325        let mut needs_default = false;
326        let mut needs_strictest = false;
327        let mut needs_tool = vec![false; self.tools.len()];
328        let mut seen_any = false;
329        let tools = &self.tools;
330        let readable = for_each_message(
331            body,
332            // Compared against the configured names as the value streams by:
333            // the name itself is never copied, however long it is.
334            &mut |name| {
335                tools
336                    .iter()
337                    .position(|(tool, _)| tool == name)
338                    .unwrap_or(UNKNOWN_TOOL)
339            },
340            &mut |message| {
341                seen_any = true;
342                match message {
343                    Message::NotToolCall => needs_default = true,
344                    Message::ToolCall(UNKNOWN_TOOL) => needs_default = true,
345                    Message::ToolCall(i) => needs_tool[i] = true,
346                    Message::Ambiguous => needs_strictest = true,
347                }
348            },
349        );
350        if !readable || needs_strictest {
351            return self.strictest.clone();
352        }
353        if !seen_any {
354            // An empty batch: nothing to call.
355            return self.default.clone();
356        }
357        // The default first, then each needed tool's, in configuration order.
358        let mut all = Requirement::default();
359        if needs_default {
360            all.absorb(&self.default);
361        }
362        for ((_, requirement), needed) in self.tools.iter().zip(&needs_tool) {
363            if *needed {
364                all.absorb(requirement);
365            }
366        }
367        all
368    }
369}
370
371/// Why [`McpToolScopes`]' fallible constructors ([`try_default`](McpToolScopes::try_default),
372/// [`try_tool`](McpToolScopes::try_tool), [`try_default_claim`](McpToolScopes::try_default_claim),
373/// [`try_tool_claim`](McpToolScopes::try_tool_claim), [`try_body_limit`](McpToolScopes::try_body_limit))
374/// refused a setting. `#[non_exhaustive]`: match with a wildcard arm.
375#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
376#[non_exhaustive]
377pub enum McpScopesError {
378    /// A scope is not an RFC 6749 §3.3 scope-token; the error names it.
379    #[error(transparent)]
380    InvalidScope(#[from] InvalidScope),
381    /// A body limit outside [`MIN_BODY_LIMIT`]`..=`[`MAX_BODY_LIMIT`].
382    #[error("body limit {0} is outside {MIN_BODY_LIMIT}..={MAX_BODY_LIMIT}")]
383    BodyLimitOutOfRange(usize),
384    /// A claim requirement ([`try_default_claim`](McpToolScopes::try_default_claim),
385    /// [`try_tool_claim`](McpToolScopes::try_tool_claim)) with a blank (empty
386    /// or whitespace-only) claim name, no accepted value, or a blank value —
387    /// none of which any token could satisfy as meant. `claim` is the claim
388    /// name as given.
389    #[error(
390        "the requirement on claim {claim:?} needs a non-blank claim name and at least one \
391         value, none of them blank"
392    )]
393    #[non_exhaustive]
394    InvalidClaimRequirement {
395        /// The claim name, as given (possibly blank).
396        claim: String,
397    },
398}
399
400/// A claim requirement, checked: a non-blank name and at least one value,
401/// none blank; values deduplicated in order.
402fn checked_claim(
403    claim: impl Into<String>,
404    values: impl IntoIterator<Item = impl Into<String>>,
405) -> Result<ClaimClause, McpScopesError> {
406    let claim = claim.into();
407    let mut any_of: Vec<String> = Vec::new();
408    let mut blank = claim.trim().is_empty();
409    for value in values {
410        let value = value.into();
411        blank |= value.trim().is_empty();
412        if !any_of.contains(&value) {
413            any_of.push(value);
414        }
415    }
416    if blank || any_of.is_empty() {
417        return Err(McpScopesError::InvalidClaimRequirement { claim });
418    }
419    Ok(ClaimClause { claim, any_of })
420}
421
422// `default` is the name the requirement reads best under; this type has no
423// meaningful `Default` (`new` is it), and a `Default` impl would make
424// `McpToolScopes::default()` resolve to the inherent method anyway.
425#[allow(clippy::new_without_default)]
426impl McpToolScopes {
427    /// No requirement at all: every tool, and every other request, needs
428    /// only what the authentication layer requires, until
429    /// [`default`](Self::default) and [`tool`](Self::tool) add some.
430    pub fn new() -> Self {
431        Self {
432            rules: Arc::new(Rules {
433                default: Requirement::default(),
434                tools: Vec::new(),
435                strictest: Requirement::default(),
436                always_scoped: false,
437                body_limit: DEFAULT_BODY_LIMIT,
438                static_bypasses: false,
439            }),
440        }
441    }
442
443    fn update(self, f: impl FnOnce(&mut Rules)) -> Self {
444        let mut rules = Arc::unwrap_or_clone(self.rules);
445        f(&mut rules);
446        Self {
447            rules: Arc::new(rules.with_strictest()),
448        }
449    }
450
451    /// The scopes every request needs unless a [`tool`](Self::tool) entry
452    /// says otherwise: every request with an empty body, every JSON-RPC message
453    /// other than `tools/call`, and a `tools/call` for a tool with no entry.
454    /// Replaces the default scopes given earlier (not the
455    /// [`default_claim`](Self::default_claim) clauses, which add to it). For
456    /// literals in code; see [`try_default`](Self::try_default) for scopes
457    /// from configuration.
458    ///
459    /// # Panics
460    ///
461    /// On a scope that is not a scope-token; see the type's docs.
462    pub fn default(self, scopes: impl IntoIterator<Item = impl Into<String>>) -> Self {
463        self.try_default(scopes)
464            .unwrap_or_else(|e| panic!("McpToolScopes::default: {e}"))
465    }
466
467    /// [`default`](Self::default) for scopes read from configuration.
468    ///
469    /// # Errors
470    ///
471    /// [`McpScopesError::InvalidScope`], naming the first scope that is not
472    /// a scope-token.
473    pub fn try_default(
474        self,
475        scopes: impl IntoIterator<Item = impl Into<String>>,
476    ) -> Result<Self, McpScopesError> {
477        let scopes = checked_scopes(scopes)?;
478        Ok(self.update(|rules| rules.default.scopes = scopes))
479    }
480
481    /// The scopes a `tools/call` for the tool named `name` needs, instead of
482    /// the [`default`](Self::default). An empty list means that tool needs
483    /// nothing beyond the authentication layer's own scopes (and its own
484    /// [`tool_claim`](Self::tool_claim) clauses, if any): a configured tool
485    /// gets neither the default scopes nor the
486    /// [`default_claim`](Self::default_claim) clauses. Replaces the scopes of
487    /// an entry for the same name, keeping its claim clauses. For literals in
488    /// code; see [`try_tool`](Self::try_tool) for a map read from
489    /// configuration.
490    ///
491    /// # Security
492    ///
493    /// `name` is matched **exactly**: byte for byte against the tool name
494    /// the request carries, after JSON decoding (`\u0041` is `A`), with no
495    /// case folding, trimming or Unicode normalization. A `tools/call` for
496    /// any other name gets the default. So register each tool under exactly
497    /// the name your MCP server dispatches on, and make that dispatch exact
498    /// too: a server that also runs `Write_Document` or `write_document `
499    /// as `write_document` would let a caller reach it under a spelling
500    /// this layer treats as unconfigured, with only the default's scopes.
501    ///
502    /// # Panics
503    ///
504    /// On a scope that is not a scope-token; see the type's docs.
505    pub fn tool(
506        self,
507        name: impl Into<String>,
508        scopes: impl IntoIterator<Item = impl Into<String>>,
509    ) -> Self {
510        self.try_tool(name, scopes)
511            .unwrap_or_else(|e| panic!("McpToolScopes::tool: {e}"))
512    }
513
514    /// [`tool`](Self::tool) for a tool-to-scopes map read from
515    /// configuration. The same exact name matching applies.
516    ///
517    /// # Errors
518    ///
519    /// [`McpScopesError::InvalidScope`], naming the first scope that is not
520    /// a scope-token.
521    ///
522    /// # Examples
523    ///
524    /// ```
525    /// use oauth_resource_server::mcp::{McpScopesError, McpToolScopes};
526    ///
527    /// // As read from a config file.
528    /// let configured = [("write_document", vec!["docs:write"]), ("purge", vec!["docs admin"])];
529    /// let mut layer = McpToolScopes::new().try_default(["docs:read"]).unwrap();
530    /// let mut refused = None;
531    /// for (tool, scopes) in configured {
532    ///     match layer.clone().try_tool(tool, scopes) {
533    ///         Ok(next) => layer = next,
534    ///         Err(McpScopesError::InvalidScope(e)) => refused = Some(e.scope().to_string()),
535    ///         Err(other) => panic!("{other}"),
536    ///     }
537    /// }
538    /// assert_eq!(refused.as_deref(), Some("docs admin"));
539    /// # let _ = layer;
540    /// ```
541    pub fn try_tool(
542        self,
543        name: impl Into<String>,
544        scopes: impl IntoIterator<Item = impl Into<String>>,
545    ) -> Result<Self, McpScopesError> {
546        let name = name.into();
547        let scopes = checked_scopes(scopes)?;
548        Ok(self.update(|rules| {
549            let claims = rules
550                .tools
551                .iter()
552                .position(|(tool, _)| *tool == name)
553                .map(|i| rules.tools.remove(i).1.claims)
554                .unwrap_or_default();
555            rules.tools.push((name, Requirement { scopes, claims }));
556        }))
557    }
558
559    /// Add a claim clause every request needs unless its tool is configured
560    /// (by [`tool`](Self::tool) or [`tool_claim`](Self::tool_claim)): the
561    /// verified top-level claim `claim` must hold at least one of `values`.
562    /// Repeatable: every clause must hold (all-of), any one value within a
563    /// clause will do (any-of). Applies to the same requests as the
564    /// [`default`](Self::default) scopes, and on top of them. For literals in
565    /// code; see [`try_default_claim`](Self::try_default_claim) for values
566    /// from configuration.
567    ///
568    /// A value matches as
569    /// [`AuthorizedToken::has_claim_value`](crate::AuthorizedToken::has_claim_value)
570    /// does: the claim is that string, or an array with that string element —
571    /// exact and case-sensitive; a space-delimited string is one value, not
572    /// split. `claim` names a top-level claim literally (a dot is part of the
573    /// name).
574    ///
575    /// # Security
576    ///
577    /// A refusal is a 403 whose `WWW-Authenticate` challenge names scopes
578    /// only — RFC 6750 has no claim error — so a client is never told which
579    /// claim or value it lacked, and no claim name or value enters a header.
580    /// The log line names the claim, never a value. A static token carries
581    /// no claims: it is refused whenever a clause applies, unless
582    /// [`static_token_bypasses_scopes`](Self::static_token_bypasses_scopes),
583    /// which covers claims too.
584    ///
585    /// # Panics
586    ///
587    /// On a blank claim name, no values, or a blank value; see
588    /// [`McpScopesError::InvalidClaimRequirement`].
589    ///
590    /// # Examples
591    ///
592    /// ```
593    /// use oauth_resource_server::mcp::McpToolScopes;
594    ///
595    /// // Every request needs the `staff` group, except calls to `search`.
596    /// let layer = McpToolScopes::new()
597    ///     .default_claim("groups", ["staff"])
598    ///     .tool("search", [] as [&str; 0]);
599    /// let staff = ["staff".to_string()];
600    /// assert_eq!(layer.claims_for_tool("write_document"), [("groups", &staff[..])]);
601    /// assert!(layer.claims_for_tool("search").is_empty());
602    /// ```
603    pub fn default_claim(
604        self,
605        claim: impl Into<String>,
606        values: impl IntoIterator<Item = impl Into<String>>,
607    ) -> Self {
608        self.try_default_claim(claim, values)
609            .unwrap_or_else(|e| panic!("McpToolScopes::default_claim: {e}"))
610    }
611
612    /// [`default_claim`](Self::default_claim) for values read from
613    /// configuration.
614    ///
615    /// # Errors
616    ///
617    /// [`McpScopesError::InvalidClaimRequirement`] on a blank claim name, no
618    /// values, or a blank value.
619    pub fn try_default_claim(
620        self,
621        claim: impl Into<String>,
622        values: impl IntoIterator<Item = impl Into<String>>,
623    ) -> Result<Self, McpScopesError> {
624        let clause = checked_claim(claim, values)?;
625        Ok(self.update(|rules| {
626            if !rules.default.claims.contains(&clause) {
627                rules.default.claims.push(clause);
628            }
629        }))
630    }
631
632    /// Add a claim clause a `tools/call` for the tool named `name` needs: the
633    /// verified top-level claim `claim` must hold at least one of `values`
634    /// (matched as in [`default_claim`](Self::default_claim)). Repeatable,
635    /// all-of across clauses, any-of within one; two clauses naming the same
636    /// claim are two requirements, never merged.
637    ///
638    /// This configures the tool: it then needs its own scopes (none, unless
639    /// [`tool`](Self::tool) gives some) and its own clauses — not the
640    /// [`default`](Self::default) scopes, nor the
641    /// [`default_claim`](Self::default_claim) clauses. Give a tool that should
642    /// also need the default scopes those scopes explicitly with `tool`.
643    ///
644    /// # Security
645    ///
646    /// The exact name matching of [`tool`](Self::tool)'s `# Security`
647    /// section applies, and the challenge and logging rules of
648    /// [`default_claim`](Self::default_claim)'s.
649    ///
650    /// # Panics
651    ///
652    /// On a blank claim name, no values, or a blank value; see
653    /// [`McpScopesError::InvalidClaimRequirement`].
654    ///
655    /// # Examples
656    ///
657    /// ```
658    /// use oauth_resource_server::mcp::McpToolScopes;
659    ///
660    /// // `purge` needs `mcp:write` AND membership of `admins` or `ops`.
661    /// let layer = McpToolScopes::new()
662    ///     .default(["mcp:read"])
663    ///     .tool("purge", ["mcp:write"])
664    ///     .tool_claim("purge", "groups", ["admins", "ops"]);
665    /// assert_eq!(layer.scopes_for_tool("purge"), ["mcp:write"]);
666    /// let groups = ["admins".to_string(), "ops".to_string()];
667    /// assert_eq!(layer.claims_for_tool("purge"), [("groups", &groups[..])]);
668    /// ```
669    pub fn tool_claim(
670        self,
671        name: impl Into<String>,
672        claim: impl Into<String>,
673        values: impl IntoIterator<Item = impl Into<String>>,
674    ) -> Self {
675        self.try_tool_claim(name, claim, values)
676            .unwrap_or_else(|e| panic!("McpToolScopes::tool_claim: {e}"))
677    }
678
679    /// [`tool_claim`](Self::tool_claim) for values read from configuration.
680    /// The same exact name matching applies.
681    ///
682    /// # Errors
683    ///
684    /// [`McpScopesError::InvalidClaimRequirement`] on a blank claim name, no
685    /// values, or a blank value.
686    ///
687    /// # Examples
688    ///
689    /// ```
690    /// use oauth_resource_server::mcp::{McpScopesError, McpToolScopes};
691    ///
692    /// let refused = McpToolScopes::new().try_tool_claim("purge", "groups", [" "]);
693    /// assert!(matches!(
694    ///     refused,
695    ///     Err(McpScopesError::InvalidClaimRequirement { claim, .. }) if claim == "groups"
696    /// ));
697    /// ```
698    pub fn try_tool_claim(
699        self,
700        name: impl Into<String>,
701        claim: impl Into<String>,
702        values: impl IntoIterator<Item = impl Into<String>>,
703    ) -> Result<Self, McpScopesError> {
704        let name = name.into();
705        let clause = checked_claim(claim, values)?;
706        Ok(self.update(|rules| {
707            let i = match rules.tools.iter().position(|(tool, _)| *tool == name) {
708                Some(i) => i,
709                None => {
710                    rules.tools.push((name, Requirement::default()));
711                    rules.tools.len() - 1
712                }
713            };
714            let claims = &mut rules.tools[i].1.claims;
715            if !claims.contains(&clause) {
716                claims.push(clause);
717            }
718        }))
719    }
720
721    /// The largest request body read, in bytes ([`DEFAULT_BODY_LIMIT`]
722    /// unless set). A body announced larger (`Content-Length`, or the
723    /// body's own size hint) is refused with 413 before any of it is read;
724    /// one that turns out larger is refused as soon as a chunk would cross
725    /// the limit, so no more than the limit is ever held. For a literal in
726    /// code; see [`try_body_limit`](Self::try_body_limit) for a configured
727    /// value.
728    ///
729    /// # Panics
730    ///
731    /// Outside [`MIN_BODY_LIMIT`]`..=`[`MAX_BODY_LIMIT`].
732    pub fn body_limit(self, bytes: usize) -> Self {
733        self.try_body_limit(bytes)
734            .unwrap_or_else(|e| panic!("McpToolScopes::body_limit: {e}"))
735    }
736
737    /// [`body_limit`](Self::body_limit) for a configured value.
738    ///
739    /// # Errors
740    ///
741    /// [`McpScopesError::BodyLimitOutOfRange`] outside
742    /// [`MIN_BODY_LIMIT`]`..=`[`MAX_BODY_LIMIT`].
743    pub fn try_body_limit(self, bytes: usize) -> Result<Self, McpScopesError> {
744        if !(MIN_BODY_LIMIT..=MAX_BODY_LIMIT).contains(&bytes) {
745            return Err(McpScopesError::BodyLimitOutOfRange(bytes));
746        }
747        Ok(self.update(|rules| rules.body_limit = bytes))
748    }
749
750    /// Let a static token through whatever the request requires — scopes
751    /// and claim clauses alike (a static token has neither) — instead of
752    /// refusing it with 403.
753    ///
754    /// # Security
755    ///
756    /// The static token then reaches every tool. Opt in only where it is
757    /// meant to be a full-access key.
758    pub fn static_token_bypasses_scopes(self) -> Self {
759        self.update(|rules| rules.static_bypasses = true)
760    }
761
762    /// The scopes `tool` requires: its own entry's (empty for a tool
763    /// configured by [`tool_claim`](Self::tool_claim) alone), or the default
764    /// for a tool with no entry.
765    pub fn scopes_for_tool(&self, tool: &str) -> &[String] {
766        &self.rules.for_tool(tool).scopes
767    }
768
769    /// The claim clauses `tool` requires, as `(claim, accepted values)`
770    /// pairs in configuration order: its own entry's (empty for a tool
771    /// configured by [`tool`](Self::tool) alone), or the
772    /// [`default_claim`](Self::default_claim) clauses for a tool with no
773    /// entry. Every pair must hold; any one value within a pair will do.
774    pub fn claims_for_tool(&self, tool: &str) -> Vec<(&str, &[String])> {
775        self.rules
776            .for_tool(tool)
777            .claims
778            .iter()
779            .map(|clause| (clause.claim.as_str(), &clause.any_of[..]))
780            .collect()
781    }
782}
783
784impl<S> tower_layer::Layer<S> for McpToolScopes {
785    type Service = McpToolScopesService<S>;
786
787    fn layer(&self, inner: S) -> Self::Service {
788        McpToolScopesService {
789            rules: Arc::clone(&self.rules),
790            inner,
791        }
792    }
793}
794
795/// The service a [`McpToolScopes`] layer wraps another in.
796///
797/// Generic over the request body, which it must be able to rebuild from the
798/// bytes it read (`ReqBody: From<Bytes>`, as `axum::body::Body` and
799/// `http_body_util::Full<Bytes>` are); a server on a body type that is not
800/// (hyper's `Incoming`) maps it to one first. Trailers of a `POST` body are
801/// not passed on.
802#[derive(Clone, Debug)]
803pub struct McpToolScopesService<S> {
804    rules: Arc<Rules>,
805    inner: S,
806}
807
808/// Why a `POST` body could not be read.
809enum ReadError {
810    TooLarge,
811    Failed,
812}
813
814/// Read `body` whole, refusing it as soon as it is known to exceed `limit`.
815/// `announced` (the larger of `Content-Length` and the size hint's lower
816/// bound, already checked against `limit`) is reserved up front, so a body
817/// that arrives as announced is held once, never in a doubling buffer.
818async fn read_capped<B: Body + Unpin>(
819    mut body: B,
820    limit: usize,
821    announced: u64,
822) -> Result<Bytes, ReadError> {
823    if body.size_hint().lower() > limit as u64 {
824        return Err(ReadError::TooLarge);
825    }
826    let reserve = usize::try_from(announced).map_or(limit, |n| n.min(limit));
827    let mut buf: Vec<u8> = Vec::with_capacity(reserve);
828    loop {
829        let frame = std::future::poll_fn(|cx| Pin::new(&mut body).poll_frame(cx)).await;
830        match frame {
831            None => return Ok(Bytes::from(buf)),
832            Some(Err(_)) => return Err(ReadError::Failed),
833            Some(Ok(frame)) => {
834                // Trailers carry no JSON-RPC and are dropped.
835                let Ok(mut data) = frame.into_data() else {
836                    continue;
837                };
838                if buf.len().saturating_add(data.remaining()) > limit {
839                    return Err(ReadError::TooLarge);
840                }
841                while data.has_remaining() {
842                    let chunk = data.chunk();
843                    let n = chunk.len();
844                    buf.extend_from_slice(chunk);
845                    data.advance(n);
846                }
847            }
848        }
849    }
850}
851
852/// A response with `status` and an empty (default) body.
853fn plain<B: Default>(status: StatusCode) -> Response<B> {
854    let mut response = Response::new(B::default());
855    *response.status_mut() = status;
856    response
857}
858
859impl<S, ReqBody, ResBody> tower_service::Service<Request<ReqBody>> for McpToolScopesService<S>
860where
861    S: tower_service::Service<Request<ReqBody>, Response = Response<ResBody>>
862        + Clone
863        + Send
864        + 'static,
865    S::Future: Send + 'static,
866    ReqBody: Body + From<Bytes> + Send + 'static,
867    ReqBody::Data: Send,
868    ReqBody::Error: Send,
869    ResBody: Default + 'static,
870{
871    type Response = Response<ResBody>;
872    type Error = S::Error;
873    type Future =
874        Pin<Box<dyn Future<Output = Result<Response<ResBody>, S::Error>> + Send + 'static>>;
875
876    fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
877        self.inner.poll_ready(cx)
878    }
879
880    fn call(&mut self, request: Request<ReqBody>) -> Self::Future {
881        let clone = self.inner.clone();
882        let mut inner = std::mem::replace(&mut self.inner, clone);
883        let rules = Arc::clone(&self.rules);
884        Box::pin(async move {
885            let (parts, body) = request.into_parts();
886            // No layer in front: refuse before reading anything.
887            if let ScopeVerdict::NoLayer = judge_scopes(&parts, &[], &[], false) {
888                return Ok(scope_refusal(
889                    &parts,
890                    &TokenRejection::Missing,
891                    &[],
892                    "McpToolScopes",
893                ));
894            }
895            let content_length = parts
896                .headers
897                .get(http::header::CONTENT_LENGTH)
898                .and_then(|v| v.to_str().ok())
899                .and_then(|v| v.trim().parse::<u64>().ok());
900            let hint = body.size_hint();
901            // No credential (an `optional()` layer passed the request), and
902            // every request needs a scope whatever its body says (a
903            // non-empty default, no tool with an empty list): refuse before
904            // reading a byte. Otherwise the body decides, so it is read.
905            if rules.always_scoped
906                && parts
907                    .extensions
908                    .get::<crate::authenticate::Credential>()
909                    .is_none()
910            {
911                return Ok(scope_refusal(
912                    &parts,
913                    &TokenRejection::Missing,
914                    &rules.default.scopes,
915                    "McpToolScopes",
916                ));
917            }
918            // Every request's body is read, whatever the method and whatever
919            // its size hint or headers claim: an empty one ends at once, and
920            // a hint of exactly 0 is not trusted as proof that no
921            // `tools/call` follows. A JSON-RPC server other than MCP's
922            // Streamable HTTP transport may read one from a `GET` or a `PUT`
923            // just as well.
924            let (required, body) = {
925                let announced = content_length.unwrap_or(0).max(hint.lower());
926                let read = if announced > rules.body_limit as u64 {
927                    Err(ReadError::TooLarge)
928                } else {
929                    read_capped(Box::pin(body), rules.body_limit, announced).await
930                };
931                let bytes = match read {
932                    Ok(bytes) => bytes,
933                    Err(ReadError::TooLarge) => {
934                        warn!(
935                            path = %parts.uri.path(),
936                            limit = rules.body_limit,
937                            "MCP request body exceeds the limit; refusing it unread"
938                        );
939                        return Ok(plain(StatusCode::PAYLOAD_TOO_LARGE));
940                    }
941                    Err(ReadError::Failed) => {
942                        warn!(
943                            path = %parts.uri.path(),
944                            "MCP request body could not be read; refusing the request"
945                        );
946                        return Ok(plain(StatusCode::BAD_REQUEST));
947                    }
948                };
949                (rules.for_body(&bytes), ReqBody::from(bytes))
950            };
951            match judge_scopes(
952                &parts,
953                &required.scopes,
954                &required.claims,
955                rules.static_bypasses,
956            ) {
957                ScopeVerdict::Pass => inner.call(Request::from_parts(parts, body)).await,
958                ScopeVerdict::Refuse(rejection) => Ok(scope_refusal_with_claims(
959                    &parts,
960                    &rejection,
961                    &required.scopes,
962                    &required.claims,
963                    "McpToolScopes",
964                )),
965                ScopeVerdict::NoLayer => Ok(scope_refusal_with_claims(
966                    &parts,
967                    &TokenRejection::Missing,
968                    &required.scopes,
969                    &required.claims,
970                    "McpToolScopes",
971                )),
972            }
973        })
974    }
975}
976
977/// `Message::ToolCall` for a tool the matcher does not know.
978pub(crate) const UNKNOWN_TOOL: usize = usize::MAX;
979
980/// One JSON-RPC message, as far as tool scopes are concerned.
981#[derive(Debug, PartialEq, Eq)]
982pub(crate) enum Message {
983    /// Anything but a `tools/call` request.
984    NotToolCall,
985    /// A `tools/call` for the tool the name matcher mapped to this index
986    /// ([`UNKNOWN_TOOL`] for none). The name itself is never copied.
987    ToolCall(usize),
988    /// A `tools/call` with no readable string `params.name`, a message with
989    /// a repeated `method`, `params` or `params.name` member, a message with
990    /// a serde_json token key at any depth ([`RAW_VALUE_TOKEN`],
991    /// [`NUMBER_TOKEN`]), or a batch element that is not an object: not
992    /// classified with certainty.
993    Ambiguous,
994}
995
996/// One message with the tool name spelled out (tests and the fuzz oracle
997/// only).
998#[cfg(any(test, fuzzing))]
999#[derive(Debug, PartialEq, Eq)]
1000pub(crate) enum NamedMessage {
1001    /// See [`Message::NotToolCall`].
1002    NotToolCall,
1003    /// A `tools/call` for this tool.
1004    ToolCall(String),
1005    /// See [`Message::Ambiguous`].
1006    Ambiguous,
1007}
1008
1009/// Every message of a body, collected (tests and the fuzz oracle only; the
1010/// request path folds them as they come, see [`for_each_message`]).
1011#[cfg(any(test, fuzzing))]
1012#[derive(Debug, PartialEq, Eq)]
1013pub(crate) enum Classified {
1014    /// One message, or a batch's messages in order (possibly none).
1015    Messages(Vec<NamedMessage>),
1016    /// Not JSON, or JSON that is neither an object nor an array.
1017    Unreadable,
1018}
1019
1020/// [`for_each_message`], collected, with every tool name kept.
1021#[cfg(any(test, fuzzing))]
1022pub(crate) fn classify(body: &[u8]) -> Classified {
1023    let mut names: Vec<String> = Vec::new();
1024    let mut messages = Vec::new();
1025    let readable = for_each_message(
1026        body,
1027        &mut |name| {
1028            names.push(name.to_string());
1029            names.len() - 1
1030        },
1031        &mut |m| messages.push(m),
1032    );
1033    if !readable {
1034        return Classified::Unreadable;
1035    }
1036    Classified::Messages(
1037        messages
1038            .into_iter()
1039            .map(|m| match m {
1040                Message::NotToolCall => NamedMessage::NotToolCall,
1041                Message::ToolCall(i) => NamedMessage::ToolCall(names[i].clone()),
1042                Message::Ambiguous => NamedMessage::Ambiguous,
1043            })
1044            .collect(),
1045    )
1046}
1047
1048/// Hand every message of `body` — one object, or each element of a batch
1049/// array, in order — to `sink`, in ONE streaming pass that builds nothing:
1050/// a tool name is handed to `tool` as a borrowed `&str` (which maps it to
1051/// an index) and never copied, and `method` is compared in place. Returns
1052/// `false` when the body is not a JSON object or array, or is not valid JSON
1053/// anywhere in it; `sink` may then have seen some messages already, and the
1054/// caller must discard them (the request path answers `false` with the
1055/// strictest set).
1056///
1057/// Every value it does not look at is still fully validated
1058/// ([`Validate`]: every string's UTF-8 and escapes, every number's range),
1059/// so it refuses exactly what `serde_json::from_slice::<Value>` refuses —
1060/// the `mcp_tool_calls` fuzz target checks that against `Value` — without
1061/// materializing the document (which costs about 16× the body). Bounded by
1062/// `serde_json`'s recursion limit; never panics.
1063pub(crate) fn for_each_message(
1064    body: &[u8],
1065    tool: &mut dyn FnMut(&str) -> usize,
1066    sink: &mut dyn FnMut(Message),
1067) -> bool {
1068    let mut deserializer = serde_json::Deserializer::from_slice(body);
1069    TopSeed { tool, sink }
1070        .deserialize(&mut deserializer)
1071        .is_ok()
1072        && deserializer.end().is_ok()
1073}
1074
1075/// Accept every scalar JSON value (after the deserializer has validated it)
1076/// as `$value`.
1077macro_rules! accept_scalars {
1078    ($value:expr) => {
1079        fn visit_bool<E>(self, _: bool) -> Result<Self::Value, E> {
1080            Ok($value)
1081        }
1082        fn visit_i64<E>(self, _: i64) -> Result<Self::Value, E> {
1083            Ok($value)
1084        }
1085        fn visit_u64<E>(self, _: u64) -> Result<Self::Value, E> {
1086            Ok($value)
1087        }
1088        fn visit_f64<E>(self, _: f64) -> Result<Self::Value, E> {
1089            Ok($value)
1090        }
1091        fn visit_str<E>(self, _: &str) -> Result<Self::Value, E> {
1092            Ok($value)
1093        }
1094        fn visit_unit<E>(self) -> Result<Self::Value, E> {
1095            Ok($value)
1096        }
1097    };
1098}
1099
1100/// Accept every non-string scalar as `$value` (a string is handled by the
1101/// visitor itself).
1102macro_rules! accept_non_string_scalars {
1103    ($value:expr) => {
1104        fn visit_bool<E>(self, _: bool) -> Result<Self::Value, E> {
1105            Ok($value)
1106        }
1107        fn visit_i64<E>(self, _: i64) -> Result<Self::Value, E> {
1108            Ok($value)
1109        }
1110        fn visit_u64<E>(self, _: u64) -> Result<Self::Value, E> {
1111            Ok($value)
1112        }
1113        fn visit_f64<E>(self, _: f64) -> Result<Self::Value, E> {
1114            Ok($value)
1115        }
1116        fn visit_unit<E>(self) -> Result<Self::Value, E> {
1117            Ok($value)
1118        }
1119    };
1120}
1121
1122/// serde_json's private "magic" object keys. They are serde_json
1123/// implementation details, not JSON: when the application's build enables
1124/// serde_json's `raw_value` feature (axum does, so feature unification turns
1125/// it on for most servers), `serde_json::Value` reads an object whose first
1126/// key is [`RAW_VALUE_TOKEN`] as the JSON *inside that key's string value*,
1127/// not as an object; with `arbitrary_precision`, an object whose first key
1128/// is [`NUMBER_TOKEN`] becomes a number. So
1129/// `{"$serde_json::private::RawValue":"{\"method\":\"tools/call\",…}"}` is a
1130/// `tools/call` to an MCP dispatcher that parses into `Value`, while a plain
1131/// JSON reading (this module's) sees one object with an unknown member. See
1132/// serde_json's `raw_value` and `arbitrary_precision` features
1133/// (<https://docs.rs/serde_json/latest/serde_json/value/struct.RawValue.html>,
1134/// <https://github.com/serde-rs/json>); the strings are copied here because
1135/// serde_json does not export them. A message holding either key at any
1136/// depth is [`Message::Ambiguous`] — no real client sends one.
1137const RAW_VALUE_TOKEN: &str = "$serde_json::private::RawValue";
1138/// See [`RAW_VALUE_TOKEN`].
1139const NUMBER_TOKEN: &str = "$serde_json::private::Number";
1140
1141/// Any JSON value, validated in full and then dropped: the stand-in for
1142/// `IgnoredAny`, which skips a number or a string without checking it.
1143/// `token_key`: whether an object anywhere inside it had a serde_json token
1144/// key ([`RAW_VALUE_TOKEN`], [`NUMBER_TOKEN`]).
1145struct Validate {
1146    token_key: bool,
1147}
1148
1149/// Validate every entry of `map`; whether any object in it (itself
1150/// included) had a serde_json token key.
1151fn validate_map<'de, A: MapAccess<'de>>(mut map: A) -> Result<bool, A::Error> {
1152    let mut token_key = false;
1153    while let Some(key) = map.next_key::<Key>()? {
1154        token_key |= matches!(key, Key::SerdeJsonToken);
1155        token_key |= map.next_value::<Validate>()?.token_key;
1156    }
1157    Ok(token_key)
1158}
1159
1160/// Validate every element of `seq`; whether any object in it had a
1161/// serde_json token key.
1162fn validate_seq<'de, A: SeqAccess<'de>>(mut seq: A) -> Result<bool, A::Error> {
1163    let mut token_key = false;
1164    while let Some(element) = seq.next_element::<Validate>()? {
1165        token_key |= element.token_key;
1166    }
1167    Ok(token_key)
1168}
1169
1170impl<'de> Deserialize<'de> for Validate {
1171    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
1172        struct V;
1173        impl<'de> Visitor<'de> for V {
1174            type Value = Validate;
1175            fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1176                f.write_str("any JSON value")
1177            }
1178            accept_scalars!(Validate { token_key: false });
1179            fn visit_map<A: MapAccess<'de>>(self, map: A) -> Result<Validate, A::Error> {
1180                Ok(Validate {
1181                    token_key: validate_map(map)?,
1182                })
1183            }
1184            fn visit_seq<A: SeqAccess<'de>>(self, seq: A) -> Result<Validate, A::Error> {
1185                Ok(Validate {
1186                    token_key: validate_seq(seq)?,
1187                })
1188            }
1189        }
1190        deserializer.deserialize_any(V)
1191    }
1192}
1193
1194/// The top-level value: one message (an object) or a batch (an array);
1195/// anything else is an error, i.e. unreadable.
1196struct TopSeed<'s> {
1197    tool: &'s mut dyn FnMut(&str) -> usize,
1198    sink: &'s mut dyn FnMut(Message),
1199}
1200
1201impl<'de> DeserializeSeed<'de> for TopSeed<'_> {
1202    type Value = ();
1203
1204    fn deserialize<D: Deserializer<'de>>(self, deserializer: D) -> Result<(), D::Error> {
1205        struct V<'s>(TopSeed<'s>);
1206        impl<'de> Visitor<'de> for V<'_> {
1207            type Value = ();
1208            fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1209                f.write_str("a JSON-RPC message or batch")
1210            }
1211            fn visit_map<A: MapAccess<'de>>(self, map: A) -> Result<(), A::Error> {
1212                let message = read_message(map, self.0.tool)?;
1213                (self.0.sink)(message);
1214                Ok(())
1215            }
1216            fn visit_seq<A: SeqAccess<'de>>(self, mut seq: A) -> Result<(), A::Error> {
1217                let TopSeed { tool, sink } = self.0;
1218                while seq
1219                    .next_element_seed(ElementSeed {
1220                        tool: &mut *tool,
1221                        sink: &mut *sink,
1222                    })?
1223                    .is_some()
1224                {}
1225                Ok(())
1226            }
1227        }
1228        deserializer.deserialize_any(V(self))
1229    }
1230}
1231
1232/// One batch element: a message, or anything else (`Ambiguous`).
1233struct ElementSeed<'s> {
1234    tool: &'s mut dyn FnMut(&str) -> usize,
1235    sink: &'s mut dyn FnMut(Message),
1236}
1237
1238impl<'de> DeserializeSeed<'de> for ElementSeed<'_> {
1239    type Value = ();
1240
1241    fn deserialize<D: Deserializer<'de>>(self, deserializer: D) -> Result<(), D::Error> {
1242        struct V<'s>(&'s mut dyn FnMut(&str) -> usize);
1243        impl<'de> Visitor<'de> for V<'_> {
1244            type Value = Message;
1245            fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1246                f.write_str("any JSON value")
1247            }
1248            accept_scalars!(Message::Ambiguous);
1249            fn visit_map<A: MapAccess<'de>>(self, map: A) -> Result<Message, A::Error> {
1250                read_message(map, self.0)
1251            }
1252            fn visit_seq<A: SeqAccess<'de>>(self, seq: A) -> Result<Message, A::Error> {
1253                validate_seq(seq)?;
1254                Ok(Message::Ambiguous)
1255            }
1256        }
1257        let message = deserializer.deserialize_any(V(self.tool))?;
1258        (self.sink)(message);
1259        Ok(())
1260    }
1261}
1262
1263/// An object key, compared without allocating (after JSON decoding, so an
1264/// escaped spelling of a serde_json token key is still one).
1265enum Key {
1266    Method,
1267    Params,
1268    Name,
1269    /// [`RAW_VALUE_TOKEN`] or [`NUMBER_TOKEN`].
1270    SerdeJsonToken,
1271    Other,
1272}
1273
1274impl<'de> Deserialize<'de> for Key {
1275    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
1276        struct V;
1277        impl Visitor<'_> for V {
1278            type Value = Key;
1279            fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1280                f.write_str("an object key")
1281            }
1282            fn visit_str<E>(self, key: &str) -> Result<Key, E> {
1283                Ok(match key {
1284                    "method" => Key::Method,
1285                    "params" => Key::Params,
1286                    "name" => Key::Name,
1287                    RAW_VALUE_TOKEN | NUMBER_TOKEN => Key::SerdeJsonToken,
1288                    _ => Key::Other,
1289                })
1290            }
1291        }
1292        deserializer.deserialize_any(V)
1293    }
1294}
1295
1296/// Whether `method` is the string `"tools/call"` — compared in place — and
1297/// whether it held a serde_json token key (a non-string `method` can only
1298/// be read as `"tools/call"` through one).
1299struct IsToolsCall {
1300    tools_call: bool,
1301    token_key: bool,
1302}
1303
1304impl<'de> Deserialize<'de> for IsToolsCall {
1305    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
1306        struct V;
1307        impl<'de> Visitor<'de> for V {
1308            type Value = IsToolsCall;
1309            fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1310                f.write_str("any JSON value")
1311            }
1312            accept_non_string_scalars!(IsToolsCall {
1313                tools_call: false,
1314                token_key: false
1315            });
1316            fn visit_str<E>(self, v: &str) -> Result<IsToolsCall, E> {
1317                Ok(IsToolsCall {
1318                    tools_call: v == "tools/call",
1319                    token_key: false,
1320                })
1321            }
1322            fn visit_map<A: MapAccess<'de>>(self, map: A) -> Result<IsToolsCall, A::Error> {
1323                Ok(IsToolsCall {
1324                    tools_call: false,
1325                    token_key: validate_map(map)?,
1326                })
1327            }
1328            fn visit_seq<A: SeqAccess<'de>>(self, seq: A) -> Result<IsToolsCall, A::Error> {
1329                Ok(IsToolsCall {
1330                    tools_call: false,
1331                    token_key: validate_seq(seq)?,
1332                })
1333            }
1334        }
1335        deserializer.deserialize_any(V)
1336    }
1337}
1338
1339/// A `params.name` value handed to the tool matcher as a borrowed `&str`:
1340/// `Some(index)` for a string, `None` for anything else (validated,
1341/// consumed); and whether it held a serde_json token key.
1342struct NameSeed<'s>(&'s mut dyn FnMut(&str) -> usize);
1343
1344impl<'de> DeserializeSeed<'de> for NameSeed<'_> {
1345    type Value = (Option<usize>, bool);
1346
1347    fn deserialize<D: Deserializer<'de>>(self, deserializer: D) -> Result<Self::Value, D::Error> {
1348        struct V<'s>(&'s mut dyn FnMut(&str) -> usize);
1349        impl<'de> Visitor<'de> for V<'_> {
1350            type Value = (Option<usize>, bool);
1351            fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1352                f.write_str("any JSON value")
1353            }
1354            accept_non_string_scalars!((None, false));
1355            fn visit_str<E>(self, v: &str) -> Result<Self::Value, E> {
1356                Ok((Some((self.0)(v)), false))
1357            }
1358            fn visit_map<A: MapAccess<'de>>(self, map: A) -> Result<Self::Value, A::Error> {
1359                Ok((None, validate_map(map)?))
1360            }
1361            fn visit_seq<A: SeqAccess<'de>>(self, seq: A) -> Result<Self::Value, A::Error> {
1362                Ok((None, validate_seq(seq)?))
1363            }
1364        }
1365        deserializer.deserialize_any(V(self.0))
1366    }
1367}
1368
1369/// What a message's `params` says about the tool: `Some(index)` only for an
1370/// object with exactly one `name`, a string; and whether it held a
1371/// serde_json token key anywhere.
1372struct ParamsSeed<'s>(&'s mut dyn FnMut(&str) -> usize);
1373
1374impl<'de> DeserializeSeed<'de> for ParamsSeed<'_> {
1375    type Value = (Option<usize>, bool);
1376
1377    fn deserialize<D: Deserializer<'de>>(self, deserializer: D) -> Result<Self::Value, D::Error> {
1378        struct V<'s>(&'s mut dyn FnMut(&str) -> usize);
1379        impl<'de> Visitor<'de> for V<'_> {
1380            type Value = (Option<usize>, bool);
1381            fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1382                f.write_str("any JSON value")
1383            }
1384            accept_scalars!((None, false));
1385            fn visit_map<A: MapAccess<'de>>(self, mut map: A) -> Result<Self::Value, A::Error> {
1386                let mut name: Option<Option<usize>> = None;
1387                let mut repeated = false;
1388                let mut token_key = false;
1389                while let Some(key) = map.next_key::<Key>()? {
1390                    match key {
1391                        Key::Name if name.is_none() => {
1392                            let (found, nested) = map.next_value_seed(NameSeed(&mut *self.0))?;
1393                            name = Some(found);
1394                            token_key |= nested;
1395                        }
1396                        Key::Name => {
1397                            repeated = true;
1398                            token_key |= map.next_value::<Validate>()?.token_key;
1399                        }
1400                        key => {
1401                            token_key |= matches!(key, Key::SerdeJsonToken);
1402                            token_key |= map.next_value::<Validate>()?.token_key;
1403                        }
1404                    }
1405                }
1406                let name = match (repeated, name) {
1407                    (false, Some(name)) => name,
1408                    _ => None,
1409                };
1410                Ok((name, token_key))
1411            }
1412            fn visit_seq<A: SeqAccess<'de>>(self, seq: A) -> Result<Self::Value, A::Error> {
1413                Ok((None, validate_seq(seq)?))
1414            }
1415        }
1416        deserializer.deserialize_any(V(self.0))
1417    }
1418}
1419
1420/// Read one message object. `params` may come before `method`, so its tool
1421/// name is matched as it streams past whatever the method turns out to be;
1422/// only the index is kept. A serde_json token key anywhere in the message
1423/// (see [`RAW_VALUE_TOKEN`]) makes it [`Message::Ambiguous`], however the
1424/// rest of it reads.
1425fn read_message<'de, A: MapAccess<'de>>(
1426    mut map: A,
1427    tool: &mut dyn FnMut(&str) -> usize,
1428) -> Result<Message, A::Error> {
1429    let mut tools_call: Option<bool> = None;
1430    let mut params: Option<Option<usize>> = None;
1431    let mut repeated = false;
1432    let mut token_key = false;
1433    while let Some(key) = map.next_key::<Key>()? {
1434        match key {
1435            Key::Method if tools_call.is_none() => {
1436                let method = map.next_value::<IsToolsCall>()?;
1437                tools_call = Some(method.tools_call);
1438                token_key |= method.token_key;
1439            }
1440            Key::Params if params.is_none() => {
1441                let (name, nested) = map.next_value_seed(ParamsSeed(&mut *tool))?;
1442                params = Some(name);
1443                token_key |= nested;
1444            }
1445            Key::Method | Key::Params => {
1446                repeated = true;
1447                token_key |= map.next_value::<Validate>()?.token_key;
1448            }
1449            key => {
1450                token_key |= matches!(key, Key::SerdeJsonToken);
1451                token_key |= map.next_value::<Validate>()?.token_key;
1452            }
1453        }
1454    }
1455    Ok(match (repeated || token_key, tools_call) {
1456        (true, _) => Message::Ambiguous,
1457        (false, Some(true)) => match params {
1458            Some(Some(index)) => Message::ToolCall(index),
1459            _ => Message::Ambiguous,
1460        },
1461        _ => Message::NotToolCall,
1462    })
1463}
1464#[cfg(all(test, feature = "axum"))]
1465mod service_tests;
1466
1467#[cfg(test)]
1468mod tests {
1469    use super::*;
1470
1471    #[test]
1472    fn classification() {
1473        let one = |json: &str| classify(json.as_bytes());
1474        let msgs = |m: Vec<NamedMessage>| Classified::Messages(m);
1475        assert_eq!(
1476            one(
1477                r#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"w","arguments":{"name":"x"}}}"#
1478            ),
1479            msgs(vec![NamedMessage::ToolCall("w".into())])
1480        );
1481        assert_eq!(
1482            one(r#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#),
1483            msgs(vec![NamedMessage::NotToolCall])
1484        );
1485        // A response, a notification: not tool calls.
1486        assert_eq!(
1487            one(r#"{"jsonrpc":"2.0","id":1,"result":{}}"#),
1488            msgs(vec![NamedMessage::NotToolCall])
1489        );
1490        assert_eq!(
1491            one(r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#),
1492            msgs(vec![NamedMessage::NotToolCall])
1493        );
1494        // A method that is not a string is not `tools/call`.
1495        assert_eq!(
1496            one(r#"{"method":7}"#),
1497            msgs(vec![NamedMessage::NotToolCall])
1498        );
1499        // Escapes are decoded exactly as any JSON parser decodes them.
1500        assert_eq!(
1501            one(r#"{"method":"tools\/call","params":{"name":"w"}}"#),
1502            msgs(vec![NamedMessage::ToolCall("w".into())])
1503        );
1504        // No readable name: ambiguous.
1505        for json in [
1506            r#"{"method":"tools/call"}"#,
1507            r#"{"method":"tools/call","params":{}}"#,
1508            r#"{"method":"tools/call","params":{"name":1}}"#,
1509            r#"{"method":"tools/call","params":["w"]}"#,
1510            r#"{"method":"tools/call","params":{"name":"r","name":"w"}}"#,
1511            r#"{"method":"tools/list","method":"tools/call","params":{"name":"w"}}"#,
1512            r#"{"method":"tools/call","params":{"name":"r"},"params":{"name":"w"}}"#,
1513        ] {
1514            assert_eq!(one(json), msgs(vec![NamedMessage::Ambiguous]), "{json}");
1515        }
1516        // Batches, element by element; a non-object element is ambiguous.
1517        assert_eq!(
1518            one(r#"[{"method":"tools/list"},{"method":"tools/call","params":{"name":"w"}},3]"#),
1519            msgs(vec![
1520                NamedMessage::NotToolCall,
1521                NamedMessage::ToolCall("w".into()),
1522                NamedMessage::Ambiguous
1523            ])
1524        );
1525        assert_eq!(one("[]"), msgs(vec![]));
1526        // Not JSON, or not an object or array.
1527        for body in [
1528            "",
1529            "{",
1530            "nope",
1531            "7",
1532            "\"tools/call\"",
1533            "{} {}",
1534            "\u{feff}{}",
1535        ] {
1536            assert_eq!(one(body), Classified::Unreadable, "{body:?}");
1537        }
1538    }
1539
1540    fn rules() -> McpToolScopes {
1541        McpToolScopes::new()
1542            .default(["mcp:read"])
1543            .tool("write_document", ["mcp:write"])
1544            .tool("admin", ["mcp:write", "mcp:admin"])
1545    }
1546
1547    #[test]
1548    fn requirements_per_body() {
1549        let r = rules();
1550        let r = &r.rules;
1551        let req = |json: &str| r.for_body(json.as_bytes()).scopes;
1552        assert_eq!(
1553            req(r#"{"method":"tools/call","params":{"name":"write_document"}}"#),
1554            ["mcp:write"]
1555        );
1556        assert_eq!(
1557            req(r#"{"method":"tools/call","params":{"name":"other"}}"#),
1558            ["mcp:read"]
1559        );
1560        assert_eq!(req(r#"{"method":"initialize"}"#), ["mcp:read"]);
1561        let strictest = ["mcp:read", "mcp:write", "mcp:admin"];
1562        assert_eq!(req("not json"), strictest);
1563        assert_eq!(req(r#"{"method":"tools/call"}"#), strictest);
1564        assert_eq!(
1565            req(
1566                r#"[{"method":"tools/call","params":{"name":"write_document"}},{"method":"tools/list"}]"#
1567            ),
1568            // The default first, then each tool's, in configuration order.
1569            ["mcp:read", "mcp:write"]
1570        );
1571        assert_eq!(req("[]"), ["mcp:read"]);
1572        // Nothing at all: the default; whitespace alone: unreadable.
1573        assert_eq!(req(""), ["mcp:read"]);
1574        assert_eq!(req("  "), strictest);
1575        // Anything a strict JSON parser refuses is unreadable, even in a
1576        // member nobody looks at: an out-of-range number, invalid UTF-8.
1577        assert_eq!(req(r#"{"method":"initialize","x":1e999}"#), strictest);
1578        assert_eq!(
1579            r.for_body(b"{\"method\":\"initialize\",\"x\":\"\xff\"}")
1580                .scopes,
1581            strictest
1582        );
1583        assert_eq!(req(r#"{"method":"initialize"} x"#), strictest);
1584        assert_eq!(rules().scopes_for_tool("admin"), ["mcp:write", "mcp:admin"]);
1585        assert_eq!(rules().scopes_for_tool("nope"), ["mcp:read"]);
1586    }
1587
1588    /// A `tools/call` of `admin` wrapped in serde_json's `RawValue` token:
1589    /// `serde_json::Value` (with `raw_value` on) reads it as the call itself.
1590    const SMUGGLED_CALL: &str = r#"{"$serde_json::private::RawValue":"{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\",\"params\":{\"name\":\"admin\",\"arguments\":{}}}"}"#;
1591
1592    #[test]
1593    fn serde_json_token_keys_need_the_strictest_set() {
1594        let r = rules();
1595        let r = &r.rules;
1596        let strictest = ["mcp:read", "mcp:write", "mcp:admin"];
1597        let ambiguous = Classified::Messages(vec![NamedMessage::Ambiguous]);
1598        // What a dispatcher parsing into `Value` would see, in this build
1599        // (axum enables `raw_value`): the smuggled call itself.
1600        #[cfg(feature = "axum")]
1601        {
1602            let seen: serde_json::Value = serde_json::from_str(SMUGGLED_CALL).unwrap();
1603            assert_eq!(seen["method"], "tools/call");
1604            assert_eq!(seen["params"]["name"], "admin");
1605        }
1606        let batch = r#"{"$serde_json::private::RawValue":"[{\"method\":\"tools/list\"},{\"method\":\"tools/call\",\"params\":{\"name\":\"admin\"}}]"}"#;
1607        // The fuzz crash input: the wrapped string is not even JSON.
1608        let crash = "\n\n\n\n{\"$serde_json::private::RawValue\":\"{:\"\t} \n";
1609        let number = r#"{"$serde_json::private::Number":"1","method":"tools/call","params":{"name":"admin"}}"#;
1610        // The key spelled with an escape is still the key.
1611        let escaped = r#"{"$serde_json::private::RawValue":"{}"}"#;
1612        // Not the first key (serde_json only looks at the first; we look at all).
1613        let later = r#"{"method":"initialize","$serde_json::private::RawValue":"{}"}"#;
1614        for body in [SMUGGLED_CALL, batch, crash, number, escaped, later] {
1615            assert_eq!(classify(body.as_bytes()), ambiguous, "{body:?}");
1616            assert_eq!(r.for_body(body.as_bytes()).scopes, strictest, "{body:?}");
1617        }
1618        // In a batch element: that element is ambiguous, so the whole batch
1619        // needs the strictest set.
1620        let in_batch = format!(r#"[{{"method":"tools/list"}},{SMUGGLED_CALL}]"#);
1621        assert_eq!(
1622            classify(in_batch.as_bytes()),
1623            Classified::Messages(vec![NamedMessage::NotToolCall, NamedMessage::Ambiguous])
1624        );
1625        assert_eq!(r.for_body(in_batch.as_bytes()).scopes, strictest);
1626        // Nested at any depth: in `params` (`Value` would read `params.name`
1627        // from the wrapped string; it was already ambiguous, having no
1628        // name), in `method` (`Value` would read `"tools/call"`; before the
1629        // token-key rule this was a `NotToolCall`), in `params.name`, and in
1630        // a member nobody else reads.
1631        for body in [
1632            r#"{"method":"tools/call","params":{"$serde_json::private::RawValue":"{\"name\":\"admin\"}"}}"#,
1633            r#"{"method":{"$serde_json::private::RawValue":"\"tools/call\""},"params":{"name":"admin"}}"#,
1634            r#"{"method":"tools/call","params":{"name":{"$serde_json::private::RawValue":"\"admin\""}}}"#,
1635            r#"{"method":"tools/call","params":{"name":"other","arguments":[{"$serde_json::private::Number":"1"}]}}"#,
1636            r#"{"method":"tools/list","id":{"x":[{"$serde_json::private::RawValue":"1"}]}}"#,
1637        ] {
1638            assert_eq!(classify(body.as_bytes()), ambiguous, "{body}");
1639            assert_eq!(r.for_body(body.as_bytes()).scopes, strictest, "{body}");
1640        }
1641        // The token string as a VALUE (not a key) is ordinary data.
1642        assert_eq!(
1643            req_scopes(
1644                r,
1645                r#"{"method":"tools/call","params":{"name":"write_document","arguments":{"x":"$serde_json::private::RawValue"}}}"#
1646            ),
1647            ["mcp:write"]
1648        );
1649        assert_eq!(
1650            classify(
1651                br#"{"method":"tools/call","params":{"name":"$serde_json::private::Number"}}"#
1652            ),
1653            Classified::Messages(vec![NamedMessage::ToolCall(
1654                "$serde_json::private::Number".into()
1655            )])
1656        );
1657        // A near miss is an ordinary key.
1658        assert_eq!(
1659            req_scopes(
1660                r,
1661                r#"{"method":"initialize","$serde_json::private::RawValu":"{}"}"#
1662            ),
1663            ["mcp:read"]
1664        );
1665    }
1666
1667    fn req_scopes(r: &Rules, json: &str) -> Vec<String> {
1668        r.for_body(json.as_bytes()).scopes
1669    }
1670
1671    #[test]
1672    fn a_repeated_tool_entry_replaces_the_earlier_one() {
1673        let r = McpToolScopes::new().tool("t", ["a"]).tool("t", ["b"]);
1674        assert_eq!(r.scopes_for_tool("t"), ["b"]);
1675        assert_eq!(r.rules.strictest.scopes, ["b"]);
1676    }
1677
1678    #[test]
1679    #[should_panic(expected = "is not a valid scope")]
1680    fn an_invalid_scope_panics() {
1681        let _ = McpToolScopes::new().tool("t", ["has space"]);
1682    }
1683
1684    #[test]
1685    fn the_try_forms_return_what_the_panicking_forms_panic_on() {
1686        match McpToolScopes::new().try_tool("t", ["ok", "has space"]) {
1687            Err(McpScopesError::InvalidScope(e)) => assert_eq!(e.scope(), "has space"),
1688            other => panic!("{other:?}"),
1689        }
1690        match McpToolScopes::new().try_default([""]) {
1691            Err(McpScopesError::InvalidScope(e)) => assert_eq!(e.scope(), ""),
1692            other => panic!("{other:?}"),
1693        }
1694        assert_eq!(
1695            McpToolScopes::new()
1696                .try_body_limit(MIN_BODY_LIMIT - 1)
1697                .unwrap_err(),
1698            McpScopesError::BodyLimitOutOfRange(MIN_BODY_LIMIT - 1)
1699        );
1700        let ok = McpToolScopes::new()
1701            .try_default(["r"])
1702            .and_then(|m| m.try_tool("t", ["w"]))
1703            .and_then(|m| m.try_body_limit(MAX_BODY_LIMIT))
1704            .unwrap();
1705        assert_eq!(ok.scopes_for_tool("t"), ["w"]);
1706        assert_eq!(ok.rules.body_limit, MAX_BODY_LIMIT);
1707    }
1708
1709    #[test]
1710    #[should_panic(expected = "body_limit")]
1711    fn a_body_limit_out_of_bounds_panics() {
1712        let _ = McpToolScopes::new().body_limit(MAX_BODY_LIMIT + 1);
1713    }
1714
1715    fn clause(claim: &str, any_of: &[&str]) -> ClaimClause {
1716        ClaimClause {
1717            claim: claim.into(),
1718            any_of: any_of.iter().map(|v| v.to_string()).collect(),
1719        }
1720    }
1721
1722    fn claim_rules() -> McpToolScopes {
1723        McpToolScopes::new()
1724            .default(["mcp:read"])
1725            .default_claim("groups", ["staff"])
1726            .tool("write_document", ["mcp:write"])
1727            .tool_claim("write_document", "groups", ["editors", "admins"])
1728            .tool_claim("purge", "role", ["a"])
1729            .tool_claim("purge", "groups", ["admins"])
1730            .tool_claim("approve", "role", ["b"])
1731            .tool("search", [] as [&str; 0])
1732    }
1733
1734    #[test]
1735    fn claim_requirements_per_body() {
1736        let r = claim_rules();
1737        let r = &r.rules;
1738        let req = |json: &str| r.for_body(json.as_bytes());
1739        let call =
1740            |tool: &str| format!(r#"{{"method":"tools/call","params":{{"name":"{tool}"}}}}"#);
1741        // A configured tool: its own scopes and clauses, not the default's.
1742        let write = req(&call("write_document"));
1743        assert_eq!(write.scopes, ["mcp:write"]);
1744        assert_eq!(write.claims, [clause("groups", &["editors", "admins"])]);
1745        // Configured by `tool_claim` alone: no scopes, not the default's.
1746        let purge = req(&call("purge"));
1747        assert!(purge.scopes.is_empty());
1748        assert_eq!(
1749            purge.claims,
1750            [clause("role", &["a"]), clause("groups", &["admins"])]
1751        );
1752        // `tool(.., [])` exempts from the default clauses too.
1753        assert!(req(&call("search")).is_empty());
1754        // Unconfigured tools and other methods: the default scopes and clauses.
1755        for body in [call("other"), r#"{"method":"tools/list"}"#.to_string()] {
1756            let got = req(&body);
1757            assert_eq!(got.scopes, ["mcp:read"]);
1758            assert_eq!(got.claims, [clause("groups", &["staff"])]);
1759        }
1760        assert_eq!(req("").claims, [clause("groups", &["staff"])]);
1761        // A batch: the union, clauses deduplicated but never merged by name.
1762        let batch = req(&format!(
1763            "[{},{},{}]",
1764            call("purge"),
1765            call("approve"),
1766            call("purge")
1767        ));
1768        assert!(batch.scopes.is_empty());
1769        assert_eq!(
1770            batch.claims,
1771            [
1772                clause("role", &["a"]),
1773                clause("groups", &["admins"]),
1774                clause("role", &["b"])
1775            ]
1776        );
1777        // Unreadable or ambiguous: the strictest set, clauses included —
1778        // `role` in {a} AND `role` in {b}, both demanded.
1779        let strictest = Requirement {
1780            scopes: vec!["mcp:read".into(), "mcp:write".into()],
1781            claims: vec![
1782                clause("groups", &["staff"]),
1783                clause("groups", &["editors", "admins"]),
1784                clause("role", &["a"]),
1785                clause("groups", &["admins"]),
1786                clause("role", &["b"]),
1787            ],
1788        };
1789        assert_eq!(req("not json"), strictest);
1790        assert_eq!(req(r#"{"method":"tools/call"}"#), strictest);
1791        assert_eq!(req("  "), strictest);
1792    }
1793
1794    #[test]
1795    fn tool_and_tool_claim_compose_in_either_order() {
1796        let a = McpToolScopes::new()
1797            .tool_claim("t", "groups", ["x"])
1798            .tool("t", ["s"]);
1799        let b = McpToolScopes::new()
1800            .tool("t", ["s"])
1801            .tool_claim("t", "groups", ["x"]);
1802        for r in [&a, &b] {
1803            assert_eq!(r.scopes_for_tool("t"), ["s"]);
1804            let x = ["x".to_string()];
1805            assert_eq!(r.claims_for_tool("t"), [("groups", &x[..])]);
1806        }
1807        // Re-adding the same clause is a no-op; values are deduplicated.
1808        let c = McpToolScopes::new()
1809            .tool_claim("t", "groups", ["x", "x"])
1810            .tool_claim("t", "groups", ["x"]);
1811        assert_eq!(c.rules.for_tool("t").claims, [clause("groups", &["x"])]);
1812        // Unconfigured: the default clauses; scope-only tool: none.
1813        let d = McpToolScopes::new()
1814            .default_claim("groups", ["staff"])
1815            .tool("w", ["s"]);
1816        let staff = ["staff".to_string()];
1817        assert_eq!(d.claims_for_tool("nope"), [("groups", &staff[..])]);
1818        assert!(d.claims_for_tool("w").is_empty());
1819        assert!(d.scopes_for_tool("nope").is_empty());
1820    }
1821
1822    #[test]
1823    fn always_scoped_counts_claims() {
1824        // A claim-only default, no tool: every request needs something.
1825        assert!(
1826            McpToolScopes::new()
1827                .default_claim("g", ["x"])
1828                .rules
1829                .always_scoped
1830        );
1831        // A tool with only a clause still needs something.
1832        assert!(
1833            McpToolScopes::new()
1834                .default_claim("g", ["x"])
1835                .tool_claim("t", "g", ["y"])
1836                .rules
1837                .always_scoped
1838        );
1839        // A tool needing nothing at all makes some request free.
1840        assert!(
1841            !McpToolScopes::new()
1842                .default_claim("g", ["x"])
1843                .tool("t", [] as [&str; 0])
1844                .rules
1845                .always_scoped
1846        );
1847        assert!(!McpToolScopes::new().rules.always_scoped);
1848    }
1849
1850    #[test]
1851    fn invalid_claim_requirements_are_refused() {
1852        let invalid = |r: Result<McpToolScopes, McpScopesError>, name: &str| match r {
1853            Err(McpScopesError::InvalidClaimRequirement { claim, .. }) => assert_eq!(claim, name),
1854            other => panic!("{other:?}"),
1855        };
1856        invalid(McpToolScopes::new().try_default_claim("", ["x"]), "");
1857        invalid(McpToolScopes::new().try_default_claim("  ", ["x"]), "  ");
1858        invalid(
1859            McpToolScopes::new().try_default_claim("g", [] as [&str; 0]),
1860            "g",
1861        );
1862        invalid(
1863            McpToolScopes::new().try_tool_claim("t", "g", ["x", " "]),
1864            "g",
1865        );
1866        invalid(McpToolScopes::new().try_tool_claim("t", "g", [""]), "g");
1867        // Nothing was configured by a refused call.
1868        let ok = McpToolScopes::new()
1869            .try_default_claim("g", ["x"])
1870            .and_then(|m| m.try_tool_claim("t", "g", ["y"]))
1871            .unwrap();
1872        assert_eq!(ok.rules.default.claims, [clause("g", &["x"])]);
1873    }
1874
1875    #[test]
1876    #[should_panic(expected = "McpToolScopes::tool_claim")]
1877    fn an_invalid_claim_requirement_panics() {
1878        let _ = McpToolScopes::new().tool_claim("t", "groups", [] as [&str; 0]);
1879    }
1880}