use serde_json::Value;
use super::{error_codes, McpServer, Request};
fn req(method: &str, params: Value) -> Request {
Request {
jsonrpc: Some("2.0".into()),
id: Some(Value::from(1u64)),
method: method.into(),
params: Some(params),
}
}
#[tokio::test]
async fn search_tool_empty_query_surfaces_as_invalid_params() {
use axum::routing::post;
use axum::{Json, Router};
use tokio::sync::oneshot;
async fn bad_search(Json(_body): Json<Value>) -> (axum::http::StatusCode, Json<Value>) {
(
axum::http::StatusCode::BAD_REQUEST,
Json(serde_json::json!({ "error": "query must not be empty" })),
)
}
let app = Router::new().route("/indexes/demo/search", post(bad_search));
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>();
let handle = tokio::spawn(async move {
let _ = axum::serve(listener, app)
.with_graceful_shutdown(async {
shutdown_rx.await.ok();
})
.await;
});
let server = McpServer::new(format!("http://{addr}"));
let resp = server
.dispatch(req(
"search",
serde_json::json!({ "index_id": "demo", "query": "" }),
))
.await;
let err = resp.error.expect("expected JSON-RPC error for empty query");
assert_eq!(
err.code,
error_codes::INVALID_PARAMS,
"empty query must map to INVALID_PARAMS, got code={}",
err.code
);
assert!(
err.message.contains("empty"),
"error message must mention 'empty': {}",
err.message
);
let resp = server
.dispatch(req(
"tools/call",
serde_json::json!({
"name": "search",
"arguments": { "index_id": "demo", "query": " " }
}),
))
.await;
let result = resp.result.expect("tools/call must return result envelope");
assert_eq!(
result["isError"], true,
"whitespace-only query must return isError=true"
);
let _ = shutdown_tx.send(());
let _ = handle.await;
}
#[tokio::test]
async fn search_lexical_empty_query_surfaces_as_invalid_params() {
use axum::routing::{get, post};
use axum::{extract::Path, Json, Router};
use tokio::sync::oneshot;
async fn bad_search(
Path(_id): Path<String>,
Json(_body): Json<Value>,
) -> (axum::http::StatusCode, Json<Value>) {
(
axum::http::StatusCode::BAD_REQUEST,
Json(serde_json::json!({ "error": "query must not be empty" })),
)
}
async fn status_ok(Path(id): Path<String>) -> Json<Value> {
Json(serde_json::json!({
"index_id": id,
"search_capabilities": ["bm25", "literal"],
"stages": {
"lexical": { "status": "ready" },
"semantic": { "status": "pending" },
"graph": { "status": "pending" },
}
}))
}
let app = Router::new()
.route("/indexes/{id}/search", post(bad_search))
.route("/indexes/{id}/status", get(status_ok));
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>();
let handle = tokio::spawn(async move {
let _ = axum::serve(listener, app)
.with_graceful_shutdown(async {
shutdown_rx.await.ok();
})
.await;
});
let server = McpServer::new(format!("http://{addr}"));
let resp = server
.dispatch(req(
"search_lexical",
serde_json::json!({ "index_id": "demo", "query": "" }),
))
.await;
let err = resp.error.expect("expected JSON-RPC error");
assert_eq!(err.code, error_codes::INVALID_PARAMS);
let resp = server
.dispatch(req(
"tools/call",
serde_json::json!({
"name": "search_lexical",
"arguments": { "index_id": "demo", "query": " " }
}),
))
.await;
let result = resp.result.expect("result envelope");
assert_eq!(result["isError"], true);
let _ = shutdown_tx.send(());
let _ = handle.await;
}
#[tokio::test]
async fn search_semantic_empty_query_surfaces_as_invalid_params() {
use axum::routing::{get, post};
use axum::{extract::Path, Json, Router};
use tokio::sync::oneshot;
async fn bad_search(
Path(_id): Path<String>,
Json(_body): Json<Value>,
) -> (axum::http::StatusCode, Json<Value>) {
(
axum::http::StatusCode::BAD_REQUEST,
Json(serde_json::json!({ "error": "query must not be empty" })),
)
}
async fn status_vector_ready(Path(id): Path<String>) -> Json<Value> {
Json(serde_json::json!({
"index_id": id,
"search_capabilities": ["bm25", "literal", "vector"],
"stages": {
"lexical": { "status": "ready" },
"semantic": { "status": "ready" },
"graph": { "status": "pending" },
}
}))
}
let app = Router::new()
.route("/indexes/{id}/search", post(bad_search))
.route("/indexes/{id}/status", get(status_vector_ready));
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>();
let handle = tokio::spawn(async move {
let _ = axum::serve(listener, app)
.with_graceful_shutdown(async {
shutdown_rx.await.ok();
})
.await;
});
let server = McpServer::new(format!("http://{addr}"));
let resp = server
.dispatch(req(
"search_semantic",
serde_json::json!({ "index_id": "demo", "query": "" }),
))
.await;
let err = resp.error.expect("expected JSON-RPC error");
assert_eq!(
err.code,
error_codes::INVALID_PARAMS,
"search_semantic empty query must map to INVALID_PARAMS, got code={}",
err.code
);
let resp = server
.dispatch(req(
"tools/call",
serde_json::json!({
"name": "search_semantic",
"arguments": { "index_id": "demo", "query": " " }
}),
))
.await;
let result = resp.result.expect("tools/call must return result envelope");
assert_eq!(
result["isError"], true,
"search_semantic whitespace-only query must return isError=true"
);
let _ = shutdown_tx.send(());
let _ = handle.await;
}
#[tokio::test]
async fn search_all_empty_query_surfaces_as_invalid_params() {
use axum::routing::post;
use axum::{extract::Path, Json, Router};
use tokio::sync::oneshot;
async fn bad_search(
Path(_id): Path<String>,
Json(_body): Json<Value>,
) -> (axum::http::StatusCode, Json<Value>) {
(
axum::http::StatusCode::BAD_REQUEST,
Json(serde_json::json!({ "error": "query must not be empty" })),
)
}
let app = Router::new().route("/indexes/{id}/search", post(bad_search));
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>();
let handle = tokio::spawn(async move {
let _ = axum::serve(listener, app)
.with_graceful_shutdown(async {
shutdown_rx.await.ok();
})
.await;
});
let server = McpServer::new(format!("http://{addr}"));
let resp = server
.dispatch(req(
"search_all",
serde_json::json!({ "index_id": "demo", "query": "" }),
))
.await;
let err = resp.error.expect("expected JSON-RPC error");
assert_eq!(
err.code,
error_codes::INVALID_PARAMS,
"search_all empty query must map to INVALID_PARAMS, got code={}",
err.code
);
let resp = server
.dispatch(req(
"tools/call",
serde_json::json!({
"name": "search_all",
"arguments": { "index_id": "demo", "query": " " }
}),
))
.await;
let result = resp.result.expect("tools/call must return result envelope");
assert_eq!(
result["isError"], true,
"search_all whitespace-only query must return isError=true"
);
let _ = shutdown_tx.send(());
let _ = handle.await;
}