protoc-gen-jsonschema
Define MCP tool schemas in Protobuf. Convert Protocol Buffers messages into JSON Schema — as a Go library (dynamic) or a protoc/buf plugin (static).
简体中文 · English
Table of Contents
- Features
- Install
- Quick start
- Plugin options
- Schema options
- Using with an LLM
- Performance
- Development
- License
Features
- Define MCP tool schemas in proto — generate the JSON Schema an MCP server
advertises as a tool's
inputSchemastraight from your message definitions. - Define JSON Schema constraints with Protobuf extension options.
- Two modes: dynamic generation (library) and static generation (plugin).
- Field-level and message-level options.
- Static output as JSON files or zero-overhead Go constants.
- High performance: ~8 μs dynamic, ~0.3 ns static access.
Install
One-line install (no Go toolchain required):
curl -fsSL https://raw.githubusercontent.com/sunerpy/protoc-gen-jsonschema/main/scripts/install.sh | sh
Windows (PowerShell):
irm https://raw.githubusercontent.com/sunerpy/protoc-gen-jsonschema/main/scripts/install.ps1 | iex
Override the version or destination:
PGJ_VERSION=0.0.7 PGJ_INSTALL_DIR=/usr/local/bin \
curl -fsSL https://raw.githubusercontent.com/sunerpy/protoc-gen-jsonschema/main/scripts/install.sh | sh
Prebuilt binary (no pipe-to-shell):
Download a prebuilt archive for your platform from the
Releases page,
extract it, and put protoc-gen-jsonschema on your PATH.
Go library:
go get github.com/sunerpy/protoc-gen-jsonschema
Plugin binary via Go:
go install github.com/sunerpy/protoc-gen-jsonschema/cmd/protoc-gen-jsonschema@latest
Make sure $(go env GOPATH)/bin is on your PATH.
Proto extensions via the Buf Schema Registry (BSR):
The extension definitions are published at
buf.build/sunerpy/protoc-gen-jsonschema.
Add it as a dependency in your buf.yaml:
version: v2
deps:
- buf.build/sunerpy/protoc-gen-jsonschema
Then run buf dep update.
Quick start
As a Go library (dynamic)
Annotate a message:
syntax = "proto3";
package example;
import "mcp/jsonschema/jsonschema.proto";
message UserRequest {
option (mcp.jsonschema.title) = "User Request";
string email = 1 [
(mcp.jsonschema.required) = true,
(mcp.jsonschema.format) = "email"
];
string name = 2 [
(mcp.jsonschema.required) = true,
(mcp.jsonschema.min_length) = 3,
(mcp.jsonschema.max_length) = 50
];
int32 age = 3 [
(mcp.jsonschema.minimum) = 18,
(mcp.jsonschema.maximum) = 120
];
}
Generate at runtime:
package main
import (
"encoding/json"
"fmt"
"log"
pb "example/pb"
"github.com/sunerpy/protoc-gen-jsonschema"
)
func main() {
schema, err := jsonschema.GenerateFromMessage(&pb.UserRequest{})
if err != nil {
log.Fatal(err)
}
out, _ := json.MarshalIndent(schema, "", " ")
fmt.Println(string(out))
}
As a protoc/buf plugin (static)
JSON files (default):
protoc --jsonschema_out=. user.proto
Go constants (zero-overhead, recommended for hot paths):
protoc --go_out=. --jsonschema_out=format=go_const:. user.proto
buf.gen.yaml:
version: v2
plugins:
- remote: buf.build/protocolbuffers/go
out: pb
opt:
- paths=source_relative
- local: protoc-gen-jsonschema
out: pb
opt:
- format=go_const
- paths=source_relative
The generated *_jsonschema.pb.go exposes zero-overhead accessors:
msg := &pb.UserRequest{}
schema := msg.GetJSONSchema() // string, ~0.29 ns
bytes := msg.GetJSONSchemaBytes() // []byte, HTTP-ready
raw := msg.GetJSONSchemaRawMessage() // json.RawMessage
Plugin options
| Option | Default | Description |
|---|---|---|
format |
json |
Output format: json or go_const. |
suffix |
_jsonschema |
Go file suffix (go_const only). |
paths |
— | source_relative or import. |
preserve_order |
false |
Preserve proto field order in the schema (go_const only). |
schema_struct |
false |
Also emit a jsonschema.Schema struct literal (go_const only). |
google_schema |
false |
Also emit a github.com/google/jsonschema-go struct literal (go_const only). |
expand_messages |
false |
Expand message-typed fields into $refs pointing at a top-level $defs. |
Schema options
Field options (mcp.jsonschema.*):
| Option | Type | Description |
|---|---|---|
required |
bool | Mark the field as required. |
description |
string | Field description. |
example |
string | Example value. |
format |
string | Format constraint (e.g. email, date-time). |
pattern |
string | Regular expression. |
min_length / max_length |
int32 | String length bounds. |
minimum / maximum |
double | Numeric bounds. |
default |
string | Default value (JSON-encoded). |
hidden |
bool | Exclude the field from the schema. |
json_name |
string | Override the JSON field name. |
Message options (mcp.jsonschema.*):
| Option | Type | Description |
|---|---|---|
title |
string | Schema title. |
message_description |
string | Schema description. |
generate_schema |
bool | Set false to skip generation for this message. |
Note on
google.protobuf.Timestamp: when a Timestamp appears as a field, the generated schema uses aoneOfaccepting both an RFC3339 string and a{seconds, nanos}object. The proto runtime (protojson) itself only accepts the RFC3339 string form; the object branch targets generic JSON consumers.
Note on
mapfields: amap<K, V>field becomes an object whoseadditionalPropertiescarries the schema of the value type, not an array.map<string, string> labels = 1generates:{ "labels": { "type": "object", "additionalProperties": { "type": "string" } } }The value type maps as follows:
Map value type additionalPropertiesstring{"type": "string"}int32/int64/uint32/uint64/sint*/fixed*/sfixed*{"type": "integer"}float/double{"type": "number"}bool{"type": "boolean"}bytes{"type": "string", "format": "byte"}enum {"type": "string", "enum": [...]}google.protobuf.Timestampthe same oneOfas a Timestamp fieldany other message {"type": "object"}Three boundaries worth knowing:
- A non-string key produces no constraint.
map<int32, V>andmap<string, V>generate the same shape, and nopropertyNameskeyword is emitted. The proto JSON mapping already serializes non-string keys as strings, and JSON Schema consumers supportpropertyNamesinconsistently.- A message value is not expanded recursively — it stays
{"type": "object"}, matching how a plain (non-map) message field is already handled.- Field options written on the map field (
description,example,format, …) apply to the outer object schema; they do not sink intoadditionalProperties.
Note on message-typed fields: by default a field whose type is another message generates a bare
{"type": "object"}— the referenced message's own fields do not appear at the reference site. This holds for a single message field, arepeatedmessage field, and amapvalue.Pass the
expand_messages=trueplugin option to emit a$refinstead. Each referenced message is collected into a top-level$defs:message Inner { string id = 1; } message Req { Inner single = 1; }generates:
{ "type": "object", "title": "Req", "properties": { "single": { "$ref": "#/$defs/mypkg.Inner" } }, "$defs": { "mypkg.Inner": { "type": "object", "title": "Inner", "properties": { "id": { "type": "string" } } } } }Details worth knowing:
$defskeys are proto full names (mypkg.Inner), so two messages with the same short name in different packages do not collide.- Only messages actually reached from the schema get a
$defsentry.- Self-referencing (
message Node { Node next = 1; }) and mutually referencing messages work — a$refnames a definition rather than inlining it, so there is nothing to expand infinitely.google.protobuf.Timestampkeeps its existingoneOfand does not get a$defsentry.- A message field carrying options emits them next to the
$ref, e.g.{"$ref": "#/$defs/mypkg.Inner", "description": "..."}.$refalongside sibling keywords is permitted from JSON Schema draft 2019-09 onward; a draft-07 or older validator ignores the siblings and honours only the$ref.For the full consumer-facing write-up of map and message schema shapes, see docs/MAP-AND-MESSAGE-SCHEMA.md.
Using with an LLM
The Model Context Protocol (MCP) requires each
tool to advertise an inputSchema — a JSON Schema describing its arguments. This
plugin lets you define that schema in Protobuf and generate it, so your proto
message is the single source of truth for the tool contract: constraints
(required, format, pattern, min_length, minimum, …) become JSON Schema
keywords the LLM uses to produce valid arguments.
message SearchToolArgs {
option (mcp.jsonschema.title) = "search";
option (mcp.jsonschema.message_description) = "Full-text search over the corpus";
string query = 1 [
(mcp.jsonschema.required) = true,
(mcp.jsonschema.min_length) = 1,
(mcp.jsonschema.description) = "The search query"
];
int32 limit = 2 [
(mcp.jsonschema.minimum) = 1,
(mcp.jsonschema.maximum) = 100,
(mcp.jsonschema.example) = "10"
];
}
Generate the schema once at build time, then hand GetJSONSchemaBytes() to your
MCP server as the tool's inputSchema — no hand-written, drift-prone JSON.
Install in one line, then drive the plugin from an agent:
curl -fsSL https://raw.githubusercontent.com/sunerpy/protoc-gen-jsonschema/main/scripts/install.sh | sh
protoc --jsonschema_out=. user.proto— emit JSON Schema files for the messages.protoc --go_out=. --jsonschema_out=format=go_const:. user.proto— emit zero-overhead Go constants (GetJSONSchema()/GetJSONSchemaBytes()), ideal as an MCP tool'sinputSchema.- For runtime use, call
jsonschema.GenerateFromMessage(msg)— returns a schema ready tojson.Marshal.
The generated schema is plain JSON (machine-readable); the plugin exits non-zero with diagnostics on stderr when a message or option is invalid.
Performance
Static access is ~29,000× faster than dynamic generation, and a JSON string constant is HTTP-ready with zero serialization cost. See docs/PERFORMANCE.md for the full comparison.
Development
make help # list targets
make fmt # format Go + non-Go files
make lint # golangci-lint
make test # run tests
make buf-lint # lint proto files
make check # fmt-check + lint + buf-lint + test
make hooks # install git hooks (fmt+lint on commit, test on push)
Commit messages follow Conventional Commits
— feat:, fix:, feat!: for breaking changes — which drive automated version
bumps and the changelog.
Releases are automated via release-please:
merging the release PR cuts a v* tag, which triggers GoReleaser to build
multi-platform binaries and publish the GitHub Release. The proto module is
published to the BSR via buf push. The in-repo changelog lives in
changelog/, split by major version.
No comments yet
Be the first to share your take.