Skip to content

feat: generate tool handlers (validate, pbjson decode, tonic call, pbjson encode) #7

Closed

Description

@erikist

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:

  1. 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.
  2. Decodes with the pbjson-generated Deserialize impl (serde_json::from_value::<Request>). On failure, return a tool error.
  3. 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).
  4. 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.
  • The checks in AGENTS.md pass.

Activity

  1. erikist commented on Oct 9, 2026

    @erikist
    ContributorAuthor

    🔍 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.

  2. erikist commented on Oct 9, 2026

    @erikist
    ContributorAuthor

    🤖 Factory: picked up by dispatcher; working on branch factory/7-feat-generate-tool-handlers-validate-pbjson.

  3. erikist commented on Oct 10, 2026

    @erikist
    ContributorAuthor

    🔍 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 Code Debug 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:
      T: tonic::client::GrpcService<tonic::body::Body> + Clone + Send + Sync + 'static,
      T::Error: Into<StdError>,
      T::ResponseBody: Body<Data = Bytes> + Send + 'static,
      <T::ResponseBody as Body>::Error: Into<StdError> + Send,
      T::Future: Send,
      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};
    
    static SCHEMA: LazyLock<Arc<JsonObject>> = LazyLock::new(|| {
        Arc::new(
            serde_json::from_str(
                r#"{"type":"object","properties":{"vibe":{"type":"string"}},"additionalProperties":false}"#,
            )
            .unwrap(),
        )
    });
    static VALIDATOR: LazyLock<jsonschema::Validator> = LazyLock::new(|| {
        jsonschema::validator_for(&serde_json::Value::Object((**SCHEMA).clone()))
            .unwrap_or_else(|e| panic!("compiling generated input schema: {e}"))
    });
    
    fn tool_error(text: String) -> CallToolResult {
        CallToolResult::error(vec![ContentBlock::text(text)])
    }
    
    async fn call_set_vibe<T>(mut client: VibeServiceClient<T>, args: Option<JsonObject>) -> CallToolResult
    where
        T: tonic::client::GrpcService<tonic::body::Body>,
        T::Error: Into<StdError>,
        T::ResponseBody: Body<Data = Bytes> + Send + 'static,
        <T::ResponseBody as Body>::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() {
            return tool_error(format!("invalid arguments: {}", errors.join("; ")));
        }
        let request: SetVibeRequest = match serde_json::from_value(args) {
            Ok(r) => r,
            Err(e) => return tool_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.
    fn assert_send<F: std::future::Future + Send + 'static>(_: F) {}
    
    struct Fake;
    #[tonic::async_trait]
    impl VibeService for Fake {
        async fn set_vibe(
            &self,
            r: tonic::Request<SetVibeRequest>,
        ) -> Result<tonic::Response<SetVibeResponse>, tonic::Status> {
            let vibe = r.into_inner().vibe;
            if vibe == "missing" {
                return Err(tonic::Status::not_found("vibe not found"));
            }
            Ok(tonic::Response::new(SetVibeResponse { previous_vibe: String::new(), vibe }))
        }
        async fn get_vibe(
            &self,
            _: tonic::Request<GetVibeRequest>,
        ) -> Result<tonic::Response<GetVibeResponse>, tonic::Status> {
            Err(tonic::Status::unimplemented(""))
        }
        async fn set_vibe_details(
            &self,
            _: tonic::Request<SetVibeDetailsRequest>,
        ) -> Result<tonic::Response<SetVibeResponse>, tonic::Status> {
            Err(tonic::Status::unimplemented(""))
        }
        async fn set_vibe_array(
            &self,
            _: tonic::Request<SetVibeArrayRequest>,
        ) -> Result<tonic::Response<SetVibeArrayResponse>, tonic::Status> {
            Err(tonic::Status::unimplemented(""))
        }
        async fn set_vibe_objects(
            &self,
            _: tonic::Request<SetVibeObjectsRequest>,
        ) -> Result<tonic::Response<SetVibeObjectsResponse>, tonic::Status> {
            Err(tonic::Status::unimplemented(""))
        }
    }
    
    #[tokio::test]
    async fn handler_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)]
    fn generic_bounds_check<T>(client: VibeServiceClient<T>)
    where
        T: tonic::client::GrpcService<tonic::body::Body> + Clone + Send + Sync + 'static,
        T::Error: Into<StdError>,
        T::ResponseBody: Body<Data = Bytes> + Send + 'static,
        <T::ResponseBody as Body>::Error: Into<StdError> + Send,
        T::Future: Send,
    {
        assert_send(call_set_vibe(client, None));
    }
  4. erikist commented on Oct 10, 2026

    @erikist
    ContributorAuthor

    🤖 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).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions