Skip to main content

protocol_droid/adapters/secondary/mcp/
method_table.rs

1//! The MCP method table — the one place a JSON-RPC method name is written down.
2//!
3//! # Why this file exists at all
4//!
5//! [`MethodTable`] is protocol-neutral so that A2A and ACP can add themselves
6//! without touching it. The cost of that neutrality is that some module has to
7//! hold the actual MCP method names, and this is it. It is deliberately small
8//! and deliberately boring: a `match` on the name, and a variant per method.
9//!
10//! # The `match` is the perfect hash
11//!
12//! The task called for a perfect-hash or `match` dispatch. A `match` on string
13//! literals is the better of the two for a fixed vocabulary of this size: rustc
14//! lowers it to a switch on the length and then a chain of memcmps, which is
15//! allocation-free and has no build step. A perfect hash would have to be
16//! generated and checked in, and would then be a second thing that can disagree
17//! with the list of methods.
18//!
19//! # Params decoding stops here
20//!
21//! Decoding a method's params into a per-revision wire type needs the negotiated
22//! revision, which the session owns. T09 delivers the table and the refusal —
23//! a params shape that does not fit becomes `-32602` — and T12 narrows the
24//! return types to the revision's `Wire`. Until then the table proves the
25//! method exists and hands back the raw params, which is exactly the split
26//! between "route" and "dispatch".
27
28use serde_json::value::RawValue;
29
30use crate::application::jsonrpc::{MethodTable, RouteError};
31
32/// Every method MCP defines, as of `2026-07-28`.
33///
34/// One variant per method, so an unknown name cannot be confused with a known
35/// one at the type level: `lookup` returning `None` is the only way to reach
36/// `-32601`.
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
38pub enum McpMethod {
39    /// `initialize`.
40    Initialize,
41    /// `ping`.
42    Ping,
43    /// `tools/list`.
44    ToolsList,
45    /// `tools/call`.
46    ToolsCall,
47    /// `resources/list`.
48    ResourcesList,
49    /// `resources/read`.
50    ResourcesRead,
51    /// `resources/templates/list`.
52    ResourceTemplatesList,
53    /// `prompts/list`.
54    PromptsList,
55    /// `prompts/get`.
56    PromptsGet,
57    /// `logging/setLevel`.
58    SetLogLevel,
59    /// `completion/complete`.
60    Complete,
61    /// `notifications/initialized`.
62    InitializedNotification,
63    /// `notifications/cancelled`.
64    CancelledNotification,
65    /// `notifications/progress`.
66    ProgressNotification,
67    /// `notifications/message`.
68    LoggingMessageNotification,
69    /// `notifications/roots/list_changed`.
70    RootsListChangedNotification,
71    /// `sampling/createMessage`.
72    SamplingCreateMessage,
73    /// `elicitation/create`.
74    ElicitationCreate,
75    /// `roots/list`.
76    RootsList,
77}
78
79impl McpMethod {
80    /// Every method, in the order the specification introduces them.
81    ///
82    /// Exists so a test can assert the direction a per-name test cannot: that
83    /// every *variant* is reachable from a name, not merely that every name has
84    /// a variant. A method added to the enum and forgotten in `lookup` is
85    /// otherwise a `-32601` waiting to happen.
86    pub const ALL: [Self; 19] = [
87        Self::Initialize,
88        Self::Ping,
89        Self::ToolsList,
90        Self::ToolsCall,
91        Self::ResourcesList,
92        Self::ResourcesRead,
93        Self::ResourceTemplatesList,
94        Self::PromptsList,
95        Self::PromptsGet,
96        Self::SetLogLevel,
97        Self::Complete,
98        Self::RootsList,
99        Self::SamplingCreateMessage,
100        Self::ElicitationCreate,
101        Self::InitializedNotification,
102        Self::CancelledNotification,
103        Self::ProgressNotification,
104        Self::LoggingMessageNotification,
105        Self::RootsListChangedNotification,
106    ];
107
108    /// The wire name of this method.
109    #[must_use]
110    pub const fn as_str(self) -> &'static str {
111        match self {
112            Self::Initialize => "initialize",
113            Self::Ping => "ping",
114            Self::ToolsList => "tools/list",
115            Self::ToolsCall => "tools/call",
116            Self::ResourcesList => "resources/list",
117            Self::ResourcesRead => "resources/read",
118            Self::ResourceTemplatesList => "resources/templates/list",
119            Self::PromptsList => "prompts/list",
120            Self::PromptsGet => "prompts/get",
121            Self::SetLogLevel => "logging/setLevel",
122            Self::Complete => "completion/complete",
123            Self::InitializedNotification => "notifications/initialized",
124            Self::CancelledNotification => "notifications/cancelled",
125            Self::ProgressNotification => "notifications/progress",
126            Self::LoggingMessageNotification => "notifications/message",
127            Self::RootsListChangedNotification => "notifications/roots/list_changed",
128            Self::SamplingCreateMessage => "sampling/createMessage",
129            Self::ElicitationCreate => "elicitation/create",
130            Self::RootsList => "roots/list",
131        }
132    }
133
134    /// Whether a request naming this method is something a server answers.
135    ///
136    /// Only informational. A server still has to consult the session's
137    /// [`FeatureProfile`](crate::domain::feature::FeatureProfile) before
138    /// answering, because a method can exist in the schema and be unavailable
139    /// in a given session — `roots/list` after `roots` is deprecated, or
140    /// `sampling/createMessage` from a client that never advertised sampling.
141    /// This answers the narrower question of whether the name is a request name
142    /// at all, which is what the router needs to pick between its two params
143    /// decoders.
144    #[must_use]
145    pub fn is_request(self) -> bool {
146        !self.as_str().starts_with("notifications/")
147    }
148}
149
150/// The MCP implementation of [`MethodTable`].
151///
152/// A unit struct: the table is a fixed vocabulary with no configuration, so a
153/// value of it is a constant rather than something to build.
154#[derive(Debug, Clone, Copy, Default)]
155pub struct McpMethodTable;
156
157impl McpMethodTable {
158    /// The shared table.
159    pub const TABLE: Self = Self;
160}
161
162impl MethodTable for McpMethodTable {
163    type MethodId = McpMethod;
164    type RequestParams<'a> = Option<&'a RawValue>;
165    type NotificationParams<'a> = Option<&'a RawValue>;
166
167    fn lookup(&self, name: &str) -> Option<McpMethod> {
168        match name {
169            "initialize" => Some(McpMethod::Initialize),
170            "ping" => Some(McpMethod::Ping),
171            "tools/list" => Some(McpMethod::ToolsList),
172            "tools/call" => Some(McpMethod::ToolsCall),
173            "resources/list" => Some(McpMethod::ResourcesList),
174            "resources/read" => Some(McpMethod::ResourcesRead),
175            "resources/templates/list" => Some(McpMethod::ResourceTemplatesList),
176            "prompts/list" => Some(McpMethod::PromptsList),
177            "prompts/get" => Some(McpMethod::PromptsGet),
178            "logging/setLevel" => Some(McpMethod::SetLogLevel),
179            "completion/complete" => Some(McpMethod::Complete),
180            "roots/list" => Some(McpMethod::RootsList),
181            "sampling/createMessage" => Some(McpMethod::SamplingCreateMessage),
182            "elicitation/create" => Some(McpMethod::ElicitationCreate),
183            "notifications/initialized" => Some(McpMethod::InitializedNotification),
184            "notifications/cancelled" => Some(McpMethod::CancelledNotification),
185            "notifications/progress" => Some(McpMethod::ProgressNotification),
186            "notifications/message" => Some(McpMethod::LoggingMessageNotification),
187            "notifications/roots/list_changed" => Some(McpMethod::RootsListChangedNotification),
188            _ => None,
189        }
190    }
191
192    fn decode_request_params<'a>(
193        &self,
194        method: McpMethod,
195        params: Option<&'a RawValue>,
196    ) -> Result<Self::RequestParams<'a>, RouteError<'a>> {
197        // `params` is optional on the wire for every MCP method, and the
198        // difference between "absent" and "null" is not one any handler
199        // cares about, so both arrive here as `None`-shaped values. T12
200        // replaces this with a per-revision `Wire` decode.
201        let _ = method;
202        Ok(params)
203    }
204
205    fn decode_notification_params<'a>(
206        &self,
207        method: McpMethod,
208        params: Option<&'a RawValue>,
209    ) -> Result<Self::NotificationParams<'a>, RouteError<'a>> {
210        let _ = method;
211        Ok(params)
212    }
213}
214
215#[cfg(test)]
216mod tests {
217    // Test code unwraps deliberately: the strict profile exists to keep
218    // production paths fallible, and a test that cannot unwrap cannot assert.
219    // The messages name the failing precondition, which is the useful part.
220    #![expect(
221        clippy::expect_used,
222        clippy::panic,
223        reason = "tests assert on fallible calls and panic with the failing precondition; propagating errors would replace a clear failure message with noise"
224    )]
225    //! The MCP table, exercised through the protocol-neutral router.
226    //!
227    //! # Why these live here and not in `tests/`
228    //!
229    //! A dependent crate can see the crate's public API, but the compiler
230    //! still calls a public-but-unused item dead when it builds this crate as
231    //! a test binary. So anything the crate exports has to be reachable from
232    //! inside the crate or the lib test target will not build.
233    //!
234    //! Direction is the other reason: `application` may not name an adapter,
235    //! but an adapter may name the application. Testing the MCP table against
236    //! the real router from *this* module keeps the dependency pointing in
237    //! the only direction the architecture allows, and it is a stronger test
238    //! than a mock — a method name that drifts out of the specification fails
239    //! here rather than in production.
240    //!
241    //! The router's own behaviour, with a stand-in table, is tested in
242    //! `application/jsonrpc/router.rs`; the batch policy in
243    //! `application/jsonrpc/batch.rs`.
244
245    use assert_matches::assert_matches;
246    use serde_json::value::RawValue;
247
248    use crate::application::jsonrpc::{MethodTable, RouteError, Routed, route_one};
249    use crate::domain::feature::{Feature, FeatureProfile};
250    use crate::domain::jsonrpc::{Frame, decode};
251
252    use super::{McpMethod, McpMethodTable};
253
254    type TestError = Box<dyn std::error::Error>;
255
256    /// The one non-MCP method name, to prove `-32601` is the table's answer
257    /// and not a special case in the router.
258    const NOT_MCP: &str = "does/not/exist";
259
260    fn route(
261        input: &[u8],
262    ) -> Result<Routed<'_, McpMethod, Option<&RawValue>, Option<&RawValue>>, RouteError<'_>> {
263        let envelope = match decode(input)? {
264            Frame::Single(envelope) => envelope,
265            // `BatchNotAllowed` is the wrong story for a single-message test,
266            // but it is the only refusal that does not lie about why: the
267            // router refuses a batch before it looks at any of its members.
268            Frame::Batch(_) => return Err(RouteError::BatchNotAllowed),
269        };
270        route_one(envelope, &McpMethodTable::TABLE)
271    }
272
273    fn request(method: &str) -> String {
274        format!(r#"{{"jsonrpc":"2.0","id":1,"method":"{method}"}}"#)
275    }
276
277    /// Every method the specification defines at this revision, as its wire
278    /// name. A method that exists in the enum but is missing here fails the
279    /// `every_method_is_reachable` test, and a name that is listed but not in
280    /// the enum fails to compile.
281    const EVERY_METHOD: &[(&str, McpMethod, bool)] = &[
282        ("initialize", McpMethod::Initialize, true),
283        ("ping", McpMethod::Ping, true),
284        ("tools/list", McpMethod::ToolsList, true),
285        ("tools/call", McpMethod::ToolsCall, true),
286        ("resources/list", McpMethod::ResourcesList, true),
287        ("resources/read", McpMethod::ResourcesRead, true),
288        (
289            "resources/templates/list",
290            McpMethod::ResourceTemplatesList,
291            true,
292        ),
293        ("prompts/list", McpMethod::PromptsList, true),
294        ("prompts/get", McpMethod::PromptsGet, true),
295        ("logging/setLevel", McpMethod::SetLogLevel, true),
296        ("completion/complete", McpMethod::Complete, true),
297        ("roots/list", McpMethod::RootsList, true),
298        (
299            "sampling/createMessage",
300            McpMethod::SamplingCreateMessage,
301            true,
302        ),
303        ("elicitation/create", McpMethod::ElicitationCreate, true),
304        (
305            "notifications/initialized",
306            McpMethod::InitializedNotification,
307            false,
308        ),
309        (
310            "notifications/cancelled",
311            McpMethod::CancelledNotification,
312            false,
313        ),
314        (
315            "notifications/progress",
316            McpMethod::ProgressNotification,
317            false,
318        ),
319        (
320            "notifications/message",
321            McpMethod::LoggingMessageNotification,
322            false,
323        ),
324        (
325            "notifications/roots/list_changed",
326            McpMethod::RootsListChangedNotification,
327            false,
328        ),
329    ];
330
331    #[test]
332    fn every_method_is_reachable_and_agrees_about_being_a_request() {
333        for (name, expected, is_request) in EVERY_METHOD {
334            let found = McpMethodTable::TABLE
335                .lookup(name)
336                .unwrap_or_else(|| panic!("{name} is in the table and must be found"));
337            assert_eq!(found, *expected, "{name} must resolve to its own variant");
338            assert_eq!(
339                found.as_str(),
340                *name,
341                "as_str must be the name lookup accepts, or a reply echoes the wrong method"
342            );
343            assert_eq!(
344                found.is_request(),
345                *is_request,
346                "{name} is {} a request",
347                if *is_request { "" } else { "not" }
348            );
349        }
350    }
351
352    #[test]
353    fn every_variant_is_in_the_list() {
354        for method in McpMethod::ALL {
355            assert!(
356                EVERY_METHOD.iter().any(|(_, listed, _)| *listed == method),
357                "{method:?} is a variant but no test names it, so nothing would fail if \
358                 lookup stopped resolving it"
359            );
360        }
361    }
362
363    #[test]
364    fn an_unknown_method_is_method_not_found() {
365        let input = request(NOT_MCP);
366        let error = route(input.as_bytes()).expect_err("no such MCP method");
367        assert_eq!(error.code(), crate::domain::ir::IrErrorCode::MethodNotFound);
368        assert_matches!(error, RouteError::MethodNotFound(name) if name == "does/not/exist");
369    }
370
371    #[test]
372    fn a_method_name_is_not_normalised() {
373        // The table is a `match`, not a lookup table, so a near-miss must miss.
374        for near in ["Initialize", "tools/List", "tools/list ", " tools/list"] {
375            let input = request(near);
376            let error = route(input.as_bytes())
377                .expect_err("the specification names methods case-sensitively");
378            assert_eq!(
379                error.code(),
380                crate::domain::ir::IrErrorCode::MethodNotFound,
381                "{near} is not a method name"
382            );
383        }
384    }
385
386    #[test]
387    fn a_request_routes_to_its_method_and_keeps_its_params_borrowed() -> Result<(), TestError> {
388        let input = br#"{"jsonrpc":"2.0","id":7,"method":"tools/call","params":{"name":"echo"}}"#;
389        match route(input) {
390            Ok(Routed::Request {
391                method, id, params, ..
392            }) => {
393                assert_eq!(method, McpMethod::ToolsCall);
394                assert_eq!(id.as_number(), Some(7));
395                let raw = params.ok_or("tools/call has params")?;
396                assert_eq!(raw.get(), r#"{"name":"echo"}"#);
397            }
398            other => return Err(format!("expected a request, got {other:?}").into()),
399        }
400        Ok(())
401    }
402
403    #[test]
404    fn a_notification_routes_without_an_id() -> Result<(), TestError> {
405        let input = br#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#;
406        match route(input) {
407            Ok(Routed::Notification { method, params }) => {
408                assert_eq!(method, McpMethod::InitializedNotification);
409                assert!(params.is_none());
410            }
411            other => return Err(format!("expected a notification, got {other:?}").into()),
412        }
413        Ok(())
414    }
415
416    #[test]
417    fn a_notification_name_sent_as_a_request_is_still_a_notification() -> Result<(), TestError> {
418        // The table does not police request-versus-notification; the shape
419        // does. A peer that names a notification method with an id gets a
420        // request that nothing answers, which the specification does not
421        // permit — so this asserts the split is by wire shape only, and that
422        // the split is total.
423        let input = br#"{"jsonrpc":"2.0","id":1,"method":"notifications/initialized"}"#;
424        match route(input) {
425            Ok(Routed::Request { method, .. }) => {
426                assert_eq!(method, McpMethod::InitializedNotification);
427                assert!(!method.is_request(), "and the method knows it is one");
428            }
429            other => return Err(format!("expected a request, got {other:?}").into()),
430        }
431        Ok(())
432    }
433
434    #[test]
435    fn a_reply_routes_to_no_method() -> Result<(), TestError> {
436        let input = br#"{"jsonrpc":"2.0","id":1,"result":{"ok":true}}"#;
437        match route(input) {
438            Ok(Routed::Reply { id, payload }) => {
439                assert_eq!(id.as_number(), Some(1));
440                assert_matches!(
441                    payload,
442                    crate::application::jsonrpc::ReplyPayload::Result(_)
443                );
444            }
445            other => return Err(format!("expected a reply, got {other:?}").into()),
446        }
447        Ok(())
448    }
449
450    #[test]
451    fn a_batch_is_refused_when_the_profile_has_no_batching() {
452        let profile = FeatureProfile::empty();
453        assert!(!profile.allows(Feature::JsonRpcBatching));
454    }
455}