Some checks failed
CI / Check / macos-latest (push) Has been cancelled
CI / Check / ubuntu-latest (push) Has been cancelled
CI / Check / windows-latest (push) Has been cancelled
CI / Test / macos-latest (push) Has been cancelled
CI / Test / ubuntu-latest (push) Has been cancelled
CI / Test / windows-latest (push) Has been cancelled
CI / Clippy (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / Security Audit (push) Has been cancelled
CI / Secrets Scan (push) Has been cancelled
CI / Install Script Smoke Test (push) Has been cancelled
187 lines
5.8 KiB
Rust
187 lines
5.8 KiB
Rust
//! MCP Server — expose OpenFang tools via the Model Context Protocol.
|
|
//!
|
|
//! Implements the server-side MCP protocol so external MCP clients
|
|
//! (Claude Desktop, VS Code, etc.) can use OpenFang's built-in tools.
|
|
//!
|
|
//! This module provides a reusable handler function — the CLI team
|
|
//! wires it into a stdio transport.
|
|
|
|
use openfang_types::tool::ToolDefinition;
|
|
use serde_json::json;
|
|
|
|
/// MCP protocol version supported by this server.
|
|
const PROTOCOL_VERSION: &str = "2024-11-05";
|
|
|
|
/// Handle an incoming MCP JSON-RPC request and return a response.
|
|
///
|
|
/// This is a stateless handler that can be called from any transport
|
|
/// (stdio, HTTP, etc.). The caller provides the available tool definitions.
|
|
pub async fn handle_mcp_request(
|
|
request: &serde_json::Value,
|
|
tools: &[ToolDefinition],
|
|
) -> serde_json::Value {
|
|
let method = request["method"].as_str().unwrap_or("");
|
|
let id = request.get("id").cloned();
|
|
|
|
match method {
|
|
"initialize" => make_response(
|
|
id,
|
|
json!({
|
|
"protocolVersion": PROTOCOL_VERSION,
|
|
"capabilities": {
|
|
"tools": {}
|
|
},
|
|
"serverInfo": {
|
|
"name": "openfang",
|
|
"version": env!("CARGO_PKG_VERSION")
|
|
}
|
|
}),
|
|
),
|
|
"notifications/initialized" => {
|
|
// Notification — no response needed
|
|
json!(null)
|
|
}
|
|
"tools/list" => {
|
|
let tool_list: Vec<serde_json::Value> = tools
|
|
.iter()
|
|
.map(|t| {
|
|
json!({
|
|
"name": t.name,
|
|
"description": t.description,
|
|
"inputSchema": t.input_schema,
|
|
})
|
|
})
|
|
.collect();
|
|
|
|
make_response(id, json!({ "tools": tool_list }))
|
|
}
|
|
"tools/call" => {
|
|
let tool_name = request["params"]["name"].as_str().unwrap_or("");
|
|
let _arguments = request["params"]
|
|
.get("arguments")
|
|
.cloned()
|
|
.unwrap_or(json!({}));
|
|
|
|
// Verify the tool exists
|
|
if !tools.iter().any(|t| t.name == tool_name) {
|
|
return make_error(id, -32602, &format!("Unknown tool: {tool_name}"));
|
|
}
|
|
|
|
// Tool execution is delegated to the caller (kernel/CLI).
|
|
// This handler just validates the request format.
|
|
// In a full implementation, the caller would wire this to execute_tool().
|
|
make_response(
|
|
id,
|
|
json!({
|
|
"content": [{
|
|
"type": "text",
|
|
"text": format!("Tool '{tool_name}' is available. Execution must be wired by the host.")
|
|
}]
|
|
}),
|
|
)
|
|
}
|
|
_ => make_error(id, -32601, &format!("Method not found: {method}")),
|
|
}
|
|
}
|
|
|
|
/// Build a JSON-RPC 2.0 success response.
|
|
fn make_response(id: Option<serde_json::Value>, result: serde_json::Value) -> serde_json::Value {
|
|
json!({
|
|
"jsonrpc": "2.0",
|
|
"id": id,
|
|
"result": result,
|
|
})
|
|
}
|
|
|
|
/// Build a JSON-RPC 2.0 error response.
|
|
fn make_error(id: Option<serde_json::Value>, code: i64, message: &str) -> serde_json::Value {
|
|
json!({
|
|
"jsonrpc": "2.0",
|
|
"id": id,
|
|
"error": {
|
|
"code": code,
|
|
"message": message,
|
|
},
|
|
})
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn test_tools() -> Vec<ToolDefinition> {
|
|
vec![
|
|
ToolDefinition {
|
|
name: "file_read".to_string(),
|
|
description: "Read a file".to_string(),
|
|
input_schema: json!({"type": "object", "properties": {"path": {"type": "string"}}}),
|
|
},
|
|
ToolDefinition {
|
|
name: "web_fetch".to_string(),
|
|
description: "Fetch a URL".to_string(),
|
|
input_schema: json!({"type": "object"}),
|
|
},
|
|
]
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_mcp_server_tools_list() {
|
|
let tools = test_tools();
|
|
let request = json!({
|
|
"jsonrpc": "2.0",
|
|
"id": 1,
|
|
"method": "tools/list",
|
|
});
|
|
|
|
let response = handle_mcp_request(&request, &tools).await;
|
|
assert_eq!(response["jsonrpc"], "2.0");
|
|
assert_eq!(response["id"], 1);
|
|
|
|
let tool_list = response["result"]["tools"].as_array().unwrap();
|
|
assert_eq!(tool_list.len(), 2);
|
|
assert_eq!(tool_list[0]["name"], "file_read");
|
|
assert_eq!(tool_list[1]["name"], "web_fetch");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_mcp_server_unknown_method() {
|
|
let tools = test_tools();
|
|
let request = json!({
|
|
"jsonrpc": "2.0",
|
|
"id": 5,
|
|
"method": "nonexistent/method",
|
|
});
|
|
|
|
let response = handle_mcp_request(&request, &tools).await;
|
|
assert_eq!(response["jsonrpc"], "2.0");
|
|
assert_eq!(response["id"], 5);
|
|
assert_eq!(response["error"]["code"], -32601);
|
|
assert!(response["error"]["message"]
|
|
.as_str()
|
|
.unwrap()
|
|
.contains("not found"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_mcp_server_initialize() {
|
|
let tools = test_tools();
|
|
let request = json!({
|
|
"jsonrpc": "2.0",
|
|
"id": 1,
|
|
"method": "initialize",
|
|
"params": {
|
|
"protocolVersion": "2024-11-05",
|
|
"capabilities": {},
|
|
"clientInfo": {"name": "test"}
|
|
}
|
|
});
|
|
|
|
let response = handle_mcp_request(&request, &tools).await;
|
|
assert_eq!(response["result"]["protocolVersion"], PROTOCOL_VERSION);
|
|
assert!(response["result"]["serverInfo"]["name"]
|
|
.as_str()
|
|
.unwrap()
|
|
.contains("openfang"));
|
|
}
|
|
}
|