gestura_core_mcp/
integrator.rs

1//! MCP Integrator - Token management and tool exposure
2//! Provides McpIntegrator trait and LocalMcp implementation for
3//! tool exposure, dual authentication, and MDH translation.
4
5use serde::{Deserialize, Serialize};
6use std::collections::HashMap;
7use std::{path::PathBuf, sync::RwLock};
8
9use crate::error::AppError;
10
11/// Token information with expiration and permissions
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct TokenInfo {
14    /// The token string
15    pub token: String,
16    /// When the token was created
17    pub created_at: chrono::DateTime<chrono::Utc>,
18    /// When the token expires
19    pub expires_at: chrono::DateTime<chrono::Utc>,
20    /// Whether haptic permission is granted
21    pub haptic_permission: bool,
22    /// Client identifier
23    pub client_id: String,
24    /// Scopes granted to this token
25    pub scopes: Vec<String>,
26}
27
28/// Result of local MDH translation suitable for MCP usage
29#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
30pub struct MdhResource {
31    /// A synthetic MCP URI derived from JSON-LD @type or file stem
32    pub uri: String,
33    /// A compacted JSON payload (local-only)
34    pub data: serde_json::Value,
35}
36
37/// Unifies MCP operations with dual authentication
38#[async_trait::async_trait]
39pub trait McpIntegrator: Send + Sync {
40    /// Expose a tool name for use by clients
41    async fn expose_tool(&self, tool: &str) -> Result<(), AppError>;
42    /// Perform dual auth (app approval + MCP token)
43    async fn authenticate_haptic(&self, token: &str) -> Result<bool, AppError>;
44    /// Validate MCP token
45    async fn validate_token(&self, token: &str) -> Result<bool, AppError>;
46    /// Register haptic permission for token
47    async fn grant_haptic_permission(&self, token: &str) -> Result<(), AppError>;
48}
49
50/// Local-only MDH translate: read JSON file, validate minimal JSON-LD shape, and create URI
51/// NOTE: In production we will use json-ld-rs to expand/compact; here we simulate locally
52pub fn mdh_translate(ld_file: PathBuf) -> Result<MdhResource, AppError> {
53    let content = std::fs::read_to_string(&ld_file)
54        .map_err(|e| AppError::Io(std::io::Error::other(format!("read mdh file: {e}"))))?;
55    let mut value: serde_json::Value = serde_json::from_str(&content).map_err(AppError::Json)?;
56
57    // Basic validation and derive a type for URI
58    let mut uri_type = None;
59    if let serde_json::Value::Object(map) = &value
60        && let Some(t) = map.get("@type").and_then(|v| v.as_str())
61    {
62        uri_type = Some(t.to_string());
63    }
64    if uri_type.is_none() {
65        // try a nested object or fallback to filename stem
66        uri_type = Some(
67            ld_file
68                .file_stem()
69                .and_then(|s| s.to_str())
70                .unwrap_or("local")
71                .to_string(),
72        );
73    }
74
75    // Simulate compacting by removing @context if present (local-only)
76    if let serde_json::Value::Object(map) = &mut value
77        && map.contains_key("@context")
78    {
79        map.remove("@context");
80    }
81
82    let uri = format!("mcp://mdh/{}", uri_type.unwrap());
83    Ok(MdhResource { uri, data: value })
84}
85
86/// MCP integrator with token storage and validation
87pub struct LocalMcp {
88    tools: RwLock<Vec<String>>,
89    tokens: RwLock<HashMap<String, TokenInfo>>,
90}
91
92impl Default for LocalMcp {
93    fn default() -> Self {
94        Self {
95            tools: RwLock::new(Vec::new()),
96            tokens: RwLock::new(HashMap::new()),
97        }
98    }
99}
100
101impl LocalMcp {
102    /// Create a new LocalMcp instance
103    pub fn new() -> Self {
104        Self::default()
105    }
106
107    /// Generate a new token for a client
108    pub fn generate_token(
109        &self,
110        client_id: &str,
111        scopes: Vec<String>,
112        duration_hours: i64,
113    ) -> Result<TokenInfo, AppError> {
114        use rand::Rng;
115        use rand::distributions::Alphanumeric;
116
117        let token: String = rand::thread_rng()
118            .sample_iter(&Alphanumeric)
119            .take(32)
120            .map(char::from)
121            .collect();
122
123        let now = chrono::Utc::now();
124        let expires_at = now + chrono::Duration::hours(duration_hours);
125
126        let token_info = TokenInfo {
127            token: token.clone(),
128            created_at: now,
129            expires_at,
130            haptic_permission: false,
131            client_id: client_id.to_string(),
132            scopes,
133        };
134
135        let mut tokens = self
136            .tokens
137            .write()
138            .map_err(|e| AppError::Io(std::io::Error::other(format!("rwlock: {e}"))))?;
139        tokens.insert(token.clone(), token_info.clone());
140
141        tracing::info!(
142            "Generated token for client {}: expires at {}",
143            client_id,
144            expires_at
145        );
146
147        Ok(token_info)
148    }
149
150    /// Clean up expired tokens
151    pub fn cleanup_expired_tokens(&self) -> Result<usize, AppError> {
152        let mut tokens = self
153            .tokens
154            .write()
155            .map_err(|e| AppError::Io(std::io::Error::other(format!("rwlock: {e}"))))?;
156
157        let now = chrono::Utc::now();
158        let initial_count = tokens.len();
159        tokens.retain(|_, info| info.expires_at > now);
160        let removed = initial_count - tokens.len();
161
162        if removed > 0 {
163            tracing::info!("Cleaned up {} expired tokens", removed);
164        }
165
166        Ok(removed)
167    }
168
169    /// Get token info if valid
170    pub fn get_token_info(&self, token: &str) -> Result<Option<TokenInfo>, AppError> {
171        let tokens = self
172            .tokens
173            .read()
174            .map_err(|e| AppError::Io(std::io::Error::other(format!("rwlock: {e}"))))?;
175
176        if let Some(info) = tokens.get(token)
177            && info.expires_at > chrono::Utc::now()
178        {
179            return Ok(Some(info.clone()));
180        }
181
182        Ok(None)
183    }
184
185    /// List all active tokens (for admin purposes)
186    pub fn list_active_tokens(&self) -> Result<Vec<TokenInfo>, AppError> {
187        let tokens = self
188            .tokens
189            .read()
190            .map_err(|e| AppError::Io(std::io::Error::other(format!("rwlock: {e}"))))?;
191
192        let now = chrono::Utc::now();
193        Ok(tokens
194            .values()
195            .filter(|info| info.expires_at > now)
196            .cloned()
197            .collect())
198    }
199}
200
201#[async_trait::async_trait]
202impl McpIntegrator for LocalMcp {
203    async fn expose_tool(&self, tool: &str) -> Result<(), AppError> {
204        let mut guard = self
205            .tools
206            .write()
207            .map_err(|e| AppError::Io(std::io::Error::other(format!("rwlock: {e}"))))?;
208        guard.push(tool.to_string());
209        tracing::info!("Exposed MCP tool: {}", tool);
210        Ok(())
211    }
212
213    async fn authenticate_haptic(&self, token: &str) -> Result<bool, AppError> {
214        // Validate token and check haptic permission
215        if let Some(info) = self.get_token_info(token)? {
216            if info.haptic_permission {
217                tracing::info!(
218                    "Haptic authentication successful for client: {}",
219                    info.client_id
220                );
221                return Ok(true);
222            }
223            tracing::warn!(
224                "Haptic permission not granted for client: {}",
225                info.client_id
226            );
227            return Ok(false);
228        }
229
230        tracing::warn!("Invalid or expired token for haptic authentication");
231        Ok(false)
232    }
233
234    async fn validate_token(&self, token: &str) -> Result<bool, AppError> {
235        // Check if token exists and is not expired
236        if let Some(info) = self.get_token_info(token)? {
237            tracing::debug!("Token validated for client: {}", info.client_id);
238            return Ok(true);
239        }
240
241        tracing::debug!("Token validation failed: invalid or expired");
242        Ok(false)
243    }
244
245    async fn grant_haptic_permission(&self, token: &str) -> Result<(), AppError> {
246        let mut tokens = self
247            .tokens
248            .write()
249            .map_err(|e| AppError::Io(std::io::Error::other(format!("rwlock: {e}"))))?;
250
251        if let Some(info) = tokens.get_mut(token) {
252            if info.expires_at > chrono::Utc::now() {
253                info.haptic_permission = true;
254                tracing::info!("Granted haptic permission for client: {}", info.client_id);
255                return Ok(());
256            }
257            return Err(AppError::Io(std::io::Error::other("Token expired")));
258        }
259
260        Err(AppError::Io(std::io::Error::other("Token not found")))
261    }
262}
263
264/// Global MCP instance
265static MCP_INSTANCE: std::sync::OnceLock<LocalMcp> = std::sync::OnceLock::new();
266
267/// Get the global MCP instance
268pub fn get_mcp() -> &'static LocalMcp {
269    MCP_INSTANCE.get_or_init(LocalMcp::new)
270}