diff --git a/Cargo.lock b/Cargo.lock index f2bd66f..981b9d0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1069,6 +1069,16 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "prettyplease" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bfe0f4c752e450fc2faf62654f1c134747922825d5b04ca717b8874f41a40c0" +dependencies = [ + "proc-macro2", + "syn 3.0.6", +] + [[package]] name = "proc-macro2" version = "1.0.107" @@ -1099,7 +1109,7 @@ dependencies = [ "log", "multimap", "petgraph", - "prettyplease", + "prettyplease 0.2.37", "prost", "prost-types", "regex", @@ -1145,15 +1155,12 @@ dependencies = [ name = "protoc-gen-rust-mcp" version = "0.1.0" dependencies = [ - "futures", "heck", "jsonschema", - "pbjson", - "pbjson-build", "pbjson-types", "pretty_assertions", + "prettyplease 0.3.0", "prost", - "prost-build", "prost-reflect", "prost-types", "protox", @@ -1162,10 +1169,9 @@ dependencies = [ "serde", "serde_json", "syn 3.0.6", + "test-fixtures", "tokio", "tonic", - "tonic-prost", - "tonic-prost-build", ] [[package]] @@ -1525,6 +1531,29 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "test-fixtures" +version = "0.1.0" +dependencies = [ + "futures", + "jsonschema", + "pbjson", + "pbjson-build", + "pbjson-types", + "prost", + "prost-build", + "prost-reflect", + "prost-types", + "protoc-gen-rust-mcp", + "protox", + "rmcp", + "serde", + "serde_json", + "tonic", + "tonic-prost", + "tonic-prost-build", +] + [[package]] name = "thiserror" version = "2.0.21" @@ -1631,7 +1660,7 @@ version = "0.14.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c68f61875ac5293cf72e6c8cf0158086428c82c37229e98c840878f1706b0322" dependencies = [ - "prettyplease", + "prettyplease 0.2.37", "proc-macro2", "quote", "syn 2.0.119", @@ -1654,7 +1683,7 @@ version = "0.14.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "654e5643eff75d7f8c99197ce1440ed19a3474eada74c12bbac488b2cafdae27" dependencies = [ - "prettyplease", + "prettyplease 0.2.37", "proc-macro2", "prost-build", "prost-types", diff --git a/Cargo.toml b/Cargo.toml index 8cdcf33..ef99730 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,6 +3,7 @@ resolver = "3" members = [ "protoc-gen-rust-mcp", "examples", + "test-fixtures", ] [workspace.package] diff --git a/Makefile b/Makefile index 63c8bc1..847ef56 100644 --- a/Makefile +++ b/Makefile @@ -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') diff --git a/examples/src/gen/examples/v1/examples.v1.mcp.rs b/examples/src/gen/examples/v1/examples.v1.mcp.rs index fcbfcf5..a0d692d 100644 --- a/examples/src/gen/examples/v1/examples.v1.mcp.rs +++ b/examples/src/gen/examples/v1/examples.v1.mcp.rs @@ -3,91 +3,106 @@ // - protoc-gen-rust-mcp v0.1.0 // - protoc v5.29.3 -static VIBE_SERVICE_SET_VIBE_INPUT_SCHEMA: ::std::sync::LazyLock<::std::sync::Arc<::rmcp::model::JsonObject>> = - ::std::sync::LazyLock::new(|| { - ::std::sync::Arc::new( - match ::serde_json::from_str(r#"{"additionalProperties":false,"description":"The request to set the vibe of the server","properties":{"vibe":{"description":"The vibe of the server to be set. Must match \\d+ or a `code` like \"chill\", and must not contain a literal newline.","type":"string"}},"type":"object"}"#) { - Ok(::serde_json::Value::Object(map)) => map, - Ok(_) => unreachable!("input schema is always a JSON object"), - Err(e) => panic!("parsing generated input schema: {e}"), - }, - ) - }); - +static VIBE_SERVICE_SET_VIBE_INPUT_SCHEMA: ::std::sync::LazyLock< + ::std::sync::Arc<::rmcp::model::JsonObject>, +> = ::std::sync::LazyLock::new(|| { + ::std::sync::Arc::new( + match ::serde_json::from_str( + r#"{"additionalProperties":false,"description":"The request to set the vibe of the server","properties":{"vibe":{"description":"The vibe of the server to be set. Must match \\d+ or a `code` like \"chill\", and must not contain a literal newline.","type":"string"}},"type":"object"}"#, + ) { + Ok(::serde_json::Value::Object(map)) => map, + Ok(_) => unreachable!("input schema is always a JSON object"), + Err(e) => panic!("parsing generated input schema: {e}"), + }, + ) +}); static VIBE_SERVICE_SET_VIBE_VALIDATOR: ::std::sync::LazyLock<::jsonschema::Validator> = ::std::sync::LazyLock::new(|| { - ::jsonschema::validator_for(&::serde_json::Value::Object(VIBE_SERVICE_SET_VIBE_INPUT_SCHEMA.as_ref().clone())) - .unwrap_or_else(|e| panic!("compiling generated input schema: {e}")) - }); - -static VIBE_SERVICE_GET_VIBE_INPUT_SCHEMA: ::std::sync::LazyLock<::std::sync::Arc<::rmcp::model::JsonObject>> = - ::std::sync::LazyLock::new(|| { - ::std::sync::Arc::new( - match ::serde_json::from_str(r#"{"additionalProperties":false,"description":"The request to get the vibe of the server","properties":{},"type":"object"}"#) { - Ok(::serde_json::Value::Object(map)) => map, - Ok(_) => unreachable!("input schema is always a JSON object"), - Err(e) => panic!("parsing generated input schema: {e}"), - }, - ) + ::jsonschema::validator_for(&::serde_json::Value::Object( + VIBE_SERVICE_SET_VIBE_INPUT_SCHEMA.as_ref().clone(), + )) + .unwrap_or_else(|e| panic!("compiling generated input schema: {e}")) }); - +static VIBE_SERVICE_GET_VIBE_INPUT_SCHEMA: ::std::sync::LazyLock< + ::std::sync::Arc<::rmcp::model::JsonObject>, +> = ::std::sync::LazyLock::new(|| { + ::std::sync::Arc::new( + match ::serde_json::from_str( + r#"{"additionalProperties":false,"description":"The request to get the vibe of the server","properties":{},"type":"object"}"#, + ) { + Ok(::serde_json::Value::Object(map)) => map, + Ok(_) => unreachable!("input schema is always a JSON object"), + Err(e) => panic!("parsing generated input schema: {e}"), + }, + ) +}); static VIBE_SERVICE_GET_VIBE_VALIDATOR: ::std::sync::LazyLock<::jsonschema::Validator> = ::std::sync::LazyLock::new(|| { - ::jsonschema::validator_for(&::serde_json::Value::Object(VIBE_SERVICE_GET_VIBE_INPUT_SCHEMA.as_ref().clone())) - .unwrap_or_else(|e| panic!("compiling generated input schema: {e}")) - }); - -static VIBE_SERVICE_SET_VIBE_DETAILS_INPUT_SCHEMA: ::std::sync::LazyLock<::std::sync::Arc<::rmcp::model::JsonObject>> = - ::std::sync::LazyLock::new(|| { - ::std::sync::Arc::new( - match ::serde_json::from_str(r#"{"additionalProperties":false,"description":"The detailed vibe of the server","properties":{"vibe":{"description":"The vibe of the string to be set","type":"string"},"vibeScalar":{"additionalProperties":false,"description":"The details of the vibe","properties":{"vibeBool":{"description":"The details of the vibe bool","type":"boolean"},"vibeBytes":{"contentEncoding":"base64","description":"the details of the vibe bytes","type":"string"},"vibeDouble":{"anyOf":[{"type":"number"},{"enum":["NaN","Infinity","-Infinity"],"type":"string"}],"description":"The details of the vibe double"},"vibeEnum":{"description":"The details of the vibe string","items":{"description":"The details of the vibe string","enum":["VIBE_UNSET","VIBE_GOOD"],"type":"string"},"type":"array"},"vibeFixed32":{"description":"The details of the vibe fixed32","minimum":0,"type":"integer"},"vibeFixed64":{"description":"The details of the vibe fixed64","minimum":0,"type":["integer","string"]},"vibeFloat":{"anyOf":[{"type":"number"},{"enum":["NaN","Infinity","-Infinity"],"type":"string"}],"description":"the details of the vibe float"},"vibeInt32":{"description":"The details of the vibe int32","type":"integer"},"vibeInt64":{"description":"The details of the vibe int64","type":["integer","string"]},"vibeSfixed32":{"description":"The details of the vibe sfixed32","type":"integer"},"vibeSfixed64":{"description":"The details of the vibe sfixed64","type":["integer","string"]},"vibeSint32":{"description":"The details of the vibe sint32","type":"integer"},"vibeSint64":{"description":"The details of the vibe sint64","type":["integer","string"]},"vibeUint32":{"description":"The details of the vibe uint32","minimum":0,"type":"integer"},"vibeUint64":{"description":"The details of the vibe uint64","minimum":0,"type":["integer","string"]}},"type":"object"}},"type":"object"}"#) { - Ok(::serde_json::Value::Object(map)) => map, - Ok(_) => unreachable!("input schema is always a JSON object"), - Err(e) => panic!("parsing generated input schema: {e}"), - }, - ) + ::jsonschema::validator_for(&::serde_json::Value::Object( + VIBE_SERVICE_GET_VIBE_INPUT_SCHEMA.as_ref().clone(), + )) + .unwrap_or_else(|e| panic!("compiling generated input schema: {e}")) }); - +static VIBE_SERVICE_SET_VIBE_DETAILS_INPUT_SCHEMA: ::std::sync::LazyLock< + ::std::sync::Arc<::rmcp::model::JsonObject>, +> = ::std::sync::LazyLock::new(|| { + ::std::sync::Arc::new( + match ::serde_json::from_str( + r#"{"additionalProperties":false,"description":"The detailed vibe of the server","properties":{"vibe":{"description":"The vibe of the string to be set","type":"string"},"vibeScalar":{"additionalProperties":false,"description":"The details of the vibe","properties":{"vibeBool":{"description":"The details of the vibe bool","type":"boolean"},"vibeBytes":{"contentEncoding":"base64","description":"the details of the vibe bytes","type":"string"},"vibeDouble":{"anyOf":[{"type":"number"},{"enum":["NaN","Infinity","-Infinity"],"type":"string"}],"description":"The details of the vibe double"},"vibeEnum":{"description":"The details of the vibe string","items":{"description":"The details of the vibe string","enum":["VIBE_UNSET","VIBE_GOOD"],"type":"string"},"type":"array"},"vibeFixed32":{"description":"The details of the vibe fixed32","minimum":0,"type":"integer"},"vibeFixed64":{"description":"The details of the vibe fixed64","minimum":0,"type":["integer","string"]},"vibeFloat":{"anyOf":[{"type":"number"},{"enum":["NaN","Infinity","-Infinity"],"type":"string"}],"description":"the details of the vibe float"},"vibeInt32":{"description":"The details of the vibe int32","type":"integer"},"vibeInt64":{"description":"The details of the vibe int64","type":["integer","string"]},"vibeSfixed32":{"description":"The details of the vibe sfixed32","type":"integer"},"vibeSfixed64":{"description":"The details of the vibe sfixed64","type":["integer","string"]},"vibeSint32":{"description":"The details of the vibe sint32","type":"integer"},"vibeSint64":{"description":"The details of the vibe sint64","type":["integer","string"]},"vibeUint32":{"description":"The details of the vibe uint32","minimum":0,"type":"integer"},"vibeUint64":{"description":"The details of the vibe uint64","minimum":0,"type":["integer","string"]}},"type":"object"}},"type":"object"}"#, + ) { + Ok(::serde_json::Value::Object(map)) => map, + Ok(_) => unreachable!("input schema is always a JSON object"), + Err(e) => panic!("parsing generated input schema: {e}"), + }, + ) +}); static VIBE_SERVICE_SET_VIBE_DETAILS_VALIDATOR: ::std::sync::LazyLock<::jsonschema::Validator> = ::std::sync::LazyLock::new(|| { - ::jsonschema::validator_for(&::serde_json::Value::Object(VIBE_SERVICE_SET_VIBE_DETAILS_INPUT_SCHEMA.as_ref().clone())) - .unwrap_or_else(|e| panic!("compiling generated input schema: {e}")) - }); - -static VIBE_SERVICE_SET_VIBE_ARRAY_INPUT_SCHEMA: ::std::sync::LazyLock<::std::sync::Arc<::rmcp::model::JsonObject>> = - ::std::sync::LazyLock::new(|| { - ::std::sync::Arc::new( - match ::serde_json::from_str(r#"{"additionalProperties":false,"description":"The vibe array request","properties":{"vibeArray":{"additionalProperties":false,"description":"The details of the vibe array","properties":{"vibeBools":{"description":"The details of the vibe bool array","items":{"type":"boolean"},"type":"array"},"vibeByteses":{"description":"the details of the vibe bytes array","items":{"contentEncoding":"base64","type":"string"},"type":"array"},"vibeDoubles":{"description":"The details of the vibe double array","items":{"anyOf":[{"type":"number"},{"enum":["NaN","Infinity","-Infinity"],"type":"string"}]},"type":"array"},"vibeFixed32s":{"description":"The details of the vibe fixed32 array","items":{"minimum":0,"type":"integer"},"type":"array"},"vibeFixed64s":{"description":"The details of the vibe fixed64 array","items":{"minimum":0,"type":["integer","string"]},"type":"array"},"vibeFloats":{"description":"the details of the vibe float array","items":{"anyOf":[{"type":"number"},{"enum":["NaN","Infinity","-Infinity"],"type":"string"}]},"type":"array"},"vibeInt32s":{"description":"The details of the vibe int32 array","items":{"type":"integer"},"type":"array"},"vibeInt64s":{"description":"The details of the vibe int64 array","items":{"type":["integer","string"]},"type":"array"},"vibeSfixed32s":{"description":"The details of the vibe sfixed32 array","items":{"type":"integer"},"type":"array"},"vibeSfixed64s":{"description":"The details of the vibe sfixed64 array","items":{"type":["integer","string"]},"type":"array"},"vibeSint32s":{"description":"The details of the vibe sint32 array","items":{"type":"integer"},"type":"array"},"vibeSint64s":{"description":"The details of the vibe sint64 array","items":{"type":["integer","string"]},"type":"array"},"vibeUint32s":{"description":"The details of the vibe uint32 array","items":{"minimum":0,"type":"integer"},"type":"array"},"vibeUint64s":{"description":"The details of the vibe uint64 array","items":{"minimum":0,"type":["integer","string"]},"type":"array"}},"type":"object"}},"type":"object"}"#) { - Ok(::serde_json::Value::Object(map)) => map, - Ok(_) => unreachable!("input schema is always a JSON object"), - Err(e) => panic!("parsing generated input schema: {e}"), - }, - ) + ::jsonschema::validator_for(&::serde_json::Value::Object( + VIBE_SERVICE_SET_VIBE_DETAILS_INPUT_SCHEMA.as_ref().clone(), + )) + .unwrap_or_else(|e| panic!("compiling generated input schema: {e}")) }); - +static VIBE_SERVICE_SET_VIBE_ARRAY_INPUT_SCHEMA: ::std::sync::LazyLock< + ::std::sync::Arc<::rmcp::model::JsonObject>, +> = ::std::sync::LazyLock::new(|| { + ::std::sync::Arc::new( + match ::serde_json::from_str( + r#"{"additionalProperties":false,"description":"The vibe array request","properties":{"vibeArray":{"additionalProperties":false,"description":"The details of the vibe array","properties":{"vibeBools":{"description":"The details of the vibe bool array","items":{"type":"boolean"},"type":"array"},"vibeByteses":{"description":"the details of the vibe bytes array","items":{"contentEncoding":"base64","type":"string"},"type":"array"},"vibeDoubles":{"description":"The details of the vibe double array","items":{"anyOf":[{"type":"number"},{"enum":["NaN","Infinity","-Infinity"],"type":"string"}]},"type":"array"},"vibeFixed32s":{"description":"The details of the vibe fixed32 array","items":{"minimum":0,"type":"integer"},"type":"array"},"vibeFixed64s":{"description":"The details of the vibe fixed64 array","items":{"minimum":0,"type":["integer","string"]},"type":"array"},"vibeFloats":{"description":"the details of the vibe float array","items":{"anyOf":[{"type":"number"},{"enum":["NaN","Infinity","-Infinity"],"type":"string"}]},"type":"array"},"vibeInt32s":{"description":"The details of the vibe int32 array","items":{"type":"integer"},"type":"array"},"vibeInt64s":{"description":"The details of the vibe int64 array","items":{"type":["integer","string"]},"type":"array"},"vibeSfixed32s":{"description":"The details of the vibe sfixed32 array","items":{"type":"integer"},"type":"array"},"vibeSfixed64s":{"description":"The details of the vibe sfixed64 array","items":{"type":["integer","string"]},"type":"array"},"vibeSint32s":{"description":"The details of the vibe sint32 array","items":{"type":"integer"},"type":"array"},"vibeSint64s":{"description":"The details of the vibe sint64 array","items":{"type":["integer","string"]},"type":"array"},"vibeUint32s":{"description":"The details of the vibe uint32 array","items":{"minimum":0,"type":"integer"},"type":"array"},"vibeUint64s":{"description":"The details of the vibe uint64 array","items":{"minimum":0,"type":["integer","string"]},"type":"array"}},"type":"object"}},"type":"object"}"#, + ) { + Ok(::serde_json::Value::Object(map)) => map, + Ok(_) => unreachable!("input schema is always a JSON object"), + Err(e) => panic!("parsing generated input schema: {e}"), + }, + ) +}); static VIBE_SERVICE_SET_VIBE_ARRAY_VALIDATOR: ::std::sync::LazyLock<::jsonschema::Validator> = ::std::sync::LazyLock::new(|| { - ::jsonschema::validator_for(&::serde_json::Value::Object(VIBE_SERVICE_SET_VIBE_ARRAY_INPUT_SCHEMA.as_ref().clone())) - .unwrap_or_else(|e| panic!("compiling generated input schema: {e}")) - }); - -static VIBE_SERVICE_SET_VIBE_OBJECTS_INPUT_SCHEMA: ::std::sync::LazyLock<::std::sync::Arc<::rmcp::model::JsonObject>> = - ::std::sync::LazyLock::new(|| { - ::std::sync::Arc::new( - match ::serde_json::from_str(r#"{"additionalProperties":false,"description":"The request to set multiple vibe objects on the server","properties":{"vibeObject":{"description":"The details of the vibe","items":{"additionalProperties":false,"description":"The vibe object of the server","properties":{"vibe":{"description":"The vibe of the server","type":"string"}},"type":"object"},"type":"array"}},"type":"object"}"#) { - Ok(::serde_json::Value::Object(map)) => map, - Ok(_) => unreachable!("input schema is always a JSON object"), - Err(e) => panic!("parsing generated input schema: {e}"), - }, - ) + ::jsonschema::validator_for(&::serde_json::Value::Object( + VIBE_SERVICE_SET_VIBE_ARRAY_INPUT_SCHEMA.as_ref().clone(), + )) + .unwrap_or_else(|e| panic!("compiling generated input schema: {e}")) }); - +static VIBE_SERVICE_SET_VIBE_OBJECTS_INPUT_SCHEMA: ::std::sync::LazyLock< + ::std::sync::Arc<::rmcp::model::JsonObject>, +> = ::std::sync::LazyLock::new(|| { + ::std::sync::Arc::new( + match ::serde_json::from_str( + r#"{"additionalProperties":false,"description":"The request to set multiple vibe objects on the server","properties":{"vibeObject":{"description":"The details of the vibe","items":{"additionalProperties":false,"description":"The vibe object of the server","properties":{"vibe":{"description":"The vibe of the server","type":"string"}},"type":"object"},"type":"array"}},"type":"object"}"#, + ) { + Ok(::serde_json::Value::Object(map)) => map, + Ok(_) => unreachable!("input schema is always a JSON object"), + Err(e) => panic!("parsing generated input schema: {e}"), + }, + ) +}); static VIBE_SERVICE_SET_VIBE_OBJECTS_VALIDATOR: ::std::sync::LazyLock<::jsonschema::Validator> = ::std::sync::LazyLock::new(|| { - ::jsonschema::validator_for(&::serde_json::Value::Object(VIBE_SERVICE_SET_VIBE_OBJECTS_INPUT_SCHEMA.as_ref().clone())) - .unwrap_or_else(|e| panic!("compiling generated input schema: {e}")) + ::jsonschema::validator_for(&::serde_json::Value::Object( + VIBE_SERVICE_SET_VIBE_OBJECTS_INPUT_SCHEMA.as_ref().clone(), + )) + .unwrap_or_else(|e| panic!("compiling generated input schema: {e}")) }); - /// A registered tool's handler: takes the raw tool call arguments /// (already looked up by name), returns the result. Built once per /// registered tool from an `async fn(client, args) -> CallToolResult` @@ -96,12 +111,11 @@ static VIBE_SERVICE_SET_VIBE_OBJECTS_VALIDATOR: ::std::sync::LazyLock<::jsonsche /// on why this, not `rmcp::ToolRouter`). pub type VibeServiceMcpServerToolHandler = ::std::sync::Arc< dyn Fn( - ::std::option::Option<::rmcp::model::JsonObject>, - ) -> ::futures::future::BoxFuture<'static, ::rmcp::model::CallToolResult> + ::std::option::Option<::rmcp::model::JsonObject>, + ) -> ::futures::future::BoxFuture<'static, ::rmcp::model::CallToolResult> + ::std::marker::Send + ::std::marker::Sync, >; - /// The MCP server for the `VibeService` gRPC service: exposes each of /// its non-streaming RPCs as an MCP tool and forwards tool calls to a /// `VibeServiceClient`. No tools are registered until @@ -110,16 +124,24 @@ pub type VibeServiceMcpServerToolHandler = ::std::sync::Arc< #[derive(Clone)] pub struct VibeServiceMcpServer { client: vibe_service_client::VibeServiceClient, - tools: ::std::collections::BTreeMap<::std::string::String, (::rmcp::model::Tool, VibeServiceMcpServerToolHandler)>, + tools: ::std::collections::BTreeMap< + ::std::string::String, + (::rmcp::model::Tool, VibeServiceMcpServerToolHandler), + >, server_info: ::rmcp::model::Implementation, } - impl VibeServiceMcpServer where - T: ::tonic::client::GrpcService<::tonic::body::Body> + ::std::clone::Clone + ::std::marker::Send + ::std::marker::Sync + 'static, + T: ::tonic::client::GrpcService<::tonic::body::Body> + + ::std::clone::Clone + + ::std::marker::Send + + ::std::marker::Sync + + 'static, T::Error: ::std::convert::Into<::tonic::codegen::StdError>, - T::ResponseBody: ::tonic::codegen::Body + ::std::marker::Send + 'static, - ::Error: ::std::convert::Into<::tonic::codegen::StdError> + ::std::marker::Send, + T::ResponseBody: + ::tonic::codegen::Body + ::std::marker::Send + 'static, + ::Error: + ::std::convert::Into<::tonic::codegen::StdError> + ::std::marker::Send, T::Future: ::std::marker::Send, { /// Creates a new `VibeServiceMcpServer` with no tools registered yet; call @@ -132,19 +154,20 @@ where server_info: ::rmcp::model::Implementation::from_build_env(), } } - /// Overrides the `serverInfo` advertised by `get_info` (defaults to this /// crate's name and version). pub fn with_server_info(mut self, server_info: ::rmcp::model::Implementation) -> Self { self.server_info = server_info; self } - /// Builds the [`rmcp::model::Tool`] for `SetVibe`. pub fn set_vibe_tool() -> ::rmcp::model::Tool { - ::rmcp::model::Tool::new(r"SetVibe", r#"This is a block comment with multiple lines to test block handling "Hello World", a `backtick`, and a path like C:\vibes\new"#, VIBE_SERVICE_SET_VIBE_INPUT_SCHEMA.clone()) + ::rmcp::model::Tool::new( + r"SetVibe", + r#"This is a block comment with multiple lines to test block handling "Hello World", a `backtick`, and a path like C:\vibes\new"#, + VIBE_SERVICE_SET_VIBE_INPUT_SCHEMA.clone(), + ) } - /// Validates, decodes, calls the backend for, and encodes the /// response of, a `SetVibe` tool call: see this module's doc /// comment for the exact steps. @@ -165,26 +188,33 @@ where let request: SetVibeRequest = match ::serde_json::from_value(args) { Ok(r) => r, Err(e) => { - return ::rmcp::model::CallToolResult::error(vec![::rmcp::model::ContentBlock::text( - ::std::format!("decoding arguments: {e}"), - )]); + return ::rmcp::model::CallToolResult::error(vec![ + ::rmcp::model::ContentBlock::text(::std::format!("decoding arguments: {e}")), + ]); } }; match client.set_vibe(::tonic::Request::new(request)).await { - Ok(response) => match ::serde_json::to_string(response.get_ref()) { - Ok(json) => ::rmcp::model::CallToolResult::success(vec![::rmcp::model::ContentBlock::text(json)]), - Err(e) => ::rmcp::model::CallToolResult::error(vec![::rmcp::model::ContentBlock::text( - ::std::format!("encoding response: {e}"), - )]), - }, - // grpc-go's Status.Error() format, which Go's generated server - // returns verbatim as the tool error text. - Err(status) => ::rmcp::model::CallToolResult::error(vec![::rmcp::model::ContentBlock::text( - ::std::format!("rpc error: code = {:?} desc = {}", status.code(), status.message()), - )]), + Ok(response) => { + match ::serde_json::to_string(response.get_ref()) { + Ok(json) => ::rmcp::model::CallToolResult::success(vec![ + ::rmcp::model::ContentBlock::text(json), + ]), + Err(e) => ::rmcp::model::CallToolResult::error(vec![ + ::rmcp::model::ContentBlock::text(::std::format!("encoding response: {e}")), + ]), + } + } + Err(status) => { + ::rmcp::model::CallToolResult::error(vec![::rmcp::model::ContentBlock::text( + ::std::format!( + "rpc error: code = {:?} desc = {}", + status.code(), + status.message() + ), + )]) + } } } - /// Calls the handler for `SetVibe` with a clone of this /// server's client. Exported so a caller can wrap it, e.g. an /// overriding tool (via [`register_tool`](Self::register_tool)) that @@ -195,12 +225,14 @@ where ) -> ::rmcp::model::CallToolResult { Self::call_set_vibe(self.client.clone(), args).await } - /// Builds the [`rmcp::model::Tool`] for `GetVibe`. pub fn get_vibe_tool() -> ::rmcp::model::Tool { - ::rmcp::model::Tool::new(r"GetVibe", r"Get Vibe of the server", VIBE_SERVICE_GET_VIBE_INPUT_SCHEMA.clone()) + ::rmcp::model::Tool::new( + r"GetVibe", + r"Get Vibe of the server", + VIBE_SERVICE_GET_VIBE_INPUT_SCHEMA.clone(), + ) } - /// Validates, decodes, calls the backend for, and encodes the /// response of, a `GetVibe` tool call: see this module's doc /// comment for the exact steps. @@ -221,26 +253,33 @@ where let request: GetVibeRequest = match ::serde_json::from_value(args) { Ok(r) => r, Err(e) => { - return ::rmcp::model::CallToolResult::error(vec![::rmcp::model::ContentBlock::text( - ::std::format!("decoding arguments: {e}"), - )]); + return ::rmcp::model::CallToolResult::error(vec![ + ::rmcp::model::ContentBlock::text(::std::format!("decoding arguments: {e}")), + ]); } }; match client.get_vibe(::tonic::Request::new(request)).await { - Ok(response) => match ::serde_json::to_string(response.get_ref()) { - Ok(json) => ::rmcp::model::CallToolResult::success(vec![::rmcp::model::ContentBlock::text(json)]), - Err(e) => ::rmcp::model::CallToolResult::error(vec![::rmcp::model::ContentBlock::text( - ::std::format!("encoding response: {e}"), - )]), - }, - // grpc-go's Status.Error() format, which Go's generated server - // returns verbatim as the tool error text. - Err(status) => ::rmcp::model::CallToolResult::error(vec![::rmcp::model::ContentBlock::text( - ::std::format!("rpc error: code = {:?} desc = {}", status.code(), status.message()), - )]), + Ok(response) => { + match ::serde_json::to_string(response.get_ref()) { + Ok(json) => ::rmcp::model::CallToolResult::success(vec![ + ::rmcp::model::ContentBlock::text(json), + ]), + Err(e) => ::rmcp::model::CallToolResult::error(vec![ + ::rmcp::model::ContentBlock::text(::std::format!("encoding response: {e}")), + ]), + } + } + Err(status) => { + ::rmcp::model::CallToolResult::error(vec![::rmcp::model::ContentBlock::text( + ::std::format!( + "rpc error: code = {:?} desc = {}", + status.code(), + status.message() + ), + )]) + } } } - /// Calls the handler for `GetVibe` with a clone of this /// server's client. Exported so a caller can wrap it, e.g. an /// overriding tool (via [`register_tool`](Self::register_tool)) that @@ -251,12 +290,14 @@ where ) -> ::rmcp::model::CallToolResult { Self::call_get_vibe(self.client.clone(), args).await } - /// Builds the [`rmcp::model::Tool`] for `SetVibeDetails`. pub fn set_vibe_details_tool() -> ::rmcp::model::Tool { - ::rmcp::model::Tool::new(r"SetVibeDetails", r"Set vibe details", VIBE_SERVICE_SET_VIBE_DETAILS_INPUT_SCHEMA.clone()) + ::rmcp::model::Tool::new( + r"SetVibeDetails", + r"Set vibe details", + VIBE_SERVICE_SET_VIBE_DETAILS_INPUT_SCHEMA.clone(), + ) } - /// Validates, decodes, calls the backend for, and encodes the /// response of, a `SetVibeDetails` tool call: see this module's doc /// comment for the exact steps. @@ -265,10 +306,11 @@ where args: ::std::option::Option<::rmcp::model::JsonObject>, ) -> ::rmcp::model::CallToolResult { let args = ::serde_json::Value::Object(args.unwrap_or_default()); - let errors: ::std::vec::Vec<::std::string::String> = VIBE_SERVICE_SET_VIBE_DETAILS_VALIDATOR - .iter_errors(&args) - .map(|e| ::std::format!("{}: {e}", e.instance_path())) - .collect(); + let errors: ::std::vec::Vec<::std::string::String> = + VIBE_SERVICE_SET_VIBE_DETAILS_VALIDATOR + .iter_errors(&args) + .map(|e| ::std::format!("{}: {e}", e.instance_path())) + .collect(); if !errors.is_empty() { return ::rmcp::model::CallToolResult::error(vec![::rmcp::model::ContentBlock::text( ::std::format!("invalid arguments: {}", errors.join("; ")), @@ -277,26 +319,36 @@ where let request: SetVibeDetailsRequest = match ::serde_json::from_value(args) { Ok(r) => r, Err(e) => { - return ::rmcp::model::CallToolResult::error(vec![::rmcp::model::ContentBlock::text( - ::std::format!("decoding arguments: {e}"), - )]); + return ::rmcp::model::CallToolResult::error(vec![ + ::rmcp::model::ContentBlock::text(::std::format!("decoding arguments: {e}")), + ]); } }; - match client.set_vibe_details(::tonic::Request::new(request)).await { - Ok(response) => match ::serde_json::to_string(response.get_ref()) { - Ok(json) => ::rmcp::model::CallToolResult::success(vec![::rmcp::model::ContentBlock::text(json)]), - Err(e) => ::rmcp::model::CallToolResult::error(vec![::rmcp::model::ContentBlock::text( - ::std::format!("encoding response: {e}"), - )]), - }, - // grpc-go's Status.Error() format, which Go's generated server - // returns verbatim as the tool error text. - Err(status) => ::rmcp::model::CallToolResult::error(vec![::rmcp::model::ContentBlock::text( - ::std::format!("rpc error: code = {:?} desc = {}", status.code(), status.message()), - )]), + match client + .set_vibe_details(::tonic::Request::new(request)) + .await + { + Ok(response) => { + match ::serde_json::to_string(response.get_ref()) { + Ok(json) => ::rmcp::model::CallToolResult::success(vec![ + ::rmcp::model::ContentBlock::text(json), + ]), + Err(e) => ::rmcp::model::CallToolResult::error(vec![ + ::rmcp::model::ContentBlock::text(::std::format!("encoding response: {e}")), + ]), + } + } + Err(status) => { + ::rmcp::model::CallToolResult::error(vec![::rmcp::model::ContentBlock::text( + ::std::format!( + "rpc error: code = {:?} desc = {}", + status.code(), + status.message() + ), + )]) + } } } - /// Calls the handler for `SetVibeDetails` with a clone of this /// server's client. Exported so a caller can wrap it, e.g. an /// overriding tool (via [`register_tool`](Self::register_tool)) that @@ -307,12 +359,14 @@ where ) -> ::rmcp::model::CallToolResult { Self::call_set_vibe_details(self.client.clone(), args).await } - /// Builds the [`rmcp::model::Tool`] for `SetVibeArray`. pub fn set_vibe_array_tool() -> ::rmcp::model::Tool { - ::rmcp::model::Tool::new(r"SetVibeArray", r"Set the vibe arrays", VIBE_SERVICE_SET_VIBE_ARRAY_INPUT_SCHEMA.clone()) + ::rmcp::model::Tool::new( + r"SetVibeArray", + r"Set the vibe arrays", + VIBE_SERVICE_SET_VIBE_ARRAY_INPUT_SCHEMA.clone(), + ) } - /// Validates, decodes, calls the backend for, and encodes the /// response of, a `SetVibeArray` tool call: see this module's doc /// comment for the exact steps. @@ -333,26 +387,33 @@ where let request: SetVibeArrayRequest = match ::serde_json::from_value(args) { Ok(r) => r, Err(e) => { - return ::rmcp::model::CallToolResult::error(vec![::rmcp::model::ContentBlock::text( - ::std::format!("decoding arguments: {e}"), - )]); + return ::rmcp::model::CallToolResult::error(vec![ + ::rmcp::model::ContentBlock::text(::std::format!("decoding arguments: {e}")), + ]); } }; match client.set_vibe_array(::tonic::Request::new(request)).await { - Ok(response) => match ::serde_json::to_string(response.get_ref()) { - Ok(json) => ::rmcp::model::CallToolResult::success(vec![::rmcp::model::ContentBlock::text(json)]), - Err(e) => ::rmcp::model::CallToolResult::error(vec![::rmcp::model::ContentBlock::text( - ::std::format!("encoding response: {e}"), - )]), - }, - // grpc-go's Status.Error() format, which Go's generated server - // returns verbatim as the tool error text. - Err(status) => ::rmcp::model::CallToolResult::error(vec![::rmcp::model::ContentBlock::text( - ::std::format!("rpc error: code = {:?} desc = {}", status.code(), status.message()), - )]), + Ok(response) => { + match ::serde_json::to_string(response.get_ref()) { + Ok(json) => ::rmcp::model::CallToolResult::success(vec![ + ::rmcp::model::ContentBlock::text(json), + ]), + Err(e) => ::rmcp::model::CallToolResult::error(vec![ + ::rmcp::model::ContentBlock::text(::std::format!("encoding response: {e}")), + ]), + } + } + Err(status) => { + ::rmcp::model::CallToolResult::error(vec![::rmcp::model::ContentBlock::text( + ::std::format!( + "rpc error: code = {:?} desc = {}", + status.code(), + status.message() + ), + )]) + } } } - /// Calls the handler for `SetVibeArray` with a clone of this /// server's client. Exported so a caller can wrap it, e.g. an /// overriding tool (via [`register_tool`](Self::register_tool)) that @@ -363,12 +424,14 @@ where ) -> ::rmcp::model::CallToolResult { Self::call_set_vibe_array(self.client.clone(), args).await } - /// Builds the [`rmcp::model::Tool`] for `SetVibeObjects`. pub fn set_vibe_objects_tool() -> ::rmcp::model::Tool { - ::rmcp::model::Tool::new(r"SetVibeObjects", r"Set multiple vibe objects", VIBE_SERVICE_SET_VIBE_OBJECTS_INPUT_SCHEMA.clone()) + ::rmcp::model::Tool::new( + r"SetVibeObjects", + r"Set multiple vibe objects", + VIBE_SERVICE_SET_VIBE_OBJECTS_INPUT_SCHEMA.clone(), + ) } - /// Validates, decodes, calls the backend for, and encodes the /// response of, a `SetVibeObjects` tool call: see this module's doc /// comment for the exact steps. @@ -377,10 +440,11 @@ where args: ::std::option::Option<::rmcp::model::JsonObject>, ) -> ::rmcp::model::CallToolResult { let args = ::serde_json::Value::Object(args.unwrap_or_default()); - let errors: ::std::vec::Vec<::std::string::String> = VIBE_SERVICE_SET_VIBE_OBJECTS_VALIDATOR - .iter_errors(&args) - .map(|e| ::std::format!("{}: {e}", e.instance_path())) - .collect(); + let errors: ::std::vec::Vec<::std::string::String> = + VIBE_SERVICE_SET_VIBE_OBJECTS_VALIDATOR + .iter_errors(&args) + .map(|e| ::std::format!("{}: {e}", e.instance_path())) + .collect(); if !errors.is_empty() { return ::rmcp::model::CallToolResult::error(vec![::rmcp::model::ContentBlock::text( ::std::format!("invalid arguments: {}", errors.join("; ")), @@ -389,26 +453,36 @@ where let request: SetVibeObjectsRequest = match ::serde_json::from_value(args) { Ok(r) => r, Err(e) => { - return ::rmcp::model::CallToolResult::error(vec![::rmcp::model::ContentBlock::text( - ::std::format!("decoding arguments: {e}"), - )]); + return ::rmcp::model::CallToolResult::error(vec![ + ::rmcp::model::ContentBlock::text(::std::format!("decoding arguments: {e}")), + ]); } }; - match client.set_vibe_objects(::tonic::Request::new(request)).await { - Ok(response) => match ::serde_json::to_string(response.get_ref()) { - Ok(json) => ::rmcp::model::CallToolResult::success(vec![::rmcp::model::ContentBlock::text(json)]), - Err(e) => ::rmcp::model::CallToolResult::error(vec![::rmcp::model::ContentBlock::text( - ::std::format!("encoding response: {e}"), - )]), - }, - // grpc-go's Status.Error() format, which Go's generated server - // returns verbatim as the tool error text. - Err(status) => ::rmcp::model::CallToolResult::error(vec![::rmcp::model::ContentBlock::text( - ::std::format!("rpc error: code = {:?} desc = {}", status.code(), status.message()), - )]), + match client + .set_vibe_objects(::tonic::Request::new(request)) + .await + { + Ok(response) => { + match ::serde_json::to_string(response.get_ref()) { + Ok(json) => ::rmcp::model::CallToolResult::success(vec![ + ::rmcp::model::ContentBlock::text(json), + ]), + Err(e) => ::rmcp::model::CallToolResult::error(vec![ + ::rmcp::model::ContentBlock::text(::std::format!("encoding response: {e}")), + ]), + } + } + Err(status) => { + ::rmcp::model::CallToolResult::error(vec![::rmcp::model::ContentBlock::text( + ::std::format!( + "rpc error: code = {:?} desc = {}", + status.code(), + status.message() + ), + )]) + } } } - /// Calls the handler for `SetVibeObjects` with a clone of this /// server's client. Exported so a caller can wrap it, e.g. an /// overriding tool (via [`register_tool`](Self::register_tool)) that @@ -419,7 +493,6 @@ where ) -> ::rmcp::model::CallToolResult { Self::call_set_vibe_objects(self.client.clone(), args).await } - /// Registers a single tool, overriding any existing tool with the same /// name (callers can also use this to add a tool this generator did /// not emit). @@ -429,52 +502,67 @@ where + ::std::marker::Send + ::std::marker::Sync + 'static, - Fut: ::std::future::Future + ::std::marker::Send + 'static, + Fut: ::std::future::Future + + ::std::marker::Send + + 'static, { - let handler: VibeServiceMcpServerToolHandler = ::std::sync::Arc::new(move |args| ::std::boxed::Box::pin(handler(args))); + let handler: VibeServiceMcpServerToolHandler = + ::std::sync::Arc::new(move |args| ::std::boxed::Box::pin(handler(args))); self.tools.insert(tool.name.to_string(), (tool, handler)); self } - /// Registers every unary RPC's tool with its default handler, the /// counterpart of Go's `RegisterDefaultTools`. pub fn register_default_tools(&mut self) -> &mut Self { { let client = self.client.clone(); - self.register_tool(Self::set_vibe_tool(), move |args| Self::call_set_vibe(client.clone(), args)); + self.register_tool(Self::set_vibe_tool(), move |args| { + Self::call_set_vibe(client.clone(), args) + }); } { let client = self.client.clone(); - self.register_tool(Self::get_vibe_tool(), move |args| Self::call_get_vibe(client.clone(), args)); + self.register_tool(Self::get_vibe_tool(), move |args| { + Self::call_get_vibe(client.clone(), args) + }); } { let client = self.client.clone(); - self.register_tool(Self::set_vibe_details_tool(), move |args| Self::call_set_vibe_details(client.clone(), args)); + self.register_tool(Self::set_vibe_details_tool(), move |args| { + Self::call_set_vibe_details(client.clone(), args) + }); } { let client = self.client.clone(); - self.register_tool(Self::set_vibe_array_tool(), move |args| Self::call_set_vibe_array(client.clone(), args)); + self.register_tool(Self::set_vibe_array_tool(), move |args| { + Self::call_set_vibe_array(client.clone(), args) + }); } { let client = self.client.clone(); - self.register_tool(Self::set_vibe_objects_tool(), move |args| Self::call_set_vibe_objects(client.clone(), args)); + self.register_tool(Self::set_vibe_objects_tool(), move |args| { + Self::call_set_vibe_objects(client.clone(), args) + }); } self } - /// Hands this server's registered tools to another generated server, /// so one MCP server can serve several services' tools (pass the /// result to [`extend_tools`](Self::extend_tools) on the other server). - pub fn into_tools(self) -> impl ::std::iter::Iterator { + pub fn into_tools( + self, + ) -> impl ::std::iter::Iterator + { self.tools.into_values() } - /// Adds tools (e.g. from another service's /// [`into_tools`](Self::into_tools)) to this server, overriding any /// existing tool with the same name. pub fn extend_tools( &mut self, - tools: impl ::std::iter::IntoIterator, + tools: impl ::std::iter::IntoIterator< + Item = (::rmcp::model::Tool, VibeServiceMcpServerToolHandler), + >, ) -> &mut Self { for (tool, handler) in tools { self.tools.insert(tool.name.to_string(), (tool, handler)); @@ -482,22 +570,28 @@ where self } } - impl ::rmcp::ServerHandler for VibeServiceMcpServer where - T: ::tonic::client::GrpcService<::tonic::body::Body> + ::std::clone::Clone + ::std::marker::Send + ::std::marker::Sync + 'static, + T: ::tonic::client::GrpcService<::tonic::body::Body> + + ::std::clone::Clone + + ::std::marker::Send + + ::std::marker::Sync + + 'static, T::Error: ::std::convert::Into<::tonic::codegen::StdError>, - T::ResponseBody: ::tonic::codegen::Body + ::std::marker::Send + 'static, - ::Error: ::std::convert::Into<::tonic::codegen::StdError> + ::std::marker::Send, + T::ResponseBody: + ::tonic::codegen::Body + ::std::marker::Send + 'static, + ::Error: + ::std::convert::Into<::tonic::codegen::StdError> + ::std::marker::Send, T::Future: ::std::marker::Send, { fn get_info(&self) -> ::rmcp::model::ServerConfig { ::rmcp::model::ServerConfig::new( - ::rmcp::model::ServerCapabilities::builder().enable_tools().build(), + ::rmcp::model::ServerCapabilities::builder() + .enable_tools() + .build(), ) .with_server_info(self.server_info.clone()) } - async fn list_tools( &self, _request: ::std::option::Option<::rmcp::model::PaginatedRequestParams>, @@ -507,7 +601,6 @@ where self.tools.values().map(|(tool, _)| tool.clone()).collect(), )) } - async fn call_tool( &self, request: ::rmcp::model::CallToolRequestParams, @@ -519,7 +612,6 @@ where .ok_or_else(|| ::rmcp::ErrorData::invalid_params("tool not found", None))?; Ok(handler(request.arguments).await.into()) } - fn get_tool(&self, name: &str) -> ::std::option::Option<::rmcp::model::Tool> { self.tools.get(name).map(|(tool, _)| tool.clone()) } diff --git a/protoc-gen-rust-mcp/Cargo.toml b/protoc-gen-rust-mcp/Cargo.toml index cb537c1..b1f5fd4 100644 --- a/protoc-gen-rust-mcp/Cargo.toml +++ b/protoc-gen-rust-mcp/Cargo.toml @@ -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" @@ -41,9 +54,6 @@ 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 @@ -51,24 +61,3 @@ default-features = false # 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" diff --git a/protoc-gen-rust-mcp/src/generator.rs b/protoc-gen-rust-mcp/src/generator.rs index 195ae94..10bb77a 100644 --- a/protoc-gen-rust-mcp/src/generator.rs +++ b/protoc-gen-rust-mcp/src/generator.rs @@ -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 { @@ -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::*; diff --git a/protoc-gen-rust-mcp/src/schema.rs b/protoc-gen-rust-mcp/src/schema.rs index 460c846..b83d558 100644 --- a/protoc-gen-rust-mcp/src/schema.rs +++ b/protoc-gen-rust-mcp/src/schema.rs @@ -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}")) diff --git a/protoc-gen-rust-mcp/src/server.rs b/protoc-gen-rust-mcp/src/server.rs index 7009e4c..c6cda4b 100644 --- a/protoc-gen-rust-mcp/src/server.rs +++ b/protoc-gen-rust-mcp/src/server.rs @@ -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)); diff --git a/protoc-gen-rust-mcp/tests/golden.rs b/protoc-gen-rust-mcp/tests/golden.rs index a560a44..cacc303 100644 --- a/protoc-gen-rust-mcp/tests/golden.rs +++ b/protoc-gen-rust-mcp/tests/golden.rs @@ -6,10 +6,16 @@ //! parses the proto file and builds its descriptor (with comments, via //! `SourceCodeInfo`) in-process, so this test needs neither a `protoc` //! binary nor network access. `generator::generate` then runs against that -//! request exactly as it would via `protoc`, and the resulting -//! `examples.v1.mcp.rs` is compared byte for byte with the committed -//! `examples/src/gen/examples/v1/examples.v1.mcp.rs` (the file `make -//! generate` writes for the same proto). +//! request exactly as it would via `protoc`; its output is already run +//! through `prettyplease` (see `generator.rs`'s `format_rust`), and is +//! then piped through the real `rustfmt` binary too (see +//! [`rustfmt_edition_2024`]), the same final pass `make generate`'s +//! `Makefile` target applies to its `*.mcp.rs` output, because +//! `prettyplease`'s line-wrapping rules don't always agree with +//! `rustfmt`'s own on every construct (#22's acceptance criterion that +//! `rustfmt --check` passes on the committed file). The result is compared +//! byte for byte with the committed +//! `examples/src/gen/examples/v1/examples.v1.mcp.rs`. //! //! When a deliberate change to the generator changes the output, //! regenerate the golden file with: @@ -19,6 +25,9 @@ //! ``` //! //! and review the diff; an unexpected change is a bug until explained. +//! This needs `rustfmt` on `PATH`, already required for this repo's own +//! `cargo fmt --all --check` check and pinned as a component in +//! `rust-toolchain.toml`. use pretty_assertions::assert_eq; use prost_types::compiler::{CodeGeneratorRequest, Version}; @@ -92,6 +101,7 @@ fn generated_output_matches_committed_golden_file() { .find(|f| f.name.as_deref() == Some("examples/v1/examples.v1.mcp.rs")) .and_then(|f| f.content.as_deref()) .expect("generator emits examples/v1/examples.v1.mcp.rs"); + let got = rustfmt_edition_2024(got); if std::env::var_os("UPDATE_GOLDEN").is_some() { std::fs::write(GOLDEN_FILE_PATH, got).expect("writing golden file"); @@ -106,3 +116,35 @@ fn generated_output_matches_committed_golden_file() { protoc-gen-rust-mcp golden` and review the diff" ); } + +/// Pipes `source` through `rustfmt --edition 2024`, the same final +/// formatting pass `make generate`'s `Makefile` target applies to every +/// `*.mcp.rs` file after this plugin runs (see this module's doc +/// comment). Panics if `rustfmt` is missing or rejects `source` (both +/// indicate a bug in the generator or this repo's toolchain setup, not a +/// normal test failure mode). +fn rustfmt_edition_2024(source: &str) -> String { + use std::io::Write as _; + use std::process::{Command, Stdio}; + + let mut child = Command::new("rustfmt") + .args(["--edition", "2024", "--emit", "stdout"]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("spawning rustfmt (required on PATH; see rust-toolchain.toml's components)"); + child + .stdin + .take() + .expect("piped stdin") + .write_all(source.as_bytes()) + .expect("writing to rustfmt's stdin"); + let output = child.wait_with_output().expect("waiting for rustfmt"); + assert!( + output.status.success(), + "rustfmt failed on generated code: {}", + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8(output.stdout).expect("rustfmt output is valid UTF-8") +} diff --git a/protoc-gen-rust-mcp/tests/server_integration.rs b/protoc-gen-rust-mcp/tests/server_integration.rs index 47a3ea6..042dd36 100644 --- a/protoc-gen-rust-mcp/tests/server_integration.rs +++ b/protoc-gen-rust-mcp/tests/server_integration.rs @@ -3,14 +3,17 @@ //! acceptance criteria: validation, pbjson decoding, the tonic call, and //! pbjson encoding), exercised end-to-end over an in-memory MCP transport //! against the real tonic-generated client code for -//! `tests/testdata/mcpgen/fixture.proto` (compiled by `build.rs` with real -//! `tonic-prost-build` and `pbjson-build`, and this crate's own generator -//! run against the same descriptors; see `build.rs`'s -//! `generate_mcpgen_fixture` for how `mcpgen.rs`, `mcpgen.serde.rs` and -//! `mcpgen.mcp.rs` land in `OUT_DIR`). `examples/tests/handler.rs` covers -//! the same #7 criteria again against the real, committed example -//! `VibeService` server, so a schema/pbjson regression in the real -//! generated code (not just this synthetic fixture) is also caught. +//! `test-fixtures/testdata/mcpgen/fixture.proto` (compiled by +//! `test-fixtures/build.rs` with real `tonic-prost-build` and +//! `pbjson-build`, and this crate's own generator run against the same +//! descriptors; see that build script's `generate_mcpgen_fixture` for how +//! `mcpgen.rs`, `mcpgen.serde.rs` and `mcpgen.mcp.rs` land in its +//! `OUT_DIR`, re-exported here as `test_fixtures::mcpgen`; #22 moved this +//! out of this crate's own `build.rs`, which no longer exists). +//! `examples/tests/handler.rs` covers the same #7 criteria again against +//! the real, committed example `VibeService` server, so a schema/pbjson +//! regression in the real generated code (not just this synthetic +//! fixture) is also caught. //! //! Unlike `examples/`, this fixture's generated code is not committed: //! `fixture.proto` exists only to give this test a service with a @@ -18,18 +21,7 @@ //! cross-service composition test), without changing the shared parity //! fixture. -#[allow( - dead_code, - missing_docs, - clippy::all, - clippy::pedantic, - unreachable_pub -)] -mod mcpgen { - include!(concat!(env!("OUT_DIR"), "/mcpgen.rs")); - include!(concat!(env!("OUT_DIR"), "/mcpgen.serde.rs")); - include!(concat!(env!("OUT_DIR"), "/mcpgen.mcp.rs")); -} +use test_fixtures::mcpgen; use mcpgen::other_fixture_service_client::OtherFixtureServiceClient; use mcpgen::other_fixture_service_server::{OtherFixtureService, OtherFixtureServiceServer}; diff --git a/test-fixtures/Cargo.toml b/test-fixtures/Cargo.toml new file mode 100644 index 0000000..ab44291 --- /dev/null +++ b/test-fixtures/Cargo.toml @@ -0,0 +1,59 @@ +[package] +name = "test-fixtures" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +description = "Dev-only test fixtures for protoc-gen-rust-mcp: compiles tests/testdata's protos and runs the real generator against them, so protoc-gen-rust-mcp's own build.rs doesn't have to (#22)." +publish = false + +[lib] +name = "test_fixtures" +path = "src/lib.rs" + +[dependencies] +# Needed at runtime by the pbjson-generated Deserialize/Serialize impls and +# the well-known-type field types this crate routes google.protobuf.* to +# (see build.rs). +pbjson = "0.8" +pbjson-types = "0.8" +prost = "0.14" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +# tests/testdata/mcpgen/fixture.proto is compiled with real tonic-prost +# codegen, so its generated client/server code needs tonic and +# tonic-prost at runtime, not just at build time. +tonic = { version = "0.14", default-features = false, features = [ + "codegen", + "channel", +] } +tonic-prost = "0.14" +# The generated MCP server code (`mcpgen.mcp.rs`, built from this crate's +# own `server::generate_service`) needs the same runtime dependencies any +# other generated `.mcp.rs` does: `rmcp` for the server/tool types, +# `jsonschema` for the per-tool argument validator, and `futures` for the +# handler type alias's `BoxFuture`. +jsonschema = { version = "0.58", default-features = false } +futures = "0.3" + +[dependencies.rmcp] +version = "3.5" +default-features = false +features = ["server"] + +[build-dependencies] +# Used only by build.rs, to compile tests/testdata/schemapb/schema.proto +# and tests/testdata/mcpgen/fixture.proto with protox (no protoc or +# network access needed), generate their pbjson/tonic Rust types, and run +# protoc-gen-rust-mcp's own `server::generate_service` against the +# mcpgen fixture's descriptors (a normal dependency on the plugin crate, +# unlike the old protoc-gen-rust-mcp/build.rs, which could not depend on +# the library crate it was building and had to #[path]-include its +# modules instead; see #22). +protoc-gen-rust-mcp = { path = "../protoc-gen-rust-mcp" } +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 } diff --git a/protoc-gen-rust-mcp/build.rs b/test-fixtures/build.rs similarity index 56% rename from protoc-gen-rust-mcp/build.rs rename to test-fixtures/build.rs index 775bc97..a9a7be0 100644 --- a/protoc-gen-rust-mcp/build.rs +++ b/test-fixtures/build.rs @@ -1,47 +1,34 @@ //! Compiles `tests/testdata/schemapb/schema.proto` into pbjson-generated -//! Rust types, for `src/schema.rs`'s tests to validate against: the -//! counterpart of Go's `schema_test.go` loading the same proto with -//! `protocompile` and comparing against `protojson`-marshaled messages. -//! Also compiles `tests/testdata/mcpgen/fixture.proto` with real -//! `tonic-prost-build`, and runs this crate's own `server::generate_service` -//! against it, so `tests/server_integration.rs` can exercise the generated -//! MCP server code against a real tonic-generated client (`#[path]`- -//! included below, since a build script cannot depend on the library -//! crate it is building). +//! Rust types, for `protoc_gen_rust_mcp::schema`'s tests to validate +//! against: the counterpart of Go's `schema_test.go` loading the same +//! proto with `protocompile` and comparing against `protojson`-marshaled +//! messages. Also compiles `tests/testdata/mcpgen/fixture.proto` with real +//! `tonic-prost-build`, and runs `protoc_gen_rust_mcp::server::generate_service` +//! against it, so `protoc-gen-rust-mcp/tests/server_integration.rs` can +//! exercise the generated MCP server code against a real tonic-generated +//! client. +//! +//! This crate exists so `protoc-gen-rust-mcp` itself has no build.rs: a +//! plain `cargo build`/`cargo install` of the plugin no longer pulls in +//! `protox`, `prost-build`, `pbjson-build` or `tonic-prost-build`, or +//! compiles these fixtures at all (#22). Unlike the old +//! `protoc-gen-rust-mcp/build.rs`, this one can depend on +//! `protoc-gen-rust-mcp` as a normal (build-)dependency instead of +//! `#[path]`-including its modules, because this crate is not the one +//! being built. //! //! Runs unconditionally (not only for `cargo test`) because Cargo does not //! give build scripts a reliable "are we testing" signal, but it only //! compiles two small protos with no network access (`protox` is a //! pure-Rust compiler and all its imports here are the bundled well-known -//! types), so the extra cost on a plain `cargo build` is negligible. -//! -//! The dependencies used here (`protox`, `prost-build`, `pbjson-build`, -//! `tonic-prost-build`) are declared under `[build-dependencies]`, not -//! `[dependencies]`: they are not part of the plugin binary itself. +//! types), so the cost is negligible, and this whole crate only runs for +//! `cargo test --workspace` / `cargo test -p protoc-gen-rust-mcp` anyway +//! (nothing outside tests depends on it). use std::path::PathBuf; -// Re-included by #[path] (a build script can't depend on the library -// crate it builds); this standalone compilation only exercises a subset -// of each module's public API, so `#[allow(dead_code)]` the rest instead -// of marking individual unused items in the real `src/` files. -#[allow(dead_code)] -#[path = "src/comments.rs"] -mod comments; -#[allow(dead_code)] -#[path = "src/rust_literal.rs"] -mod rust_literal; -#[path = "src/schema.rs"] -mod schema; -#[allow(dead_code)] -#[path = "src/server.rs"] -mod server; -#[allow(dead_code)] -#[path = "src/source_info.rs"] -mod source_info; - fn main() -> Result<(), Box> { - let proto_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/testdata/schemapb"); + let proto_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("testdata/schemapb"); println!("cargo:rerun-if-changed={}", proto_dir.display()); let fds = protox::compile(["schema.proto"], [&proto_dir])?; @@ -72,17 +59,17 @@ fn main() -> Result<(), Box> { } /// Compiles `tests/testdata/mcpgen/fixture.proto` with real `tonic-prost- -/// build` and `pbjson-build` (so `tests/server_integration.rs` exercises -/// this generator's output against a real tonic-generated client and +/// build` and `pbjson-build` (so `server_integration.rs` exercises this +/// generator's output against a real tonic-generated client and /// pbjson-generated `Serialize`/`Deserialize` impls, not a hand-written /// stand-in — the generated handler body, #7, decodes/encodes arguments -/// with those impls), then runs `server::generate_service` (this crate's -/// own generator, `#[path]`-included above) against the same descriptors -/// and writes the result as `mcpgen.mcp.rs` into `OUT_DIR`, so the test -/// can `include!` all three files the way `examples/src/gen/` does for -/// the real example. +/// with those impls), then runs `protoc_gen_rust_mcp::server::generate_service` +/// (a normal dependency; see this file's module doc comment) against the +/// same descriptors and writes the result as `mcpgen.mcp.rs` into +/// `OUT_DIR`, so the test can `include!` all three files the way +/// `examples/src/gen/` does for the real example. fn generate_mcpgen_fixture() -> Result<(), Box> { - let proto_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/testdata/mcpgen"); + let proto_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("testdata/mcpgen"); println!("cargo:rerun-if-changed={}", proto_dir.display()); let fds = protox::compile(["fixture.proto"], [&proto_dir])?; @@ -102,7 +89,7 @@ fn generate_mcpgen_fixture() -> Result<(), Box> { let mut content = String::from("// @generated for tests/server_integration.rs\n"); for service in pool.services() { - content.push_str(&server::generate_service(&service)); + content.push_str(&protoc_gen_rust_mcp::server::generate_service(&service)); content.push('\n'); } diff --git a/test-fixtures/src/lib.rs b/test-fixtures/src/lib.rs new file mode 100644 index 0000000..b16433c --- /dev/null +++ b/test-fixtures/src/lib.rs @@ -0,0 +1,49 @@ +//! Dev-only test fixtures for `protoc-gen-rust-mcp` (`publish = false`, +//! not a dependency of the plugin itself): compiles +//! `testdata/schemapb/schema.proto` and `testdata/mcpgen/fixture.proto` +//! (via `build.rs`, with `protox`, `pbjson-build` and `tonic-prost-build`) +//! and runs `protoc_gen_rust_mcp::server::generate_service` against the +//! latter, so `protoc-gen-rust-mcp`'s own tests can validate against real +//! pbjson-generated types and a real tonic-generated client/server, +//! without the plugin crate itself needing a `build.rs` or those +//! build-time dependencies (#22). +//! +//! `protoc-gen-rust-mcp` depends on this crate as a dev-dependency; this +//! crate depends on `protoc-gen-rust-mcp` as a normal (build-)dependency +//! to run its generator, which is possible here precisely because this +//! crate is not the one being built when `generate_service` runs. + +/// The pbjson-generated Rust types for `testdata/schemapb/schema.proto`, +/// used to validate generated schemas against real pbjson-serialized +/// messages: the counterpart of Go's `schema_test.go` validating against +/// `protojson`-marshaled messages. +#[allow( + missing_docs, + clippy::all, + clippy::pedantic, + dead_code, + unreachable_pub +)] +pub mod schemapb { + include!(concat!(env!("OUT_DIR"), "/schemapb.rs")); + include!(concat!(env!("OUT_DIR"), "/schemapb.serde.rs")); +} + +/// The real tonic-generated client/server code, pbjson-generated +/// `Serialize`/`Deserialize` impls, and this generator's own output for +/// `testdata/mcpgen/fixture.proto` (all three produced by `build.rs`), +/// exercised end-to-end by `protoc-gen-rust-mcp/tests/server_integration.rs` +/// against a real tonic-generated client, the way `examples/` does for +/// the real, committed `VibeService` example. +#[allow( + dead_code, + missing_docs, + clippy::all, + clippy::pedantic, + unreachable_pub +)] +pub mod mcpgen { + include!(concat!(env!("OUT_DIR"), "/mcpgen.rs")); + include!(concat!(env!("OUT_DIR"), "/mcpgen.serde.rs")); + include!(concat!(env!("OUT_DIR"), "/mcpgen.mcp.rs")); +} diff --git a/protoc-gen-rust-mcp/tests/testdata/mcpgen/fixture.proto b/test-fixtures/testdata/mcpgen/fixture.proto similarity index 100% rename from protoc-gen-rust-mcp/tests/testdata/mcpgen/fixture.proto rename to test-fixtures/testdata/mcpgen/fixture.proto diff --git a/protoc-gen-rust-mcp/tests/testdata/schemapb/schema.proto b/test-fixtures/testdata/schemapb/schema.proto similarity index 100% rename from protoc-gen-rust-mcp/tests/testdata/schemapb/schema.proto rename to test-fixtures/testdata/schemapb/schema.proto