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