1use rmcp::{
16 handler::server::wrapper::Parameters,
17 model::{
18 Implementation, InitializeRequestParams, InitializeResult, ListResourceTemplatesResult,
19 ListResourcesResult, PaginatedRequestParams, RawResource, RawResourceTemplate,
20 ReadResourceRequestParams, ReadResourceResult, Resource, ResourceContents,
21 ResourceTemplate, ServerCapabilities,
22 },
23 schemars,
24 serde_json::{self, json, Value},
25 service::RequestContext,
26 tool, tool_handler, tool_router, ErrorData as McpError, RoleServer, ServerHandler,
27};
28use serde::Deserialize;
29
30use crate::store::{EntityData, EntityStore};
31
32#[derive(Debug, Deserialize, schemars::JsonSchema)]
36#[serde(rename_all = "camelCase")]
37pub struct EntityQueryParams {
38 pub entity_type: String,
40
41 #[serde(default)]
44 pub filter: Option<serde_json::Map<String, Value>>,
45
46 #[serde(default = "default_limit")]
48 pub limit: usize,
49}
50
51fn default_limit() -> usize {
52 100
53}
54
55#[derive(Debug, Deserialize, schemars::JsonSchema)]
57#[serde(rename_all = "camelCase")]
58pub struct EntityUpsertParams {
59 pub entity_type: String,
61
62 pub id: String,
64
65 pub data: serde_json::Map<String, Value>,
67}
68
69#[derive(Debug, Deserialize, schemars::JsonSchema)]
71#[serde(rename_all = "camelCase")]
72pub struct EntityDeleteParams {
73 pub entity_type: String,
75
76 pub id: String,
78}
79
80#[derive(Clone)]
87pub struct EntityGraphServer {
88 pub(crate) store: EntityStore,
89}
90
91#[tool_router]
93impl EntityGraphServer {
94 pub fn new(store: EntityStore) -> Self {
96 Self { store }
97 }
98
99 #[tool(description = "List all entity type names registered in the entity graph.")]
103 async fn entity_list_types(&self) -> String {
104 let types = self.store.list_types();
105 serde_json::to_string_pretty(&types).unwrap_or_else(|_| "[]".to_owned())
106 }
107
108 #[tool(description = "Query entities from the graph by type. \
110 Supports optional field equality filtering (AND semantics).")]
111 async fn entity_query(&self, Parameters(params): Parameters<EntityQueryParams>) -> String {
112 let entities = self.store.list_entities(¶ms.entity_type);
113
114 let filtered: Vec<&EntityData> = entities
115 .iter()
116 .filter(|(_, data)| {
117 params
118 .filter
119 .as_ref()
120 .is_none_or(|f| f.iter().all(|(k, v)| data.get(k) == Some(v)))
121 })
122 .take(params.limit)
123 .map(|(_, data)| data)
124 .collect();
125
126 serde_json::to_string_pretty(&filtered).unwrap_or_else(|_| "[]".to_owned())
127 }
128
129 #[tool(
131 description = "Insert or merge-update an entity in the graph (shallow merge). \
132 Returns the final merged entity."
133 )]
134 async fn entity_upsert(&self, Parameters(params): Parameters<EntityUpsertParams>) -> String {
135 let merged = self
136 .store
137 .upsert(¶ms.entity_type, ¶ms.id, params.data);
138 serde_json::to_string_pretty(&merged).unwrap_or_else(|_| "{}".to_owned())
139 }
140
141 #[tool(description = "Delete an entity from the graph by type and id. \
143 Returns the removed entity if it existed.")]
144 async fn entity_delete(&self, Parameters(params): Parameters<EntityDeleteParams>) -> String {
145 match self.store.delete(¶ms.entity_type, ¶ms.id) {
146 Some(data) => serde_json::to_string_pretty(&data).unwrap_or_else(|_| "{}".to_owned()),
147 None => json!({
148 "deleted": false,
149 "entityType": params.entity_type,
150 "id": params.id
151 })
152 .to_string(),
153 }
154 }
155}
156
157impl EntityGraphServer {
160 pub(crate) fn read_resource_by_uri(&self, uri: &str) -> Result<ReadResourceResult, McpError> {
163 let path = uri.strip_prefix("entity://").ok_or_else(|| {
165 McpError::invalid_params(format!("Unsupported resource URI scheme: {uri}"), None)
166 })?;
167
168 let parts: Vec<&str> = path.splitn(2, '/').collect();
169 let entity_type = parts[0];
170
171 let text = if parts.len() == 2 {
172 let id = parts[1];
174 let data = self
175 .store
176 .get(entity_type, id)
177 .map_err(|e| McpError::resource_not_found(e.to_string(), None))?;
178 serde_json::to_string_pretty(&data).unwrap_or_else(|_| "{}".to_owned())
179 } else {
180 let entities: Vec<EntityData> = self
182 .store
183 .list_entities(entity_type)
184 .into_iter()
185 .map(|(_, data)| data)
186 .collect();
187 serde_json::to_string_pretty(&entities).unwrap_or_else(|_| "[]".to_owned())
188 };
189
190 Ok(ReadResourceResult::new(vec![
191 ResourceContents::TextResourceContents {
192 uri: uri.to_owned(),
193 mime_type: Some("application/json".to_owned()),
194 text,
195 meta: None,
196 },
197 ]))
198 }
199}
200
201#[tool_handler]
208impl ServerHandler for EntityGraphServer {
209 async fn initialize(
210 &self,
211 _request: InitializeRequestParams,
212 _context: RequestContext<RoleServer>,
213 ) -> Result<InitializeResult, McpError> {
214 Ok(InitializeResult::new(
215 ServerCapabilities::builder()
216 .enable_tools()
217 .enable_resources()
218 .build(),
219 )
220 .with_server_info(Implementation::new(
221 env!("CARGO_PKG_NAME"),
222 env!("CARGO_PKG_VERSION"),
223 ))
224 .with_instructions(
225 "Entity-graph MCP server. \
226 Resources: entity://{entityType}/{entityId} (single entity) \
227 and entity://{entityType} (all entities of a type). \
228 Tools: entity_list_types, entity_query, entity_upsert, entity_delete.",
229 ))
230 }
231
232 async fn list_resource_templates(
233 &self,
234 _request: Option<PaginatedRequestParams>,
235 _context: RequestContext<RoleServer>,
236 ) -> Result<ListResourceTemplatesResult, McpError> {
237 Ok(ListResourceTemplatesResult {
238 meta: None,
239 resource_templates: vec![
240 ResourceTemplate {
241 raw: RawResourceTemplate {
242 uri_template: "entity://{entityType}/{entityId}".to_owned(),
243 name: "Entity by type and id".to_owned(),
244 title: Some("Single entity".to_owned()),
245 description: Some(
246 "Read a single entity. URI: entity://<type>/<id>".to_owned(),
247 ),
248 mime_type: Some("application/json".to_owned()),
249 icons: None,
250 },
251 annotations: None,
252 },
253 ResourceTemplate {
254 raw: RawResourceTemplate {
255 uri_template: "entity://{entityType}".to_owned(),
256 name: "Entities by type".to_owned(),
257 title: Some("All entities of a type".to_owned()),
258 description: Some(
259 "List all entities of a type. URI: entity://<type>".to_owned(),
260 ),
261 mime_type: Some("application/json".to_owned()),
262 icons: None,
263 },
264 annotations: None,
265 },
266 ],
267 next_cursor: None,
268 })
269 }
270
271 async fn list_resources(
272 &self,
273 _request: Option<PaginatedRequestParams>,
274 _context: RequestContext<RoleServer>,
275 ) -> Result<ListResourcesResult, McpError> {
276 let resources: Vec<Resource> = self
277 .store
278 .list_types()
279 .into_iter()
280 .map(|entity_type| Resource {
281 raw: RawResource {
282 uri: format!("entity://{entity_type}"),
283 name: entity_type.clone(),
284 title: Some(format!("{entity_type} entities")),
285 description: Some(format!("All entities of type {entity_type} in the graph.")),
286 mime_type: Some("application/json".to_owned()),
287 size: None,
288 icons: None,
289 meta: None,
290 },
291 annotations: None,
292 })
293 .collect();
294
295 Ok(ListResourcesResult {
296 meta: None,
297 resources,
298 next_cursor: None,
299 })
300 }
301
302 async fn read_resource(
303 &self,
304 request: ReadResourceRequestParams,
305 _context: RequestContext<RoleServer>,
306 ) -> Result<ReadResourceResult, McpError> {
307 self.read_resource_by_uri(&request.uri)
308 }
309}
310
311#[cfg(test)]
314mod tests {
315 use super::*;
316 use serde_json::json;
317
318 fn make_server() -> EntityGraphServer {
319 let store = EntityStore::new();
320 store.seed(
321 "Invoice",
322 vec![
323 (
324 "inv_1".into(),
325 json!({"id": "inv_1", "amount": 100, "status": "draft"})
326 .as_object()
327 .cloned()
328 .unwrap(),
329 ),
330 (
331 "inv_2".into(),
332 json!({"id": "inv_2", "amount": 200, "status": "paid"})
333 .as_object()
334 .cloned()
335 .unwrap(),
336 ),
337 ],
338 );
339 EntityGraphServer::new(store)
340 }
341
342 #[tokio::test]
343 async fn entity_list_types_returns_types() {
344 let server = make_server();
345 let result = server.entity_list_types().await;
346 assert!(result.contains("Invoice"), "expected 'Invoice' in {result}");
347 }
348
349 #[tokio::test]
350 async fn entity_query_no_filter_returns_all() {
351 let server = make_server();
352 let params = EntityQueryParams {
353 entity_type: "Invoice".into(),
354 filter: None,
355 limit: 100,
356 };
357 let result = server.entity_query(Parameters(params)).await;
358 let parsed: Vec<Value> = serde_json::from_str(&result).unwrap();
359 assert_eq!(parsed.len(), 2);
360 }
361
362 #[tokio::test]
363 async fn entity_query_with_filter_returns_subset() {
364 let server = make_server();
365 let mut filter = serde_json::Map::new();
366 filter.insert("status".into(), json!("paid"));
367 let params = EntityQueryParams {
368 entity_type: "Invoice".into(),
369 filter: Some(filter),
370 limit: 100,
371 };
372 let result = server.entity_query(Parameters(params)).await;
373 let parsed: Vec<Value> = serde_json::from_str(&result).unwrap();
374 assert_eq!(parsed.len(), 1);
375 assert_eq!(parsed[0]["status"], json!("paid"));
376 }
377
378 #[tokio::test]
379 async fn entity_upsert_and_delete_roundtrip() {
380 let store = EntityStore::new();
381 let server = EntityGraphServer::new(store);
382
383 let upsert_result = server
384 .entity_upsert(Parameters(EntityUpsertParams {
385 entity_type: "Company".into(),
386 id: "co_1".into(),
387 data: json!({"name": "Acme"}).as_object().cloned().unwrap(),
388 }))
389 .await;
390 let obj: Value = serde_json::from_str(&upsert_result).unwrap();
391 assert_eq!(obj["name"], json!("Acme"));
392
393 let del_result = server
394 .entity_delete(Parameters(EntityDeleteParams {
395 entity_type: "Company".into(),
396 id: "co_1".into(),
397 }))
398 .await;
399 let del_obj: Value = serde_json::from_str(&del_result).unwrap();
400 assert_eq!(del_obj["name"], json!("Acme"));
401 }
402
403 #[test]
404 fn read_resource_by_uri_single_entity() {
405 let store = EntityStore::new();
406 store.seed(
407 "Company",
408 vec![(
409 "co_1".into(),
410 json!({"name": "Acme"}).as_object().cloned().unwrap(),
411 )],
412 );
413 let server = EntityGraphServer::new(store);
414
415 let result = server
416 .read_resource_by_uri("entity://Company/co_1")
417 .unwrap();
418 let text = match &result.contents[0] {
419 ResourceContents::TextResourceContents { text, .. } => text.clone(),
420 _ => panic!("expected text"),
421 };
422 let obj: Value = serde_json::from_str(&text).unwrap();
423 assert_eq!(obj["name"], json!("Acme"));
424 }
425
426 #[test]
427 fn read_resource_by_uri_type_list() {
428 let store = EntityStore::new();
429 store.seed(
430 "Company",
431 vec![
432 (
433 "co_1".into(),
434 json!({"name": "A"}).as_object().cloned().unwrap(),
435 ),
436 (
437 "co_2".into(),
438 json!({"name": "B"}).as_object().cloned().unwrap(),
439 ),
440 ],
441 );
442 let server = EntityGraphServer::new(store);
443
444 let result = server.read_resource_by_uri("entity://Company").unwrap();
445 let text = match &result.contents[0] {
446 ResourceContents::TextResourceContents { text, .. } => text.clone(),
447 _ => panic!("expected text"),
448 };
449 let arr: Vec<Value> = serde_json::from_str(&text).unwrap();
450 assert_eq!(arr.len(), 2);
451 }
452
453 #[test]
454 fn read_resource_by_uri_unknown_scheme_returns_error() {
455 let server = EntityGraphServer::new(EntityStore::new());
456 let err = server
457 .read_resource_by_uri("file:///etc/passwd")
458 .unwrap_err();
459 assert!(err.message.contains("Unsupported resource URI scheme"));
460 }
461
462 #[test]
463 fn read_resource_by_uri_missing_entity_returns_not_found() {
464 let server = EntityGraphServer::new(EntityStore::new());
465 let err = server
466 .read_resource_by_uri("entity://Company/nonexistent")
467 .unwrap_err();
468 assert_eq!(err.code, rmcp::model::ErrorCode::RESOURCE_NOT_FOUND);
469 }
470}