Skip to content

Commit 168368b

Browse files
Saranya-jenaHarness
authored andcommitted
chore: [ML-1336]: Added list_secrets tool and e2e test (#153)
* chore: [ML-1336]: Added list_secrets tool and e2e test Signed-off-by: Saranya-jena <[email protected]>
1 parent a14c74e commit 168368b

File tree

5 files changed

+342
-1
lines changed

5 files changed

+342
-1
lines changed

client/dto/secrets.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,3 +36,35 @@ type SecretSpec struct {
3636
type SecretGetOptions struct {
3737
// Add any additional options needed for getting secrets
3838
}
39+
40+
// ListSecretsResponse represents the response from the list secrets API
41+
type ListSecretsResponse struct {
42+
Status string `json:"status"`
43+
Data ListSecretsData `json:"data"`
44+
MetaData interface{} `json:"metaData"`
45+
CorrelationID string `json:"correlationId"`
46+
}
47+
48+
// ListSecretsData represents the data field in the list secrets response
49+
type ListSecretsData struct {
50+
TotalPages int `json:"totalPages"`
51+
TotalItems int `json:"totalItems"`
52+
PageItemCount int `json:"pageItemCount"`
53+
PageSize int `json:"pageSize"`
54+
Content []SecretData `json:"content"`
55+
}
56+
57+
// SecretFilterProperties represents the filter properties for listing secrets
58+
type SecretFilterProperties struct {
59+
SecretName string `json:"secretName,omitempty"`
60+
SecretIdentifier string `json:"secretIdentifier,omitempty"`
61+
SecretTypes []string `json:"secretTypes,omitempty"`
62+
SecretManagerIdentifiers []string `json:"secretManagerIdentifiers,omitempty"`
63+
Description string `json:"description,omitempty"`
64+
SearchTerm string `json:"searchTerm,omitempty"`
65+
IncludeSecretsFromEverySubScope bool `json:"includeSecretsFromEverySubScope,omitempty"`
66+
IncludeAllSecretsAccessibleAtScope bool `json:"includeAllSecretsAccessibleAtScope,omitempty"`
67+
Tags map[string]string `json:"tags,omitempty"`
68+
Labels map[string]string `json:"labels,omitempty"`
69+
FilterType string `json:"filterType,omitempty"`
70+
}

client/secrets.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,14 @@ package client
33
import (
44
"context"
55
"fmt"
6+
"log/slog"
67

78
"github.com/harness/harness-mcp/client/dto"
89
)
910

1011
const (
1112
secretsBasePath = "/v2/secrets"
13+
listSecretsPath = "/v2/secrets/list/secrets"
1214
)
1315

1416
// SecretsClient provides methods to interact with the Harness secrets API
@@ -34,3 +36,33 @@ func (c *SecretsClient) GetSecret(ctx context.Context, scope dto.Scope, secretId
3436

3537
return &response, nil
3638
}
39+
40+
// ListSecrets retrieves a list of secrets with pagination and filtering options
41+
func (c *SecretsClient) ListSecrets(ctx context.Context, scope dto.Scope, pageIndex, pageSize int, sortOrders []string, filters dto.SecretFilterProperties) (*dto.ListSecretsResponse, error) {
42+
if scope.AccountID == "" {
43+
return nil, fmt.Errorf("accountIdentifier cannot be null")
44+
}
45+
46+
params := make(map[string]string)
47+
addScope(scope, params)
48+
49+
// Add pagination parameters
50+
params["pageIndex"] = fmt.Sprintf("%d", pageIndex)
51+
params["pageSize"] = fmt.Sprintf("%d", pageSize)
52+
53+
// The API requires filterType in the request body
54+
// Ensure filterType is set to "Secret" for listing secrets
55+
if filters.FilterType == "" {
56+
filters.FilterType = "Secret"
57+
}
58+
reqBody := filters
59+
slog.Info("Request body", "body", reqBody)
60+
var response dto.ListSecretsResponse
61+
headers := make(map[string]string)
62+
err := c.Post(ctx, listSecretsPath, params, reqBody, headers, &response)
63+
if err != nil {
64+
return nil, fmt.Errorf("failed to list secrets: %w", err)
65+
}
66+
67+
return &response, nil
68+
}

pkg/harness/tools/secrets.go

Lines changed: 141 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,9 @@ import (
44
"context"
55
"encoding/json"
66
"fmt"
7-
7+
88
"github.com/harness/harness-mcp/client"
9+
"github.com/harness/harness-mcp/client/dto"
910
"github.com/harness/harness-mcp/cmd/harness-mcp-server/config"
1011
"github.com/mark3labs/mcp-go/mcp"
1112
"github.com/mark3labs/mcp-go/server"
@@ -46,3 +47,142 @@ func GetSecretTool(config *config.Config, client *client.SecretsClient) (tool mc
4647
return mcp.NewToolResultText(string(r)), nil
4748
}
4849
}
50+
51+
// Secret types supported by the Harness secrets API
52+
const (
53+
SecretTypeSecretFile = "SecretFile"
54+
SecretTypeSecretText = "SecretText"
55+
SecretTypeSSHKey = "SSHKey"
56+
SecretTypeWinRmCredentials = "WinRmCredentials"
57+
)
58+
59+
// Sort fields for secrets
60+
const (
61+
SortByName = "name"
62+
SortByIdentifier = "identifier"
63+
SortByCreated = "created"
64+
SortByUpdated = "updated"
65+
)
66+
67+
// ListSecretsTool creates a tool for listing secrets from Harness
68+
func ListSecretsTool(config *config.Config, client *client.SecretsClient) (tool mcp.Tool, handler server.ToolHandlerFunc) {
69+
return mcp.NewTool("list_secrets",
70+
mcp.WithDescription("List secrets from Harness with filtering and pagination options."),
71+
mcp.WithArray("secret",
72+
mcp.WithStringItems(),
73+
mcp.Description("Identifier field of secrets"),
74+
),
75+
mcp.WithArray("type",
76+
mcp.WithStringItems(),
77+
mcp.Description("Secret types on which the filter will be applied"),
78+
mcp.Enum(
79+
SecretTypeSecretFile,
80+
SecretTypeSecretText,
81+
SecretTypeSSHKey,
82+
SecretTypeWinRmCredentials,
83+
),
84+
),
85+
mcp.WithBoolean("recursive",
86+
mcp.Description("Expand current scope to include all child scopes"),
87+
),
88+
mcp.WithString("search_term",
89+
mcp.Description("Filter resources having attributes matching with search term"),
90+
),
91+
mcp.WithString("filter_type",
92+
mcp.Description("Type of resources to filter"),
93+
mcp.Enum(
94+
"Secret", "Connector", "DelegateProfile", "Delegate", "PipelineSetup",
95+
"PipelineExecution", "Deployment", "Audit", "Template", "Trigger",
96+
"EnvironmentGroup", "FileStore", "CCMRecommendation", "Anomaly",
97+
"RIInventory", "SPInventory", "Autocud", "CCMConnector",
98+
"CCMK8sConnector", "Environment", "RuleExecution", "Override",
99+
"InputSet", "Webhook",
100+
),
101+
),
102+
WithScope(config, false),
103+
WithPagination(),
104+
),
105+
func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
106+
scope, err := FetchScope(config, request, false)
107+
if err != nil {
108+
return mcp.NewToolResultError(err.Error()), nil
109+
}
110+
111+
// Get pagination parameters
112+
page, size, err := FetchPagination(request)
113+
if err != nil {
114+
return mcp.NewToolResultError(err.Error()), nil
115+
}
116+
117+
// Get filter parameters
118+
secretIds, err := OptionalAnyArrayParam(request, "secret")
119+
if err != nil {
120+
return mcp.NewToolResultError(err.Error()), nil
121+
}
122+
// Convert []any to []string
123+
secretIdsStr := make([]string, 0, len(secretIds))
124+
for _, id := range secretIds {
125+
if str, ok := id.(string); ok {
126+
secretIdsStr = append(secretIdsStr, str)
127+
}
128+
}
129+
130+
secretTypes, err := OptionalAnyArrayParam(request, "type")
131+
if err != nil {
132+
return mcp.NewToolResultError(err.Error()), nil
133+
}
134+
// Convert []any to []string
135+
secretTypesStr := make([]string, 0, len(secretTypes))
136+
for _, t := range secretTypes {
137+
if str, ok := t.(string); ok {
138+
secretTypesStr = append(secretTypesStr, str)
139+
}
140+
}
141+
142+
recursive, err := OptionalParam[bool](request, "recursive")
143+
if err != nil {
144+
return mcp.NewToolResultError(err.Error()), nil
145+
}
146+
147+
searchTerm, err := OptionalParam[string](request, "search_term")
148+
if err != nil {
149+
return mcp.NewToolResultError(err.Error()), nil
150+
}
151+
152+
// Temporarily removing sort orders due to API compatibility issues
153+
// We'll revisit this once we have more information about the expected format
154+
sortOrders := []string{}
155+
156+
// Get filter_type parameter
157+
filterType, err := OptionalParam[string](request, "filter_type")
158+
if err != nil {
159+
return mcp.NewToolResultError(err.Error()), nil
160+
}
161+
162+
// Create filter properties
163+
filters := dto.SecretFilterProperties{
164+
SecretTypes: secretTypesStr,
165+
SearchTerm: searchTerm,
166+
IncludeSecretsFromEverySubScope: recursive,
167+
FilterType: filterType,
168+
}
169+
170+
// If secretIds is provided, use the first one as secretIdentifier
171+
if len(secretIdsStr) > 0 {
172+
filters.SecretIdentifier = secretIdsStr[0]
173+
}
174+
175+
// Call the client to list secrets
176+
response, err := client.ListSecrets(ctx, scope, page, size, sortOrders, filters)
177+
if err != nil {
178+
return nil, fmt.Errorf("failed to list secrets: %w", err)
179+
}
180+
181+
r, err := json.Marshal(response)
182+
if err != nil {
183+
return nil, fmt.Errorf("failed to marshal list secrets response: %w", err)
184+
}
185+
186+
return mcp.NewToolResultText(string(r)), nil
187+
}
188+
}

pkg/modules/core.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -494,6 +494,7 @@ func RegisterSecrets(config *config.Config, tsg *toolsets.ToolsetGroup) error {
494494
secrets := toolsets.NewToolset("secrets", "Harness Secrets related tools").
495495
AddReadTools(
496496
toolsets.NewServerTool(tools.GetSecretTool(config, secretsClient)),
497+
toolsets.NewServerTool(tools.ListSecretsTool(config, secretsClient)),
497498
)
498499

499500
// Add toolset to the group

test/e2e/secrets_test.go

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
//go:build e2e
2+
3+
package e2e
4+
5+
import (
6+
"context"
7+
"encoding/json"
8+
"fmt"
9+
"testing"
10+
11+
"github.com/harness/harness-mcp/client/dto"
12+
"github.com/mark3labs/mcp-go/mcp"
13+
"github.com/stretchr/testify/require"
14+
)
15+
16+
// TestListSecretsTools verifies that the list_secrets tool is available
17+
func TestListSecretsTools(t *testing.T) {
18+
t.Parallel()
19+
20+
mcpClient := setupMCPClient(t)
21+
ctx := context.Background()
22+
23+
// List available tools
24+
request := mcp.ListToolsRequest{}
25+
response, err := mcpClient.ListTools(ctx, request)
26+
require.NoError(t, err, "expected to list tools successfully")
27+
28+
// Check that secrets tools are available and get their actual names
29+
var foundSecretsTools = make(map[string]string)
30+
secretsToolPatterns := []string{
31+
"list_secrets",
32+
"get_secret",
33+
}
34+
35+
// Print all available tools
36+
fmt.Println("Available tools:")
37+
for _, tool := range response.Tools {
38+
fmt.Printf("- %s\n", tool.Name)
39+
40+
// Check if this tool matches any of our patterns
41+
for _, pattern := range secretsToolPatterns {
42+
if tool.Name == pattern {
43+
foundSecretsTools[pattern] = tool.Name
44+
break
45+
}
46+
}
47+
}
48+
49+
// Print what we found
50+
fmt.Println("Found secrets tools:")
51+
for pattern, actualName := range foundSecretsTools {
52+
fmt.Printf("- %s -> %s\n", pattern, actualName)
53+
}
54+
55+
// Check if we found the list_secrets tool
56+
require.Contains(t, foundSecretsTools, "list_secrets", "expected to find list_secrets tool")
57+
}
58+
59+
// TestListSecrets verifies that the list_secrets tool works correctly with the filter_type parameter
60+
// and then calls get_secret on the first secret found
61+
func TestListSecrets(t *testing.T) {
62+
t.Parallel()
63+
64+
mcpClient := setupMCPClient(t)
65+
ctx := context.Background()
66+
accountID := getE2EAccountID(t)
67+
68+
// Call the list_secrets tool with filter_type parameter
69+
listRequest := mcp.CallToolRequest{}
70+
listRequest.Params.Name = "list_secrets"
71+
listRequest.Params.Arguments = map[string]any{
72+
"accountIdentifier": accountID,
73+
"page_index": 0,
74+
"page_size": 10,
75+
"filter_type": "Secret", // Testing the filter_type parameter
76+
}
77+
78+
listResponse, err := mcpClient.CallTool(ctx, listRequest)
79+
require.NoError(t, err, "expected to call 'list_secrets' tool successfully")
80+
if listResponse.IsError {
81+
t.Logf("Error response: %v", listResponse.Content)
82+
}
83+
require.False(t, listResponse.IsError, "expected result not to be an error")
84+
85+
// Verify response content
86+
require.NotEmpty(t, listResponse.Content, "expected content to not be empty")
87+
88+
// Parse the response to extract secrets
89+
textContent, ok := listResponse.Content[0].(mcp.TextContent)
90+
require.True(t, ok, "expected content to be of type TextContent")
91+
92+
var secretsResponse dto.ListSecretsResponse
93+
err = json.Unmarshal([]byte(textContent.Text), &secretsResponse)
94+
require.NoError(t, err, "expected to unmarshal response successfully")
95+
96+
// Verify the response structure
97+
require.Equal(t, "SUCCESS", secretsResponse.Status, "expected status to be SUCCESS")
98+
t.Logf("Found %d secrets", len(secretsResponse.Data.Content))
99+
100+
// If we found any secrets, get the first one using get_secret tool
101+
if len(secretsResponse.Data.Content) > 0 {
102+
firstSecret := secretsResponse.Data.Content[0]
103+
secretIdentifier := firstSecret.Secret.Identifier
104+
t.Logf("Getting details for secret: %s", secretIdentifier)
105+
106+
// Call the get_secret tool with the identifier of the first secret
107+
getRequest := mcp.CallToolRequest{}
108+
getRequest.Params.Name = "get_secret"
109+
getRequest.Params.Arguments = map[string]any{
110+
"accountIdentifier": accountID,
111+
"secret_identifier": secretIdentifier,
112+
}
113+
114+
getResponse, err := mcpClient.CallTool(ctx, getRequest)
115+
require.NoError(t, err, "expected to call 'get_secret' tool successfully")
116+
require.False(t, getResponse.IsError, "expected get_secret result not to be an error")
117+
118+
// Verify get_secret response content
119+
require.NotEmpty(t, getResponse.Content, "expected get_secret content to not be empty")
120+
121+
// Parse the get_secret response
122+
getTextContent, ok := getResponse.Content[0].(mcp.TextContent)
123+
require.True(t, ok, "expected get_secret content to be of type TextContent")
124+
125+
var getSecretResponse dto.SecretResponse
126+
err = json.Unmarshal([]byte(getTextContent.Text), &getSecretResponse)
127+
require.NoError(t, err, "expected to unmarshal get_secret response successfully")
128+
129+
// Verify the get_secret response structure
130+
require.Equal(t, "SUCCESS", getSecretResponse.Status, "expected get_secret status to be SUCCESS")
131+
require.Equal(t, secretIdentifier, getSecretResponse.Data.Secret.Identifier, "expected get_secret to return the requested secret")
132+
t.Logf("Successfully retrieved secret: %s (Type: %s)", getSecretResponse.Data.Secret.Name, getSecretResponse.Data.Secret.Type)
133+
} else {
134+
t.Log("No secrets found, skipping get_secret test")
135+
}
136+
}

0 commit comments

Comments
 (0)