src/web.bend

ezmcp@0.1.0.0 · 229 lines · 27 definitions

1# ezmcp/src/web: the streamable HTTP transport's decisions, with no IO
2# (basic/transports#streamable-http). One HTTP request in, a reply out, or a
3# tool call (or, under 2025-03-26, a batch) for src/io.bend to run before it
4# replies. Every POST is answered with one JSON body or 202; there is no SSE
5# stream and no session.
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 emerging-ezhttp@0.21.0.0/src/http.bend as Http
10import ezmcp@0.1.0.0/src/rpc.bend as Rpc
11import ezmcp@0.1.0.0/src/server.bend as Server
12
13# what the transport does with one HTTP request: reply now, run a tool
14# first, or run a batch's acts first; `rev` is the revision the request is
15# read by
16type Web is Data:
17  Now{status: U32, headers: List<&2, Http.Header>, body: String}
18  Later{rev: String, id: Value.Json, name: String, args: Value.Json}
19  Many{rev: String, acts: List<&2, Server.Act>}
20
21# the header a JSON body carries
22def json() -> List<&2, Http.Header>:
23  [Http.H{"Content-Type", "application/json"}]
24
25# a reply with a JSON body
26def json.reply(status: U32, val: Value.Json) -> Web:
27  Now{status, json(), Json.print(val)}
28
29# a server's act, under revision `rev`, as an HTTP reply: a request's reply
30# is 200 with its JSON, a notification or a response is 202 with no body, a
31# message the server cannot accept is 400 with its error
32def act(rev: String, lead: Server.Act) -> Web:
33  match lead:
34    case Server.Quiet{}:
35      Now{202, [], ""}
36    case Server.Send{reply}:
37      json.reply(200, reply)
38    case Server.Fault{reply}:
39      json.reply(400, reply)
40    case Server.Run{id, name, args}:
41      Later{rev, id, name, args}
42    case Server.Batch{acts}:
43      Many{rev, acts}
44
45# the reply to a tool call, once it ran
46def ran(rev: String, id: Value.Json, out: Rpc.Out) -> Web:
47  json.reply(200, Server.ran(rev, id, out))
48
49# the reply to a batch once its acts ran: 202 with no body when none had a
50# reply, else 200 with the array of the replies
51def many(replies: Maybe<&2, Value.Json>) -> Web:
52  match replies:
53    case None{}:
54      Now{202, [], ""}
55    case Some{batch}:
56      json.reply(200, batch)
57
58# a request-target's path: everything before `?`
59def path.of(target: String) -> String:
60  Http.first(String.split(target, '?'))
61
62# true at a hit, else the rest of the search
63def origin.or(hit: Bool, rest: Unit -> Bool) -> Bool:
64  match hit:
65    case True{}:
66      True{}
67    case False{}:
68      rest(Unit{})
69
70# whether the server lists an Origin
71def origin.listed(origins: List<&2, String>, +origin: String) -> Bool:
72  match origins:
73    case []:
74      False{}
75    case listed <> rest:
76      origin.or(String.eq(listed, origin), _u => origin.listed(rest, origin))
77
78# a port's digits after a colon
79def loopback.colon(tail: String, colon: Bool) -> Bool:
80  match colon:
81    case True{}:
82      Rpc.digits(tail)
83    case False{}:
84      False{}
85
86# after the host: nothing, or a colon and a port's digits
87def loopback.port(rest: String) -> Bool:
88  match rest:
89    case SNil{}:
90      True{}
91    case SCon{head, tail}:
92      loopback.colon(tail, Char.is_eq(head, ':'))
93
94# a host of n chars that is a loopback name, then a port
95def loopback.at(+after: String, num: Nat, hit: Bool) -> Bool:
96  match hit:
97    case True{}:
98      loopback.port(String.drop(after, num))
99    case False{}:
100      False{}
101
102# whether what follows the scheme is a loopback host and an optional port
103def loopback.host(+after: String) -> Bool:
104  origin.or(loopback.at(after, 9n, String.starts_with(after, "localhost")),
105    _u => origin.or(loopback.at(after, 9n,
106      String.starts_with(after, "127.0.0.1")),
107      _v => loopback.at(after, 5n, String.starts_with(after, "[::1]"))))
108
109# a scheme of n chars, then a loopback host
110def loopback.scheme(+origin: String, num: Nat, hit: Bool) -> Bool:
111  match hit:
112    case True{}:
113      loopback.host(String.drop(origin, num))
114    case False{}:
115      False{}
116
117# whether an Origin is a page on this machine: http or https, host
118# localhost, 127.0.0.1 or [::1], and an optional port
119def loopback(+origin: String) -> Bool:
120  origin.or(loopback.scheme(origin, 7n, String.starts_with(origin, "http://")),
121    _u => loopback.scheme(origin, 8n, String.starts_with(origin, "https://")))
122
123# whether an Origin is allowed: an absent one (a client that is not a
124# browser), a page on this machine, or one the server lists
125def origin.ok(origins: List<&2, String>, +origin: String) -> Bool:
126  origin.or(String.is_empty(origin),
127    _u => origin.or(loopback(origin), _v => origin.listed(origins, origin)))
128
129# whether an MCP-Protocol-Version header is acceptable: absent, or a
130# revision ezmcp speaks
131def version.ok(+val: String) -> Bool:
132  Bool.or(String.is_empty(val), Server.supported(val))
133
134# the revision a request is read by: its MCP-Protocol-Version, or 2025-03-26
135# when it has none (basic/transports#protocol-version-header)
136def revision(+val: String) -> String:
137  Bool.pick(String, String.is_empty(val), Server.oldest(), val)
138
139# a plain-text reply
140def text(status: U32, body: String) -> Web:
141  Now{status, [Http.H{"Content-Type", "text/plain"}], body}
142
143# a POST read by revision `rev`: the server's step on its body
144def post.as(srv: Server.Server, body: String, +rev: String) -> Web:
145  act(rev, Server.step(srv, rev, body))
146
147# a POST: refused for an unsupported revision, else the server's step under
148# the revision its header names
149def post.ok(srv: Server.Server, body: String, header: String, ok: Bool) -> Web:
150  match ok:
151    case False{}:
152      text(400, "Bad Request: unsupported MCP-Protocol-Version")
153    case True{}:
154      post.as(srv, body, revision(header))
155
156# a POST: the decision on its MCP-Protocol-Version header, empty when absent
157def post(srv: Server.Server, body: String, +header: String) -> Web:
158  post.ok(srv, body, header, version.ok(header))
159
160# any method but POST is 405
161def method(
162  srv: Server.Server,
163  body: String,
164  header: String,
165  is_post: Bool
166) -> Web:
167  match is_post:
168    case False{}:
169      Now{405, [Http.H{"Allow", "POST"}], ""}
170    case True{}:
171      post(srv, body, header)
172
173# a request whose Origin is not allowed is 403
174def origin(
175  srv: Server.Server,
176  body: String,
177  header: String,
178  is_post: Bool,
179  allowed: Bool
180) -> Web:
181  match allowed:
182    case False{}:
183      text(403, "Forbidden: Origin not allowed")
184    case True{}:
185      method(srv, body, header, is_post)
186
187# the decision over a request's parts: the path first, then the Origin, then
188# the method, then the protocol version (its MCP-Protocol-Version header,
189# empty when absent), then the body
190def route(
191  srv: Server.Server,
192  here: Bool,
193  allowed: Bool,
194  is_post: Bool,
195  header: String,
196  body: String
197) -> Web:
198  match here:
199    case False{}:
200      text(404, "Not Found")
201    case True{}:
202      origin(srv, body, header, is_post, allowed)
203
204# what the transport does with one HTTP request to an endpoint at `path`,
205# which allows the browser origins listed
206def decide(
207  srv: Server.Server,
208  path: String,
209  origins: List<&2, String>,
210  req: Http.Request
211) -> Web:
212  match req:
213    case Http.Bad{why}:
214      text(400, why)
215    case Http.Request{+m, +target, +headers, body}:
216      route(srv, String.eq(path.of(target), path),
217        origin.ok(origins, Http.headers.find(headers, "Origin")),
218        String.eq(m, "POST"),
219        Http.headers.find(headers, "MCP-Protocol-Version"), body)
220
221# a Web reply as ezhttp's reply (a Later or a Many never reaches here)
222def reply(wid: Web) -> Http.Reply:  # noqa: L001 only src/io.bend calls it (MCP-TRUST-1)
223  match wid:
224    case Now{status, headers, body}:
225      Http.Reply{status, headers, body}
226    case Later{rev, id, name, args}:
227      Http.Reply{500, [], "ezmcp: a tool call was not run"}
228    case Many{rev, acts}:
229      Http.Reply{500, [], "ezmcp: a batch was not run"}