ftl-core 0.0.2

Core library for FTL tools - lightweight MCP server for WebAssembly
Documentation
use serde_json::{Value, json};

use crate::{
    tool::{Tool, ToolInfo},
    types::{JsonRpcError, JsonRpcRequest, JsonRpcResponse, McpError, ToolError},
};

pub struct McpServer<T: Tool> {
    tool: T,
}

impl<T: Tool> McpServer<T> {
    pub fn new(tool: T) -> Self {
        Self { tool }
    }

    pub fn handle_request(&self, request: JsonRpcRequest) -> JsonRpcResponse {
        let request_id = request.id.clone().unwrap_or(serde_json::Value::Null);
        match self.process_request(request) {
            Ok(result) => JsonRpcResponse {
                jsonrpc: "2.0".to_string(),
                result: Some(result),
                error: None,
                id: request_id,
            },
            Err(e) => JsonRpcResponse {
                jsonrpc: "2.0".to_string(),
                result: None,
                error: Some(self.error_to_jsonrpc(e)),
                id: request_id,
            },
        }
    }

    fn process_request(&self, request: JsonRpcRequest) -> Result<Value, McpError> {
        match request.method.as_str() {
            "initialize" => self.handle_initialize(request.params),
            "tools/list" => self.handle_tools_list(),
            "tools/call" => self.handle_tools_call(request.params),
            method => Err(McpError::MethodNotFound(method.to_string())),
        }
    }

    fn handle_initialize(&self, _params: Option<Value>) -> Result<Value, McpError> {
        Ok(json!({
            "protocolVersion": "2025-03-26",
            "serverInfo": {
                "name": self.tool.server_name(),
                "version": self.tool.server_version()
            },
            "capabilities": self.tool.capabilities()
        }))
    }

    fn handle_tools_list(&self) -> Result<Value, McpError> {
        let tool_info = ToolInfo::from(&self.tool);
        Ok(json!({
            "tools": [{
                "name": tool_info.name,
                "description": tool_info.description,
                "inputSchema": tool_info.input_schema
            }]
        }))
    }

    fn handle_tools_call(&self, params: Option<Value>) -> Result<Value, McpError> {
        let params = params
            .ok_or_else(|| McpError::InvalidRequest("Missing params for tools/call".to_string()))?;

        let tool_name = params["name"]
            .as_str()
            .ok_or_else(|| McpError::InvalidRequest("Missing tool name".to_string()))?;

        if tool_name != self.tool.name() {
            return Err(McpError::ToolError(ToolError::InvalidArguments(format!(
                "Unknown tool: {tool_name}"
            ))));
        }

        let arguments = params.get("arguments").cloned().unwrap_or(json!({}));

        match self.tool.call(&arguments) {
            Ok(result) => Ok(json!(result)),
            Err(e) => Err(McpError::ToolError(e)),
        }
    }

    fn error_to_jsonrpc(&self, error: McpError) -> JsonRpcError {
        match error {
            McpError::InvalidRequest(msg) => JsonRpcError::invalid_request(msg),
            McpError::MethodNotFound(method) => JsonRpcError::method_not_found(method),
            McpError::ToolError(e) => JsonRpcError::internal_error(e.to_string()),
            McpError::JsonError(e) => JsonRpcError::internal_error(e.to_string()),
        }
    }
}

#[cfg(test)]
mod tests {
    use serde_json::json;

    use super::*;
    use crate::{
        tool::Tool,
        types::{ToolError, ToolResult},
    };

    #[derive(Clone)]
    struct TestTool;

    impl Tool for TestTool {
        fn name(&self) -> &'static str {
            "test_tool"
        }

        fn description(&self) -> &'static str {
            "A test tool"
        }

        fn input_schema(&self) -> Value {
            json!({
                "type": "object",
                "properties": {
                    "input": {"type": "string"}
                },
                "required": ["input"]
            })
        }

        fn call(&self, args: &Value) -> Result<ToolResult, ToolError> {
            if let Some(input) = args.get("input").and_then(|v| v.as_str()) {
                Ok(ToolResult::text(format!("Processed: {input}")))
            } else {
                Err(ToolError::InvalidArguments("Missing input".to_string()))
            }
        }
    }

    #[test]
    fn test_initialize() {
        let server = McpServer::new(TestTool);
        let request = JsonRpcRequest {
            jsonrpc: "2.0".to_string(),
            method: "initialize".to_string(),
            params: None,
            id: Some(json!(1)),
        };

        let response = server.handle_request(request);
        assert!(response.error.is_none());

        let result = response.result.unwrap();
        assert_eq!(result["protocolVersion"], "2025-03-26");
        assert_eq!(result["serverInfo"]["name"], "ftl-test_tool");
    }

    #[test]
    fn test_tools_list() {
        let server = McpServer::new(TestTool);
        let request = JsonRpcRequest {
            jsonrpc: "2.0".to_string(),
            method: "tools/list".to_string(),
            params: None,
            id: Some(json!(1)),
        };

        let response = server.handle_request(request);
        assert!(response.error.is_none());

        let result = response.result.unwrap();
        let tools = result["tools"].as_array().unwrap();
        assert_eq!(tools.len(), 1);
        assert_eq!(tools[0]["name"], "test_tool");
        assert_eq!(tools[0]["description"], "A test tool");
    }

    #[test]
    fn test_tools_call_success() {
        let server = McpServer::new(TestTool);
        let request = JsonRpcRequest {
            jsonrpc: "2.0".to_string(),
            method: "tools/call".to_string(),
            params: Some(json!({
                "name": "test_tool",
                "arguments": {
                    "input": "hello"
                }
            })),
            id: Some(json!(1)),
        };

        let response = server.handle_request(request);
        assert!(response.error.is_none());

        let result = response.result.unwrap();
        assert_eq!(result["content"][0]["text"], "Processed: hello");
    }

    #[test]
    fn test_tools_call_missing_argument() {
        let server = McpServer::new(TestTool);
        let request = JsonRpcRequest {
            jsonrpc: "2.0".to_string(),
            method: "tools/call".to_string(),
            params: Some(json!({
                "name": "test_tool",
                "arguments": {}
            })),
            id: Some(json!(1)),
        };

        let response = server.handle_request(request);
        assert!(response.error.is_some());
        assert_eq!(response.error.unwrap().code, -32603);
    }

    #[test]
    fn test_unknown_method() {
        let server = McpServer::new(TestTool);
        let request = JsonRpcRequest {
            jsonrpc: "2.0".to_string(),
            method: "unknown/method".to_string(),
            params: None,
            id: Some(json!(1)),
        };

        let response = server.handle_request(request);
        assert!(response.error.is_some());
        assert_eq!(response.error.unwrap().code, -32601);
    }

    #[test]
    fn test_missing_params() {
        let server = McpServer::new(TestTool);
        let request = JsonRpcRequest {
            jsonrpc: "2.0".to_string(),
            method: "tools/call".to_string(),
            params: None,
            id: Some(json!(1)),
        };

        let response = server.handle_request(request);
        assert!(response.error.is_some());
        assert_eq!(response.error.unwrap().code, -32600);
    }

    #[test]
    fn test_wrong_tool_name() {
        let server = McpServer::new(TestTool);
        let request = JsonRpcRequest {
            jsonrpc: "2.0".to_string(),
            method: "tools/call".to_string(),
            params: Some(json!({
                "name": "wrong_tool",
                "arguments": {"input": "test"}
            })),
            id: Some(json!(1)),
        };

        let response = server.handle_request(request);
        assert!(response.error.is_some());
    }
}