Skip to main content

mcp_usage_core/
method.rs

1//! The MCP method taxonomy, and the classifications the meter needs from it.
2//!
3//! A method arrives at an intermediary in the `Mcp-Method` header, which
4//! 2026-07-28 makes REQUIRED on every request. Parsing it is therefore the whole
5//! of request classification: no body, no allocation, one string comparison
6//! against a small fixed set.
7//!
8//! Each classification maps to a billing or caching rule from the specification.
9
10use std::fmt;
11
12/// A method named in the `Mcp-Method` header.
13///
14/// `Other` carries anything outside the core protocol so that an unrecognized
15/// method is classified conservatively rather than rejected: extensions define
16/// their own methods, and a meter that 400s on every extension it has not been
17/// taught about is worse than useless in front of a real server.
18#[derive(Debug, Clone, PartialEq, Eq, Hash)]
19pub enum Method {
20    /// Invoke a server tool.
21    ToolsCall,
22    /// List the tools exposed by the server.
23    ToolsList,
24    /// Read a resource by URI.
25    ResourcesRead,
26    /// List resources exposed by the server.
27    ResourcesList,
28    /// List URI templates exposed by the server.
29    ResourcesTemplatesList,
30    /// Render a named prompt.
31    PromptsGet,
32    /// List prompts exposed by the server.
33    PromptsList,
34    /// Discover server capabilities without an initialization handshake.
35    ServerDiscover,
36    /// Poll a durable task.
37    TasksGet,
38    /// Supply input requested by a durable task.
39    TasksUpdate,
40    /// Request cancellation of a durable task.
41    TasksCancel,
42    /// Open the long-lived change-notification stream.
43    SubscriptionsListen,
44    /// An extension method not known to this build.
45    Other(String),
46}
47
48impl Method {
49    /// Parse a `Mcp-Method` header value.
50    ///
51    /// Header *values* are case-sensitive (only field names are not), so this
52    /// does not fold case: `Tools/Call` is not `tools/call`, and
53    /// treating it as such would let a client dodge a per-method price rule by
54    /// changing capitalization.
55    #[must_use]
56    pub fn parse(raw: &str) -> Self {
57        match raw {
58            "tools/call" => Self::ToolsCall,
59            "tools/list" => Self::ToolsList,
60            "resources/read" => Self::ResourcesRead,
61            "resources/list" => Self::ResourcesList,
62            "resources/templates/list" => Self::ResourcesTemplatesList,
63            "prompts/get" => Self::PromptsGet,
64            "prompts/list" => Self::PromptsList,
65            "server/discover" => Self::ServerDiscover,
66            "tasks/get" => Self::TasksGet,
67            "tasks/update" => Self::TasksUpdate,
68            "tasks/cancel" => Self::TasksCancel,
69            "subscriptions/listen" => Self::SubscriptionsListen,
70            other => Self::Other(other.to_owned()),
71        }
72    }
73
74    /// The canonical wire string.
75    #[must_use]
76    pub fn as_str(&self) -> &str {
77        match self {
78            Self::ToolsCall => "tools/call",
79            Self::ToolsList => "tools/list",
80            Self::ResourcesRead => "resources/read",
81            Self::ResourcesList => "resources/list",
82            Self::ResourcesTemplatesList => "resources/templates/list",
83            Self::PromptsGet => "prompts/get",
84            Self::PromptsList => "prompts/list",
85            Self::ServerDiscover => "server/discover",
86            Self::TasksGet => "tasks/get",
87            Self::TasksUpdate => "tasks/update",
88            Self::TasksCancel => "tasks/cancel",
89            Self::SubscriptionsListen => "subscriptions/listen",
90            Self::Other(s) => s,
91        }
92    }
93
94    /// Whether this method carries `Mcp-Name` (from `params.name` or `params.uri`).
95    ///
96    /// The spec requires the header only for `tools/call`, `resources/read`, and
97    /// `prompts/get`. A meter pricing per tool can therefore only expect a name on
98    /// these three, and must not treat its absence elsewhere as malformed.
99    #[must_use]
100    pub const fn carries_name(&self) -> bool {
101        matches!(
102            self,
103            Self::ToolsCall | Self::ResourcesRead | Self::PromptsGet
104        )
105    }
106
107    /// Whether the server is permitted to answer with an `InputRequiredResult`.
108    ///
109    /// The spec is exhaustive and closed here: servers MAY return one on
110    /// `prompts/get`, `resources/read`, and `tools/call`, and "MUST NOT send
111    /// `InputRequiredResult` responses on any other client requests."
112    ///
113    /// This is what bounds the body peek. Only these three can be a continuation,
114    /// so only these three ever need their body inspected, and the other nine
115    /// method values are decided from the header alone.
116    #[must_use]
117    pub const fn supports_mrtr(&self) -> bool {
118        matches!(
119            self,
120            Self::ToolsCall | Self::ResourcesRead | Self::PromptsGet
121        )
122    }
123
124    /// Whether results for this method carry caching hints (`ttlMs`, `cacheScope`).
125    ///
126    /// Note that `resources/read` appears in both this set and [`Self::supports_mrtr`].
127    /// That overlap is exactly where the "MRTR results MUST NOT be cached" rule
128    /// bites, and it is the reason the cache and the meter share one body peek.
129    #[must_use]
130    pub const fn is_cacheable(&self) -> bool {
131        matches!(
132            self,
133            Self::ServerDiscover
134                | Self::ToolsList
135                | Self::PromptsList
136                | Self::ResourcesList
137                | Self::ResourcesTemplatesList
138                | Self::ResourcesRead
139        )
140    }
141
142    /// Discovery traffic: the client finding out what the server offers.
143    ///
144    /// Never billable. It is overhead the protocol imposes on the client, it is
145    /// cacheable so it stops reaching the origin, and charging for it
146    /// would price the act of connecting.
147    #[must_use]
148    pub const fn is_discovery(&self) -> bool {
149        matches!(
150            self,
151            Self::ServerDiscover
152                | Self::ToolsList
153                | Self::PromptsList
154                | Self::ResourcesList
155                | Self::ResourcesTemplatesList
156        )
157    }
158
159    /// Task lifecycle driving: polling, supplying input, cancelling.
160    ///
161    /// Never billable on its own. The work was commissioned by the originating
162    /// `tools/call`; these are the client asking whether it is done yet. A meter
163    /// that counts requests charges a 10-minute job polled every 2s roughly 300
164    /// times for one unit of delivered work.
165    #[must_use]
166    pub const fn is_task_drive(&self) -> bool {
167        matches!(self, Self::TasksGet | Self::TasksUpdate | Self::TasksCancel)
168    }
169
170    /// Whether this method can deliver work that is worth charging for.
171    ///
172    /// Three methods do. Everything else is lifecycle (`initialize`, `ping`),
173    /// discovery, task plumbing, or a vendor extension, and none of those
174    /// deliver a priced result no matter what they answer with.
175    ///
176    /// Deliberately its own predicate rather than a reuse of
177    /// [`Self::carries_name`], which happens to match the same three today:
178    /// they answer different questions, and a future method that carries a
179    /// name without delivering priced work must not silently start billing.
180    #[must_use]
181    pub const fn delivers_priced_work(&self) -> bool {
182        matches!(
183            self,
184            Self::ToolsCall | Self::ResourcesRead | Self::PromptsGet
185        )
186    }
187}
188
189impl fmt::Display for Method {
190    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
191        f.write_str(self.as_str())
192    }
193}
194
195#[cfg(test)]
196mod tests {
197    use super::*;
198
199    #[test]
200    fn parses_every_core_method() {
201        for wire in [
202            "tools/call",
203            "tools/list",
204            "resources/read",
205            "resources/list",
206            "resources/templates/list",
207            "prompts/get",
208            "prompts/list",
209            "server/discover",
210            "tasks/get",
211            "tasks/update",
212            "tasks/cancel",
213            "subscriptions/listen",
214        ] {
215            let m = Method::parse(wire);
216            assert!(
217                !matches!(m, Method::Other(_)),
218                "{wire} fell through to Other"
219            );
220            assert_eq!(m.as_str(), wire, "round trip failed for {wire}");
221        }
222    }
223
224    #[test]
225    fn unknown_methods_are_retained_verbatim() {
226        let m = Method::parse("io.example/frobnicate");
227        assert_eq!(m, Method::Other("io.example/frobnicate".to_owned()));
228        assert_eq!(m.as_str(), "io.example/frobnicate");
229    }
230
231    #[test]
232    fn method_values_are_case_sensitive() {
233        // Header field *names* are case-insensitive; values are not. Folding case
234        // here would let "Tools/Call" slip past a per-method rule.
235        assert!(matches!(Method::parse("Tools/Call"), Method::Other(_)));
236        assert!(matches!(Method::parse("TOOLS/LIST"), Method::Other(_)));
237    }
238
239    #[test]
240    fn mrtr_set_is_exactly_the_three_the_spec_allows() {
241        let allowed: Vec<Method> = [
242            "tools/call",
243            "tools/list",
244            "resources/read",
245            "resources/list",
246            "resources/templates/list",
247            "prompts/get",
248            "prompts/list",
249            "server/discover",
250            "tasks/get",
251            "tasks/update",
252            "tasks/cancel",
253            "subscriptions/listen",
254            "io.example/other",
255        ]
256        .into_iter()
257        .map(Method::parse)
258        .filter(Method::supports_mrtr)
259        .collect();
260
261        assert_eq!(
262            allowed,
263            vec![Method::ToolsCall, Method::ResourcesRead, Method::PromptsGet]
264        );
265    }
266
267    #[test]
268    fn resources_read_is_both_cacheable_and_mrtr_capable() {
269        // The overlap that forces cache and meter to share one body peek.
270        let m = Method::ResourcesRead;
271        assert!(m.is_cacheable());
272        assert!(m.supports_mrtr());
273    }
274
275    #[test]
276    fn discovery_is_cacheable_but_lists_are_not_mrtr_capable() {
277        for m in [
278            Method::ToolsList,
279            Method::PromptsList,
280            Method::ResourcesList,
281            Method::ResourcesTemplatesList,
282            Method::ServerDiscover,
283        ] {
284            assert!(m.is_cacheable(), "{m} should be cacheable");
285            assert!(m.is_discovery(), "{m} should be discovery");
286            assert!(
287                !m.supports_mrtr(),
288                "{m} must not accept InputRequiredResult"
289            );
290        }
291    }
292
293    #[test]
294    fn task_drive_covers_get_update_cancel_only() {
295        assert!(Method::TasksGet.is_task_drive());
296        assert!(Method::TasksUpdate.is_task_drive());
297        assert!(Method::TasksCancel.is_task_drive());
298        assert!(!Method::ToolsCall.is_task_drive());
299        // tasks/list was removed in this revision; it must not resolve to a known method.
300        assert!(matches!(Method::parse("tasks/list"), Method::Other(_)));
301    }
302
303    #[test]
304    fn name_bearing_methods_match_the_header_requirement() {
305        assert!(Method::ToolsCall.carries_name());
306        assert!(Method::ResourcesRead.carries_name());
307        assert!(Method::PromptsGet.carries_name());
308        assert!(!Method::ToolsList.carries_name());
309        assert!(!Method::TasksGet.carries_name());
310    }
311}