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}