1 # ezmcp: Model Context Protocol servers and clients for Bend 2, revisions 2 # 2025-03-26, 2025-06-18 and 2025-11-25 3 # 4 # A server is a name, a version, instructions and a tool table; a runner 5 # (String -> Json -> IO(Rpc.Out)) runs a tool by name on its arguments and 6 # answers `ok` of a tool result, or `refuse` with a JSON-RPC error. ezmcp serves it over streamable HTTP (on ezhttp) 7 # or stdio, and answers initialize, ping, tools/list and tools/call itself. 8 # The client side builds requests and reads replies over HTTP. This file is 9 # the whole public interface; everything under src/ is internal, except the 10 # types src/server.bend, src/rpc.bend and src/web.bend name. 11 import Base 12 import 0xa81e161008ecc2f297c0a3065cb9b188/src/value.bend as Value 13 import 0xedb2120a04a47dc18c3a756ec7c62750/src/http.bend as Http 14 import ./src/rpc.bend as Rpc 15 import ./src/server.bend as Server 16 import ./src/web.bend as Web 17 import ./src/client.bend as Client 18 import ./src/io.bend as Io 19 import ./src/openapi.bend as Openapi 20 21 # --- servers --- 22 23 # a tool: its name, a description for the model, and the JSON Schema of its 24 # arguments (an object schema) 25 def tool(name: String, description: String, input: Value.Json) -> Server.Tool: # noqa: L001 the Server.Tool constructor 26 Server.Tool{name, description, input} 27 28 # a server: name and version (serverInfo), instructions for the client ("" for 29 # none), and its tools in the order tools/list shows them 30 def server( # noqa: L001 the Server.Server constructor 31 name: String, 32 version: String, 33 instructions: String, 34 tools: List<&2, Server.Tool> 35 ) -> Server.Server: 36 Server.Server{name, version, instructions, tools} 37 38 # the latest protocol revision ezmcp speaks 39 def latest() -> String: # noqa: L001 re-export of Server.latest 40 Server.latest() 41 42 # the protocol revisions ezmcp speaks, oldest first 43 def revisions() -> List<&2, String>: # noqa: L001 re-export of Server.revisions 44 Server.revisions() 45 46 # what a server does with one message's text, read by revision `rev`: Quiet 47 # (send nothing), Send or Fault (send this reply; a Fault is a message it 48 # could not accept), Run (run a tool, then send `ran` of its result), or, 49 # under 2025-03-26, Batch (do each of its acts, then send `batch.reply` of 50 # their replies). For a transport of your own 51 def step(srv: Server.Server, rev: String, text: String) -> Server.Act: # noqa: L001 re-export of Server.step 52 Server.step(srv, rev, text) 53 54 # `step`'s act, and the revision a stream's next message is read by: the one 55 # an initialize was answered with, else `rev`. For a stream transport of your 56 # own, which starts at `latest()` 57 def turn(srv: Server.Server, rev: String, text: String) -> Server.Turn: # noqa: L001 re-export of Server.turn 58 Server.turn(srv, rev, text) 59 60 # the reply to a tool call with this id under revision `rev`, once the 61 # runner answered (2025-03-26 leaves structuredContent out) 62 def ran(rev: String, id: Value.Json, out: Rpc.Out) -> Value.Json: # noqa: L001 re-export of Server.ran 63 Server.ran(rev, id, out) 64 65 # what a batch sends back, from its acts' replies in order (None for an act 66 # with none): nothing when none has one, else their array 67 def batch.reply(replies: List<&2, Maybe<&2, Value.Json>>) -> Maybe<&2, Value.Json>: 68 Server.batch.reply(replies) 69 70 # --- what a runner answers --- 71 72 # a runner's answer for a tool that ran: its tool result, which may itself 73 # report a failure with `isError` (`result.error`) 74 def ok(result: Value.Json) -> Rpc.Out: # noqa: L001 the Rpc.Ok constructor 75 Rpc.Ok{result} 76 77 # a runner's answer for a call it refuses as a protocol error: a JSON-RPC 78 # error with this code and message instead of a tool result 79 # (server/tools#error-handling) 80 def refuse(code: Rpc.Code, message: String) -> Rpc.Out: # noqa: L001 the Rpc.Err constructor 81 Rpc.Err{code, message} 82 83 # refuse with -32602 Invalid params, as for arguments that do not match the 84 # tool's input schema 85 def refuse.params(message: String) -> Rpc.Out: 86 Rpc.Err{Rpc.InvalidParams{}, message} 87 88 # refuse with -32603 Internal error, as for a server that cannot run the tool 89 def refuse.internal(message: String) -> Rpc.Out: 90 Rpc.Err{Rpc.InternalError{}, message} 91 92 # --- tool results --- 93 94 # a text content block 95 def text(text2: String) -> Value.Json: # noqa: L001 re-export of Server.text 96 Server.text(text2) 97 98 # a tool result from content blocks; is_error flags a tool that failed, so 99 # the model sees the failure (server/tools#error-handling) 100 def result(blocks: List<&2, Value.Json>, is_error: Bool) -> Value.Json: # noqa: L001 re-export of Server.result 101 Server.result(blocks, is_error) 102 103 # a tool result of one text block 104 def result.text(text: String) -> Value.Json: 105 Server.result([Server.text(text)], False{}) 106 107 # a failed tool's result: one text block, isError true 108 def result.error(text: String) -> Value.Json: 109 Server.result([Server.text(text)], True{}) 110 111 # a tool result with structured content, and its JSON text as a text block; 112 # a 2025-03-26 client gets the text block alone 113 def result.json(val: Value.Json) -> Value.Json: 114 Server.structured(val, False{}) 115 116 # --- streamable HTTP --- 117 118 # what the HTTP transport decides for one request to an endpoint at `path` 119 # that allows the browser origins listed: a reply now, or a tool to run 120 def http.decide( 121 srv: Server.Server, 122 path: String, 123 origins: List<&2, String>, 124 req: Http.Request 125 ) -> Web.Web: 126 Web.decide(srv, path, origins, req) 127 128 # one request to the MCP endpoint at `path`, as an ezhttp IO handler, for a 129 # server that serves other paths too 130 def http.handle( 131 ~run: String -> Value.Json -> IO(Rpc.Out), 132 ~srv: Server.Server, 133 ~path: String, 134 ~origins: List<&2, String>, 135 req: Http.Request 136 ) -> IO(Http.Reply): 137 Io.http.handle(run, srv, path, origins, req) 138 139 # serve the endpoint on host:port for `limit` connections, each on its own 140 # fork with no limit on how many at once, then stop once every connection is 141 # closed 142 def http.serve( 143 ~run: String -> Value.Json -> IO(Rpc.Out), 144 ~srv: Server.Server, 145 ~path: String, 146 ~origins: List<&2, String>, 147 host: String, 148 port: U32, 149 limit: U32 150 ) -> IO(Unit): 151 Io.http.serve(run, srv, path, origins, host, port, limit) 152 153 # serve the endpoint on host:port until the process is stopped, each 154 # connection on its own fork with no limit on how many at once 155 @unsafe def http.serve_forever( 156 ~run: String -> Value.Json -> IO(Rpc.Out), 157 ~srv: Server.Server, 158 ~path: String, 159 ~origins: List<&2, String>, 160 host: String, 161 port: U32 162 ) -> IO(Unit): 163 Io.http.serve_forever(run, srv, path, origins, host, port) 164 165 # --- with a context --- 166 # 167 # A runner passed as `~run` is a closed template, so it cannot capture a 168 # value read at run time (arguments, environment, a base URL). These take a 169 # context `ctx` of any Data type `C` and hand it to the runner on every 170 # call: `run(ctx, name, args)`. 171 172 # one request to the MCP endpoint at `path`, its runner given `ctx` 173 def http.handle_with( 174 ~C: Data, 175 ~run: C -> String -> Value.Json -> IO(Rpc.Out), 176 ~srv: Server.Server, 177 ~path: String, 178 ~origins: List<&2, String>, 179 +ctx: C, 180 req: Http.Request 181 ) -> IO(Http.Reply): 182 Io.http.handle_with(C, run, srv, path, origins, ctx, req) 183 184 # serve the endpoint on host:port for `limit` connections, its runner given 185 # `ctx`, each connection on its own fork and at most `max` at once (0: no 186 # limit; 1: one at a time), then stop once every connection is closed 187 def http.serve_with( 188 ~C: Data, 189 ~run: C -> String -> Value.Json -> IO(Rpc.Out), 190 ~srv: Server.Server, 191 ~path: String, 192 ~origins: List<&2, String>, 193 +ctx: C, 194 host: String, 195 port: U32, 196 limit: U32, 197 max: U32 198 ) -> IO(Unit): 199 Io.http.serve_with(C, run, srv, path, origins, ctx, host, port, limit, max) 200 201 # serve the endpoint on host:port, its runner given `ctx`, each connection on 202 # its own fork and at most `max` at once (0: no limit; 1: one at a time), 203 # until the process is stopped 204 @unsafe def http.serve_forever_with( 205 ~C: Data, 206 ~run: C -> String -> Value.Json -> IO(Rpc.Out), 207 ~srv: Server.Server, 208 ~path: String, 209 ~origins: List<&2, String>, 210 +ctx: C, 211 host: String, 212 port: U32, 213 max: U32 214 ) -> IO(Unit): 215 Io.http.serve_forever_with(C, run, srv, path, origins, ctx, host, port, max) 216 217 # --- with the server in the context --- 218 # 219 # A server built at run time (from an OpenAPI document read at startup, 220 # say) cannot be a `~srv` either. These read it from the context, with 221 # `srv(ctx)`, on every request. 222 223 # one request to the MCP endpoint at `path`, its server and runner given 224 # `ctx` 225 def http.handle_from( 226 ~C: Data, 227 ~srv: C -> Server.Server, 228 ~run: C -> String -> Value.Json -> IO(Rpc.Out), 229 ~path: String, 230 ~origins: List<&2, String>, 231 +ctx: C, 232 req: Http.Request 233 ) -> IO(Http.Reply): 234 Io.http.web(C, run, ctx, Web.decide(srv(ctx), path, origins, req)) 235 236 # serve the endpoint on host:port for `limit` connections, its server and 237 # runner given `ctx`, each connection on its own fork and at most `max` at 238 # once (0: no limit), then stop once every connection is closed 239 def http.serve_from( 240 ~C: Data, 241 ~srv: C -> Server.Server, 242 ~run: C -> String -> Value.Json -> IO(Rpc.Out), 243 ~path: String, 244 ~origins: List<&2, String>, 245 +ctx: C, 246 host: String, 247 port: U32, 248 limit: U32, 249 max: U32 250 ) -> IO(Unit): 251 Io.http.serve_from(C, srv, run, path, origins, ctx, host, port, limit, max) 252 253 # serve the endpoint on host:port, its server and runner given `ctx`, each 254 # connection on its own fork and at most `max` at once (0: no limit), until 255 # the process is stopped 256 @unsafe def http.serve_forever_from( 257 ~C: Data, 258 ~srv: C -> Server.Server, 259 ~run: C -> String -> Value.Json -> IO(Rpc.Out), 260 ~path: String, 261 ~origins: List<&2, String>, 262 +ctx: C, 263 host: String, 264 port: U32, 265 max: U32 266 ) -> IO(Unit): 267 Io.http.serve_forever_from(C, srv, run, path, origins, ctx, host, port, max) 268 269 # serve stdin and stdout, its runner given `ctx`, until stdin ends 270 @unsafe def stdio.serve_with( 271 ~C: Data, 272 ~run: C -> String -> Value.Json -> IO(Rpc.Out), 273 ~srv: Server.Server, 274 +ctx: C 275 ) -> IO(Unit): 276 Io.stdio.serve_with(C, run, srv, ctx) 277 278 # --- stdio --- 279 280 # serve newline-delimited JSON-RPC on stdin and stdout until stdin ends; 281 # write logs with IO.print_err, never to stdout 282 @unsafe def stdio.serve( 283 ~run: String -> Value.Json -> IO(Rpc.Out), 284 ~srv: Server.Server 285 ) -> IO(Unit): 286 Io.stdio.serve(run, srv) 287 288 # --- OpenAPI --- 289 # 290 # An OpenAPI 3.0 or 3.1 document as a tool table, one tool for each 291 # operation, and a tool call as the HTTP request it stands for. Build the 292 # server with `server(...)` of each op's `openapi.tool` once the document 293 # is read, and serve it with `http.serve_forever_from`; in the runner, 294 # `openapi.check` the arguments (refuse.params when they do not fit), then 295 # send `openapi.call`'s request with ezhttp. 296 297 # every operation of an OpenAPI document (a JSON value), in the order of 298 # its paths, each path's in the order get, put, post, delete, options, 299 # head, patch, trace; its local references (`#/...`) followed 300 def openapi.ops(doc: Value.Json) -> List<&2, Openapi.Op>: 301 Openapi.ops(doc) 302 303 # an operation's tool: its name, description and input schema 304 def openapi.tool(op: Openapi.Op) -> Server.Tool: 305 Openapi.tool(op) 306 307 # an operation's tool name: its operationId as a tool name 308 def openapi.name(op: Openapi.Op) -> String: 309 Openapi.name(op) 310 311 # an operation under another tool name, for a client that limits names 312 def openapi.renamed(op: Openapi.Op, name: String) -> Openapi.Op: 313 Openapi.renamed(op, name) 314 315 # what is wrong with a call's arguments for an operation's input schema, 316 # when anything is: Some of the message, or None when they fit 317 def openapi.check(op: Openapi.Op, args: Value.Json) -> Maybe<&2, String>: 318 Openapi.check(op, args) 319 320 # the request a call of an operation with these arguments stands for: 321 # Send{method, target, headers, body} (the target is the path and query, 322 # for the caller to append to the API's base URL), or Unfilled{path} when a 323 # path parameter has no argument 324 def openapi.call(op: Openapi.Op, args: Value.Json) -> Openapi.Call: 325 Openapi.call(op, args) 326 327 # --- client --- 328 329 # the initialize request, naming the client 330 def client.initialize( 331 id: Value.Json, 332 name: String, 333 version: String 334 ) -> Value.Json: 335 Client.initialize(id, name, version) 336 337 # the notification to send once initialize succeeded 338 def client.initialized() -> Value.Json: 339 Client.initialized() 340 341 # a tools/list request 342 def client.tools(id: Value.Json) -> Value.Json: 343 Client.tools(id) 344 345 # a tools/call request 346 def client.call(id: Value.Json, name: String, args: Value.Json) -> Value.Json: 347 Client.call(id, name, args) 348 349 # what a reply's JSON text says: Got{id, result}, Refused{id, code, 350 # message} or Unread{why} 351 def client.read(text: String) -> Rpc.Reply: 352 Rpc.read(text) 353 354 # the revision an initialize result names, "" when it names none 355 def client.version(result: Value.Json) -> String: 356 Client.version(result) 357 358 # POST a request to an MCP endpoint and read its reply, JSON or an event 359 # stream. `version` is the negotiated revision ("" before initialize); the 360 # timeout bounds connecting, sending and receiving 361 def client.ask( 362 url: String, 363 version: String, 364 timeout_ms: U32, 365 msg: Value.Json 366 ) -> IO(Rpc.Reply): 367 Io.client.ask(url, version, timeout_ms, msg) 368 369 # POST a notification; whether the server accepted it (any 2xx) 370 def client.tell( 371 url: String, 372 version: String, 373 timeout_ms: U32, 374 msg: Value.Json 375 ) -> IO(Bool): 376 Io.client.tell(url, version, timeout_ms, msg)