diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 73ff341..8a49caf 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -14,4 +14,39 @@ Please note we have a [code of conduct](CODE_OF_CONDUCT.md), please follow it in 2. Make your changes, and ensure that your code passes all tests. We prefer that you use small commits for each logical change with a descriptive commit message. 3. Maintainers will be notified of your pull request and will review it. If you have any questions, please feel free to ask. 4. Make sure you leave a good description of your changes in the pull request. This will help us understand your changes and make it easier for us to review them. Also please refernce the issue that this fixes in the description, eg `Fixes #1234`. -5. Please follow our code of conduct in all your interactions with the project. Good discourse is always welcome and we view a healthy discourse on both PRs and issues as a sign of a healthy project. \ No newline at end of file +5. Please follow our code of conduct in all your interactions with the project. Good discourse is always welcome and we view a healthy discourse on both PRs and issues as a sign of a healthy project. + +### Running the tests +Run these before opening a pull request, and paste the commands and their +results in the PR description (see [`AGENTS.md`](AGENTS.md)): + +```sh +gofmt -l . # must print nothing +go vet ./... +go build ./... +go test ./... +``` + +### Updating the golden file +`cmd/protoc-gen-go-mcp/golden_test.go` compares the plugin's generated code +byte-for-byte against the committed +[`examples/gen/example/v1/example_mcp.pb.go`](examples/gen/example/v1/example_mcp.pb.go). +If you change the generator (`cmd/protoc-gen-go-mcp/**`) in a way that +deliberately changes its output, update the golden file and review the diff — +an unexpected change is a bug until explained: + +```sh +go test ./cmd/protoc-gen-go-mcp -run Golden -update +``` + +### Regenerating the examples +If you changed the generator or `examples/protos/example.proto`, regenerate +the committed example code and commit the result in the same PR: + +```sh +make generate # needs protoc 29.3, protoc-gen-go v1.36.6, protoc-gen-go-grpc v1.5.1 on PATH +git status # the regenerated files belong in the same PR +``` + +A CI job also fails the build if the committed examples are out of date with +what `make generate` would produce. diff --git a/README.md b/README.md index 31c2311..a659d87 100644 --- a/README.md +++ b/README.md @@ -3,9 +3,16 @@ protoc-gen-go-mcp This is a [Topeka](#topeka) plugin for the [protoc compiler](https://grpc.io/docs/protoc-installation/) that generates a [model-context-protocol(MCP)](https://modelcontextprotocol.io/introduction) server based on a [protocol buffer](https://protobuf.dev/) definition. Conceptually, this allows an AI model to use existing [gRPC](https://grpc.io/) codebases with natural language, allowing for rapid prototyping and usage of LLM capabilities for protobuf based codebases. #### Prerequisites -- [Go](https://go.dev/doc/install) 1.25 or later -- [protoc](https://grpc.io/docs/protoc-installation/) 3.20 or later -- [protoc-gen-go-grpc](https://grpc.io/docs/languages/go/quickstart/) 1.71 or later +- [Go](https://go.dev/doc/install) 1.25 or later (the generated code imports + the official MCP SDK, which requires it) +- [protoc](https://grpc.io/docs/protoc-installation/) 29.3 (the version this + plugin is tested against; see [`Makefile`](./Makefile) and + [`AGENTS.md`](./AGENTS.md)) +- [protoc-gen-go](https://pkg.go.dev/google.golang.org/protobuf/cmd/protoc-gen-go) + v1.36.6 and + [protoc-gen-go-grpc](https://pkg.go.dev/google.golang.org/grpc/cmd/protoc-gen-go-grpc) + v1.5.1 (the exact versions pinned as `tool` directives in + [`go.mod`](./go.mod)) #### Running the plugin Check out the [Makefile](./Makefile) for explicit command usage. Use `make generate` to generate the example MCP server from the [proto file](./examples/protos/example.proto). @@ -49,6 +56,83 @@ Add the `mcp-vibe` server to your mcp servers: } ``` +#### What the plugin generates +A generated `NewMCPServer` wraps a gRPC client and an +[`*mcp.Server`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#Server) +from the [official Go MCP SDK](https://github.com/modelcontextprotocol/go-sdk). +Once you've called `RegisterDefaultTools()` (or registered individual tools, +see below), serve that `*mcp.Server` the way any MCP server is served. The +two most common transports: + +```golang +// client is a examplev1.VibeServiceClient for the backend gRPC service, e.g. +// examplev1.NewVibeServiceClient(conn) for a grpc.ClientConn. + +// Over stdio, for CLI-launched MCP clients (see cmd/mcp-vibe/main.go for the +// full, runnable version of this). +stdioServer := mcp.NewServer(&mcp.Implementation{Name: "vibe", Version: "0.0.1"}, nil) +examplev1.NewVibeServiceMCPServer(client, stdioServer).RegisterDefaultTools() +if err := stdioServer.Run(ctx, &mcp.StdioTransport{}); err != nil { + panic(err) +} +``` + +```golang +// Over Streamable HTTP, for network clients. client is the same +// examplev1.VibeServiceClient as above. +httpServer := mcp.NewServer(&mcp.Implementation{Name: "vibe", Version: "0.0.1"}, nil) +examplev1.NewVibeServiceMCPServer(client, httpServer).RegisterDefaultTools() +handler := mcp.NewStreamableHTTPHandler(func(*http.Request) *mcp.Server { + return httpServer +}, nil) +if err := http.ListenAndServe(":8080", handler); err != nil { + panic(err) +} +``` + +Both snippets are kept compiling in +[`examples/gen/example/v1/example_test.go`](./examples/gen/example/v1/example_test.go) +(`Example_wiring`), so they stay in sync with the generated API; `go vet` and +`go test` cover that file on every run of the [checks](./AGENTS.md) (it is a +`_test.go` file, so plain `go build` skips it). + +#### How tool arguments map to the request message +Each tool's `InputSchema` is the [JSON Schema](https://json-schema.org/) for +its RPC's request message, and the generated handler decodes incoming +arguments with +[`protojson.Unmarshal`](https://pkg.go.dev/google.golang.org/protobuf/encoding/protojson#Unmarshal). +That means tool arguments follow protojson's mapping, not the proto field +names or Go struct tags: + +- **Field names are protojson names** (lowerCamel by default, e.g. a proto + field `previous_vibe` is the JSON key `previousVibe`), not the original + snake_case proto name. The schema's `additionalProperties: false` rejects + an unrecognized key — including the snake_case form — as a tool error + rather than silently ignoring or coercing it. +- **64-bit integers (`int64`, `uint64`, `sint64`, `fixed64`, `sfixed64`) are + accepted as either a JSON number or a decimal string** (`"type": + ["integer", "string"]`), matching protojson's own encoding of them as + strings (to avoid precision loss in JSON's float64 number type). All other + integer kinds are plain JSON numbers. +- **Enum fields are their value's name as a string** (e.g. `"VIBE_GOOD"`), + not the underlying numeric value, matching protojson's default enum + encoding. +- **`bytes` fields are base64-encoded strings**, per protojson's `bytes` + mapping. +- **`float`/`double` fields also accept the strings `"NaN"`, `"Infinity"` + and `"-Infinity"`** (in addition to a JSON number), matching protojson's + encoding of those non-finite values (a plain JSON number can't represent + them). +- Tool call results are protojson-encoded the same way, so a result's keys + and enum/int64 representations follow the same rules — **including that + protojson omits zero-valued fields** (an empty string, `0`, `false`, an + unset enum, etc.) from the result entirely, rather than emitting them + explicitly. +- **A gRPC error from the backend becomes a tool error** (`isError: true`) + whose text is the error as returned by the generated client, which for a + `status.Error` includes the gRPC status code (e.g. `rpc error: code = + NotFound desc = vibe not found`). + #### Philosophical Notes The plugin uses the existing code generation for protocol buffers and gRPC servers and builds upon that base, using and reusing parts where necessary. This gives us a healthy amount of code reuse while allowing us to control what we expose to end users. We want this plugin to provide sane, out-of-the-box functionality while allowing for easy extension. @@ -102,6 +186,51 @@ func (s *vibeServiceMCPServer) RegisterDefaultTools() { } ``` +#### Upgrading from 0.2.x +0.3.0 moves generated code from +[`github.com/mark3labs/mcp-go`](https://github.com/mark3labs/mcp-go) to the +[official Go MCP SDK](https://github.com/modelcontextprotocol/go-sdk) +(`github.com/modelcontextprotocol/go-sdk`, requiring Go 1.25). Regenerating +with 0.3.0 changes the generated API: + +- **`NewMCPServer` takes a + [`*mcp.Server`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#Server)** + (was `*server.MCPServer`), and the generated struct's exported `MCPServer` + field has the same new type. Construct it with `mcp.NewServer(&mcp.Implementation{...}, nil)` + and serve it as shown in [What the plugin generates](#what-the-plugin-generates) + above. +- **`XxxTool()` returns `*mcp.Tool`** (was `mcp.Tool`), built directly as + `&mcp.Tool{Name, Description, InputSchema}` rather than via `mcp.NewTool(...)`. +- **`RegisterTool`'s signature changed**: its tool parameter is now `*mcp.Tool` + (was `mcp.Tool`) and its handler parameter is now + `mcp.ToolHandlerFor[json.RawMessage, any]` — i.e. + `func(ctx context.Context, req *mcp.CallToolRequest, args json.RawMessage) (*mcp.CallToolResult, any, error)` + (was `func(context.Context, mcp.CallToolRequest) (*mcp.CallToolResult, error)`). + Registration now goes through + [`mcp.AddTool`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#AddTool), + so the SDK validates incoming arguments against the tool's input schema + before the handler ever runs — the old SDK didn't enforce the schema at + all. +- **Each tool's `InputSchema` is now the top-level request-message schema** + (previously nested under a property named after the request message). +- **Tool call arguments and results are protojson**, not the old + reflection-based encoding: see + [How tool arguments map to the request message](#how-tool-arguments-map-to-the-request-message) + above for the field-naming, 64-bit-integer, and enum differences this + implies. In particular, a caller sending the old snake_case field names + (e.g. `previous_vibe`) now gets a tool error, because the schema's + `additionalProperties: false` rejects unrecognized keys. Results are also + affected: **protojson omits zero-valued fields**, so a caller that relied + on seeing an explicit `0`/`""`/`false` for an unset field in the old + encoding now won't see that key in the result at all. +- **Go 1.25 or later is required** to build generated code and this plugin. + +To upgrade: update Go, `protoc-gen-go`, and `protoc-gen-go-grpc` to the +versions in [Prerequisites](#prerequisites); regenerate with +`protoc --go-mcp_out=...` (or `make generate` for the example); update any +callers that construct the generated server, call `RegisterTool` directly, +or send/parse tool arguments or results, per the changes above. + #### Topeka [Topeka](https://topeka.ai) is an open source project that provides code-generators for [Model-Context-Protocol (MCP)](https://modelcontextprotocol.io/introduction). diff --git a/examples/gen/example/v1/example_test.go b/examples/gen/example/v1/example_test.go new file mode 100644 index 0000000..abef6d6 --- /dev/null +++ b/examples/gen/example/v1/example_test.go @@ -0,0 +1,49 @@ +// This file backs the README's "What the plugin generates" section: go vet +// and go test cover it (it is a _test.go file, so plain go build skips it), +// so the wiring snippets shown there cannot silently drift from code that +// actually compiles against the generated API. It is not meant to be run as +// a test (Example_wiring never returns), only compiled. +package examplev1_test + +import ( + "context" + "net/http" + + "github.com/modelcontextprotocol/go-sdk/mcp" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" + + examplev1 "protoc-gen-go-mcp/examples/gen/example/v1" +) + +// Example_wiring shows how to serve a generated MCP server over both stdio +// and Streamable HTTP, given a gRPC client for the service it wraps. It is +// compiled by `go vet`/`go test` but never run (it blocks forever), so it +// has no "Output:" comment. +func Example_wiring() { + ctx := context.Background() + + conn, err := grpc.NewClient("localhost:50051", grpc.WithTransportCredentials(insecure.NewCredentials())) + if err != nil { + panic(err) + } + client := examplev1.NewVibeServiceClient(conn) + + // Over stdio: one server, one session, for CLI-launched MCP clients. + stdioServer := mcp.NewServer(&mcp.Implementation{Name: "vibe", Version: "0.0.1"}, nil) + examplev1.NewVibeServiceMCPServer(client, stdioServer).RegisterDefaultTools() + if err := stdioServer.Run(ctx, &mcp.StdioTransport{}); err != nil { + panic(err) + } + + // Over Streamable HTTP: a new *mcp.Server per request (or reuse one, as + // here, if sessions don't need to be isolated from each other). + httpServer := mcp.NewServer(&mcp.Implementation{Name: "vibe", Version: "0.0.1"}, nil) + examplev1.NewVibeServiceMCPServer(client, httpServer).RegisterDefaultTools() + handler := mcp.NewStreamableHTTPHandler(func(*http.Request) *mcp.Server { + return httpServer + }, nil) + if err := http.ListenAndServe(":8080", handler); err != nil { + panic(err) + } +}