Skip to main content

claude_utils/mcp/
server.rs

1use axum::response::sse::{Event, KeepAlive, Sse};
2use axum::{
3    extract::{Query, State},
4    http::{header, HeaderMap, StatusCode},
5    response::{IntoResponse, Response},
6    routing::{get, post},
7    Json, Router,
8};
9use serde::Deserialize;
10use serde_json::{json, Value};
11use std::sync::Arc;
12use std::time::Duration;
13use tokio::sync::RwLock;
14use tower_http::cors::CorsLayer;
15use tracing::{error, info};
16
17use crate::{
18    clipboard::{ClipboardContent, ClipboardManager},
19    file_manager::FileManager,
20    mcp::{auth::AuthManager, protocol::*},
21    ClaudeUtilsError, Result,
22};
23
24#[derive(Clone)]
25pub struct McpServerState {
26    pub clipboard: Arc<ClipboardManager>,
27    pub file_manager: Arc<FileManager>,
28    pub auth_manager: Arc<AuthManager>,
29    pub initialized: Arc<RwLock<bool>>,
30}
31
32#[derive(Debug, Deserialize)]
33pub struct AuthQuery {
34    token: Option<String>,
35}
36
37pub struct McpServer {
38    state: McpServerState,
39    port: u16,
40    host: String,
41}
42
43impl McpServer {
44    pub async fn new(
45        clipboard: Arc<ClipboardManager>,
46        file_manager: Arc<FileManager>,
47        auth_manager: AuthManager,
48        port: u16,
49        host: String,
50    ) -> Result<Self> {
51        let state = McpServerState {
52            clipboard,
53            file_manager,
54            auth_manager: Arc::new(auth_manager),
55            initialized: Arc::new(RwLock::new(false)),
56        };
57
58        Ok(Self { state, port, host })
59    }
60
61    pub async fn run(self) -> Result<()> {
62        let app = Router::new()
63            .route("/health", get(health_handler))
64            .route("/", post(jsonrpc_handler))
65            .route("/rpc", post(jsonrpc_handler))
66            .route("/sse", get(sse_handler))
67            .layer(CorsLayer::permissive())
68            .with_state(self.state);
69
70        let addr = format!("{}:{}", self.host, self.port);
71        let listener = tokio::net::TcpListener::bind(&addr)
72            .await
73            .map_err(|e| ClaudeUtilsError::Server(format!("Failed to bind to {addr}: {e}")))?;
74
75        info!("MCP server listening on http://{}", addr);
76
77        axum::serve(listener, app)
78            .await
79            .map_err(|e| ClaudeUtilsError::Server(e.to_string()))?;
80
81        Ok(())
82    }
83}
84
85// Health check endpoint
86async fn health_handler(State(state): State<McpServerState>) -> impl IntoResponse {
87    let token = state.auth_manager.get_token().await;
88
89    Json(json!({
90        "status": "healthy",
91        "version": env!("CARGO_PKG_VERSION"),
92        "platform": std::env::consts::OS,
93        "capabilities": ["text", "image", "watch"],
94        "auth_required": token.is_some(),
95    }))
96}
97
98// Main JSON-RPC handler
99async fn jsonrpc_handler(
100    State(state): State<McpServerState>,
101    headers: HeaderMap,
102    Json(request): Json<Value>,
103) -> Response {
104    // Check authentication
105    let auth_header = headers
106        .get(header::AUTHORIZATION)
107        .and_then(|h| h.to_str().ok())
108        .and_then(|h| h.strip_prefix("Bearer "));
109
110    if !state.auth_manager.validate_token(auth_header).await {
111        return (
112            StatusCode::UNAUTHORIZED,
113            Json(create_error_response(
114                None,
115                -32000,
116                "Authentication required".to_string(),
117            )),
118        )
119            .into_response();
120    }
121
122    // Handle batch requests
123    if request.is_array() {
124        let requests = request.as_array().unwrap();
125        let mut responses = Vec::new();
126
127        for req in requests {
128            if let Ok(rpc_req) = serde_json::from_value::<JsonRpcRequest>(req.clone()) {
129                responses.push(handle_single_request(state.clone(), rpc_req).await);
130            }
131        }
132
133        return Json(Value::Array(
134            responses
135                .into_iter()
136                .map(|r| serde_json::to_value(r).unwrap())
137                .collect(),
138        ))
139        .into_response();
140    }
141
142    // Handle single request
143    match serde_json::from_value::<JsonRpcRequest>(request) {
144        Ok(rpc_req) => {
145            let response = handle_single_request(state, rpc_req).await;
146            Json(response).into_response()
147        }
148        Err(_) => Json(create_error_response(
149            None,
150            PARSE_ERROR,
151            "Invalid JSON-RPC request".to_string(),
152        ))
153        .into_response(),
154    }
155}
156
157async fn handle_single_request(state: McpServerState, request: JsonRpcRequest) -> JsonRpcResponse {
158    match request.method.as_str() {
159        INITIALIZE => handle_initialize(state, request).await,
160        INITIALIZED => handle_initialized(state, request).await,
161        TOOLS_LIST => handle_tools_list(state, request).await,
162        TOOLS_CALL => handle_tools_call(state, request).await,
163        _ => create_error_response(
164            request.id,
165            METHOD_NOT_FOUND,
166            format!("Method not found: {}", request.method),
167        ),
168    }
169}
170
171async fn handle_initialize(_state: McpServerState, request: JsonRpcRequest) -> JsonRpcResponse {
172    let response = InitializeResponse {
173        protocol_version: "1.0".to_string(),
174        capabilities: ServerCapabilities {
175            tools: Some(ToolsCapability {}),
176            resources: None,
177            prompts: None,
178        },
179        server_info: Some(ServerInfo {
180            name: "claude-utils-clipboard".to_string(),
181            version: Some(env!("CARGO_PKG_VERSION").to_string()),
182        }),
183    };
184
185    create_success_response(request.id, serde_json::to_value(response).unwrap())
186}
187
188async fn handle_initialized(state: McpServerState, request: JsonRpcRequest) -> JsonRpcResponse {
189    *state.initialized.write().await = true;
190    info!("MCP server initialized");
191    create_success_response(request.id, json!({}))
192}
193
194async fn handle_tools_list(_state: McpServerState, request: JsonRpcRequest) -> JsonRpcResponse {
195    let tools = vec![
196        Tool {
197            name: "clipboard.get".to_string(),
198            description: "Get current clipboard content (text or image)".to_string(),
199            input_schema: json!({
200                "type": "object",
201                "properties": {
202                    "format": {
203                        "type": "string",
204                        "enum": ["auto", "text", "image"],
205                        "description": "Preferred format (auto detects automatically)",
206                        "default": "auto"
207                    }
208                },
209                "required": []
210            }),
211        },
212        Tool {
213            name: "clipboard.set".to_string(),
214            description: "Set clipboard content (requires --write flag)".to_string(),
215            input_schema: json!({
216                "type": "object",
217                "properties": {
218                    "type": {
219                        "type": "string",
220                        "enum": ["text/plain", "image/png"],
221                        "description": "Content type"
222                    },
223                    "data": {
224                        "type": "string",
225                        "description": "Content data (text or base64 for images)"
226                    }
227                },
228                "required": ["type", "data"]
229            }),
230        },
231    ];
232
233    let response = ToolListResponse { tools };
234    create_success_response(request.id, serde_json::to_value(response).unwrap())
235}
236
237async fn handle_tools_call(state: McpServerState, request: JsonRpcRequest) -> JsonRpcResponse {
238    let params = match request.params {
239        Some(p) => p,
240        None => {
241            return create_error_response(
242                request.id,
243                INVALID_PARAMS,
244                "Missing parameters".to_string(),
245            )
246        }
247    };
248
249    let tool_request: ToolCallRequest = match serde_json::from_value(params) {
250        Ok(r) => r,
251        Err(e) => {
252            return create_error_response(
253                request.id,
254                INVALID_PARAMS,
255                format!("Invalid parameters: {e}"),
256            )
257        }
258    };
259
260    match tool_request.name.as_str() {
261        "clipboard.get" => handle_clipboard_get(state, request.id, tool_request.arguments).await,
262        "clipboard.set" => handle_clipboard_set(state, request.id, tool_request.arguments).await,
263        _ => create_error_response(
264            request.id,
265            METHOD_NOT_FOUND,
266            format!("Unknown tool: {}", tool_request.name),
267        ),
268    }
269}
270
271async fn handle_clipboard_get(
272    state: McpServerState,
273    id: Option<Value>,
274    _args: Option<Value>,
275) -> JsonRpcResponse {
276    // Get clipboard content
277    let clipboard_data = match state.clipboard.get_content() {
278        Ok(data) => data,
279        Err(e) => {
280            return create_error_response(id, INTERNAL_ERROR, format!("Clipboard error: {e}"))
281        }
282    };
283
284    // Handle image staging if needed
285    let final_content = match &clipboard_data.content {
286        ClipboardContent::ImagePng {
287            data: None,
288            width,
289            height,
290            size,
291            ..
292        }
293        | ClipboardContent::ImageJpeg {
294            data: None,
295            width,
296            height,
297            size,
298            ..
299        } => {
300            // Need to stage the image
301            match state.clipboard.get_raw_image() {
302                Ok(image_data) => {
303                    match state.file_manager.stage_image(&image_data, "png").await {
304                        Ok(staged) => {
305                            // Update content with file path
306                            match clipboard_data.content {
307                                ClipboardContent::ImagePng { .. } => ClipboardContent::ImagePng {
308                                    data: None,
309                                    file: Some(staged.path.to_string_lossy().to_string()),
310                                    width: *width,
311                                    height: *height,
312                                    size: *size,
313                                },
314                                ClipboardContent::ImageJpeg { .. } => ClipboardContent::ImageJpeg {
315                                    data: None,
316                                    file: Some(staged.path.to_string_lossy().to_string()),
317                                    width: *width,
318                                    height: *height,
319                                    size: *size,
320                                },
321                                _ => clipboard_data.content.clone(),
322                            }
323                        }
324                        Err(e) => {
325                            error!("Failed to stage image: {}", e);
326                            clipboard_data.content.clone()
327                        }
328                    }
329                }
330                Err(e) => {
331                    error!("Failed to get raw image: {}", e);
332                    clipboard_data.content.clone()
333                }
334            }
335        }
336        _ => clipboard_data.content.clone(),
337    };
338
339    // Create response
340    let response_data = json!({
341        "content": final_content,
342        "metadata": clipboard_data.metadata,
343    });
344
345    let tool_response = ToolCallResponse {
346        content: vec![Content::Text {
347            text: serde_json::to_string_pretty(&response_data).unwrap(),
348        }],
349    };
350
351    create_success_response(id, serde_json::to_value(tool_response).unwrap())
352}
353
354async fn handle_clipboard_set(
355    state: McpServerState,
356    id: Option<Value>,
357    args: Option<Value>,
358) -> JsonRpcResponse {
359    // TODO: Check for --write flag permission
360
361    #[derive(Deserialize)]
362    struct SetArgs {
363        r#type: String,
364        data: String,
365    }
366
367    let args: SetArgs = match args.and_then(|a| serde_json::from_value(a).ok()) {
368        Some(a) => a,
369        None => return create_error_response(id, INVALID_PARAMS, "Invalid arguments".to_string()),
370    };
371
372    let content = match args.r#type.as_str() {
373        "text/plain" => ClipboardContent::Text {
374            data: args.data,
375            truncated: None,
376        },
377        "image/png" => ClipboardContent::ImagePng {
378            data: Some(args.data),
379            file: None,
380            width: 0, // Will be updated by clipboard manager
381            height: 0,
382            size: 0,
383        },
384        _ => {
385            return create_error_response(
386                id,
387                INVALID_PARAMS,
388                format!("Unsupported type: {}", args.r#type),
389            )
390        }
391    };
392
393    match state.clipboard.set_content(&content) {
394        Ok(_) => {
395            let tool_response = ToolCallResponse {
396                content: vec![Content::Text {
397                    text: "Clipboard updated successfully".to_string(),
398                }],
399            };
400            create_success_response(id, serde_json::to_value(tool_response).unwrap())
401        }
402        Err(e) => {
403            create_error_response(id, INTERNAL_ERROR, format!("Failed to set clipboard: {e}"))
404        }
405    }
406}
407
408// SSE handler for real-time updates
409async fn sse_handler(
410    State(state): State<McpServerState>,
411    Query(auth): Query<AuthQuery>,
412) -> std::result::Result<impl IntoResponse, StatusCode> {
413    // Check authentication
414    if !state
415        .auth_manager
416        .validate_token(auth.token.as_deref())
417        .await
418    {
419        return Err(StatusCode::UNAUTHORIZED);
420    }
421
422    let stream = async_stream::stream! {
423        loop {
424            tokio::time::sleep(Duration::from_secs(30)).await;
425            yield Ok::<_, anyhow::Error>(Event::default()
426                .data("heartbeat")
427                .event("ping"));
428        }
429    };
430
431    Ok(Sse::new(stream).keep_alive(KeepAlive::default()))
432}