| 1 | |
|
| 2 | |
|
| 3 | |
|
| 4 | |
|
| 5 | |
|
| 6 | |
|
| 7 | |
|
| 8 | |
|
| 9 | |
|
| 10 | | use serde_json::{json, Value}; |
| 11 | | use std::io::{BufRead, Write}; |
| 12 | |
|
| 13 | |
|
| 14 | |
|
| 15 | | pub const PROTOCOL_VERSIONS: &[&str] = &["2025-06-18", "2025-03-26", "2024-11-05"]; |
| 16 | |
|
| 17 | |
|
| 18 | | #[derive(Clone, Debug)] |
| 19 | | pub struct Tool { |
| 20 | | pub name: String, |
| 21 | | pub description: String, |
| 22 | | |
| 23 | | pub input_schema: Value, |
| 24 | | |
| 25 | | pub read_only: bool, |
| 26 | | |
| 27 | | pub destructive: bool, |
| 28 | | } |
| 29 | |
|
| 30 | | impl Tool { |
| 31 | 38× | fn describe(&self) -> Value { |
| 32 | 38× | json!({ |
| 33 | 38× | "name": self.name, |
| 34 | 38× | "description": self.description, |
| 35 | 38× | "inputSchema": self.input_schema, |
| 36 | 38× | "annotations": { |
| 37 | 38× | "readOnlyHint": self.read_only, |
| 38 | 38× | "destructiveHint": self.destructive, |
| 39 | | }, |
| 40 | | }) |
| 41 | 38× | } |
| 42 | | } |
| 43 | |
|
| 44 | |
|
| 45 | |
|
| 46 | |
|
| 47 | | #[derive(Clone, Debug, Default, PartialEq)] |
| 48 | | pub struct ToolOutput { |
| 49 | | pub text: String, |
| 50 | | pub structured: Option<Value>, |
| 51 | | pub is_error: bool, |
| 52 | | } |
| 53 | |
|
| 54 | | impl ToolOutput { |
| 55 | 5× | pub fn ok(text: impl Into<String>) -> ToolOutput { |
| 56 | 5× | ToolOutput { |
| 57 | 5× | text: text.into(), |
| 58 | 5× | structured: None, |
| 59 | 5× | is_error: false, |
| 60 | 5× | } |
| 61 | 5× | } |
| 62 | |
|
| 63 | 2× | pub fn error(text: impl Into<String>) -> ToolOutput { |
| 64 | 2× | ToolOutput { |
| 65 | 2× | text: text.into(), |
| 66 | 2× | structured: None, |
| 67 | 2× | is_error: true, |
| 68 | 2× | } |
| 69 | 2× | } |
| 70 | |
|
| 71 | 8× | fn to_json(&self) -> Value { |
| 72 | 8× | let mut out = json!({ |
| 73 | 8× | "content": [{ "type": "text", "text": self.text }], |
| 74 | 8× | "isError": self.is_error, |
| 75 | | }); |
| 76 | | |
| 77 | 8× | if let Some(s) = &self.structured { |
| 78 | 4× | let obj = if s.is_object() { |
| 79 | 3× | s.clone() |
| 80 | | } else { |
| 81 | 1× | json!({ "result": s }) |
| 82 | | }; |
| 83 | 4× | out["structuredContent"] = obj; |
| 84 | 4× | } |
| 85 | 8× | out |
| 86 | 8× | } |
| 87 | | } |
| 88 | |
|
| 89 | |
|
| 90 | | pub trait Tools { |
| 91 | | fn list(&self) -> Vec<Tool>; |
| 92 | | |
| 93 | | fn call(&self, name: &str, args: &Value) -> Option<ToolOutput>; |
| 94 | | } |
| 95 | |
|
| 96 | |
|
| 97 | | #[derive(Clone, Debug)] |
| 98 | | pub struct ServerInfo { |
| 99 | | pub name: String, |
| 100 | | pub version: String, |
| 101 | | |
| 102 | | pub instructions: String, |
| 103 | | } |
| 104 | |
|
| 105 | | const PARSE_ERROR: i64 = -32700; |
| 106 | | const INVALID_REQUEST: i64 = -32600; |
| 107 | | const METHOD_NOT_FOUND: i64 = -32601; |
| 108 | | const INVALID_PARAMS: i64 = -32602; |
| 109 | |
|
| 110 | 5× | fn error(id: Value, code: i64, message: &str) -> Value { |
| 111 | 5× | json!({ "jsonrpc": "2.0", "id": id, "error": { "code": code, "message": message } }) |
| 112 | 5× | } |
| 113 | |
|
| 114 | 15× | fn result(id: Value, result: Value) -> Value { |
| 115 | 15× | json!({ "jsonrpc": "2.0", "id": id, "result": result }) |
| 116 | 15× | } |
| 117 | |
|
| 118 | |
|
| 119 | 22× | pub fn handle(info: &ServerInfo, tools: &dyn Tools, message: &Value) -> Option<Value> { |
| 120 | 22× | let Some(obj) = message.as_object() else { |
| 121 | 0× | return Some(error( |
| 122 | 0× | Value::Null, |
| 123 | 0× | INVALID_REQUEST, |
| 124 | 0× | "a message is a JSON object", |
| 125 | 0× | )); |
| 126 | | }; |
| 127 | 22× | let method = obj.get("method").and_then(Value::as_str); |
| 128 | 22× | let id = obj.get("id").cloned(); |
| 129 | 22× | let Some(method) = method else { |
| 130 | | |
| 131 | | |
| 132 | 0× | return id.map(|id| error(id, INVALID_REQUEST, "a request needs a method")); |
| 133 | | }; |
| 134 | | |
| 135 | 22× | let id = id?; |
| 136 | 19× | let params = obj.get("params").cloned().unwrap_or(Value::Null); |
| 137 | 19× | Some(match method { |
| 138 | 19× | "initialize" => { |
| 139 | 3× | let asked = params.get("protocolVersion").and_then(Value::as_str); |
| 140 | 3× | let version = asked |
| 141 | 3× | .filter(|v| PROTOCOL_VERSIONS.contains(v)) |
| 142 | 3× | .unwrap_or(PROTOCOL_VERSIONS[0]); |
| 143 | 3× | result( |
| 144 | 3× | id, |
| 145 | 3× | json!({ |
| 146 | 3× | "protocolVersion": version, |
| 147 | 3× | "capabilities": { "tools": { "listChanged": false } }, |
| 148 | 3× | "serverInfo": { "name": info.name, "version": info.version }, |
| 149 | 3× | "instructions": info.instructions, |
| 150 | | }), |
| 151 | | ) |
| 152 | | } |
| 153 | 16× | "ping" => result(id, json!({})), |
| 154 | 15× | "tools/list" => result( |
| 155 | 3× | id, |
| 156 | 3× | json!({ "tools": tools.list().iter().map(Tool::describe).collect::<Vec<_>>() }), |
| 157 | | ), |
| 158 | 12× | "tools/call" => { |
| 159 | 11× | let Some(name) = params.get("name").and_then(Value::as_str) else { |
| 160 | 0× | return Some(error( |
| 161 | 0× | id, |
| 162 | 0× | INVALID_PARAMS, |
| 163 | 0× | "tools/call needs the tool's name", |
| 164 | 0× | )); |
| 165 | | }; |
| 166 | 11× | let args = params |
| 167 | 11× | .get("arguments") |
| 168 | 11× | .cloned() |
| 169 | 11× | .unwrap_or_else(|| json!({})); |
| 170 | 11× | if !args.is_object() { |
| 171 | 1× | return Some(error( |
| 172 | 1× | id, |
| 173 | 1× | INVALID_PARAMS, |
| 174 | 1× | "a tool's arguments are a JSON object", |
| 175 | 1× | )); |
| 176 | 10× | } |
| 177 | 10× | match tools.call(name, &args) { |
| 178 | 8× | Some(out) => result(id, out.to_json()), |
| 179 | 2× | None => error(id, INVALID_PARAMS, &format!("no tool named {name}")), |
| 180 | | } |
| 181 | | } |
| 182 | 1× | other => error(id, METHOD_NOT_FOUND, &format!("{other} is not supported")), |
| 183 | | }) |
| 184 | 22× | } |
| 185 | |
|
| 186 | |
|
| 187 | |
|
| 188 | |
|
| 189 | 4× | pub fn serve( |
| 190 | 4× | info: &ServerInfo, |
| 191 | 4× | tools: &dyn Tools, |
| 192 | 4× | input: impl BufRead, |
| 193 | 4× | mut output: impl Write, |
| 194 | 4× | ) -> std::io::Result<()> { |
| 195 | 23× | for line in input.lines() { |
| 196 | 23× | let line = line?; |
| 197 | 23× | if line.trim().is_empty() { |
| 198 | 1× | continue; |
| 199 | 22× | } |
| 200 | 22× | let reply = match serde_json::from_str::<Value>(&line) { |
| 201 | 1× | Ok(Value::Array(batch)) => { |
| 202 | 1× | let replies: Vec<Value> = batch |
| 203 | 1× | .iter() |
| 204 | 2× | .filter_map(|m| handle(info, tools, m)) |
| 205 | 1× | .collect(); |
| 206 | 1× | (!replies.is_empty()).then_some(Value::Array(replies)) |
| 207 | | } |
| 208 | 20× | Ok(message) => handle(info, tools, &message), |
| 209 | 1× | Err(e) => Some(error(Value::Null, PARSE_ERROR, &format!("not JSON: {e}"))), |
| 210 | | }; |
| 211 | 22× | if let Some(reply) = reply { |
| 212 | 20× | writeln!(output, "{reply}")?; |
| 213 | 20× | output.flush()?; |
| 214 | 2× | } |
| 215 | | } |
| 216 | 4× | Ok(()) |
| 217 | 4× | } |
| 218 | |
|
| 219 | |
|
| 220 | 20× | pub fn arg_str<'a>(args: &'a Value, key: &str) -> Option<&'a str> { |
| 221 | 20× | args.get(key).and_then(Value::as_str) |
| 222 | 20× | } |
| 223 | |
|
| 224 | |
|
| 225 | 10× | pub fn arg_strings(args: &Value, key: &str) -> Result<Vec<String>, String> { |
| 226 | 10× | match args.get(key) { |
| 227 | 4× | None | Some(Value::Null) => Ok(vec![]), |
| 228 | 1× | Some(Value::String(s)) => Ok(vec![s.clone()]), |
| 229 | 5× | Some(Value::Array(a)) => a |
| 230 | 5× | .iter() |
| 231 | 6× | .map(|v| { |
| 232 | 6× | v.as_str() |
| 233 | 6× | .map(str::to_string) |
| 234 | 6× | .ok_or_else(|| format!("{key} must be a list of strings")) |
| 235 | 6× | }) |
| 236 | 5× | .collect(), |
| 237 | 0× | Some(_) => Err(format!("{key} must be a list of strings")), |
| 238 | | } |
| 239 | 10× | } |
| 240 | |
|
| 241 | | #[cfg(test)] |
| 242 | | mod tests { |
| 243 | | use super::*; |
| 244 | |
|
| 245 | | struct Echo; |
| 246 | |
|
| 247 | | impl Tools for Echo { |
| 248 | 1× | fn list(&self) -> Vec<Tool> { |
| 249 | 1× | vec![Tool { |
| 250 | 1× | name: "echo".into(), |
| 251 | 1× | description: "says it back".into(), |
| 252 | 1× | input_schema: json!({"type": "object"}), |
| 253 | 1× | read_only: true, |
| 254 | 1× | destructive: false, |
| 255 | 1× | }] |
| 256 | 1× | } |
| 257 | 2× | fn call(&self, name: &str, args: &Value) -> Option<ToolOutput> { |
| 258 | 2× | (name == "echo").then(|| ToolOutput { |
| 259 | 1× | text: args.to_string(), |
| 260 | 1× | structured: Some(json!([1, 2])), |
| 261 | | is_error: false, |
| 262 | 1× | }) |
| 263 | 2× | } |
| 264 | | } |
| 265 | |
|
| 266 | 2× | fn info() -> ServerInfo { |
| 267 | 2× | ServerInfo { |
| 268 | 2× | name: "t".into(), |
| 269 | 2× | version: "1".into(), |
| 270 | 2× | instructions: "be careful".into(), |
| 271 | 2× | } |
| 272 | 2× | } |
| 273 | |
|
| 274 | 2× | fn talk(lines: &str) -> Vec<Value> { |
| 275 | 2× | let mut out = Vec::new(); |
| 276 | 2× | serve(&info(), &Echo, lines.as_bytes(), &mut out).unwrap(); |
| 277 | 2× | String::from_utf8(out) |
| 278 | 2× | .unwrap() |
| 279 | 2× | .lines() |
| 280 | 9× | .map(|l| serde_json::from_str(l).unwrap()) |
| 281 | 2× | .collect() |
| 282 | 2× | } |
| 283 | |
|
| 284 | | #[test] |
| 285 | 1× | fn a_session_initializes_lists_and_calls() { |
| 286 | 1× | let r = talk(concat!( |
| 287 | | r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"c","version":"0"}}}"#, |
| 288 | | "\n", |
| 289 | | r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#, |
| 290 | | "\n\n", |
| 291 | | r#"{"jsonrpc":"2.0","id":2,"method":"tools/list"}"#, |
| 292 | | "\n", |
| 293 | | r#"{"jsonrpc":"2.0","id":"three","method":"tools/call","params":{"name":"echo","arguments":{"a":1}}}"#, |
| 294 | | "\n", |
| 295 | | )); |
| 296 | 1× | assert_eq!(r.len(), 3, "the notification gets no reply: {r:?}"); |
| 297 | 1× | assert_eq!( |
| 298 | 1× | r[0]["result"]["protocolVersion"], "2025-03-26", |
| 299 | | "a version we speak is kept" |
| 300 | | ); |
| 301 | 1× | assert_eq!(r[0]["result"]["instructions"], "be careful"); |
| 302 | 1× | assert_eq!(r[1]["result"]["tools"][0]["name"], "echo"); |
| 303 | 1× | assert_eq!( |
| 304 | 1× | r[1]["result"]["tools"][0]["annotations"]["readOnlyHint"], |
| 305 | | true |
| 306 | | ); |
| 307 | 1× | assert_eq!(r[2]["id"], "three"); |
| 308 | 1× | assert_eq!(r[2]["result"]["content"][0]["text"], r#"{"a":1}"#); |
| 309 | 1× | assert_eq!( |
| 310 | 1× | r[2]["result"]["structuredContent"], |
| 311 | 1× | json!({"result": [1, 2]}), |
| 312 | | "an array is wrapped in an object" |
| 313 | | ); |
| 314 | 1× | assert_eq!(r[2]["result"]["isError"], false); |
| 315 | 1× | } |
| 316 | |
|
| 317 | | #[test] |
| 318 | 1× | fn errors_are_protocol_errors_and_unknown_versions_get_the_newest() { |
| 319 | 1× | let r = talk(concat!( |
| 320 | | "not json\n", |
| 321 | | r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"1999-01-01"}}"#, |
| 322 | | "\n", |
| 323 | | r#"{"jsonrpc":"2.0","id":2,"method":"resources/list"}"#, |
| 324 | | "\n", |
| 325 | | r#"{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"nope"}}"#, |
| 326 | | "\n", |
| 327 | | r#"{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"echo","arguments":[1]}}"#, |
| 328 | | "\n", |
| 329 | | r#"[{"jsonrpc":"2.0","id":5,"method":"ping"},{"jsonrpc":"2.0","method":"notifications/cancelled"}]"#, |
| 330 | | "\n", |
| 331 | | )); |
| 332 | 1× | assert_eq!(r[0]["error"]["code"], PARSE_ERROR); |
| 333 | 1× | assert_eq!(r[1]["result"]["protocolVersion"], PROTOCOL_VERSIONS[0]); |
| 334 | 1× | assert_eq!(r[2]["error"]["code"], METHOD_NOT_FOUND); |
| 335 | 1× | assert_eq!(r[3]["error"]["code"], INVALID_PARAMS); |
| 336 | 1× | assert_eq!(r[4]["error"]["code"], INVALID_PARAMS); |
| 337 | 1× | assert_eq!( |
| 338 | 1× | r[5], |
| 339 | 1× | json!([{"jsonrpc": "2.0", "id": 5, "result": {}}]), |
| 340 | | "a batch is answered as a batch" |
| 341 | | ); |
| 342 | 1× | } |
| 343 | |
|
| 344 | | #[test] |
| 345 | 1× | fn string_arguments_and_lists() { |
| 346 | 1× | let a = json!({"one": "x", "many": ["a", "b"], "bad": [1]}); |
| 347 | 1× | assert_eq!(arg_str(&a, "one"), Some("x")); |
| 348 | 1× | assert_eq!(arg_strings(&a, "one").unwrap(), vec!["x"]); |
| 349 | 1× | assert_eq!(arg_strings(&a, "many").unwrap(), vec!["a", "b"]); |
| 350 | 1× | assert_eq!(arg_strings(&a, "missing").unwrap(), Vec::<String>::new()); |
| 351 | 1× | assert!(arg_strings(&a, "bad").is_err()); |
| 352 | 1× | } |
| 353 | | } |