Building MCP Servers from Protobuf (Part2) - Automate MCP Server Creation with Protoc Plugins

September 21, 2025

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:

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.

Share this