Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 38 additions & 9 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ resolver = "3"
members = [
"protoc-gen-rust-mcp",
"examples",
"test-fixtures",
]

[workspace.package]
Expand Down
9 changes: 9 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,12 @@ generate: protoc-gen-rust-mcp
--plugin=protoc-gen-rust-mcp=target/debug/protoc-gen-rust-mcp \
--rust-mcp_out=$(GENDIR) \
$(PROTOS) && echo "Generated successfully!"
@# protoc-gen-rust-mcp already runs its *.mcp.rs output through
@# prettyplease (see generator.rs's format_rust), the same formatter
@# prost-build/tonic-build use for their own output, but prettyplease's
@# line-wrapping rules differ from rustfmt's on a few constructs (e.g.
@# chained method calls), so its output is not always identical to
@# `rustfmt --check`'s expectations (#22's acceptance criterion). Run
@# the real rustfmt over just the *.mcp.rs files as a final pass to
@# guarantee that.
@rustfmt --edition 2024 $(shell find $(GENDIR) -name '*.mcp.rs')
492 changes: 292 additions & 200 deletions examples/src/gen/examples/v1/examples.v1.mcp.rs

Large diffs are not rendered by default.

41 changes: 15 additions & 26 deletions protoc-gen-rust-mcp/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,16 +21,29 @@ prost-types = "0.14"
prost-reflect = "0.16"
serde_json = "1"
heck = "0.5"
# Formats generator.rs's output with the same approach prost-build and
# tonic-build use for their own generated code (see
# prost_build::Config::format's prettyplease::unparse call): a normal
# dependency, not a build-dependency, since it runs inside this crate's
# own library code (generator::generate), not a build script (#22: this
# crate has no build.rs at all; see test-fixtures/ for the proto
# compilation this crate's *tests* need).
syn = { version = "3", default-features = false, features = ["parsing"] }
prettyplease = "0.3"

[dev-dependencies]
jsonschema = { version = "0.58", default-features = false }
pretty_assertions = "1.4"
protox = "0.10.0"
rand = "0.10"
pbjson = "0.8"
pbjson-types = "0.8"
serde = { version = "1", features = ["derive"] }
syn = { version = "3", features = ["parsing"] }
# Compiles tests/testdata/{schemapb,mcpgen} (via its own build.rs) and
# runs this crate's own generator against the mcpgen fixture with real
# tonic-generated/pbjson-generated types, so schema.rs's and
# server_integration.rs's tests don't need this crate's own build.rs to
# do that work (#22).
test-fixtures = { path = "../test-fixtures" }

[dev-dependencies.rmcp]
version = "3.5"
Expand All @@ -41,34 +54,10 @@ features = ["server", "client", "transport-io"]
version = "1"
features = ["macros", "rt-multi-thread"]

[dev-dependencies.futures]
version = "0.3"

[dev-dependencies.tonic]
version = "0.14"
default-features = false
# "server" + "router" run a real in-process tonic server in
# tests/server_integration.rs, so the generated handler body (#7) is
# exercised against a real backend, not just a never-connecting client.
features = ["codegen", "channel", "server", "router"]

[dev-dependencies.tonic-prost]
version = "0.14"

