Skip to content

feat: mcp-vibe example server over stdio (#10) - #27

Merged
erikist merged 1 commit into
mainfrom
factory/10-feat-mcp-vibe-example-server-over
Oct 10, 2026
Merged

erikist merged 1 commit into
mainfrom
factory/10-feat-mcp-vibe-example-server-over

Conversation

@erikist

@erikist erikist commented Oct 10, 2026

Copy link
Copy Markdown
Contributor

Closes #10

Summary

Adds the mcp-vibe binary (examples/src/bin/mcp-vibe.rs), the Rust counterpart of Go's cmd/mcp-vibe/ (stablekernel/protoc-gen-go-mcp at 9e072f9):

  • Starts an in-process tonic VibeService backend on 127.0.0.1:0 with a process-wide vibe starting at "initial vibe".
  • SetVibe/GetVibe behave like Go's vibe.go: SetVibe with an empty vibe returns an error with message "vibe cannot be empty". Go's handler returns a plain fmt.Errorf, which grpc-go reports to the client as codes.Unknown (the default for a non-Status error, not InvalidArgument), so the Rust backend uses tonic::Status::unknown to match. The other three RPCs (SetVibeDetails, SetVibeArray, SetVibeObjects) return Unimplemented, as in Go.
  • Connects a VibeServiceClient to the in-process backend, builds VibeServiceMcpServer::new(client).with_server_info(Implementation::new("vibe", "0.0.1")), and calls .register_default_tools().
  • Serves MCP over stdio (rmcp::transport::io::stdio()), shutting down cleanly on SIGINT/SIGTERM (via tokio::signal) or stdin EOF (the transport closing naturally resolves running.waiting()).
  • Only the MCP transport writes to stdout; a couple of one-line status messages ("serving MCP over stdio", "shutting down", and a backend-failure message if the in-process gRPC server task ever errors) go to stderr.

Also, since binaries can't use [dev-dependencies]:

  • Moves tonic's "router" feature (needed for Server::builder().add_service(...)) and rmcp's "transport-io" feature from examples/Cargo.toml's [dev-dependencies] (where tests/handler.rs needed them) to [dependencies], now that mcp-vibe needs them too as a normal binary.
  • Adds tokio (with rt-multi-thread, macros, net, signal) as a normal dependency for the same reason.
  • Keeps rmcp's "client" feature as a dev-dependency (only tests need an MCP client), and adds "transport-child-process" to it for the new binary test below.

Decisions

  • SetVibe's empty-vibe error code: matched Go's actual wire behavior (codes.Unknown) rather than the more semantically-fitting InvalidArgument, since parity with the Go reference takes precedence here per AGENTS.md.
  • Minimal stderr logging: the issue says "logs go to stderr" without specifying what to log; added a startup line, a shutdown line, and an error line if the in-process backend task fails, so "stdout is MCP-only, logs go to stderr" is an observable, testable property rather than vacuously true (nothing logs today without these).
  • [[bin]] lives in examples/Cargo.toml (examples/src/bin/mcp-vibe.rs), matching the issue's "or wherever it lives" and cargo install --path examples --bin mcp-vibe.

Smoke test (issue's acceptance criterion)

Built cargo build -p examples --bin mcp-vibe, then piped initialize, notifications/initialized, tools/list, and tools/call of SetVibe then GetVibe into the binary:

Input:

{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"smoke-test","version":"0.0.1"}}}
{"jsonrpc":"2.0","method":"notifications/initialized"}
{"jsonrpc":"2.0","id":2,"method":"tools/list"}
{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"SetVibe","arguments":{"vibe":"chill"}}}
{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"GetVibe","arguments":{}}}

stdout (the tools/list response is abbreviated to just the tool names below; full input schemas in that response match the existing examples/tests/mcp_tool_parity.rs fixtures):

