Introduction
In this blog series, we’ll show you how to build an MCP (Model Context Protocol) server packed with useful tools. Rather than starting from scratch, we’ll take advantage of our existing Protocol Buffers and Google’s gRPC transcoding. By creating a custom protoc (Protocol Buffer compiler) plugin, we can automatically generate the MCP server. This unified approach lets us produce gRPC services, OpenAPI specifications, REST APIs, and the MCP server all from the same source.
This blog series contains 4 articles:
- Part-1: Generating REST APIs from Protobuf Using gRPC Transcoding
- Part-2: (this article) Automate MCP Server Creation with Protoc Plugins
- Part-3: Enhance AI Interactions with Proto Comments
- Part-4: Insights from Running MCP Tools in Practice (To Be Done)
What You'll Build
By the end of this tutorial, you'll have:
- A custom protoc plugin that generates MCP servers from proto files
- Custom annotations to mark which methods become MCP tools
- A complete pipeline that generates gRPC, REST, and MCP code from one source
- Working MCP servers that AI Agents can interact with
All the code mentioned in this article can be found in Github repository: zhangcz828/proto-to-mcp-tutorial
Prerequisites
Before we start, make sure you have completed Part 1 and have:
- The bookstore tutorial project from Part 1
- Go 1.19+ installed
- Protocol Buffer compiler (
protoc
) installed - Python 3.8+ installed (for MCP server runtime)
- npx installed
Understanding the Protocol Buffer Plugin Ecosystem
In Part 1, we showed how gRPC transcoding helped us unify our gRPC and REST API definitions. We had clean, well-documented HTTP endpoints generated automatically from our proto files. But then we faced a new challenge: we wanted AI systems to interact with our services through the Model Context Protocol.
MCP is a standardized way for AI applications to access tools and resources programmatically. Without our unified approach, we would have been looking at manually writing MCP server code for each service, essentially creating a fourth set of API definitions to maintain alongside our gRPC services, REST endpoints, and OpenAPI specs.
That's when we realized we could extend our protoc-based approach. If we could generate REST APIs and OpenAPI specs from proto files, why not MCP servers too? The same HTTP annotations that guided our REST endpoint generation could also inform how to structure MCP tools.
Set Up Your Project
Let's start by extending our tutorial project from Part 1. Navigate back to your tutorial directory:
cd proto-to-mcp-tutorial
# Create directories for our plugin
mkdir -p plugins/protoc-gen-mcp
mkdir -p generated/mcp
mkdir -p google/protobuf
mkdir -p mcp/protobuf
Your project structure should now look like:
proto-to-mcp-tutorial
├── cmd
│ └── server
│ └── main.go
├── generate.sh
├── generated
│ ├── go
│ │ ├── bookstore_grpc.pb.go
│ │ ├── bookstore.pb.go
│ │ └── bookstore.pb.gw.go
│ ├── mcp # New
│ └── openapi
│ └── openapi.yaml
├── go.mod
├── go.sum
├── google
│ └── protobuf # New
├── mcp # New
│ └── protobuf
├── googleapis
│ └── google
│ └── api
│ ├── annotations.proto
│ └── http.proto
├── plugins # New
│ └── protoc-gen-mcp
├── proto
│ └── bookstore.proto
└── README.md
Create Custom MCP Annotations
First, we need to download the required proto files:
curl -o google/protobuf/descriptor.proto \
https://raw.githubusercontent.com/protocolbuffers/protobuf/refs/heads/main/src/google/protobuf/descriptor.proto
Then we need to create custom annotations to mark which RPC methods should become MCP tools. Create a new file mcp/protobuf/annotations.proto
for our MCP annotations:
syntax = "proto3";
package example.v1;
option go_package = "proto-to-mcp-tutorial/generated/go/mcp;mcp";
import "google/protobuf/descriptor.proto";
// Custom extension for marking methods as MCP tools
extend google.protobuf.MethodOptions {
MCPOptions mcp = 50003;
}
message MCPOptions {
// Whether this method should be exposed as an MCP tool
bool enabled = 1;
}
Last, generate Go code for MCP annotations:
export GOOGLEAPIS_DIR=./googleapis
export MCP_DIR=.
protoc -I${GOOGLEAPIS_DIR} -I${MCP_DIR} --go_out=./generated/go --go_opt=paths=source_relative mcp/protobuf/annotations.proto
Update Your Bookstore Proto with MCP Annotations
Now let's enhance our proto/bookstore.proto
to include MCP annotations:
syntax = "proto3";
package bookstore.v1;
import "google/api/annotations.proto";
import "mcp/protobuf/annotations.proto";
option go_package = "generated/go/bookstore/v1";
service BookstoreService {
// Get a book by ID
rpc GetBook(GetBookRequest) returns (Book) {
option (google.api.http) = {
get: "/v1/books/{book_id}"
};
option (mcp.v1.tool) = {
enabled: true
};
}
// Create a new book
rpc CreateBook(CreateBookRequest) returns (Book) {
option (google.api.http) = {
post: "/v1/books"
body: "book"
};
option (mcp.v1.tool) = {
enabled: true
};
}
}
message Book {
string book_id = 1;
string title = 2;
string author = 3;
int32 pages = 4;
}
message GetBookRequest {
// The ID of the book to retrieve
string book_id = 1;
}
message CreateBookRequest {
// The book object to create
Book book = 1;
}
Build the protoc-gen-mcp Plugin
Now we'll create file plugins/protoc-gen-mcp/main.go
as our custom protoc plugin. This plugin will read our proto files and generate Python MCP server code:
package main
import (
"bytes"
"fmt"
"regexp"
"strings"
"text/template"
"google.golang.org/protobuf/compiler/protogen"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/reflect/protoreflect"
"google.golang.org/protobuf/types/descriptorpb"
"google.golang.org/protobuf/types/pluginpb"
mcpannotations "proto-to-mcp-tutorial/generated/go/mcp/protobuf"
httpannotations "google.golang.org/genproto/googleapis/api/annotations"
)
func main() {
protogen.Options{}.Run(func(gen *protogen.Plugin) error {
gen.SupportedFeatures = uint64(pluginpb.CodeGeneratorResponse_FEATURE_PROTO3_OPTIONAL)
// Extract MCP methods from the proto files
mcpMethods := extractMCPMethods(gen)
if len(mcpMethods) == 0 {
return nil // No MCP methods found
}
// Generate main server file
generateMCPServer(gen, mcpMethods)
return nil
})
}
type MCPMethod struct {
Service *protogen.Service
Method *protogen.Method
ToolName string
Description string
HTTPInfo *HTTPInfo
Input *protogen.Message
Output *protogen.Message
Parameters []*MCPParameter
}
type MCPParameter struct {
Name string
Type string
Required bool
Description string
}
type HTTPInfo struct {
Method string
Path string
Body string
}
func extractMCPMethods(gen *protogen.Plugin) []*MCPMethod {
var mcpMethods []*MCPMethod
for _, file := range gen.Files {
if !file.Generate {
continue
}
for _, service := range file.Services {
for _, method := range service.Methods {
if hasMCPToolAnnotation(method) {
mcpMethod := &MCPMethod{
Service: service,
Method: method,
ToolName: generateToolName(method),
Description: extractDescription(method),
HTTPInfo: extractHTTPInfo(method),
Input: method.Input,
Output: method.Output,
Parameters: extractParameters(method.Input),
}
mcpMethods = append(mcpMethods, mcpMethod)
}
}
}
}
return mcpMethods
}
func hasMCPToolAnnotation(method *protogen.Method) bool {
options := method.Desc.Options().(*descriptorpb.MethodOptions)
if options == nil {
return false
}
// Check if method has mcp.v1.tool annotation
if !proto.HasExtension(options, mcpannotations.E_Tool) {
return false
}
// Get annotation value and check if enabled
toolOptions := proto.GetExtension(options, mcpannotations.E_Tool).(*mcpannotations.MCPToolOptions)
return toolOptions != nil && toolOptions.Enabled
}
func extractParameters(inputType *protogen.Message) []*MCPParameter {
var parameters []*MCPParameter
if inputType != nil {
for _, field := range inputType.Fields {
param := &MCPParameter{
Name: string(field.Desc.Name()),
Type: getFieldType(field),
Required: isFieldRequired(field),
Description: extractFieldDescription(field),
}
parameters = append(parameters, param)
}
}
return parameters
}
func isFieldRequired(field *protogen.Field) bool {
// In proto3, technically all fields are optional, but for business logic:
// If the field not marked as optional keyword, consider it required
if field.Desc.HasOptionalKeyword() {
return false
}
return true
}
func getFieldType(field *protogen.Field) string {
// Check if the field is repeated (array/list)
if field.Desc.Cardinality() == protoreflect.Repeated {
return "list"
}
switch field.Desc.Kind() {
case protoreflect.StringKind:
return "string"
case protoreflect.Int32Kind, protoreflect.Int64Kind:
return "integer"
case protoreflect.BoolKind:
return "boolean"
case protoreflect.MessageKind:
return "object"
case protoreflect.EnumKind:
return "string"
default:
return "string"
}
}
func extractFieldDescription(field *protogen.Field) string {
if field.Comments.Leading != "" {
return strings.TrimSpace(string(field.Comments.Leading))
}
return ""
}
func generateToolName(method *protogen.Method) string {
methodName := string(method.Desc.Name())
// Convert to snake_case
methodName = camelToSnake(methodName)
return methodName
}
func camelToSnake(str string) string {
re := regexp.MustCompile("([a-z0-9])([A-Z])")
snake := re.ReplaceAllString(str, "${1}_${2}")
return strings.ToLower(snake)
}
func extractDescription(method *protogen.Method) string {
if method.Comments.Leading != "" {
return strings.TrimSpace(string(method.Comments.Leading))
}
return fmt.Sprintf("Execute %s RPC method", method.Desc.Name())
}
func extractHTTPInfo(method *protogen.Method) *HTTPInfo {
options := method.Desc.Options().(*descriptorpb.MethodOptions)
if options == nil {
return nil
}
if !proto.HasExtension(options, httpannotations.E_Http) {
return nil
}
httpRule := proto.GetExtension(options, httpannotations.E_Http).(*httpannotations.HttpRule)
if httpRule == nil {
return nil
}
info := &HTTPInfo{}
switch pattern := httpRule.Pattern.(type) {
case *httpannotations.HttpRule_Get:
info.Method = "GET"
info.Path = pattern.Get
case *httpannotations.HttpRule_Post:
info.Method = "POST"
info.Path = pattern.Post
info.Body = httpRule.Body
case *httpannotations.HttpRule_Put:
info.Method = "PUT"
info.Path = pattern.Put
info.Body = httpRule.Body
case *httpannotations.HttpRule_Delete:
info.Method = "DELETE"
info.Path = pattern.Delete
case *httpannotations.HttpRule_Patch:
info.Method = "PATCH"
info.Path = pattern.Patch
info.Body = httpRule.Body
}
return info
}
func generateMCPServer(gen *protogen.Plugin, mcpMethods []*MCPMethod) {
outputFile := gen.NewGeneratedFile("mcp_server.py", ".")
funcMap := template.FuncMap{
"contains": func(s, substr string) bool {
return strings.Contains(s, substr)
},
"printf": fmt.Sprintf,
"indent": func(text string, spaces int) string {
if text == "" {
return text
}
lines := strings.Split(text, "\n")
indentStr := strings.Repeat(" ", spaces)
var result []string
for i, line := range lines {
if strings.TrimSpace(line) != "" {
result = append(result, indentStr+line)
} else if i < len(lines)-1 { // Keep empty lines except the last one
result = append(result, "")
}
}
return strings.Join(result, "\n")
},
}
tmpl := template.Must(template.New("mcp_server").Funcs(funcMap).Parse(mcpServerTemplate))
var buf bytes.Buffer
if err := tmpl.Execute(&buf, mcpMethods); err != nil {
return
}
outputFile.P(buf.String())
}
const mcpServerTemplate = `#!/usr/bin/env python3
"""
MCP Server for UPM API - Auto-generated from Protocol Buffers
This server provides access to project management operations
through the Model Context Protocol.
"""
import os
import sys
from typing import Any
import json
import httpx
from mcp.server.fastmcp import FastMCP
API_BASE = 'http://localhost:8080'
VERIFY_SSL = False
# Initialize FastMCP
mcp = FastMCP('Bookstore Server')
async def make_api_request(url: str, method: str = "GET", payload: dict = None) -> dict[str, Any] | None:
"""Make a HTTP request to the specified URL."""
headers = {
"Content-Type": "application/json",
}
# Use SSL verification based on environment variable
async with httpx.AsyncClient(verify=VERIFY_SSL) as client:
try:
if method.upper() == "GET":
response = await client.get(url, headers=headers, timeout=30.0)
elif method.upper() == "PUT":
response = await client.put(url, headers=headers, json=payload, timeout=30.0)
elif method.upper() == "POST":
response = await client.post(url, headers=headers, json=payload, timeout=30.0)
elif method.upper() == "DELETE":
response = await client.delete(url, headers=headers, timeout=30.0)
elif method.upper() == "PATCH":
response = await client.patch(url, headers=headers, json=payload, timeout=30.0)
else:
return {"error": f"Unsupported HTTP method: {method}"}
response.raise_for_status()
# Handle DELETE responses that might be empty
if method.upper() == "DELETE":
if response.status_code == 200 or response.status_code == 204:
return {"success": True, "message": "Resource deleted successfully"}
# Try to parse JSON, return empty dict if no content
try:
return response.json()
except:
return {"success": True}
except httpx.HTTPStatusError as e:
return {"error": f"HTTP {e.response.status_code}: {e.response.text}"}
except Exception as e:
return {"error": str(e)}
# MCP Tools
{{range .}}
@mcp.tool()
async def {{.ToolName}}({{range $i, $param := .Parameters}}{{if $i}}, {{end}}{{$param.Name}}: {{if eq $param.Type "string"}}str{{else if eq $param.Type "integer"}}int{{else if eq $param.Type "boolean"}}bool{{else if eq $param.Type "list"}}list{{else}}dict{{end}}{{if not $param.Required}} = None{{end}}{{end}}) -> str:
"""{{.Description}}
{{if .HTTPInfo}}
HTTP: {{.HTTPInfo.Method}} {{.HTTPInfo.Path}}{{end}}
Parameters:{{range .Parameters}}
- {{.Name}} ({{.Type}}{{if not .Required}}, optional{{end}}): {{if contains .Description "\n"}}{{indent .Description 6}}{{else}}{{.Description}}{{end}}{{end}}
Returns:
- str: JSON formatted response from the API containing the result or error information
"""
try:
{{if .HTTPInfo}}
# Construct the URL
url = f"{API_BASE}{{.HTTPInfo.Path}}"
{{$httpInfo := .HTTPInfo}}{{range .Parameters}}{{if and .Required (contains $httpInfo.Path (printf "{%s}" .Name))}}
url = url.replace("{" + "{{.Name}}" + "}", str({{.Name}})){{end}}{{end}}
# Prepare payload for non-GET requests
payload = {}
{{range .Parameters}}{{if and (ne $httpInfo.Method "GET") (ne $httpInfo.Method "DELETE") (not (contains $httpInfo.Path (printf "{%s}" .Name)))}}
{{if .Required}}payload["{{.Name}}"] = {{.Name}}{{else}}if {{.Name}} is not None:
payload["{{.Name}}"] = {{.Name}}{{end}}{{end}}{{end}}
# Make the API request
result = await make_api_request(url, "{{.HTTPInfo.Method}}", payload if payload else None)
{{else}}
result = {"error": "No HTTP endpoint defined for this method"}{{end}}
# Return formatted JSON response
import json
return json.dumps(result, indent=2)
except Exception as e:
# Handle any errors that occur during execution
import json
error_result = {
"error": f"Tool execution failed: {str(e)}",
"tool_name": "{{.ToolName}}",
"error_type": type(e).__name__
}
return json.dumps(error_result, indent=2)
{{end}}
if __name__ == '__main__':
# Run the MCP server
mcp.run()
`
Now let's compile our plugin and make it available to protoc:
# Run under the root path of this project
go build -o protoc-gen-mcp plugins/protoc-gen-mcp/main.go
Generate All Code
Update your generate.sh
script to include MCP generation:
#!/bin/bash
export GOOGLEAPIS_DIR=./googleapis
export MCP_DIR=.
echo "Checking for required protoc plugins..."
# Check if protoc-gen-go is available
if ! command -v protoc-gen-go &> /dev/null; then
echo "❌ protoc-gen-go not found. Installing..."
go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
if [ $? -ne 0 ]; then
echo "⚠️ Failed to install protoc-gen-go. Skipping Go generation."
exit 1
fi
fi
# Check if protoc-gen-go-grpc is available
if ! command -v protoc-gen-go-grpc &> /dev/null; then
echo "❌ protoc-gen-go-grpc not found. Installing..."
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest
if [ $? -ne 0 ]; then
echo "⚠️ Failed to install protoc-gen-go-grpc. Skipping gRPC generation."
exit 1
fi
fi
# Check if protoc-gen-grpc-gateway is available
if ! command -v protoc-gen-grpc-gateway &> /dev/null; then
echo "❌ protoc-gen-grpc-gateway not found. Installing..."
go install github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-grpc-gateway@latest
if [ $? -ne 0 ]; then
echo "⚠️ Failed to install protoc-gen-grpc-gateway. Skipping Gateway generation."
exit 1
fi
fi
echo "🔧 Generating Go gRPC code..."
# Step 1: Generate Go code for MCP annotations
echo "🔧 Generating MCP annotations Go code..."
protoc -I${GOOGLEAPIS_DIR} -I${MCP_DIR} --go_out=./generated/go --go_opt=paths=source_relative mcp/protobuf/annotations.proto
# Step 2: Generate Go gRPC code
protoc -I${GOOGLEAPIS_DIR} -I${MCP_DIR} --proto_path=proto --go_out=./generated/go --go_opt paths=source_relative --go-grpc_out=./generated/go --go-grpc_opt paths=source_relative bookstore.proto
# Generate gRPC Gateway for REST endpoints
echo "🔧 Generating gRPC Gateway..."
protoc -I${GOOGLEAPIS_DIR} -I${MCP_DIR} --proto_path=proto --grpc-gateway_out=./generated/go --grpc-gateway_opt paths=source_relative bookstore.proto
# Generate OpenAPI specifications
echo "🔧 Generating OpenAPI spec..."
protoc -I${GOOGLEAPIS_DIR} -I${MCP_DIR} -I./proto --openapi_out=./generated/openapi \
--openapi_opt=fq_schema_naming=true \
--openapi_opt=version="1.0.0" \
--openapi_opt=title="Bookstore API" \
bookstore.proto
# Generate MCP server (always available since we have our custom plugin)
echo "🔧 Generating MCP server..."
protoc -I${GOOGLEAPIS_DIR} -I${MCP_DIR} --proto_path=proto \
--plugin=protoc-gen-mcp=./protoc-gen-mcp \
--mcp_out=./generated/mcp \
bookstore.proto
echo ""
echo "🎉 Generation complete!"
echo "📁 Check the ./generated directory for all generated files."
Run the updated generation script:
./generate.sh
You should see output like:
Checking for required protoc plugins...
🔧 Generating Go gRPC code...
🔧 Generating MCP annotations Go code...
🔧 Generating gRPC Gateway...
🔧 Generating OpenAPI spec...
🔧 Generating MCP server...
🎉 Generation complete!
📁 Check the ./generated directory for all generated files.
Test the generated MCP Server
Start the Go server (from Part 1) by running command go run cmd/server/main.go
in terminal 1.
In terminal 2, install uv which is a Python package and project manager, then setup the environment and start the MCP server:
# Create Python virtual environment
uv venv
source .venv/bin/activate
# Install MCP server dependencies
uv pip install fastmcp httpx --native-tls
# Start MCP Inspector
npx @modelcontextprotocol/inspector \
uv \
--directory ./generated/mcp \
run \
mcp_server.py
Test MCP tool listing
In the opened MCP inspector UI page, click the connect and then list all tools, there should be two, get_book
and create_book
Test tool execution
Select the tool get_book
, input the book_id
with book-1
and run it, should get the same result as curl -X GET http://localhost:8080/v1/books/book-1
Conclusion
You now have a complete pipeline where one proto file generates three different interfaces:
# Test gRPC interface
grpcurl -plaintext -d '{"book_id": "book-1"}' localhost:9090 bookstore.v1.BookstoreService/GetBook
# Test REST interface
curl http://localhost:8080/v1/books/book-1
# Test MCP interface (as shown above)
All three should return the same book data!
The same proto file now contains everything needed to generate gRPC services, REST endpoints, OpenAPI specs, and MCP tools.
Here’s the updated architecture looks like:
Next Steps
When we started testing our generated MCP tools with AI Agents, we quickly realized something important: having working tools isn't enough. AI Agents need rich, detailed descriptions to understand what each tool does and when to use it appropriately. In Part 3, we'll show how proto comments become intelligent MCP tool documentation that guides AI interactions.