Transport gRPC - ChristianPresley/zig-sdk GitHub Wiki

Transport gRPC

The gRPC binding is a custom transport under the custom transport rules of the specification1. It keeps the JSON-RPC message format, the request patterns and the per-request metadata. The server can also serve the typed service of the Google Cloud proto files on the same port, see The typed binding. This page is the normative description of the binding. The SDK provides protobuf, HPACK, HTTP/2 and gRPC itself, with no dependency.

Applies to: MCP 2026-07-28, proto/mcp_zig_transport_v1.proto, zig-sdk 0.1.0. The typed binding: the Google Cloud proto files of release v0.2.0, zig-sdk 0.2.0. The nested header annotations, the header value checks and the default timeout: the same release. The early responses, Stream.stopWaits, CallStream, the refused streams, the HPACK table after a size error and the header blocks after a reset: zig-sdk 0.4.0.

The service

The proto package is mcp.zig.transport.v1. The service Mcp has one method:

rpc Call(JsonRpcMessage) returns (stream JsonRpcMessage);

JsonRpcMessage has one field, bytes jsonrpc = 1, with one JSON-RPC message as UTF-8 text. The fields 2 to 15 have the reserved status. The path of the method is /mcp.zig.transport.v1.Mcp/Call.

The content type is application/grpc+proto. A server accepts every application/grpc content type. Only the identity encoding is used. A call with another grpc-encoding ends with UNIMPLEMENTED2.

Request patterns

  • One Call carries one JSON-RPC request. The client sends the message and ends its side of the stream.
  • The response stream carries zero or more notifications related to the request, for example notifications/progress, then exactly one response, then the trailers with grpc-status: 0.
  • subscriptions/listen is one long-lived call. The acknowledgment and every event are messages on the stream. The server ends the stream with the result and grpc-status: 0. The client ends it with RST_STREAM(CANCEL).
  • A multi round-trip request (input_required) is a new call with the answers, as on the other transports.
  • Cancellation by the client is RST_STREAM(CANCEL). The server cancels the request handler when it sees the reset.
  • The server can send a complete response before it reads the request message. Examples are an authorization challenge, an unknown method and the HTTP status 415. See Early responses.
  • A notification from the client is a call the server answers with an empty stream and grpc-status: 0. Servers do not send requests.
  • A deadline travels in grpc-timeout. The server cancels the handler when it passes and answers DEADLINE_EXCEEDED. The SDK client sends the time that is left of the request timeout. A request without a timeout gets limits.request_timeout (60 s), so each call has a grpc-timeout by default. A listen stream has no default timeout.

Metadata

The request metadata mirrors the Streamable HTTP headers3 in lowercase:

Metadata Value
mcp-protocol-version The protocol version of the body.
mcp-method The JSON-RPC method.
mcp-name params.name for tools/call and prompts/get, params.uri for resources/read, params.taskId for the task methods.
mcp-param-* The tool arguments annotated with x-mcp-header, with the base64 sentinel for values that are not plain.
authorization The bearer token, when the server requires one.

The server verifies the metadata against the body with the same rules as the HTTP server. A mismatch or a missing header is the JSON-RPC error -32020. The server also gives -32020 for a raw value with a control character other than tab or a byte that is not ASCII. An annotated integer outside the safe range of JavaScript also gets -32020. An x-mcp-header annotation can be on a nested property that a chain of properties keywords reaches.

The client removes a tool with an invalid annotation from the tools/list result and logs a warning. It refuses to mirror an integer outside the safe range with error.InvalidRequest. After a -32020 error for a tool call, it reads tools/list again and sends the call one more time. See the Client Guide.

Errors

A JSON-RPC error that ends a call before any message travels in headers-only trailers6:

Trailer Value
grpc-status INVALID_ARGUMENT for -32700, -32600, -32602, -32020, -32021 and -32022. UNIMPLEMENTED for -32601.
grpc-message The error message, percent-encoded.
mcp-error-code The JSON-RPC error code in decimal.
mcp-error-bin The JSON-RPC error response, base64.

The SDK server ends a call in this way only for an error that it finds before the handler runs: -32700, -32600, -32020 and -32022. The client delivers the decoded error response to the application, so the application sees the same error.Rpc as on the other transports. An error inside a request that runs is an ordinary JSON-RPC error response message, followed by grpc-status: 0.

Other cases:

  • An unknown path ends with UNIMPLEMENTED.
  • A request that is not a POST gets HTTP status 405. A request without a gRPC content type gets 415.
  • A message larger than max_message_bytes ends with RESOURCE_EXHAUSTED.
  • A missing or invalid bearer token ends with UNAUTHENTICATED or PERMISSION_DENIED and a www-authenticate trailer.

Client.Diagnostics.http_status has the HTTP status of the response, also when the request failed. For the trailers UNAUTHENTICATED and PERMISSION_DENIED, it has 401 and 403. A response with another HTTP status than 200 gives error.InvalidResponse.

Early responses

