src/client.bend

ezmcp@0.1.0.0 · 77 lines · 12 definitions

1# ezmcp/src/client: an MCP client's messages and how it reads replies, with
2# no IO. src/io.bend POSTs them over ezhttp.
3import Base
4import emerging-ezjson@1.2.0.0/main.bend as Json
5import emerging-ezjson@1.2.0.0/src/value.bend as Value
6import emerging-ezhttp@0.21.0.0/src/http.bend as Http
7import ezmcp@0.1.0.0/src/rpc.bend as Rpc
8import ezmcp@0.1.0.0/src/server.bend as Server
9import ezmcp@0.1.0.0/src/sse.bend as Sse
10
11# the params of initialize: the latest revision, no client capabilities,
12# and the client's name and version (basic/lifecycle#initialization)
13def init.params(name: String, version: String) -> Value.Json:
14  Json.obj([("protocolVersion", Json.str(Server.latest())),
15    ("capabilities", Json.obj([])),
16    ("clientInfo", Json.obj([("name", Json.str(name)),
17      ("version", Json.str(version))]))])
18
19# the initialize request
20def initialize(id: Value.Json, name: String, version: String) -> Value.Json:
21  Rpc.request(id, "initialize", init.params(name, version))
22
23# the notification a client sends once initialize succeeded
24def initialized() -> Value.Json:  # noqa: L001 closed constant; notice_no_id holds for every notification
25  Rpc.notice("notifications/initialized", Json.obj([]))
26
27# a tools/list request
28def tools(id: Value.Json) -> Value.Json:
29  Rpc.request(id, "tools/list", Json.obj([]))
30
31# a tools/call request
32def call(id: Value.Json, name: String, args: Value.Json) -> Value.Json:
33  Rpc.request(id, "tools/call",
34    Json.obj([("name", Json.str(name)), ("arguments", args)]))
35
36# the headers of every POST: a JSON body, both reply types accepted, and
37# the negotiated revision once there is one (empty before initialize)
38def headers(+version: String) -> List<&2, Http.Header>:
39  Bool.pick(List<&2, Http.Header>, String.is_empty(version),
40    [Http.H{"Content-Type", "application/json"},
41      Http.H{"Accept", "application/json, text/event-stream"}],
42    [Http.H{"Content-Type", "application/json"},
43      Http.H{"Accept", "application/json, text/event-stream"},
44      Http.H{"MCP-Protocol-Version", version}])
45
46# whether a Content-Type is an event stream
47def is.sse(+ctype: String) -> Bool:
48  String.starts_with(String.to_lower(String.trim(ctype)), "text/event-stream")
49
50# a reply body's JSON text: an event stream's last data, else the body
51def body(sse: Bool, +text: String) -> String:
52  match sse:
53    case True{}:
54      Sse.last(text)
55    case False{}:
56      text
57
58def took.ok(+ctype: String, text: String, ok: Bool, +status: U32) -> Rpc.Reply:
59  match ok:
60    case True{}:
61      Rpc.read(body(is.sse(ctype), text))
62    case False{}:
63      Rpc.Unread{"HTTP " ++ U32.show(status) ++ ": " ++ text}
64
65# what an HTTP reply to a request says: a 200 is read as JSON or as an event
66# stream by its Content-Type; any other status is unread
67def took(+status: U32, ctype: String, text: String) -> Rpc.Reply:
68  took.ok(ctype, text, U32.is_eq(status, 200), status)
69
70# whether an HTTP reply to a notification accepted it: any 2xx
71def accepted(+status: U32) -> Bool:  # noqa: L001 only src/io.bend calls it (MCP-TRUST-1)
72  Bool.and(U32.is_ge(status, 200), U32.is_lt(status, 300))
73
74# the revision an initialize result names, "" when it names none
75def version(result: Value.Json) -> String:
76  Maybe.default(&2, String, Json.as_str(Json.get(result, "protocolVersion")),
77    "")