You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Part of #2. Generate the real tool handlers, the counterpart of Go's generateHandler in mcp.go at 9e072f9 together with the Go SDK's behavior around it. That includes the argument validation the Go SDK does and rmcp doesn't.
Do
For each unary RPC, the generated <rpc>_handler:
Validates the arguments (None is treated as {}) against the tool's input schema with the jsonschema crate. Compile the validator once per tool, e.g. in a LazyLock.
On failure, return CallToolResult::error with text naming the failing instance path(s) and the reasons. The backend must not be called.
Decodes with the pbjson-generated Deserialize impl (serde_json::from_value::<Request>). On failure, return a tool error.
Calls the tonic client: clone it, and pass a tonic::Request.
On a tonic::Status, return a tool error whose text matches grpc-go's format, which is what Go users see: rpc error: code = NotFound desc = vibe not found (the code's CamelCase name, then the message).
Encodes the response with the pbjson Serialize impl (serde_json::to_string). Return CallToolResult::success(vec![Content::text(json)]).
Like Go's protojson.Marshal, zero-valued fields are omitted, int64s are strings and enums are names.
If there's a pbjson/protojson difference visible here, list it in the PR.
Only errors in the MCP machinery itself become JSON-RPC errors. Bad arguments, decode failures and gRPC errors are all tool errors (isError: true), as in Go.
Regenerate the example, and update the golden file (#8).
Acceptance criteria
Unit tests on the generated example server, against an in-process tonic backend or a mock GrpcService, cover:
success, with the result JSON matching protojson conventions
a gRPC NotFound → isError with rpc error: code = NotFound desc = …
a wrong argument type → isError, backend not called
an unknown field (incl. the snake_case previous_vibe form) → isError, backend not called
an int64 accepted both as a number and as a string; an enum by name
NaN/Infinity floats
make generate reproduces the committed example, and the golden test passes.
🔍 Heads-up from the #18 review: pbjson serializes non-finite floats as null (Go's protojson emits "NaN", "Infinity" and "-Infinity"), so tool results differ from Go there. Inputs are fine. List this in the PR's pbjson-differences section, and pin it with a test. Making results match Go is a nice-to-have, not required.
🔍 Guidance: a verified handler body. Written against current main's generated example code, and run against a real in-process tonic server (examples/tests/proto_handler.rs in a scratch checkout):
ok: {"content":[{"type":"text","text":"{\"vibe\":\"chill\"}"}],"isError":false}
grpc error: {"content":[{"type":"text","text":"rpc error: code = NotFound desc = vibe not found"}],"isError":true}
wrong type: {"content":[{"type":"text","text":"invalid arguments: /vibe: 5 is not of type \"string\""}],"isError":true}
unknown field: {"content":[{"type":"text","text":"invalid arguments: : Additional properties are not allowed ('previous_vibe' was unexpected)"}],"isError":true}
Key facts:
Dependencies:jsonschema = { version = "0.58", default-features = false } is enough (no HTTP resolver). Validate with validator_for(&Value::Object((**SCHEMA).clone())) in a per-tool LazyLock<jsonschema::Validator>, next to the existing schema static, with the same service-prefixed name.
Status text:format!("rpc error: code = {:?} desc = {}", status.code(), status.message()). tonic's CodeDebug impl prints grpc-go's CamelCase names.
Bounds: these make the handler future Send + 'static, which the ToolHandler closures need. Use them on the generated impl<T> and the ServerHandler impl:
StdError, Body and Bytes come from tonic::codegen.
Tonic features for tests: a test-side tonic server needs the router feature (Server::add_service), plus tonic::transport::server::TcpIncoming::from(tokio::net::TcpListener) for an ephemeral port.
Commit and push early. A push resets the 2-hour limit.
prototype
//! Prototype of the #7 handler body, written as the generator would emit it.use std::sync::{Arc,LazyLock};use examples::v1::vibe_service_client::VibeServiceClient;use examples::v1::vibe_service_server::{VibeService,VibeServiceServer};use examples::v1::*;use rmcp::model::{CallToolResult,ContentBlock,JsonObject};use tonic::codegen::{Body,Bytes,StdError};staticSCHEMA:LazyLock<Arc<JsonObject>> = LazyLock::new(|| {Arc::new(
serde_json::from_str(r#"{"type":"object","properties":{"vibe":{"type":"string"}},"additionalProperties":false}"#,).unwrap(),)});staticVALIDATOR:LazyLock<jsonschema::Validator> = LazyLock::new(|| {
jsonschema::validator_for(&serde_json::Value::Object((**SCHEMA).clone())).unwrap_or_else(|e| panic!("compiling generated input schema: {e}"))});fntool_error(text:String) -> CallToolResult{CallToolResult::error(vec![ContentBlock::text(text)])}asyncfncall_set_vibe<T>(mutclient:VibeServiceClient<T>,args:Option<JsonObject>) -> CallToolResultwhereT: tonic::client::GrpcService<tonic::body::Body>,T::Error:Into<StdError>,T::ResponseBody:Body<Data = Bytes> + Send + 'static,
<T::ResponseBodyasBody>::Error:Into<StdError> + Send,{let args = serde_json::Value::Object(args.unwrap_or_default());let errors:Vec<String> = VALIDATOR.iter_errors(&args).map(|e| format!("{}: {}", e.instance_path(), e)).collect();if !errors.is_empty(){returntool_error(format!("invalid arguments: {}", errors.join("; ")));}let request:SetVibeRequest = match serde_json::from_value(args){Ok(r) => r,Err(e) => returntool_error(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) => CallToolResult::success(vec![ContentBlock::text(json)]),Err(e) => tool_error(format!("encoding response: {e}")),},// grpc-go's status.Error format, which Go's generated server returns verbatim.Err(status) => tool_error(format!("rpc error: code = {:?} desc = {}",
status.code(),
status.message())),}}/// The generated server's ToolHandler closures need a 'static + Send future.fnassert_send<F: std::future::Future + Send + 'static>(_:F){}structFake;#[tonic::async_trait]implVibeServiceforFake{asyncfnset_vibe(&self,r: tonic::Request<SetVibeRequest>,) -> Result<tonic::Response<SetVibeResponse>, tonic::Status>{let vibe = r.into_inner().vibe;if vibe == "missing"{returnErr(tonic::Status::not_found("vibe not found"));}Ok(tonic::Response::new(SetVibeResponse{previous_vibe:String::new(), vibe }))}asyncfnget_vibe(&self,
_: tonic::Request<GetVibeRequest>,) -> Result<tonic::Response<GetVibeResponse>, tonic::Status>{Err(tonic::Status::unimplemented(""))}asyncfnset_vibe_details(&self,
_: tonic::Request<SetVibeDetailsRequest>,) -> Result<tonic::Response<SetVibeResponse>, tonic::Status>{Err(tonic::Status::unimplemented(""))}asyncfnset_vibe_array(&self,
_: tonic::Request<SetVibeArrayRequest>,) -> Result<tonic::Response<SetVibeArrayResponse>, tonic::Status>{Err(tonic::Status::unimplemented(""))}asyncfnset_vibe_objects(&self,
_: tonic::Request<SetVibeObjectsRequest>,) -> Result<tonic::Response<SetVibeObjectsResponse>, tonic::Status>{Err(tonic::Status::unimplemented(""))}}#[tokio::test]asyncfnhandler_end_to_end(){let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();let addr = listener.local_addr().unwrap();let incoming = tonic::transport::server::TcpIncoming::from(listener);
tokio::spawn(
tonic::transport::Server::builder().add_service(VibeServiceServer::new(Fake)).serve_with_incoming(incoming),);let client = VibeServiceClient::connect(format!("http://{addr}")).await.unwrap();let obj = |v: serde_json::Value| v.as_object().cloned();assert_send(call_set_vibe(client.clone(),None));for(label, args)in[("ok",obj(serde_json::json!({"vibe":"chill"}))),("grpc error",obj(serde_json::json!({"vibe":"missing"}))),("wrong type",obj(serde_json::json!({"vibe":5}))),("unknown field",obj(serde_json::json!({"previous_vibe":"x"}))),]{let r = call_set_vibe(client.clone(), args).await;println!("{label}: {}", serde_json::to_string(&r).unwrap());}}/// Minimal bounds for a generic `T` so the handler future is `Send + 'static`/// (what the generated server's `ToolHandler` closures need).#[allow(dead_code)]fngeneric_bounds_check<T>(client:VibeServiceClient<T>)whereT: tonic::client::GrpcService<tonic::body::Body> + Clone + Send + Sync + 'static,T::Error:Into<StdError>,T::ResponseBody:Body<Data = Bytes> + Send + 'static,
<T::ResponseBodyasBody>::Error:Into<StdError> + Send,T::Future:Send,{assert_send(call_set_vibe(client,None));}
🤖 Factory: opened PR #25, which implements the real handler bodies (validate with jsonschema, pbjson decode, tonic call with grpc-go-formatted errors, pbjson encode), regenerates the committed example, and updates the golden file. All AGENTS.md checks pass; see the PR for test evidence and the pbjson NaN/Infinity divergence note (per the earlier review comment on this issue).
Activity
erikist commentedon Oct 9, 2026
🔍 Heads-up from the #18 review: pbjson serializes non-finite floats as
null(Go's protojson emits"NaN","Infinity"and"-Infinity"), so tool results differ from Go there. Inputs are fine. List this in the PR's pbjson-differences section, and pin it with a test. Making results match Go is a nice-to-have, not required.feat: generate MCP tool definitions and server registration API (#6)
feat: generate MCP tool definitions and the server registration API (#6…
erikist commentedon Oct 9, 2026
🤖 Factory: picked up by dispatcher; working on branch factory/7-feat-generate-tool-handlers-validate-pbjson.
erikist commentedon Oct 10, 2026
🔍 Guidance: a verified handler body. Written against current
main's generated example code, and run against a real in-process tonic server (examples/tests/proto_handler.rsin a scratch checkout):Key facts:
jsonschema = { version = "0.58", default-features = false }is enough (no HTTP resolver). Validate withvalidator_for(&Value::Object((**SCHEMA).clone()))in a per-toolLazyLock<jsonschema::Validator>, next to the existing schema static, with the same service-prefixed name.format!("rpc error: code = {:?} desc = {}", status.code(), status.message()). tonic'sCodeDebugimpl prints grpc-go's CamelCase names.Send + 'static, which theToolHandlerclosures need. Use them on the generatedimpl<T>and theServerHandlerimpl:StdError,BodyandBytescome fromtonic::codegen.routerfeature (Server::add_service), plustonic::transport::server::TcpIncoming::from(tokio::net::TcpListener)for an ephemeral port.Commit and push early. A push resets the 2-hour limit.
prototype
fix(generator): update stale stub-handler wording after self-review (#7)
erikist commentedon Oct 10, 2026
🤖 Factory: opened PR #25, which implements the real handler bodies (validate with
jsonschema, pbjson decode, tonic call with grpc-go-formatted errors, pbjson encode), regenerates the committed example, and updates the golden file. AllAGENTS.mdchecks pass; see the PR for test evidence and the pbjson NaN/Infinity divergence note (per the earlier review comment on this issue).test(examples): pin NaN-result and enum-as-number review follow-ups (#7)
feat: generate tool handlers (validate, pbjson decode, tonic call, pb…