MCP for gRPC Health Checking protocol
- 4 minutes read - 679 wordsModel Context Protocol (MCP) is “all the rage” these days.
I stumbled upon protoc-gen-go-mcp and think it’s an elegant application of two technologies: programmatically generating an MCP server from a gRPC protobuf.
I’m considering building an MCP server for Ackal but, thought I’d start with something simple: gRPC Health Checking protocol.
I was surprised to learn as I was doing this that there’s a new List (Add List method to gRPC Health service #143) added to grpc.health.v1.Health. My (Ackal) healthcheck server does not yet implement it (see later).
Curiously, the protocol’s Watch method is not generated by protoc-gen-go-mcp and is not available as part of the MCP server’s “tools”. I assume (!?) this is because it’s not a unary RPC (request-response) but a server-streaming RPC (requests-responses) and that this doesn’t fit well with LLMs. Don’t know.
protoc
protoc-gen-go-mcp uses an (underlying|backing) gRPC protobuf to generate the sources for the MCP server. health.proto is part of the grpc-proto repo. The plugin generates MCP-specific code in {$servicename}mcp sub-folder and references the backing service’s methods.
I hacked this in my repo but you should let it do its thing:
GRPC_PROTO="/path/to/grpc-proto"
MODULE="..." # Your repo
PROTO="grpc/health/v1/health.proto"
protoc \
--proto_path=${GRPC_PROTO} \
--go-mcp_out=${PWD} \
--go-mcp_opt=module=${MODULE} \
--go-mcp_opt=M${PROTO}=${MODULE}/protos \
${GRPC_PROTO}/${PROTO}
The generated file imports {your-repo}/protos aliased to protos. This is incorrect and we also want to use the published healthpb repo "google.golang.org/grpc/health/grpc_health_v1".
You can:
HEALTHPB="google.golang.org/grpc/health/grpc_health_v1"
sed \
--in-place \
--expression="s|${MODULE}/protos|${HEALTHPB}|g" \
${PWD}/protos/grpc_health_v1mcp/health.pb.mcp.go
For consistency, you may also want to replace protos with healthpb.
MCP
The rest of the code follows the example in the protoc-gen-go-mcp:
main.go:
package main
import (
"flag"
"log/slog"
"os"
grpc_health_v1mcp "your/repo/protos/grpc_health_v1mcp"
"github.com/mark3labs/mcp-go/server"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
healthpb "google.golang.org/grpc/health/grpc_health_v1"
)
func main() {
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
endpoint := flag.String(
"endpoint.grpc",
"",
"The gRPC endpoint (host:port) of the Healthcheck server.",
)
flag.Parse()
serverOpts := []server.ServerOption{
server.WithLogging(),
}
s := server.NewMCPServer(
"mcp",
"0.0.1",
serverOpts...,
)
srv := Server{
logger: logger,
}
grpc_health_v1mcp.RegisterHealthHandler(s, &srv)
dialOpts := []grpc.DialOption{
grpc.WithTransportCredentials(insecure.NewCredentials()),
}
conn, err := grpc.NewClient(*endpoint, dialOpts...)
if err != nil {
logger.Error("unable to create gRPC client",
"endpoint", *endpoint,
"err", err,
)
}
defer func() {
if err := conn.Close(); err != nil {
logger.Info("unable to close gRPC connection")
}
}()
grpcClient := healthpb.NewHealthClient(conn)
grpc_health_v1mcp.ForwardToHealthClient(s, grpcClient)
logger.Info("Starting MCP server")
if err := server.ServeStdio(s); err != nil {
logger.Error("unable to start stdio server", "err", err)
}
}
And server.go:
package main
import (
"context"
"log/slog"
"your/repo/protos/grpc_health_v1mcp"
healthpb "google.golang.org/grpc/health/grpc_health_v1"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
var _ grpc_health_v1mcp.HealthServer = (*Server)(nil)
type Server struct {
logger *slog.Logger
}
func (s *Server) Check(
ctx context.Context,
in *healthpb.HealthCheckRequest,
) (*healthpb.HealthCheckResponse, error) {
s.logger.Info("Check called")
return &healthpb.HealthCheckResponse{},
status.Errorf(
codes.Unimplemented,
"method Check not implemented",
)
}
func (s *Server) List(
ctx context.Context,
in *healthpb.HealthListRequest,
) (*healthpb.HealthListResponse, error) {
s.logger.Info("List called")
return &healthpb.HealthListResponse{},
status.Errorf(
codes.Unimplemented,
"method List not implemented",
)
}
func (s *Server) Watch(
in *healthpb.HealthCheckRequest,
srv healthpb.Health_WatchServer,
) error {
s.logger.Info("Watch called")
return status.Errorf(
codes.Unimplemented,
"method Watch not implemented",
)
}
What’s curious is that, while these Server methods must exist in order that Server implements the interface, they are never (!?) called.
NOTE all the methods return “not implemented”.
Test
Run the server and pipe stdout through jq to pretty print it:
go run . | jq -r .
Yields:
{
"time":"2025-06-12T00:00:00.000000000-07:00",
"level":"INFO",
"msg":"Starting MCP server"
}
And then to list the MCP server’s methods, we use "method": "tools/list"
NOTE MCP uses
JSON-RPCandmodelcontextprotocol.iodocuments the JSON-RPC messages includingtools/listandtools/callas used below.
NOTE The JSON-RPC requests should contain no newlines as these are used by this server’s stdin parsing to delimit the commands.
{
"jsonrpc":"2.0",
"id":1,
"method":"tools/list",
"params":{}
}
Yields:
{
"jsonrpc":"2.0",
"id":1,
"result":{
"tools":[
{
"description":"Check gets the health...",
"inputSchema":{
"properties":{
"service":{
"type":"string"
}
},
"required":[],
"type":"object"
},
"name":"grpc_health_v1_Health_Check"
},{
"description":"List provides a ...",
"inputSchema":{
"properties":{},
"required":[],
"type":"object"
},
"name":"grpc_health_v1_Health_List"
}
]
}
}
NOTE The gRPC fully-qualified method names are mapped from e.g.
grpc.health.v1.Health/Checktogrpc_health_v1_Health_Checkby this MCP implementation.
And to invoke the grpc.health.v1.Health/Check method:
{
"jsonrpc":"2.0",
"id":2,
"method":"tools/call",
"params":{
"name":"grpc_health_v1_Health_Check",
"arguments":{
"service":"foo"
}
}
}
Yields:
{
"jsonrpc":"2.0",
"id":2,
"result":{
"content":[
{
"type":"text",
"text":"{\"status\":\"SERVING\"}"
}
]
}
}
That’s all!