Skip to content

Client Guide

Christian Presley edited this page Sep 30, 2026 · 12 revisions

Client Guide

This page describes the client library: mcp.Client, the transports, the hooks and the request options. The client obeys the client part of the specification1.

Applies to: MCP 2026-07-28, Zig 0.16.0, zig-sdk 0.1.0.

Create a client

var client: mcp.Client = .init(gpa, io, .{
    .info = .{ .name = "cli", .version = "0.1.0" },
    .capabilities = .{ .elicitation = .{}, .roots = .{} },
    .hooks = .{ .elicit_form = onForm, .list_roots = onRoots },
});
defer client.deinit();
client.connect(transport);

The client stamps _meta on every request: the protocol version, the client info, the client capabilities and a progress token2. Its integer ids increase by one for each request.

Transports

Transport How to get it
In-memory link to a server in the same process mcp.transport.memory.ClientLink.init(io, gpa, &server).transport()
stdio with a child process mcp.transport.stdio.Client.spawn(io, gpa, .{ .argv = ... }) then .transport()
Unix domain socket mcp.transport.unix.Client.connect(io, gpa, .{ .path = ... }) then .transport(). See Transport Unix Socket.
Streamable HTTP and HTTPS mcp.transport.HttpClient.init(io, gpa, .{ .url = ... }) then .transport(). For https, tls = .{ .trust = ..., .identity = ... } sets the trust policy and a client certificate. The default is the system trust store. See TLS and Certificates.

The stdio client demuxes responses by id, and notifications by progress token and subscription id. It sends notifications/cancelled when the timeout of a request expires or when the application cancels it. close() closes the standard input of the process and waits for it. kill() terminates it at once.

The HTTP client opens one connection and sends one POST per request3. HTTPS runs on the SDK TLS client. It mirrors the method, the name or URI, and the annotated parameters into the Mcp-* headers with the base64 sentinel where needed4. It learns x-mcp-header annotations from tools/list and removes tools with invalid annotations from the list. See Authorization for the auth and auth_provider options.

Requests

Every method takes an arena that owns the result:

const tools = try client.listTools(arena, null, .{});
const result = try client.callTool(arena, "add", .{ .a = 2, .b = 3 }, .{ .timeout = .fromSeconds(30) });
const resource = try client.readResource(arena, "file:///readme", .{});
const prompt = try client.getPrompt(arena, "review", .{ .lang = "zig" }, .{});
const raw = try client.requestAs(arena, MyResult, "server/discover", params, .{});

RequestOptions has:

  • timeout: the deadline for the whole request, with every multi round-trip round.
  • cancel: a cancel token that stops the request.
  • on_progress, on_log and on_notification: callbacks for the notifications on the request stream.
  • allow_input_required: return the raw InputRequiredResult. The client does not call the hooks.
  • allow_task: return the CreateTaskResult of the Tasks extension in Response.task. The client does not wait for the task.
  • cache_mode: default serves a cached result when the server gave it a lifetime, bypass never touches the cache, refresh stores the fresh result. The cache is off unless Options.cache.enabled is set. A list-changed notification on a listen stream drops the cached lists, and invalidateCache() drops everything.
  • retry: what to do when the client loses the stream before a response byte arrived. auto re-issues idempotent methods with a new id, never does not, and force re-issues every method. The limit is limits.max_lost_stream_retries (3).
  • diagnostics: receives the JSON-RPC error and other findings.

A JSON-RPC error becomes error.Rpc with the error in diagnostics.rpc_error. When the server rejects the protocol version with -32022 and lists ours as supported, the client retries once5.

Multi round-trip requests

When a result says input_required, the client runs one hook per input request6. Then it sends the request again with a new id, the answers and the unchanged requestState. The client rejects an input kind it did not declare. The limit is ten rounds.

The hooks are:

  • elicit_form(ctx, params) !ElicitResult
  • elicit_url(ctx, params) !ElicitResult: show the URL to the user. The SDK never opens it.
  • sample(ctx, params) !CreateMessageResult
  • list_roots(ctx) ![]const Root: every root must be a file:// URI.

Tasks

A client that declares the extension io.modelcontextprotocol/tasks in capabilities.extensions can receive a task instead of a tool result. See Tasks Extension. callTool then polls the task, answers its input requests with the hooks and returns the tool result. The other methods give full control:

switch (try client.callToolOrTask(arena, "slow_compute", .{ .seconds = 5 }, .{})) {
    .complete => |result| use(result),
    .task => |task| {
        const done = try client.awaitTask(arena, task.taskId, .{ .timeout = .fromSeconds(60) });
        use(try mcp.Client.taskResult(arena, done));
    },
}

getTask, updateTask and cancelTask send one request each. awaitTask returns error.Rpc with the task error in the diagnostics when the task failed, and error.TaskCancelled when the server canceled the task.

