Guide · Data Serialization Integration
MCP Server Protocol Buffers — proto3 encoding, schema evolution, gRPC backend integration
Three Protocol Buffers behaviours trip up MCP server authors: proto3 drops all zero-value fields on the wire — a tool response decoded from a gRPC backend will silently omit every field whose value is 0, false, or "", causing the agent to see missing keys where the backend actually sent meaningful defaults; the fix is to use optional field syntax (proto3 optional) so the encoder emits the field even when its value is the default; field numbers can never be reused or changed once a message has been deployed — changing field 3 from string user_id to int64 user_id will silently misparse existing serialised messages in any consumer that hasn't yet deployed the new schema; always reserve deleted field numbers with the reserved keyword; and Timestamps require explicit conversion — google.protobuf.Timestamp arrives as { seconds: Long, nanos: number } in protobufjs, not a JS Date, and passing it directly to JSON.stringify produces a plain object instead of an ISO string; call Timestamp.toDate() before serialising to MCP tool output.
TL;DR
Use protobufjs or @bufbuild/protobuf to decode gRPC backend responses in MCP tool handlers. Never reuse or renumber deleted fields — reserve them. Use optional fields to preserve zero-value semantics in proto3. Convert google.protobuf.Timestamp via .toDate().toISOString() before returning from tool handlers. Use oneof for discriminated union results so agents can branch cleanly on result type.
Decoding gRPC backend responses in MCP tool handlers
MCP servers that proxy gRPC backends receive binary protobuf payloads and must decode them into plain JSON-serialisable objects before returning from a tool handler. Two popular approaches exist: the legacy protobufjs library (runtime schema loading from .proto files) and the modern @bufbuild/protobuf library (generated TypeScript classes with full type safety). The generated approach eliminates runtime .proto loading and produces typed toJSON() methods that handle Timestamp conversion and zero-value fields correctly.
// Generated approach with @bufbuild/protobuf + @connectrpc/connect
// Run: npx buf generate --template buf.gen.yaml
// This produces typed message classes with toJson() methods
import { createClient } from '@connectrpc/connect';
import { createGrpcTransport } from '@connectrpc/connect-node';
import { OrderService } from './gen/orders/v1/orders_connect.js';
import { GetOrderRequest } from './gen/orders/v1/orders_pb.js';
const transport = createGrpcTransport({
baseUrl: process.env.ORDERS_GRPC_URL!,
httpVersion: '2',
});
const ordersClient = createClient(OrderService, transport);
// MCP tool handler — decodes proto response to plain object
server.tool(
'get_order',
{ order_id: z.string() },
async ({ order_id }) => {
const request = new GetOrderRequest({ orderId: order_id });
const response = await ordersClient.getOrder(request);
// toJson() handles:
// - Timestamp → ISO-8601 string
// - Enum → string name
// - bytes → base64 string
// - optional fields present even at zero value
const orderJson = response.order?.toJson({ enumAsInteger: false });
return {
content: [{ type: 'text', text: JSON.stringify(orderJson, null, 2) }],
};
}
);
// Protobufjs approach (runtime .proto loading) — use when you cannot run codegen
import protobuf from 'protobufjs';
const root = await protobuf.load('./proto/orders.proto');
const Order = root.lookupType('orders.v1.Order');
const GetOrderResponse = root.lookupType('orders.v1.GetOrderResponse');
// After receiving binary from gRPC channel:
async function decodeOrderResponse(binaryPayload: Uint8Array) {
const decoded = GetOrderResponse.decode(binaryPayload);
// toObject() converts Long → number, bytes → Buffer, nested messages → plain objects
return GetOrderResponse.toObject(decoded, {
longs: String, // Long → decimal string (safer than Number for 64-bit)
enums: String, // enum → name string
bytes: String, // bytes → base64 string
defaults: true, // include zero-value fields (critical for MCP tool responses)
arrays: true, // empty repeated fields become [] not undefined
objects: true,
oneofs: true, // include which oneof case is set
});
}
Field number rules — never reuse, always reserve
Proto3 encodes each field by its number, not its name. If you delete field 5 and later add a different field with number 5, any consumer running the old schema will silently decode the new field's bytes using the old field's type, producing corrupt data. The reserved keyword prevents this at the compiler level — the proto compiler rejects any .proto file that uses a reserved field number or name.
// orders/v1/orders.proto — safe field evolution example
syntax = "proto3";
package orders.v1;
import "google/protobuf/timestamp.proto";
message Order {
string order_id = 1;
string user_id = 2;
// Field 3 was "string coupon_code" — removed in v2
// Field 4 was "bool is_priority" — removed in v2
reserved 3, 4;
reserved "coupon_code", "is_priority"; // also reserve names to prevent reuse
repeated LineItem items = 5;
google.protobuf.Timestamp created_at = 6;
OrderStatus status = 7;
// proto3 optional — field 8 will be present on wire even when value is 0
optional int32 retry_count = 8;
// oneof for discriminated result — only one case is present per message
oneof fulfillment {
ShippingDetails shipping = 9;
PickupDetails pickup = 10;
DigitalDelivery digital = 11;
}
}
enum OrderStatus {
ORDER_STATUS_UNSPECIFIED = 0; // proto3 enum must have 0 as first value
ORDER_STATUS_PENDING = 1;
ORDER_STATUS_CONFIRMED = 2;
ORDER_STATUS_SHIPPED = 3;
ORDER_STATUS_DELIVERED = 4;
ORDER_STATUS_CANCELLED = 5;
}
// Safe evolution rules summary:
// ✅ Add new fields (use new numbers only)
// ✅ Rename fields (wire format uses numbers, not names)
// ✅ Add new enum values
// ✅ Change optional → repeated (backward compat with single value)
// ❌ Remove fields without reserving the number AND name
// ❌ Change a field's type
// ❌ Change a field's number
// ❌ Remove enum value 0 (proto3 default)
For MCP tool handlers that call gRPC backends across different deployment versions, forward-compatibility matters as much as backward-compatibility. A new backend may start sending field 12 that the MCP server's proto descriptor doesn't know about — protobufjs and the Buf SDK both preserve unknown fields in a binary buffer by default, so you can safely pass them through without data loss even before redeploying the descriptor.
Timestamp handling — always convert before returning to agents
google.protobuf.Timestamp is encoded as { seconds: number | Long, nanos: number } in protobufjs and as a typed Timestamp class in @bufbuild/protobuf. Both require an explicit conversion step before the value becomes a useful ISO string for agent consumption. Forgetting the conversion causes the tool response to contain a nested object like { "seconds": "1748294400", "nanos": 0 } instead of "2025-05-26T12:00:00Z", which most agent reasoning fails to parse as a date.
import { Timestamp } from '@bufbuild/protobuf/wkt';
// @bufbuild/protobuf Timestamp utilities
function protoTimestampToISO(ts: Timestamp | undefined): string | null {
if (!ts) return null;
return ts.toDate().toISOString();
}
// protobufjs Timestamp (runtime loading) — convert manually
function protobufjsTimestampToISO(ts: { seconds: number | Long; nanos: number } | null | undefined): string | null {
if (!ts) return null;
const seconds = typeof ts.seconds === 'object'
? Number(ts.seconds.toString()) // Long → number
: ts.seconds;
return new Date(seconds * 1000 + Math.floor(ts.nanos / 1_000_000)).toISOString();
}
// Flatten a decoded Order before returning from MCP tool
function flattenOrder(raw: Record<string, unknown>): Record<string, unknown> {
return {
order_id: raw.orderId,
user_id: raw.userId,
status: raw.status, // already a string from { enums: String }
created_at: protobufjsTimestampToISO(raw.createdAt as any),
updated_at: protobufjsTimestampToISO(raw.updatedAt as any),
items: Array.isArray(raw.items) ? raw.items : [],
// Resolve oneof — protobufjs toObject with oneofs:true adds "fulfillment" key
fulfillment_type: (raw.fulfillment as string) ?? null,
fulfillment: raw[(raw.fulfillment as string)] ?? null,
};
}
server.tool('get_order', { order_id: z.string() }, async ({ order_id }) => {
const raw = await fetchOrderFromGrpc(order_id);
const flattened = flattenOrder(raw);
return {
content: [{ type: 'text', text: JSON.stringify(flattened, null, 2) }],
};
});
Health probe — verify proto descriptor at startup
An MCP server that loads .proto files at runtime (protobufjs) can fail silently if the proto file path is wrong or a import dependency is missing — the error only surfaces when the first tool call tries to encode/decode. Run a descriptor verification probe at startup so the server fails fast rather than returning confusing decode errors at request time.
import protobuf from 'protobufjs';
async function verifyProtoDescriptors(): Promise<void> {
const root = await protobuf.load([
'./proto/orders.proto',
'./proto/inventory.proto',
'./proto/users.proto',
]);
// Verify all expected message types are resolvable
const requiredTypes = [
'orders.v1.Order',
'orders.v1.GetOrderRequest',
'orders.v1.GetOrderResponse',
'inventory.v1.Product',
'users.v1.User',
];
for (const typeName of requiredTypes) {
try {
root.lookupType(typeName);
} catch {
throw new Error(`Proto descriptor missing required type: ${typeName}. Check import paths and proto file layout.`);
}
}
console.log(`Proto descriptors verified: ${requiredTypes.length} types OK`);
}
// For @bufbuild/protobuf + gRPC Transport — health probe via reflection or known RPC
import { createGrpcTransport } from '@connectrpc/connect-node';
import { HealthService } from './gen/grpc/health/v1/health_connect.js';
import { HealthCheckRequest } from './gen/grpc/health/v1/health_pb.js';
async function checkGrpcBackendHealth(serviceName: string): Promise<boolean> {
const transport = createGrpcTransport({ baseUrl: process.env.GRPC_URL!, httpVersion: '2' });
const healthClient = createClient(HealthService, transport);
try {
const resp = await healthClient.check(
new HealthCheckRequest({ service: serviceName }),
{ signal: AbortSignal.timeout(3000) }
);
return resp.status === 1; // SERVING
} catch {
return false;
}
}
// Register as MCP resource for AliveMCP monitoring
server.resource('proto_health', 'proto://health', async () => {
await verifyProtoDescriptors();
const grpcOk = await checkGrpcBackendHealth('orders.v1.OrderService');
return {
contents: [{
uri: 'proto://health',
text: JSON.stringify({ proto_descriptors: 'ok', grpc_backend: grpcOk ? 'serving' : 'not_serving' }),
}],
};
});