use crate::{ActivitySource, AppState, DaemonEvent, HookType, InjectionKind};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use trusty_common::mcp::initialize_response;
pub mod error_codes {
pub const PARSE_ERROR: i32 = -32700;
pub const INVALID_REQUEST: i32 = -32600;
pub const METHOD_NOT_FOUND: i32 = -32601;
pub const INVALID_PARAMS: i32 = -32602;
pub const INTERNAL_ERROR: i32 = -32603;
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JsonRpcRequest {
#[serde(default)]
pub jsonrpc: Option<String>,
#[serde(default)]
pub id: Option<Value>,
pub method: String,
#[serde(default)]
pub params: Option<Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JsonRpcResponse {
pub jsonrpc: String,
pub id: Value,
#[serde(skip_serializing_if = "Option::is_none")]
pub result: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<JsonRpcError>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JsonRpcError {
pub code: i32,
pub message: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub data: Option<Value>,
}
impl JsonRpcResponse {
pub fn ok(id: Value, result: Value) -> Self {
Self {
jsonrpc: "2.0".to_string(),
id,
result: Some(result),
error: None,
}
}
pub fn err(id: Value, code: i32, message: impl Into<String>) -> Self {
Self {
jsonrpc: "2.0".to_string(),
id,
result: None,
error: Some(JsonRpcError {
code,
message: message.into(),
data: None,
}),
}
}
pub fn from_anyhow(id: Value, e: anyhow::Error) -> Self {
Self::err(id, error_codes::INTERNAL_ERROR, format!("{e:#}"))
}
}
const TOOL_METHODS: &[&str] = &[
"add_alias",
"discover_aliases",
"get_prompt_context",
"kg_assert",
"kg_bootstrap",
"kg_gaps",
"kg_query",
"list_prompt_facts",
"memory_forget",
"memory_list",
"memory_note",
"memory_recall",
"memory_recall_all",
"memory_recall_deep",
"memory_remember",
"memory_send_message",
"palace_compact",
"palace_create",
"palace_info",
"palace_list",
"remove_prompt_fact",
];
#[derive(Debug, Clone, Serialize, Deserialize)]
struct HookFiredParams {
#[serde(default)]
palace_id: Option<String>,
#[serde(default)]
palace_name: Option<String>,
hook_type: HookType,
injection_kind: InjectionKind,
#[serde(default)]
injection_length: u64,
#[serde(default)]
trigger_prompt_excerpt: String,
#[serde(default)]
duration_ms: u64,
}
pub async fn dispatch(state: &AppState, req: JsonRpcRequest) -> JsonRpcResponse {
let id = req.id.clone().unwrap_or(Value::Null);
let params = req.params.clone().unwrap_or(Value::Null);
match req.method.as_str() {
"initialize" => {
let extra = state
.default_palace
.as_deref()
.map(|p| json!({"default_palace": p}));
let result = initialize_response("trusty-memory", &state.version, extra);
return JsonRpcResponse::ok(id, result);
}
"notifications/initialized" | "notifications/cancelled" => {
return JsonRpcResponse::ok(Value::Null, Value::Null);
}
"ping" => return JsonRpcResponse::ok(id, json!({})),
"rpc.discover" => {
let result = crate::openrpc::build_discover_response(
&state.version,
state.default_palace.is_some(),
);
return JsonRpcResponse::ok(id, result);
}
"tools/list" => {
let result = crate::tools::tool_definitions_with(state.default_palace.is_some());
return JsonRpcResponse::ok(id, result);
}
"tools/call" => {
let name = params
.get("name")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let args = params.get("arguments").cloned().unwrap_or(Value::Null);
return match crate::tools::dispatch_tool(state, &name, args).await {
Ok(content) => {
let text = match &content {
Value::String(s) => s.clone(),
other => other.to_string(),
};
JsonRpcResponse::ok(id, json!({"content": [{"type": "text", "text": text}]}))
}
Err(e) => JsonRpcResponse::from_anyhow(id, e),
};
}
"hook_fired" => return dispatch_hook_fired(state, id, params),
_ => {}
}
if TOOL_METHODS.contains(&req.method.as_str()) {
return match crate::tools::dispatch_tool(state, &req.method, params).await {
Ok(result) => JsonRpcResponse::ok(id, result),
Err(e) => JsonRpcResponse::from_anyhow(id, e),
};
}
JsonRpcResponse::err(
id,
error_codes::METHOD_NOT_FOUND,
format!("Method not found: {}", req.method),
)
}
fn dispatch_hook_fired(state: &AppState, id: Value, params: Value) -> JsonRpcResponse {
let parsed: HookFiredParams = match serde_json::from_value(params) {
Ok(p) => p,
Err(e) => {
return JsonRpcResponse::err(
id,
error_codes::INVALID_PARAMS,
format!("hook_fired: invalid params: {e}"),
);
}
};
state.emit(DaemonEvent::HookFired {
palace_id: parsed.palace_id,
palace_name: parsed.palace_name,
hook_type: parsed.hook_type,
injection_kind: parsed.injection_kind,
injection_length: parsed.injection_length,
trigger_prompt_excerpt: parsed.trigger_prompt_excerpt,
timestamp: chrono::Utc::now(),
duration_ms: parsed.duration_ms,
source: ActivitySource::Hook,
});
JsonRpcResponse::ok(id, json!({"status": "ok"}))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::AppState;
use serde_json::json;
fn test_state() -> AppState {
let tmp = tempfile::tempdir().expect("tempdir");
let root = tmp.path().to_path_buf();
std::mem::forget(tmp);
AppState::new(root)
}
#[test]
fn jsonrpc_request_round_trip() {
let req = JsonRpcRequest {
jsonrpc: Some("2.0".to_string()),
id: Some(json!(1)),
method: "palace_list".to_string(),
params: Some(json!({})),
};
let s = serde_json::to_string(&req).unwrap();
let back: JsonRpcRequest = serde_json::from_str(&s).unwrap();
assert_eq!(back.method, "palace_list");
assert_eq!(back.id, Some(json!(1)));
}
#[tokio::test]
async fn dispatch_palace_list_returns_empty_array_initially() {
let state = test_state();
let req = JsonRpcRequest {
jsonrpc: Some("2.0".to_string()),
id: Some(json!(1)),
method: "palace_list".to_string(),
params: Some(json!({})),
};
let resp = dispatch(&state, req).await;
assert!(resp.error.is_none(), "expected ok, got {:?}", resp.error);
let result = resp.result.expect("result");
let palaces = result["palaces"]
.as_array()
.expect("result.palaces must be an array");
assert!(palaces.is_empty(), "fresh state must list zero palaces");
}
#[tokio::test]
async fn dispatch_unknown_method_returns_method_not_found() {
let state = test_state();
let req = JsonRpcRequest {
jsonrpc: Some("2.0".to_string()),
id: Some(json!(7)),
method: "definitely_not_a_real_method".to_string(),
params: None,
};
let resp = dispatch(&state, req).await;
assert!(resp.result.is_none());
let err = resp.error.expect("error");
assert_eq!(err.code, error_codes::METHOD_NOT_FOUND);
assert!(err.message.contains("definitely_not_a_real_method"));
}
#[tokio::test]
async fn dispatch_initialize_returns_capabilities() {
let state = test_state();
let req = JsonRpcRequest {
jsonrpc: Some("2.0".to_string()),
id: Some(json!(1)),
method: "initialize".to_string(),
params: Some(json!({
"protocolVersion": "2024-11-05",
"capabilities": {},
"clientInfo": {"name": "test", "version": "1.0"}
})),
};
let resp = dispatch(&state, req).await;
assert!(
resp.error.is_none(),
"initialize must not error: {:?}",
resp.error
);
let result = resp.result.expect("result");
assert_eq!(
result["protocolVersion"], "2024-11-05",
"must echo the negotiated protocol version"
);
assert!(
result["capabilities"]["tools"].is_object(),
"must advertise tools capability"
);
assert_eq!(
result["serverInfo"]["name"], "trusty-memory",
"serverInfo.name must be trusty-memory"
);
}
#[tokio::test]
async fn dispatch_ping_returns_empty_object() {
let state = test_state();
let req = JsonRpcRequest {
jsonrpc: Some("2.0".to_string()),
id: Some(json!(42)),
method: "ping".to_string(),
params: None,
};
let resp = dispatch(&state, req).await;
assert_eq!(resp.id, json!(42));
assert_eq!(resp.result, Some(json!({})));
}
#[tokio::test]
async fn dispatch_tools_list_returns_tool_array() {
let state = test_state();
let req = JsonRpcRequest {
jsonrpc: Some("2.0".to_string()),
id: Some(json!(2)),
method: "tools/list".to_string(),
params: None,
};
let resp = dispatch(&state, req).await;
let result = resp.result.expect("result");
let tools = result["tools"].as_array().expect("tools array");
assert!(!tools.is_empty());
}
#[tokio::test]
async fn dispatch_hook_fired_emits_activity() {
let state = test_state();
let req = JsonRpcRequest {
jsonrpc: Some("2.0".to_string()),
id: Some(json!(3)),
method: "hook_fired".to_string(),
params: Some(json!({
"palace_id": "p",
"palace_name": "p",
"hook_type": "UserPromptSubmit",
"injection_kind": "prompt-context",
"injection_length": 100,
"trigger_prompt_excerpt": "test",
"duration_ms": 5,
})),
};
let resp = dispatch(&state, req).await;
assert!(resp.error.is_none(), "expected ok, got {:?}", resp.error);
state.flush_activity_writes().await;
let count = state.activity_log.count().unwrap();
assert_eq!(count, 1, "hook_fired must persist one activity row");
}
#[tokio::test]
async fn dispatch_hook_fired_invalid_params_errors() {
let state = test_state();
let req = JsonRpcRequest {
jsonrpc: Some("2.0".to_string()),
id: Some(json!(4)),
method: "hook_fired".to_string(),
params: Some(json!({"wrong": "shape"})),
};
let resp = dispatch(&state, req).await;
let err = resp.error.expect("error");
assert_eq!(err.code, error_codes::INVALID_PARAMS);
}
}