reasonkit/mcp/
delta_tools.rs

1//! Protocol Delta MCP Tools
2//!
3//! MCP tool implementations for Protocol Delta's verification capabilities.
4//! These tools expose the ProofLedger immutable citation ledger to high-level
5//! agents (Claude, Grok, Gemini) for source verification and drift detection.
6//!
7//! ## Available Tools
8//!
9//! - `proof_anchor` - Anchor content to the immutable ledger
10//! - `proof_verify` - Verify content against an anchored hash
11//! - `proof_lookup` - Look up an anchor by hash
12//! - `proof_list_by_url` - List all anchors for a URL
13//! - `proof_stats` - Get ledger statistics
14//!
15//! ## Philosophy
16//!
17//! > "We do not quote the wind. We quote the stone."
18//!
19//! Protocol Delta replaces weak URL citations with cryptographically-bound
20//! anchors that can detect content drift over time.
21
22use super::tools::{Tool, ToolResult};
23use crate::verification::{ProofLedger, ProofLedgerError};
24use serde::{Deserialize, Serialize};
25use serde_json::{json, Value};
26use std::collections::HashMap;
27use std::path::{Path, PathBuf};
28use std::sync::Arc;
29use tokio::sync::RwLock;
30
31/// Protocol Delta tool handler (uses ProofLedger)
32pub struct DeltaToolHandler {
33    /// ProofLedger instance
34    ledger: Arc<RwLock<ProofLedger>>,
35    /// Ledger path (kept for potential future use like backup/export)
36    #[allow(dead_code)]
37    ledger_path: PathBuf,
38}
39
40/// Input for delta_anchor tool
41#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct DeltaAnchorInput {
43    /// Content to anchor
44    pub content: String,
45    /// Source URL
46    pub url: String,
47    /// Optional metadata (JSON)
48    #[serde(skip_serializing_if = "Option::is_none")]
49    pub metadata: Option<String>,
50}
51
52/// Input for delta_verify tool
53#[derive(Debug, Clone, Serialize, Deserialize)]
54pub struct DeltaVerifyInput {
55    /// Original hash from citation
56    pub hash: String,
57    /// Current content to verify
58    pub content: String,
59}
60
61/// Input for delta_lookup tool
62#[derive(Debug, Clone, Serialize, Deserialize)]
63pub struct DeltaLookupInput {
64    /// Hash to look up
65    pub hash: String,
66}
67
68/// Input for delta_list_by_url tool
69#[derive(Debug, Clone, Serialize, Deserialize)]
70pub struct DeltaListByUrlInput {
71    /// URL to search for
72    pub url: String,
73}
74
75/// Output for delta_anchor tool
76#[derive(Debug, Clone, Serialize, Deserialize)]
77pub struct DeltaAnchorOutput {
78    /// SHA-256 hash (citation ID)
79    pub hash: String,
80    /// Short hash for display (first 8 chars)
81    pub short_hash: String,
82    /// Source URL
83    pub url: String,
84    /// Timestamp (RFC3339)
85    pub timestamp: String,
86    /// Citation format for reports
87    pub citation: String,
88}
89
90/// Output for delta_verify tool
91#[derive(Debug, Clone, Serialize, Deserialize)]
92pub struct DeltaVerifyOutput {
93    /// Whether verification passed
94    pub verified: bool,
95    /// Original hash
96    pub original_hash: String,
97    /// Current content hash
98    pub current_hash: String,
99    /// Human-readable status
100    pub status: String,
101    /// Recommendation for the agent
102    pub recommendation: String,
103}
104
105impl DeltaToolHandler {
106    /// Create a new Protocol Delta tool handler
107    ///
108    /// # Arguments
109    ///
110    /// * `ledger_path` - Path to the SQLite ledger database
111    pub fn new<P: AsRef<Path>>(ledger_path: P) -> Result<Self, ProofLedgerError> {
112        let path = ledger_path.as_ref().to_path_buf();
113        let ledger = ProofLedger::new(&path)?;
114
115        #[allow(clippy::arc_with_non_send_sync)]
116        Ok(Self {
117            ledger: Arc::new(RwLock::new(ledger)),
118            ledger_path: path,
119        })
120    }
121
122    /// Create an in-memory handler (for testing)
123    pub fn in_memory() -> Result<Self, ProofLedgerError> {
124        let ledger = ProofLedger::in_memory()?;
125
126        #[allow(clippy::arc_with_non_send_sync)]
127        Ok(Self {
128            ledger: Arc::new(RwLock::new(ledger)),
129            ledger_path: PathBuf::from(":memory:"),
130        })
131    }
132
133    /// Get tool definitions for MCP registration
134    pub fn tool_definitions() -> Vec<Tool> {
135        vec![
136            Tool::with_schema(
137                "delta_anchor",
138                "Anchor content to Protocol Delta's immutable citation ledger. \
139                Returns a SHA-256 hash that can be used as a verifiable citation. \
140                Use this BEFORE making claims based on external sources.",
141                json!({
142                    "type": "object",
143                    "properties": {
144                        "content": {
145                            "type": "string",
146                            "description": "The exact content/text to anchor (will be hashed)"
147                        },
148                        "url": {
149                            "type": "string",
150                            "description": "Source URL where the content was retrieved from"
151                        },
152                        "metadata": {
153                            "type": "string",
154                            "description": "Optional JSON metadata (source type, confidence, etc.)"
155                        }
156                    },
157                    "required": ["content", "url"]
158                }),
159            ),
160            Tool::with_schema(
161                "delta_verify",
162                "Verify that content matches an anchored citation. \
163                Use this to detect if a source has changed (content drift) \
164                since it was originally cited.",
165                json!({
166                    "type": "object",
167                    "properties": {
168                        "hash": {
169                            "type": "string",
170                            "description": "The original SHA-256 hash from the citation"
171                        },
172                        "content": {
173                            "type": "string",
174                            "description": "The current content to verify against the anchor"
175                        }
176                    },
177                    "required": ["hash", "content"]
178                }),
179            ),
180            Tool::with_schema(
181                "delta_lookup",
182                "Look up an anchor by its hash. Returns the original URL, \
183                timestamp, and content snippet for a given citation hash.",
184                json!({
185                    "type": "object",
186                    "properties": {
187                        "hash": {
188                            "type": "string",
189                            "description": "The SHA-256 hash to look up"
190                        }
191                    },
192                    "required": ["hash"]
193                }),
194            ),
195            Tool::with_schema(
196                "delta_list_by_url",
197                "List all anchored citations from a specific URL. \
198                Useful for finding historical citations from a source.",
199                json!({
200                    "type": "object",
201                    "properties": {
202                        "url": {
203                            "type": "string",
204                            "description": "The URL to search for"
205                        }
206                    },
207                    "required": ["url"]
208                }),
209            ),
210            Tool::with_schema(
211                "delta_stats",
212                "Get statistics about the Protocol Delta ledger. \
213                Returns total anchors, ledger path, and status.",
214                json!({
215                    "type": "object",
216                    "properties": {},
217                    "required": []
218                }),
219            ),
220        ]
221    }
222
223    /// Handle a delta_anchor tool call
224    pub async fn handle_anchor(
225        &self,
226        args: &HashMap<String, Value>,
227    ) -> Result<ToolResult, ProofLedgerError> {
228        let content = args
229            .get("content")
230            .and_then(|v| v.as_str())
231            .ok_or_else(|| {
232                ProofLedgerError::Io(std::io::Error::new(
233                    std::io::ErrorKind::InvalidInput,
234                    "Missing 'content' argument",
235                ))
236            })?;
237
238        let url = args.get("url").and_then(|v| v.as_str()).ok_or_else(|| {
239            ProofLedgerError::Io(std::io::Error::new(
240                std::io::ErrorKind::InvalidInput,
241                "Missing 'url' argument",
242            ))
243        })?;
244
245        let metadata = args
246            .get("metadata")
247            .and_then(|v| v.as_str())
248            .map(|s| s.to_string());
249
250        let ledger = self.ledger.read().await;
251        let hash = ledger.anchor(content, url, metadata)?;
252
253        let output = DeltaAnchorOutput {
254            hash: hash.clone(),
255            short_hash: hash[..8].to_string(),
256            url: url.to_string(),
257            timestamp: chrono::Utc::now().to_rfc3339(),
258            citation: format!(
259                "[sha256:{}...] (Anchored {}) → {}",
260                &hash[..8],
261                chrono::Utc::now().format("%Y-%m-%d"),
262                url
263            ),
264        };
265
266        let json_output = serde_json::to_string_pretty(&output).unwrap_or_else(|_| hash.clone());
267
268        Ok(ToolResult::text(format!(
269            "ANCHORED: Content successfully bound to immutable ledger.\n\n\
270            Citation ID: {}\n\
271            Short Hash: {}\n\
272            Source: {}\n\n\
273            Use this citation format in reports:\n\
274            {}\n\n\
275            Raw JSON:\n{}",
276            output.hash, output.short_hash, output.url, output.citation, json_output
277        )))
278    }
279
280    /// Handle a delta_verify tool call
281    pub async fn handle_verify(
282        &self,
283        args: &HashMap<String, Value>,
284    ) -> Result<ToolResult, ProofLedgerError> {
285        let hash = args.get("hash").and_then(|v| v.as_str()).ok_or_else(|| {
286            ProofLedgerError::Io(std::io::Error::new(
287                std::io::ErrorKind::InvalidInput,
288                "Missing 'hash' argument",
289            ))
290        })?;
291
292        let content = args
293            .get("content")
294            .and_then(|v| v.as_str())
295            .ok_or_else(|| {
296                ProofLedgerError::Io(std::io::Error::new(
297                    std::io::ErrorKind::InvalidInput,
298                    "Missing 'content' argument",
299                ))
300            })?;
301
302        let ledger = self.ledger.read().await;
303        let result = ledger.verify(hash, content)?;
304
305        let output = DeltaVerifyOutput {
306            verified: result.verified,
307            original_hash: result.original_hash.clone(),
308            current_hash: result.current_hash.clone(),
309            status: if result.verified {
310                "VERIFIED".to_string()
311            } else {
312                "DRIFT_DETECTED".to_string()
313            },
314            recommendation: if result.verified {
315                "Citation is valid. Content matches the original anchor.".to_string()
316            } else {
317                format!(
318                    "WARNING: Content has changed since anchoring. \
319                    Original hash: {}..., Current hash: {}... \
320                    Consider re-anchoring or flagging this citation as outdated.",
321                    &result.original_hash[..8],
322                    &result.current_hash[..8]
323                )
324            },
325        };
326
327        let json_output = serde_json::to_string_pretty(&output)
328            .unwrap_or_else(|_| format!("verified: {}", result.verified));
329
330        let status_icon = if result.verified { "✓" } else { "⚠" };
331
332        Ok(ToolResult::text(format!(
333            "{} {}\n\n\
334            Original Hash: {}...\n\
335            Current Hash:  {}...\n\
336            Match: {}\n\n\
337            Recommendation: {}\n\n\
338            Raw JSON:\n{}",
339            status_icon,
340            output.status,
341            &output.original_hash[..8],
342            &output.current_hash[..8],
343            output.verified,
344            output.recommendation,
345            json_output
346        )))
347    }
348
349    /// Handle a delta_lookup tool call
350    pub async fn handle_lookup(
351        &self,
352        args: &HashMap<String, Value>,
353    ) -> Result<ToolResult, ProofLedgerError> {
354        let hash = args.get("hash").and_then(|v| v.as_str()).ok_or_else(|| {
355            ProofLedgerError::Io(std::io::Error::new(
356                std::io::ErrorKind::InvalidInput,
357                "Missing 'hash' argument",
358            ))
359        })?;
360
361        let ledger = self.ledger.read().await;
362        let anchor = ledger.get_anchor(hash)?;
363
364        let json_output = serde_json::to_string_pretty(&anchor)
365            .unwrap_or_else(|_| format!("hash: {}", anchor.hash));
366
367        Ok(ToolResult::text(format!(
368            "ANCHOR FOUND\n\n\
369            Hash: {}\n\
370            URL: {}\n\
371            Timestamp: {}\n\
372            Snippet: {}...\n\n\
373            Raw JSON:\n{}",
374            anchor.hash,
375            anchor.url,
376            anchor.timestamp.to_rfc3339(),
377            &anchor.content_snippet[..anchor.content_snippet.len().min(100)],
378            json_output
379        )))
380    }
381
382    /// Handle a delta_list_by_url tool call
383    pub async fn handle_list_by_url(
384        &self,
385        args: &HashMap<String, Value>,
386    ) -> Result<ToolResult, ProofLedgerError> {
387        let url = args.get("url").and_then(|v| v.as_str()).ok_or_else(|| {
388            ProofLedgerError::Io(std::io::Error::new(
389                std::io::ErrorKind::InvalidInput,
390                "Missing 'url' argument",
391            ))
392        })?;
393
394        let ledger = self.ledger.read().await;
395        let anchors = ledger.list_by_url(url)?;
396
397        if anchors.is_empty() {
398            return Ok(ToolResult::text(format!(
399                "No anchors found for URL: {}\n\n\
400                This URL has no citations in the ledger. \
401                Use delta_anchor to create the first citation.",
402                url
403            )));
404        }
405
406        let mut output = format!("ANCHORS FOR URL: {}\nTotal: {}\n\n", url, anchors.len());
407
408        for (i, anchor) in anchors.iter().enumerate() {
409            output.push_str(&format!(
410                "{}. [{}...] - {}\n   Snippet: {}...\n\n",
411                i + 1,
412                &anchor.hash[..8],
413                anchor.timestamp.format("%Y-%m-%d %H:%M:%S UTC"),
414                &anchor.content_snippet[..anchor.content_snippet.len().min(60)]
415            ));
416        }
417
418        let json_output =
419            serde_json::to_string_pretty(&anchors).unwrap_or_else(|_| "[]".to_string());
420
421        output.push_str(&format!("\nRaw JSON:\n{}", json_output));
422
423        Ok(ToolResult::text(output))
424    }
425
426    /// Handle a delta_stats tool call
427    pub async fn handle_stats(&self) -> Result<ToolResult, ProofLedgerError> {
428        let ledger = self.ledger.read().await;
429        let count = ledger.count()?;
430        let path = ledger.ledger_path();
431
432        let stats = json!({
433            "total_anchors": count,
434            "ledger_path": path.to_string_lossy(),
435            "status": "operational",
436            "protocol_version": "delta_v2"
437        });
438
439        let json_output = serde_json::to_string_pretty(&stats).unwrap_or_else(|_| "{}".to_string());
440
441        Ok(ToolResult::text(format!(
442            "PROTOCOL DELTA LEDGER STATUS\n\n\
443            Total Anchors: {}\n\
444            Ledger Path: {}\n\
445            Status: Operational\n\
446            Protocol Version: Delta V2 (Amber)\n\n\
447            Raw JSON:\n{}",
448            count,
449            path.display(),
450            json_output
451        )))
452    }
453
454    /// Dispatch a tool call to the appropriate handler
455    pub async fn handle_tool(
456        &self,
457        name: &str,
458        args: &HashMap<String, Value>,
459    ) -> Result<ToolResult, ProofLedgerError> {
460        match name {
461            "delta_anchor" => self.handle_anchor(args).await,
462            "delta_verify" => self.handle_verify(args).await,
463            "delta_lookup" => self.handle_lookup(args).await,
464            "delta_list_by_url" => self.handle_list_by_url(args).await,
465            "delta_stats" => self.handle_stats().await,
466            _ => Ok(ToolResult::error(format!("Unknown tool: {}", name))),
467        }
468    }
469}
470
471#[cfg(test)]
472mod tests {
473    use super::*;
474    use crate::mcp::tools::ToolResultContent;
475
476    #[tokio::test]
477    async fn test_delta_anchor() {
478        let handler = DeltaToolHandler::in_memory().unwrap();
479
480        let mut args = HashMap::new();
481        args.insert("content".to_string(), json!("Test content for anchoring"));
482        args.insert("url".to_string(), json!("https://example.com/test"));
483
484        let result = handler.handle_anchor(&args).await.unwrap();
485
486        assert!(result.is_error.is_none() || !result.is_error.unwrap());
487        if let ToolResultContent::Text { text } = &result.content[0] {
488            assert!(text.contains("ANCHORED"));
489            assert!(text.contains("sha256"));
490        }
491    }
492
493    #[tokio::test]
494    async fn test_delta_verify_success() {
495        let handler = DeltaToolHandler::in_memory().unwrap();
496
497        // First anchor
498        let mut anchor_args = HashMap::new();
499        anchor_args.insert("content".to_string(), json!("Immutable content"));
500        anchor_args.insert("url".to_string(), json!("https://example.com"));
501
502        let anchor_result = handler.handle_anchor(&anchor_args).await.unwrap();
503
504        // Extract hash from result
505        let hash = if let ToolResultContent::Text { text } = &anchor_result.content[0] {
506            // Parse hash from output
507            text.lines()
508                .find(|l: &&str| l.starts_with("Citation ID:"))
509                .and_then(|l: &str| l.split(':').nth(1))
510                .map(|s: &str| s.trim().to_string())
511                .unwrap()
512        } else {
513            panic!("Expected text content");
514        };
515
516        // Now verify
517        let mut verify_args = HashMap::new();
518        verify_args.insert("hash".to_string(), json!(hash));
519        verify_args.insert("content".to_string(), json!("Immutable content"));
520
521        let result = handler.handle_verify(&verify_args).await.unwrap();
522
523        if let ToolResultContent::Text { text } = &result.content[0] {
524            assert!(text.contains("VERIFIED"));
525            assert!(text.contains("Match: true"));
526        }
527    }
528
529    #[tokio::test]
530    async fn test_delta_verify_drift() {
531        let handler = DeltaToolHandler::in_memory().unwrap();
532
533        // Anchor original
534        let mut anchor_args = HashMap::new();
535        anchor_args.insert("content".to_string(), json!("Original content"));
536        anchor_args.insert("url".to_string(), json!("https://example.com"));
537
538        let anchor_result = handler.handle_anchor(&anchor_args).await.unwrap();
539
540        let hash = if let ToolResultContent::Text { text } = &anchor_result.content[0] {
541            text.lines()
542                .find(|l: &&str| l.starts_with("Citation ID:"))
543                .and_then(|l: &str| l.split(':').nth(1))
544                .map(|s: &str| s.trim().to_string())
545                .unwrap()
546        } else {
547            panic!("Expected text content");
548        };
549
550        // Verify with different content
551        let mut verify_args = HashMap::new();
552        verify_args.insert("hash".to_string(), json!(hash));
553        verify_args.insert("content".to_string(), json!("Modified content"));
554
555        let result = handler.handle_verify(&verify_args).await.unwrap();
556
557        if let ToolResultContent::Text { text } = &result.content[0] {
558            assert!(text.contains("DRIFT_DETECTED"));
559            assert!(text.contains("WARNING"));
560        }
561    }
562
563    #[tokio::test]
564    async fn test_delta_stats() {
565        let handler = DeltaToolHandler::in_memory().unwrap();
566
567        let result = handler.handle_stats().await.unwrap();
568
569        if let ToolResultContent::Text { text } = &result.content[0] {
570            assert!(text.contains("PROTOCOL DELTA LEDGER STATUS"));
571            assert!(text.contains("Total Anchors:"));
572            assert!(text.contains("Delta V2"));
573        }
574    }
575
576    #[tokio::test]
577    async fn test_tool_definitions() {
578        let tools = DeltaToolHandler::tool_definitions();
579
580        assert_eq!(tools.len(), 5);
581
582        let names: Vec<&str> = tools.iter().map(|t| t.name.as_str()).collect();
583        assert!(names.contains(&"delta_anchor"));
584        assert!(names.contains(&"delta_verify"));
585        assert!(names.contains(&"delta_lookup"));
586        assert!(names.contains(&"delta_list_by_url"));
587        assert!(names.contains(&"delta_stats"));
588    }
589}