use serde_json::value::RawValue;
use crate::application::jsonrpc::{MethodTable, RouteError};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum McpMethod {
Initialize,
Ping,
ToolsList,
ToolsCall,
ResourcesList,
ResourcesRead,
ResourceTemplatesList,
PromptsList,
PromptsGet,
SetLogLevel,
Complete,
InitializedNotification,
CancelledNotification,
ProgressNotification,
LoggingMessageNotification,
RootsListChangedNotification,
SamplingCreateMessage,
ElicitationCreate,
RootsList,
}
impl McpMethod {
pub const ALL: [Self; 19] = [
Self::Initialize,
Self::Ping,
Self::ToolsList,
Self::ToolsCall,
Self::ResourcesList,
Self::ResourcesRead,
Self::ResourceTemplatesList,
Self::PromptsList,
Self::PromptsGet,
Self::SetLogLevel,
Self::Complete,
Self::RootsList,
Self::SamplingCreateMessage,
Self::ElicitationCreate,
Self::InitializedNotification,
Self::CancelledNotification,
Self::ProgressNotification,
Self::LoggingMessageNotification,
Self::RootsListChangedNotification,
];
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Initialize => "initialize",
Self::Ping => "ping",
Self::ToolsList => "tools/list",
Self::ToolsCall => "tools/call",
Self::ResourcesList => "resources/list",
Self::ResourcesRead => "resources/read",
Self::ResourceTemplatesList => "resources/templates/list",
Self::PromptsList => "prompts/list",
Self::PromptsGet => "prompts/get",
Self::SetLogLevel => "logging/setLevel",
Self::Complete => "completion/complete",
Self::InitializedNotification => "notifications/initialized",
Self::CancelledNotification => "notifications/cancelled",
Self::ProgressNotification => "notifications/progress",
Self::LoggingMessageNotification => "notifications/message",
Self::RootsListChangedNotification => "notifications/roots/list_changed",
Self::SamplingCreateMessage => "sampling/createMessage",
Self::ElicitationCreate => "elicitation/create",
Self::RootsList => "roots/list",
}
}
#[must_use]
pub fn is_request(self) -> bool {
!self.as_str().starts_with("notifications/")
}
}
#[derive(Debug, Clone, Copy, Default)]
pub struct McpMethodTable;
impl McpMethodTable {
pub const TABLE: Self = Self;
}
impl MethodTable for McpMethodTable {
type MethodId = McpMethod;
type RequestParams<'a> = Option<&'a RawValue>;
type NotificationParams<'a> = Option<&'a RawValue>;
fn lookup(&self, name: &str) -> Option<McpMethod> {
match name {
"initialize" => Some(McpMethod::Initialize),
"ping" => Some(McpMethod::Ping),
"tools/list" => Some(McpMethod::ToolsList),
"tools/call" => Some(McpMethod::ToolsCall),
"resources/list" => Some(McpMethod::ResourcesList),
"resources/read" => Some(McpMethod::ResourcesRead),
"resources/templates/list" => Some(McpMethod::ResourceTemplatesList),
"prompts/list" => Some(McpMethod::PromptsList),
"prompts/get" => Some(McpMethod::PromptsGet),
"logging/setLevel" => Some(McpMethod::SetLogLevel),
"completion/complete" => Some(McpMethod::Complete),
"roots/list" => Some(McpMethod::RootsList),
"sampling/createMessage" => Some(McpMethod::SamplingCreateMessage),
"elicitation/create" => Some(McpMethod::ElicitationCreate),
"notifications/initialized" => Some(McpMethod::InitializedNotification),
"notifications/cancelled" => Some(McpMethod::CancelledNotification),
"notifications/progress" => Some(McpMethod::ProgressNotification),
"notifications/message" => Some(McpMethod::LoggingMessageNotification),
"notifications/roots/list_changed" => Some(McpMethod::RootsListChangedNotification),
_ => None,
}
}
fn decode_request_params<'a>(
&self,
method: McpMethod,
params: Option<&'a RawValue>,
) -> Result<Self::RequestParams<'a>, RouteError<'a>> {
let _ = method;
Ok(params)
}
fn decode_notification_params<'a>(
&self,
method: McpMethod,
params: Option<&'a RawValue>,
) -> Result<Self::NotificationParams<'a>, RouteError<'a>> {
let _ = method;
Ok(params)
}
}
#[cfg(test)]
mod tests {
#![expect(
clippy::expect_used,
clippy::panic,
reason = "tests assert on fallible calls and panic with the failing precondition; propagating errors would replace a clear failure message with noise"
)]
use assert_matches::assert_matches;
use serde_json::value::RawValue;
use crate::application::jsonrpc::{MethodTable, RouteError, Routed, route_one};
use crate::domain::feature::{Feature, FeatureProfile};
use crate::domain::jsonrpc::{Frame, decode};
use super::{McpMethod, McpMethodTable};
type TestError = Box<dyn std::error::Error>;
const NOT_MCP: &str = "does/not/exist";
fn route(
input: &[u8],
) -> Result<Routed<'_, McpMethod, Option<&RawValue>, Option<&RawValue>>, RouteError<'_>> {
let envelope = match decode(input)? {
Frame::Single(envelope) => envelope,
Frame::Batch(_) => return Err(RouteError::BatchNotAllowed),
};
route_one(envelope, &McpMethodTable::TABLE)
}
fn request(method: &str) -> String {
format!(r#"{{"jsonrpc":"2.0","id":1,"method":"{method}"}}"#)
}
const EVERY_METHOD: &[(&str, McpMethod, bool)] = &[
("initialize", McpMethod::Initialize, true),
("ping", McpMethod::Ping, true),
("tools/list", McpMethod::ToolsList, true),
("tools/call", McpMethod::ToolsCall, true),
("resources/list", McpMethod::ResourcesList, true),
("resources/read", McpMethod::ResourcesRead, true),
(
"resources/templates/list",
McpMethod::ResourceTemplatesList,
true,
),
("prompts/list", McpMethod::PromptsList, true),
("prompts/get", McpMethod::PromptsGet, true),
("logging/setLevel", McpMethod::SetLogLevel, true),
("completion/complete", McpMethod::Complete, true),
("roots/list", McpMethod::RootsList, true),
(
"sampling/createMessage",
McpMethod::SamplingCreateMessage,
true,
),
("elicitation/create", McpMethod::ElicitationCreate, true),
(
"notifications/initialized",
McpMethod::InitializedNotification,
false,
),
(
"notifications/cancelled",
McpMethod::CancelledNotification,
false,
),
(
"notifications/progress",
McpMethod::ProgressNotification,
false,
),
(
"notifications/message",
McpMethod::LoggingMessageNotification,
false,
),
(
"notifications/roots/list_changed",
McpMethod::RootsListChangedNotification,
false,
),
];
#[test]
fn every_method_is_reachable_and_agrees_about_being_a_request() {
for (name, expected, is_request) in EVERY_METHOD {
let found = McpMethodTable::TABLE
.lookup(name)
.unwrap_or_else(|| panic!("{name} is in the table and must be found"));
assert_eq!(found, *expected, "{name} must resolve to its own variant");
assert_eq!(
found.as_str(),
*name,
"as_str must be the name lookup accepts, or a reply echoes the wrong method"
);
assert_eq!(
found.is_request(),
*is_request,
"{name} is {} a request",
if *is_request { "" } else { "not" }
);
}
}
#[test]
fn every_variant_is_in_the_list() {
for method in McpMethod::ALL {
assert!(
EVERY_METHOD.iter().any(|(_, listed, _)| *listed == method),
"{method:?} is a variant but no test names it, so nothing would fail if \
lookup stopped resolving it"
);
}
}
#[test]
fn an_unknown_method_is_method_not_found() {
let input = request(NOT_MCP);
let error = route(input.as_bytes()).expect_err("no such MCP method");
assert_eq!(error.code(), crate::domain::ir::IrErrorCode::MethodNotFound);
assert_matches!(error, RouteError::MethodNotFound(name) if name == "does/not/exist");
}
#[test]
fn a_method_name_is_not_normalised() {
for near in ["Initialize", "tools/List", "tools/list ", " tools/list"] {
let input = request(near);
let error = route(input.as_bytes())
.expect_err("the specification names methods case-sensitively");
assert_eq!(
error.code(),
crate::domain::ir::IrErrorCode::MethodNotFound,
"{near} is not a method name"
);
}
}
#[test]
fn a_request_routes_to_its_method_and_keeps_its_params_borrowed() -> Result<(), TestError> {
let input = br#"{"jsonrpc":"2.0","id":7,"method":"tools/call","params":{"name":"echo"}}"#;
match route(input) {
Ok(Routed::Request {
method, id, params, ..
}) => {
assert_eq!(method, McpMethod::ToolsCall);
assert_eq!(id.as_number(), Some(7));
let raw = params.ok_or("tools/call has params")?;
assert_eq!(raw.get(), r#"{"name":"echo"}"#);
}
other => return Err(format!("expected a request, got {other:?}").into()),
}
Ok(())
}
#[test]
fn a_notification_routes_without_an_id() -> Result<(), TestError> {
let input = br#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#;
match route(input) {
Ok(Routed::Notification { method, params }) => {
assert_eq!(method, McpMethod::InitializedNotification);
assert!(params.is_none());
}
other => return Err(format!("expected a notification, got {other:?}").into()),
}
Ok(())
}
#[test]
fn a_notification_name_sent_as_a_request_is_still_a_notification() -> Result<(), TestError> {
let input = br#"{"jsonrpc":"2.0","id":1,"method":"notifications/initialized"}"#;
match route(input) {
Ok(Routed::Request { method, .. }) => {
assert_eq!(method, McpMethod::InitializedNotification);
assert!(!method.is_request(), "and the method knows it is one");
}
other => return Err(format!("expected a request, got {other:?}").into()),
}
Ok(())
}
#[test]
fn a_reply_routes_to_no_method() -> Result<(), TestError> {
let input = br#"{"jsonrpc":"2.0","id":1,"result":{"ok":true}}"#;
match route(input) {
Ok(Routed::Reply { id, payload }) => {
assert_eq!(id.as_number(), Some(1));
assert_matches!(
payload,
crate::application::jsonrpc::ReplyPayload::Result(_)
);
}
other => return Err(format!("expected a reply, got {other:?}").into()),
}
Ok(())
}
#[test]
fn a_batch_is_refused_when_the_profile_has_no_batching() {
let profile = FeatureProfile::empty();
assert!(!profile.allows(Feature::JsonRpcBatching));
}
}