ftl_sdk_rs/
spin.rs

1use spin_sdk::http::{Method, Request, Response};
2
3use crate::{server::McpServer, tool::Tool, types::JsonRpcRequest};
4
5/// Create a handler function for serving a single FTL SDK tool as an MCP
6/// server over HTTP
7///
8/// This function handles all HTTP/CORS details and delegates MCP protocol
9/// handling to the McpServer.
10pub fn create_handler<T: Tool + 'static>(
11    tool: T,
12) -> impl Fn(Request) -> Result<Response, String> + Clone {
13    move |req: Request| -> Result<Response, String> {
14        let server = McpServer::new(tool.clone());
15        handle_mcp_request(server, req)
16    }
17}
18
19fn handle_mcp_request<T: Tool>(server: McpServer<T>, req: Request) -> Result<Response, String> {
20    // Handle CORS preflight requests
21    if *req.method() == Method::Options {
22        return Ok(Response::builder()
23            .status(200)
24            .header("Access-Control-Allow-Origin", "*")
25            .header("Access-Control-Allow-Methods", "POST, OPTIONS")
26            .header("Access-Control-Allow-Headers", "Content-Type")
27            .header("Content-Type", "application/json")
28            .body("")
29            .build());
30    }
31
32    // Only handle POST requests for JSON-RPC
33    if *req.method() != Method::Post {
34        return Ok(Response::builder()
35            .status(405)
36            .header("Access-Control-Allow-Origin", "*")
37            .header("Access-Control-Allow-Methods", "POST, OPTIONS")
38            .header("Access-Control-Allow-Headers", "Content-Type")
39            .header("Content-Type", "application/json")
40            .body("Method not allowed")
41            .build());
42    }
43
44    // Parse the JSON-RPC request
45    match read_request_body(&req) {
46        Ok(body_str) => match serde_json::from_str::<JsonRpcRequest>(&body_str) {
47            Ok(json_req) => {
48                let response_data = server.handle_request(json_req);
49                let response_json = serde_json::to_string(&response_data)
50                    .map_err(|e| format!("Failed to serialize response: {e}"))?;
51
52                Ok(Response::builder()
53                    .status(200)
54                    .header("Access-Control-Allow-Origin", "*")
55                    .header("Access-Control-Allow-Methods", "POST, OPTIONS")
56                    .header("Access-Control-Allow-Headers", "Content-Type")
57                    .header("Content-Type", "application/json")
58                    .body(response_json)
59                    .build())
60            }
61            Err(e) => {
62                eprintln!("Failed to parse JSON-RPC request: {e}");
63                let error_response =
64                    crate::types::JsonRpcResponse::error(None, -32700, "Parse error");
65
66                let response_json = serde_json::to_string(&error_response)
67                    .map_err(|e| format!("Failed to serialize error response: {e}"))?;
68                Ok(Response::builder()
69                    .status(400)
70                    .header("Access-Control-Allow-Origin", "*")
71                    .header("Access-Control-Allow-Methods", "POST, OPTIONS")
72                    .header("Access-Control-Allow-Headers", "Content-Type")
73                    .header("Content-Type", "application/json")
74                    .body(response_json)
75                    .build())
76            }
77        },
78        Err(e) => {
79            eprintln!("Failed to read request body: {e}");
80            let error_response =
81                crate::types::JsonRpcResponse::error(None, -32700, "Failed to read request body");
82
83            let response_json = serde_json::to_string(&error_response)
84                .map_err(|e| format!("Failed to serialize error response: {e}"))?;
85            Ok(Response::builder()
86                .status(400)
87                .header("Access-Control-Allow-Origin", "*")
88                .header("Access-Control-Allow-Methods", "POST, OPTIONS")
89                .header("Access-Control-Allow-Headers", "Content-Type")
90                .header("Content-Type", "application/json")
91                .body(response_json)
92                .build())
93        }
94    }
95}
96
97fn read_request_body(req: &Request) -> Result<String, String> {
98    String::from_utf8(req.body().to_vec())
99        .map_err(|e| format!("Failed to parse request body as UTF-8: {e}"))
100}
101
102/// Macro to create the main entry point for a tool server
103///
104/// Example:
105/// ```rust
106/// use ftl_sdk_rs::prelude::*;
107///
108/// #[derive(Clone)]
109/// struct MyTool;
110///
111/// impl Tool for MyTool {
112///     fn name(&self) -> &'static str {
113///         "my-tool"
114///     }
115///     fn description(&self) -> &'static str {
116///         "Example tool"
117///     }
118///     fn input_schema(&self) -> serde_json::Value {
119///         serde_json::json!({})
120///     }
121///     fn call(&self, _args: &serde_json::Value) -> Result<ToolResult, ToolError> {
122///         Ok(ToolResult::text("Hello".to_string()))
123///     }
124/// }
125///
126/// // Then use the macro:
127/// // ftl_mcp_server!(MyTool);
128/// ```
129#[macro_export]
130macro_rules! ftl_mcp_server {
131    ($tool:expr) => {
132        #[spin_sdk::http_component]
133        fn handle_mcp_request(req: spin_sdk::http::Request) -> spin_sdk::http::Response {
134            let server = $crate::McpServer::new($tool);
135
136            // Helper function to create error responses
137            let create_error_response = |status: u16, message: &str| {
138                spin_sdk::http::Response::builder()
139                    .status(status)
140                    .header("Access-Control-Allow-Origin", "*")
141                    .header("Access-Control-Allow-Methods", "POST, OPTIONS")
142                    .header("Access-Control-Allow-Headers", "Content-Type")
143                    .header("Content-Type", "application/json")
144                    .body(message)
145                    .build()
146            };
147
148            match req.method() {
149                &spin_sdk::http::Method::Options => spin_sdk::http::Response::builder()
150                    .status(200)
151                    .header("Access-Control-Allow-Origin", "*")
152                    .header("Access-Control-Allow-Methods", "POST, OPTIONS")
153                    .header("Access-Control-Allow-Headers", "Content-Type")
154                    .header("Content-Type", "application/json")
155                    .body("")
156                    .build(),
157                &spin_sdk::http::Method::Post => {
158                    let body_str = match String::from_utf8(req.body().to_vec()) {
159                        Ok(s) => s,
160                        Err(_) => {
161                            return create_error_response(400, "Invalid UTF-8 in request body");
162                        }
163                    };
164
165                    match serde_json::from_str::<$crate::JsonRpcRequest>(&body_str) {
166                        Ok(json_req) => {
167                            let response_data = server.handle_request(json_req);
168                            match serde_json::to_string(&response_data) {
169                                Ok(response_json) => spin_sdk::http::Response::builder()
170                                    .status(200)
171                                    .header("Access-Control-Allow-Origin", "*")
172                                    .header("Access-Control-Allow-Methods", "POST, OPTIONS")
173                                    .header("Access-Control-Allow-Headers", "Content-Type")
174                                    .header("Content-Type", "application/json")
175                                    .body(response_json)
176                                    .build(),
177                                Err(_) => {
178                                    create_error_response(500, "Failed to serialize response")
179                                }
180                            }
181                        }
182                        Err(_) => {
183                            let error_response =
184                                $crate::JsonRpcResponse::error(None, -32700, "Parse error");
185                            match serde_json::to_string(&error_response) {
186                                Ok(response_json) => spin_sdk::http::Response::builder()
187                                    .status(400)
188                                    .header("Access-Control-Allow-Origin", "*")
189                                    .header("Access-Control-Allow-Methods", "POST, OPTIONS")
190                                    .header("Access-Control-Allow-Headers", "Content-Type")
191                                    .header("Content-Type", "application/json")
192                                    .body(response_json)
193                                    .build(),
194                                Err(_) => {
195                                    create_error_response(500, "Failed to serialize error response")
196                                }
197                            }
198                        }
199                    }
200                }
201                _ => create_error_response(405, "Method not allowed"),
202            }
203        }
204    };
205}