main.bend

ezmcp@0.1.0.0 · 376 lines · 43 definitions

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.
11import Base
12import emerging-ezjson@1.2.0.0/src/value.bend as Value
13import emerging-ezhttp@0.21.0.0/src/http.bend as Http
14import ezmcp@0.1.0.0/src/rpc.bend as Rpc
15import ezmcp@0.1.0.0/src/server.bend as Server
16import ezmcp@0.1.0.0/src/web.bend as Web
17import ezmcp@0.1.0.0/src/client.bend as Client
18import ezmcp@0.1.0.0/src/io.bend as Io
19import ezmcp@0.1.0.0/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)
25def 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
30def 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
39def latest() -> String:  # noqa: L001 re-export of Server.latest
40  Server.latest()
41
42# the protocol revisions ezmcp speaks, oldest first
43def 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
51def 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()`
57def 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)
62def 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
67def 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`)
74def 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)
80def 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
85def 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
89def refuse.internal(message: String) -> Rpc.Out:
90  Rpc.Err{Rpc.InternalError{}, message}
91
92# --- tool results ---
93
94# a text content block
95def 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)
100def 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
104def 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
108def 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
113def 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
120def 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
130def 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
142def 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`
173def 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
187def 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`
225def 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
239def 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
300def 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
304def openapi.tool(op: Openapi.Op) -> Server.Tool:
305  Openapi.tool(op)
306
307# an operation's tool name: its operationId as a tool name
308def openapi.name(op: Openapi.Op) -> String:
309  Openapi.name(op)
310
311# an operation under another tool name, for a client that limits names
312def 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
317def 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
324def 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
330def 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
338def client.initialized() -> Value.Json:
339  Client.initialized()
340
341# a tools/list request
342def client.tools(id: Value.Json) -> Value.Json:
343  Client.tools(id)
344
345# a tools/call request
346def 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}
351def client.read(text: String) -> Rpc.Reply:
352  Rpc.read(text)
353
354# the revision an initialize result names, "" when it names none
355def 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
361def 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)
370def 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)