use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::cosmos::CosmosBackend;
use crate::mcp::error::McpError;
use crate::mcp::expose::ExposeResolver;
#[derive(Debug, Deserialize, JsonSchema)]
pub struct GetRequest {
pub data_source: String,
pub id: String,
#[serde(default)]
pub include_deleted: bool,
}
#[derive(Debug, Serialize)]
pub struct GetResponse {
pub document: Option<Value>,
}
pub async fn run(
cosmos: &dyn CosmosBackend,
expose: &ExposeResolver,
req: GetRequest,
) -> Result<GetResponse, McpError> {
let resolved = expose.resolve(&req.data_source)?;
for backing in &resolved.backed_by {
let sql = "SELECT * FROM c WHERE c.id = @id";
let params = vec![("@id".to_string(), Value::String(req.id.clone()))];
let mut stream = cosmos.query(&backing.container, sql, params).await?;
if let Some(page) = stream.next_page().await? {
for doc in page {
let is_deleted = doc
.get("_deleted")
.and_then(Value::as_bool)
.unwrap_or(false);
if is_deleted && !req.include_deleted {
continue;
}
return Ok(GetResponse {
document: Some(doc),
});
}
}
}
Ok(GetResponse { document: None })
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cosmos::InMemoryCosmos;
use crate::mcp::tools::test_helpers::build_expose_jira_issues;
use serde_json::json;
async fn make_cosmos_with_doc(id: &str, deleted: bool) -> InMemoryCosmos {
let cosmos = InMemoryCosmos::new();
let mut doc = json!({
"id": id,
"_partition_key": "DO",
"status": "Open",
});
if deleted {
doc["_deleted"] = json!(true);
}
cosmos.upsert("jira-issues", doc).await.unwrap();
cosmos
}
#[tokio::test]
async fn get_returns_document_when_found() {
let cosmos = make_cosmos_with_doc("DO-1", false).await;
let expose = build_expose_jira_issues();
let req = GetRequest {
data_source: "jira_issues".into(),
id: "DO-1".into(),
include_deleted: false,
};
let resp = run(&cosmos, &expose, req).await.unwrap();
assert!(resp.document.is_some());
assert_eq!(resp.document.unwrap()["id"], "DO-1");
}
#[tokio::test]
async fn get_returns_none_for_missing_document() {
let cosmos = make_cosmos_with_doc("DO-1", false).await;
let expose = build_expose_jira_issues();
let req = GetRequest {
data_source: "jira_issues".into(),
id: "DO-9999".into(),
include_deleted: false,
};
let resp = run(&cosmos, &expose, req).await.unwrap();
assert!(resp.document.is_none());
}
#[tokio::test]
async fn get_returns_null_for_soft_deleted_by_default() {
let cosmos = make_cosmos_with_doc("DO-2", true).await;
let expose = build_expose_jira_issues();
let req = GetRequest {
data_source: "jira_issues".into(),
id: "DO-2".into(),
include_deleted: false,
};
let resp = run(&cosmos, &expose, req).await.unwrap();
assert!(
resp.document.is_none(),
"soft-deleted doc should not be returned"
);
}
#[tokio::test]
async fn get_returns_soft_deleted_when_include_deleted_set() {
let cosmos = make_cosmos_with_doc("DO-2", true).await;
let expose = build_expose_jira_issues();
let req = GetRequest {
data_source: "jira_issues".into(),
id: "DO-2".into(),
include_deleted: true,
};
let resp = run(&cosmos, &expose, req).await.unwrap();
assert!(
resp.document.is_some(),
"soft-deleted doc should be returned with include_deleted=true"
);
}
#[tokio::test]
async fn get_forbidden_for_unexposed_data_source() {
let cosmos = make_cosmos_with_doc("DO-1", false).await;
let expose = build_expose_jira_issues(); let req = GetRequest {
data_source: "confluence_pages".into(),
id: "some-id".into(),
include_deleted: false,
};
let err = run(&cosmos, &expose, req).await.unwrap_err();
assert!(matches!(err, McpError::Forbidden(_)));
}
#[tokio::test]
async fn get_searches_multiple_containers() {
let cosmos = InMemoryCosmos::new();
cosmos
.upsert(
"jira-issues-2",
json!({"id": "DO-5", "_partition_key": "DO", "status": "Done"}),
)
.await
.unwrap();
use crate::config::data_sources::{BackedBy, ResolvedDataSource};
use crate::mcp::expose::ExposeResolver;
use std::collections::HashMap;
let mut map = HashMap::new();
map.insert(
"jira_issues".to_string(),
ResolvedDataSource {
kind: "jira_issue".to_string(),
backed_by: vec![
BackedBy {
container: "jira-issues-1".to_string(),
},
BackedBy {
container: "jira-issues-2".to_string(),
},
],
},
);
let expose = ExposeResolver::from_map(map);
let req = GetRequest {
data_source: "jira_issues".into(),
id: "DO-5".into(),
include_deleted: false,
};
let resp = run(&cosmos, &expose, req).await.unwrap();
assert!(resp.document.is_some());
assert_eq!(resp.document.unwrap()["id"], "DO-5");
}
}