Files
shacraft-core/crates/shacraft-mcp/src/main.rs
T

750 lines
34 KiB
Rust

//! Bounded newline-delimited JSON-RPC MCP transport, independent of world storage.
use anyhow::{Context, Result, bail};
use clap::Parser;
use serde_json::{Value, json};
use std::{
io::{self, BufRead, Read, Write},
path::PathBuf,
time::Duration,
};
const MAX_FRAME: usize = 8 * 1024 * 1024;
const VERSIONS: &[&str] = &["2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05"];
#[derive(Parser)]
#[command(about = "Shacraft MCP server over stdio (JSON-RPC; diagnostics only on stderr)")]
struct Args {
#[arg(long, default_value = "http://127.0.0.1:4000")]
server: String,
#[arg(long, default_value = "data")]
data: PathBuf,
#[arg(long)]
token_file: Option<PathBuf>,
}
struct Backend {
url: String,
token_file: PathBuf,
client: reqwest::blocking::Client,
}
impl Backend {
fn new(args: Args) -> Result<Self> {
let parsed = reqwest::Url::parse(&args.server).context("Invalid --server URL")?;
if !matches!(parsed.scheme(), "http" | "https")
|| parsed.host_str().is_none()
|| !parsed.username().is_empty()
|| parsed.password().is_some()
|| parsed.query().is_some()
|| parsed.fragment().is_some()
{
bail!("--server must be an HTTP(S) URL without credentials, query, or fragment");
}
Ok(Self {
url: args.server.trim_end_matches('/').to_owned(),
token_file: args
.token_file
.unwrap_or_else(|| args.data.join("control.token")),
client: reqwest::blocking::Client::builder()
.connect_timeout(Duration::from_secs(3))
.timeout(Duration::from_secs(20))
.redirect(reqwest::redirect::Policy::none())
.build()?,
})
}
fn read_response(&self, mut response: reqwest::blocking::Response) -> Result<Value> {
let status = response.status();
let mut bytes = Vec::new();
Read::by_ref(&mut response)
.take((MAX_FRAME + 1) as u64)
.read_to_end(&mut bytes)?;
if bytes.len() > MAX_FRAME {
bail!("Server response exceeds 8 MiB; request a smaller region");
}
let body: Value = serde_json::from_slice(&bytes).context("Server returned invalid JSON")?;
if let Some(error) = body.get("error") {
bail!("{}", error.as_str().unwrap_or("Control operation failed"));
}
if !status.is_success() {
bail!("Server returned HTTP {}", status.as_u16());
}
Ok(body)
}
fn control(&self, method: &str, params: &Value) -> Result<Value> {
let file = std::fs::File::open(&self.token_file)
.context("Cannot read control token; start shacraft-server or set --token-file")?;
let mut token = String::new();
file.take(4097).read_to_string(&mut token)?;
if token.len() > 4096 {
bail!("Control token file is too large");
}
let token = token.trim();
if token.is_empty() || token.chars().any(char::is_control) {
bail!("Control token is empty or invalid");
}
let response = self
.client
.post(format!("{}/api/control", self.url))
.bearer_auth(token)
.json(&json!({"method":method,"params":params}))
.send()
.context("Shacraft server is unavailable")?;
self.read_response(response)?
.get("result")
.cloned()
.context("Control response is missing result")
}
fn public(&self, path: &str) -> Result<Value> {
self.read_response(
self.client
.get(format!("{}{}", self.url, path))
.send()
.context("Shacraft server is unavailable")?,
)
}
}
fn object(properties: Value, required: &[&str]) -> Value {
json!({"type":"object","properties":properties,"required":required,"additionalProperties":false})
}
fn pos(integer: bool) -> Value {
json!({"type":"array","items":{"type":if integer{"integer"}else{"number"}},"minItems":3,"maxItems":3})
}
fn world() -> Value {
json!({"type":"string","pattern":"^[A-Za-z0-9_-]{1,64}$"})
}
fn operation() -> Value {
json!({"type":"string","minLength":1,"maxLength":128,"description":"Caller-generated idempotency key, retained per world. Reuse only for an identical retry."})
}
fn revision() -> Value {
json!({"type":"integer","minimum":0,"description":"Observed world revision; a mismatch refuses the write without mutation."})
}
fn metadata_revision() -> Value {
json!({"type":"integer","minimum":0,"description":"Optional global metadata revision from entity_list or metrics; detects concurrent entity/rule changes."})
}
fn arena_position() -> Value {
json!({"type":"array","items":{"type":"number","minimum":-32700,"maximum":32700},"minItems":3,"maxItems":3,"description":"Feet position. Y must be greater than elimination_y + 0.5."})
}
fn block() -> Value {
json!({"oneOf":[{"type":"integer","minimum":0},{"type":"string","minLength":1,"maxLength":512}],"description":"Runtime BlockId or canonical state, e.g. minecraft:oak_stairs[facing=north,half=bottom,shape=straight,waterlogged=false]. Use catalog_search to discover states."})
}
fn tool(name: &str, description: &str, input: Value, read_only: bool, idempotent: bool) -> Value {
json!({"name":name,"description":description,"inputSchema":input,"annotations":{"readOnlyHint":read_only,"destructiveHint":!read_only,"idempotentHint":idempotent,"openWorldHint":false}})
}
fn tools() -> Vec<Value> {
vec![
tool(
"world_list",
"List all persistent worlds, their revisions and pinned source templates. Start here before reading or editing a world.",
object(json!({}), &[]),
true,
true,
),
tool(
"world_create",
"Create an empty independent world or a copy-on-write snapshot of an existing template. Later source edits never change the copy.",
object(json!({"world":world(),"template":world()}), &["world"]),
false,
false,
),
tool(
"world_read",
"Read a bounded inclusive region. Returns non-air cells plus world revision and registry information. Maximum region volume 262144 cells.",
object(
json!({"world":world(),"min":pos(true),"max":pos(true)}),
&["world", "min", "max"],
),
true,
true,
),
tool(
"world_edit",
"Atomically edit at most 32768 unique cells. Requires current revision and idempotency key. Use air (BlockId 0) to remove a block. The server commits durably before replying.",
object(
json!({"world":world(),"expected_revision":revision(),"operation_id":operation(),"changes":{"type":"array","minItems":1,"maxItems":32768,"items":object(json!({"pos":pos(true),"block":{"type":"integer","minimum":0,"description":"Runtime BlockId from catalog_search"}}),&["pos","block"])}}),
&["world", "expected_revision", "operation_id", "changes"],
),
false,
true,
),
tool(
"world_undo",
"Undo the latest edit atomically. Refuses when any later revision or reset could be overwritten. target_operation is the original edit's operation_id.",
object(
json!({"world":world(),"expected_revision":revision(),"operation_id":operation(),"target_operation":operation()}),
&[
"world",
"expected_revision",
"operation_id",
"target_operation",
],
),
false,
true,
),
tool(
"world_reset",
"Reset a world to its pinned base snapshot, or air if created without a template. All instance edits are removed; revision increases. Reset is an undo barrier.",
object(
json!({"world":world(),"expected_revision":revision(),"operation_id":operation()}),
&["world", "expected_revision", "operation_id"],
),
false,
true,
),
tool(
"catalog_search",
"Search the Minecraft Java 26.2 and installed-package state catalog. Reports exact identifiers, runtime IDs, shapes, and coverage; registered content does not imply all vanilla behaviors are simulated.",
object(
json!({"query":{"type":"string","maxLength":256},"offset":{"type":"integer","minimum":0},"limit":{"type":"integer","minimum":1,"maximum":256}}),
&[],
),
true,
true,
),
tool(
"entity_list",
"List persisted entities of one world, including IDs, positions, and properties. Entity kinds are defined in the public entity catalog.",
object(json!({"world":world()}), &["world"]),
true,
true,
),
tool(
"entity_spawn",
"Spawn one entity from a registered kind at a feet position. Its persisted properties are distinct from implemented behavior. Discover kinds in the entity catalog resource.",
object(
json!({"world":world(),"kind":{"type":"string","maxLength":128},"position":pos(false),"properties":{"type":"object"},"yaw":{"type":"number"}}),
&["world", "kind", "position"],
),
false,
false,
),
tool(
"entity_update",
"Update a persisted entity's position, yaw, or properties. Fields omitted from the request are preserved.",
object(
json!({"id":{"type":"string"},"expected_revision":metadata_revision(),"position":pos(false),"properties":{"type":"object"},"yaw":{"type":"number"}}),
&["id"],
),
false,
true,
),
tool(
"entity_delete",
"Remove a single persisted entity by ID from its world.",
object(
json!({"id":{"type":"string"},"expected_revision":metadata_revision()}),
&["id"],
),
false,
true,
),
tool(
"arena_start",
"Start the configured Spleef match in the specified arena. Requires enough joined players and resets the arena floor according to its rules.",
object(json!({"world":world()}), &["world"]),
false,
false,
),
tool(
"arena_configure",
"Persist creative/Spleef mode, spawn positions and match rules. Configuration is refused during a started round. Nonempty spawn_points must cover min_players and all joining participants; points must be at least 0.8 apart. floor_block defines the breakable material and does not rebuild the map.",
object(
json!({"world":world(),"expected_revision":metadata_revision(),"mode":{"type":"string","enum":["creative","spleef"]},"spawn":arena_position(),"countdown_seconds":{"type":"integer","minimum":1,"maximum":30},"round_seconds":{"type":"integer","minimum":1,"maximum":3600},"min_players":{"type":"integer","minimum":2,"maximum":32},"elimination_y":{"type":"number","minimum":-32700,"maximum":32700},"spawn_points":{"type":"array","maxItems":32,"items":arena_position(),"description":"Empty uses a radius-7 circle. Otherwise positions must cover all participants."},"spectator_spawn":arena_position(),"floor_block":{"type":"string","minLength":1,"maxLength":512,"description":"Known solid canonical state that players may break during Spleef."}}),
&["world"],
),
false,
true,
),
tool(
"metrics",
"Read live process RSS, storage/cache estimates, connected players, tick and queue diagnostics. Payload estimates must not be reported as total process RAM.",
object(json!({}), &[]),
true,
true,
),
tool(
"build_plan",
"Plan bounded construction without changing world blocks. Expands box, line and cylinder operations, checks limits and returns a plan_id, summary and expected revision. Review the plan before build_commit.",
object(
json!({"world":world(),"expected_revision":revision(),"operations":{"type":"array","minItems":1,"maxItems":128,"items":{"oneOf":[object(json!({"type":{"const":"box"},"min":pos(true),"max":pos(true),"block":block(),"hollow":{"type":"boolean"}}),&["type","min","max","block"]),object(json!({"type":{"const":"line"},"from":pos(true),"to":pos(true),"block":block()}),&["type","from","to","block"]),object(json!({"type":{"const":"cylinder"},"center":pos(true),"radius":{"type":"integer","minimum":1,"maximum":64},"height":{"type":"integer","minimum":1,"maximum":128},"block":block(),"hollow":{"type":"boolean"}}),&["type","center","radius","height","block"])]}}}),
&["world", "expected_revision", "operations"],
),
false,
false,
),
tool(
"build_commit",
"Commit a previously reviewed build_plan atomically using plan_id and an idempotency key. A stale revision is refused; create a new plan after inspecting intervening changes.",
object(
json!({"plan_id":{"type":"string","minLength":1,"maxLength":128},"operation_id":operation()}),
&["plan_id", "operation_id"],
),
false,
true,
),
tool(
"camera_capture",
"Capture a server-rendered isometric PNG of a bounded world region. Returns an MCP image with world/revision metadata; this is an inspection projection, not the browser's first-person camera.",
object(
json!({"world":world(),"min":pos(true),"max":pos(true)}),
&["world"],
),
true,
true,
),
]
}
fn validate(schema: &Value, value: &Value, path: &str) -> std::result::Result<(), String> {
if let Some(variants) = schema.get("oneOf").and_then(Value::as_array)
&& variants
.iter()
.filter(|s| validate(s, value, path).is_ok())
.count()
!= 1
{
return Err(format!("{path}: does not match exactly one accepted form"));
}
if let Some(choices) = schema.get("enum").and_then(Value::as_array)
&& !choices.contains(value)
{
return Err(format!("{path}: value is not in allowed enum"));
}
if let Some(constant) = schema.get("const")
&& value != constant
{
return Err(format!("{path}: expected {constant}"));
}
if let Some(t) = schema.get("type").and_then(Value::as_str) {
let matches = match t {
"object" => value.is_object(),
"array" => value.is_array(),
"string" => value.is_string(),
"integer" => value.is_i64() || value.is_u64(),
"number" => value.is_number(),
"boolean" => value.is_boolean(),
_ => true,
};
if !matches {
return Err(format!("{path}: expected {t}"));
}
}
if let Some(obj) = value.as_object() {
if let Some(required) = schema.get("required").and_then(Value::as_array) {
for key in required {
let key = key.as_str().unwrap_or("");
if !obj.contains_key(key) {
return Err(format!("{path}.{key}: required"));
}
}
}
if let Some(properties) = schema.get("properties").and_then(Value::as_object) {
for (key, value) in obj {
if let Some(subschema) = properties.get(key) {
validate(subschema, value, &format!("{path}.{key}"))?;
} else if schema.get("additionalProperties") == Some(&json!(false)) {
return Err(format!("{path}.{key}: unknown field"));
}
}
}
}
if let Some(values) = value.as_array() {
if let Some(n) = schema.get("minItems").and_then(Value::as_u64)
&& values.len() < (n as usize)
{
return Err(format!("{path}: too few items"));
}
if let Some(n) = schema.get("maxItems").and_then(Value::as_u64)
&& values.len() > (n as usize)
{
return Err(format!("{path}: too many items"));
}
if let Some(item) = schema.get("items") {
for (i, value) in values.iter().enumerate() {
validate(item, value, &format!("{path}[{i}]"))?;
}
}
}
if let Some(s) = value.as_str() {
let len = s.chars().count();
if schema
.get("minLength")
.and_then(Value::as_u64)
.is_some_and(|n| len < n as usize)
|| schema
.get("maxLength")
.and_then(Value::as_u64)
.is_some_and(|n| len > n as usize)
{
return Err(format!("{path}: string length out of range"));
}
if schema.get("pattern") == Some(&json!("^[A-Za-z0-9_-]{1,64}$"))
&& (s.is_empty()
|| s.len() > 64
|| !s
.bytes()
.all(|c| c.is_ascii_alphanumeric() || c == b'_' || c == b'-'))
{
return Err(format!("{path}: invalid world name"));
}
}
if let Some(n) = value.as_f64()
&& (schema
.get("minimum")
.and_then(Value::as_f64)
.is_some_and(|min| n < min)
|| schema
.get("maximum")
.and_then(Value::as_f64)
.is_some_and(|max| n > max))
{
return Err(format!("{path}: number out of range"));
}
Ok(())
}
fn resources() -> Value {
json!({"resources":[
{"uri":"shacraft://server/status","name":"Server status","description":"Live server health and storage metrics","mimeType":"application/json"},
{"uri":"shacraft://catalog/blocks","name":"Block catalog","description":"Initial page and search endpoint for registered block states","mimeType":"application/json"},
{"uri":"shacraft://catalog/entities","name":"Entity catalog","description":"All registered entity kinds, shapes and coverage","mimeType":"application/json"},
{"uri":"shacraft://packages","name":"Required packages","description":"Client-visible manifest and resource integrity hashes; contains no admin credentials","mimeType":"application/json"}
]})
}
fn rpc_error(id: Value, code: i32, message: impl Into<String>) -> Value {
json!({"jsonrpc":"2.0","id":id,"error":{"code":code,"message":message.into()}})
}
fn rpc_result(id: Value, result: Value) -> Value {
json!({"jsonrpc":"2.0","id":id,"result":result})
}
fn tool_error(message: impl Into<String>) -> Value {
json!({"isError":true,"content":[{"type":"text","text":message.into()}]})
}
#[derive(Default)]
struct Session {
initialized: bool,
ready: bool,
}
impl Session {
fn dispatch(&mut self, request: Value, backend: &Backend) -> Option<Value> {
let id = request.get("id").cloned().unwrap_or(Value::Null);
if !request.is_object()
|| request.get("jsonrpc") != Some(&json!("2.0"))
|| request.get("method").and_then(Value::as_str).is_none()
|| request
.get("id")
.is_some_and(|id| !id.is_string() && !id.is_i64() && !id.is_u64())
{
return Some(rpc_error(id, -32600, "Invalid JSON-RPC request"));
}
let method = request["method"].as_str().unwrap();
if request.get("id").is_none() {
if method == "notifications/initialized" && self.initialized {
self.ready = true;
}
return None;
}
let params = request.get("params").cloned().unwrap_or_else(|| json!({}));
if !params.is_object() {
return Some(rpc_error(id, -32602, "params must be an object"));
}
if method == "initialize" {
if self.initialized {
return Some(rpc_error(id, -32600, "Session already initialized"));
}
let Some(version) = params.get("protocolVersion").and_then(Value::as_str) else {
return Some(rpc_error(id, -32602, "protocolVersion is required"));
};
if !params.get("capabilities").is_some_and(Value::is_object)
|| !params
.get("clientInfo")
.is_some_and(|v| v["name"].is_string() && v["version"].is_string())
{
return Some(rpc_error(
id,
-32602,
"capabilities and clientInfo.name/version are required",
));
}
self.initialized = true;
return Some(rpc_result(
id,
json!({"protocolVersion":if VERSIONS.contains(&version){version}else{VERSIONS[0]},"capabilities":{"tools":{"listChanged":false},"resources":{"subscribe":false,"listChanged":false},"prompts":{"listChanged":false}},"serverInfo":{"name":"shacraft-mcp","version":env!("CARGO_PKG_VERSION")},"instructions":"Inspect world revisions and catalog before editing. Plan bounded construction with build_plan; commit with a unique operation_id. Preserve expected_revision on retries. camera_capture is an isometric inspection image. This MCP controls only the configured Shacraft server."}),
));
}
if method == "ping" {
return Some(rpc_result(id, json!({})));
}
if !self.ready {
return Some(rpc_error(
id,
-32002,
"Initialize and send notifications/initialized first",
));
}
let result = match method {
"tools/list" => {
if params.get("cursor").is_some() {
return Some(rpc_error(id, -32602, "This tool list has no further pages"));
}
json!({"tools":tools()})
}
"tools/call" => {
let Some(name) = params.get("name").and_then(Value::as_str) else {
return Some(rpc_error(id, -32602, "Tool name is required"));
};
let list = tools();
let Some(tool) = list.iter().find(|t| t["name"] == name) else {
return Some(rpc_error(id, -32602, "Unknown tool"));
};
let args = params
.get("arguments")
.cloned()
.unwrap_or_else(|| json!({}));
if let Err(error) = validate(&tool["inputSchema"], &args, "arguments") {
tool_error(error)
} else {
let control = name.replacen('_', ".", 1);
match backend.control(&control, &args) {
Ok(mut output) => {
let mut content = Vec::new();
if name == "camera_capture"
&& let Some(png) = output.get("png_base64").and_then(Value::as_str)
{
content.push(
json!({"type":"image","data":png,"mimeType":"image/png"}),
);
if let Some(obj) = output.as_object_mut() {
obj.remove("png_base64");
}
}
content.push(json!({"type":"text","text":serde_json::to_string(&output).unwrap_or_default()}));
let structured = if output.is_object() {
output
} else {
json!({"result":output})
};
json!({"content":content,"structuredContent":structured,"isError":false})
}
Err(error) => tool_error(format!("{error:#}")),
}
}
}
"resources/list" => resources(),
"resources/templates/list" => json!({"resourceTemplates":[]}),
"resources/read" => {
let Some(uri) = params.get("uri").and_then(Value::as_str) else {
return Some(rpc_error(id, -32602, "Resource URI is required"));
};
let path = match uri {
"shacraft://server/status" => "/api/metrics",
"shacraft://catalog/blocks" => "/api/catalog?offset=0&limit=64",
"shacraft://catalog/entities" => "/api/entities",
"shacraft://packages" => "/api/manifest",
_ => return Some(rpc_error(id, -32002, "Unknown resource URI")),
};
match backend.public(path) {
Ok(data) => {
json!({"contents":[{"uri":uri,"mimeType":"application/json","text":serde_json::to_string(&data).unwrap_or_default()}]})
}
Err(error) => return Some(rpc_error(id, -32000, format!("{error:#}"))),
}
}
"prompts/list" => {
json!({"prompts":[{"name":"construct","description":"Inspect, plan, commit and visually verify a bounded Shacraft build","arguments":[{"name":"world","description":"Target world name","required":true},{"name":"request","description":"What to build and any constraints","required":true}]}]})
}
"prompts/get" => {
if params["name"] != "construct" {
return Some(rpc_error(id, -32602, "Unknown prompt"));
}
let a = &params["arguments"];
let (Some(world), Some(request)) = (a["world"].as_str(), a["request"].as_str())
else {
return Some(rpc_error(
id,
-32602,
"world and request string arguments are required",
));
};
json!({"description":"Revision-safe construction workflow","messages":[{"role":"user","content":{"type":"text","text":format!("Build in world {world}: {request}\nUse world_list and world_read to inspect the site and current revision, then catalog_search for exact block states. Use build_plan for bounded box/line/cylinder operations. Review the resulting bounds and change count against the requested construction; then call build_commit with a unique operation_id if the request authorizes the build. Capture camera_capture afterward and inspect the result. If the revision conflicts, read the world and make a new plan rather than forcing a stale write. Undo uses the original operation_id and is safe only before later edits. Explain content coverage and any limitations relevant to the build.")}}]})
}
_ => return Some(rpc_error(id, -32601, "Method not found")),
};
Some(rpc_result(id, result))
}
}
/// Discards an oversized frame through its newline without accumulating its tail.
fn read_frame(reader: &mut impl BufRead) -> io::Result<Option<std::result::Result<Vec<u8>, ()>>> {
let mut frame = Vec::new();
let mut oversized = false;
loop {
let buffer = reader.fill_buf()?;
if buffer.is_empty() {
return Ok(if frame.is_empty() && !oversized {
None
} else {
Some(if oversized { Err(()) } else { Ok(frame) })
});
}
let end = buffer.iter().position(|&b| b == b'\n').map(|i| i + 1);
let take = end.unwrap_or(buffer.len());
if !oversized {
if frame.len() + take > MAX_FRAME {
oversized = true;
frame.clear();
} else {
frame.extend_from_slice(&buffer[..take]);
}
}
reader.consume(take);
if end.is_some() {
return Ok(Some(if oversized { Err(()) } else { Ok(frame) }));
}
}
}
fn main() -> Result<()> {
let backend = Backend::new(Args::parse())?;
let mut session = Session::default();
let stdin = io::stdin();
let mut input = stdin.lock();
let stdout = io::stdout();
let mut output = stdout.lock();
while let Some(frame) = read_frame(&mut input)? {
let reply = match frame {
Err(()) => Some(rpc_error(Value::Null, -32600, "Request exceeds 8 MiB")),
Ok(bytes) => match serde_json::from_slice::<Value>(&bytes) {
Ok(request) => session.dispatch(request, &backend),
Err(_) => Some(rpc_error(Value::Null, -32700, "Parse error")),
},
};
if let Some(reply) = reply {
serde_json::to_writer(&mut output, &reply)?;
output.write_all(b"\n")?;
output.flush()?;
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn backend() -> Backend {
Backend::new(Args {
server: "http://127.0.0.1:1".into(),
data: PathBuf::from("/nonexistent-shacraft-test-data"),
token_file: None,
})
.unwrap()
}
fn request(id: i32, method: &str, params: Value) -> Value {
json!({"jsonrpc":"2.0","id":id,"method":method,"params":params})
}
fn initialize(s: &mut Session, b: &Backend, version: &str) -> Value {
let result=s.dispatch(request(1,"initialize",json!({"protocolVersion":version,"capabilities":{},"clientInfo":{"name":"test","version":"1"}})),b).unwrap();
assert!(
s.dispatch(
json!({"jsonrpc":"2.0","method":"notifications/initialized"}),
b
)
.is_none()
);
result
}
#[test]
fn lifecycle_negotiates_and_rejects_before_initialization() {
let b = backend();
let mut s = Session::default();
assert_eq!(
s.dispatch(request(1, "tools/list", json!({})), &b).unwrap()["error"]["code"],
-32002
);
assert_eq!(
initialize(&mut s, &b, "unknown")["result"]["protocolVersion"],
VERSIONS[0]
);
let list = s.dispatch(request(2, "tools/list", json!({})), &b).unwrap();
assert_eq!(list["result"]["tools"].as_array().unwrap().len(), 17);
assert_eq!(
s.dispatch(request(3, "initialize", json!({})), &b).unwrap()["error"]["code"],
-32600
);
}
#[test]
fn supported_version_is_preserved() {
let b = backend();
let mut s = Session::default();
assert_eq!(
initialize(&mut s, &b, "2024-11-05")["result"]["protocolVersion"],
"2024-11-05"
);
}
#[test]
fn notifications_never_generate_responses() {
let b = backend();
let mut s = Session::default();
assert!(
s.dispatch(json!({"jsonrpc":"2.0","method":"unknown/notification"}), &b)
.is_none()
);
assert!(s.dispatch(json!({"jsonrpc":"2.0","method":"notifications/cancelled","params":{"requestId":3}}),&b).is_none());
}
#[test]
fn invalid_tool_arguments_are_mcp_content_errors() {
let b = backend();
let mut s = Session::default();
initialize(&mut s, &b, VERSIONS[0]);
let reply=s.dispatch(request(2,"tools/call",json!({"name":"world_edit","arguments":{"world":"lobby","expected_revision":0,"operation_id":"x","changes":[{"pos":[0,0],"block":1}]}})),&b).unwrap();
assert_eq!(reply["result"]["isError"], true);
assert!(
reply["result"]["content"][0]["text"]
.as_str()
.unwrap()
.contains("too few items")
);
}
#[test]
fn unavailable_token_is_a_tool_error_not_transport_error() {
let b = backend();
let mut s = Session::default();
initialize(&mut s, &b, VERSIONS[0]);
let reply = s
.dispatch(request(2, "tools/call", json!({"name":"world_list"})), &b)
.unwrap();
assert_eq!(reply["result"]["isError"], true);
assert!(reply.get("error").is_none());
}
#[test]
fn malformed_ids_and_requests_do_not_execute() {
let b = backend();
let mut s = Session::default();
for req in [
json!([]),
json!({"jsonrpc":"2.0","id":null,"method":"ping"}),
json!({"id":1,"method":"ping"}),
] {
assert_eq!(s.dispatch(req, &b).unwrap()["error"]["code"], -32600);
}
}
#[test]
fn bounded_reader_recovers_after_oversized_line() {
let mut input = vec![b'x'; MAX_FRAME + 100];
input.extend_from_slice(b"\n{\"ok\":true}\n");
let mut input = io::BufReader::new(io::Cursor::new(input));
assert!(read_frame(&mut input).unwrap().unwrap().is_err());
assert_eq!(
read_frame(&mut input).unwrap().unwrap().unwrap(),
b"{\"ok\":true}\n"
);
assert!(read_frame(&mut input).unwrap().is_none());
}
#[test]
fn build_shapes_validate_without_allowing_invented_parameters() {
let list = tools();
let schema = &list.iter().find(|t| t["name"] == "build_plan").unwrap()["inputSchema"];
assert!(validate(schema,&json!({"world":"lobby","expected_revision":0,"operations":[{"type":"box","min":[0,0,0],"max":[3,3,3],"block":"minecraft:stone"}]}),"args").is_ok());
assert!(validate(schema,&json!({"world":"lobby","expected_revision":0,"operations":[{"type":"box","min":[0,0,0],"max":[3,3,3],"block":1,"erase_everything":true}]}),"args").is_err());
}
}