#
tokens: 4738/50000 2/2 files
lines: off (toggle) GitHub
raw markdown copy
# 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
}

```