1 # ezmcp/src/rpc: JSON-RPC 2.0 as MCP 2025-06-18 uses it, with no IO. One 2 # decoded message is classified as a request, a notification, a response or 3 # an invalid request; replies and requests are built here; a reply a client 4 # reads back is classified here too. 5 import Base 6 import 0xa81e161008ecc2f297c0a3065cb9b188/main.bend as Json 7 import 0xa81e161008ecc2f297c0a3065cb9b188/src/value.bend as Value 8 9 # what one incoming message is (JSON-RPC 2.0 §4, MCP basic#messages) 10 type Msg is Data: 11 Ask{id: Value.Json, method: String, params: Value.Json} 12 Tell{method: String, params: Value.Json} 13 Answer{id: Value.Json} 14 Junk{id: Value.Json, why: String} 15 16 # the fields of a message the classification looks at 17 type Head is Data: 18 Head{obj: Bool, v2: Bool, has_id: Bool, id_ok: Bool, id: Value.Json, 19 method: Maybe<&2, String>, params: Value.Json, has_result: Bool, 20 has_error: Bool} 21 22 # the error codes JSON-RPC 2.0 §5.1 predefines 23 type Code is Data: 24 ParseError{} 25 InvalidRequest{} 26 MethodNotFound{} 27 InvalidParams{} 28 InternalError{} 29 30 # how a request ends: a result, or an error with its code and message 31 type Out is Data: 32 Ok{result: Value.Json} 33 Err{code: Code, message: String} 34 35 # what a reply a client reads says 36 type Reply is Data: 37 Got{id: Value.Json, result: Value.Json} 38 Refused{id: Value.Json, code: String, message: String} 39 Unread{why: String} 40 41 # --- integers and ids --- 42 43 # the rest of a test that holds so far: false once one char fails 44 def digits.and(ok: Bool, rest: Unit -> Bool) -> Bool: 45 match ok: 46 case True{}: 47 rest(Unit{}) 48 case False{}: 49 False{} 50 51 # whether every char of a text is an ASCII digit (true for the empty text) 52 def digits.go(text: String) -> Bool: 53 match text: 54 case SNil{}: 55 True{} 56 case SCon{head, tail}: 57 digits.and(Char.is_digit(head), _u => digits.go(tail)) 58 59 # whether a text is one or more ASCII digits 60 def digits(text: String) -> Bool: 61 match text: 62 case SNil{}: 63 False{} 64 case SCon{head, tail}: 65 digits.and(Char.is_digit(head), _u => digits.go(tail)) 66 67 # an integer's text after its first char: digits after a minus, else the 68 # first char a digit and digits after it 69 def int.text.of(head: Char, tail: String, minus: Bool) -> Bool: 70 match minus: 71 case True{}: 72 digits(tail) 73 case False{}: 74 digits(SCon{head, tail}) 75 76 # whether a number's text is an integer: an optional minus, then digits 77 def int.text(text: String) -> Bool: 78 match text: 79 case SNil{}: 80 False{} 81 case SCon{+head, tail}: 82 int.text.of(head, tail, Char.is_eq(head, '-')) 83 84 def id.num(meth: Maybe<&2, String>) -> Bool: 85 match meth: 86 case None{}: 87 False{} 88 case Some{raw}: 89 int.text(raw) 90 91 def id.str(meth: Maybe<&2, String>, +val: Value.Json) -> Bool: 92 match meth: 93 case Some{_s}: 94 True{} 95 case None{}: 96 id.num(Json.as_num(val)) 97 98 # whether a value is a request id MCP allows: a string or an integer, never 99 # null (basic#requests) 100 def id.ok(+val: Value.Json) -> Bool: 101 id.str(Json.as_str(val), val) 102 103 # --- classification --- 104 105 def is.v2(meth: Maybe<&2, String>) -> Bool: 106 match meth: 107 case None{}: 108 False{} 109 case Some{s}: 110 String.eq(s, "2.0") 111 112 # the fields of one decoded message 113 def head(+val: Value.Json) -> Head: 114 Head{Json.is_obj(val), is.v2(Json.as_str(Json.get(val, "jsonrpc"))), 115 Json.has(val, "id"), id.ok(Json.get(val, "id")), Json.get(val, "id"), 116 Json.as_str(Json.get(val, "method")), Json.get(val, "params"), 117 Json.has(val, "result"), Json.has(val, "error")} 118 119 # the id an error reply to a message carries: its own when it has a valid 120 # one, null otherwise (JSON-RPC 2.0 §5) 121 def head.id(has_id: Bool, id_ok: Bool, id: Value.Json) -> Value.Json: 122 Bool.pick(Value.Json, Bool.and(has_id, id_ok), id, Json.null()) 123 124 # a message with a method: a request when it has an id, which must be a 125 # valid one; a notification when it has none 126 def decide.method( 127 has_id: Bool, 128 id_ok: Bool, 129 id: Value.Json, 130 method: String, 131 params: Value.Json 132 ) -> Msg: 133 match has_id: 134 case False{}: 135 Tell{method, params} 136 case True{}: 137 Bool.pick(Msg, id_ok, Ask{id, method, params}, 138 Junk{Json.null(), "Invalid Request: id must be a string or an integer"}) 139 140 # whether exactly one of two is true 141 def one(lead: Bool, byte: Bool) -> Bool: 142 match lead byte: 143 case True{} False{}: 144 True{} 145 case False{} True{}: 146 True{} 147 case x y: 148 False{} 149 150 # a message with no method: a response when it has a valid id and exactly 151 # one of result and error 152 def decide.answer( 153 +has_id: Bool, 154 +id_ok: Bool, 155 +id: Value.Json, 156 has_result: Bool, 157 has_error: Bool 158 ) -> Msg: 159 Bool.pick(Msg, Bool.and(Bool.and(has_id, id_ok), 160 one(has_result, has_error)), 161 Answer{id}, Junk{head.id(has_id, id_ok, id), "Invalid Request: no method"}) 162 163 def decide.kind( 164 has_id: Bool, 165 +id_ok: Bool, 166 +id: Value.Json, 167 method: Maybe<&2, String>, 168 params: Value.Json, 169 has_result: Bool, 170 has_error: Bool 171 ) -> Msg: 172 match method: 173 case Some{m}: 174 decide.method(has_id, id_ok, id, m, params) 175 case None{}: 176 decide.answer(has_id, id_ok, id, has_result, has_error) 177 178 def decide.v2( 179 v2: Bool, 180 +has_id: Bool, 181 +id_ok: Bool, 182 +id: Value.Json, 183 method: Maybe<&2, String>, 184 params: Value.Json, 185 has_result: Bool, 186 has_error: Bool 187 ) -> Msg: 188 match v2: 189 case False{}: 190 Junk{head.id(has_id, id_ok, id), 191 "Invalid Request: jsonrpc must be \"2.0\""} 192 case True{}: 193 decide.kind(has_id, id_ok, id, method, params, has_result, has_error) 194 195 # what a message is, from its fields 196 def decide(head: Head) -> Msg: 197 match head: 198 case Head{obj, v2, has_id, id_ok, id, method, params, has_result, has_error}: 199 Bool.pick(Msg, obj, 200 decide.v2(v2, has_id, id_ok, id, method, params, has_result, 201 has_error), 202 Junk{Json.null(), "Invalid Request: not a JSON-RPC object"}) 203 204 # what one decoded message is 205 def classify(val: Value.Json) -> Msg: 206 decide(head(val)) 207 208 # --- replies --- 209 210 # an error code's number 211 def code.num(cb: Code) -> String: 212 match cb: 213 case ParseError{}: 214 "-32700" 215 case InvalidRequest{}: 216 "-32600" 217 case MethodNotFound{}: 218 "-32601" 219 case InvalidParams{}: 220 "-32602" 221 case InternalError{}: 222 "-32603" 223 224 # an error object: an integer code and a message (basic#responses) 225 def error.obj(cb: Code, message: String) -> Value.Json: 226 Json.obj([("code", Json.num(code.num(cb))), ("message", Json.str(message))]) 227 228 # the reply to a request with this id (MCP basic#responses) 229 def reply(id: Value.Json, out: Out) -> Value.Json: 230 match out: 231 case Ok{result}: 232 Json.obj([("jsonrpc", Json.str("2.0")), ("id", id), ("result", result)]) 233 case Err{c, message}: 234 Json.obj([("jsonrpc", Json.str("2.0")), ("id", id), 235 ("error", error.obj(c, message))]) 236 237 # a request: jsonrpc, id, method and params 238 def request(id: Value.Json, method: String, params: Value.Json) -> Value.Json: 239 Json.obj([("jsonrpc", Json.str("2.0")), ("id", id), 240 ("method", Json.str(method)), ("params", params)]) 241 242 # a notification: jsonrpc, method and params, and no id 243 def notice(method: String, params: Value.Json) -> Value.Json: 244 Json.obj([("jsonrpc", Json.str("2.0")), ("method", Json.str(method)), 245 ("params", params)]) 246 247 # --- reading a reply (client side) --- 248 249 # the fields of a reply a client reads 250 type Back is Data: 251 Back{v2: Bool, id: Value.Json, has_result: Bool, result: Value.Json, 252 has_error: Bool, code: Maybe<&2, String>, message: Maybe<&2, String>} 253 254 def back.refused( 255 id: Value.Json, 256 code: Maybe<&2, String>, 257 message: Maybe<&2, String> 258 ) -> Reply: 259 match code message: 260 case Some{c} Some{m}: 261 Refused{id, c, m} 262 case c m: 263 Unread{"the error has no integer code or no message"} 264 265 def back.has( 266 id: Value.Json, 267 has_result: Bool, 268 result: Value.Json, 269 has_error: Bool, 270 code: Maybe<&2, String>, 271 message: Maybe<&2, String> 272 ) -> Reply: 273 match has_result has_error: 274 case True{} False{}: 275 Got{id, result} 276 case False{} True{}: 277 back.refused(id, code, message) 278 case r e: 279 Unread{"a reply must hold exactly one of result and error"} 280 281 # what a reply says, from its fields 282 def back.decide(byte: Back) -> Reply: 283 match byte: 284 case Back{v2, id, has_result, result, has_error, code, message}: 285 Bool.pick(Reply, v2, 286 back.has(id, has_result, result, has_error, code, message), 287 Unread{"not a JSON-RPC 2.0 reply"}) 288 289 def back.code(meth: Maybe<&2, String>) -> Maybe<&2, String>: 290 match meth: 291 case None{}: 292 None{} 293 case Some{+raw}: 294 Bool.pick(Maybe<&2, String>, int.text(raw), Some{raw}, None{}) 295 296 # the fields of one decoded reply 297 def back(+val: Value.Json) -> Back: 298 Back{is.v2(Json.as_str(Json.get(val, "jsonrpc"))), Json.get(val, "id"), 299 Json.has(val, "result"), Json.get(val, "result"), Json.has(val, "error"), 300 back.code(Json.as_num(Json.get(Json.get(val, "error"), "code"))), 301 Json.as_str(Json.get(Json.get(val, "error"), "message"))} 302 303 def read.parsed(meth: Maybe<&2, Value.Json>) -> Reply: 304 match meth: 305 case None{}: 306 Unread{"the reply is not JSON"} 307 case Some{v}: 308 back.decide(back(v)) 309 310 # what one reply's text says 311 def read(text: String) -> Reply: 312 read.parsed(Json.parse(text))