Skip to main content

phago_rag/
mcp.rs

1//! MCP Adapter — Model Context Protocol interface for Phago.
2//!
3//! Provides three core tools for external LLMs/agents to interact
4//! with the biological knowledge graph:
5//!
6//! - `phago_remember`: Ingest text into the colony (document → digestion → graph)
7//! - `phago_recall`: Query the knowledge graph with hybrid scoring
8//! - `phago_explore`: Structural queries (paths, bridges, centrality, components)
9//!
10//! All operations use serializable request/response types compatible
11//! with JSON-RPC or any other transport layer.
12
13use phago_core::topology::TopologyGraph;
14use phago_core::types::*;
15use phago_runtime::colony::Colony;
16use serde::{Deserialize, Serialize};
17
18// === phago_remember ===
19
20#[derive(Debug, Deserialize)]
21pub struct RememberRequest {
22    pub title: String,
23    pub content: String,
24    #[serde(default)]
25    pub ticks: Option<u64>,
26}
27
28#[derive(Debug, Serialize)]
29pub struct RememberResponse {
30    pub document_id: String,
31    pub nodes_created: usize,
32    pub edges_created: usize,
33    pub tick: u64,
34}
35
36/// Ingest a document into the colony and run digestion.
37pub fn phago_remember(colony: &mut Colony, req: &RememberRequest) -> RememberResponse {
38    use phago_agents::digester::Digester;
39
40    let before_nodes = colony.stats().graph_nodes;
41    let before_edges = colony.stats().graph_edges;
42
43    let doc_id = colony.ingest_document(&req.title, &req.content, Position::new(0.0, 0.0));
44
45    // Spawn a digester to process the document
46    colony.spawn(Box::new(
47        Digester::new(Position::new(0.0, 0.0)).with_max_idle(30),
48    ));
49
50    // Run enough ticks for digestion
51    let ticks = req.ticks.unwrap_or(15);
52    colony.run(ticks);
53
54    let after_nodes = colony.stats().graph_nodes;
55    let after_edges = colony.stats().graph_edges;
56
57    RememberResponse {
58        document_id: format!("{}", doc_id.0),
59        nodes_created: after_nodes.saturating_sub(before_nodes),
60        edges_created: after_edges.saturating_sub(before_edges),
61        tick: colony.stats().tick,
62    }
63}
64
65// === phago_recall ===
66
67#[derive(Debug, Deserialize)]
68pub struct RecallRequest {
69    pub query: String,
70    #[serde(default = "default_max_results")]
71    pub max_results: usize,
72    #[serde(default = "default_alpha")]
73    pub alpha: f64,
74}
75
76fn default_max_results() -> usize { 10 }
77fn default_alpha() -> f64 { 0.5 }
78
79#[derive(Debug, Serialize)]
80pub struct RecallResult {
81    pub label: String,
82    pub score: f64,
83    pub tfidf_score: f64,
84    pub graph_score: f64,
85}
86
87#[derive(Debug, Serialize)]
88pub struct RecallResponse {
89    pub results: Vec<RecallResult>,
90    pub total_nodes: usize,
91    pub total_edges: usize,
92}
93
94/// Query the knowledge graph using hybrid scoring.
95pub fn phago_recall(colony: &Colony, req: &RecallRequest) -> RecallResponse {
96    use crate::hybrid::{hybrid_query, HybridConfig};
97
98    let config = HybridConfig {
99        alpha: req.alpha,
100        max_results: req.max_results,
101        candidate_multiplier: 3,
102    };
103
104    let results = hybrid_query(colony, &req.query, &config);
105
106    RecallResponse {
107        results: results
108            .into_iter()
109            .map(|r| RecallResult {
110                label: r.label,
111                score: r.final_score,
112                tfidf_score: r.tfidf_score,
113                graph_score: r.graph_score,
114            })
115            .collect(),
116        total_nodes: colony.stats().graph_nodes,
117        total_edges: colony.stats().graph_edges,
118    }
119}
120
121// === phago_explore ===
122
123#[derive(Debug, Deserialize)]
124#[serde(tag = "type")]
125pub enum ExploreRequest {
126    #[serde(rename = "path")]
127    ShortestPath { from: String, to: String },
128    #[serde(rename = "centrality")]
129    Centrality {
130        #[serde(default = "default_top_k")]
131        top_k: usize,
132    },
133    #[serde(rename = "bridges")]
134    Bridges {
135        #[serde(default = "default_top_k")]
136        top_k: usize,
137    },
138    #[serde(rename = "stats")]
139    Stats,
140}
141
142fn default_top_k() -> usize { 10 }
143
144#[derive(Debug, Serialize)]
145#[serde(tag = "type")]
146pub enum ExploreResponse {
147    #[serde(rename = "path")]
148    Path {
149        found: bool,
150        path: Vec<String>,
151        cost: f64,
152    },
153    #[serde(rename = "centrality")]
154    Centrality {
155        nodes: Vec<CentralityEntry>,
156    },
157    #[serde(rename = "bridges")]
158    Bridges {
159        nodes: Vec<BridgeEntry>,
160    },
161    #[serde(rename = "stats")]
162    Stats {
163        total_nodes: usize,
164        total_edges: usize,
165        connected_components: usize,
166        tick: u64,
167        agents_alive: usize,
168    },
169}
170
171#[derive(Debug, Serialize)]
172pub struct CentralityEntry {
173    pub label: String,
174    pub centrality: f64,
175}
176
177#[derive(Debug, Serialize)]
178pub struct BridgeEntry {
179    pub label: String,
180    pub fragility: f64,
181}
182
183/// Explore the graph structure.
184pub fn phago_explore(colony: &Colony, req: &ExploreRequest) -> ExploreResponse {
185    let graph = colony.substrate().graph();
186
187    match req {
188        ExploreRequest::ShortestPath { from, to } => {
189            let from_nodes = graph.find_nodes_by_label(from);
190            let to_nodes = graph.find_nodes_by_label(to);
191
192            if let (Some(&from_id), Some(&to_id)) = (from_nodes.first(), to_nodes.first()) {
193                if let Some((path, cost)) = graph.shortest_path(&from_id, &to_id) {
194                    let labels: Vec<String> = path
195                        .iter()
196                        .filter_map(|nid| graph.get_node(nid).map(|n| n.label.clone()))
197                        .collect();
198                    ExploreResponse::Path {
199                        found: true,
200                        path: labels,
201                        cost,
202                    }
203                } else {
204                    ExploreResponse::Path {
205                        found: false,
206                        path: Vec::new(),
207                        cost: 0.0,
208                    }
209                }
210            } else {
211                ExploreResponse::Path {
212                    found: false,
213                    path: Vec::new(),
214                    cost: 0.0,
215                }
216            }
217        }
218        ExploreRequest::Centrality { top_k } => {
219            let centrality = graph.betweenness_centrality(100);
220            let entries: Vec<CentralityEntry> = centrality
221                .into_iter()
222                .take(*top_k)
223                .filter_map(|(nid, c)| {
224                    graph.get_node(&nid).map(|n| CentralityEntry {
225                        label: n.label.clone(),
226                        centrality: c,
227                    })
228                })
229                .collect();
230            ExploreResponse::Centrality { nodes: entries }
231        }
232        ExploreRequest::Bridges { top_k } => {
233            let bridges = graph.bridge_nodes(*top_k);
234            let entries: Vec<BridgeEntry> = bridges
235                .into_iter()
236                .filter_map(|(nid, f)| {
237                    graph.get_node(&nid).map(|n| BridgeEntry {
238                        label: n.label.clone(),
239                        fragility: f,
240                    })
241                })
242                .collect();
243            ExploreResponse::Bridges { nodes: entries }
244        }
245        ExploreRequest::Stats => {
246            let stats = colony.stats();
247            ExploreResponse::Stats {
248                total_nodes: stats.graph_nodes,
249                total_edges: stats.graph_edges,
250                connected_components: graph.connected_components(),
251                tick: stats.tick,
252                agents_alive: stats.agents_alive,
253            }
254        }
255    }
256}
257
258#[cfg(test)]
259mod tests {
260    use super::*;
261
262    #[test]
263    fn remember_creates_nodes_and_edges() {
264        let mut colony = Colony::new();
265        let req = RememberRequest {
266            title: "Biology 101".into(),
267            content: "The cell membrane controls transport of molecules and proteins".into(),
268            ticks: Some(15),
269        };
270        let resp = phago_remember(&mut colony, &req);
271        assert!(resp.nodes_created > 0, "should create nodes");
272    }
273
274    #[test]
275    fn recall_returns_results() {
276        let mut colony = Colony::new();
277        let _ = phago_remember(&mut colony, &RememberRequest {
278            title: "Bio".into(),
279            content: "cell membrane protein transport channel receptor".into(),
280            ticks: Some(15),
281        });
282        let _ = phago_remember(&mut colony, &RememberRequest {
283            title: "Bio2".into(),
284            content: "cell membrane protein signaling pathway cascade".into(),
285            ticks: Some(15),
286        });
287
288        let resp = phago_recall(&colony, &RecallRequest {
289            query: "cell membrane".into(),
290            max_results: 5,
291            alpha: 0.5,
292        });
293        assert!(!resp.results.is_empty(), "should return results");
294    }
295
296    #[test]
297    fn explore_stats_works() {
298        let mut colony = Colony::new();
299        let _ = phago_remember(&mut colony, &RememberRequest {
300            title: "Bio".into(),
301            content: "cell membrane protein".into(),
302            ticks: Some(15),
303        });
304
305        let resp = phago_explore(&colony, &ExploreRequest::Stats);
306        match resp {
307            ExploreResponse::Stats { total_nodes, .. } => {
308                assert!(total_nodes > 0, "should have nodes");
309            }
310            _ => panic!("expected Stats response"),
311        }
312    }
313}