Skip to content

feat: protojson-compatible google.protobuf.FieldMask in tool args, results and schemas (#20) - #29

Merged
erikist merged 3 commits into
mainfrom
factory/20-feat-protojson-compatible-google-protobuf-fieldmask
Oct 10, 2026
Merged

erikist merged 3 commits into
mainfrom
factory/20-feat-protojson-compatible-google-protobuf-fieldmask

Conversation

@erikist

@erikist erikist commented Oct 10, 2026

Copy link
Copy Markdown
Contributor

Closes #20

Summary

Makes google.protobuf.FieldMask protojson-compatible in tool arguments, results and schemas, matching Go's protoc-gen-go-mcp instead of pbjson's raw wire shape ({"paths": [...]}).

  • Schema (schema.rs): google.protobuf.FieldMask now maps to {"type": "string"}, exactly like Go, instead of the plain-message {"type":"object","properties":{"paths":...}} shape pbjson's own serde would otherwise imply.
  • Generated per-message JSON rewrites (server.rs + new field_mask.rs): for each RPC's request/response message that transitively contains a FieldMask field (through singular, repeated or map-valued message fields, including recursive messages), the generator emits a memoized <service>_fm_in_<message>/<service>_fm_out_<message> function, keyed by each field's json_name. Two shared per-service helpers (<service>_fm_str_to_obj/<service>_fm_obj_to_str) do the actual conversion between protojson's comma-joined lowerCamel string and pbjson's {"paths": [...]} shape, built on two small path-segment case converters matching protojson's strs.JSONSnakeCase/JSONCamelCase rules.
  • Handler order: validate (schema says string) → fm_in on the raw arguments → pbjson decode → tonic call → serde_json::to_value → fm_out → to_string. A method whose request and response have no FieldMask anywhere generates exactly the same handler body as before (no extra branch, no let mut): example.proto has no FieldMask field, so the golden file and its parity fixture are byte-for-byte unchanged.
  • Tests: added an UpdateVibe RPC to test-fixtures/testdata/mcpgen/fixture.proto with a top-level FieldMask, one nested inside a message field, and a repeated FieldMask in the request, and a response that echoes all three back — exercised end-to-end over a real in-process tonic backend and MCP transport in protoc-gen-rust-mcp/tests/server_integration.rs. Also unit tests in field_mask.rs for the transitive-containment detection (direct, nested, map-valued, and purely-cyclic-without-FieldMask cases) and the generated rewrite bodies.

Design decisions

Followed the approach a maintainer outlined directly in a comment on #20 (option (a) from the issue: the generator knows statically where FieldMask fields are, emits small JSON rewrites, no extern_path/build-setup burden on users). Two decisions beyond that comment, both confirmed by the end-to-end tests:

  • A present-but-empty FieldMask (updateMask: "") round-trips through pbjson as {} (proto3 message-field presence means it isn't omitted the way an absent field would be); fm_out converts that {} to "" rather than leaving it as an empty object, matching what Go's protojson would also produce for a zero-path FieldMask.
  • The shared helpers/converters are emitted once per service (not deduplicated across services sharing one generated file), prefixed with the service's own snake_case name, consistent with how the existing ..._INPUT_SCHEMA/..._VALIDATOR constants already avoid cross-service name collisions in one include!d file.

README

Issue #20 asked for a "Differences from protoc-gen-go-mcp" section (or to tell #13, the dedicated README-parity issue, what goes in it). #13 is still open and owns the whole README rewrite, so I left a note there (see the comment I'm about to leave on #13) instead of writing a partial section here that #13 would have to reconcile.

Test evidence

Ran exactly the checks AGENTS.md specifies, from the repo root:

cargo fmt --all --check
cargo clippy --workspace --all-targets -- -D warnings
cargo build --workspace
cargo test --workspace

All pass: cargo fmt --all --check is silent (no diff), cargo clippy reports no warnings, cargo build --workspace succeeds, and cargo test --workspace passes all suites, including:

  • protoc-gen-rust-mcp's golden.rs (committed golden file unchanged) and example_proto_descriptions.rs,
  • protoc-gen-rust-mcp's new field_mask unit tests (10 tests) and updated schema tests,
  • protoc-gen-rust-mcp/tests/server_integration.rs's new UpdateVibe tests (schema shape, top-level/nested/repeated decode, result encoding, empty-mask round-trip, wrong-type rejection),
  • examples' e2e.rs, handler.rs, mcp_tool_parity.rs, mcp_vibe_binary.rs (all pre-existing, unaffected).

Also ran:

make generate
git status   # no diff: examples/src/gen/** is unchanged, confirming the
             # no-FieldMask-anywhere code path is byte-identical to before

Rust toolchain/compiler versions used: as pinned by rust-toolchain.toml and the factory image (protoc 29.3, protoc-gen-prost/protoc-gen-tonic/protoc-gen-prost-serde per AGENTS.md); no dependency versions changed (no Cargo.toml/Cargo.lock diff).

Notes for reviewers

  • Self-reviewed via the reviewer subagent against this issue's acceptance criteria; no blocking findings. Two optional gaps it flagged, left as follow-ups rather than blocking this PR:

    • No end-to-end test for a FieldMask reached through a repeated message field (only a repeated FieldMask itself and a singular nested message are covered); the code path looks correct by inspection and by the map-valued unit test, just not exercised end-to-end for that exact shape.
    • No explicit test for an entirely absent FieldMask field (only the empty-string-but-present case is tested); the rewrite functions no-op safely when a key is simply missing from the JSON object, by construction.
  • This branch does not touch examples/, .github/workflows/**, AGENTS.md, docs/GOAL.md, CHANGELOG.md, or the release-please config/manifest.

  • test-fixtures/build.rs now also routes google.protobuf well-known types to pbjson_types for the mcpgen fixture (needed for UpdateVibeRequest/Response to have the Serialize/Deserialize impls the new handler code calls), mirroring what schemapb's build config already did.

  • Factory-authored

sf-factory-bot added 3 commits October 10, 2026 03:22
FieldMask's schema now matches Go's protojson-compatible encoding
("type": "string") instead of pbjson's raw {"paths": [...]}
shape. server.rs's generated per-message JSON rewrites (next commit)
make the actual argument/result encoding match this schema.
For a request or response message that transitively contains a
google.protobuf.FieldMask field (through singular, repeated or
map-valued message fields, including recursive messages), the
generator now emits a memoized <service>_fm_in_<message>/
<service>_fm_out_<message> function and wires it into the method's
handler: fm_in runs on the raw JSON arguments after schema validation
and before the pbjson decode, fm_out runs on the pbjson-encoded
response before it is stringified.

Two shared per-service helpers (<service>_fm_str_to_obj/
<service>_fm_obj_to_str) convert between protojson's comma-joined
lowerCamel path string and pbjson's own {"paths": [...]} shape,
built on two small lowerCamel<->snake_case path-segment converters
matching protojson's strs.JSONSnakeCase/JSONCamelCase rules.

Methods whose request and response have no FieldMask field anywhere
(every method in example.proto) generate exactly the same handler
body as before: the golden file and example.proto's parity fixture
are unchanged.
…tes (#20)

Add an UpdateVibe RPC to test-fixtures' mcpgen fixture with a
top-level FieldMask, one nested inside a message field, and a
repeated FieldMask, plus a response that echoes all three back, to
exercise the generator's new fm_in/fm_out wiring end to end over a
real in-process tonic backend and MCP transport:

- the tool's input schema maps every FieldMask field to
  {"type": "string"}, matching Go
- {"updateMask": "vibe,someField"} decodes into paths
  ["vibe", "some_field"]; the nested and repeated masks convert
  the same way
- a response FieldMask comes back as a comma-joined lowerCamel string
- an empty mask string round-trips to "", not an empty object
- a non-string updateMask is rejected by schema validation before the
  backend is ever called

Also add protoc-gen-rust-mcp/src/field_mask.rs unit tests for a
map<string, FieldMask> field (needs_rewrite and the generated
iterate-values rewrite body), rounding out singular, repeated and map
field coverage.

make generate and the golden/parity tests are confirmed unchanged for
example.proto, which has no FieldMask field.
@erikist

erikist commented Oct 10, 2026

Copy link
Copy Markdown
Contributor Author

🔍 Review: LGTM. It follows the design, leaves example.proto's output byte-identical (no examples/src/gen changes, so the golden and parity tests are untouched), and the end-to-end test covers top-level, nested and repeated masks plus the echoed result. The empty-mask → "" choice matches protojson. CI is green on all 6. Thanks for handing the Differences section to #13.

@erikist
erikist merged commit 7236582 into main Oct 10, 2026
6 checks passed
@erikist
erikist deleted the factory/20-feat-protojson-compatible-google-protobuf-fieldmask branch October 10, 2026 04:16
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: protojson-compatible google.protobuf.FieldMask in tool args, results and schemas

1 participant