# Directory Structure
```
├── go.mod
├── go.sum
├── main.go
└── README.md
```
# Files
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
```markdown
# MCP Grpcurl
This project is an Model Context Protocol (MCP) server designed to interact with gRPC services using the `grpcurl` tool. It leverages the `grpcurl` command-line utility to perform various operations on gRPC services, such as invoking methods, listing services, and describing service details.
## Features
- **Invoke gRPC Methods**: Use reflection to invoke gRPC methods with custom headers and JSON payloads.
- **List gRPC Services**: Retrieve a list of all available gRPC services on the target server.
- **Describe gRPC Services**: Get detailed descriptions of gRPC services or message types.
## Requirements
- Go 1.23.0 or later
- `grpcurl` tool installed on your system
## Setup
1. install grpcurl: https://github.com/fullstorydev/grpcurl
2. Install the package:
```bash
go install github.com/wricardo/mcp-grpcurl@latest
```
3. Configure Cline by adding the following to your MCP settings:
```json
"mcp-grpcurl": {
"command": "mcp-grpcurl",
"env": {
"ADDRESS": "localhost:8005"
},
"disabled": false,
"autoApprove": []
}
```
## Usage
Run the MCP server:
```bash
mcp-grpc-client
```
### Tools
- **invoke**: Invoke a gRPC method using reflection.
- Parameters:
- `method`: Fully-qualified method name (e.g., `package.Service/Method`).
- `request`: JSON payload for the request.
- `headers`: (Optional) JSON object for custom gRPC headers.
- **list**: List all available gRPC services on the target server.
- **describe**: Describe a gRPC service or message type.
- Use dot notation for symbols (e.g., `mypackage.MyService`).
```
--------------------------------------------------------------------------------
/main.go:
--------------------------------------------------------------------------------
```go
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log"
"os"
"strings"
"time"
"github.com/fullstorydev/grpcurl"
"github.com/golang/protobuf/jsonpb"
"github.com/golang/protobuf/proto" //lint:ignore SA1019 same as above
"github.com/jhump/protoreflect/desc"
"github.com/jhump/protoreflect/grpcreflect"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
"google.golang.org/grpc"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/reflection/grpc_reflection_v1alpha"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/descriptorpb"
)
// customEventHandler extends the DefaultEventHandler to capture headers and trailers
type customEventHandler struct {
*grpcurl.DefaultEventHandler
headers metadata.MD
trailers metadata.MD
}
// OnReceiveTrailers captures the incoming trailer metadata
func (h *customEventHandler) OnReceiveTrailers(status *status.Status, md metadata.MD) {
if h.trailers == nil {
h.trailers = md
} else {
for k, v := range md {
h.trailers[k] = append(h.trailers[k], v...)
}
}
h.DefaultEventHandler.OnReceiveTrailers(status, md)
}
// NewGrpcReflectionServer creates a new GrpcReflectionServer for the given target address.
func NewGrpcReflectionServer(host string) *GrpcReflectionServer {
srv := server.NewMCPServer(
"grpcReflectionServer",
"1.0.0",
server.WithLogging(),
)
grs := &GrpcReflectionServer{
srv: srv,
host: host,
headers: make(map[string]string),
}
grs.registerTools()
return grs
}
// Serve starts the MCP server over standard I/O.
func (g *GrpcReflectionServer) Serve() error {
return server.ServeStdio(g.srv)
}
// registerTools registers the grpcurl-based tools available via the MCP server.
func (g *GrpcReflectionServer) registerTools() {
// Tool: set-headers
setHeadersTool := mcp.NewTool(
"set-headers",
mcp.WithDescription(`Set global headers to be used with all future gRPC requests.
Parameters:
- "headers": JSON object with header key-value pairs, e.g. {"Authorization": "Bearer <token>"}.
- "clear": (Optional) Boolean to clear all existing headers before setting new ones.`),
mcp.WithString("headers", mcp.Description("JSON object with header key-value pairs"), mcp.Required()),
mcp.WithBoolean("clear", mcp.Description("Clear existing headers before setting new ones")),
)
g.srv.AddTool(setHeadersTool, func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := request.Params.Arguments
headersJSON, _ := args["headers"].(string)
clear, _ := args["clear"].(bool)
// Parse headers
newHeaders := make(map[string]string)
if err := json.Unmarshal([]byte(headersJSON), &newHeaders); err != nil {
return toolError("Failed to parse headers JSON: " + err.Error()), nil
}
// Clear existing headers if requested
if clear {
g.headers = make(map[string]string)
}
// Update headers
for k, v := range newHeaders {
g.headers[k] = v
}
// Format headers for display
headersMap := make(map[string]interface{})
for k, v := range g.headers {
headersMap[k] = v
}
jsonResponse, err := json.MarshalIndent(headersMap, "", " ")
if err != nil {
return toolError("Failed to marshal headers: " + err.Error()), nil
}
return toolSuccess(fmt.Sprintf("Headers updated successfully:\n%s", string(jsonResponse))), nil
})
// Tool 1: invoke
invokeTool := mcp.NewTool(
"invoke",
mcp.WithDescription(`Invokes a gRPC method using reflection.
Parameters:
- "method": Fully-qualified method name (e.g., package.Service/Method).
- "request": JSON payload for the request.
- "headers": (Optional) JSON object for custom gRPC headers that will be merged with global headers.`),
mcp.WithString("method", mcp.Description("Fully-qualified method name (e.g., package.Service/Method)"), mcp.Required()),
mcp.WithString("request", mcp.Description("JSON request payload"), mcp.Required()),
mcp.WithString("headers", mcp.Description("Optional JSON object for request-specific headers")),
)
g.srv.AddTool(invokeTool, func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := request.Params.Arguments
method, _ := args["method"].(string)
reqPayload, _ := args["request"].(string)
requestHeadersJSON, _ := args["headers"].(string)
// Parse request-specific headers if provided and merge with global headers
headers := []string{}
// Start with global headers
for k, v := range g.headers {
headers = append(headers, fmt.Sprintf("%s: %s", k, v))
}
// Add request-specific headers if provided
if requestHeadersJSON != "" {
requestHeaders := make(map[string]string)
if err := json.Unmarshal([]byte(requestHeadersJSON), &requestHeaders); err != nil {
return toolError("Failed to parse headers JSON: " + err.Error()), nil
}
for k, v := range requestHeaders {
headers = append(headers, fmt.Sprintf("%s: %s", k, v))
}
}
// Create a gRPC client connection.
network := "tcp"
target := g.host
dialTime := 10 * time.Second
dialOptions := []grpc.DialOption{
grpc.WithBlock(),
grpc.WithTimeout(dialTime),
grpc.WithInsecure(), // adjust based on security requirements.
}
cc, err := grpcurl.BlockingDial(ctx, network, target, nil, dialOptions...)
if err != nil {
return toolError("Failed to create gRPC connection: " + err.Error()), nil
}
defer cc.Close()
// Create a reflection client and descriptor source.
refClient := grpcreflect.NewClient(ctx, grpc_reflection_v1alpha.NewServerReflectionClient(cc))
defer refClient.Reset()
descSource := grpcurl.DescriptorSourceFromServer(ctx, refClient)
// Create an in-memory buffer to capture output.
var outputBuffer bytes.Buffer
// Create a formatter (we don't need the parser in the new API).
_, formatter, err := grpcurl.RequestParserAndFormatter(grpcurl.FormatJSON, descSource, &outputBuffer, grpcurl.FormatOptions{})
if err != nil {
return toolError("Failed to create formatter: " + err.Error()), nil
}
// Create a custom event handler with header capture capability
handler := &customEventHandler{
DefaultEventHandler: &grpcurl.DefaultEventHandler{
Out: &outputBuffer,
Formatter: formatter,
VerbosityLevel: 0,
NumResponses: 0,
Status: nil,
},
}
// Create a request supplier that supplies a single JSON message.
reqSupplier := &singleMessageSupplier{
data: []byte(reqPayload),
}
// Invoke the gRPC method using the new API signature.
err = grpcurl.InvokeRPC(ctx, descSource, cc, method, headers, handler, reqSupplier.Supply)
if err != nil {
return toolError("Failed to invoke RPC: " + err.Error()), nil
}
// Check if there was an error status from the RPC
if handler.Status != nil && handler.Status.Err() != nil {
return toolError(fmt.Sprintf("RPC failed: %v", handler.Status.Err())), nil
}
// Convert metadata.MD to map for JSON marshaling
headersMap := metadataToMap(handler.headers)
trailersMap := metadataToMap(handler.trailers)
// Create a structured response with headers and trailers
response := map[string]interface{}{
"body": outputBuffer.String(),
"headers": headersMap,
"trailers": trailersMap,
"metadata": map[string]interface{}{
"status_code": handler.Status.Code().String(),
},
}
// Convert the response to JSON.
jsonResponse, err := json.MarshalIndent(response, "", " ")
if err != nil {
return toolError("Failed to marshal response: " + err.Error()), nil
}
// Return the structured response.
return toolSuccess(string(jsonResponse)), nil
})
// Tool 2: list
listTool := mcp.NewTool(
"list",
mcp.WithDescription("Lists all available gRPC services on the target server using reflection."),
)
g.srv.AddTool(listTool, func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
// Create a gRPC client connection
network := "tcp"
target := g.host
dialTime := 10 * time.Second
dialOptions := []grpc.DialOption{
grpc.WithBlock(),
grpc.WithTimeout(dialTime),
grpc.WithInsecure(),
}
cc, err := grpcurl.BlockingDial(ctx, network, target, nil, dialOptions...)
if err != nil {
return toolError("Failed to create gRPC connection: " + err.Error()), nil
}
defer cc.Close()
// Create a reflection client
refClient := grpcreflect.NewClient(ctx, grpc_reflection_v1alpha.NewServerReflectionClient(cc))
defer refClient.Reset()
// List all services
services, err := refClient.ListServices()
if err != nil {
return toolError("Failed to list services: " + err.Error()), nil
}
// Format the output similarly to grpcurl
var output strings.Builder
for _, svc := range services {
if svc != "grpc.reflection.v1alpha.ServerReflection" {
output.WriteString(svc)
output.WriteString("\n")
}
}
return toolSuccess(output.String()), nil
})
// Tool 3: describe
describeTool := mcp.NewTool(
"describe",
mcp.WithDescription(`Describes a gRPC service or message type.
Provide the target entity using dot notation.
Examples:
- "mypackage.MyService" to describe the service.
- "mypackage.MyMessage.MyRpc" to describe a specific RPC method.
- "mypackage.MyMessage" to describe a message type.
Note: Slash notation (e.g., "mypackage.MyService/MyMethod") is used for invoking RPCs, not for describing symbols.`),
WithStringArray("entities", mcp.Description("The services or messages type to describe (use dot notation)"), mcp.Required()),
)
g.srv.AddTool(describeTool, func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
// Create a gRPC client connection
network := "tcp"
target := g.host
dialTime := 10 * time.Second
dialOptions := []grpc.DialOption{
grpc.WithBlock(),
grpc.WithTimeout(dialTime),
grpc.WithInsecure(),
}
cc, err := grpcurl.BlockingDial(ctx, network, target, nil, dialOptions...)
if err != nil {
return toolError("Failed to create gRPC connection: " + err.Error()), nil
}
defer cc.Close()
// Create a reflection client and descriptor source
refClient := grpcreflect.NewClient(ctx, grpc_reflection_v1alpha.NewServerReflectionClient(cc))
defer refClient.Reset()
descSource := grpcurl.DescriptorSourceFromServer(ctx, refClient)
args := request.Params.Arguments
entities, ok := args["entities"].(string)
var tmp []string
if ok {
tmp = strings.Split(entities, ",")
} else if entities, ok := args["entities"].([]interface{}); ok {
for _, entity := range entities {
entityStr, ok := entity.(string)
if !ok {
given, _ := json.Marshal(entity)
return toolError(fmt.Sprintf("entities argument should be an array of strings instead of %s", given)), nil
}
tmp = append(tmp, entityStr)
}
}
// Split the entities by comma
if len(tmp) == 0 {
return toolError("No entities provided"), nil
}
var results []string
for _, entityStr := range tmp {
// Remove leading dot if present
if entityStr != "" && entityStr[0] == '.' {
entityStr = entityStr[1:]
}
// Find the symbol
dsc, err := descSource.FindSymbol(entityStr)
if err != nil {
return toolError(fmt.Sprintf("Failed to resolve symbol %q: %v", entityStr, err)), nil
}
fqn := dsc.GetFullyQualifiedName()
var elementType string
// Determine the type of the descriptor
switch d := dsc.(type) {
case *desc.MessageDescriptor:
elementType = "a message"
if parent, ok := d.GetParent().(*desc.MessageDescriptor); ok {
if d.IsMapEntry() {
for _, f := range parent.GetFields() {
if f.IsMap() && f.GetMessageType() == d {
elementType = "the entry type for a map field"
dsc = f
break
}
}
} else {
for _, f := range parent.GetFields() {
if f.GetType() == descriptorpb.FieldDescriptorProto_TYPE_GROUP && f.GetMessageType() == d {
elementType = "the type of a group field"
dsc = f
break
}
}
}
}
case *desc.FieldDescriptor:
elementType = "a field"
if d.GetType() == descriptorpb.FieldDescriptorProto_TYPE_GROUP {
elementType = "a group field"
} else if d.IsExtension() {
elementType = "an extension"
}
case *desc.OneOfDescriptor:
elementType = "a one-of"
case *desc.EnumDescriptor:
elementType = "an enum"
case *desc.EnumValueDescriptor:
elementType = "an enum value"
case *desc.ServiceDescriptor:
elementType = "a service"
case *desc.MethodDescriptor:
elementType = "a method"
default:
return toolError(fmt.Sprintf("descriptor has unrecognized type %T", dsc)), nil
}
// Get the descriptor text
txt, err := grpcurl.GetDescriptorText(dsc, descSource)
if err != nil {
return toolError(fmt.Sprintf("Failed to describe symbol %q: %v", entityStr, err)), nil
}
description := fmt.Sprintf("%s is %s:\n%s", fqn, elementType, txt)
// // For message types, also show a JSON template
// if msgDesc, ok := dsc.(*desc.MessageDescriptor); ok {
// tmpl := grpcurl.MakeTemplate(msgDesc)
// options := grpcurl.FormatOptions{EmitJSONDefaultFields: true}
// _, formatter, err := grpcurl.RequestParserAndFormatter(grpcurl.FormatJSON, descSource, nil, options)
// if err != nil {
// return toolError(fmt.Sprintf("Failed to create formatter: %v", err)), nil
// }
// str, err := formatter(tmpl)
// if err != nil {
// return toolError(fmt.Sprintf("Failed to print template for message %s: %v", entityStr, err)), nil
// }
// description += "\nMessage template:\n" + str
// }
results = append(results, description)
}
return toolSuccess(strings.Join(results, "\n\n")), nil
})
return
}
// metadataToMap converts gRPC metadata to a map suitable for JSON marshaling
func metadataToMap(md metadata.MD) map[string]interface{} {
result := make(map[string]interface{})
for key, values := range md {
// If there's only one value, store it directly rather than as an array
if len(values) == 1 {
result[key] = values[0]
} else if len(values) > 0 {
result[key] = values
}
}
return result
}
// singleMessageSupplier implements grpcurl.RequestSupplier interface for a single message.
type singleMessageSupplier struct {
data []byte
used bool
}
// Supply implements the grpcurl.RequestSupplier interface.
func (s *singleMessageSupplier) Supply(msg proto.Message) error {
if s.used {
return io.EOF
}
s.used = true
return jsonpb.Unmarshal(bytes.NewReader(s.data), msg)
}
func main() {
address := os.Getenv("ADDRESS")
if address == "" {
log.Fatal("ADDRESS environment variable is required")
os.Exit(1)
}
grpcServer := NewGrpcReflectionServer(address)
if err := grpcServer.Serve(); err != nil && err != io.EOF {
log.Fatal("Error serving MCP server:", err)
os.Exit(1)
}
}
// WithStringArray adds a string array property to the tool schema.
func WithStringArray(name string, opts ...mcp.PropertyOption) mcp.ToolOption {
return func(t *mcp.Tool) {
schema := map[string]interface{}{
"type": "array",
"items": map[string]interface{}{
"type": "string",
},
}
for _, opt := range opts {
opt(schema)
}
if required, ok := schema["required"].(bool); ok && required {
delete(schema, "required")
if t.InputSchema.Required == nil {
t.InputSchema.Required = []string{name}
} else {
t.InputSchema.Required = append(t.InputSchema.Required, name)
}
}
t.InputSchema.Properties[name] = schema
}
}
// toolSuccess creates a successful MCP response with the provided text contents.
func toolSuccess(contents ...string) *mcp.CallToolResult {
var iface []interface{}
for _, c := range contents {
iface = append(iface, mcp.NewTextContent(c))
}
return &mcp.CallToolResult{
Content: iface,
IsError: false,
}
}
// toolError creates an MCP error response with the given error message.
func toolError(message string) *mcp.CallToolResult {
return &mcp.CallToolResult{
Content: []interface{}{mcp.NewTextContent(message)},
IsError: true,
}
}
// GrpcReflectionServer wraps grpcurl functionalities into an MCP server.
type GrpcReflectionServer struct {
srv *server.MCPServer
host string
headers map[string]string // Global headers to be used with all requests
}
```