Skip to main content

claude_repl_mcp/
lib.rs

1#![forbid(unsafe_code)]
2#![warn(missing_docs)]
3use std::path::{Path, PathBuf};
4use std::process::Stdio;
5
6use rmcp::handler::server::router::tool::ToolRouter;
7use rmcp::handler::server::tool::ToolCallContext;
8use rmcp::handler::server::wrapper::Parameters;
9use rmcp::model::{
10    CallToolRequestParams, CallToolResult, Implementation, ListToolsResult, PaginatedRequestParams,
11    ServerCapabilities, ServerInfo,
12};
13use rmcp::service::{RequestContext, RoleServer};
14use rmcp::{ErrorData as McpError, ServerHandler, tool, tool_router};
15use schemars::JsonSchema;
16use serde::{Deserialize, Serialize};
17use tokio::io::AsyncReadExt;
18use tokio::process::Command;
19
20#[derive(Debug, nexcore_error::Error)]
21pub enum BridgeError {
22    #[error("claude cli not found at {0}")]
23    MissingClaudeCli(String),
24    #[error("failed to spawn claude cli: {0}")]
25    SpawnFailed(String),
26    #[error("claude cli failed: {0}")]
27    CliFailed(String),
28    #[error("claude cli timed out after {0}ms")]
29    Timeout(u64),
30}
31
32#[derive(Debug, Clone)]
33pub struct ClaudeCliPath(PathBuf);
34
35impl ClaudeCliPath {
36    pub fn discover() -> Result<Self, BridgeError> {
37        if let Ok(path) = std::env::var("CLAUDE_CLI_PATH") {
38            let pb = PathBuf::from(path);
39            return Self::validate(pb);
40        }
41        Self::validate(PathBuf::from("/home/matthew/.local/bin/claude"))
42    }
43
44    fn validate(path: PathBuf) -> Result<Self, BridgeError> {
45        if path.exists() {
46            Ok(Self(path))
47        } else {
48            Err(BridgeError::MissingClaudeCli(path.display().to_string()))
49        }
50    }
51
52    fn as_path(&self) -> &Path {
53        &self.0
54    }
55}
56
57#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
58pub struct ClaudeReplParams {
59    pub prompt: String,
60    pub model: Option<String>,
61    pub session_id: Option<String>,
62    pub settings_path: Option<String>,
63    pub mcp_config_path: Option<String>,
64    pub strict_mcp_config: Option<bool>,
65    pub permission_mode: Option<String>,
66    pub allowed_tools: Option<Vec<String>>,
67    pub output_format: Option<String>,
68    pub system_prompt: Option<String>,
69    pub append_system_prompt: Option<String>,
70    pub persist_session: Option<bool>,
71    pub timeout_ms: Option<u64>,
72    pub max_output_bytes: Option<usize>,
73}
74
75#[derive(Clone)]
76pub struct ClaudeReplMcpServer {
77    tool_router: ToolRouter<Self>,
78}
79
80#[tool_router]
81impl ClaudeReplMcpServer {
82    #[must_use]
83    pub fn new() -> Self {
84        Self {
85            tool_router: Self::tool_router(),
86        }
87    }
88
89    #[tool(
90        description = "Bridge to Claude Code CLI. Provide {prompt} and optional session/model/settings."
91    )]
92    async fn claude_repl(
93        &self,
94        Parameters(params): Parameters<ClaudeReplParams>,
95    ) -> Result<CallToolResult, McpError> {
96        match run_claude(params).await {
97            Ok(output) => Ok(CallToolResult::success(vec![rmcp::model::Content::text(
98                output,
99            )])),
100            Err(err) => Ok(CallToolResult::success(vec![rmcp::model::Content::text(
101                err.to_string(),
102            )])),
103        }
104    }
105}
106
107impl Default for ClaudeReplMcpServer {
108    fn default() -> Self {
109        Self::new()
110    }
111}
112
113impl ServerHandler for ClaudeReplMcpServer {
114    fn get_info(&self) -> ServerInfo {
115        ServerInfo {
116            instructions: Some(
117                r#"Claude REPL MCP Bridge
118
119Provides a single tool `claude_repl` that forwards prompts to the Claude Code CLI.
120"#
121                .into(),
122            ),
123            capabilities: ServerCapabilities::builder().enable_tools().build(),
124            server_info: Implementation {
125                name: "claude-repl-mcp".into(),
126                version: env!("CARGO_PKG_VERSION").into(),
127                title: Some("Claude REPL MCP Bridge".into()),
128                icons: None,
129                website_url: None,
130            },
131            ..Default::default()
132        }
133    }
134
135    fn call_tool(
136        &self,
137        request: CallToolRequestParams,
138        context: RequestContext<RoleServer>,
139    ) -> impl std::future::Future<Output = Result<CallToolResult, McpError>> + Send + '_ {
140        async move {
141            let tcc = ToolCallContext::new(self, request, context);
142            let result = self.tool_router.call(tcc).await?;
143            Ok(result)
144        }
145    }
146
147    fn list_tools(
148        &self,
149        _request: Option<PaginatedRequestParams>,
150        _context: RequestContext<RoleServer>,
151    ) -> impl std::future::Future<Output = Result<ListToolsResult, McpError>> + Send + '_ {
152        std::future::ready(Ok(ListToolsResult {
153            tools: self.tool_router.list_all(),
154            meta: None,
155            next_cursor: None,
156        }))
157    }
158}
159
160async fn run_claude(params: ClaudeReplParams) -> Result<String, BridgeError> {
161    let cli = ClaudeCliPath::discover()?;
162
163    let mut cmd = Command::new(cli.as_path());
164    cmd.arg("--print");
165
166    let output_format = params.output_format.unwrap_or_else(|| "text".to_string());
167    cmd.arg("--output-format").arg(output_format);
168
169    if let Some(model) = params.model {
170        cmd.arg("--model").arg(model);
171    }
172
173    if let Some(session_id) = params.session_id {
174        cmd.arg("--session-id").arg(session_id);
175    } else if params.persist_session == Some(false) {
176        cmd.arg("--no-session-persistence");
177    }
178
179    if let Some(settings_path) = params.settings_path {
180        cmd.arg("--settings").arg(settings_path);
181    }
182
183    if let Some(mcp_config_path) = params.mcp_config_path {
184        cmd.arg("--mcp-config").arg(mcp_config_path);
185        if params.strict_mcp_config.unwrap_or(false) {
186            cmd.arg("--strict-mcp-config");
187        }
188    }
189
190    if let Some(permission_mode) = params.permission_mode {
191        cmd.arg("--permission-mode").arg(permission_mode);
192    }
193
194    if let Some(allowed) = params.allowed_tools {
195        if !allowed.is_empty() {
196            cmd.arg("--allowedTools").arg(allowed.join(","));
197        }
198    }
199
200    if let Some(system_prompt) = params.system_prompt {
201        cmd.arg("--system-prompt").arg(system_prompt);
202    }
203
204    if let Some(append_system_prompt) = params.append_system_prompt {
205        cmd.arg("--append-system-prompt").arg(append_system_prompt);
206    }
207
208    cmd.arg(params.prompt);
209
210    cmd.stdin(Stdio::null());
211    let mut child = cmd
212        .stdout(Stdio::piped())
213        .stderr(Stdio::piped())
214        .spawn()
215        .map_err(|err| BridgeError::SpawnFailed(err.to_string()))?;
216
217    let mut stdout = child
218        .stdout
219        .take()
220        .ok_or_else(|| BridgeError::SpawnFailed("missing stdout".to_string()))?;
221    let mut stderr = child
222        .stderr
223        .take()
224        .ok_or_else(|| BridgeError::SpawnFailed("missing stderr".to_string()))?;
225
226    let stdout_task = tokio::spawn(async move {
227        let mut buf = Vec::new();
228        stdout
229            .read_to_end(&mut buf)
230            .await
231            .map_err(|err| BridgeError::CliFailed(err.to_string()))?;
232        Ok::<Vec<u8>, BridgeError>(buf)
233    });
234
235    let stderr_task = tokio::spawn(async move {
236        let mut buf = Vec::new();
237        stderr
238            .read_to_end(&mut buf)
239            .await
240            .map_err(|err| BridgeError::CliFailed(err.to_string()))?;
241        Ok::<Vec<u8>, BridgeError>(buf)
242    });
243
244    let status = if let Some(timeout_ms) = params.timeout_ms {
245        match tokio::time::timeout(std::time::Duration::from_millis(timeout_ms), child.wait()).await
246        {
247            Ok(result) => result.map_err(|err| BridgeError::CliFailed(err.to_string()))?,
248            Err(_) => {
249                let _ = child.kill().await;
250                return Err(BridgeError::Timeout(timeout_ms));
251            }
252        }
253    } else {
254        child
255            .wait()
256            .await
257            .map_err(|err| BridgeError::CliFailed(err.to_string()))?
258    };
259
260    let stdout_buf = stdout_task
261        .await
262        .map_err(|err| BridgeError::CliFailed(err.to_string()))??;
263    let stderr_buf = stderr_task
264        .await
265        .map_err(|err| BridgeError::CliFailed(err.to_string()))??;
266
267    let output = std::process::Output {
268        status,
269        stdout: stdout_buf,
270        stderr: stderr_buf,
271    };
272
273    if !output.status.success() {
274        let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
275        let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
276        let combined = if stderr.is_empty() { stdout } else { stderr };
277        return Err(BridgeError::CliFailed(combined));
278    }
279
280    let max_bytes = params.max_output_bytes.unwrap_or(1_000_000);
281    let mut out = output.stdout;
282    if out.len() > max_bytes {
283        out.truncate(max_bytes);
284    }
285    Ok(String::from_utf8_lossy(&out).to_string())
286}
287
288#[cfg(test)]
289mod tests {
290    use super::*;
291
292    #[test]
293    fn serialize_params() {
294        let params = ClaudeReplParams {
295            prompt: "hello".to_string(),
296            model: Some("sonnet".to_string()),
297            session_id: None,
298            settings_path: None,
299            mcp_config_path: None,
300            strict_mcp_config: None,
301            permission_mode: None,
302            allowed_tools: None,
303            output_format: None,
304            system_prompt: None,
305            append_system_prompt: None,
306            persist_session: Some(false),
307            timeout_ms: Some(2000),
308            max_output_bytes: Some(1024),
309        };
310        let json = serde_json::to_string(&params).expect("serialize");
311        assert!(json.contains("\"prompt\""));
312    }
313}