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:
- call
start(orsupervised) to bind a mist listener, by default on127.0.0.1with an ephemeral port; or - call
handlerand mount the returnedHandlerin an application:handle(handler, request)takes aRequest(BitArray)and returns a bufferedResponse(BytesTree)(one line in wisp:http.handle(mcp, request.set_body(req, body)) |> response.map(wisp.Bytes)), andmist_handler(handler)mounts the full streaming endpoint in a mist application.
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)) <> "/"
}
| Setting | Default | Setter |
|---|---|---|
| bind | 127.0.0.1, ephemeral port | with_bind |
| non-loopback bind without protection | refused by start | allow_unauthenticated |
| Host and Origin allow-lists | loopback and the bind host | with_allowed_hosts, with_allowed_origins |
| request body | 1 MiB | with_max_body_bytes |
| response, or one stream’s events | 1 MiB | with_max_response_bytes |
| admitted-request deadline | 30 s | with_request_timeout |
| cancellation grace | 5 s | with_cancellation_grace |
| SSE keepalive | 15 s | with_sse_keepalive |
| concurrent requests | 1,024, then 503 | with_max_concurrent_requests |
concurrent subscriptions/listen streams | 64, then 503 | with_max_listen_streams |
| JSON nesting depth | 64 | with_max_json_depth |
| request correlation | the client’s x-correlation-id, else fresh | with_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)startrefuses a non-loopback host withoutnew_protectedorallow_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.