relay/http

The Streamable HTTP endpoint of an MCP server: a handler you mount in your own router, or a listener Relay runs on mist.

Describe the endpoint with new(server), new_with_context(server, context) or new_protected(server, verifier, protection, context), and adjust it with the with_* setters. Then either:

The context builder sees the request, so a context can carry a tenant or a principal; an Error(response) answers the request before Relay dispatches protocol work. The host reads the bounded body before slot admission. new_protected reads the bearer token, admits it with relay/authorization, answers a refusal with its RFC 6750 challenge, and serves the RFC 9728 metadata document at authorization.metadata_path(protection).

import gleam/int
import relay/http
import relay/server

pub fn serve(service: server.Server(Nil)) -> String {
  let assert Ok(mcp) = http.start(http.new(service))
  "http://127.0.0.1:" <> int.to_string(http.port(mcp)) <> "/"
}
SettingDefaultSetter
bind127.0.0.1, ephemeral portwith_bind
non-loopback bind without protectionrefused by startallow_unauthenticated
Host and Origin allow-listsloopback and the bind hostwith_allowed_hosts, with_allowed_origins
request body1 MiBwith_max_body_bytes
response, or one stream’s events1 MiBwith_max_response_bytes
admitted-request deadline30 swith_request_timeout
cancellation grace5 swith_cancellation_grace
SSE keepalive15 swith_sse_keepalive
concurrent requests1,024, then 503with_max_concurrent_requests
concurrent subscriptions/listen streams64, then 503with_max_listen_streams
JSON nesting depth64with_max_json_depth
request correlationthe client’s x-correlation-id, else freshwith_correlation

Every admitted request has one sinal/correlation: the with_correlation builder’s value, else the client’s x-correlation-id header (1 to 128 visible ASCII characters; Relay’s client sends its relay/client.with_correlation there), else a fresh one. The request’s telemetry, its verifier and its handler (relay/tool.correlation) carry the same value. It is client input and never authorizes anything.

MCP 2026-07-28 cancels a request when its client closes the connection: the endpoint then cancels the invocation and writes nothing. The handler’s relay/tool.cancelled selector fires, and it has the cancellation grace to stop work it started elsewhere and return before Relay kills it; the endpoint does not hold the response for it. A buffered handle cannot see the socket, so its requests end at the request timeout instead, and it answers subscriptions/listen with 406; use mist_handler or start for streaming.

The deadline starts at slot admission, before correlation, verification, and context callbacks. Those callbacks run in a managed request worker, which remains alive through finite output or an established subscription. Expiry returns 504 before dispatch; late admission cannot start a handler. Callback failure returns 500 without terminating the buffered caller. Cleanup has its own cancellation grace and retains capacity until owned processes stop. Borrowed application actors remain application-owned; detached children, exit-trapping children, and remote effects are not automatically adopted or undone. Callback-created Subjects belong to the request worker, not to the process calling buffered handle.

Types

An endpoint description. Build it with new, new_with_context or new_protected; handler, start and supervised validate it.

pub opaque type Config(context)

The setting that validate refused.

pub type ConfigField {
  BindHost
  BindPort
  TlsFiles
  MaxBodyBytes
  MaxResponseBytes
  RequestTimeout
  CancellationGrace
  SseKeepalive
  AllowedHosts
  MaxConcurrentRequests
  MaxListenStreams
  MaxJsonDepth
}

Constructors

  • BindHost
  • BindPort
  • TlsFiles
  • MaxBodyBytes
  • MaxResponseBytes
  • RequestTimeout
  • CancellationGrace
  • SseKeepalive
  • AllowedHosts
  • MaxConcurrentRequests
  • MaxListenStreams
  • MaxJsonDepth

A running endpoint: a mounted handler, or a listener from start or supervised.

pub opaque type Handler(context)

The messages of an endpoint actor; used to name a supervised one.

pub opaque type Message(context)

