Transport gRPC - ChristianPresley/zig-sdk GitHub Wiki
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 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.
- One
Callcarries 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 withgrpc-status: 0. -
subscriptions/listenis one long-lived call. The acknowledgment and every event are messages on the stream. The server ends the stream with the result andgrpc-status: 0. The client ends it withRST_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 answersDEADLINE_EXCEEDED. The SDK client sends the time that is left of the request timeout. A request without a timeout getslimits.request_timeout(60 s), so each call has agrpc-timeoutby default. A listen stream has no default timeout.
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.
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
POSTgets HTTP status 405. A request without a gRPC content type gets 415. - A message larger than
max_message_bytesends withRESOURCE_EXHAUSTED. - A missing or invalid bearer token ends with
UNAUTHENTICATEDorPERMISSION_DENIEDand awww-authenticatetrailer.
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.
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.
- 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. APUSH_PROMISEframe is a protocol error. - A
PING,PRIORITY,RST_STREAM,SETTINGSorWINDOW_UPDATEframe with a wrong length is a connection errorFRAME_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
authorizationmetadata to an index.
-
http2.Connection.Stream.cancelresets a stream withCANCEL. It wakes each task that waits on the stream, for example inread,waitHeaders,waitEnd,sendDataorwaitCancelled. The task then sees the reset. - A stream that gets a
cancelbefore its headers go out gets no id, andsendHeadersgiveserror.StreamReset. Thus the peer never sees the stream. -
Stream.stopWaitsstops the waits on a stream without a reset. Each wait on the stream then giveserror.Canceledwhen it must block. Use it, notFuture.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.CallStreamholds the stream of a call that runs in its own task, andCallStream.stopresets 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.
- After a field over
max_field_sizeor a header list overmax_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 withPROTOCOL_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 thatStream.closeended 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.
| 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. |
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.
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 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 |
-
_metatravels incommon.metadata. JSON values travel asgoogle.protobuf.Struct. - A
Structkeeps numbers as doubles. An integer outside the range from -(2^53) to 2^53 is an error. - The
bytesfieldsdataandblobcarry the base64 text of MCP, as in the reference transport. - An
input_requiredresult usesresult_type,input_requestsandrequest_state. The answers travel incommon.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
_metagets the value ofmcp-protocol-version, else2026-07-28. Missing client capabilities become an empty object.
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.
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/listenand the extension methods have no RPC. Withfallback = .tunnel, the client sends them on the tunnel. - The binding refuses a
taskresult,structuredContentthat is not an object, and keys without a field. - Proto3 does not tell a default value from an absent value. Thus
destructiveHint: falsearrives as absent, and the receiver seestrue. - An empty
blobarrives as an emptytext. Some numbers are 32-bit floats.
| 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 |
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());- 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,
stopWaitsand 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-interopcalls the tunnel from a Nodehttp2peer and fromcurlover HTTP/2. It also calls all eight typed RPCs from@grpc/grpc-jswith the vendored proto files.
- MCP-TRANSPORTS-2026-07-28: Transports, and the rules for custom transports.
- GRPC-HTTP2: gRPC over HTTP/2.
- MCP-SEP-2243: HTTP headers derived from the message.
- HPACK-RFC7541: HPACK: Header Compression for HTTP/2.
- RFC9113: HTTP/2.
- GRPC-STATUS: Status codes and their use in gRPC.
- GCP-GRPC-PROTO: The Google Cloud proto files of MCP over gRPC, release v0.2.0.
-
GCP-GRPC-PY: The reference transport in Python, branch
v2.