registerTool wires one ToolBinding into the MCP server. The handler: 1. Constructs a fresh proto.Message of the right type via dynamicpb 2. Unmarshals JSON args into it 3. Invokes the gRPC method via grpc.ClientConn.Invoke (with bearer) 4. Marshals the response back to JSON for the MCP client
(b ToolBinding)
| 162 | // 3. Invokes the gRPC method via grpc.ClientConn.Invoke (with bearer) |
| 163 | // 4. Marshals the response back to JSON for the MCP client |
| 164 | func (s *Server) registerTool(b ToolBinding) { |
| 165 | schema := schemaForMessage(b.InputDescriptor) |
| 166 | tool := &mcp.Tool{ |
| 167 | Name: b.Name, |
| 168 | Description: b.Description, |
| 169 | InputSchema: schema, |
| 170 | } |
| 171 | if b.Destructive { |
| 172 | // MCP clients show a destructive-action confirmation when this is set. |
| 173 | tool.Annotations = &mcp.ToolAnnotations{DestructiveHint: ptrTrue()} |
| 174 | } |
| 175 | |
| 176 | s.mcpSrv.AddTool(tool, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { |
| 177 | // Build a dynamic proto.Message for the request, then unmarshal JSON. |
| 178 | reqMsg := dynamicpb.NewMessage(b.InputDescriptor) |
| 179 | if len(req.Params.Arguments) > 0 && !isJSONNull(req.Params.Arguments) { |
| 180 | if err := (protojson.UnmarshalOptions{DiscardUnknown: true}).Unmarshal(req.Params.Arguments, reqMsg); err != nil { |
| 181 | // Argument decode failures surface as tool errors (not |
| 182 | // protocol errors) so the LLM gets actionable text. |
| 183 | return errorResult("invalid arguments: " + err.Error()), nil |
| 184 | } |
| 185 | } |
| 186 | |
| 187 | respMsg := dynamicpb.NewMessage(b.OutputDescriptor) |
| 188 | if err := s.gwConn.Invoke(s.stampAuth(ctx), b.FullMethod, reqMsg, respMsg); err != nil { |
| 189 | s.log.Debug().Err(err).Str("tool", b.Name).Str("method", b.FullMethod).Msg("MCP tool invocation failed") |
| 190 | // gRPC errors (Unimplemented, PermissionDenied, NotFound, ...) |
| 191 | // become CallToolResult{IsError: true} with the gRPC status |
| 192 | // message as the content. The MCP host shows this to the LLM |
| 193 | // in a way that lets it react / try a different tool, rather |
| 194 | // than a low-level JSON-RPC failure that would just abort. |
| 195 | return errorResult(err.Error()), nil |
| 196 | } |
| 197 | |
| 198 | respJSON, err := (protojson.MarshalOptions{UseProtoNames: true, EmitUnpopulated: true}).Marshal(respMsg) |
| 199 | if err != nil { |
| 200 | return errorResult("encode response: " + err.Error()), nil |
| 201 | } |
| 202 | // Surface as both Content (text-shaped) and StructuredContent so MCP |
| 203 | // clients that prefer either get something they can consume. |
| 204 | var structured any |
| 205 | _ = json.Unmarshal(respJSON, &structured) |
| 206 | return &mcp.CallToolResult{ |
| 207 | Content: []mcp.Content{&mcp.TextContent{Text: string(respJSON)}}, |
| 208 | StructuredContent: structured, |
| 209 | }, nil |
| 210 | }) |
| 211 | } |
| 212 | |
| 213 | func ptrTrue() *bool { v := true; return &v } |
| 214 |
no test coverage detected