Why an endpoint did not start.

pub type StartError {
  InvalidConfig(field: ConfigField)
  UnauthenticatedNonLoopbackBind(host: String)
  HandlerFailed(actor.StartError)
  BindFailed(host: String, port: Int)
}

Constructors

  • InvalidConfig(field: ConfigField)

    A setting is out of range.

  • UnauthenticatedNonLoopbackBind(host: String)

    start refuses a non-loopback host without new_protected or allow_unauthenticated.

  • HandlerFailed(actor.StartError)

    The endpoint actor did not start.

  • BindFailed(host: String, port: Int)

    mist could not bind the interface and port.

Values

pub fn allow_unauthenticated(
  config: Config(context),
) -> Config(context)

Lets start bind a non-loopback host without new_protected. Every peer that can reach the address can then call every tool, read every resource and run every prompt. This is unsafe outside a trusted network or without a proxy that authenticates each request.

pub fn describe_start_error(error: StartError) -> String

A one-line description of a start error.

pub fn handle(
  handler: Handler(context),
  request: request.Request(BitArray),
) -> response.Response(bytes_tree.BytesTree)

Answers one request with a buffered response, for mounting in any gleam_http server. The body must already be read; a larger body than the limit gets 413. Progress notifications are dropped, a disconnect is not seen, and subscriptions/listen gets 406; use mist_handler for streaming.

pub fn handler(
  config: Config(context),
) -> Result(Handler(context), StartError)

Starts a mountable endpoint, linked to the caller. Mount it with handle or mist_handler; it binds no port.

pub fn mist_handler(
  handler: Handler(context),
) -> fn(request.Request(mist.Connection)) -> response.Response(
  mist.ResponseData,
)

The full streaming endpoint as a mist handler: server-sent events for progress and subscriptions/listen, keepalives, and cancellation when the client disconnects.

pub fn named(
  name: process.Name(Message(context)),
) -> Handler(context)

The endpoint registered under name by supervised.

pub fn new(server: server.Server(Nil)) -> Config(Nil)

An endpoint for a server whose handlers need no context.

pub fn new_protected(
  server: server.Server(context),
  verifier: authorization.Verifier(principal),
  protection: authorization.Protection,
  context: fn(
    request.Request(BitArray),
    authorization.Grant(principal),
  ) -> Result(context, response.Response(bytes_tree.BytesTree)),
) -> Config(context)

An endpoint that admits only requests whose bearer token verifier accepts for protection, and builds each request’s context from the request and the Grant. A refusal answers with the challenge from relay/authorization.challenge; a GET of the RFC 9728 metadata path returns authorization.resource_metadata(protection).

pub fn new_with_context(
  server: server.Server(context),
  context: fn(request.Request(BitArray)) -> Result(
    context,
    response.Response(bytes_tree.BytesTree),
  ),
) -> Config(context)

An endpoint that builds each request’s context from the request. An Error(response) answers the request as is, before protocol dispatch. The host has already read the bounded body. The callback runs in a Relay-owned worker, retained through the exchange or established stream.

pub fn notify(
  handler: Handler(context),
  notification: subscriptions.Notification,
) -> Nil

Tells every open subscriptions/listen stream that asked for it.

pub fn port(handler: Handler(context)) -> Int

The port a listener is bound to. Panics for a handler from handler, which binds none.

pub fn register_tool(
  handler: Handler(context),
  tool: tool.Tool(context),
) -> Result(Nil, server.RegisterError)

Registers a tool for later requests and tells listening streams the tool list changed.

pub fn start(
  config: Config(context),
) -> Result(Handler(context), StartError)

Starts the endpoint and a mist listener on the configured bind, linked to the caller. Refuses a non-loopback bind without protection.

pub fn stop(handler: Handler(context)) -> Nil

Stops the endpoint: closes its streams, stops its listener and its actor without terminating its linked caller. Initiates request cleanup, which may continue after this call returns. Waits at most 5 s. Unexpected listener failure still reaches the caller through the link.