Skills and Apps

The client has methods for two more extensions:

  • Skills: listSkills, getSkill, readDirectory and readSkillFile. readSkillFile verifies the size, the digest and the frontmatter of each file against the skill entry. See Skills Extension.
  • Apps: declare the extension with mcp.protocol.apps.declare. Then toolUi gives the UI metadata of a tool, and readUiResource reads its view. See Apps Extension.

Subscriptions

listen(arena, filter, options) opens the stream. The on_notification callback receives the acknowledgment and every event. The call returns when the server closes the stream, or with error.Canceled when the cancel token fires.

A lost stream ends the call with error.Closed. Pass .retry = .force to open the stream again with a new id, for example after the stdio client restarted the server process7.

Icons

A server can attach icons to Implementation, Tool, Resource, ResourceTemplate, ResourceLink and Prompt. The client treats the icon metadata and the icon bytes as data from a source that it does not trust8. mcp.icons has the rules, and mcp.Client applies them with the client options.

var client: mcp.Client = .init(gpa, io, .{
    .info = .{ .name = "cli", .version = "0.1.0" },
    .icons = .{ .formats = .{ .svg = true } },
    .hooks = .{ .icon_decoder = sanitizeIcon, .userdata = &app },
});
const icon = client.selectIcon(tool.icons, .{ .size = 32, .theme = .dark }, server_url) orelse return;
const image = try client.fetchIcon(arena, icon, .{ .server_url = server_url });
show(image.format, image.bytes);

The client obeys these rules:

  • The src is an https URL or a data: URI9. The client rejects each other scheme, for example http:, javascript:, file:, ftp: and ws:, with error.UnsupportedScheme. An https URL with user information gives error.InvalidUri.
  • An https icon must have the origin of the MCP endpoint10. server_url gives that origin. The origin includes the scheme, so an http endpoint never matches an https icon. Without a server URL, for example on stdio, only data: icons and the trusted origins pass.
  • Policy.trusted_origins adds other origins. Policy.origins = .any disables the origin rule. Policy.origins = .none accepts only data: icons. Policy.allow_data = false rejects data: icons.
  • The fetch sends one GET request for each redirect step, with only the headers host, connection, accept and accept-encoding. It sends no cookie and no Authorization header. It sends a client certificate only when the tls option of fetchIcon has an identity.
  • A redirect must stay on the origin of the icon URL and on https. The client follows at most limits.http.max_redirect_hops (3) redirects. After that, it gives error.TooManyRedirects.
  • The magic bytes set the format. The media type of the response or of the data: URI applies first. The mimeType of the icon applies when that type is absent or generic. A mismatch gives error.MimeMismatch, and an unknown format gives error.UnknownFormat.
  • PNG and JPEG are on by default and need no decoder. GIF, WebP and SVG are off by default. They pass only when the policy turns them on and the icon_decoder hook is set. An SVG image can contain script.
  • The client reads the width and the height from the PNG, JPEG, GIF and WebP header. A value above limits.icon.max_dimension gives error.DimensionsTooLarge.

The icon_decoder hook receives each checked image, also PNG and JPEG. It can decode, clean or convert the image. It returns an error to reject the image, and the client then gives error.DecoderFailed. The client gives the result of the hook to the caller.

selectIcon ranks the candidates by theme first, then by size, then by their order. For the theme, an equal theme wins over an icon without a theme, and that icon wins over another theme. For the size, an exact size wins. Then an icon with sizes = ["any"] or without sizes, then the smallest larger size, then the largest smaller size. The function uses only the declared mimeType for the format rule. Without an icon_decoder, it removes GIF, WebP and SVG icons.

fetchIcon returns an icons.Image with the format, the bytes and the dimensions. The dimensions are null for SVG. The errors are in icons.Error, for example OriginNotAllowed, RedirectRefused, MimeMismatch, TooLarge and Timeout. The functions mcp.icons.checkSource, decodeData, inspect, select and fetch give the same rules without a client. See Limits and Security for the limits.

References

  1. MCP-CLIENT-2026-07-28: Client features overview.
  2. MCP-SEP-2575: Per-request metadata.
  3. MCP-HTTP-2026-07-28: Streamable HTTP transport.
  4. MCP-SEP-2243: HTTP headers derived from the message.
  5. MCP-VERSIONING-2026-07-28: Version negotiation.
  6. MCP-SEP-2322: Multi round-trip requests.
  7. MCP-SUBSCRIPTIONS-2026-07-28: Subscriptions, and a new listen request after a reconnection.
  8. MCP-BASIC-2026-07-28: Icons, the MIME types and their security considerations.
  9. RFC2397: The data URL scheme.
  10. RFC6454: The origin of a URI.

Clone this wiki locally