src/server.bend

ezmcp@0.1.0.0 · 465 lines · 59 definitions

1# ezmcp/src/server: an MCP server's decisions, with no IO. One message's
2# text in, under the protocol revision the transport reads it by, an act
3# out: a reply to send, nothing to send, a tool call for the transport to
4# run (src/io.bend) before it replies with `ran`, or, under 2025-03-26, a
5# batch of those.
6import Base
7import emerging-ezjson@1.2.0.0/main.bend as Json
8import emerging-ezjson@1.2.0.0/src/value.bend as Value
9import ezmcp@0.1.0.0/src/rpc.bend as Rpc
10
11# one tool: its name, what it does, and the JSON Schema of its arguments
12type Tool is Data:
13  Tool{name: String, description: String, input: Value.Json}
14
15# a server: its name and version, instructions for the client (empty for
16# none), and its tools
17type Server is Data:
18  Server{name: String, version: String, instructions: String,
19    tools: List<&2, Tool>}
20
21# what a server does with one message; a Batch's acts are its elements',
22# in order, and none of them is a Batch
23type Act is Data:
24  Quiet{}
25  Send{reply: Value.Json}
26  Fault{reply: Value.Json}
27  Run{id: Value.Json, name: String, args: Value.Json}
28  Batch{acts: List<&2, Act>}
29
30# what a server does with one message, and the revision the messages after
31# it are read by on the same stream
32type Turn is Data:
33  Turn{act: Act, rev: String}
34
35# how a method ends: an outcome now, or a tool to run
36type Step is Data:
37  Now{out: Rpc.Out}
38  Call{name: String, args: Value.Json}
39
40# the methods a server answers
41type Method is Data:
42  Initialize{}
43  Ping{}
44  ListTools{}
45  CallTool{}
46  Other{}
47
48# --- protocol versions ---
49
50# the latest revision ezmcp speaks
51def latest() -> String:
52  "2025-11-25"
53
54# the oldest: the one JSON-RPC batches belong to, and the one a server
55# assumes for an HTTP request that names none
56def oldest() -> String:
57  "2025-03-26"
58
59# the revisions ezmcp speaks, oldest first
60def revisions() -> List<&2, String>:  # noqa: L001 closed constant; supported_revisions states its members
61  [oldest(), "2025-06-18", latest()]
62
63# whether ezmcp speaks a revision
64def supported(+val: String) -> Bool:
65  Bool.or(String.eq(val, oldest()),
66    Bool.or(String.eq(val, "2025-06-18"), String.eq(val, latest())))
67
68# whether a revision receives JSON-RPC batches: 2025-03-26 does, and
69# 2025-06-18 removed them
70def batches(rev: String) -> Bool:
71  String.eq(rev, oldest())
72
73# the revision a server answers to the one a client asks for: the same when
74# it is supported, else the latest (basic/lifecycle#version-negotiation)
75def negotiate.of(val: String, ok: Bool) -> String:
76  match ok:
77    case True{}:
78      val
79    case False{}:
80      latest()
81
82# the revision answered to an initialize request's, if it names one
83def negotiate(asked: Maybe<&2, String>) -> String:
84  match asked:
85    case None{}:
86      latest()
87    case Some{+v}:
88      negotiate.of(v, supported(v))
89
90# --- the revision of a stream ---
91
92def session.ask(rev: String, params: Value.Json, init: Bool) -> String:
93  match init:
94    case True{}:
95      negotiate(Json.as_str(Json.get(params, "protocolVersion")))
96    case False{}:
97      rev
98
99# the revision the messages after this one are read by: the one the server
100# answers an initialize request with, else the one they were read by
101def session(rev: String, msg: Rpc.Msg) -> String:
102  match msg:
103    case Rpc.Ask{id, +m, params}:
104      session.ask(rev, params, String.eq(m, "initialize"))
105    case Rpc.Tell{m, params}:
106      rev
107    case Rpc.Answer{id}:
108      rev
109    case Rpc.Junk{id, why}:
110      rev
111
112# --- initialize ---
113
114# the capabilities a tool table declares: tools when there is one, with
115# listChanged false, since the table never changes
116def capabilities(tools: List<&2, Tool>) -> Value.Json:
117  match tools:
118    case []:
119      Json.obj([])
120    case t <> rest:
121      Json.obj([("tools", Json.obj([("listChanged", Json.bool(False{}))]))])
122
123def init.info(name: String, version: String) -> Value.Json:
124  Json.obj([("name", Json.str(name)), ("version", Json.str(version))])
125
126# the instructions member, when there are any
127def init.extra(
128  instructions: String,
129  empty: Bool
130) -> List<&1, String & Value.Json>:
131  match empty:
132    case True{}:
133      []
134    case False{}:
135      [("instructions", Json.str(instructions))]
136
137# the members of an initialize result
138def init.with(
139  version: String,
140  caps: Value.Json,
141  info: Value.Json,
142  +instructions: String
143) -> Value.Json:
144  Json.obj(List.append(&1, String & Value.Json,
145    [("protocolVersion", Json.str(version)), ("capabilities", caps),
146      ("serverInfo", info)],
147    init.extra(instructions, String.is_empty(instructions))))
148
149# the result of initialize (basic/lifecycle#initialization)
150def init(srv: Server, params: Value.Json) -> Value.Json:
151  match srv:
152    case Server{name, version, instructions, tools}:
153      init.with(negotiate(Json.as_str(Json.get(params, "protocolVersion"))),
154        capabilities(tools), init.info(name, version), instructions)
155
156# --- tools ---
157
158# one tool as tools/list shows it (server/tools#tool)
159def tool.json(tail: Tool) -> Value.Json:
160  match tail:
161    case Tool{name, description, input}:
162      Json.obj([("name", Json.str(name)), ("description", Json.str(description)),
163        ("inputSchema", input)])
164
165def tool.all(tools: List<&2, Tool>) -> List<&2, Value.Json>:
166  match tools:
167    case []:
168      []
169    case t <> rest:
170      tool.json(t) <> tool.all(rest)
171
172# the result of tools/list: every tool, in order, on one page
173def list(srv: Server) -> Value.Json:
174  match srv:
175    case Server{name, version, instructions, tools}:
176      Json.obj([("tools", Json.arr(tool.all(tools)))])
177
178# true at a hit, else the rest of the search
179def known.or(hit: Bool, rest: Unit -> Bool) -> Bool:
180  match hit:
181    case True{}:
182      True{}
183    case False{}:
184      rest(Unit{})
185
186# whether a tool table has a tool by this name
187def known(tools: List<&2, Tool>, +name: String) -> Bool:
188  match tools:
189    case []:
190      False{}
191    case Tool{tname, _desc, _input} <> rest:
192      known.or(String.eq(tname, name), _u => known(rest, name))
193
194# an invalid-params outcome
195def invalid(message: String) -> Step:
196  Now{Rpc.Err{Rpc.InvalidParams{}, message}}
197
198# the arguments of a call: absent is no arguments, an object is passed on,
199# anything else is invalid
200def call.args(+name: String, has: Bool, +args: Value.Json) -> Step:
201  match has:
202    case False{}:
203      Call{name, Json.obj([])}
204    case True{}:
205      Bool.pick(Step, Json.is_obj(args), Call{name, args},
206        invalid("tools/call: arguments must be an object"))
207
208def call.known(+name: String, +params: Value.Json, hit: Bool) -> Step:
209  match hit:
210    case False{}:
211      invalid("Unknown tool: " ++ name)
212    case True{}:
213      call.args(name, Json.has(params, "arguments"),
214        Json.get(params, "arguments"))
215
216def call.named(
217  tools: List<&2, Tool>,
218  +params: Value.Json,
219  name: Maybe<&2, String>
220) -> Step:
221  match name:
222    case None{}:
223      invalid("tools/call: params.name must be a string")
224    case Some{+n}:
225      call.known(n, params, known(tools, n))
226
227# tools/call: a known tool with object arguments is a call to run; anything
228# else is an invalid-params error (server/tools#error-handling)
229def call(srv: Server, +params: Value.Json) -> Step:
230  match srv:
231    case Server{name, version, instructions, tools}:
232      call.named(tools, params, Json.as_str(Json.get(params, "name")))
233
234# --- dispatch ---
235
236def method.of(+meth: String) -> Method:
237  Bool.pick(Method, String.eq(meth, "initialize"), Initialize{},
238    Bool.pick(Method, String.eq(meth, "ping"), Ping{},
239      Bool.pick(Method, String.eq(meth, "tools/list"), ListTools{},
240        Bool.pick(Method, String.eq(meth, "tools/call"), CallTool{}, Other{}))))
241
242def dispatch.of(
243  srv: Server,
244  meth: String,
245  params: Value.Json,
246  which: Method
247) -> Step:
248  match which:
249    case Initialize{}:
250      Now{Rpc.Ok{init(srv, params)}}
251    case Ping{}:
252      Now{Rpc.Ok{Json.obj([])}}
253    case ListTools{}:
254      Now{Rpc.Ok{list(srv)}}
255    case CallTool{}:
256      call(srv, params)
257    case Other{}:
258      Now{Rpc.Err{Rpc.MethodNotFound{}, "Method not found: " ++ meth}}
259
260# how a request for a method ends
261def dispatch(srv: Server, +meth: String, params: Value.Json) -> Step:
262  dispatch.of(srv, meth, params, method.of(meth))
263
264# a request's step as an act
265def answer(id: Value.Json, text: Step) -> Act:
266  match text:
267    case Now{out}:
268      Send{Rpc.reply(id, out)}
269    case Call{name, args}:
270      Run{id, name, args}
271
272# what a server does with one classified message: a request is answered, a
273# notification or a response is not, an invalid request is a fault
274def handle(srv: Server, msg: Rpc.Msg) -> Act:
275  match msg:
276    case Rpc.Ask{id, m, params}:
277      answer(id, dispatch(srv, m, params))
278    case Rpc.Tell{m, params}:
279      Quiet{}
280    case Rpc.Answer{id}:
281      Quiet{}
282    case Rpc.Junk{id, why}:
283      Fault{Rpc.reply(id, Rpc.Err{Rpc.InvalidRequest{}, why})}
284
285# the fault for text that is not JSON (JSON-RPC 2.0 §5.1)
286def unparsed() -> Act:
287  Fault{Rpc.reply(Json.null(),
288    Rpc.Err{Rpc.ParseError{}, "Parse error"})}
289
290# --- batches (2025-03-26) ---
291
292def batch.init(id: Value.Json, msg: Rpc.Msg, init: Bool, srv: Server) -> Act:
293  match init:
294    case True{}:
295      Send{Rpc.reply(id, Rpc.Err{Rpc.InvalidRequest{},
296        "Invalid Request: initialize must not be part of a batch"})}
297    case False{}:
298      handle(srv, msg)
299
300# what a server does with one element of a batch: what it does with the
301# message alone, except that initialize is an invalid request
302# (2025-03-26/basic/lifecycle#initialization)
303def batch.one(srv: Server, +msg: Rpc.Msg) -> Act:
304  match msg:
305    case Rpc.Ask{+id, +m, params}:
306      batch.init(id, msg, String.eq(m, "initialize"), srv)
307    case other:
308      handle(srv, other)
309
310# every element's act, in order
311def batch.all(+srv: Server, items: List<&2, Value.Json>) -> List<&2, Act>:
312  match items:
313    case []:
314      []
315    case i <> rest:
316      batch.one(srv, Rpc.classify(i)) <> batch.all(srv, rest)
317
318# what a server does with a batch: an empty one is one invalid request
319# (JSON-RPC §6); any other is a batch of its elements' acts
320def batch(srv: Server, items: List<&2, Value.Json>) -> Act:
321  match items:
322    case []:
323      Fault{Rpc.reply(Json.null(), Rpc.Err{Rpc.InvalidRequest{},
324        "Invalid Request: empty batch"})}
325    case i <> rest:
326      Batch{batch.all(srv, i <> rest)}
327
328# the replies of a batch's elements that have one, in order
329def batch.some(replies: List<&2, Maybe<&2, Value.Json>>) -> List<&2, Value.Json>:
330  match replies:
331    case []:
332      []
333    case None{} <> rest:
334      batch.some(rest)
335    case Some{r} <> rest:
336      r <> batch.some(rest)
337
338def batch.gather(replies: List<&2, Value.Json>) -> Maybe<&2, Value.Json>:
339  match replies:
340    case []:
341      None{}
342    case r <> rest:
343      Some{Json.arr(r <> rest)}
344
345# what a batch sends back: nothing when no element has a reply, else the
346# array of the replies (JSON-RPC §6)
347def batch.reply(replies: List<&2, Maybe<&2, Value.Json>>) -> Maybe<&2, Value.Json>:
348  batch.gather(batch.some(replies))
349
350# --- a message's turn ---
351
352# a message's act, and the revision after it
353def turn.msg(srv: Server, rev: String, +msg: Rpc.Msg) -> Turn:
354  Turn{handle(srv, msg), session(rev, msg)}
355
356# a decoded value: a batch when it is an array and the revision receives
357# batches, else one message
358def turn.value(srv: Server, rev: String, +val: Value.Json, many: Bool) -> Turn:
359  match many:
360    case True{}:
361      Turn{batch(srv, Json.items(val)), rev}
362    case False{}:
363      turn.msg(srv, rev, Rpc.classify(val))
364
365# a decoded message, or text that was not JSON
366def turn.parsed(srv: Server, +rev: String, meth: Maybe<&2, Value.Json>) -> Turn:
367  match meth:
368    case None{}:
369      Turn{unparsed(), rev}
370    case Some{+v}:
371      turn.value(srv, rev, v, Bool.and(Json.is_arr(v), batches(rev)))
372
373# what a server does with one message's text, read by revision `rev`, and
374# the revision the stream's next message is read by
375def turn(srv: Server, rev: String, text: String) -> Turn:
376  turn.parsed(srv, rev, Json.parse(text))
377
378# a turn's act
379def turn.act(tn: Turn) -> Act:
380  match tn:
381    case Turn{act, rev}:
382      act
383
384# what a server does with one message's text, read by revision `rev`
385def step(srv: Server, rev: String, text: String) -> Act:
386  turn.act(turn(srv, rev, text))
387
388# --- the reply to a tool call ---
389
390def without.pair(hit: Bool, key: String, val: Value.Json, rest: Value.Json) -> Value.Json:
391  match hit:
392    case True{}:
393      rest
394    case False{}:
395      Value.JPair{key, val, rest}
396
397def without.bind(
398  hit: Bool,
399  src: String,
400  num: U32,
401  val: Value.Json,
402  rest: Value.Json
403) -> Value.Json:
404  match hit:
405    case True{}:
406      rest
407    case False{}:
408      Value.JBind{src, num, val, rest}
409
410# an object's members, less every one named `key`
411def without.go(cells: Value.Json, +key: String) -> Value.Json:
412  match cells:
413    case Value.JPair{+k, v, r}:
414      without.pair(Value.find.eq(k, key), k, v, without.go(r, key))
415    case Value.JBind{+src, +num, v, r}:
416      without.bind(Value.span.eq(src, num, key), src, num, v,
417        without.go(r, key))
418    case other:
419      other
420
421# an object less every member named `key`; any other value as it is
422def without(val: Value.Json, key: String) -> Value.Json:
423  match val:
424    case Value.JObj{pairs}:
425      Value.JObj{without.go(pairs, key)}
426    case other:
427      other
428
429def shape.of(result: Value.Json, old: Bool) -> Value.Json:
430  match old:
431    case True{}:
432      without(result, "structuredContent")
433    case False{}:
434      result
435
436# a tool's result as revision `rev` reads it: 2025-03-26 has no
437# structuredContent (2025-06-18/server/tools#structured-content), so it is
438# left out; any later revision gets the result as the runner answered it
439def shape(rev: String, result: Value.Json) -> Value.Json:
440  shape.of(result, String.eq(rev, oldest()))
441
442# the reply to a tool call under revision `rev`, once the runner answered:
443# its tool result, shaped for the revision, or the protocol error it chose
444# (server/tools#error-handling)
445def ran(rev: String, id: Value.Json, out: Rpc.Out) -> Value.Json:
446  match out:
447    case Rpc.Ok{result}:
448      Rpc.reply(id, Rpc.Ok{shape(rev, result)})
449    case Rpc.Err{c, message}:
450      Rpc.reply(id, Rpc.Err{c, message})
451
452# --- tool results (server/tools#tool-result) ---
453
454# a text content block
455def text(text2: String) -> Value.Json:
456  Json.obj([("type", Json.str("text")), ("text", Json.str(text2))])
457
458# a tool result from content blocks, flagged when the tool failed
459def result(blocks: List<&2, Value.Json>, bad: Bool) -> Value.Json:
460  Json.obj([("content", Json.arr(blocks)), ("isError", Json.bool(bad))])
461
462# a tool result with structured content, and its JSON text as a text block
463def structured(+val: Value.Json, bad: Bool) -> Value.Json:
464  Json.obj([("content", Json.arr([text(Json.print(val))])),
465    ("structuredContent", val), ("isError", Json.bool(bad))])