pub fn supervised(
  config: Config(context),
  name: process.Name(Message(context)),
) -> supervision.ChildSpecification(Handler(context))

A child specification for a listener registered under name. The handle from named(name) stays valid across restarts; port reads the current port.

pub fn unregister_tool(
  handler: Handler(context),
  name: String,
) -> Bool

Removes a tool; returns whether it existed. Listening streams hear of a change.

pub fn validate(
  config: Config(context),
) -> Result(Nil, StartError)

Checks a description as start would, binding nothing.

pub fn with_allowed_hosts(
  config: Config(context),
  hosts: List(String),
) -> Config(context)

The Host header values the endpoint accepts, with or without a port. The default is the bind host and the loopback names.

pub fn with_allowed_origins(
  config: Config(context),
  origins: List(String),
) -> Config(context)

The Origin header values the endpoint accepts, such as "https://app.example.com". A request without Origin is accepted.

pub fn with_bind(
  config: Config(context),
  host: String,
  port: Int,
) -> Config(context)

The interface and port start binds. Port 0 picks a free port; read it with port. A non-loopback host also needs a wider Host allow-list.

pub fn with_cancellation_grace(
  config: Config(context),
  grace: duration.Duration,
) -> Config(context)

How long a cancelled handler may keep running after its relay/tool.cancelled selector fires (the client disconnected, or the handler timed out, or the endpoint stopped), before Relay kills it. Zero kills it at once. This also bounds cleanup of request callbacks and runtime resources. Active and retiring work retain capacity until termination.

pub fn with_correlation(
  config: Config(context),
  correlation: fn(request.Request(BitArray)) -> option.Option(
    correlation.Correlation,
  ),
) -> Config(context)

Builds each request’s sinal/correlation, for example from a request-id header. When it returns None, which the default always does, the request uses the correlation the client sent in the x-correlation-id header (as Relay’s client does for relay/client.with_correlation), else a fresh correlation.unique(). A sent value counts only when it is 1 to 128 visible ASCII characters. The request’s telemetry, its verifier (relay/authorization.verifier) and its handler (relay/tool.correlation) all carry the one value. It is client input: Relay never uses it to authorize, and neither should the builder.

pub fn with_label(
  config: Config(context),
  label: String,
) -> Config(context)

The listener label in this endpoint’s telemetry.

pub fn with_max_body_bytes(
  config: Config(context),
  bytes: Int,
) -> Config(context)

The largest request body; a larger one gets 413.

pub fn with_max_concurrent_requests(
  config: Config(context),
  count: Int,
) -> Config(context)

How many requests, streams included, may be in flight; beyond it the endpoint answers 503.

pub fn with_max_json_depth(
  config: Config(context),
  depth: Int,
) -> Config(context)

The deepest object and array nesting a request body may have.

pub fn with_max_listen_streams(
  config: Config(context),
  count: Int,
) -> Config(context)

How many subscriptions/listen streams may be open; beyond it the endpoint answers 503.

pub fn with_max_response_bytes(
  config: Config(context),
  bytes: Int,
) -> Config(context)

The largest buffered response, and the most event bytes one stream may carry before it closes.

pub fn with_request_timeout(
  config: Config(context),
  timeout: duration.Duration,
) -> Config(context)

One deadline from slot admission through correlation, verification, native context, finite execution and response writes. Admission consumes the same budget as execution. Body reading precedes admission. An acknowledged subscription retains its stream lifetime and bounded per-write waits.

pub fn with_sse_keepalive(
  config: Config(context),
  interval: duration.Duration,
) -> Config(context)

How often an idle event stream writes a keepalive comment; a failed write detects a vanished client.

pub fn with_tls(
  config: Config(context),
  certfile: String,
  keyfile: String,
) -> Config(context)

Serves HTTPS with these certificate and key files.

✨ Search Document