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//! 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}