entity_graph_mcp/
server.rs

1//! MCP server implementation.
2//!
3//! `EntityGraphServer` implements [`rmcp::ServerHandler`] and exposes:
4//!
5//! **Resources**
6//! - Template: `entity://{entityType}/{entityId}` — read a single entity
7//! - Template: `entity://{entityType}` — list all entities of a type
8//!
9//! **Tools** (registered via `#[tool_router]` + `#[tool_handler]`)
10//! - `entity_list_types`  — list registered entity type names
11//! - `entity_query`       — query entities by type with optional field filters
12//! - `entity_upsert`      — insert or merge-update an entity
13//! - `entity_delete`      — remove an entity from the graph
14
15use 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// ── Tool parameter types ──────────────────────────────────────────────────────
33
34/// Parameters for `entity_query`.
35#[derive(Debug, Deserialize, schemars::JsonSchema)]
36#[serde(rename_all = "camelCase")]
37pub struct EntityQueryParams {
38    /// The entity type to query (e.g. `"Invoice"`, `"User"`).
39    pub entity_type: String,
40
41    /// Optional map of field → value for equality filtering.
42    /// All provided fields must match (AND semantics).
43    #[serde(default)]
44    pub filter: Option<serde_json::Map<String, Value>>,
45
46    /// Maximum number of results to return. Defaults to 100.
47    #[serde(default = "default_limit")]
48    pub limit: usize,
49}
50
51fn default_limit() -> usize {
52    100
53}
54
55/// Parameters for `entity_upsert`.
56#[derive(Debug, Deserialize, schemars::JsonSchema)]
57#[serde(rename_all = "camelCase")]
58pub struct EntityUpsertParams {
59    /// The entity type (e.g. `"Invoice"`).
60    pub entity_type: String,
61
62    /// The entity's unique identifier.
63    pub id: String,
64
65    /// JSON object of fields to insert or merge into the entity.
66    pub data: serde_json::Map<String, Value>,
67}
68
69/// Parameters for `entity_delete`.
70#[derive(Debug, Deserialize, schemars::JsonSchema)]
71#[serde(rename_all = "camelCase")]
72pub struct EntityDeleteParams {
73    /// The entity type.
74    pub entity_type: String,
75
76    /// The entity's unique identifier.
77    pub id: String,
78}
79
80// ── Server struct + tools ─────────────────────────────────────────────────────
81
82/// The entity-graph MCP server.
83///
84/// Mount this with `rmcp::serve_server` (stdio) or
85/// `transport::run_http` (Streamable HTTP over Axum).
86#[derive(Clone)]
87pub struct EntityGraphServer {
88    pub(crate) store: EntityStore,
89}
90
91/// Step 1: define tools; generates `EntityGraphServer::tool_router()`.
92#[tool_router]
93impl EntityGraphServer {
94    /// Create a new server backed by the given entity store.
95    pub fn new(store: EntityStore) -> Self {
96        Self { store }
97    }
98
99    // ── Tools ─────────────────────────────────────────────────────────────────
100
101    /// List all entity type names registered in the graph.
102    #[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    /// Query entities by type with optional field-equality filters.
109    #[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(&params.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    /// Insert or merge-update an entity in the graph.
130    #[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(&params.entity_type, &params.id, params.data);
138        serde_json::to_string_pretty(&merged).unwrap_or_else(|_| "{}".to_owned())
139    }
140
141    /// Delete an entity from the graph.
142    #[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(&params.entity_type, &params.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
157// ── Resource helpers (pure, no MCP context needed) ────────────────────────────
158
159impl EntityGraphServer {
160    /// Read a resource by URI — internal helper, testable without a live MCP
161    /// `RequestContext`.
162    pub(crate) fn read_resource_by_uri(&self, uri: &str) -> Result<ReadResourceResult, McpError> {
163        // Strip the "entity://" scheme prefix.
164        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            // Single entity: entity://<type>/<id>
173            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            // All entities of type: entity://<type>
181            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// ── ServerHandler — Step 2: delegate tools + add resources ───────────────────
202
203/// `#[tool_handler]` injects `call_tool` and `list_tools` from the generated
204/// `tool_router()`.  We override the remaining `ServerHandler` methods
205/// (`initialize`, `list_resources`, `list_resource_templates`, `read_resource`)
206/// as plain `async fn` per clippy's `manual_async_fn` lint.
207#[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// ── Tests ─────────────────────────────────────────────────────────────────────
312
313#[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}