diff --git a/Cargo.toml b/Cargo.toml index c37c183..c8a5a5e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -39,6 +39,8 @@ urlencoding = "2.1" axum = "0.7" tower = "0.5" tower-http = { version = "0.5", features = ["cors", "fs"] } +axum-extra = { version = "0.9", features = ["typed-header"] } +tracing = "0.1" hyper = { version = "1.0", features = ["full"] } tower-sessions = "0.12" jsonwebtoken = "9.2" diff --git a/src/main.rs b/src/main.rs index 4172a1e..d202a4c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -79,6 +79,15 @@ enum Commands { /// Path to the blog directory #[arg(default_value = ".")] path: PathBuf, + /// Enable Claude proxy mode + #[arg(long)] + claude_proxy: bool, + /// API token for Claude proxy authentication + #[arg(long)] + api_token: Option, + /// Claude Code executable path + #[arg(long, default_value = "claude")] + claude_code_path: String, }, /// Generate documentation from code Doc(commands::doc::DocCommand), @@ -203,9 +212,20 @@ async fn main() -> Result<()> { std::env::set_current_dir(path)?; commands::clean::execute().await?; } - Commands::Mcp { port, path } => { + Commands::Mcp { port, path, claude_proxy, api_token, claude_code_path } => { use crate::mcp::McpServer; - let server = McpServer::new(path); + let mut server = McpServer::new(path); + + if claude_proxy { + let token = api_token + .or_else(|| std::env::var("CLAUDE_PROXY_API_TOKEN").ok()) + .ok_or_else(|| { + anyhow::anyhow!("API token is required when --claude-proxy is enabled. Set CLAUDE_PROXY_API_TOKEN environment variable or use --api-token") + })?; + server = server.with_claude_proxy(token, Some(claude_code_path.clone())); + println!("Claude proxy mode enabled - using Claude Code executable: {}", claude_code_path); + } + server.serve(port).await?; } Commands::Doc(doc_cmd) => { diff --git a/src/mcp/claude_proxy.rs b/src/mcp/claude_proxy.rs new file mode 100644 index 0000000..25727a8 --- /dev/null +++ b/src/mcp/claude_proxy.rs @@ -0,0 +1,156 @@ +use anyhow::Result; +use axum::{ + extract::State, + http::StatusCode, + response::Json, +}; +use axum_extra::{ + headers::{authorization::Bearer, Authorization}, + TypedHeader, +}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +// Removed unused import + +#[derive(Debug, Deserialize)] +pub struct ChatRequest { + pub question: String, + #[serde(rename = "systemPrompt")] + pub system_prompt: String, + #[serde(default)] + pub context: Value, +} + +#[derive(Debug, Serialize)] +pub struct ChatResponse { + pub answer: String, +} + +#[derive(Clone)] +pub struct ClaudeProxyState { + pub api_token: String, + pub claude_code_path: String, +} + +pub async fn claude_chat_handler( + State(state): State, + auth: Option>>, + Json(request): Json, +) -> Result, StatusCode> { + // Claude proxyが有効かチェック + let claude_proxy = state.claude_proxy.as_ref().ok_or(StatusCode::NOT_FOUND)?; + + // 認証チェック + let auth = auth.ok_or(StatusCode::UNAUTHORIZED)?; + if auth.token() != claude_proxy.api_token { + return Err(StatusCode::UNAUTHORIZED); + } + + // Claude CodeのMCP通信実装 + let response = communicate_with_claude_mcp( + &request.question, + &request.system_prompt, + &request.context, + &claude_proxy.claude_code_path, + ).await?; + + Ok(Json(ChatResponse { answer: response })) +} + +async fn communicate_with_claude_mcp( + message: &str, + system: &str, + _context: &Value, + claude_code_path: &str, +) -> Result { + tracing::info!("Communicating with Claude Code via stdio"); + tracing::info!("Message: {}", message); + tracing::info!("System prompt: {}", system); + + // Claude Code MCPプロセスを起動 + // Use the full path to avoid shell function and don't use --continue + let claude_executable = if claude_code_path == "claude" { + "/Users/syui/.claude/local/claude" + } else { + claude_code_path + }; + + let mut child = tokio::process::Command::new(claude_executable) + .args(&["--print", "--output-format", "text"]) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .map_err(|e| { + tracing::error!("Failed to start Claude Code process: {}", e); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + // プロンプトを構築 + let full_prompt = if !system.is_empty() { + format!("{}\n\nUser: {}", system, message) + } else { + message.to_string() + }; + + // 標準入力にプロンプトを送信 + if let Some(stdin) = child.stdin.take() { + use tokio::io::AsyncWriteExt; + let mut stdin = stdin; + stdin.write_all(full_prompt.as_bytes()).await.map_err(|e| { + tracing::error!("Failed to write to Claude Code stdin: {}", e); + StatusCode::INTERNAL_SERVER_ERROR + })?; + stdin.shutdown().await.map_err(|e| { + tracing::error!("Failed to close Claude Code stdin: {}", e); + StatusCode::INTERNAL_SERVER_ERROR + })?; + } + + // プロセス完了を待機(タイムアウト付き) + let output = tokio::time::timeout( + tokio::time::Duration::from_secs(30), + child.wait_with_output() + ) + .await + .map_err(|_| { + tracing::error!("Claude Code process timed out"); + StatusCode::REQUEST_TIMEOUT + })? + .map_err(|e| { + tracing::error!("Claude Code process failed: {}", e); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + // プロセス終了ステータスをチェック + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + tracing::error!("Claude Code process failed with stderr: {}", stderr); + return Ok("Claude Codeプロセスでエラーが発生しました".to_string()); + } + + // 標準出力を解析 + let stdout = String::from_utf8_lossy(&output.stdout); + tracing::debug!("Claude Code stdout: {}", stdout); + + // Claude Codeは通常プレーンテキストを返すので、そのまま返す + Ok(stdout.trim().to_string()) +} + +pub async fn claude_tools_handler() -> Json { + Json(json!({ + "tools": { + "chat": { + "description": "Chat with Claude", + "inputSchema": { + "type": "object", + "properties": { + "message": {"type": "string"}, + "system": {"type": "string"} + }, + "required": ["message"] + } + } + } + })) +} \ No newline at end of file diff --git a/src/mcp/mod.rs b/src/mcp/mod.rs index 1fbb19f..6adb0e9 100644 --- a/src/mcp/mod.rs +++ b/src/mcp/mod.rs @@ -1,5 +1,6 @@ pub mod server; pub mod tools; pub mod types; +pub mod claude_proxy; pub use server::McpServer; \ No newline at end of file diff --git a/src/mcp/server.rs b/src/mcp/server.rs index 47631f9..b525272 100644 --- a/src/mcp/server.rs +++ b/src/mcp/server.rs @@ -12,10 +12,12 @@ use std::sync::Arc; use tower_http::cors::CorsLayer; use crate::mcp::tools::BlogTools; use crate::mcp::types::{McpRequest, McpResponse, McpError, CreatePostRequest, ListPostsRequest, BuildRequest}; +use crate::mcp::claude_proxy::{claude_chat_handler, claude_tools_handler, ClaudeProxyState}; #[derive(Clone)] pub struct AppState { - blog_tools: Arc, + pub blog_tools: Arc, + pub claude_proxy: Option>, } pub struct McpServer { @@ -25,17 +27,31 @@ pub struct McpServer { impl McpServer { pub fn new(base_path: PathBuf) -> Self { let blog_tools = Arc::new(BlogTools::new(base_path)); - let app_state = AppState { blog_tools }; + let app_state = AppState { + blog_tools, + claude_proxy: None, + }; Self { app_state } } + pub fn with_claude_proxy(mut self, api_token: String, claude_code_path: Option) -> Self { + let claude_code_path = claude_code_path.unwrap_or_else(|| "claude".to_string()); + self.app_state.claude_proxy = Some(Arc::new(ClaudeProxyState { + api_token, + claude_code_path, + })); + self + } + pub fn create_router(&self) -> Router { Router::new() .route("/", get(root_handler)) .route("/mcp/tools/list", get(list_tools)) .route("/mcp/tools/call", post(call_tool)) .route("/health", get(health_check)) + .route("/api/claude-mcp", post(claude_chat_handler)) + .route("/claude/tools", get(claude_tools_handler)) .layer(CorsLayer::permissive()) .with_state(self.app_state.clone()) }