src/rpc.bend

ezmcp@0.1.0.0 · 312 lines · 36 definitions

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.
5import Base
6import emerging-ezjson@1.2.0.0/main.bend as Json
7import emerging-ezjson@1.2.0.0/src/value.bend as Value
8
9# what one incoming message is (JSON-RPC 2.0 §4, MCP basic#messages)
10type 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
17type 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
23type 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
31type Out is Data:
32  Ok{result: Value.Json}
33  Err{code: Code, message: String}
34
35# what a reply a client reads says
36type 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
44def 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)
52def 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
60def 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
69def 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
77def 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
84def id.num(meth: Maybe<&2, String>) -> Bool:
85  match meth:
86    case None{}:
87      False{}
88    case Some{raw}:
89      int.text(raw)
90
91def 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)
100def id.ok(+val: Value.Json) -> Bool:
101  id.str(Json.as_str(val), val)
102
103# --- classification ---
104
105def 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
113def 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)
121def 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
126def 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
141def 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
152def 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
163def 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
178def 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
196def 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
205def classify(val: Value.Json) -> Msg:
206  decide(head(val))
207
208# --- replies ---
209
210# an error code's number
211def 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)
225def 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)
229def 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
238def 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
243def 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
250type 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
254def 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
265def 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
282def 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
289def 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
297def 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
303def 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
311def read(text: String) -> Reply:
312  read.parsed(Json.parse(text))