The server can end a call before it reads the request message, for example with the errors above. After a complete response, the server resets a stream with RST_STREAM(NO_ERROR) when the request did not end5. The client then stops the request and keeps the response. Before version 0.4.0, the server sent CANCEL.

The SDK client drops the remaining part of the request only when the server ended the stream and the stream then got a reset. The ended stream tells that the response is complete. The reset can come from the server or from the client, and its code has no effect. Thus the client also keeps the response of a server that resets with CANCEL, for example a server of version 0.3.0. In this state, sendHeaders and sendData of a client stream send nothing and give no error, and the client reads the response.

Before version 0.4.0, a reset that came before the request message made the send fail with error.StreamReset. The transport then lost the response, and the client returned error.TransportFailed or sent an idempotent request again.

Connections

  • The client connects with cleartext HTTP/2 and prior knowledge, or with TLS and ALPN h2. The server refuses a TLS client that negotiated another protocol.
  • The client sends SETTINGS_ENABLE_PUSH = 0. A PUSH_PROMISE frame is a protocol error.
  • A PING, PRIORITY, RST_STREAM, SETTINGS or WINDOW_UPDATE frame with a wrong length is a connection error FRAME_SIZE_ERROR.
  • Flow control applies in both directions. The server advertises a window of 1 MiB per stream and 4 MiB per connection.
  • The client keeps one connection per channel and opens a new one when the connection is gone.
  • The HPACK encoder never adds to the dynamic table, and it never adds the authorization metadata to an index.

Streams and resets

  • http2.Connection.Stream.cancel resets a stream with CANCEL. It wakes each task that waits on the stream, for example in read, waitHeaders, waitEnd, sendData or waitCancelled. The task then sees the reset.
  • A stream that gets a cancel before its headers go out gets no id, and sendHeaders gives error.StreamReset. Thus the peer never sees the stream.
  • Stream.stopWaits stops the waits on a stream without a reset. Each wait on the stream then gives error.Canceled when it must block. Use it, not Future.cancel, to stop a task that waits on a stream. In Zig 0.16.0, the condition of std can lose a cancel that comes together with a wake-up.
  • After a call, the server stops the task that watches the stream for a reset with stopWaits. Before version 0.4.0, a lost cancel kept the handler and its stream until the client reset the stream or closed the connection.
  • A call can stop for its deadline or for a cancel. Then the tunnel and typed channels reset the stream of the call before they wait for its task. mcp_grpc.client.CallStream holds the stream of a call that runs in its own task, and CallStream.stop resets it.
  • A peer can open a stream with a header list over the limit or with headers that are not valid. The connection frees such a stream after the reset. Such a stream does not count against the limit of concurrent streams. Before version 0.4.0, the connection refused each new stream after that number of such requests.

Header blocks

  • After a field over max_field_size or a header list over max_header_list_size, the HPACK decoder reads the complete block. It applies each change to the dynamic table, adds no more headers and gives the error at the end.
  • Thus the dynamic table stays the same as the table of the peer, and the next block of the connection decodes correctly4. The encoder of the SDK adds no entries, thus only another peer can cause this case.
  • A string length past the end of the block makes the block not valid, also when the string is over max_field_size. The connection then ends with PROTOCOL_ERROR.
  • A server connection ignores a header block on a peer stream that it reset5. An example is the trailers that a client sent before the reset arrived. The connection decodes the block to keep the HPACK table in sync.
  • The connection remembers the last http2.Connection.remembered_resets (64) streams that it reset: the streams that it refused and the streams that Stream.close ended with a reset.
  • A header block on another closed stream, or on a stream id that the client did not open, still ends the connection with PROTOCOL_ERROR.

Limits

Limit Default On overflow
Concurrent streams per connection limits.max_in_flight_requests (256) REFUSED_STREAM
Frame size 16384 octets Connection error FRAME_SIZE_ERROR
Header list 8 KiB ENHANCE_YOUR_CALM
Header block across CONTINUATION frames 32 KiB Connection error PROTOCOL_ERROR
Message 4 MiB RESOURCE_EXHAUSTED
Peer streams with a reset that a server connection remembers http2.Connection.remembered_resets (64) A header block on an older stream is a connection error PROTOCOL_ERROR.

Steps: serve gRPC

var transport: mcp_grpc.Server = .init(io, gpa, &server, .{ .port = 50051 });
try transport.serve();

Add .tls = &tls_server for TLS with ALPN h2 and .auth = &resource_server for bearer tokens. With auth, a handler reads the principal of the token with ctx.principal(), as on HTTP.

Steps: connect

const channel = try mcp_grpc.Channel.init(io, gpa, .{ .host = "127.0.0.1", .port = 50051 });
defer channel.deinit();
client.connect(channel.transport());

Add .tls = .{ .trust = ... } for TLS and .extra_metadata = &.{ .{ .name = "authorization", .value = "Bearer ..." } } for a static token.

The typed binding

