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 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 body larger than the [limit](McpToolScopes::body_limit) | refused with 413, unread beyond the limit |
60//! | a body that fails while it is being read (a client disconnect, a transport error) | refused with a bare 400, logged at `warn` |
61//!
62//! Every request's body is read and classified, whatever the method — MCP's
63//! own transport sends JSON-RPC in `POST`s only, but another JSON-RPC server
64//! may read a `tools/call` from any request with a body — and whatever its
65//! headers or size hint claim: an empty body ends at once, and a size hint
66//! of exactly 0 is not taken as proof that no `tools/call` follows. A body
67//! of whitespace alone is not empty: it is unreadable, so the strictest set.
68//!
69//! A request whose credential lacks the scopes is refused with 403 and the
70//! authentication layer's own refusal (its `on_reject` body), with a
71//! `WWW-Authenticate` challenge naming the layer's scopes followed by the
72//! ones this request needed — the MCP authorization spec's per-operation
73//! challenge. A static token has no scopes, so it is refused the same way
74//! whenever scopes are required, unless
75//! [`static_token_bypasses_scopes`](McpToolScopes::static_token_bypasses_scopes).
76//! A request with no credential (an `optional` layer passed it through) gets
77//! the layer's own 401 when what it needs is not empty, and is served when
78//! it needs nothing — an anonymous `initialize` or a call to a public tool
79//! behind an empty default. Only when every request needs a scope (a
80//! non-empty default and no tool with an empty list) is it refused before
81//! its body is read.
82//! Anything served reaches the MCP server with its body byte-identical.
83//!
84//! # Tool names are matched exactly
85//!
86//! A `tools/call` is matched to a [`tool`](McpToolScopes::tool) entry byte
87//! for byte on its JSON-decoded `params.name`: no case folding, trimming or
88//! Unicode normalization, and any name without an entry gets the default.
89//! That is only safe if the MCP server dispatches exactly as well. Register
90//! every tool under exactly the name the server dispatches on, and make sure
91//! the dispatcher does not normalize: one that also runs `Write_Document`
92//! or `write_document ` as `write_document` turns "an unconfigured tool gets
93//! the default" into a way around that tool's scopes.
94//!
95//! # Why fail closed on what it cannot read
96//!
97//! A body this layer cannot parse is still handed to the MCP server, whose
98//! parser may read it differently — and a `tools/call` that slipped past as
99//! "not a tool call" would skip its tool's requirement. So nothing this
100//! layer cannot classify with certainty is ever given less than the
101//! strictest set: an unreadable body, a `tools/call` with no readable tool
102//! name, and a message with a repeated member (which two JSON parsers can
103//! resolve to different values). A caller holding every scope loses nothing
104//! (the server answers a malformed body with its own error); everyone else
105//! is refused before it is parsed a second time. An over-limit body is
106//! refused rather than passed on, because it could only be passed unread.
107//!
108//! # Resource use
109//!
110//! The body is held once (reserved up front when its length is announced),
111//! and parsed in a single streaming pass that validates it fully but builds
112//! no document and copies no tool name: memory beyond the body itself stays
113//! small and does not grow with the number of messages in a batch or the
114//! length of a name.
115//!
116//! Reading the body has **no timeout of its own**: a client that trickles
117//! a body in slowly (slow-loris) holds the request open for as long as the
118//! server lets it. Set a read or request timeout on the server (hyper's
119//! `header_read_timeout`, a `tower_http::timeout` layer, or your proxy's),
120//! as for any endpoint that reads a body.
121//!
122//! # Privacy
123//!
124//! Nothing from the body — tool name, arguments, identifiers — is ever
125//! logged; refusals are logged (target `oauth_resource_server::http_layer`,
126//! as for [`crate::http_layer::RequireScopes`], with the same stable
127//! `auth.*` fields) with the request path and the configured scopes only.
128//! Oversized and unreadable bodies are logged at `warn` with the path and
129//! the limit; those are not authentication decisions, so they carry no
130//! `auth.*` field and are not counted by the `metrics` feature.
131//!
132//! # Checking scopes in the tool handler instead
133//!
134//! The layers insert the validated [`crate::AuthorizedToken`] into the
135//! request's extensions. An MCP server framework that hands a tool handler
136//! the HTTP request parts (rmcp does, as an `Extension<http::request::Parts>`
137//! in the tool call's context) can check a scope there, per call:
138//!
139//! ```
140//! use http::request::Parts;
141//! use oauth_resource_server::AuthorizedToken;
142//!
143//! fn may_write(parts: &Parts) -> bool {
144//!     parts
145//!         .extensions
146//!         .get::<AuthorizedToken>()
147//!         .is_some_and(|token| token.require_scopes(&["mcp:write"]).is_ok())
148//! }
149//! ```
150//!
151//! That answers inside the protocol (a tool error), not with the 403 and
152//! challenge a client can re-authorize from; [`McpToolScopes`] does the
153//! latter.
154
155use std::future::Future;
156use std::pin::Pin;
157use std::sync::Arc;
158use std::task::{Context, Poll};
159
160use bytes::{Buf, Bytes};
161use http::{Request, Response, StatusCode};
162use http_body::Body;
163use serde::de::{DeserializeSeed, MapAccess, SeqAccess, Visitor};
164use serde::{Deserialize, Deserializer};
165use tracing::warn;
166
167use crate::http_layer::{InvalidScope, ScopeVerdict, checked_scopes, judge_scopes, scope_refusal};
168use crate::token::TokenRejection;
169
170/// The default [`McpToolScopes::body_limit`]: 1 MiB.
171pub const DEFAULT_BODY_LIMIT: usize = 1024 * 1024;
172/// The smallest [`McpToolScopes::body_limit`] allowed: 4 KiB.
173pub const MIN_BODY_LIMIT: usize = 4 * 1024;
174/// The largest [`McpToolScopes::body_limit`] allowed: 64 MiB.
175pub const MAX_BODY_LIMIT: usize = 64 * 1024 * 1024;
176
177/// Per-tool scope requirements for an MCP server's JSON-RPC endpoint, as a
178/// `tower::Layer`; see the [module docs](self) for what it enforces.
179///
180/// Built once at startup, with a builder-style API: every method takes and
181/// returns the value.
182///
183/// Tool names are matched **exactly** (byte for byte, after JSON
184/// decoding); see [`tool`](Self::tool)'s `# Security` section before
185/// relying on it.
186///
187/// # Panics
188///
189/// [`default`](Self::default) and [`tool`](Self::tool) panic on a scope that
190/// is not an RFC 6749 §3.3 scope-token (empty, or holding a space, `"`,
191/// `\`, a control or non-ASCII character) — no token can carry one — and
192/// [`body_limit`](Self::body_limit) outside
193/// [`MIN_BODY_LIMIT`]`..=`[`MAX_BODY_LIMIT`]. They are for literals in code;
194/// the `try_` forms ([`try_default`](Self::try_default),
195/// [`try_tool`](Self::try_tool), [`try_body_limit`](Self::try_body_limit))
196/// return a [`McpScopesError`] instead, for settings read from
197/// configuration.
198#[derive(Clone, Debug)]
199pub struct McpToolScopes {
200    rules: Arc<Rules>,
201}
202
203#[derive(Clone, Debug)]
204struct Rules {
205    default: Vec<String>,
206    /// In the order configured; a repeated name replaces the earlier entry.
207    tools: Vec<(String, Vec<String>)>,
208    /// `default` followed by every tool's scopes, deduplicated.
209    strictest: Vec<String>,
210    /// Whether every request needs at least one scope, whatever its body:
211    /// a non-empty default and no tool with an empty list. Only then is a
212    /// request with no credential refused before its body is read.
213    always_scoped: bool,
214    body_limit: usize,
215    static_bypasses: bool,
216}
217
218impl Rules {
219    fn with_strictest(mut self) -> Self {
220        let mut all: Vec<String> = Vec::new();
221        for scope in self
222            .default
223            .iter()
224            .chain(self.tools.iter().flat_map(|(_, scopes)| scopes))
225        {
226            if !all.contains(scope) {
227                all.push(scope.clone());
228            }
229        }
230        self.strictest = all;
231        self.always_scoped =
232            !self.default.is_empty() && self.tools.iter().all(|(_, scopes)| !scopes.is_empty());
233        self
234    }
235
236    fn for_tool(&self, name: &str) -> &[String] {
237        self.tools
238            .iter()
239            .find(|(tool, _)| tool == name)
240            .map_or(&self.default, |(_, scopes)| scopes)
241    }
242
243    /// The scopes a request body requires (see the module docs' table),
244    /// folded message by message as the body is parsed: what is kept is one
245    /// flag per configured tool, never the messages themselves, so a batch of
246    /// any length costs no more memory than one call.
247    ///
248    /// An empty body (nothing at all: `Content-Length: 0`, a chunked body
249    /// with no chunks, a `GET`) needs the default; anything else that is not
250    /// a JSON object or array — whitespace alone included — the strictest
251    /// set.
252    fn for_body(&self, body: &[u8]) -> Vec<String> {
253        if body.is_empty() {
254            return self.default.clone();
255        }
256        let mut needs_default = false;
257        let mut needs_strictest = false;
258        let mut needs_tool = vec![false; self.tools.len()];
259        let mut seen_any = false;
260        let tools = &self.tools;
261        let readable = for_each_message(
262            body,
263            // Compared against the configured names as the value streams by:
264            // the name itself is never copied, however long it is.
265            &mut |name| {
266                tools
267                    .iter()
268                    .position(|(tool, _)| tool == name)
269                    .unwrap_or(UNKNOWN_TOOL)
270            },
271            &mut |message| {
272                seen_any = true;
273                match message {
274                    Message::NotToolCall => needs_default = true,
275                    Message::ToolCall(UNKNOWN_TOOL) => needs_default = true,
276                    Message::ToolCall(i) => needs_tool[i] = true,
277                    Message::Ambiguous => needs_strictest = true,
278                }
279            },
280        );
281        if !readable || needs_strictest {
282            return self.strictest.clone();
283        }
284        if !seen_any {
285            // An empty batch: nothing to call.
286            return self.default.clone();
287        }
288        // The default first, then each needed tool's, in configuration order.
289        let mut all: Vec<String> = Vec::new();
290        let needed = needs_default
291            .then_some(&self.default[..])
292            .into_iter()
293            .chain(
294                self.tools
295                    .iter()
296                    .zip(&needs_tool)
297                    .filter(|(_, needed)| **needed)
298                    .map(|((_, scopes), _)| &scopes[..]),
299            );
300        for scopes in needed {
301            for scope in scopes {
302                if !all.contains(scope) {
303                    all.push(scope.clone());
304                }
305            }
306        }
307        all
308    }
309}
310
311/// Why [`McpToolScopes`]' fallible constructors ([`try_default`](McpToolScopes::try_default),
312/// [`try_tool`](McpToolScopes::try_tool), [`try_body_limit`](McpToolScopes::try_body_limit))
313/// refused a setting. `#[non_exhaustive]`: match with a wildcard arm.
314#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
315#[non_exhaustive]
316pub enum McpScopesError {
317    /// A scope is not an RFC 6749 §3.3 scope-token; the error names it.
318    #[error(transparent)]
319    InvalidScope(#[from] InvalidScope),
320    /// A body limit outside [`MIN_BODY_LIMIT`]`..=`[`MAX_BODY_LIMIT`].
321    #[error("body limit {0} is outside {MIN_BODY_LIMIT}..={MAX_BODY_LIMIT}")]
322    BodyLimitOutOfRange(usize),
323}
324
325// `default` is the name the requirement reads best under; this type has no
326// meaningful `Default` (`new` is it), and a `Default` impl would make
327// `McpToolScopes::default()` resolve to the inherent method anyway.
328#[allow(clippy::new_without_default)]
329impl McpToolScopes {
330    /// No requirement at all: every tool, and every other request, needs
331    /// only what the authentication layer requires, until
332    /// [`default`](Self::default) and [`tool`](Self::tool) add some.
333    pub fn new() -> Self {
334        Self {
335            rules: Arc::new(Rules {
336                default: Vec::new(),
337                tools: Vec::new(),
338                strictest: Vec::new(),
339                always_scoped: false,
340                body_limit: DEFAULT_BODY_LIMIT,
341                static_bypasses: false,
342            }),
343        }
344    }
345
346    fn update(self, f: impl FnOnce(&mut Rules)) -> Self {
347        let mut rules = Arc::unwrap_or_clone(self.rules);
348        f(&mut rules);
349        Self {
350            rules: Arc::new(rules.with_strictest()),
351        }
352    }
353
354    /// The scopes every request needs unless a [`tool`](Self::tool) entry
355    /// says otherwise: every request with an empty body, every JSON-RPC message
356    /// other than `tools/call`, and a `tools/call` for a tool with no entry.
357    /// Replaces a default given earlier. For literals in code; see
358    /// [`try_default`](Self::try_default) for scopes from configuration.
359    ///
360    /// # Panics
361    ///
362    /// On a scope that is not a scope-token; see the type's docs.
363    pub fn default(self, scopes: impl IntoIterator<Item = impl Into<String>>) -> Self {
364        self.try_default(scopes)
365            .unwrap_or_else(|e| panic!("McpToolScopes::default: {e}"))
366    }
367
368    /// [`default`](Self::default) for scopes read from configuration.
369    ///
370    /// # Errors
371    ///
372    /// [`McpScopesError::InvalidScope`], naming the first scope that is not
373    /// a scope-token.
374    pub fn try_default(
375        self,
376        scopes: impl IntoIterator<Item = impl Into<String>>,
377    ) -> Result<Self, McpScopesError> {
378        let scopes = checked_scopes(scopes)?;
379        Ok(self.update(|rules| rules.default = scopes))
380    }
381
382    /// The scopes a `tools/call` for the tool named `name` needs, instead of
383    /// the [`default`](Self::default). An empty list means that tool needs
384    /// nothing beyond the authentication layer's own scopes. Replaces an
385    /// entry for the same name. For literals in code; see
386    /// [`try_tool`](Self::try_tool) for a map read from configuration.
387    ///
388    /// # Security
389    ///
390    /// `name` is matched **exactly**: byte for byte against the tool name
391    /// the request carries, after JSON decoding (`\u0041` is `A`), with no
392    /// case folding, trimming or Unicode normalization. A `tools/call` for
393    /// any other name gets the default. So register each tool under exactly
394    /// the name your MCP server dispatches on, and make that dispatch exact
395    /// too: a server that also runs `Write_Document` or `write_document `
396    /// as `write_document` would let a caller reach it under a spelling
397    /// this layer treats as unconfigured, with only the default's scopes.
398    ///
399    /// # Panics
400    ///
401    /// On a scope that is not a scope-token; see the type's docs.
402    pub fn tool(
403        self,
404        name: impl Into<String>,
405        scopes: impl IntoIterator<Item = impl Into<String>>,
406    ) -> Self {
407        self.try_tool(name, scopes)
408            .unwrap_or_else(|e| panic!("McpToolScopes::tool: {e}"))
409    }
410
411    /// [`tool`](Self::tool) for a tool-to-scopes map read from
412    /// configuration. The same exact name matching applies.
413    ///
414    /// # Errors
415    ///
416    /// [`McpScopesError::InvalidScope`], naming the first scope that is not
417    /// a scope-token.
418    ///
419    /// # Examples
420    ///
421    /// ```
422    /// use oauth_resource_server::mcp::{McpScopesError, McpToolScopes};
423    ///
424    /// // As read from a config file.
425    /// let configured = [("write_document", vec!["docs:write"]), ("purge", vec!["docs admin"])];
426    /// let mut layer = McpToolScopes::new().try_default(["docs:read"]).unwrap();
427    /// let mut refused = None;
428    /// for (tool, scopes) in configured {
429    ///     match layer.clone().try_tool(tool, scopes) {
430    ///         Ok(next) => layer = next,
431    ///         Err(McpScopesError::InvalidScope(e)) => refused = Some(e.scope().to_string()),
432    ///         Err(other) => panic!("{other}"),
433    ///     }
434    /// }
435    /// assert_eq!(refused.as_deref(), Some("docs admin"));
436    /// # let _ = layer;
437    /// ```
438    pub fn try_tool(
439        self,
440        name: impl Into<String>,
441        scopes: impl IntoIterator<Item = impl Into<String>>,
442    ) -> Result<Self, McpScopesError> {
443        let name = name.into();
444        let scopes = checked_scopes(scopes)?;
445        Ok(self.update(|rules| {
446            rules.tools.retain(|(tool, _)| *tool != name);
447            rules.tools.push((name, scopes));
448        }))
449    }
450
451    /// The largest request body read, in bytes ([`DEFAULT_BODY_LIMIT`]
452    /// unless set). A body announced larger (`Content-Length`, or the
453    /// body's own size hint) is refused with 413 before any of it is read;
454    /// one that turns out larger is refused as soon as a chunk would cross
455    /// the limit, so no more than the limit is ever held. For a literal in
456    /// code; see [`try_body_limit`](Self::try_body_limit) for a configured
457    /// value.
458    ///
459    /// # Panics
460    ///
461    /// Outside [`MIN_BODY_LIMIT`]`..=`[`MAX_BODY_LIMIT`].
462    pub fn body_limit(self, bytes: usize) -> Self {
463        self.try_body_limit(bytes)
464            .unwrap_or_else(|e| panic!("McpToolScopes::body_limit: {e}"))
465    }
466
467    /// [`body_limit`](Self::body_limit) for a configured value.
468    ///
469    /// # Errors
470    ///
471    /// [`McpScopesError::BodyLimitOutOfRange`] outside
472    /// [`MIN_BODY_LIMIT`]`..=`[`MAX_BODY_LIMIT`].
473    pub fn try_body_limit(self, bytes: usize) -> Result<Self, McpScopesError> {
474        if !(MIN_BODY_LIMIT..=MAX_BODY_LIMIT).contains(&bytes) {
475            return Err(McpScopesError::BodyLimitOutOfRange(bytes));
476        }
477        Ok(self.update(|rules| rules.body_limit = bytes))
478    }
479
480    /// Let a static token through whatever the request requires, instead
481    /// of refusing it with 403.
482    ///
483    /// # Security
484    ///
485    /// The static token then reaches every tool. Opt in only where it is
486    /// meant to be a full-access key.
487    pub fn static_token_bypasses_scopes(self) -> Self {
488        self.update(|rules| rules.static_bypasses = true)
489    }
490
491    /// The scopes `tool` requires: its own entry, or the default.
492    pub fn scopes_for_tool(&self, tool: &str) -> &[String] {
493        self.rules.for_tool(tool)
494    }
495}
496
497impl<S> tower_layer::Layer<S> for McpToolScopes {
498    type Service = McpToolScopesService<S>;
499
500    fn layer(&self, inner: S) -> Self::Service {
501        McpToolScopesService {
502            rules: Arc::clone(&self.rules),
503            inner,
504        }
505    }
506}
507
508/// The service a [`McpToolScopes`] layer wraps another in.
509///
510/// Generic over the request body, which it must be able to rebuild from the
511/// bytes it read (`ReqBody: From<Bytes>`, as `axum::body::Body` and
512/// `http_body_util::Full<Bytes>` are); a server on a body type that is not
513/// (hyper's `Incoming`) maps it to one first. Trailers of a `POST` body are
514/// not passed on.
515#[derive(Clone, Debug)]
516pub struct McpToolScopesService<S> {
517    rules: Arc<Rules>,
518    inner: S,
519}
520
521/// Why a `POST` body could not be read.
522enum ReadError {
523    TooLarge,
524    Failed,
525}
526
527/// Read `body` whole, refusing it as soon as it is known to exceed `limit`.
528/// `announced` (the larger of `Content-Length` and the size hint's lower
529/// bound, already checked against `limit`) is reserved up front, so a body
530/// that arrives as announced is held once, never in a doubling buffer.
531async fn read_capped<B: Body + Unpin>(
532    mut body: B,
533    limit: usize,
534    announced: u64,
535) -> Result<Bytes, ReadError> {
536    if body.size_hint().lower() > limit as u64 {
537        return Err(ReadError::TooLarge);
538    }
539    let reserve = usize::try_from(announced).map_or(limit, |n| n.min(limit));
540    let mut buf: Vec<u8> = Vec::with_capacity(reserve);
541    loop {
542        let frame = std::future::poll_fn(|cx| Pin::new(&mut body).poll_frame(cx)).await;
543        match frame {
544            None => return Ok(Bytes::from(buf)),
545            Some(Err(_)) => return Err(ReadError::Failed),
546            Some(Ok(frame)) => {
547                // Trailers carry no JSON-RPC and are dropped.
548                let Ok(mut data) = frame.into_data() else {
549                    continue;
550                };
551                if buf.len().saturating_add(data.remaining()) > limit {
552                    return Err(ReadError::TooLarge);
553                }
554                while data.has_remaining() {
555                    let chunk = data.chunk();
556                    let n = chunk.len();
557                    buf.extend_from_slice(chunk);
558                    data.advance(n);
559                }
560            }
561        }
562    }
563}
564
565/// A response with `status` and an empty (default) body.
566fn plain<B: Default>(status: StatusCode) -> Response<B> {
567    let mut response = Response::new(B::default());
568    *response.status_mut() = status;
569    response
570}
571
572impl<S, ReqBody, ResBody> tower_service::Service<Request<ReqBody>> for McpToolScopesService<S>
573where
574    S: tower_service::Service<Request<ReqBody>, Response = Response<ResBody>>
575        + Clone
576        + Send
577        + 'static,
578    S::Future: Send + 'static,
579    ReqBody: Body + From<Bytes> + Send + 'static,
580    ReqBody::Data: Send,
581    ReqBody::Error: Send,
582    ResBody: Default + 'static,
583{
584    type Response = Response<ResBody>;
585    type Error = S::Error;
586    type Future =
587        Pin<Box<dyn Future<Output = Result<Response<ResBody>, S::Error>> + Send + 'static>>;
588
589    fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
590        self.inner.poll_ready(cx)
591    }
592
593    fn call(&mut self, request: Request<ReqBody>) -> Self::Future {
594        let clone = self.inner.clone();
595        let mut inner = std::mem::replace(&mut self.inner, clone);
596        let rules = Arc::clone(&self.rules);
597        Box::pin(async move {
598            let (parts, body) = request.into_parts();
599            // No layer in front: refuse before reading anything.
600            if let ScopeVerdict::NoLayer = judge_scopes(&parts, &[], false) {
601                return Ok(scope_refusal(
602                    &parts,
603                    &TokenRejection::Missing,
604                    &[],
605                    "McpToolScopes",
606                ));
607            }
608            let content_length = parts
609                .headers
610                .get(http::header::CONTENT_LENGTH)
611                .and_then(|v| v.to_str().ok())
612                .and_then(|v| v.trim().parse::<u64>().ok());
613            let hint = body.size_hint();
614            // No credential (an `optional()` layer passed the request), and
615            // every request needs a scope whatever its body says (a
616            // non-empty default, no tool with an empty list): refuse before
617            // reading a byte. Otherwise the body decides, so it is read.
618            if rules.always_scoped
619                && parts
620                    .extensions
621                    .get::<crate::authenticate::Credential>()
622                    .is_none()
623            {
624                return Ok(scope_refusal(
625                    &parts,
626                    &TokenRejection::Missing,
627                    &rules.default,
628                    "McpToolScopes",
629                ));
630            }
631            // Every request's body is read, whatever the method and whatever
632            // its size hint or headers claim: an empty one ends at once, and
633            // a hint of exactly 0 is not trusted as proof that no
634            // `tools/call` follows. A JSON-RPC server other than MCP's
635            // Streamable HTTP transport may read one from a `GET` or a `PUT`
636            // just as well.
637            let (required, body) = {
638                let announced = content_length.unwrap_or(0).max(hint.lower());
639                let read = if announced > rules.body_limit as u64 {
640                    Err(ReadError::TooLarge)
641                } else {
642                    read_capped(Box::pin(body), rules.body_limit, announced).await
643                };
644                let bytes = match read {
645                    Ok(bytes) => bytes,
646                    Err(ReadError::TooLarge) => {
647                        warn!(
648                            path = %parts.uri.path(),
649                            limit = rules.body_limit,
650                            "MCP request body exceeds the limit; refusing it unread"
651                        );
652                        return Ok(plain(StatusCode::PAYLOAD_TOO_LARGE));
653                    }
654                    Err(ReadError::Failed) => {
655                        warn!(
656                            path = %parts.uri.path(),
657                            "MCP request body could not be read; refusing the request"
658                        );
659                        return Ok(plain(StatusCode::BAD_REQUEST));
660                    }
661                };
662                (rules.for_body(&bytes), ReqBody::from(bytes))
663            };
664            match judge_scopes(&parts, &required, rules.static_bypasses) {
665                ScopeVerdict::Pass => inner.call(Request::from_parts(parts, body)).await,
666                ScopeVerdict::Refuse(rejection) => Ok(scope_refusal(
667                    &parts,
668                    &rejection,
669                    &required,
670                    "McpToolScopes",
671                )),
672                ScopeVerdict::NoLayer => Ok(scope_refusal(
673                    &parts,
674                    &TokenRejection::Missing,
675                    &required,
676                    "McpToolScopes",
677                )),
678            }
679        })
680    }
681}
682
683/// `Message::ToolCall` for a tool the matcher does not know.
684pub(crate) const UNKNOWN_TOOL: usize = usize::MAX;
685
686/// One JSON-RPC message, as far as tool scopes are concerned.
687#[derive(Debug, PartialEq, Eq)]
688pub(crate) enum Message {
689    /// Anything but a `tools/call` request.
690    NotToolCall,
691    /// A `tools/call` for the tool the name matcher mapped to this index
692    /// ([`UNKNOWN_TOOL`] for none). The name itself is never copied.
693    ToolCall(usize),
694    /// A `tools/call` with no readable string `params.name`, a message with
695    /// a repeated `method`, `params` or `params.name` member, or a batch
696    /// element that is not an object: not classified with certainty.
697    Ambiguous,
698}
699
700/// One message with the tool name spelled out (tests and the fuzz oracle
701/// only).
702#[cfg(any(test, fuzzing))]
703#[derive(Debug, PartialEq, Eq)]
704pub(crate) enum NamedMessage {
705    /// See [`Message::NotToolCall`].
706    NotToolCall,
707    /// A `tools/call` for this tool.
708    ToolCall(String),
709    /// See [`Message::Ambiguous`].
710    Ambiguous,
711}
712
713/// Every message of a body, collected (tests and the fuzz oracle only; the
714/// request path folds them as they come, see [`for_each_message`]).
715#[cfg(any(test, fuzzing))]
716#[derive(Debug, PartialEq, Eq)]
717pub(crate) enum Classified {
718    /// One message, or a batch's messages in order (possibly none).
719    Messages(Vec<NamedMessage>),
720    /// Not JSON, or JSON that is neither an object nor an array.
721    Unreadable,
722}
723
724/// [`for_each_message`], collected, with every tool name kept.
725#[cfg(any(test, fuzzing))]
726pub(crate) fn classify(body: &[u8]) -> Classified {
727    let mut names: Vec<String> = Vec::new();
728    let mut messages = Vec::new();
729    let readable = for_each_message(
730        body,
731        &mut |name| {
732            names.push(name.to_string());
733            names.len() - 1
734        },
735        &mut |m| messages.push(m),
736    );
737    if !readable {
738        return Classified::Unreadable;
739    }
740    Classified::Messages(
741        messages
742            .into_iter()
743            .map(|m| match m {
744                Message::NotToolCall => NamedMessage::NotToolCall,
745                Message::ToolCall(i) => NamedMessage::ToolCall(names[i].clone()),
746                Message::Ambiguous => NamedMessage::Ambiguous,
747            })
748            .collect(),
749    )
750}
751
752/// Hand every message of `body` — one object, or each element of a batch
753/// array, in order — to `sink`, in ONE streaming pass that builds nothing:
754/// a tool name is handed to `tool` as a borrowed `&str` (which maps it to
755/// an index) and never copied, and `method` is compared in place. Returns
756/// `false` when the body is not a JSON object or array, or is not valid JSON
757/// anywhere in it; `sink` may then have seen some messages already, and the
758/// caller must discard them (the request path answers `false` with the
759/// strictest set).
760///
761/// Every value it does not look at is still fully validated
762/// ([`Validate`]: every string's UTF-8 and escapes, every number's range),
763/// so it refuses exactly what `serde_json::from_slice::<Value>` refuses —
764/// the `mcp_tool_calls` fuzz target checks that against `Value` — without
765/// materializing the document (which costs about 16× the body). Bounded by
766/// `serde_json`'s recursion limit; never panics.
767pub(crate) fn for_each_message(
768    body: &[u8],
769    tool: &mut dyn FnMut(&str) -> usize,
770    sink: &mut dyn FnMut(Message),
771) -> bool {
772    let mut deserializer = serde_json::Deserializer::from_slice(body);
773    TopSeed { tool, sink }
774        .deserialize(&mut deserializer)
775        .is_ok()
776        && deserializer.end().is_ok()
777}
778
779/// Accept every scalar JSON value (after the deserializer has validated it)
780/// as `$value`.
781macro_rules! accept_scalars {
782    ($value:expr) => {
783        fn visit_bool<E>(self, _: bool) -> Result<Self::Value, E> {
784            Ok($value)
785        }
786        fn visit_i64<E>(self, _: i64) -> Result<Self::Value, E> {
787            Ok($value)
788        }
789        fn visit_u64<E>(self, _: u64) -> Result<Self::Value, E> {
790            Ok($value)
791        }
792        fn visit_f64<E>(self, _: f64) -> Result<Self::Value, E> {
793            Ok($value)
794        }
795        fn visit_str<E>(self, _: &str) -> Result<Self::Value, E> {
796            Ok($value)
797        }
798        fn visit_unit<E>(self) -> Result<Self::Value, E> {
799            Ok($value)
800        }
801    };
802}
803
804/// Accept every non-string scalar as `$value` (a string is handled by the
805/// visitor itself).
806macro_rules! accept_non_string_scalars {
807    ($value:expr) => {
808        fn visit_bool<E>(self, _: bool) -> Result<Self::Value, E> {
809            Ok($value)
810        }
811        fn visit_i64<E>(self, _: i64) -> Result<Self::Value, E> {
812            Ok($value)
813        }
814        fn visit_u64<E>(self, _: u64) -> Result<Self::Value, E> {
815            Ok($value)
816        }
817        fn visit_f64<E>(self, _: f64) -> Result<Self::Value, E> {
818            Ok($value)
819        }
820        fn visit_unit<E>(self) -> Result<Self::Value, E> {
821            Ok($value)
822        }
823    };
824}
825
826/// Any JSON value, validated in full and then dropped: the stand-in for
827/// `IgnoredAny`, which skips a number or a string without checking it.
828struct Validate;
829
830impl<'de> Deserialize<'de> for Validate {
831    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
832        struct V;
833        impl<'de> Visitor<'de> for V {
834            type Value = Validate;
835            fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
836                f.write_str("any JSON value")
837            }
838            accept_scalars!(Validate);
839            fn visit_map<A: MapAccess<'de>>(self, mut map: A) -> Result<Validate, A::Error> {
840                while map.next_entry::<Validate, Validate>()?.is_some() {}
841                Ok(Validate)
842            }
843            fn visit_seq<A: SeqAccess<'de>>(self, mut seq: A) -> Result<Validate, A::Error> {
844                while seq.next_element::<Validate>()?.is_some() {}
845                Ok(Validate)
846            }
847        }
848        deserializer.deserialize_any(V)
849    }
850}
851
852/// The top-level value: one message (an object) or a batch (an array);
853/// anything else is an error, i.e. unreadable.
854struct TopSeed<'s> {
855    tool: &'s mut dyn FnMut(&str) -> usize,
856    sink: &'s mut dyn FnMut(Message),
857}
858
859impl<'de> DeserializeSeed<'de> for TopSeed<'_> {
860    type Value = ();
861
862    fn deserialize<D: Deserializer<'de>>(self, deserializer: D) -> Result<(), D::Error> {
863        struct V<'s>(TopSeed<'s>);
864        impl<'de> Visitor<'de> for V<'_> {
865            type Value = ();
866            fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
867                f.write_str("a JSON-RPC message or batch")
868            }
869            fn visit_map<A: MapAccess<'de>>(self, map: A) -> Result<(), A::Error> {
870                let message = read_message(map, self.0.tool)?;
871                (self.0.sink)(message);
872                Ok(())
873            }
874            fn visit_seq<A: SeqAccess<'de>>(self, mut seq: A) -> Result<(), A::Error> {
875                let TopSeed { tool, sink } = self.0;
876                while seq
877                    .next_element_seed(ElementSeed {
878                        tool: &mut *tool,
879                        sink: &mut *sink,
880                    })?
881                    .is_some()
882                {}
883                Ok(())
884            }
885        }
886        deserializer.deserialize_any(V(self))
887    }
888}
889
890/// One batch element: a message, or anything else (`Ambiguous`).
891struct ElementSeed<'s> {
892    tool: &'s mut dyn FnMut(&str) -> usize,
893    sink: &'s mut dyn FnMut(Message),
894}
895
896impl<'de> DeserializeSeed<'de> for ElementSeed<'_> {
897    type Value = ();
898
899    fn deserialize<D: Deserializer<'de>>(self, deserializer: D) -> Result<(), D::Error> {
900        struct V<'s>(&'s mut dyn FnMut(&str) -> usize);
901        impl<'de> Visitor<'de> for V<'_> {
902            type Value = Message;
903            fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
904                f.write_str("any JSON value")
905            }
906            accept_scalars!(Message::Ambiguous);
907            fn visit_map<A: MapAccess<'de>>(self, map: A) -> Result<Message, A::Error> {
908                read_message(map, self.0)
909            }
910            fn visit_seq<A: SeqAccess<'de>>(self, mut seq: A) -> Result<Message, A::Error> {
911                while seq.next_element::<Validate>()?.is_some() {}
912                Ok(Message::Ambiguous)
913            }
914        }
915        let message = deserializer.deserialize_any(V(self.tool))?;
916        (self.sink)(message);
917        Ok(())
918    }
919}
920
921/// An object key, compared without allocating.
922enum Key {
923    Method,
924    Params,
925    Name,
926    Other,
927}
928
929impl<'de> Deserialize<'de> for Key {
930    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
931        struct V;
932        impl Visitor<'_> for V {
933            type Value = Key;
934            fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
935                f.write_str("an object key")
936            }
937            fn visit_str<E>(self, key: &str) -> Result<Key, E> {
938                Ok(match key {
939                    "method" => Key::Method,
940                    "params" => Key::Params,
941                    "name" => Key::Name,
942                    _ => Key::Other,
943                })
944            }
945        }
946        deserializer.deserialize_any(V)
947    }
948}
949
950/// Whether `method` is the string `"tools/call"` (`Some(true)`), another
951/// string (`Some(false)`), or not a string (`None`) — compared in place.
952struct IsToolsCall(Option<bool>);
953
954impl<'de> Deserialize<'de> for IsToolsCall {
955    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
956        struct V;
957        impl<'de> Visitor<'de> for V {
958            type Value = IsToolsCall;
959            fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
960                f.write_str("any JSON value")
961            }
962            accept_non_string_scalars!(IsToolsCall(None));
963            fn visit_str<E>(self, v: &str) -> Result<IsToolsCall, E> {
964                Ok(IsToolsCall(Some(v == "tools/call")))
965            }
966            fn visit_map<A: MapAccess<'de>>(self, mut map: A) -> Result<IsToolsCall, A::Error> {
967                while map.next_entry::<Validate, Validate>()?.is_some() {}
968                Ok(IsToolsCall(None))
969            }
970            fn visit_seq<A: SeqAccess<'de>>(self, mut seq: A) -> Result<IsToolsCall, A::Error> {
971                while seq.next_element::<Validate>()?.is_some() {}
972                Ok(IsToolsCall(None))
973            }
974        }
975        deserializer.deserialize_any(V)
976    }
977}
978
979/// A `params.name` value handed to the tool matcher as a borrowed `&str`:
980/// `Some(index)` for a string, `None` for anything else (validated,
981/// consumed).
982struct NameSeed<'s>(&'s mut dyn FnMut(&str) -> usize);
983
984impl<'de> DeserializeSeed<'de> for NameSeed<'_> {
985    type Value = Option<usize>;
986
987    fn deserialize<D: Deserializer<'de>>(self, deserializer: D) -> Result<Option<usize>, D::Error> {
988        struct V<'s>(&'s mut dyn FnMut(&str) -> usize);
989        impl<'de> Visitor<'de> for V<'_> {
990            type Value = Option<usize>;
991            fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
992                f.write_str("any JSON value")
993            }
994            accept_non_string_scalars!(None);
995            fn visit_str<E>(self, v: &str) -> Result<Option<usize>, E> {
996                Ok(Some((self.0)(v)))
997            }
998            fn visit_map<A: MapAccess<'de>>(self, mut map: A) -> Result<Option<usize>, A::Error> {
999                while map.next_entry::<Validate, Validate>()?.is_some() {}
1000                Ok(None)
1001            }
1002            fn visit_seq<A: SeqAccess<'de>>(self, mut seq: A) -> Result<Option<usize>, A::Error> {
1003                while seq.next_element::<Validate>()?.is_some() {}
1004                Ok(None)
1005            }
1006        }
1007        deserializer.deserialize_any(V(self.0))
1008    }
1009}
1010
1011/// What a message's `params` says about the tool: `Some(index)` only for an
1012/// object with exactly one `name`, a string.
1013struct ParamsSeed<'s>(&'s mut dyn FnMut(&str) -> usize);
1014
1015impl<'de> DeserializeSeed<'de> for ParamsSeed<'_> {
1016    type Value = Option<usize>;
1017
1018    fn deserialize<D: Deserializer<'de>>(self, deserializer: D) -> Result<Option<usize>, D::Error> {
1019        struct V<'s>(&'s mut dyn FnMut(&str) -> usize);
1020        impl<'de> Visitor<'de> for V<'_> {
1021            type Value = Option<usize>;
1022            fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1023                f.write_str("any JSON value")
1024            }
1025            accept_scalars!(None);
1026            fn visit_map<A: MapAccess<'de>>(self, mut map: A) -> Result<Option<usize>, A::Error> {
1027                let mut name: Option<Option<usize>> = None;
1028                let mut repeated = false;
1029                while let Some(key) = map.next_key::<Key>()? {
1030                    match key {
1031                        Key::Name if name.is_none() => {
1032                            name = Some(map.next_value_seed(NameSeed(&mut *self.0))?);
1033                        }
1034                        Key::Name => {
1035                            repeated = true;
1036                            map.next_value::<Validate>()?;
1037                        }
1038                        _ => {
1039                            map.next_value::<Validate>()?;
1040                        }
1041                    }
1042                }
1043                Ok(match (repeated, name) {
1044                    (false, Some(name)) => name,
1045                    _ => None,
1046                })
1047            }
1048            fn visit_seq<A: SeqAccess<'de>>(self, mut seq: A) -> Result<Option<usize>, A::Error> {
1049                while seq.next_element::<Validate>()?.is_some() {}
1050                Ok(None)
1051            }
1052        }
1053        deserializer.deserialize_any(V(self.0))
1054    }
1055}
1056
1057/// Read one message object. `params` may come before `method`, so its tool
1058/// name is matched as it streams past whatever the method turns out to be;
1059/// only the index is kept.
1060fn read_message<'de, A: MapAccess<'de>>(
1061    mut map: A,
1062    tool: &mut dyn FnMut(&str) -> usize,
1063) -> Result<Message, A::Error> {
1064    let mut method: Option<Option<bool>> = None;
1065    let mut params: Option<Option<usize>> = None;
1066    let mut repeated = false;
1067    while let Some(key) = map.next_key::<Key>()? {
1068        match key {
1069            Key::Method if method.is_none() => {
1070                method = Some(map.next_value::<IsToolsCall>()?.0);
1071            }
1072            Key::Params if params.is_none() => {
1073                params = Some(map.next_value_seed(ParamsSeed(&mut *tool))?);
1074            }
1075            Key::Method | Key::Params => {
1076                repeated = true;
1077                map.next_value::<Validate>()?;
1078            }
1079            _ => {
1080                map.next_value::<Validate>()?;
1081            }
1082        }
1083    }
1084    Ok(match (repeated, method) {
1085        (true, _) => Message::Ambiguous,
1086        (false, Some(Some(true))) => match params {
1087            Some(Some(index)) => Message::ToolCall(index),
1088            _ => Message::Ambiguous,
1089        },
1090        _ => Message::NotToolCall,
1091    })
1092}
1093#[cfg(all(test, feature = "axum"))]
1094mod service_tests;
1095
1096#[cfg(test)]
1097mod tests {
1098    use super::*;
1099
1100    #[test]
1101    fn classification() {
1102        let one = |json: &str| classify(json.as_bytes());
1103        let msgs = |m: Vec<NamedMessage>| Classified::Messages(m);
1104        assert_eq!(
1105            one(
1106                r#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"w","arguments":{"name":"x"}}}"#
1107            ),
1108            msgs(vec![NamedMessage::ToolCall("w".into())])
1109        );
1110        assert_eq!(
1111            one(r#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#),
1112            msgs(vec![NamedMessage::NotToolCall])
1113        );
1114        // A response, a notification: not tool calls.
1115        assert_eq!(
1116            one(r#"{"jsonrpc":"2.0","id":1,"result":{}}"#),
1117            msgs(vec![NamedMessage::NotToolCall])
1118        );
1119        assert_eq!(
1120            one(r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#),
1121            msgs(vec![NamedMessage::NotToolCall])
1122        );
1123        // A method that is not a string is not `tools/call`.
1124        assert_eq!(
1125            one(r#"{"method":7}"#),
1126            msgs(vec![NamedMessage::NotToolCall])
1127        );
1128        // Escapes are decoded exactly as any JSON parser decodes them.
1129        assert_eq!(
1130            one(r#"{"method":"tools\/call","params":{"name":"w"}}"#),
1131            msgs(vec![NamedMessage::ToolCall("w".into())])
1132        );
1133        // No readable name: ambiguous.
1134        for json in [
1135            r#"{"method":"tools/call"}"#,
1136            r#"{"method":"tools/call","params":{}}"#,
1137            r#"{"method":"tools/call","params":{"name":1}}"#,
1138            r#"{"method":"tools/call","params":["w"]}"#,
1139            r#"{"method":"tools/call","params":{"name":"r","name":"w"}}"#,
1140            r#"{"method":"tools/list","method":"tools/call","params":{"name":"w"}}"#,
1141            r#"{"method":"tools/call","params":{"name":"r"},"params":{"name":"w"}}"#,
1142        ] {
1143            assert_eq!(one(json), msgs(vec![NamedMessage::Ambiguous]), "{json}");
1144        }
1145        // Batches, element by element; a non-object element is ambiguous.
1146        assert_eq!(
1147            one(r#"[{"method":"tools/list"},{"method":"tools/call","params":{"name":"w"}},3]"#),
1148            msgs(vec![
1149                NamedMessage::NotToolCall,
1150                NamedMessage::ToolCall("w".into()),
1151                NamedMessage::Ambiguous
1152            ])
1153        );
1154        assert_eq!(one("[]"), msgs(vec![]));
1155        // Not JSON, or not an object or array.
1156        for body in [
1157            "",
1158            "{",
1159            "nope",
1160            "7",
1161            "\"tools/call\"",
1162            "{} {}",
1163            "\u{feff}{}",
1164        ] {
1165            assert_eq!(one(body), Classified::Unreadable, "{body:?}");
1166        }
1167    }
1168
1169    fn rules() -> McpToolScopes {
1170        McpToolScopes::new()
1171            .default(["mcp:read"])
1172            .tool("write_document", ["mcp:write"])
1173            .tool("admin", ["mcp:write", "mcp:admin"])
1174    }
1175
1176    #[test]
1177    fn requirements_per_body() {
1178        let r = rules();
1179        let r = &r.rules;
1180        let req = |json: &str| r.for_body(json.as_bytes());
1181        assert_eq!(
1182            req(r#"{"method":"tools/call","params":{"name":"write_document"}}"#),
1183            ["mcp:write"]
1184        );
1185        assert_eq!(
1186            req(r#"{"method":"tools/call","params":{"name":"other"}}"#),
1187            ["mcp:read"]
1188        );
1189        assert_eq!(req(r#"{"method":"initialize"}"#), ["mcp:read"]);
1190        let strictest = ["mcp:read", "mcp:write", "mcp:admin"];
1191        assert_eq!(req("not json"), strictest);
1192        assert_eq!(req(r#"{"method":"tools/call"}"#), strictest);
1193        assert_eq!(
1194            req(
1195                r#"[{"method":"tools/call","params":{"name":"write_document"}},{"method":"tools/list"}]"#
1196            ),
1197            // The default first, then each tool's, in configuration order.
1198            ["mcp:read", "mcp:write"]
1199        );
1200        assert_eq!(req("[]"), ["mcp:read"]);
1201        // Nothing at all: the default; whitespace alone: unreadable.
1202        assert_eq!(req(""), ["mcp:read"]);
1203        assert_eq!(req("  "), strictest);
1204        // Anything a strict JSON parser refuses is unreadable, even in a
1205        // member nobody looks at: an out-of-range number, invalid UTF-8.
1206        assert_eq!(req(r#"{"method":"initialize","x":1e999}"#), strictest);
1207        assert_eq!(
1208            r.for_body(b"{\"method\":\"initialize\",\"x\":\"\xff\"}"),
1209            strictest
1210        );
1211        assert_eq!(req(r#"{"method":"initialize"} x"#), strictest);
1212        assert_eq!(rules().scopes_for_tool("admin"), ["mcp:write", "mcp:admin"]);
1213        assert_eq!(rules().scopes_for_tool("nope"), ["mcp:read"]);
1214    }
1215
1216    #[test]
1217    fn a_repeated_tool_entry_replaces_the_earlier_one() {
1218        let r = McpToolScopes::new().tool("t", ["a"]).tool("t", ["b"]);
1219        assert_eq!(r.scopes_for_tool("t"), ["b"]);
1220        assert_eq!(r.rules.strictest, ["b"]);
1221    }
1222
1223    #[test]
1224    #[should_panic(expected = "is not a valid scope")]
1225    fn an_invalid_scope_panics() {
1226        let _ = McpToolScopes::new().tool("t", ["has space"]);
1227    }
1228
1229    #[test]
1230    fn the_try_forms_return_what_the_panicking_forms_panic_on() {
1231        match McpToolScopes::new().try_tool("t", ["ok", "has space"]) {
1232            Err(McpScopesError::InvalidScope(e)) => assert_eq!(e.scope(), "has space"),
1233            other => panic!("{other:?}"),
1234        }
1235        match McpToolScopes::new().try_default([""]) {
1236            Err(McpScopesError::InvalidScope(e)) => assert_eq!(e.scope(), ""),
1237            other => panic!("{other:?}"),
1238        }
1239        assert_eq!(
1240            McpToolScopes::new()
1241                .try_body_limit(MIN_BODY_LIMIT - 1)
1242                .unwrap_err(),
1243            McpScopesError::BodyLimitOutOfRange(MIN_BODY_LIMIT - 1)
1244        );
1245        let ok = McpToolScopes::new()
1246            .try_default(["r"])
1247            .and_then(|m| m.try_tool("t", ["w"]))
1248            .and_then(|m| m.try_body_limit(MAX_BODY_LIMIT))
1249            .unwrap();
1250        assert_eq!(ok.scopes_for_tool("t"), ["w"]);
1251        assert_eq!(ok.rules.body_limit, MAX_BODY_LIMIT);
1252    }
1253
1254    #[test]
1255    #[should_panic(expected = "body_limit")]
1256    fn a_body_limit_out_of_bounds_panics() {
1257        let _ = McpToolScopes::new().body_limit(MAX_BODY_LIMIT + 1);
1258    }
1259}