mod skills;
mod tools;
use actix_web::{web, HttpRequest, HttpResponse};
use serde_json::{json, Value};
use crate::AppState;
pub const PROTOCOL_VERSION: &str = "2025-03-26";
const SERVER_NAME: &str = "jss-mcp";
const SERVER_VERSION: &str = "0.1.0";
const RPC_INVALID_REQUEST: i64 = -32600;
const RPC_METHOD_NOT_FOUND: i64 = -32601;
const RPC_INVALID_PARAMS: i64 = -32602;
#[derive(Clone)]
pub struct McpCtx {
pub web_id: Option<String>,
pub origin: String,
pub federation_depth: u32,
}
fn rpc_result(id: &Value, result: Value) -> Value {
json!({ "jsonrpc": "2.0", "id": id, "result": result })
}
fn rpc_error(id: &Value, code: i64, message: &str) -> Value {
json!({ "jsonrpc": "2.0", "id": id, "error": { "code": code, "message": message } })
}
fn tool_text(text: impl Into<String>) -> Value {
json!({ "content": [{ "type": "text", "text": text.into() }], "isError": false })
}
fn tool_error(message: impl Into<String>) -> Value {
json!({ "content": [{ "type": "text", "text": message.into() }], "isError": true })
}
fn tool_json(value: Value) -> Value {
tool_text(serde_json::to_string_pretty(&value).unwrap_or_else(|_| "null".to_string()))
}
fn is_allowed_method(method: &str) -> bool {
matches!(
method,
"initialize"
| "initialized"
| "notifications/initialized"
| "tools/list"
| "tools/call"
| "ping"
)
}
async fn dispatch(msg: &Value, state: &AppState, ctx: &McpCtx) -> Option<Value> {
let id = msg.get("id").cloned().unwrap_or(Value::Null);
let method = msg.get("method").and_then(Value::as_str).unwrap_or("");
if !is_allowed_method(method) {
return Some(rpc_error(
&id,
RPC_METHOD_NOT_FOUND,
&format!("unknown method: {method}"),
));
}
match method {
"ping" => Some(rpc_result(&id, json!({}))),
"initialize" => Some(rpc_result(
&id,
json!({
"protocolVersion": PROTOCOL_VERSION,
"serverInfo": { "name": SERVER_NAME, "version": SERVER_VERSION },
"capabilities": { "tools": { "listChanged": false } }
}),
)),
"initialized" | "notifications/initialized" => None,
"tools/list" => Some(rpc_result(&id, json!({ "tools": tools::list_tools_for_rpc() }))),
"tools/call" => {
let name = msg
.get("params")
.and_then(|p| p.get("name"))
.and_then(Value::as_str);
let name = match name {
Some(n) if !n.is_empty() => n,
_ => return Some(rpc_error(&id, RPC_INVALID_PARAMS, "tool name required")),
};
let args = msg
.get("params")
.and_then(|p| p.get("arguments"))
.cloned()
.unwrap_or_else(|| json!({}));
let result = tools::call_tool(name, &args, state, ctx).await;
Some(rpc_result(&id, result))
}
_ => Some(rpc_error(
&id,
RPC_METHOD_NOT_FOUND,
&format!("unhandled method: {method}"),
)),
}
}
fn origin_of(req: &HttpRequest) -> String {
let conn = req.connection_info();
format!("{}://{}", conn.scheme(), conn.host())
}
pub async fn handle_mcp(
req: HttpRequest,
body: web::Bytes,
state: web::Data<AppState>,
) -> HttpResponse {
let parsed: Value = match serde_json::from_slice(&body) {
Ok(v) => v,
Err(_) => {
return HttpResponse::BadRequest()
.json(rpc_error(&Value::Null, RPC_INVALID_REQUEST, "expected JSON-RPC body"));
}
};
let pubkey = crate::extract_pubkey(&req).await;
let web_id = crate::agent_uri(pubkey.as_ref());
let federation_depth = req
.headers()
.get("mcp-federation-depth")
.and_then(|v| v.to_str().ok())
.and_then(|s| s.parse::<u32>().ok())
.unwrap_or(0);
let ctx = McpCtx {
web_id,
origin: origin_of(&req),
federation_depth,
};
if !parsed.is_array() && tools::is_streaming_call(&parsed) {
return tools::handle_subscribe(&parsed, state.get_ref().clone(), ctx).await;
}
if let Some(arr) = parsed.as_array() {
let mut out: Vec<Value> = Vec::new();
for msg in arr {
if let Some(r) = dispatch(msg, state.get_ref(), &ctx).await {
out.push(r);
}
}
return HttpResponse::Ok().json(out);
}
match dispatch(&parsed, state.get_ref(), &ctx).await {
None => HttpResponse::NoContent().finish(),
Some(result) => HttpResponse::Ok().json(result),
}
}
pub async fn handle_mcp_options() -> HttpResponse {
HttpResponse::NoContent()
.insert_header(("Allow", "POST, OPTIONS"))
.finish()
}