# Directory Structure ``` ├── go.mod ├── go.sum ├── main.go └── README.md ``` # Files -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- ```markdown 1 | # MCP Grpcurl 2 | 3 | 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. 4 | 5 | ## Features 6 | 7 | - **Invoke gRPC Methods**: Use reflection to invoke gRPC methods with custom headers and JSON payloads. 8 | - **List gRPC Services**: Retrieve a list of all available gRPC services on the target server. 9 | - **Describe gRPC Services**: Get detailed descriptions of gRPC services or message types. 10 | 11 | ## Requirements 12 | 13 | - Go 1.23.0 or later 14 | - `grpcurl` tool installed on your system 15 | 16 | ## Setup 17 | 1. install grpcurl: https://github.com/fullstorydev/grpcurl 18 | 19 | 2. Install the package: 20 | ```bash 21 | go install github.com/wricardo/mcp-grpcurl@latest 22 | ``` 23 | 24 | 3. Configure Cline by adding the following to your MCP settings: 25 | ```json 26 | "mcp-grpcurl": { 27 | "command": "mcp-grpcurl", 28 | "env": { 29 | "ADDRESS": "localhost:8005" 30 | }, 31 | "disabled": false, 32 | "autoApprove": [] 33 | } 34 | ``` 35 | 36 | ## Usage 37 | 38 | Run the MCP server: 39 | ```bash 40 | mcp-grpc-client 41 | ``` 42 | 43 | ### Tools 44 | 45 | - **invoke**: Invoke a gRPC method using reflection. 46 | - Parameters: 47 | - `method`: Fully-qualified method name (e.g., `package.Service/Method`). 48 | - `request`: JSON payload for the request. 49 | - `headers`: (Optional) JSON object for custom gRPC headers. 50 | 51 | - **list**: List all available gRPC services on the target server. 52 | 53 | - **describe**: Describe a gRPC service or message type. 54 | - Use dot notation for symbols (e.g., `mypackage.MyService`). 55 | ``` -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- ```go 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "fmt" 8 | "io" 9 | "log" 10 | "os" 11 | "strings" 12 | "time" 13 | 14 | "github.com/fullstorydev/grpcurl" 15 | "github.com/golang/protobuf/jsonpb" 16 | "github.com/golang/protobuf/proto" //lint:ignore SA1019 same as above 17 | "github.com/jhump/protoreflect/desc" 18 | "github.com/jhump/protoreflect/grpcreflect" 19 | "github.com/mark3labs/mcp-go/mcp" 20 | "github.com/mark3labs/mcp-go/server" 21 | "google.golang.org/grpc" 22 | "google.golang.org/grpc/metadata" 23 | "google.golang.org/grpc/reflection/grpc_reflection_v1alpha" 24 | "google.golang.org/grpc/status" 25 | "google.golang.org/protobuf/types/descriptorpb" 26 | ) 27 | 28 | // customEventHandler extends the DefaultEventHandler to capture headers and trailers 29 | type customEventHandler struct { 30 | *grpcurl.DefaultEventHandler 31 | headers metadata.MD 32 | trailers metadata.MD 33 | } 34 | 35 | // OnReceiveTrailers captures the incoming trailer metadata 36 | func (h *customEventHandler) OnReceiveTrailers(status *status.Status, md metadata.MD) { 37 | if h.trailers == nil { 38 | h.trailers = md 39 | } else { 40 | for k, v := range md { 41 | h.trailers[k] = append(h.trailers[k], v...) 42 | } 43 | } 44 | h.DefaultEventHandler.OnReceiveTrailers(status, md) 45 | } 46 | 47 | // NewGrpcReflectionServer creates a new GrpcReflectionServer for the given target address. 48 | func NewGrpcReflectionServer(host string) *GrpcReflectionServer { 49 | srv := server.NewMCPServer( 50 | "grpcReflectionServer", 51 | "1.0.0", 52 | server.WithLogging(), 53 | ) 54 | 55 | grs := &GrpcReflectionServer{ 56 | srv: srv, 57 | host: host, 58 | headers: make(map[string]string), 59 | } 60 | 61 | grs.registerTools() 62 | return grs 63 | } 64 | 65 | // Serve starts the MCP server over standard I/O. 66 | func (g *GrpcReflectionServer) Serve() error { 67 | return server.ServeStdio(g.srv) 68 | } 69 | 70 | // registerTools registers the grpcurl-based tools available via the MCP server. 71 | func (g *GrpcReflectionServer) registerTools() { 72 | // Tool: set-headers 73 | setHeadersTool := mcp.NewTool( 74 | "set-headers", 75 | mcp.WithDescription(`Set global headers to be used with all future gRPC requests. 76 | Parameters: 77 | - "headers": JSON object with header key-value pairs, e.g. {"Authorization": "Bearer <token>"}. 78 | - "clear": (Optional) Boolean to clear all existing headers before setting new ones.`), 79 | mcp.WithString("headers", mcp.Description("JSON object with header key-value pairs"), mcp.Required()), 80 | mcp.WithBoolean("clear", mcp.Description("Clear existing headers before setting new ones")), 81 | ) 82 | g.srv.AddTool(setHeadersTool, func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { 83 | args := request.Params.Arguments 84 | headersJSON, _ := args["headers"].(string) 85 | clear, _ := args["clear"].(bool) 86 | 87 | // Parse headers 88 | newHeaders := make(map[string]string) 89 | if err := json.Unmarshal([]byte(headersJSON), &newHeaders); err != nil { 90 | return toolError("Failed to parse headers JSON: " + err.Error()), nil 91 | } 92 | 93 | // Clear existing headers if requested 94 | if clear { 95 | g.headers = make(map[string]string) 96 | } 97 | 98 | // Update headers 99 | for k, v := range newHeaders { 100 | g.headers[k] = v 101 | } 102 | 103 | // Format headers for display 104 | headersMap := make(map[string]interface{}) 105 | for k, v := range g.headers { 106 | headersMap[k] = v 107 | } 108 | 109 | jsonResponse, err := json.MarshalIndent(headersMap, "", " ") 110 | if err != nil { 111 | return toolError("Failed to marshal headers: " + err.Error()), nil 112 | } 113 | 114 | return toolSuccess(fmt.Sprintf("Headers updated successfully:\n%s", string(jsonResponse))), nil 115 | }) 116 | 117 | // Tool 1: invoke 118 | invokeTool := mcp.NewTool( 119 | "invoke", 120 | mcp.WithDescription(`Invokes a gRPC method using reflection. 121 | Parameters: 122 | - "method": Fully-qualified method name (e.g., package.Service/Method). 123 | - "request": JSON payload for the request. 124 | - "headers": (Optional) JSON object for custom gRPC headers that will be merged with global headers.`), 125 | mcp.WithString("method", mcp.Description("Fully-qualified method name (e.g., package.Service/Method)"), mcp.Required()), 126 | mcp.WithString("request", mcp.Description("JSON request payload"), mcp.Required()), 127 | mcp.WithString("headers", mcp.Description("Optional JSON object for request-specific headers")), 128 | ) 129 | g.srv.AddTool(invokeTool, func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { 130 | args := request.Params.Arguments 131 | method, _ := args["method"].(string) 132 | reqPayload, _ := args["request"].(string) 133 | requestHeadersJSON, _ := args["headers"].(string) 134 | 135 | // Parse request-specific headers if provided and merge with global headers 136 | headers := []string{} 137 | 138 | // Start with global headers 139 | for k, v := range g.headers { 140 | headers = append(headers, fmt.Sprintf("%s: %s", k, v)) 141 | } 142 | 143 | // Add request-specific headers if provided 144 | if requestHeadersJSON != "" { 145 | requestHeaders := make(map[string]string) 146 | if err := json.Unmarshal([]byte(requestHeadersJSON), &requestHeaders); err != nil { 147 | return toolError("Failed to parse headers JSON: " + err.Error()), nil 148 | } 149 | for k, v := range requestHeaders { 150 | headers = append(headers, fmt.Sprintf("%s: %s", k, v)) 151 | } 152 | } 153 | 154 | // Create a gRPC client connection. 155 | network := "tcp" 156 | target := g.host 157 | dialTime := 10 * time.Second 158 | 159 | dialOptions := []grpc.DialOption{ 160 | grpc.WithBlock(), 161 | grpc.WithTimeout(dialTime), 162 | grpc.WithInsecure(), // adjust based on security requirements. 163 | } 164 | 165 | cc, err := grpcurl.BlockingDial(ctx, network, target, nil, dialOptions...) 166 | if err != nil { 167 | return toolError("Failed to create gRPC connection: " + err.Error()), nil 168 | } 169 | defer cc.Close() 170 | 171 | // Create a reflection client and descriptor source. 172 | refClient := grpcreflect.NewClient(ctx, grpc_reflection_v1alpha.NewServerReflectionClient(cc)) 173 | defer refClient.Reset() 174 | descSource := grpcurl.DescriptorSourceFromServer(ctx, refClient) 175 | 176 | // Create an in-memory buffer to capture output. 177 | var outputBuffer bytes.Buffer 178 | 179 | // Create a formatter (we don't need the parser in the new API). 180 | _, formatter, err := grpcurl.RequestParserAndFormatter(grpcurl.FormatJSON, descSource, &outputBuffer, grpcurl.FormatOptions{}) 181 | if err != nil { 182 | return toolError("Failed to create formatter: " + err.Error()), nil 183 | } 184 | 185 | // Create a custom event handler with header capture capability 186 | handler := &customEventHandler{ 187 | DefaultEventHandler: &grpcurl.DefaultEventHandler{ 188 | Out: &outputBuffer, 189 | Formatter: formatter, 190 | VerbosityLevel: 0, 191 | NumResponses: 0, 192 | Status: nil, 193 | }, 194 | } 195 | 196 | // Create a request supplier that supplies a single JSON message. 197 | reqSupplier := &singleMessageSupplier{ 198 | data: []byte(reqPayload), 199 | } 200 | 201 | // Invoke the gRPC method using the new API signature. 202 | err = grpcurl.InvokeRPC(ctx, descSource, cc, method, headers, handler, reqSupplier.Supply) 203 | if err != nil { 204 | return toolError("Failed to invoke RPC: " + err.Error()), nil 205 | } 206 | 207 | // Check if there was an error status from the RPC 208 | if handler.Status != nil && handler.Status.Err() != nil { 209 | return toolError(fmt.Sprintf("RPC failed: %v", handler.Status.Err())), nil 210 | } 211 | 212 | // Convert metadata.MD to map for JSON marshaling 213 | headersMap := metadataToMap(handler.headers) 214 | trailersMap := metadataToMap(handler.trailers) 215 | 216 | // Create a structured response with headers and trailers 217 | response := map[string]interface{}{ 218 | "body": outputBuffer.String(), 219 | "headers": headersMap, 220 | "trailers": trailersMap, 221 | "metadata": map[string]interface{}{ 222 | "status_code": handler.Status.Code().String(), 223 | }, 224 | } 225 | 226 | // Convert the response to JSON. 227 | jsonResponse, err := json.MarshalIndent(response, "", " ") 228 | if err != nil { 229 | return toolError("Failed to marshal response: " + err.Error()), nil 230 | } 231 | 232 | // Return the structured response. 233 | return toolSuccess(string(jsonResponse)), nil 234 | }) 235 | 236 | // Tool 2: list 237 | listTool := mcp.NewTool( 238 | "list", 239 | mcp.WithDescription("Lists all available gRPC services on the target server using reflection."), 240 | ) 241 | g.srv.AddTool(listTool, func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { 242 | // Create a gRPC client connection 243 | network := "tcp" 244 | target := g.host 245 | dialTime := 10 * time.Second 246 | 247 | dialOptions := []grpc.DialOption{ 248 | grpc.WithBlock(), 249 | grpc.WithTimeout(dialTime), 250 | grpc.WithInsecure(), 251 | } 252 | 253 | cc, err := grpcurl.BlockingDial(ctx, network, target, nil, dialOptions...) 254 | if err != nil { 255 | return toolError("Failed to create gRPC connection: " + err.Error()), nil 256 | } 257 | defer cc.Close() 258 | 259 | // Create a reflection client 260 | refClient := grpcreflect.NewClient(ctx, grpc_reflection_v1alpha.NewServerReflectionClient(cc)) 261 | defer refClient.Reset() 262 | 263 | // List all services 264 | services, err := refClient.ListServices() 265 | if err != nil { 266 | return toolError("Failed to list services: " + err.Error()), nil 267 | } 268 | 269 | // Format the output similarly to grpcurl 270 | var output strings.Builder 271 | for _, svc := range services { 272 | if svc != "grpc.reflection.v1alpha.ServerReflection" { 273 | output.WriteString(svc) 274 | output.WriteString("\n") 275 | } 276 | } 277 | 278 | return toolSuccess(output.String()), nil 279 | }) 280 | 281 | // Tool 3: describe 282 | describeTool := mcp.NewTool( 283 | "describe", 284 | mcp.WithDescription(`Describes a gRPC service or message type. 285 | Provide the target entity using dot notation. 286 | Examples: 287 | - "mypackage.MyService" to describe the service. 288 | - "mypackage.MyMessage.MyRpc" to describe a specific RPC method. 289 | - "mypackage.MyMessage" to describe a message type. 290 | Note: Slash notation (e.g., "mypackage.MyService/MyMethod") is used for invoking RPCs, not for describing symbols.`), 291 | WithStringArray("entities", mcp.Description("The services or messages type to describe (use dot notation)"), mcp.Required()), 292 | ) 293 | g.srv.AddTool(describeTool, func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { 294 | // Create a gRPC client connection 295 | network := "tcp" 296 | target := g.host 297 | dialTime := 10 * time.Second 298 | 299 | dialOptions := []grpc.DialOption{ 300 | grpc.WithBlock(), 301 | grpc.WithTimeout(dialTime), 302 | grpc.WithInsecure(), 303 | } 304 | 305 | cc, err := grpcurl.BlockingDial(ctx, network, target, nil, dialOptions...) 306 | if err != nil { 307 | return toolError("Failed to create gRPC connection: " + err.Error()), nil 308 | } 309 | defer cc.Close() 310 | 311 | // Create a reflection client and descriptor source 312 | refClient := grpcreflect.NewClient(ctx, grpc_reflection_v1alpha.NewServerReflectionClient(cc)) 313 | defer refClient.Reset() 314 | descSource := grpcurl.DescriptorSourceFromServer(ctx, refClient) 315 | 316 | args := request.Params.Arguments 317 | entities, ok := args["entities"].(string) 318 | var tmp []string 319 | if ok { 320 | tmp = strings.Split(entities, ",") 321 | } else if entities, ok := args["entities"].([]interface{}); ok { 322 | for _, entity := range entities { 323 | entityStr, ok := entity.(string) 324 | if !ok { 325 | given, _ := json.Marshal(entity) 326 | return toolError(fmt.Sprintf("entities argument should be an array of strings instead of %s", given)), nil 327 | } 328 | tmp = append(tmp, entityStr) 329 | } 330 | } 331 | 332 | // Split the entities by comma 333 | if len(tmp) == 0 { 334 | return toolError("No entities provided"), nil 335 | } 336 | 337 | var results []string 338 | 339 | for _, entityStr := range tmp { 340 | // Remove leading dot if present 341 | if entityStr != "" && entityStr[0] == '.' { 342 | entityStr = entityStr[1:] 343 | } 344 | 345 | // Find the symbol 346 | dsc, err := descSource.FindSymbol(entityStr) 347 | if err != nil { 348 | return toolError(fmt.Sprintf("Failed to resolve symbol %q: %v", entityStr, err)), nil 349 | } 350 | 351 | fqn := dsc.GetFullyQualifiedName() 352 | var elementType string 353 | 354 | // Determine the type of the descriptor 355 | switch d := dsc.(type) { 356 | case *desc.MessageDescriptor: 357 | elementType = "a message" 358 | if parent, ok := d.GetParent().(*desc.MessageDescriptor); ok { 359 | if d.IsMapEntry() { 360 | for _, f := range parent.GetFields() { 361 | if f.IsMap() && f.GetMessageType() == d { 362 | elementType = "the entry type for a map field" 363 | dsc = f 364 | break 365 | } 366 | } 367 | } else { 368 | for _, f := range parent.GetFields() { 369 | if f.GetType() == descriptorpb.FieldDescriptorProto_TYPE_GROUP && f.GetMessageType() == d { 370 | elementType = "the type of a group field" 371 | dsc = f 372 | break 373 | } 374 | } 375 | } 376 | } 377 | case *desc.FieldDescriptor: 378 | elementType = "a field" 379 | if d.GetType() == descriptorpb.FieldDescriptorProto_TYPE_GROUP { 380 | elementType = "a group field" 381 | } else if d.IsExtension() { 382 | elementType = "an extension" 383 | } 384 | case *desc.OneOfDescriptor: 385 | elementType = "a one-of" 386 | case *desc.EnumDescriptor: 387 | elementType = "an enum" 388 | case *desc.EnumValueDescriptor: 389 | elementType = "an enum value" 390 | case *desc.ServiceDescriptor: 391 | elementType = "a service" 392 | case *desc.MethodDescriptor: 393 | elementType = "a method" 394 | default: 395 | return toolError(fmt.Sprintf("descriptor has unrecognized type %T", dsc)), nil 396 | } 397 | 398 | // Get the descriptor text 399 | txt, err := grpcurl.GetDescriptorText(dsc, descSource) 400 | if err != nil { 401 | return toolError(fmt.Sprintf("Failed to describe symbol %q: %v", entityStr, err)), nil 402 | } 403 | 404 | description := fmt.Sprintf("%s is %s:\n%s", fqn, elementType, txt) 405 | 406 | // // For message types, also show a JSON template 407 | // if msgDesc, ok := dsc.(*desc.MessageDescriptor); ok { 408 | // tmpl := grpcurl.MakeTemplate(msgDesc) 409 | // options := grpcurl.FormatOptions{EmitJSONDefaultFields: true} 410 | // _, formatter, err := grpcurl.RequestParserAndFormatter(grpcurl.FormatJSON, descSource, nil, options) 411 | // if err != nil { 412 | // return toolError(fmt.Sprintf("Failed to create formatter: %v", err)), nil 413 | // } 414 | // str, err := formatter(tmpl) 415 | // if err != nil { 416 | // return toolError(fmt.Sprintf("Failed to print template for message %s: %v", entityStr, err)), nil 417 | // } 418 | // description += "\nMessage template:\n" + str 419 | // } 420 | 421 | results = append(results, description) 422 | } 423 | 424 | return toolSuccess(strings.Join(results, "\n\n")), nil 425 | }) 426 | 427 | return 428 | } 429 | 430 | // metadataToMap converts gRPC metadata to a map suitable for JSON marshaling 431 | func metadataToMap(md metadata.MD) map[string]interface{} { 432 | result := make(map[string]interface{}) 433 | for key, values := range md { 434 | // If there's only one value, store it directly rather than as an array 435 | if len(values) == 1 { 436 | result[key] = values[0] 437 | } else if len(values) > 0 { 438 | result[key] = values 439 | } 440 | } 441 | return result 442 | } 443 | 444 | // singleMessageSupplier implements grpcurl.RequestSupplier interface for a single message. 445 | type singleMessageSupplier struct { 446 | data []byte 447 | used bool 448 | } 449 | 450 | // Supply implements the grpcurl.RequestSupplier interface. 451 | func (s *singleMessageSupplier) Supply(msg proto.Message) error { 452 | if s.used { 453 | return io.EOF 454 | } 455 | s.used = true 456 | return jsonpb.Unmarshal(bytes.NewReader(s.data), msg) 457 | } 458 | 459 | func main() { 460 | address := os.Getenv("ADDRESS") 461 | if address == "" { 462 | log.Fatal("ADDRESS environment variable is required") 463 | os.Exit(1) 464 | } 465 | grpcServer := NewGrpcReflectionServer(address) 466 | if err := grpcServer.Serve(); err != nil && err != io.EOF { 467 | log.Fatal("Error serving MCP server:", err) 468 | os.Exit(1) 469 | } 470 | } 471 | 472 | // WithStringArray adds a string array property to the tool schema. 473 | func WithStringArray(name string, opts ...mcp.PropertyOption) mcp.ToolOption { 474 | return func(t *mcp.Tool) { 475 | schema := map[string]interface{}{ 476 | "type": "array", 477 | "items": map[string]interface{}{ 478 | "type": "string", 479 | }, 480 | } 481 | 482 | for _, opt := range opts { 483 | opt(schema) 484 | } 485 | 486 | if required, ok := schema["required"].(bool); ok && required { 487 | delete(schema, "required") 488 | if t.InputSchema.Required == nil { 489 | t.InputSchema.Required = []string{name} 490 | } else { 491 | t.InputSchema.Required = append(t.InputSchema.Required, name) 492 | } 493 | } 494 | 495 | t.InputSchema.Properties[name] = schema 496 | } 497 | } 498 | 499 | // toolSuccess creates a successful MCP response with the provided text contents. 500 | func toolSuccess(contents ...string) *mcp.CallToolResult { 501 | var iface []interface{} 502 | for _, c := range contents { 503 | iface = append(iface, mcp.NewTextContent(c)) 504 | } 505 | return &mcp.CallToolResult{ 506 | Content: iface, 507 | IsError: false, 508 | } 509 | } 510 | 511 | // toolError creates an MCP error response with the given error message. 512 | func toolError(message string) *mcp.CallToolResult { 513 | return &mcp.CallToolResult{ 514 | Content: []interface{}{mcp.NewTextContent(message)}, 515 | IsError: true, 516 | } 517 | } 518 | 519 | // GrpcReflectionServer wraps grpcurl functionalities into an MCP server. 520 | type GrpcReflectionServer struct { 521 | srv *server.MCPServer 522 | host string 523 | headers map[string]string // Global headers to be used with all requests 524 | } 525 | ```