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 Header annotations. It gives each SSE event to the caller when the event arrives.

See Authorization for the auth and auth_provider options.

Header annotations

The HTTP client and the gRPC client mirror the tool parameters with an x-mcp-header annotation4. An annotation can be on a nested property. The client reads the value at the property path in the arguments.

The client removes a tool from the tools/list result when an annotation is not valid. For example, a chain of properties keywords from the root must reach the annotated property. An annotation below items, anyOf or $defs is not valid. The client logs a warning with the tool name and the reason. The optional hook tool_headers.on_reject of the transport receives each rejection.

The client mirrors an integer only when it is in the safe range of JavaScript, from -(2^53-1) to 2^53-1. For another integer, the request fails with error.InvalidRequest before the client sends it.

When the server answers a tool call with -32020, the tool schema can have new annotations. The client then reads all pages of tools/list again and sends the tool call one more time. The limit of the pages is limits.max_auto_pages (64).

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 time for the whole request, with every multi round-trip round. Null uses limits.request_timeout (60 s). Progress notifications do not extend the timeout. When the time ends, the client cancels the request and returns error.Timeout.
  • max_total_timeout: the upper limit of timeout. Null uses limits.max_total_timeout (600 s). A longer timeout gets this value.
  • cancel: a cancel token that stops the request.
  • on_progress, on_log and on_notification: callbacks for the notifications on the request stream. on_progress receives at most limits.max_progress_rate_per_s (50) notifications per second. The client drops the other progress notifications.
  • 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. See The result cache.
  • 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 subscriptions/listen stream has no default timeout. It gets timeout and max_total_timeout only when the caller sets them.

When a tool has an output schema in the listTools result, the client checks the structuredContent of each callTool result against it11. On a mismatch, the client logs a warning and sets diagnostics.structured_content_invalid. The result stays available to the caller.

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.

The result cache

With Options.cache.enabled, the client keeps the results of discovery, list and read requests that carry a positive ttlMs12. The cache uses these rules:

  • A private result, and a result without cacheScope, belongs to one credential. The transport gives the credential: the bearer token on HTTP, or the authorization metadata on gRPC. The cache keeps the SHA-256 digest of the credential, not the credential.
  • The cache serves a private result only for the same credential. When the credential changes, the cache drops the private results.
  • A request with requestState or inputResponses does not use the cache.
  • After an error -32602 for a request with a cursor, the cache drops the cached pages of that list.
  • A list-changed notification on a listen stream drops the cached lists.
  • connect() and invalidateCache() drop every cached result.

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: the client validates the accepted content against requestedSchema. A mismatch gives error.HookFailed.
  • elicit_url(ctx, params) !ElicitResult: show the URL to the user. The SDK never opens it. Before the hook runs, the client checks that the URL is a valid absolute URL with types.isValidUrl13. Another URL gives error.InvalidResponse.
  • sample(ctx, params) !CreateMessageResult: before the hook runs, the client checks the tool result rules of the messages with types.checkSamplingMessages14. A violation gives error.InvalidResponse.
  • 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.
  11. MCP-TOOLS-2026-07-28: Tools, the output schema and structured content.
  12. MCP-CACHING-2026-07-28: Caching, the scope of a result.
  13. MCP-ELICITATION-2026-07-28: Elicitation, the URL mode.
  14. MCP-SAMPLING-2026-07-28: Sampling, the rules for tool uses and tool results.

Clone this wiki locally