{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-06-18","capabilities":{"tools":{}},"serverInfo":{"name":"vibe","version":"0.0.1"}}}
{"jsonrpc":"2.0","id":2,"result":{"tools":[/* GetVibe, SetVibe, SetVibeArray, SetVibeDetails, SetVibeObjects, full schemas omitted for brevity */]}}
{"jsonrpc":"2.0","id":3,"result":{"content":[{"type":"text","text":"{\"previousVibe\":\"initial vibe\",\"vibe\":\"chill\"}"}],"isError":false}}
{"jsonrpc":"2.0","id":4,"result":{"content":[{"type":"text","text":"{\"vibe\":\"chill\"}"}],"isError":false}}

stderr:

mcp-vibe: serving MCP over stdio

(responses to requests 3 and 4 arrive out of request order in one run depending on scheduling, since nothing in the protocol requires in-order completion of pipelined tools/calls against the same tool registry map — id is correct and GetVibe always echoes whatever SetVibe set.)

Also verified separately:

  • SIGTERM and SIGINT to a running mcp-vibe (reading from a FIFO, so stdin never hits EOF on its own) both cause a clean, prompt exit.
  • cargo install --path examples --bin mcp-vibe builds and installs successfully; the installed binary behaves the same as above.

Test evidence

$ cargo fmt --all --check
# (no output, clean)

$ cargo clippy --workspace --all-targets -- -D warnings
Finished `dev` profile [unoptimized + debuginfo] target(s) in ...
# (no warnings)

$ cargo build --workspace
Finished `dev` profile [unoptimized + debuginfo] target(s) in ...

$ cargo test --workspace
# all suites: 0 failed (76 tests across both crates, including the new
# examples/tests/mcp_vibe_binary.rs, which spawns the real mcp-vibe
# binary as a subprocess via rmcp::transport::TokioChildProcess and
# drives it through initialize, tools/list, and tools/call (SetVibe then
# GetVibe) over its actual stdio transport)
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out  # mcp_vibe_binary.rs

No proto or generator changes, so make generate / examples/src/gen/ are untouched.

Toolchain checked: rustc 1.99.0, cargo 1.99.0 (from rustc --version/cargo --version in this environment, matching rust-toolchain.toml). rmcp pinned at 3.5 already in examples/Cargo.toml; confirmed rmcp 3.5.1 (latest 3.5.x) is what actually resolves (cargo tree -p rmcp), and that it exposes transport::TokioChildProcess/ConfigureCommandExt under its transport-child-process feature, and transport::io::stdio() under transport-io, by reading the crate source in the local registry cache.

Self-review

Ran the reviewer subagent once against this branch and the issue's acceptance criteria. It flagged:

  • SetVibe's error code should match Go's actual codes.Unknown (not InvalidArgument) — fixed.
  • The in-process backend's spawned server task silently dropped errors — fixed (now logs to stderr if it stops).
  • No stderr logging existed to make "logs go to stderr" verifiable — fixed (added minimal startup/shutdown/error lines).
    Re-ran all checks after these fixes; still green.

Notes for reviewers

  • tonic::Status::unknown for the empty-vibe error is a deliberate parity choice (see Decisions above), not an oversight — happy to change if a human disagrees with weighing Go-parity over a more idiomatic gRPC code here.

  • The signal-watcher task spawned in main is not explicitly joined/aborted when mcp-vibe exits via the stdin-EOF path; it's harmless because the process exits right after waiting() returns, but flagging it since the reviewer agent noted it.

  • No new follow-up issues filed; this is believed to fully close out feat: mcp-vibe example server over stdio #10.

  • Factory-authored

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
@erikist

erikist commented Oct 10, 2026

Copy link
Copy Markdown
Contributor Author

🔍 Review: LGTM. Side-by-side check against Go: I sent the same stdio session (initialize, tools/list, SetVibe{chill}, SetVibe{\"\"}) to Go 0.3.0's mcp-vibe and to this PR's binary:

  • serverInfo is identical (vibe / 0.0.1).
  • tools/list is JSON-identical.
  • Both tools/call result texts match exactly, including rpc error: code = Unknown desc = vibe cannot be empty, and the isError values match.
  • Only the startup line goes to stderr.

Matching Go's real Unknown code was a good catch.

@erikist
erikist merged commit b996339 into main Oct 10, 2026
6 checks passed
@erikist
erikist deleted the factory/10-feat-mcp-vibe-example-server-over branch October 10, 2026 02:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: mcp-vibe example server over stdio

1 participant