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(¶ms).expect("serialize");
311 assert!(json.contains("\"prompt\""));
312 }
313}