Skip to main content

ares/mcp/
eruka_proxy.rs

1// ares/src/mcp/eruka_proxy.rs
2// Proxy layer that forwards MCP tool calls to Eruka's HTTP API.
3
4use crate::mcp::auth::McpSession;
5use crate::mcp::tools::{
6    ErukaReadInput, ErukaReadOutput,
7    ErukaWriteInput, ErukaWriteOutput,
8    ErukaSearchInput, ErukaSearchOutput, ErukaSearchResult,
9};
10use crate::types::AppError;
11use serde_json::Value;
12
13/// Error type for Eruka proxy operations.
14#[derive(Debug, thiserror::Error)]
15pub enum ErukaProxyError {
16    #[error("Eruka HTTP request failed: {0}")]
17    Http(#[from] reqwest::Error),
18
19    #[error("Eruka returned error: {status} — {body}")]
20    ApiError { status: u16, body: String },
21
22    #[error("Failed to parse Eruka response: {0}")]
23    Parse(String),
24
25    #[error("Eruka is not configured or unreachable")]
26    NotConfigured,
27}
28
29impl From<ErukaProxyError> for AppError {
30    fn from(e: ErukaProxyError) -> Self {
31        match e {
32            ErukaProxyError::Http(e) => AppError::External(format!("Eruka HTTP error: {}", e)),
33            ErukaProxyError::ApiError { status, body } => {
34                AppError::External(format!("Eruka API error {}: {}", status, body))
35            }
36            ErukaProxyError::Parse(s) => AppError::External(format!("Eruka parse error: {}", s)),
37            ErukaProxyError::NotConfigured => {
38                AppError::External("Eruka not configured".to_string())
39            }
40        }
41    }
42}
43
44/// Eruka proxy client.
45/// Created once per MCP session, holds the HTTP client and Eruka base URL.
46pub struct ErukaProxy {
47    http: reqwest::Client,
48    base_url: String,
49}
50
51impl ErukaProxy {
52    /// Creates a new ErukaProxy.
53    ///
54    /// # Arguments
55    /// - `eruka_base_url`: Base URL of the Eruka API (e.g., "https://eruka.dirmacs.com")
56    pub fn new(eruka_base_url: &str) -> Self {
57        let http = reqwest::Client::builder()
58            .timeout(std::time::Duration::from_secs(15))
59            .build()
60            .expect("Failed to build Eruka proxy HTTP client");
61
62        Self {
63            http,
64            base_url: eruka_base_url.trim_end_matches('/').to_string(),
65        }
66    }
67
68    /// Reads a single field from Eruka.
69    ///
70    /// Calls: GET {eruka}/api/workspaces/{workspace_id}/context/{category}/{field}
71    pub async fn read(
72        &self,
73        session: &McpSession,
74        input: ErukaReadInput,
75    ) -> Result<ErukaReadOutput, ErukaProxyError> {
76        let workspace_id = input
77            .workspace_id
78            .as_deref()
79            .unwrap_or(&session.eruka_workspace_id);
80
81        let url = format!(
82            "{}/api/workspaces/{}/context/{}/{}",
83            self.base_url, workspace_id, input.category, input.field
84        );
85
86        let response = self.http.get(&url).send().await?;
87
88        if !response.status().is_success() {
89            let status = response.status().as_u16();
90            let body = response.text().await.unwrap_or_default();
91            return Err(ErukaProxyError::ApiError { status, body });
92        }
93
94        let json: Value = response.json().await?;
95
96        Ok(ErukaReadOutput {
97            field: json["field"]
98                .as_str()
99                .unwrap_or(&input.field)
100                .to_string(),
101            value: json["value"].clone(),
102            state: json["state"]
103                .as_str()
104                .unwrap_or("UNKNOWN")
105                .to_string(),
106            confidence: json["confidence"].as_f64().unwrap_or(0.0),
107            last_updated: json["last_updated"].as_str().map(String::from),
108        })
109    }
110
111    /// Writes a field to Eruka.
112    ///
113    /// Calls: POST {eruka}/api/workspaces/{workspace_id}/context
114    pub async fn write(
115        &self,
116        session: &McpSession,
117        input: ErukaWriteInput,
118    ) -> Result<ErukaWriteOutput, ErukaProxyError> {
119        let workspace_id = input
120            .workspace_id
121            .as_deref()
122            .unwrap_or(&session.eruka_workspace_id);
123
124        let url = format!(
125            "{}/api/workspaces/{}/context",
126            self.base_url, workspace_id
127        );
128
129        let body = serde_json::json!({
130            "category": input.category,
131            "field": input.field,
132            "value": input.value,
133            "confidence": input.confidence,
134            "source": input.source
135        });
136
137        let response = self.http.post(&url).json(&body).send().await?;
138
139        if !response.status().is_success() {
140            let status = response.status().as_u16();
141            let body_text = response.text().await.unwrap_or_default();
142            return Err(ErukaProxyError::ApiError {
143                status,
144                body: body_text,
145            });
146        }
147
148        let json: Value = response.json().await?;
149
150        let state = if input.confidence >= 1.0 {
151            "CONFIRMED"
152        } else {
153            "UNCERTAIN"
154        };
155
156        Ok(ErukaWriteOutput {
157            field: input.field,
158            state: state.to_string(),
159            written_at: chrono::Utc::now().to_rfc3339(),
160        })
161    }
162
163    /// Searches Eruka knowledge base.
164    ///
165    /// Calls: POST {eruka}/api/workspaces/{workspace_id}/search
166    pub async fn search(
167        &self,
168        session: &McpSession,
169        input: ErukaSearchInput,
170    ) -> Result<ErukaSearchOutput, ErukaProxyError> {
171        let workspace_id = input
172            .workspace_id
173            .as_deref()
174            .unwrap_or(&session.eruka_workspace_id);
175
176        let url = format!(
177            "{}/api/workspaces/{}/search",
178            self.base_url, workspace_id
179        );
180
181        let body = serde_json::json!({
182            "query": input.query,
183            "limit": input.limit
184        });
185
186        let response = self.http.post(&url).json(&body).send().await?;
187
188        if !response.status().is_success() {
189            let status = response.status().as_u16();
190            let body_text = response.text().await.unwrap_or_default();
191            return Err(ErukaProxyError::ApiError {
192                status,
193                body: body_text,
194            });
195        }
196
197        let json: Value = response.json().await?;
198
199        let results: Vec<ErukaSearchResult> = json["results"]
200            .as_array()
201            .map(|arr| {
202                arr.iter()
203                    .map(|r| ErukaSearchResult {
204                        category: r["category"].as_str().unwrap_or("").to_string(),
205                        field: r["field"].as_str().unwrap_or("").to_string(),
206                        value: r["value"].clone(),
207                        state: r["state"].as_str().unwrap_or("UNKNOWN").to_string(),
208                        relevance: r["relevance"].as_f64().unwrap_or(0.0),
209                    })
210                    .collect()
211            })
212            .unwrap_or_default();
213
214        let total = results.len();
215
216        Ok(ErukaSearchOutput {
217            results,
218            total_results: total,
219        })
220    }
221}