Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 36 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
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.
135 changes: 132 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -49,6 +56,83 @@ Add the `mcp-vibe` server to your mcp servers:
}
```

#### What the plugin generates
A generated `New<Service>MCPServer` 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.

Expand Down Expand Up @@ -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:

- **`New<Service>MCPServer` 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).

Expand Down
49 changes: 49 additions & 0 deletions examples/gen/example/v1/example_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading