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.0.1 (unreleased).

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. It uses increasing integer ids.

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()
Streamable HTTP and HTTPS mcp.transport.HttpClient.init(io, gpa, .{ .url = ... }) then .transport()

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

The HTTP client sends one POST per request3. 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 option.

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, including 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 instead of running the hooks.
  • allow_task: return the CreateTaskResult of the Tasks extension in Response.task instead of waiting for the task.
  • retry: what to do when the stream is lost 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 task was cancelled.

Subscriptions

listen(arena, filter, options) opens the stream. The on_notification callback receives the acknowledgement 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.

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, re-sending a listen request after a reconnection.

Clone this wiki locally