diff --git a/memoria/crates/memoria-api/src/routes/mcp.rs b/memoria/crates/memoria-api/src/routes/mcp.rs index c5190ed..f9d0379 100644 --- a/memoria/crates/memoria-api/src/routes/mcp.rs +++ b/memoria/crates/memoria-api/src/routes/mcp.rs @@ -156,7 +156,8 @@ pub async fn mcp_handler( auth: AuthUser, body: String, ) -> impl IntoResponse { - // Start timing here — auth already succeeded, this is real billable traffic. + // Start timing after auth. Billable MCP requests are recorded below; transport + // liveness checks short-circuit before entering the usage-accounting pipeline. let t = std::time::Instant::now(); // Helper: record a validation-failure entry and return early. @@ -232,6 +233,17 @@ pub async fn mcp_handler( } }; + // `ping` is a transport-level liveness check and can be sent frequently by MCP + // clients. Keep authentication and rate limiting, but do not count it as product + // usage or persist it in the API call log. + if method == "ping" { + if req.get("id").is_none() { + return StatusCode::NO_CONTENT.into_response(); + } + let id = req["id"].clone(); + return Json(json!({"jsonrpc": "2.0", "id": id, "result": {}})).into_response(); + } + let params = req.get("params").cloned(); let track_path = tracking_path(&method, params.as_ref()); let tracked_tool = if method == "tools/call" { diff --git a/memoria/crates/memoria-api/tests/api_e2e.rs b/memoria/crates/memoria-api/tests/api_e2e.rs index 0566fdb..eb9918b 100644 --- a/memoria/crates/memoria-api/tests/api_e2e.rs +++ b/memoria/crates/memoria-api/tests/api_e2e.rs @@ -9434,6 +9434,45 @@ async fn test_mcp_initialize() { ); } +#[tokio::test] +async fn test_mcp_ping() { + let (base, client, _server) = spawn_server().await; + let uid = uid(); + + let resp = mcp_post_with_headers( + &client, + &base, + json!({"jsonrpc": "2.0", "id": 2, "method": "ping"}), + &[("X-User-Id", uid.as_str())], + ) + .await; + + assert_eq!(resp, json!({"jsonrpc": "2.0", "id": 2, "result": {}})); + println!("✅ POST /mcp ping"); +} + +#[tokio::test] +async fn test_mcp_ping_notification_returns_no_content() { + let (base, client, _server) = spawn_server().await; + let uid = uid(); + + let response = client + .post(format!("{base}/mcp")) + .header("Content-Type", "application/json") + .header("X-User-Id", uid) + .body(r#"{"jsonrpc":"2.0","method":"ping"}"#) + .send() + .await + .expect("POST /mcp ping notification"); + + assert_eq!(response.status(), reqwest::StatusCode::NO_CONTENT); + assert!(response + .text() + .await + .expect("read ping notification response") + .is_empty()); +} + #[tokio::test] async fn test_mcp_tools_list() { let (base, client, _server) = spawn_server().await; diff --git a/memoria/crates/memoria-mcp/src/server.rs b/memoria/crates/memoria-mcp/src/server.rs index 41cdc1a..7d38edd 100644 --- a/memoria/crates/memoria-mcp/src/server.rs +++ b/memoria/crates/memoria-mcp/src/server.rs @@ -46,12 +46,24 @@ struct Response { enum RpcMethod { Initialize, + Ping, ToolsList, ToolsCall, NotificationsInitialized, Unknown(String), } +fn parse_rpc_method(method: &str) -> RpcMethod { + match method { + "initialize" => RpcMethod::Initialize, + "ping" => RpcMethod::Ping, + "tools/list" => RpcMethod::ToolsList, + "tools/call" => RpcMethod::ToolsCall, + "notifications/initialized" => RpcMethod::NotificationsInitialized, + _ => RpcMethod::Unknown(method.to_string()), + } +} + const GIT_TOOL_NAMES: &[&str] = &[ "memory_snapshot", "memory_snapshots", @@ -291,19 +303,14 @@ async fn dispatch( user_id: &str, ) -> Result { let p = params.unwrap_or(Value::Null); - let method = match method { - "initialize" => RpcMethod::Initialize, - "tools/list" => RpcMethod::ToolsList, - "tools/call" => RpcMethod::ToolsCall, - "notifications/initialized" => RpcMethod::NotificationsInitialized, - _ => RpcMethod::Unknown(method.to_string()), - }; + let method = parse_rpc_method(method); match method { RpcMethod::Initialize => Ok(json!({ "protocolVersion": "2024-11-05", "capabilities": {"tools": {}}, "serverInfo": {"name": "memoria-mcp-rs", "version": "0.1.0"} })), + RpcMethod::Ping => Ok(json!({})), RpcMethod::ToolsList => { let mut all_tools = tools::list().as_array().unwrap().clone(); all_tools.extend(git_tools::list().as_array().unwrap().clone()); @@ -350,19 +357,14 @@ async fn dispatch_embedded_owned( user_id: String, ) -> Result { let p = params.unwrap_or(Value::Null); - let method = match method.as_str() { - "initialize" => RpcMethod::Initialize, - "tools/list" => RpcMethod::ToolsList, - "tools/call" => RpcMethod::ToolsCall, - "notifications/initialized" => RpcMethod::NotificationsInitialized, - _ => RpcMethod::Unknown(method), - }; + let method = parse_rpc_method(&method); match method { RpcMethod::Initialize => Ok(json!({ "protocolVersion": "2024-11-05", "capabilities": {"tools": {}}, "serverInfo": {"name": "memoria-mcp-rs", "version": "0.1.0"} })), + RpcMethod::Ping => Ok(json!({})), RpcMethod::ToolsList => { let mut all_tools = tools::list().as_array().unwrap().clone(); all_tools.extend(git_tools::list().as_array().unwrap().clone()); @@ -398,7 +400,31 @@ async fn dispatch_embedded_owned( #[cfg(test)] mod tests { - use super::{is_git_tool, GIT_TOOL_NAMES}; + use super::{ + dispatch, is_git_tool, parse_rpc_method, Mode, RemoteClient, RpcMethod, GIT_TOOL_NAMES, + }; + use serde_json::json; + + #[test] + fn ping_is_a_known_rpc_method() { + assert!(matches!(parse_rpc_method("ping"), RpcMethod::Ping)); + } + + #[tokio::test] + async fn ping_returns_an_empty_result_without_calling_the_backend() { + let mode = Mode::Remote(RemoteClient::new( + "http://127.0.0.1:1", + None, + "test-user".to_string(), + None, + )); + + let result = dispatch("ping", None, &mode, "test-user") + .await + .expect("ping should succeed"); + + assert_eq!(result, json!({})); + } #[test] fn git_dispatch_list_includes_memory_apply() {