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