1use serde_json::value::RawValue;
29
30use crate::application::jsonrpc::{MethodTable, RouteError};
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
38pub enum McpMethod {
39 Initialize,
41 Ping,
43 ToolsList,
45 ToolsCall,
47 ResourcesList,
49 ResourcesRead,
51 ResourceTemplatesList,
53 PromptsList,
55 PromptsGet,
57 SetLogLevel,
59 Complete,
61 InitializedNotification,
63 CancelledNotification,
65 ProgressNotification,
67 LoggingMessageNotification,
69 RootsListChangedNotification,
71 SamplingCreateMessage,
73 ElicitationCreate,
75 RootsList,
77}
78
79impl McpMethod {
80 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 #[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 #[must_use]
145 pub fn is_request(self) -> bool {
146 !self.as_str().starts_with("notifications/")
147 }
148}
149
150#[derive(Debug, Clone, Copy, Default)]
155pub struct McpMethodTable;
156
157impl McpMethodTable {
158 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 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 #![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 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 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 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 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 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 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}