Skip to content

Client Guide

Christian Presley edited this page Oct 8, 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. The Unix socket and WebSocket transports, auth_provider, the icons and the Skills and Apps methods: zig-sdk 0.2.0. The nested header annotations, the retry after -32020, the default timeout and the progress rate: the same release. The check of structured content and of input requests, and the credential of the cache: the same release. The request options log_level, meta and inline_notifications, the scratch memory of notifications, Diagnostics.http_status and the HTTP proxies: zig-sdk 0.4.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. With Options.log_level or RequestOptions.log_level, it also sends the log level. A request can add more entries, see Request metadata. 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.
WebSocket (ws and wss) mcp.transport.websocket.Client.init(io, gpa, .{ .url = ... }) then .transport(). See Transport WebSocket.
gRPC mcp_grpc.Channel.init(io, gpa, .{ .host = ..., .port = ... }) then .transport(). See Transport gRPC.

The stdio client demuxes responses by id, and notifications by progress token and subscription id. A notification with neither goes to the on_notification spawn option, for example a log message of the server. See Notifications.

The stdio client 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. A process that does not exit gets a termination signal after limits.shutdown_grace. kill() terminates it at once. exitStatus() gives the exit status of the last server process that ended. See Transport stdio.

The HTTP client sends each request as one POST on its own connection3. 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. max_response_bytes (4 MiB) limits an application/json body and each SSE event, but not the whole SSE response.

HTTPS runs on the SDK TLS client. The tls option also sets the cipher suites (cipher_suites), the record padding (padding) and the revocation checks (revocation).

See Authorization for the auth and auth_provider options.

HTTP proxies

The proxy option of HttpClient sends the connections through an HTTP proxy. The client asks the proxy for a CONNECT tunnel to the server, also for an http URL. The tunnel carries the TLS handshake and the requests. Thus for an https URL, the proxy sees only the host and the port of the server. OAuthClient.Options.proxy does the same for the requests of the OAuth client. Give both clients the same value.

const http = try mcp.transport.HttpClient.init(io, gpa, .{
    .url = "https://mcp.example.com/mcp",
    .proxy = .{ .environment = init.environ_map },
});

The option has three forms:

  • .environment: the proxy variables of the environment that the application gives, for example the environ_map of std.process.Init. An https URL takes the first of https_proxy, HTTPS_PROXY, all_proxy and ALL_PROXY with a value. An http URL takes the first of http_proxy, HTTP_PROXY, all_proxy and ALL_PROXY. When REQUEST_METHOD has a value, the client ignores http_proxy and HTTP_PROXY. no_proxy or NO_PROXY names the hosts that the client connects to directly.
  • .explicit: a proxy URL of the application, with its own no_proxy list.
  • .none: no proxy.

The default is .{ .environment = null }, thus the client connects directly. A proxy from the environment never gets a loopback host. The proxy URL must use http: the SDK has no TLS to the proxy and no socks5 proxy. For another URL, HttpClient.init returns error.InvalidProxy. The user and the password of the URL go only to the proxy, in the CONNECT request.

When the proxy refuses the tunnel, the log scope mcp_proxy has a warning with the proxy, the target and the status. A request through HttpClient then returns error.TransportFailed. See Proxy for the no_proxy match and the proxy URL, and Authorization for the OAuth client.

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, .{});

The other typed methods are discover, listResources, listResourceTemplates, listPrompts and complete. request sends a method of the method table and parses its result type. requestAs returns a Response with the parsed result and the raw value.

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. The memory link (memory.ClientLink) does not obey it.
  • 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 that the transport routes to the request. on_progress receives at most limits.max_progress_rate_per_s (50) notifications per second. The client drops the other progress notifications. The arguments of each callback are valid only during the call. See Notifications.
  • inline_notifications: call the notification callbacks of the request on the reader task of the transport, in the order of arrival. See Notifications.
  • log_level and meta: the log level and more _meta entries for this request. See Request metadata.
  • 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, the HTTP status and other findings. See Errors and diagnostics.

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.

Request metadata

RequestOptions.log_level asks the server for the log messages of one request at this level and above15. It replaces Options.log_level of the client for that request. The client sends it in _meta also when Options.log_level is null. Log messages are a deprecated feature of the specification. See Deprecated Features.

RequestOptions.meta adds more _meta entries to one request, for example traceparent, tracestate, baggage or a key with a vendor prefix8. The client writes these entries first and its own entries after them. Thus the entries of the SDK always have priority. The result cache ignores _meta.

