feat: expose data-rich household tools over stdio MCP

This commit is contained in:
LucaPisl
2026-10-01 01:19:52 +03:00
parent f604ef84a4
commit 959eee2a10
4 changed files with 339 additions and 0 deletions

61
tests/mcp_protocol.rs Normal file
View File

@@ -0,0 +1,61 @@
mod support;
use hermes_grocy_mcp::{mcp::McpServer, model::AccessMode};
use rmcp::{ServiceExt, model::CallToolRequestParams};
use serde_json::json;
use std::sync::Arc;
use support::{MemoryStore, selection};
#[tokio::test]
async fn protocol_returns_data_and_policy() {
let app = axum::Router::new().fallback(|| async { axum::Json(json!([{"id":7,"amount":"2"}])) });
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
let api = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
for mode in [AccessMode::Full, AccessMode::ReadOnly] {
let store = Arc::new(MemoryStore {
url: format!("http://127.0.0.1:{port}"),
access: mode,
key: "synthetic-key".into(),
});
let server = McpServer::new(store, selection(), mode);
let (a, b) = tokio::io::duplex(65536);
let serving =
tokio::spawn(async move { server.serve(a).await.unwrap().waiting().await.unwrap() });
let client = ().serve(b).await.unwrap();
let tools = client.list_all_tools().await.unwrap();
assert!(tools.len() > 20);
if mode == AccessMode::ReadOnly {
assert!(!tools.iter().any(|t| t.name == "records_delete"));
} else {
let delete = tools.iter().find(|t| t.name == "records_delete").unwrap();
assert_eq!(
delete.annotations.as_ref().unwrap().destructive_hint,
Some(true)
);
}
let result = client
.call_tool(
CallToolRequestParams::new("stock_list")
.with_arguments(json!({}).as_object().unwrap().clone()),
)
.await
.unwrap();
let text = result.content[0].as_text().unwrap();
let data: serde_json::Value = serde_json::from_str(&text.text).unwrap();
assert_eq!(Some(data.clone()), result.structured_content);
assert_eq!(data["data"]["items"][0]["id"], 7);
assert_ne!(result.is_error, Some(true));
let invalid = client
.call_tool(CallToolRequestParams::new("not_a_tool"))
.await
.unwrap();
assert_eq!(invalid.is_error, Some(true));
assert!(
!serde_json::to_string(&invalid)
.unwrap()
.contains("synthetic-key")
);
client.cancel().await.unwrap();
serving.await.unwrap();
}
api.abort();
}

52
tests/support/mod.rs Normal file
View File

@@ -0,0 +1,52 @@
use futures_util::future::BoxFuture;
use hermes_grocy_mcp::{
client::ApiBase,
credentials::{CredentialStore, EnrollmentConsent},
error::{AppError, Result},
model::{AccessMode, PrivateProfile, ProfileSelection, ProviderIdentity, TransportPolicy},
};
use serde_json::json;
pub struct MemoryStore {
pub url: String,
pub access: AccessMode,
pub key: String,
}
impl CredentialStore for MemoryStore {
fn load(&self, _s: ProfileSelection) -> BoxFuture<'static, Result<PrivateProfile>> {
let url = self.url.clone();
let access = self.access;
let key = self.key.clone();
Box::pin(async move {
Ok(PrivateProfile {
api_base: ApiBase::parse(&url, TransportPolicy::LoopbackDevelopment)?,
api_key: secrecy::SecretString::from(key),
access,
timezone: Some("UTC".into()),
defaults: json!({}),
ca_path: None,
transport: TransportPolicy::LoopbackDevelopment,
unsafe_storage: false,
})
})
}
fn put(
&self,
_s: ProfileSelection,
_p: PrivateProfile,
_c: EnrollmentConsent,
) -> BoxFuture<'static, Result<()>> {
Box::pin(async { Err(AppError::input()) })
}
fn forget(&self, _s: ProfileSelection) -> BoxFuture<'static, Result<()>> {
Box::pin(async { Err(AppError::input()) })
}
}
pub fn selection() -> ProfileSelection {
ProfileSelection {
profile: "synthetic".into(),
provider: ProviderIdentity {
bus_name: "org.freedesktop.secrets".into(),
executable: "/test/wallet".into(),
},
}
}