Skip to main content

vtcode_core/exec/
sdk_ipc.rs

1//! Inter-process communication for calling MCP tools from executing code.
2//!
3//! This module provides a file-based IPC mechanism that allows code running in
4//! a separate process to call MCP tools. The code writes tool requests to a file, and
5//! the executor reads and processes them, writing back results.
6//!
7//! Optionally supports PII (Personally Identifiable Information) protection by
8//! tokenizing sensitive data in requests before tool execution and de-tokenizing
9//! responses before returning to the code.
10//!
11//! # Protocol
12//!
13//! Requests (code → executor):
14//! ```json
15//! {
16//!   "id": "uuid",
17//!   "tool_name": "search_tools",
18//!   "args": {"keyword": "file"}
19//! }
20//! ```
21//!
22//! Responses (executor → code):
23//! ```json
24//! {
25//!   "id": "uuid",
26//!   "success": true,
27//!   "result": {...}
28//! }
29//! ```
30//! or
31//! ```json
32//! {
33//!   "id": "uuid",
34//!   "success": false,
35//!   "error": "Tool not found"
36//! }
37//! ```
38//!
39//! # PII Protection
40//!
41//! When enabled, the handler automatically:
42//! 1. Detects PII patterns in request arguments
43//! 2. Tokenizes sensitive data before tool execution
44//! 3. De-tokenizes responses before returning to code
45//! 4. Maintains token mapping for the session
46
47use crate::utils::file_utils::{
48    parse_json_with_context, read_file_with_context, write_file_with_context,
49};
50use anyhow::{Context, Result};
51use std::path::PathBuf;
52use std::sync::Arc;
53use std::time::Duration;
54use tokio::fs;
55use tokio::time::sleep;
56use uuid::Uuid;
57
58use crate::tools::request_response::{ToolCallRequest, ToolCallResponse};
59
60const REQUEST_POLL_INTERVAL: Duration = Duration::from_millis(50);
61
62/// IPC request from executing code to executor.
63pub type ToolRequest = ToolCallRequest;
64
65/// IPC response from executor to executing code.
66pub type ToolResponse = ToolCallResponse;
67
68/// IPC handler for tool invocation between code and executor.
69pub struct ToolIpcHandler {
70    ipc_dir: PathBuf,
71    pii_tokenizer: Option<Arc<crate::exec::PiiTokenizer>>,
72}
73
74impl ToolIpcHandler {
75    /// Create a new IPC handler with the given directory.
76    pub fn new(ipc_dir: PathBuf) -> Self {
77        Self {
78            ipc_dir,
79            pii_tokenizer: None,
80        }
81    }
82
83    /// Create a new IPC handler with PII protection enabled.
84    pub fn with_pii_protection(ipc_dir: PathBuf) -> Result<Self> {
85        Ok(Self {
86            ipc_dir,
87            pii_tokenizer: Some(Arc::new(crate::exec::PiiTokenizer::new()?)),
88        })
89    }
90
91    /// Enable PII protection on existing handler.
92    pub fn enable_pii_protection(&mut self) -> Result<()> {
93        self.pii_tokenizer = Some(Arc::new(crate::exec::PiiTokenizer::new()?));
94        Ok(())
95    }
96
97    /// Read a tool request from the code.
98    pub async fn read_request(&self) -> Result<Option<ToolRequest>> {
99        let request_file = self.ipc_dir.join("request.json");
100
101        if !request_file.exists() {
102            return Ok(None);
103        }
104
105        let content = read_file_with_context(&request_file, "request file").await?;
106        let request: ToolRequest = parse_json_with_context(&content, "request JSON")?;
107
108        // Clean up request file
109        let _ = fs::remove_file(&request_file).await;
110
111        Ok(Some(request))
112    }
113
114    /// Process request for PII (tokenize if enabled).
115    pub fn process_request_for_pii(&self, request: &mut ToolRequest) -> Result<()> {
116        if let Some(tokenizer) = &self.pii_tokenizer {
117            let args_str =
118                serde_json::to_string(&request.args).context("failed to serialize request args")?;
119            let (tokenized, _) = tokenizer
120                .tokenize_string(&args_str)
121                .context("PII tokenization failed")?;
122            request.args = parse_json_with_context(&tokenized, "tokenized args")?;
123        }
124        Ok(())
125    }
126
127    /// Process response for PII (de-tokenize if enabled).
128    pub fn process_response_for_pii(&self, response: &mut ToolResponse) -> Result<()> {
129        if let Some(tokenizer) = &self.pii_tokenizer
130            && let Some(result) = &response.result
131        {
132            let result_str =
133                serde_json::to_string(result).context("failed to serialize response result")?;
134            let detokenized = tokenizer
135                .detokenize_string(&result_str)
136                .context("PII de-tokenization failed")?;
137            response.result = Some(parse_json_with_context(
138                &detokenized,
139                "de-tokenized result",
140            )?);
141        }
142        Ok(())
143    }
144
145    /// Write a tool response back to the code.
146    pub async fn write_response(&self, mut response: ToolResponse) -> Result<()> {
147        // De-tokenize response before writing back to code
148        self.process_response_for_pii(&mut response)?;
149
150        let response_file = self.ipc_dir.join("response.json");
151
152        let json = serde_json::to_string(&response).context("failed to serialize response")?;
153
154        write_file_with_context(&response_file, &json, "response file").await?;
155
156        Ok(())
157    }
158
159    /// Wait for a request with timeout.
160    pub async fn wait_for_request(&self, timeout: Duration) -> Result<Option<ToolRequest>> {
161        let start = std::time::Instant::now();
162
163        loop {
164            if let Some(request) = self.read_request().await? {
165                return Ok(Some(request));
166            }
167
168            let Some(remaining_timeout) = timeout.checked_sub(start.elapsed()) else {
169                return Ok(None);
170            };
171
172            sleep(remaining_timeout.min(REQUEST_POLL_INTERVAL)).await;
173        }
174    }
175
176    /// Create a request ID.
177    pub fn new_request_id() -> String {
178        Uuid::new_v4().to_string()
179    }
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185    use serde_json::json;
186    use tempfile::tempdir;
187    use tokio::time::Instant;
188
189    #[test]
190    fn serialize_tool_request() {
191        let request = ToolRequest {
192            id: "test-id".into(),
193            tool_name: "read_file".into(),
194            args: json!({"path": "/test"}),
195            metadata: None,
196        };
197
198        let json = serde_json::to_string(&request).expect("ToolRequest should serialize");
199        assert!(json.contains("test-id"));
200        assert!(json.contains("read_file"));
201    }
202
203    #[test]
204    fn serialize_success_response() {
205        let response = ToolResponse {
206            id: "test-id".into(),
207            success: true,
208            result: Some(json!({"data": "test"})),
209            error: None,
210            duration_ms: None,
211            cache_hit: None,
212        };
213
214        let json = serde_json::to_string(&response).expect("ToolResponse should serialize");
215        assert!(json.contains("test-id"));
216        assert!(json.contains("true"));
217        assert!(!json.contains("error"));
218    }
219
220    #[test]
221    fn serialize_error_response() {
222        let response = ToolResponse {
223            id: "test-id".into(),
224            success: false,
225            result: None,
226            error: Some("File not found".into()),
227            duration_ms: None,
228            cache_hit: None,
229        };
230
231        let json = serde_json::to_string(&response).expect("ToolResponse should serialize");
232        assert!(json.contains("test-id"));
233        assert!(json.contains("false"));
234        assert!(json.contains("File not found"));
235    }
236
237    #[tokio::test]
238    async fn wait_for_request_reads_delayed_request() {
239        let temp_dir = tempdir().expect("temp dir should create");
240        let handler = ToolIpcHandler::new(temp_dir.path().to_path_buf());
241        let request = ToolRequest {
242            id: "test-id".into(),
243            tool_name: "read_file".into(),
244            args: json!({"path": "/tmp/test"}),
245            metadata: None,
246        };
247        let request_json =
248            serde_json::to_string(&request).expect("request should serialize to JSON");
249        let request_path = temp_dir.path().join("request.json");
250
251        tokio::spawn(async move {
252            sleep(Duration::from_millis(10)).await;
253            fs::write(request_path, request_json)
254                .await
255                .expect("request file should write");
256        });
257
258        let received = handler
259            .wait_for_request(Duration::from_millis(200))
260            .await
261            .expect("request wait should succeed");
262
263        assert_eq!(received.expect("request should arrive").id, "test-id");
264    }
265
266    #[tokio::test]
267    async fn wait_for_request_respects_short_timeout() {
268        let temp_dir = tempdir().expect("temp dir should create");
269        let handler = ToolIpcHandler::new(temp_dir.path().to_path_buf());
270        let start = Instant::now();
271
272        let received = handler
273            .wait_for_request(Duration::from_millis(5))
274            .await
275            .expect("request wait should succeed");
276
277        assert!(received.is_none());
278        assert!(start.elapsed() < Duration::from_millis(40));
279    }
280}