var meta: std.json.ObjectMap = .empty;
try meta.put(arena, "traceparent", .{ .string = "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01" });
const result = try client.callTool(arena, "add", .{ .a = 2, .b = 3 }, .{ .meta = meta, .log_level = .debug });

Before the client sends the request, it checks each key. The request fails with error.InvalidMeta, and the client sends nothing, in these cases:

  • mcp.protocol.meta.validateKey refuses the key.
  • The SDK owns the key. meta.sdk_owned_request_keys has these keys: the protocol version, the client information, the client capabilities, the log level, the subscription id and the progress token. meta.isSdkOwnedRequestKey tells if a key is one of them.

The client accepts the other keys under io.modelcontextprotocol/, because extensions define keys there. Use log_level, not meta, to set the log level.

Notifications

The callbacks on_progress, on_log and on_notification of RequestOptions receive the notifications that the transport routes to the request. On HTTP and gRPC, each request has its own stream, and the callbacks get the notifications of that stream. Hooks.on_notification gets a routed notification when the request has no on_notification and the notification is not progress or log.

The stdio, Unix socket and WebSocket transports route a notification only by its progress token or its subscription id7. A notification with neither goes to the on_notification option of the transport. On stdio, that is a spawn option of stdio.Client. A log message of the server is such a notification, also during a request. Thus on these transports, on_log gets no log messages. The router drops a notification whose progress token or subscription id names no request in flight.

The client parses each notification into scratch memory and resets that memory after the frame. Thus the arguments of each callback are valid only during the call. A callback that keeps a part of them must copy it. The request arena does not hold the notifications, thus a long subscriptions/listen stream does not make the request arena larger. In version 0.3.0, the method and params of on_notification stayed in the request arena.

By default, the reader task puts a notification in the queue of the request, and the task of the request calls the callback later. The response of another request can then get to its caller first. With RequestOptions.inline_notifications, the reader task calls the callbacks of the request at once, before it routes the next frame. Thus a notification gets to its callback before the response of another request that the server wrote after it. An example is a notifications/tools/list_changed event of a listen stream and the tools/call result that caused it.

The stdio, Unix socket and WebSocket transports obey inline_notifications. The memory link, HTTP and gRPC ignore it. On HTTP and gRPC, the client cannot know the order in which the server wrote the frames of two requests.

An inline callback blocks the reader task of the connection. Thus it must only copy, serialize and write the data. It must not send a request through the same client. It must not wait for a lock that another task holds while that task waits for a response through the same client. The request returns only after its last callback returned.

Errors and diagnostics

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.

error.InvalidResponse tells that the response is not a valid result for the method, or that the transport cannot read the response. Examples are a message over a size limit, a line that is not valid UTF-8 and an error response with a null id. An HTTP status without a JSON-RPC message also gives this error. The client does not send the request again after this error.

The stdio, Unix socket and WebSocket clients make a request fail at once when the router cannot read its response. The router finds the request by the top-level "id" member of the frame. It logs a warning in the scope mcp_router for each frame that it drops. The client also sends notifications/cancelled for the request that failed, because the server can still run it. In version 0.3.0, the router dropped such a frame, and the request waited until its timeout. On WebSocket, a message over the size limit or a message that is not valid UTF-8 closes the connection, and the requests fail with error.Closed.

diagnostics.http_status has the HTTP status of the last response to the request, also when the request failed. The Streamable HTTP and gRPC transports give it. The WebSocket transport gives the status of an upgrade that the server refused. Thus a caller can tell a wrong path, for example status 404, from a response that is not valid. Both give error.InvalidResponse.

error.InvalidMeta tells that a key of RequestOptions.meta is not valid. See Request metadata.

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 access token on HTTP and WebSocket, 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 server sends the acknowledgment before the first event, see Server Guide. The arguments of the callback are valid only during the call. The call returns when the server closes the stream, or with error.Canceled when the cancel token fires.

The stdio, Unix socket and WebSocket clients obey inline_notifications. With it, an event gets to the callback before the response of another request that the server wrote after the event. Use it when the order of a list-changed event and a tool result is important. See Notifications.

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: Base protocol: the _meta keys and their prefixes, and the icons with their MIME types and 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.
  15. MCP-LOGGING-2026-07-28: Logging, the log level of a request.

Clone this wiki locally