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}