Relay

Relay is a Gleam MCP server and client for Erlang. It implements the frozen MCP 2026-07-28 revision over stdio and Streamable HTTP.

Capabilities

When Relay fits

Use Relay when a Gleam application needs to expose typed operations to MCP peers, or call an existing MCP server while retaining native Gleam values and explicit failure information. Common setups are:

Install

Add Relay and JSON Blueprint from Hex; add Sinal or HTTP Gun directly when your application imports their modules or types.

gleam add relay json_blueprint@2

Use Gleam >= 1.18.0 with the Erlang target. Commit gleam.toml and manifest.toml so builds use the same resolved versions.

Server flow and ownership

On the server, the transport admits a request to a runtime actor. The actor serializes protocol state through a pure reducer and starts isolated workers for application handlers. HTTP can build request context and ask a verifier before dispatch; stdio trusts its parent process.

flowchart LR
  App[Application definitions and handlers] --> Catalog[Service catalog]
  Peer[MCP peer] --> Transport[HTTP or stdio transport]
  Catalog --> Runtime[Runtime actor]
  Transport --> Runtime
  Runtime <-->|events and effects| Reducer[Pure reducer]
  Runtime --> Worker[Isolated handler worker]
  Worker --> Runtime

Serve and call a typed tool

This runnable example defines one typed tool and reuses its definition on both sides of a local HTTP round trip.

import gleam/int
import gleam/io
import gleam/string
import json/blueprint/codec
import relay/client
import relay/http
import relay/server
import relay/tool

/// One `Definition` holds the input and output codecs used by both the server
/// handler and the client call.
pub fn greet() -> tool.Definition(String, String) {
  let input = {
    use name <- codec.field("name", codec.string(), get: fn(name) { name })
    codec.success(name)
  }
  tool.define("greet", input, codec.string())
  |> tool.with_description("Greets the user by name")
  |> tool.with_read_only_hint(True)
}

/// Binds an ephemeral loopback port, calls the tool and closes everything.
/// Application code handles each `Result` and closes resources it created when
/// a later operation fails.
pub fn serve_and_call() -> Nil {
  let service =
    server.new([tool.handle(greet(), fn(name) { Ok("Hello, " <> name <> "!") })])
  let assert Ok(mcp) = http.start(http.new(service))
  let url = "http://127.0.0.1:" <> int.to_string(http.port(mcp)) <> "/"

  let assert Ok(config) = client.http(url)
  let assert Ok(peer) = client.connect(config)
  let assert Ok(client.Succeeded("Hello, Ada!", _)) =
    client.call(peer, greet(), "Ada")
  client.close(peer)
  http.stop(mcp)
}

Mount in an application

Use http.handler when the host router should own HTTP listening. The complete example supplies the imports and Tenant type. It reads a tenant header as example input; derive trusted tenant authority through your application’s authentication and policy. This buffered interface cannot stream progress or observe client disconnects and returns 406 for subscriptions/listen; mount http.mist_handler when streaming and disconnect cancellation matter. Call http.stop when the mounted endpoint shuts down.

pub fn mount(service: server.Server(Tenant)) -> http.Handler(Tenant) {
  let assert Ok(mcp) =
    http.new_with_context(service, fn(req) {
      case request.get_header(req, "x-tenant") {
        Ok(id) -> Ok(Tenant(id))
        Error(Nil) ->
          Error(response.new(400) |> response.set_body(bytes_tree.new()))
      }
    })
    |> http.with_allowed_hosts(["mcp.example.com", "127.0.0.1"])
    |> http.handler
  mcp
}

For bearer-protected endpoints, see the protection recipe. Relay checks the verifier’s attested resource and scopes and formats challenges and metadata; the verifier owns token authenticity, issuer, and expiry checks.

Limits to plan for

Learn more

✨ Search Document