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:

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)
  }
}
SettingDefaultSetter
request timeout30 swith_timeout, per call with_deadline
connect10 swith_connect_timeout
response size1 MiBwith_max_response_bytes
listings256 pages, 10,000 itemswith_listing_limits
stdio pending calls64, then TooManyPendingCallswith_max_pending_calls
input methods advertisednonewith_input_methods
headers over plain HTTP to a non-loopback hostrefusedallow_plaintext_headers
retriesnone: 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

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

  • NotSent

    Nothing reached the server; sending again cannot duplicate an effect.

  • MaybeSent

    The request may have reached the server, which may have acted on it.

  • Completed

    The 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

  • Configuration

    Fix the configuration.

  • Unreachable

    The server could not be reached, or the connection broke.

  • Timeout
  • Cancellation
  • Overloaded

    Too many calls were waiting.

  • Rejected

    The server refused the request with an HTTP status.

  • Protocol

    The server answered with a JSON-RPC error or broke the protocol.

  • InvalidInput

    Fix the arguments or the input responses.

  • TooLarge

    A 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)
  • ConnectFailed

    The connection or the child process could not be established.

  • Refused

    The 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-Authenticate challenge.

  • 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; detail is for logs only.

  • UnsupportedVersion(supported: List(String))

    The server does not support the 2026-07-28 revision.

  • 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; detail is for logs only.

  • InvalidInputResponses

    resume needs 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

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 describe_error(error: Error) -> String

A one-line description for logs.

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 evidence(error: Error) -> Evidence

Whether the server may have received the request.

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 kind(error: Error) -> Kind

The closed classification of an error, for branching.

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.

✨ Search Document