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. 6 import Base 7 import 0xa81e161008ecc2f297c0a3065cb9b188/main.bend as Json 8 import 0xa81e161008ecc2f297c0a3065cb9b188/src/value.bend as Value 9 import 0xedb2120a04a47dc18c3a756ec7c62750/src/http.bend as Http 10 import ./rpc.bend as Rpc 11 import ./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 16 type 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 22 def json() -> List<&2, Http.Header>: 23 [Http.H{"Content-Type", "application/json"}] 24 25 # a reply with a JSON body 26 def 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 32 def 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 46 def 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 51 def 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 `?` 59 def path.of(target: String) -> String: 60 Http.first(String.split(target, '?')) 61 62 # true at a hit, else the rest of the search 63 def 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 71 def 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 79 def 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 87 def 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 95 def 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 103 def 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 110 def 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 119 def 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 125 def 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 131 def 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) 136 def revision(+val: String) -> String: 137 Bool.pick(String, String.is_empty(val), Server.oldest(), val) 138 139 # a plain-text reply 140 def 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 144 def 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 149 def 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 157 def 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 161 def 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 174 def 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 190 def 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 206 def 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) 222 def 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"}