The server can also serve the typed service model_context_protocol.Mcp of the Google Cloud proto files7. Each unary RPC carries one MCP request as a typed message. The mapping, the metadata and the errors follow the reference transport8. The path is /model_context_protocol.Mcp/ and the name of the RPC. Authorization, deadlines and RST_STREAM work as on the tunnel.

RPC MCP method Routing metadata
ListResources, ListResourceTemplates, ListPrompts, ListTools The list methods None
ReadResource resources/read mcp_resource: the URI
GetPrompt prompts/get mcp_prompt: the prompt name
CallTool tools/call mcp_tool: the tool name
Complete completion/complete mcp_resource: the URI or the prompt name of the reference

Mapping and metadata

  • _meta travels in common.metadata. JSON values travel as google.protobuf.Struct.
  • A Struct keeps numbers as doubles. An integer outside the range from -(2^53) to 2^53 is an error.
  • The bytes fields data and blob carry the base64 text of MCP, as in the reference transport.
  • An input_required result uses result_type, input_requests and request_state. The answers travel in common.input_responses.
  • The routing metadata is optional. When it is present, it must equal the value in the message, else the error is -32020.
  • A request without a protocol version in _meta gets the value of mcp-protocol-version, else 2026-07-28. Missing client capabilities become an empty object.

Errors

A JSON-RPC error ends the call with headers only. -32700, -32600, -32602, -32020, -32021 and -32022 give INVALID_ARGUMENT. -32601 gives UNIMPLEMENTED. Other codes give INTERNAL. The trailers are grpc-message (at most 1024 bytes of text), mcp-error-code and mcp-error-bin. The server sends mcp-error-bin only when it has 4096 bytes or less.

The SDK client reads mcp-error-bin, else mcp-error-code and grpc-message, else the status map of the reference transport. Malformed protobuf gives INVALID_ARGUMENT and -32700.

Gaps

The binding refuses a part of MCP that the messages cannot carry. The server answers INTERNAL and -32603 with the reason. The client fails with error.InvalidRequest before a byte goes out.

  • The messages have no cursor. The server merges up to 1024 pages into one list response.
  • The server drops notifications related to a request, such as progress.
  • server/discover, subscriptions/listen and the extension methods have no RPC. With fallback = .tunnel, the client sends them on the tunnel.
  • The binding refuses a task result, structuredContent that is not an object, and keys without a field.
  • Proto3 does not tell a default value from an absent value. Thus destructiveHint: false arrives as absent, and the receiver sees true.
  • An empty blob arrives as an empty text. Some numbers are 32-bit floats.

Limits

Limit Default On overflow
Request message max_message_bytes (4 MiB) RESOURCE_EXHAUSTED
Nesting depth typed_limits.max_depth (64) INVALID_ARGUMENT
Decoded elements typed_limits.max_elements (262144) INVALID_ARGUMENT
Pages in one response 1024 INTERNAL

Steps: use the typed binding

var transport: mcp_grpc.Server = .init(io, gpa, &server, .{
    .port = 50051,
    .bindings = .{ .tunnel = true, .typed = true },
});
const channel = try mcp_grpc.TypedChannel.init(io, gpa, .{
    .channel = .{ .host = "127.0.0.1", .port = 50051 },
    .fallback = .tunnel,
});
client.connect(channel.transport());

Tests

  • The tests check HPACK with every example of RFC 7541 appendix C4.
  • HTTP/2 loopback tests cover flow control with bodies larger than the windows, interleaved streams, resets and settings5.
  • The MCP client runs over the channel against the SDK server. The tests cover discovery, tools with progress, mirrored metadata and multi round-trip requests. They also cover errors in the trailers, deadlines, a listen stream and TLS with ALPN h2.
  • A raw HTTP/2 client checks the unknown path, the wrong content type and missing metadata. It also sends the request message after the response and the reset, and reads the response.
  • HTTP/2 connection tests cover a response before the end of the request, stopWaits and a cancel from another task. Other cases are a cancel before the stream has an id and refused streams. They also cover the HPACK table after a size error and header blocks after a reset.
  • Each typed message makes a round trip with each field, with unknown fields and with each prefix of its bytes. The MCP client runs all eight typed RPCs against the SDK server, with multi round-trip calls, errors, deadlines and the tunnel fallback.
  • The CI job grpc-interop calls the tunnel from a Node http2 peer and from curl over HTTP/2. It also calls all eight typed RPCs from @grpc/grpc-js with the vendored proto files.

References

  1. MCP-TRANSPORTS-2026-07-28: Transports, and the rules for custom transports.
  2. GRPC-HTTP2: gRPC over HTTP/2.
  3. MCP-SEP-2243: HTTP headers derived from the message.
  4. HPACK-RFC7541: HPACK: Header Compression for HTTP/2.
  5. RFC9113: HTTP/2.
  6. GRPC-STATUS: Status codes and their use in gRPC.
  7. GCP-GRPC-PROTO: The Google Cloud proto files of MCP over gRPC, release v0.2.0.
  8. GCP-GRPC-PY: The reference transport in Python, branch v2.
⚠️ **GitHub.com Fallback** ⚠️