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
171impl fmt::Display for Method {
172    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
173        f.write_str(self.as_str())
174    }
175}
176
177#[cfg(test)]
178mod tests {
179    use super::*;
180
181    #[test]
182    fn parses_every_core_method() {
183        for wire in [
184            "tools/call",
185            "tools/list",
186            "resources/read",
187            "resources/list",
188            "resources/templates/list",
189            "prompts/get",
190            "prompts/list",
191            "server/discover",
192            "tasks/get",
193            "tasks/update",
194            "tasks/cancel",
195            "subscriptions/listen",
196        ] {
197            let m = Method::parse(wire);
198            assert!(
199                !matches!(m, Method::Other(_)),
200                "{wire} fell through to Other"
201            );
202            assert_eq!(m.as_str(), wire, "round trip failed for {wire}");
203        }
204    }
205
206    #[test]
207    fn unknown_methods_are_retained_verbatim() {
208        let m = Method::parse("io.example/frobnicate");
209        assert_eq!(m, Method::Other("io.example/frobnicate".to_owned()));
210        assert_eq!(m.as_str(), "io.example/frobnicate");
211    }
212
213    #[test]
214    fn method_values_are_case_sensitive() {
215        // Header field *names* are case-insensitive; values are not. Folding case
216        // here would let "Tools/Call" slip past a per-method rule.
217        assert!(matches!(Method::parse("Tools/Call"), Method::Other(_)));
218        assert!(matches!(Method::parse("TOOLS/LIST"), Method::Other(_)));
219    }
220
221    #[test]
222    fn mrtr_set_is_exactly_the_three_the_spec_allows() {
223        let allowed: Vec<Method> = [
224            "tools/call",
225            "tools/list",
226            "resources/read",
227            "resources/list",
228            "resources/templates/list",
229            "prompts/get",
230            "prompts/list",
231            "server/discover",
232            "tasks/get",
233            "tasks/update",
234            "tasks/cancel",
235            "subscriptions/listen",
236            "io.example/other",
237        ]
238        .into_iter()
239        .map(Method::parse)
240        .filter(Method::supports_mrtr)
241        .collect();
242
243        assert_eq!(
244            allowed,
245            vec![Method::ToolsCall, Method::ResourcesRead, Method::PromptsGet]
246        );
247    }
248
249    #[test]
250    fn resources_read_is_both_cacheable_and_mrtr_capable() {
251        // The overlap that forces cache and meter to share one body peek.
252        let m = Method::ResourcesRead;
253        assert!(m.is_cacheable());
254        assert!(m.supports_mrtr());
255    }
256
257    #[test]
258    fn discovery_is_cacheable_but_lists_are_not_mrtr_capable() {
259        for m in [
260            Method::ToolsList,
261            Method::PromptsList,
262            Method::ResourcesList,
263            Method::ResourcesTemplatesList,
264            Method::ServerDiscover,
265        ] {
266            assert!(m.is_cacheable(), "{m} should be cacheable");
267            assert!(m.is_discovery(), "{m} should be discovery");
268            assert!(
269                !m.supports_mrtr(),
270                "{m} must not accept InputRequiredResult"
271            );
272        }
273    }
274
275    #[test]
276    fn task_drive_covers_get_update_cancel_only() {
277        assert!(Method::TasksGet.is_task_drive());
278        assert!(Method::TasksUpdate.is_task_drive());
279        assert!(Method::TasksCancel.is_task_drive());
280        assert!(!Method::ToolsCall.is_task_drive());
281        // tasks/list was removed in this revision; it must not resolve to a known method.
282        assert!(matches!(Method::parse("tasks/list"), Method::Other(_)));
283    }
284
285    #[test]
286    fn name_bearing_methods_match_the_header_requirement() {
287        assert!(Method::ToolsCall.carries_name());
288        assert!(Method::ResourcesRead.carries_name());
289        assert!(Method::PromptsGet.carries_name());
290        assert!(!Method::ToolsList.carries_name());
291        assert!(!Method::TasksGet.carries_name());
292    }
293}