# 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 |
```