This is a Topeka plugin for the protoc compiler that generates a model-context-protocol(MCP) server based on a protocol buffer definition. Conceptually, this allows an AI model to use existing gRPC codebases with natural language, allowing for rapid prototyping and usage of LLM capabilities for protobuf based codebases.
This is the Rust counterpart of Topeka's Go plugin
stablekernel/protoc-gen-go-mcp:
for the same .proto, it generates the same tool names, descriptions and
input schemas, on prost,
tonic and pbjson
instead of Go's protobuf/gRPC/protojson stack, and the official
Rust MCP SDK (rmcp) instead of Go's
official MCP SDK.
- The Rust toolchain, pinned in
rust-toolchain.toml(checked against the factory image's installedrustc --version/cargo --versionwhen that file was added;rustuppicks up the pin automatically once you're in this repository). - protoc 29.3 (the version
this plugin is tested against; see
MakefileandAGENTS.md). protoc-gen-prost0.5.0,protoc-gen-tonic0.5.0 andprotoc-gen-prost-serde0.4.0 onPATH, the threeprotocplugins that generate the prost/tonic/pbjson code this plugin's own output sits next to:cargo install --locked protoc-gen-prost@0.5.0 protoc-gen-tonic@0.5.0 protoc-gen-prost-serde@0.4.0
The generated code itself (not this plugin) depends on these crates at
runtime; regenerating with different versions changes the generated API,
so document any change in your PR, same as this repository's own
AGENTS.md asks of its committed example:
| crate | version | why |
|---|---|---|
prost, prost-types |
0.14 | message types the generated tool arguments/results decode into |
tonic |
0.14 | the gRPC client the generated handler forwards tool calls to |
tonic-prost |
0.14 | tonic's prost codec, needed by protoc-gen-tonic's own output |
pbjson, pbjson-types |
0.8.x (pinned, to match the pbjson-build protoc-gen-prost-serde 0.4.0 uses) |
the protojson-compatible JSON (de)serialization tool arguments/results use |
rmcp, features server + whatever transport you serve over (e.g. transport-io for stdio, transport-streamable-http-server for Streamable HTTP) |
3.5 | the official Rust MCP SDK the generated server implements ServerHandler against |
serde_json |
1 | the JsonObject/Value types tool arguments and results pass through |
jsonschema, default-features = false (every generated schema is self-contained, so no HTTP/file $ref resolution is needed) |
0.58 | validates a tool call's raw arguments against its input schema before decoding, the validation Go's SDK does and rmcp itself does not |
futures |
0.3 | BoxFuture, the generated tool handler's return type |
See examples/Cargo.toml for the exact feature
sets the committed example server (mcp-vibe) needs on top of this.
Check out the Makefile for explicit command usage. Use
make generate to regenerate the example MCP server from the
proto file with the real protoc
pipeline (protoc-gen-prost, protoc-gen-tonic, protoc-gen-prost-serde,
then this plugin).
Outside make generate, invoke protoc directly, pointing --rust- mcp_out at the same directory as the other three plugins' output (this
plugin's generated <package>.mcp.rs is spliced into prost's own
<package>.rs via an include!, the same convention
protoc-gen-tonic/protoc-gen-prost-serde use for their own output, so
they must share a directory and run against the same --proto_path):
protoc \
--proto_path=examples/protos \
--prost_out=examples/src/gen \
--tonic_out=examples/src/gen \
--prost-serde_out=examples/src/gen \
--plugin=protoc-gen-rust-mcp=target/debug/protoc-gen-rust-mcp \
--rust-mcp_out=examples/src/gen \
examples/protos/example.protoThe result is one <package>.rs per proto package (prost's own output,
with the other three plugins' include! lines spliced in at its
// @@protoc_insertion_point(module) marker) plus this plugin's own
standalone <package>.mcp.rs. A crate that vendors generated code
include!s just the top-level <package>.rs (see
examples/src/lib.rs):
pub mod v1 {
include!("gen/examples/v1/examples.v1.rs");
}Like any protoc plugin, protoc-gen-rust-mcp reads an encoded
CodeGeneratorRequest on stdin and writes an encoded
CodeGeneratorResponse to stdout, so you can't attach a debugger to it
while protoc is driving it (there's no interactive terminal to attach
through, and protoc doesn't wait around). Instead, save the request
once and replay it as many times as you like outside protoc:
protoc --proto_path=examples/protos \
--plugin=protoc-gen-debug=./protoc-gen-debug \
--debug_out=. examples/protos/example.protoprotoc-gen-debug is a tiny script that saves
whatever protoc sends it on stdin to request.bin in the current
directory (the counterpart of Go's own protoc-gen-debug, which instead
starts dlv --headless; Rust's debuggers don't have a direct equivalent
of that, hence the two-step version here). protoc reports an error
afterwards (--debug_out never gets a real CodeGeneratorResponse back),
but the request is already saved by then.
Replay it directly:
cargo build -p protoc-gen-rust-mcp
./target/debug/protoc-gen-rust-mcp < request.bin > /dev/nullor under a real debugger. rustup installs the
rust-lldb/rust-gdb
wrapper scripts (pretty-printer-aware front ends for a real lldb/gdb)
alongside rustc by default, so if your machine has lldb or gdb
itself installed (neither is in the minimal factory image this repo's
AGENTS.md checks run in — install one to use this locally):
rust-lldb -- ./target/debug/protoc-gen-rust-mcp < request.bin
# or: rust-gdb -- ./target/debug/protoc-gen-rust-mcp < request.binor in VS Code with the
CodeLLDB
extension (which bundles its own LLDB, so it needs no separate lldb
install) and the committed .vscode/launch.json
(Debug protoc-gen-rust-mcp (replay saved request)), which builds the
plugin and redirects request.bin to its stdin for you.
Install the example mcp-vibe server:
cargo install --path examples --bin mcp-vibeAdd the mcp-vibe server to your mcp servers:
{
"mcpServers": {
"vibe": {
"command": "mcp-vibe"
}
}
}and run a client with above mcp server attached (eg. claude desktop)
A generated <Service>McpServer<T> wraps a tonic <Service>Client<T> and
implements rmcp::ServerHandler
directly (see protoc-gen-rust-mcp/src/server.rs's
module doc comment for why it's hand-implemented rather than built on
rmcp's own ToolRouter). Once you've called register_default_tools()
(or registered individual tools, see below), serve it the way any
rmcp::ServerHandler is served. The two most common transports (from
examples/tests/wiring.rs, compiled by
cargo test — see that file's doc comment):
// client is an examples::v1::vibe_service_client::VibeServiceClient for
// the backend gRPC service, e.g. VibeServiceClient::new(channel) for a
// tonic::transport::Channel.
// Over stdio, for CLI-launched MCP clients (see
// examples/src/bin/mcp-vibe.rs for the full, runnable version of this).
let mut server = VibeServiceMcpServer::new(client)
.with_server_info(rmcp::model::Implementation::new("vibe", "0.0.1"));
server.register_default_tools();
let running = server.serve(rmcp::transport::io::stdio()).await?;
running.waiting().await?;// Over Streamable HTTP, for network clients: rmcp's StreamableHttpService
// is a tower::Service, wired into a hyper server here via hyper-util's
// TowerToHyperService adapter (the same pairing rmcp's own
// examples/servers/src/counter_hyper_streamable_http.rs uses). client is
// the same VibeServiceClient as above; the service factory closure below
// builds a fresh VibeServiceMcpServer per session.
let service = TowerToHyperService::new(StreamableHttpService::new(
move || {
let mut server = VibeServiceMcpServer::new(client.clone())
.with_server_info(rmcp::model::Implementation::new("vibe", "0.0.1"));
server.register_default_tools();
Ok::<_, std::io::Error>(server)
},
LocalSessionManager::default().into(),
Default::default(),
));
let listener = tokio::net::TcpListener::bind("127.0.0.1:8080").await?;
loop {
let (stream, _) = listener.accept().await?;
let io = TokioIo::new(stream);
let service = service.clone();
tokio::spawn(async move {
let _ = Builder::new(TokioExecutor::default())
.serve_connection(io, service)
.await;
});
}Both snippets are kept compiling in
examples/tests/wiring.rs (serve_stdio,
serve_streamable_http), so they stay in sync with the generated API;
cargo build --tests/cargo test cover that file on every run of the
checks (it is a tests/*.rs file, so plain cargo build
skips it).
Each tool's inputSchema is the JSON Schema
for its RPC's request message, and the generated handler decodes incoming
arguments with the pbjson-generated Deserialize
(serde_json::from_value), the counterpart of Go's
protojson.Unmarshal.
Schema validation runs first (see
protoc-gen-rust-mcp/src/server.rs's
module doc comment): the raw arguments are checked against a
jsonschema::Validator compiled from the same schema embedded in the
tool's inputSchema, the validation Go's SDK does via mcp.AddTool and
rmcp itself does not. On failure every failing instance path and reason
is listed in the tool error text (e.g. invalid arguments: /vibe: 5 is not of type "string"), and the backend is never called. That means
tool arguments follow pbjson's mapping (matching protojson on every point
documented below), not the proto field names or Rust struct field names:
- Field names are the field's JSON name (lowerCamel by default, e.g. a
proto field
previous_vibeis the JSON keypreviousVibe), not the original snake_case proto name. The schema'sadditionalProperties: falserejects an unrecognized key — including the snake_case form — as a tool error rather than silently ignoring or coercing it (e.g.invalid arguments: : Additional properties are not allowed ('previous_vibe' was unexpected)). - 64-bit integers (
int64,uint64,sint64,fixed64,sfixed64) are accepted as either a JSON number or a decimal string ("type": ["integer", "string"]), matching protojson's own encoding of them as strings (to avoid precision loss in JSON's number type). All other integer kinds are plain JSON numbers. - Enum fields are their value's name as a string (e.g.
"VIBE_GOOD"), not the underlying numeric value, matching protojson's default enum encoding. bytesfields are base64-encoded strings, per protojson'sbytesmapping.float/doublefields also accept the strings"NaN","Infinity"and"-Infinity"(in addition to a JSON number), matching protojson's encoding of those non-finite values (a plain JSON number can't represent them) — see Differences from protoc-gen-go-mcp below for the one place this stops matching protojson: non-finite values in a tool result.- Tool call results are pbjson-encoded the same way, so a result's keys
and enum/int64 representations follow the same rules — including
that pbjson, like protojson, omits zero-valued fields (an empty
string,
0,false, an unset enum, etc.) from the result entirely, rather than emitting them explicitly. - A gRPC error from the backend becomes a tool error (
isError: true) whose text matches grpc-go'sStatus.Error()format:rpc error: code = NotFound desc = vibe not found(tonic::Code'sDebugimpl happens to render the same CamelCase names grpc-go uses, confirmed againsttonic::Code0.14.6).
This plugin's generated code uses pbjson, not protojson, for tool arguments and results. The two agree on every point in the previous section, with these documented exceptions:
google.protobuf.Anyis pbjson-shaped, not protojson-shaped:{"typeUrl": "...", "value": "<base64>"}(the plain two-field messageAnyis on the wire), not protojson's unpacked-message-plus-injected-"@type"representation. pbjson has no special case forAny(seeprotoc-gen-rust-mcp/src/schema.rs'sany_schemaandprotoc-gen-rust-mcp/src/server.rs's module doc comment for exactly what pbjson emits).- Non-finite floats (
NaN/Infinity/-Infinity) in tool results come out as JSONnull, not the strings protojson emits. Both pbjson and protojson accept all three spellings as tool call input (see the previous section's float/double rule); this divergence is output-only. Seeschema.rs'sfloat_schemaandexamples/tests/handler.rs'snon_finite_floats_in_result_are_encoded_as_json_null. - No editions support:
this plugin only advertises
FEATURE_PROTO3_OPTIONAL, notFEATURE_SUPPORTS_EDITIONS(seegenerator.rs'sFEATURE_PROTO3_OPTIONALdoc comment for why — in short, neitherprost-reflectnorprotoc-gen-prostsupport editions files yet, so this stack can't generate editions code regardless of what this plugin advertises).protocrejects an editions input file with a clear error instead of this plugin panicking on it.
One more well-known type gets special JSON handling, but it's made to
match Go rather than diverge from it: google.protobuf.FieldMask's
schema is {"type": "string"} and its wire representation is protojson's
comma-joined lowerCamel-path string, even though pbjson's own
Serialize/Deserialize for it would otherwise be {"paths": [...]}.
Generated per-message code rewrites a FieldMask field's JSON between the
two shapes, between schema validation and the pbjson decode/encode (see
protoc-gen-rust-mcp/src/field_mask.rs).
The plugin uses the existing code generation for protocol buffers and gRPC servers and builds upon that base, using and reusing parts where necessary. This gives us a healthy amount of code reuse while allowing us to control what we expose to end users. We want this plugin to provide sane, out-of-the-box functionality while allowing for easy extension.
How is this achieved?
The code is broken into composable parts:
- The
protoc-gen-rust-mcpplugin generates default tools based on the request parameters for any given RPC. eg:
message SetVibeRequest {
string vibe = 1;
}
message SetVibeResponse {
string previous_vibe = 1;
string vibe = 2;
}
service VibeService {
// Set Vibe
rpc SetVibe(SetVibeRequest) returns (SetVibeResponse) {}
}This snippet defines the SetVibe RPC, which takes a SetVibeRequest message and contains a definition of the request parameter SetVibeRequest message. The plugin generates the following tool by default:
// VIBE_SERVICE_SET_VIBE_INPUT_SCHEMA is a package-level constant holding
// the JSON Schema derived from SetVibeRequest.
impl<T> VibeServiceMcpServer<T> {
pub fn set_vibe_tool() -> rmcp::model::Tool {
rmcp::model::Tool::new(
"SetVibe",
"Set Vibe",
VIBE_SERVICE_SET_VIBE_INPUT_SCHEMA.clone(),
)
}
}This tool can be subsequently registered with the server to make the RPC available to the model.
- The
protoc-gen-rust-mcpplugin generates a default handler that interacts with a generated gRPC client for interaction with this server to parse the tool call arguments into a defined gRPC request leveraging a generated client.
impl<T> VibeServiceMcpServer<T> {
async fn call_set_vibe(
mut client: vibe_service_client::VibeServiceClient<T>,
args: Option<rmcp::model::JsonObject>,
) -> rmcp::model::CallToolResult {
// ... validate args against the schema, decode, call the client, encode the response
}
}- These two pieces are combined upon registration to provide the LLM with knowledge of the RPC method and how to use them.
register_default_toolsbuilds each tool's handler from a clone of the server's own client, captured once per registration rather than once per call:
impl<T> VibeServiceMcpServer<T> {
pub fn register_default_tools(&mut self) -> &mut Self {
// ...other tools registered above
{
let client = self.client.clone();
self.register_tool(Self::set_vibe_tool(), move |args| {
Self::call_set_vibe(client.clone(), args)
});
}
// ...other tools registered below
self
}
}Every registered tool's arguments are validated against its input schema before the handler's decode step runs (see How tool arguments map to the request message above) — this plugin's own generated code does that validation itself, since (unlike Go's SDK) rmcp does not validate incoming arguments against a tool's schema.
Topeka is an open source project that provides code-generators for Model-Context-Protocol (MCP). It is designed to facilitate the usage of MCP seamlessly against existing gRPC based applications. This is done via leveraging code generation using the protoc compiler and installing the relevant Topeka plugin.
The plugins follow Semantic Versioning and any plugin prior to 1.0.0 releases ARE still subject to breaking changes. Please note, this is applied to the generated servers, not the plugins themselves, which do not provide public APIs. This project reserves the right to change how code generation is achieved, while maintaining stable MCP server APIs.
Stable Kernel is the primary maintainer of this project and sponsor of the plugins, though we welcome outside contributions.
Stable Kernel is a digital transformation company building solutions that power LLM enablement for growing businesses. We have a track record of helping our partners solve their biggest challenges on their digital journey, whether they need insights or implementation. Every day, millions of people rely on software that we developed, and our custom software development and technology services have been trusted by some of the most innovative Fortune 500 companies in the world.