use serde_json::{json, Value};
use crate::tools::Tools;
pub const PROTOCOL_VERSION: &str = "2024-11-05";
pub struct McpServer {
tools: Tools,
version: String,
}
impl McpServer {
pub fn new(tools: Tools) -> Self {
McpServer { tools, version: env!("CARGO_PKG_VERSION").to_string() }
}
pub fn handle_line(&self, line: &str) -> Option<String> {
let trimmed = line.trim();
if trimmed.is_empty() {
return None;
}
let msg: Value = match serde_json::from_str(trimmed) {
Ok(v) => v,
Err(e) => return Some(error_response(Value::Null, -32700, &format!("parse error: {e}"))),
};
let method = msg.get("method").and_then(Value::as_str).unwrap_or("");
let id = msg.get("id").cloned();
let id = id?;
match method {
"initialize" => Some(ok_response(
id,
json!({
"protocolVersion": PROTOCOL_VERSION,
"capabilities": { "tools": {} },
"serverInfo": { "name": "scema-mcp", "version": self.version },
"instructions":
"The Scematica Omni loop: perceive an environment, rank competing branches \
against a goal, and seal a verifiable record. Two things to know before \
calling anything. First, an unmeasured quantity renders as an em dash and \
contributed nothing to the score — it is not a zero, and reasoning about it \
as one will be wrong. Second, grounding is never inferred: a goal that does \
not cite a counted signal id in `ground` has no measured expected gain and \
will score at or below zero, and that is the correct answer rather than a \
malfunction. Call omni_observe first to see the signal ids."
}),
)),
"ping" => Some(ok_response(id, json!({}))),
"tools/list" => Some(ok_response(id, json!({ "tools": self.tools.definitions() }))),
"tools/call" => {
let params = msg.get("params").cloned().unwrap_or_else(|| json!({}));
let name = params.get("name").and_then(Value::as_str).unwrap_or("");
let args = params.get("arguments").cloned().unwrap_or_else(|| json!({}));
if name.is_empty() {
return Some(error_response(id, -32602, "params.name is required"));
}
let result = self.tools.call(name, &args);
Some(ok_response(
id,
json!({
"content": [{ "type": "text", "text": result.text }],
"isError": result.is_error,
}),
))
}
other => Some(error_response(id, -32601, &format!("method not found: {other}"))),
}
}
}
fn ok_response(id: Value, result: Value) -> String {
json!({ "jsonrpc": "2.0", "id": id, "result": result }).to_string()
}
fn error_response(id: Value, code: i32, message: &str) -> String {
json!({ "jsonrpc": "2.0", "id": id, "error": { "code": code, "message": message } }).to_string()
}
#[cfg(test)]
mod tests {
use super::*;
use scema_agent::Agent;
use scema_tools::Workspace;
use std::fs;
use std::path::PathBuf;
fn scratch() -> PathBuf {
let p = std::env::temp_dir().join(format!(
"scema-omni-rpc-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
fs::create_dir_all(p.join("src")).unwrap();
fs::write(p.join("Cargo.toml"), "[package]\nname = \"t\"\n").unwrap();
fs::write(p.join("src/lib.rs"), "fn a() {}\n").unwrap();
p
}
fn server(root: &PathBuf) -> McpServer {
McpServer::new(Tools {
agent: Agent::new(root.join(".scema"), None),
workspace: Workspace::new([root]).unwrap(),
root: root.join(".scema"),
allow_decide: false,
})
}
fn call(s: &McpServer, line: &str) -> Value {
serde_json::from_str(&s.handle_line(line).expect("expected a reply")).unwrap()
}
#[test]
fn initialize_advertises_the_protocol_and_the_two_rules_a_model_needs() {
let root = scratch();
let v = call(&server(&root), r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}"#);
assert_eq!(v["result"]["protocolVersion"], json!(PROTOCOL_VERSION));
let instructions = v["result"]["instructions"].as_str().unwrap();
assert!(instructions.contains("em dash"));
assert!(instructions.contains("never inferred"));
fs::remove_dir_all(&root).ok();
}
#[test]
fn a_notification_gets_no_reply() {
let root = scratch();
assert!(server(&root)
.handle_line(r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#)
.is_none());
fs::remove_dir_all(&root).ok();
}
#[test]
fn tools_list_returns_the_catalogue() {
let root = scratch();
let v = call(&server(&root), r#"{"jsonrpc":"2.0","id":2,"method":"tools/list"}"#);
let names: Vec<&str> = v["result"]["tools"]
.as_array()
.unwrap()
.iter()
.map(|t| t["name"].as_str().unwrap())
.collect();
assert!(names.contains(&"omni_observe"));
assert!(names.contains(&"omni_simulate"));
assert!(!names.contains(&"omni_decide"), "not advertised when disabled");
fs::remove_dir_all(&root).ok();
}
#[test]
fn a_refused_path_is_a_tool_result_not_a_protocol_error() {
let root = scratch();
let outside = std::env::temp_dir().to_string_lossy().to_string();
let line = json!({
"jsonrpc": "2.0", "id": 3, "method": "tools/call",
"params": { "name": "omni_observe", "arguments": { "locator": outside } }
})
.to_string();
let v = call(&server(&root), &line);
assert!(v.get("error").is_none(), "must not be a JSON-RPC error");
assert_eq!(v["result"]["isError"], json!(true));
assert!(v["result"]["content"][0]["text"]
.as_str()
.unwrap()
.contains("outside this workspace"));
fs::remove_dir_all(&root).ok();
}
#[test]
fn malformed_json_is_a_parse_error_with_a_null_id() {
let root = scratch();
let v = call(&server(&root), "{ not json");
assert_eq!(v["error"]["code"], json!(-32700));
assert_eq!(v["id"], json!(null));
fs::remove_dir_all(&root).ok();
}
#[test]
fn an_unknown_method_is_method_not_found() {
let root = scratch();
let v = call(&server(&root), r#"{"jsonrpc":"2.0","id":9,"method":"does/not/exist"}"#);
assert_eq!(v["error"]["code"], json!(-32601));
fs::remove_dir_all(&root).ok();
}
#[test]
fn a_tools_call_without_a_name_is_an_invalid_params_error() {
let root = scratch();
let v = call(&server(&root), r#"{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{}}"#);
assert_eq!(v["error"]["code"], json!(-32602));
fs::remove_dir_all(&root).ok();
}
#[test]
fn a_blank_line_is_ignored() {
let root = scratch();
assert!(server(&root).handle_line(" ").is_none());
fs::remove_dir_all(&root).ok();
}
#[test]
fn every_reply_is_a_single_line() {
let root = scratch();
let s = server(&root);
for line in [
r#"{"jsonrpc":"2.0","id":1,"method":"initialize"}"#,
r#"{"jsonrpc":"2.0","id":2,"method":"tools/list"}"#,
r#"{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"omni_policy","arguments":{}}}"#,
] {
let reply = s.handle_line(line).unwrap();
assert!(!reply.contains('\n'), "reply must be one line: {reply}");
}
fs::remove_dir_all(&root).ok();
}
}