From da7841e5ee6216e49dac895ee3a6af09ee409d0f Mon Sep 17 00:00:00 2001 From: sf-factory-bot Date: Sat, 10 Oct 2026 01:55:50 +0000 Subject: [PATCH] feat(examples): add mcp-vibe demo server over stdio (#10) Add the `mcp-vibe` binary (`examples/src/bin/mcp-vibe.rs`), the Rust counterpart of Go's `cmd/mcp-vibe/` at 9e072f9: - starts an in-process tonic `VibeService` backend on 127.0.0.1:0, with SetVibe/GetVibe behaving like Go's vibe.go (initial vibe "initial vibe", empty vibe -> error "vibe cannot be empty"); the other RPCs return Unimplemented - connects a tonic client to it, builds a VibeServiceMcpServer and calls register_default_tools() - serves MCP over stdio with server info name "vibe", version "0.0.1", shutting down cleanly on SIGINT/SIGTERM or stdin EOF Also: - moves rmcp's "transport-io" and tonic's "router" features, and tokio, from dev-dependencies to normal dependencies of examples/Cargo.toml (binaries can't use dev-dependencies), with comments explaining why - adds examples/tests/mcp_vibe_binary.rs, spawning the real binary as a subprocess via rmcp::transport::TokioChildProcess and driving it through initialize, tools/list, and tools/call (SetVibe then GetVibe) - fills in README.md's mcp-vibe install TODO --- Cargo.lock | 95 ++++++++++++++++++ README.md | 6 +- examples/Cargo.toml | 48 ++++++--- examples/src/bin/mcp-vibe.rs | 156 ++++++++++++++++++++++++++++++ examples/tests/mcp_vibe_binary.rs | 71 ++++++++++++++ 5 files changed, 360 insertions(+), 16 deletions(-) create mode 100644 examples/src/bin/mcp-vibe.rs create mode 100644 examples/tests/mcp_vibe_binary.rs diff --git a/Cargo.lock b/Cargo.lock index f2bd66f..022d798 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -179,6 +179,12 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + [[package]] name = "chacha20" version = "0.10.2" @@ -850,6 +856,18 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "num" version = "0.4.3" @@ -1078,6 +1096,20 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "process-wrap" +version = "10.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f21b97672d2dc848e7b25701ab4535618b92f4861c13cc3f7f7bed52ad3c8da" +dependencies = [ + "futures", + "indexmap", + "nix", + "tokio", + "tracing", + "windows", +] + [[package]] name = "prost" version = "0.14.4" @@ -1319,6 +1351,7 @@ dependencies = [ "indexmap", "pastey", "pin-project-lite", + "process-wrap", "schemars", "serde", "serde_json", @@ -1441,6 +1474,16 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + [[package]] name = "slab" version = "0.4.12" @@ -1555,6 +1598,7 @@ dependencies = [ "libc", "mio", "pin-project-lite", + "signal-hook-registry", "socket2", "tokio-macros", "windows-sys", @@ -1852,6 +1896,27 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core", +] + [[package]] name = "windows-core" version = "0.62.2" @@ -1865,6 +1930,17 @@ dependencies = [ "windows-strings", ] +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + [[package]] name = "windows-implement" version = "0.60.2" @@ -1893,6 +1969,16 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core", + "windows-link", +] + [[package]] name = "windows-result" version = "0.4.1" @@ -1920,6 +2006,15 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + [[package]] name = "wit-bindgen" version = "0.57.1" diff --git a/README.md b/README.md index 4c97247..b7881f3 100644 --- a/README.md +++ b/README.md @@ -13,8 +13,10 @@ This is a [Topeka](#topeka) plugin for the [protoc compiler](https://grpc.io/doc [TODO: add instructions to debug the plugin] #### Testing the example -Install the example `mcp-vibe` server -[TODO: add instructions to run the project specific example server] +Install the example `mcp-vibe` server: +```sh +cargo install --path examples --bin mcp-vibe +``` Add the `mcp-vibe` server to your mcp servers: ```json { diff --git a/examples/Cargo.toml b/examples/Cargo.toml index 6eb5713..93fc8d4 100644 --- a/examples/Cargo.toml +++ b/examples/Cargo.toml @@ -11,6 +11,10 @@ publish = false name = "examples" path = "src/lib.rs" +[[bin]] +name = "mcp-vibe" +path = "src/bin/mcp-vibe.rs" + [dependencies] prost = "0.14" prost-types = "0.14" @@ -18,6 +22,11 @@ tonic = { version = "0.14", default-features = false, features = [ "codegen", "channel", "server", + # `mcp-vibe` (src/bin/mcp-vibe.rs) runs an in-process `VibeService` + # backend with `Server::builder().add_service(...)`, which needs the + # router feature (tests/handler.rs needed the same thing as a + # dev-dependency before this binary existed as a normal one). + "router", ] } tonic-prost = "0.14" pbjson = "0.8" @@ -25,7 +34,12 @@ pbjson-types = "0.8" serde = { version = "1", features = ["derive"] } serde_json = "1" async-trait = "0.1" -rmcp = { version = "3.5", default-features = false, features = ["server"] } +rmcp = { version = "3.5", default-features = false, features = [ + "server", + # `mcp-vibe` serves MCP over stdio (`rmcp::transport::io::stdio`); this + # was a test-only dev-dependency before this binary existed. + "transport-io", +] } futures = "0.3" # Validates tool call arguments against the generated input schema before # decoding them (see protoc-gen-rust-mcp/src/server.rs's module doc @@ -34,19 +48,25 @@ futures = "0.3" # `$ref` resolution is needed (every generated schema is self-contained), # hence `default-features = false`. jsonschema = { version = "0.58", default-features = false } +# `mcp-vibe`'s async runtime: "rt-multi-thread" and "macros" to run +# `#[tokio::main]`, "net" for the in-process backend's `TcpListener`, +# and "signal" to shut down cleanly on SIGINT/SIGTERM. +tokio = { version = "1", features = [ + "macros", + "rt-multi-thread", + "net", + "signal", +] } [dev-dependencies] -tokio = { version = "1", features = ["macros", "rt-multi-thread"] } -# "router" adds Server::builder()/add_service, to run a real in-process -# tonic server in tests/handler.rs ("server" and "channel" are already -# pulled in by [dependencies].tonic above). -tonic = { version = "0.14", default-features = false, features = ["router"] } # tests/handler.rs drives the generated server through a real rmcp -# client ("client") over an in-memory duplex transport -# ("transport-io"), instead of calling the handler directly, so it -# exercises the same wire path a real MCP client would. Needed here (in -# addition to [dependencies]'s "server") so `cargo test -p examples` -# compiles on its own, without relying on feature unification with -# protoc-gen-rust-mcp's own dev-dependency on rmcp under -# `cargo test --workspace`. -rmcp = { version = "3.5", default-features = false, features = ["client", "transport-io"] } +# client ("client") over an in-memory duplex transport, instead of +# calling the handler directly, so it exercises the same wire path a +# real MCP client would. tests/mcp_vibe_binary.rs goes one step further +# and spawns the real `mcp-vibe` binary as a subprocess +# ("transport-child-process"), driving it over its actual stdio +# transport. +rmcp = { version = "3.5", default-features = false, features = [ + "client", + "transport-child-process", +] } diff --git a/examples/src/bin/mcp-vibe.rs b/examples/src/bin/mcp-vibe.rs new file mode 100644 index 0000000..5519c4b --- /dev/null +++ b/examples/src/bin/mcp-vibe.rs @@ -0,0 +1,156 @@ +//! `mcp-vibe`: a demo MCP server for the example `VibeService` +//! (`examples/protos/example.proto`), the counterpart of Go's +//! `cmd/mcp-vibe/` (`stablekernel/protoc-gen-go-mcp` at `9e072f9`). +//! +//! It starts an in-process tonic `VibeService` backend (below), connects a +//! client to it, builds a `VibeServiceMcpServer` with +//! [`register_default_tools`](examples::v1::VibeServiceMcpServer::register_default_tools), +//! and serves MCP over stdio. Nothing writes to stdout except the MCP +//! transport: all logs go to stderr. +//! +//! Install with `cargo install --path examples --bin mcp-vibe` and point an +//! MCP client at the resulting `mcp-vibe` binary over stdio. + +use std::sync::Mutex; + +use examples::v1::vibe_service_client::VibeServiceClient; +use examples::v1::vibe_service_server::{VibeService, VibeServiceServer}; +use examples::v1::{ + GetVibeRequest, GetVibeResponse, SetVibeArrayRequest, SetVibeArrayResponse, + SetVibeDetailsRequest, SetVibeObjectsRequest, SetVibeObjectsResponse, SetVibeRequest, + SetVibeResponse, VibeServiceMcpServer, +}; +use rmcp::ServiceExt; +use tonic::transport::server::TcpIncoming; + +/// A `VibeService` backend holding a single, process-wide vibe, starting +/// at `"initial vibe"`. Only `SetVibe` and `GetVibe` are implemented, as +/// in Go's `vibe.go`; the rest return `Unimplemented`. +struct VibeServiceBackend { + vibe: Mutex, +} + +#[tonic::async_trait] +impl VibeService for VibeServiceBackend { + async fn set_vibe( + &self, + request: tonic::Request, + ) -> Result, tonic::Status> { + let new_vibe = request.into_inner().vibe; + if new_vibe.is_empty() { + // Go's `vibe.go` returns a plain `fmt.Errorf("vibe cannot be + // empty")`, which grpc-go's server interceptor reports to the + // client as `codes.Unknown` (the default for a non-`Status` + // error), not `InvalidArgument`; `tonic::Status::unknown` + // matches that. + return Err(tonic::Status::unknown("vibe cannot be empty")); + } + + let mut vibe = self.vibe.lock().unwrap_or_else(|e| e.into_inner()); + let previous_vibe = std::mem::replace(&mut *vibe, new_vibe.clone()); + + Ok(tonic::Response::new(SetVibeResponse { + previous_vibe, + vibe: new_vibe, + })) + } + + async fn get_vibe( + &self, + _request: tonic::Request, + ) -> Result, tonic::Status> { + let vibe = self.vibe.lock().unwrap_or_else(|e| e.into_inner()).clone(); + Ok(tonic::Response::new(GetVibeResponse { vibe })) + } + + async fn set_vibe_details( + &self, + _request: tonic::Request, + ) -> Result, tonic::Status> { + Err(tonic::Status::unimplemented("not implemented")) + } + + async fn set_vibe_array( + &self, + _request: tonic::Request, + ) -> Result, tonic::Status> { + Err(tonic::Status::unimplemented("not implemented")) + } + + async fn set_vibe_objects( + &self, + _request: tonic::Request, + ) -> Result, tonic::Status> { + Err(tonic::Status::unimplemented("not implemented")) + } +} + +/// Starts an in-process `VibeServiceBackend` on an OS-assigned ephemeral +/// TCP port (`127.0.0.1:0`) and returns a `VibeServiceClient` connected +/// to it, plus a handle that shuts the backend down when dropped or +/// explicitly aborted. +async fn spawn_backend() +-> Result, Box> { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?; + let addr = listener.local_addr()?; + let incoming = TcpIncoming::from(listener); + let backend = VibeServiceBackend { + vibe: Mutex::new("initial vibe".to_string()), + }; + tokio::spawn(async move { + if let Err(e) = tonic::transport::Server::builder() + .add_service(VibeServiceServer::new(backend)) + .serve_with_incoming(incoming) + .await + { + eprintln!("mcp-vibe: in-process VibeService backend stopped: {e}"); + } + }); + let client = VibeServiceClient::connect(format!("http://{addr}")).await?; + Ok(client) +} + +/// Resolves when a termination signal is received: Ctrl+C (`SIGINT` on +/// Unix), or `SIGTERM` on Unix. On non-Unix platforms only Ctrl+C is +/// handled. +async fn shutdown_signal() { + #[cfg(unix)] + { + let mut sigterm = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) + .expect("install SIGTERM handler"); + tokio::select! { + _ = tokio::signal::ctrl_c() => {} + _ = sigterm.recv() => {} + } + } + #[cfg(not(unix))] + { + let _ = tokio::signal::ctrl_c().await; + } +} + +#[tokio::main] +async fn main() -> Result<(), Box> { + let client = spawn_backend().await?; + + 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?; + eprintln!("mcp-vibe: serving MCP over stdio"); + let cancellation_token = running.cancellation_token(); + tokio::spawn(async move { + shutdown_signal().await; + eprintln!("mcp-vibe: shutting down"); + cancellation_token.cancel(); + }); + + // Resolves once the transport closes (stdin EOF), or once the spawned + // task above cancels it on SIGINT/SIGTERM. The process exits right + // after, so the signal-watcher task above never needs to be joined or + // aborted explicitly on the stdin-EOF path. + running.waiting().await?; + + Ok(()) +} diff --git a/examples/tests/mcp_vibe_binary.rs b/examples/tests/mcp_vibe_binary.rs new file mode 100644 index 0000000..a8de7f5 --- /dev/null +++ b/examples/tests/mcp_vibe_binary.rs @@ -0,0 +1,71 @@ +//! Spawns the real `mcp-vibe` binary as a subprocess and drives it over +//! stdio with a real MCP client (issue #10's acceptance criteria): +//! `initialize`, `notifications/initialized`, `tools/list`, and a +//! `tools/call` of `SetVibe` then `GetVibe`. Unlike `examples/tests/handler.rs` +//! (which exercises the generated handler in-process), this is the only +//! test that runs the actual `mcp-vibe` process end to end, proving stdio +//! framing, the in-process gRPC backend wiring, and clean shutdown all +//! work together. + +use rmcp::ServiceExt; +use rmcp::model::{CallToolRequestParams, JsonObject}; +use rmcp::transport::{ConfigureCommandExt, TokioChildProcess}; + +fn text_of(result: &rmcp::model::CallToolResult) -> &str { + let rmcp::model::ContentBlock::Text(text) = &result.content[0] else { + panic!("expected text content"); + }; + &text.text +} + +#[tokio::test] +async fn initialize_list_tools_and_call_set_then_get_vibe() { + let transport = TokioChildProcess::new( + tokio::process::Command::new(env!("CARGO_BIN_EXE_mcp-vibe")).configure(|_cmd| {}), + ) + .expect("spawn mcp-vibe"); + + let client = ().serve(transport).await.expect("initialize handshake"); + + // `initialize` + `notifications/initialized` already happened as part + // of `serve`'s handshake; `peer_info` exposes the result. + let info = client.peer_info().expect("peer info after initialize"); + let server_info = info + .server_info + .as_ref() + .expect("server_info present after initialize"); + assert_eq!(server_info.name, "vibe"); + assert_eq!(server_info.version, "0.0.1"); + + let tools = client.list_all_tools().await.expect("tools/list"); + let tool_names: Vec<&str> = tools.iter().map(|t| t.name.as_ref()).collect(); + assert!( + tool_names.contains(&"SetVibe"), + "expected SetVibe among {tool_names:?}" + ); + assert!( + tool_names.contains(&"GetVibe"), + "expected GetVibe among {tool_names:?}" + ); + + let mut args = JsonObject::new(); + args.insert("vibe".to_string(), serde_json::json!("chill")); + let set_result = client + .call_tool(CallToolRequestParams::new("SetVibe").with_arguments(args)) + .await + .expect("tools/call SetVibe"); + assert_eq!(set_result.is_error, Some(false)); + assert_eq!( + text_of(&set_result), + r#"{"previousVibe":"initial vibe","vibe":"chill"}"# + ); + + let get_result = client + .call_tool(CallToolRequestParams::new("GetVibe")) + .await + .expect("tools/call GetVibe"); + assert_eq!(get_result.is_error, Some(false)); + assert_eq!(text_of(&get_result), r#"{"vibe":"chill"}"#); + + client.cancel().await.expect("clean shutdown"); +}