feat: expose data-rich household tools over stdio MCP
This commit is contained in:
61
tests/mcp_protocol.rs
Normal file
61
tests/mcp_protocol.rs
Normal 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
52
tests/support/mod.rs
Normal 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(),
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user