Files
foxhunt/bin/fxt/src/mcp/protocol.rs
jgrusewski 0fdcc496be fix: type-safe proto enums, MCP protocol compliance, TUI teardown
- Replace hardcoded i32 enum matches with ProtoEnum::try_from() in
  8 command files (cluster, config, data, model, risk, service, train, tune)
- Replace hardcoded i32 literals with enum variants (EmergencyStopType,
  ExportFormat, TrainingMode)
- Add JSON-RPC 2.0 PARSE_ERROR (-32700) for malformed JSON input
- Validate jsonrpc:"2.0" version on incoming MCP requests
- Change unreachable execute_tool catch-all from Ok to Err
- Fix TUI teardown to not mask original event_loop error
- Rename config_cmd module to config (commands::config)
- 77 tests pass, 0 clippy warnings

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 22:29:13 +01:00

136 lines
4.2 KiB
Rust

//! JSON-RPC 2.0 message types for the MCP protocol.
use serde::{Deserialize, Serialize};
use serde_json::Value;
/// A JSON-RPC 2.0 request (or notification, when `id` is `None`).
#[derive(Debug, Deserialize)]
pub struct JsonRpcRequest {
/// Must be `"2.0"`.
pub jsonrpc: String,
/// Request identifier. `None` for notifications (no response expected).
pub id: Option<Value>,
/// The method to invoke (e.g. `"initialize"`, `"tools/list"`, `"tools/call"`).
pub method: String,
/// Method parameters. Defaults to `null` when absent.
#[serde(default)]
pub params: Value,
}
/// A JSON-RPC 2.0 response.
#[derive(Debug, Serialize)]
pub struct JsonRpcResponse {
/// Always `"2.0"`.
pub jsonrpc: String,
/// Mirrors the `id` from the request.
pub id: Value,
/// The result on success.
#[serde(skip_serializing_if = "Option::is_none")]
pub result: Option<Value>,
/// The error on failure.
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<JsonRpcError>,
}
/// A JSON-RPC 2.0 error object.
#[derive(Debug, Serialize)]
pub struct JsonRpcError {
/// Numeric error code (see JSON-RPC 2.0 spec).
pub code: i64,
/// Human-readable error message.
pub message: String,
/// Optional structured error data.
#[serde(skip_serializing_if = "Option::is_none")]
pub data: Option<Value>,
}
// ── Standard JSON-RPC 2.0 error codes ───────────────────────────────
/// The JSON sent is not valid JSON (parse error).
pub const PARSE_ERROR: i64 = -32700;
/// The JSON sent is not a valid JSON-RPC request.
pub const INVALID_REQUEST: i64 = -32600;
/// The method does not exist or is not available.
pub const METHOD_NOT_FOUND: i64 = -32601;
/// Invalid method parameters.
pub const INVALID_PARAMS: i64 = -32602;
/// Internal server error.
pub const INTERNAL_ERROR: i64 = -32603;
// ── Helpers ─────────────────────────────────────────────────────────
/// Build a successful response.
pub fn success_response(id: Value, result: Value) -> JsonRpcResponse {
JsonRpcResponse {
jsonrpc: "2.0".into(),
id,
result: Some(result),
error: None,
}
}
/// Build an error response.
pub fn error_response(id: Value, code: i64, message: &str) -> JsonRpcResponse {
JsonRpcResponse {
jsonrpc: "2.0".into(),
id,
result: None,
error: Some(JsonRpcError {
code,
message: message.to_owned(),
data: None,
}),
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn deserialize_request_with_id() {
let raw = r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}"#;
let req: JsonRpcRequest = serde_json::from_str(raw).unwrap();
assert_eq!(req.jsonrpc, "2.0");
assert_eq!(req.method, "initialize");
assert_eq!(req.id, Some(json!(1)));
}
#[test]
fn deserialize_notification_no_id() {
let raw = r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#;
let req: JsonRpcRequest = serde_json::from_str(raw).unwrap();
assert!(req.id.is_none());
assert_eq!(req.params, Value::Null);
}
#[test]
fn serialize_success_response() {
let resp = success_response(json!(1), json!({"ok": true}));
let s = serde_json::to_string(&resp).unwrap();
assert!(s.contains(r#""result":"#));
assert!(!s.contains(r#""error""#));
}
#[test]
fn serialize_error_response() {
let resp = error_response(json!(2), METHOD_NOT_FOUND, "Method not found");
let s = serde_json::to_string(&resp).unwrap();
assert!(s.contains(r#""error""#));
assert!(!s.contains(r#""result""#));
}
#[test]
fn deserialize_request_string_id() {
let raw = r#"{"jsonrpc":"2.0","id":"abc","method":"tools/list"}"#;
let req: JsonRpcRequest = serde_json::from_str(raw).unwrap();
assert_eq!(req.id, Some(json!("abc")));
}
}