relay/client
An MCP client for the 2026-07-28 revision, over Streamable HTTP, a
local stdio child process, or a server in the same VM.
Build a Config with http(url), stdio(executable, arguments) or
in_process(server, context), adjust it with the with_* setters, and
connect. Then:
call(client, definition, input)calls a tool with the samerelay/tool.Definitionthe server registered, and decodes its output;list_toolsandcall_discoveredserve tools known only at runtime;read_resource,get_prompt,complete,discover, the listings,listenand the rawcall_rawandlist_rawcover the rest.
Every operation returns Result(_, Error). Error is opaque: branch on
kind(error) or evidence(error), match the HTTP status, the JSON-RPC
error or the transport failure with its submission Evidence in
reason(error), and log describe_error(error) with
error_correlation(error), the correlation the call was sent with,
which finds the server’s events for it. A tool’s own failure is not an Error: it is the ToolFailed
result.
Per-call controls are views on the client: with_deadline,
with_cancellation, with_correlation and with_idempotency_key return a
new handle over the same connection, and every operation through it
honours them. In MCP 2026-07-28 closing the connection cancels a
request, so a deadline or a cancellation that ends an HTTP call closes
that call’s connection, and the server stops its handler; the next call
reconnects.
A view’s correlation travels to the server: in the request’s _meta
under io.github.gleam-dream/correlation on every transport, and over
HTTP also in the x-correlation-id header. A Relay server tags that
request’s events with it and hands it to the handler and the verifier.
A view without a correlation mints a fresh one per request and sends
that, so the client’s [relay, client, call] event and the server’s
events always share one, and so does a failed call’s error_correlation.
A listing, or a call and its resume, is one call: its requests share the
correlation. Only a correlation of visible ASCII characters
(! to ~) is sent; the server mints its own for any other. The server treats the value as
untrusted telemetry and never uses it to authorize anything.
import gleam/result
import relay/client
import relay/tool
pub fn greet(
greet: tool.Definition(String, String),
) -> Result(String, client.Error) {
use config <- result.try(client.http("http://127.0.0.1:3000/"))
use peer <- result.try(client.connect(config))
let outcome = client.call(peer, greet, "Ada")
client.close(peer)
case outcome {
Ok(client.Succeeded(text, _)) -> Ok(text)
Ok(_) -> Ok("the tool did not answer")
Error(error) -> Error(error)
}
}
| Setting | Default | Setter |
|---|---|---|
| request timeout | 30 s | with_timeout, per call with_deadline |
| connect | 10 s | with_connect_timeout |
| response size | 1 MiB | with_max_response_bytes |
| listings | 256 pages, 10,000 items | with_listing_limits |
| stdio pending calls | 64, then TooManyPendingCalls | with_max_pending_calls |
| input methods advertised | none | with_input_methods |
| headers over plain HTTP to a non-loopback host | refused | allow_plaintext_headers |
| retries | none: decide with evidence |
Types
A connected client. Copies share the connection; with_deadline,
with_cancellation and with_correlation return views of it.
pub opaque type Client
How to reach a server. Build it with http, stdio or in_process;
connect validates it.
pub opaque type Config
The setting connect refused.
pub type ConfigField {
Url
RequestTimeout
ConnectTimeout
MaxResponseBytes
ListingLimits
CaCertFile
MaxPendingCalls
Headers
}
Constructors
-
Url -
RequestTimeout -
ConnectTimeout -
MaxResponseBytes -
ListingLimits -
CaCertFile -
MaxPendingCalls -
Headers
A paused call: its client, arguments and output decoder. The client must stay open until the call ends.
pub opaque type Continuation(output)
What server/discover returned. capabilities is the server’s
capabilities object; read it with has_capability or by pattern. Read
fields by label.
pub type Discovery {
Discovery(
server_info: option.Option(ServerInfo),
supported_versions: List(String),
capabilities: value.Value,
instructions: option.Option(String),
)
}
Constructors
-
Discovery( server_info: option.Option(ServerInfo), supported_versions: List(String), capabilities: value.Value, instructions: option.Option(String), )
Why an operation failed, with the call it failed in. It is opaque so
that context can grow without breaking a match: read the failure with
reason, the classification with kind and evidence, and the
correlation the call was sent with, which finds the server’s events for
it, with error_correlation. An error from http, connect or a bad
setting precedes any call and carries a fresh correlation that no server
saw.
pub opaque type Error
Whether the server may have received a request.
pub type Evidence {
NotSent
MaybeSent
Completed
}
Constructors
-
NotSentNothing reached the server; sending again cannot duplicate an effect.
-
MaybeSentThe request may have reached the server, which may have acted on it.
-
CompletedThe server received the request and answered it.
The closed classification of an Error. It never gains variants.
pub type Kind {
Configuration
Unreachable
Timeout
Cancellation
Overloaded
Rejected
Protocol
InvalidInput
TooLarge
}
Constructors
-
ConfigurationFix the configuration.
-
UnreachableThe server could not be reached, or the connection broke.
-
Timeout -
Cancellation -
OverloadedToo many calls were waiting.
-
RejectedThe server refused the request with an HTTP status.
-
ProtocolThe server answered with a JSON-RPC error or broke the protocol.
-
InvalidInputFix the arguments or the input responses.
-
TooLargeA response or listing exceeded its bound.
What went wrong. It may gain variants in a minor release: branch on
kind and evidence, and match variants of reason(error) with a _
arm.
pub type Reason {
InvalidConfig(field: ConfigField)
ConnectFailed
Refused
TimedOut(evidence: Evidence)
Cancelled(evidence: Evidence)
ConnectionClosed(evidence: Evidence)
ResponseTooLarge(limit: Int)
TooManyPendingCalls(limit: Int)
HttpStatus(
status: Int,
www_authenticate: option.Option(String),
)
RpcError(
code: Int,
message: String,
data: option.Option(value.Value),
)
MalformedResponse(detail: String)
UnsupportedVersion(supported: List(String))
UnsupportedInputRequest(method: String)
InvalidArguments(detail: String)
InvalidInputResponses
ListingLimitExceeded(limit: Int)
}
Constructors
-
InvalidConfig(field: ConfigField) -
ConnectFailedThe connection or the child process could not be established.
-
RefusedThe HTTP client’s destination policy refused the host.
-
TimedOut(evidence: Evidence) -
Cancelled(evidence: Evidence) -
ConnectionClosed(evidence: Evidence) -
ResponseTooLarge(limit: Int) -
TooManyPendingCalls(limit: Int) -
HttpStatus(status: Int, www_authenticate: option.Option(String))The server answered with a non-success HTTP status and no JSON-RPC error; a 401 or 403 carries its
WWW-Authenticatechallenge. -
RpcError( code: Int, message: String, data: option.Option(value.Value), )The server answered with a JSON-RPC error object.
-
MalformedResponse(detail: String)The response broke the protocol;
detailis for logs only. -
UnsupportedVersion(supported: List(String))The server does not support the
2026-07-28revision. -
UnsupportedInputRequest(method: String)The server asked for an input method this client did not advertise.
-
InvalidArguments(detail: String)The arguments could not be encoded or are not a JSON object;
detailis for logs only. -
InvalidInputResponsesresumeneeds exactly one JSON object response per request key. -
ListingLimitExceeded(limit: Int)
The server’s name and version.
pub type ServerInfo {
ServerInfo(name: String, version: String)
}
Constructors
-
ServerInfo(name: String, version: String)
An open subscriptions/listen stream. Read it from the process that
opened it.
pub opaque type Subscription
How a tool call ended, when the server answered it.
pub type ToolResult(output) {
Succeeded(output: output, content: List(content.ContentBlock))
ToolFailed(
content: List(content.ContentBlock),
structured: option.Option(value.Value),
)
InputRequired(
continuation: Continuation(output),
requests: dict.Dict(String, tool.InputRequest),
)
}
Constructors
-
Succeeded(output: output, content: List(content.ContentBlock))The tool succeeded;
contentholds its content blocks. -
ToolFailed( content: List(content.ContentBlock), structured: option.Option(value.Value), )The tool failed and said why in
content; nothing went wrong in the protocol. -
InputRequired( continuation: Continuation(output), requests: dict.Dict(String, tool.InputRequest), )The tool needs answers first: handle each request and
resume.
Values
pub fn allow_plaintext_headers(config: Config) -> Config
Lets a client with headers send them over plain http:// to a
non-loopback host, where anyone on the path can read them.
pub fn call(
client: Client,
definition: tool.Definition(input, output),
input: input,
) -> Result(ToolResult(output), Error)
Calls a tool with the definition the server registered: encodes the input with its codec and decodes the structured output.
pub fn call_discovered(
client: Client,
declaration: tool.Declaration,
arguments: value.Value,
) -> Result(ToolResult(option.Option(value.Value)), Error)
Calls a tool known from list_tools with exact JSON arguments, which
must be an object. The output preserves structured-content presence:
None when absent, Some(value.Null) for explicit JSON null, and
Some(value) for any other structured value. Content blocks are retained
in every case, independently of the declaration’s output schema.
pub fn call_raw(
client: Client,
method: String,
name: option.Option(String),
params: List(#(String, json.Json)),
) -> Result(value.Value, Error)
Sends any request method with these params and returns its result object
as an exact JSON value. tools/call, prompts/get and resources/read
need the name their routing header carries; other methods take None.
pub fn close(client: Client) -> Nil
Closes the connection and every request and stream on it, without
waiting for them. Closing during HTTP header acquisition returns
Cancelled(NotSent); an already-dispatched call retains MaybeSent. Over
HTTP its connection closes, which cancels it on the server; over stdio the
child is closed; in process its handler is cancelled and keeps the
runtime’s cancellation grace. A caller’s HTTP Gun client
(with_http_client) is not stopped, so calls in flight through it run
until they end; cancel them with with_cancellation.
pub fn close_subscription(subscription: Subscription) -> Nil
Ends the stream; other requests on the client continue.
pub fn complete(
client: Client,
query: completion.Request,
) -> Result(completion.Values, Error)
Asks the server to complete an argument.
pub fn connect(config: Config) -> Result(Client, Error)
Validates the configuration and connects: starts the HTTP client, the stdio child or the in-process runtime. The connection is linked to the calling process.
pub fn discover(client: Client) -> Result(Discovery, Error)
Asks the server what it supports. Fails with UnsupportedVersion when
it does not support 2026-07-28.
pub fn error_correlation(error: Error) -> correlation.Correlation
The correlation the failed operation was sent with: the view’s
(with_correlation), else the one minted for that call. Log it to find
the server’s events for the request, or hand it to the caller who
reports the failure.
case client.call(peer, greet, "Ada") {
Error(error) ->
log.warning(
client.describe_error(error)
<> " correlation="
<> correlation.to_string(client.error_correlation(error)),
)
Ok(_) -> Nil
}
pub fn get_prompt(
client: Client,
name: String,
arguments: dict.Dict(String, String),
) -> Result(prompts.PromptResult, Error)
Renders a prompt with string arguments.
pub fn has_capability(discovery: Discovery, name: String) -> Bool
Whether the server advertised a top-level capability, such as "tools"
or "completions".
pub fn http(url: String) -> Result(Config, Error)
A Streamable HTTP server at an absolute http or https URL, such as
"https://mcp.example.com/mcp". A URL with userinfo, a query or a
fragment fails with InvalidConfig(Url); a missing path becomes /.
pub fn in_process(
server: server.Server(context),
context: context,
) -> Config
A server in this VM, served through its own relay/runtime with this
context: no socket and no child process, the same wire messages. Useful
for tests (relay/testing.connect) and for composing servers.
pub fn is_retryable(
error: Error,
idempotent idempotent: Bool,
) -> Bool
Whether sending the same request again is safe: always for a failure that sent nothing, for a lost connection or a timeout only when the operation is idempotent, and for HTTP 429 and 503.
pub fn list_prompts(
client: Client,
) -> Result(List(prompts.Declaration), Error)
Every prompt the server lists.
pub fn list_raw(
client: Client,
method: String,
collection: String,
) -> Result(List(value.Value), Error)
Every item of a paginated listing method, such as "tools/list" with
collection "tools", as exact JSON values.
pub fn list_resource_templates(
client: Client,
) -> Result(List(resources.TemplateDeclaration), Error)
Every resource template the server lists.
pub fn list_resources(
client: Client,
) -> Result(List(resources.Declaration), Error)
Every static resource the server lists.
pub fn list_tools(
client: Client,
) -> Result(List(tool.Declaration), Error)
Every tool the server lists, across all pages.
pub fn listen(
client: Client,
notifications: List(subscriptions.Notification),
) -> Result(Subscription, Error)
Opens a stream for these notifications; ResourceUpdated(uri) subscribes
to one resource. Returns once the server acknowledged it. One request budget
covers dynamic headers, transport opening and acknowledgement. The relative
timeout stops applying after acknowledgement; explicit absolute deadlines
and cancellation continue to apply to the established stream.
pub fn listening(
subscription: Subscription,
) -> List(subscriptions.Notification)
The notifications the server confirmed for this stream.
pub fn name(error: Error) -> String
A stable identifier for logs and stored records, such as
"timed_out.maybe_sent".
pub fn new_error(
reason: Reason,
correlation: correlation.Correlation,
) -> Error
An error for tests and fakes: reason as the failure of the call that
carried correlation. relay/testing.error supplies a fresh one.
pub fn next_notification(
subscription: Subscription,
wait: duration.Duration,
) -> Result(option.Option(subscriptions.Notification), Error)
Waits at most wait for the next notification; Ok(None) means none
arrived. The stream stays open after a wait. A terminal transport or
protocol failure closes the stream and releases its request owner.
pub fn read_resource(
client: Client,
uri: String,
) -> Result(List(content.ResourceContents), Error)
Reads a resource.
pub fn reason(error: Error) -> Reason
The failure, to match on.
case client.reason(error) {
client.HttpStatus(401, Some(challenge)) -> reauthorize(challenge)
_ -> Nil
}
pub fn resume(
continuation: Continuation(output),
responses: dict.Dict(String, json.Json),
) -> Result(ToolResult(output), Error)
Continues a paused call with one JSON object per request key. Further rounds may follow.
pub fn stdio(
executable: String,
arguments: List(String),
) -> Config
A server launched as a local child process that speaks MCP on its
standard input and output. The executable runs directly, never through a
shell. The command is held in a closure, so it does not print in
string.inspect, crash reports or logs.
pub fn with_ca_cert_file(config: Config, path: String) -> Config
Trusts the CA certificates in this PEM file instead of the system’s, for HTTPS.
pub fn with_cancellation(
client: Client,
token: cancellation.Token,
) -> Client
A view whose operations end when token is cancelled. A cancelled HTTP
call closes its connection, which cancels it on the server; a stdio call
sends notifications/cancelled.
pub fn with_connect_timeout(
config: Config,
timeout: duration.Duration,
) -> Config
How long connecting, or starting the stdio child, may take.
pub fn with_correlation(
client: Client,
correlation: correlation.Correlation,
) -> Client
A view whose operations carry this correlation in Relay’s and HTTP
Gun’s telemetry, and send it to the server, which tags the request’s
events with it and hands it to the tool handler (tool.correlation).
Without a view correlation, each request mints a fresh one and uses it
the same way, so a [relay, client, call] event always shares its
correlation with the server’s events. A correlation that is not visible
ASCII stays local: the server mints its own.
pub fn with_deadline(
client: Client,
deadline: deadline.Deadline,
) -> Client
A view whose operations all end by deadline, a budget shared across
calls; the client’s timeout still applies when it is shorter.
pub fn with_headers(
config: Config,
headers: fn() -> List(#(String, String)),
) -> Config
Adds headers to every HTTP request, computed per request so a
refreshing token source plugs in:
with_headers(config, fn() { [#("authorization", "Bearer " <> token())] }).
The callback runs in a temporary request-owned process within the request
budget. It returns header data; do not return or retain resources whose
lifetime depends on that temporary process. Cancellation and caller exit
terminate blocked acquisition. A callback crash returns
InvalidConfig(Headers) without exception details. Captured application
resources remain borrowed; detached work and remote effects are not adopted.
Header names must be lowercase. Over plain http://, a client with
headers reaches only loopback addresses unless allow_plaintext_headers.
pub fn with_http_client(
config: Config,
client: http_gun.Client,
) -> Config
Sends through this HTTP Gun client instead of starting one, sharing its pool, destination policy, cassettes and telemetry. Relay narrows each request to the URL’s host and never stops the client.
pub fn with_idempotency_key(
client: Client,
key: String,
) -> Client
A view whose requests carry key as their idempotency key, in _meta
under io.github.gleam-dream/idempotency-key; the handler reads it with
relay/tool.idempotency_key. Sending the same key again is your promise
that the request is the same one, so a server can answer a retry
without doing the work twice.
Use one view for one logical request and its retries, with a key unique
to that request, such as an order id: every request through the view
carries the key. A key must be 1 to 128 visible ASCII characters (! to
~); otherwise each request fails with InvalidArguments before it is
sent.
pub fn with_input_methods(
config: Config,
methods: List(tool.InputMethod),
) -> Config
Advertises the server-initiated input methods this application will
answer itself; a request for another method fails with
UnsupportedInputRequest.
pub fn with_label(config: Config, label: String) -> Config
The client label in Relay’s client telemetry, and the HTTP Gun label of
a client Relay starts.
pub fn with_listing_limits(
config: Config,
pages: Int,
items: Int,
) -> Config
Bounds the listings: at most pages pages and items items.
pub fn with_max_pending_calls(
config: Config,
count: Int,
) -> Config
How many calls may wait for a stdio child at once.
pub fn with_max_response_bytes(
config: Config,
bytes: Int,
) -> Config
The largest response, or listen event, the client reads.
pub fn with_timeout(
config: Config,
timeout: duration.Duration,
) -> Config
How long one request may take, including dynamic headers. A view’s
with_deadline can shorten it. For subscriptions this bounds opening and
acknowledgement; an established stream keeps its own lifetime.