[build-dependencies]
# Used only by build.rs, to compile tests/testdata/schemapb/schema.proto
# (and tests/testdata/mcpgen/fixture.proto, for server.rs's integration
# test) into pbjson-generated and tonic-generated Rust types, and (for the
# mcpgen fixture) to run this crate's own code generator: build.rs
# `#[path]`-includes src/{comments,rust_literal,schema,server,
# source_info}.rs directly (a build script can't depend on the library
# it's building), so every crate those modules import needs to be
# available here too, not just under [dependencies].
protox = "0.10"
prost-build = "0.14"
prost-reflect = "0.16"
prost-types = "0.14"
pbjson-build = "0.8"
tonic-prost-build = { version = "0.14", default-features = false }
heck = "0.5"
serde_json = "1"
48 changes: 42 additions & 6 deletions protoc-gen-rust-mcp/src/generator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -133,17 +133,29 @@ fn generate_package(
let mcp_filename = format!("{package}.mcp.rs");
let main_filename = format!("{package}.rs");

// Every service's generated code (`server::generate_service`'s raw,
// not-yet-formatted output), concatenated, then run through
// `prettyplease` *before* the header is prepended: the same order
// prost-build itself uses for its own `// This file is @generated by
// prost-build.` banner (`Config::format`/`add_generated_modules`),
// because `syn::parse_file` would otherwise swallow the header's
// plain `//` line comments (`syn` only preserves comments that are
// doc comments or attached to an AST node it round-trips, neither of
// which a file's very first lines are).
let mut body = String::new();
for (i, service) in services.iter().enumerate() {
if i > 0 {
body.push('\n');
}
body.push_str(&server::generate_service(service));
}

// Starts with the same DO-NOT-EDIT banner and versions block every
// other generated file in this crate starts with (#4's `file_header`),
// the counterpart of Go's `example_mcp.pb.go` header.
let mut content = file_header(env!("CARGO_PKG_VERSION"), compiler_version).join("\n");
content.push_str("\n\n");
for (i, service) in services.iter().enumerate() {
if i > 0 {
content.push('\n');
}
content.push_str(&server::generate_service(service));
}
content.push_str(&format_rust(&body));

vec![
File {
Expand All @@ -160,6 +172,30 @@ fn generate_package(
]
}

/// Formats a generated Rust source fragment with `prettyplease`, the same
/// formatter `prost-build` and `tonic-build` use for their own output (see
/// `prost_build::Config::format`, which this generator previously had no
/// counterpart for, and `tonic_build::manual::Method`'s codegen): `syn`
/// parses `rust` as a complete file (every item `server::generate_service`
/// emits — `static`s, a type alias, a struct, its two `impl` blocks — is
/// valid at file scope on its own, since the generated `.mcp.rs` is
/// `include!`d directly into the package's module), then `prettyplease`
/// re-renders it at the usual ~100-column width, replacing the
/// hand-written `format!` calls' own ad hoc indentation (#22: those often
/// produced 100+-column lines, like the embedded-schema `static`s' single
/// line each).
///
/// Panics if `rust` does not parse as a `syn::File`: every caller passes
/// this generator's own output, so a parse failure here means a bug in
/// `server::generate_service`, not malformed user input — the same
/// contract `prost_build::Config::format`'s own `syn::parse_file(buf)
/// .unwrap()` relies on.
fn format_rust(rust: &str) -> String {
let file: syn::File = syn::parse_str(rust)
.unwrap_or_else(|e| panic!("generated code is not valid Rust: {e}\n\n{rust}"));
prettyplease::unparse(&file)
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down
30 changes: 10 additions & 20 deletions protoc-gen-rust-mcp/src/schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -435,38 +435,28 @@ fn leading_comment(path: &[i32], file: &prost_types::FileDescriptorProto) -> Str
process_comment_to_string(raw)
}

/// The pbjson-generated Rust types for `tests/testdata/schemapb/schema.proto`
/// (compiled by `build.rs`), used by the tests below to validate generated
/// schemas against real pbjson-serialized messages: the counterpart of Go's
/// `schema_test.go` validating against `protojson`-marshaled messages.
#[cfg(test)]
#[allow(
missing_docs,
clippy::all,
clippy::pedantic,
dead_code,
unreachable_pub
)]
mod schemapb {
include!(concat!(env!("OUT_DIR"), "/schemapb.rs"));
include!(concat!(env!("OUT_DIR"), "/schemapb.serde.rs"));
}

#[cfg(test)]
mod tests {
use super::schemapb;
use super::*;
use prost_reflect::DescriptorPool;
use serde_json::Map as JsonMap;
use std::path::PathBuf;
// The pbjson-generated Rust types for test-fixtures'
// `testdata/schemapb/schema.proto` (compiled by its own build.rs; see
// #22), used below to validate generated schemas against real
// pbjson-serialized messages: the counterpart of Go's
// `schema_test.go` validating against `protojson`-marshaled messages.
use test_fixtures::schemapb;

/// Compiles `cmd/protoc-gen-go-mcp/testdata/schemapb/schema.proto`
/// (vendored under `tests/testdata/schemapb/`) and returns the
/// (vendored under `test-fixtures/testdata/schemapb/`) and returns the
/// `MessageDescriptor` for `message_name`. Uses `protox`, a pure-Rust
/// protobuf compiler, so `cargo test` never depends on network access
/// or an installed `protoc`.
fn load_message(message_name: &str) -> MessageDescriptor {
let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/testdata/schemapb");
let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("test-fixtures/testdata/schemapb");
let fds = protox::compile(["schema.proto"], [dir]).expect("compiling schema.proto");
let pool = DescriptorPool::from_file_descriptor_set(fds).expect("building descriptor pool");
pool.get_message_by_name(&format!("schemapb.{message_name}"))
Expand Down
10 changes: 6 additions & 4 deletions protoc-gen-rust-mcp/src/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -138,15 +138,17 @@ pub fn generate_service(service: &ServiceDescriptor) -> String {

let mut out = String::new();

// A doc comment (`///`), not a plain `//` one: `format_rust`
// (`generator.rs`) runs this whole module through `prettyplease`,
// which parses it as a `syn::File` and only keeps comments that are
// doc comments or attached to an AST node it round-trips — a plain
// comment here would silently vanish (#22).
for name in &skipped {
let _ = writeln!(
out,
"// Skipping {name}: client- or server-streaming RPCs are not supported as MCP tools."
"/// Skipping {name}: client- or server-streaming RPCs are not supported as MCP tools."
);
}
if !skipped.is_empty() {
out.push('\n');
}

for method in &unary_methods {
out.push_str(&schema_constant(service_name, method));
Expand Down
Loading
Loading