Skip to content

Commit 718e2cb

Browse files
JAORMXclaude
andauthored
Add secrets management support to ToolHive MCP server (#2006)
* Add secrets management support to ToolHive MCP server Implement comprehensive secrets management functionality for the ToolHive MCP server: - Add list_secrets tool to list available secrets from ToolHive secrets store - Add set_secret tool to set secrets from file paths (file-based input only) - Enhance run_server tool with secrets parameter support - Add SecretMapping struct for structured secret name/target specification - Include comprehensive test coverage for all new functionality - Integrate with existing ToolHive secrets providers (encrypted, 1Password, etc.) The run_server tool now accepts a secrets array parameter allowing users to pass secrets to MCP servers when running them, matching the CLI --secret flag functionality but through the MCP interface. * Add documentation for secrets management design decisions Clarify the following aspects of the secrets management implementation: - Description field in SecretInfo is populated by providers that support it (e.g., 1Password provides "Vault :: Item :: Field" format) and remains empty for providers without description support - SecretMapping intentionally excludes Description field as it's only relevant for listing/discovery, not for runtime secret mapping - ListSecrets request parameter follows MCP tool handler interface requirements but is unused since list_secrets takes no arguments Addresses review feedback from @yrobla on PR #2006. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]> --------- Co-authored-by: Claude <[email protected]>
1 parent 4cbc301 commit 718e2cb

File tree

8 files changed

+629
-16
lines changed

8 files changed

+629
-16
lines changed

pkg/mcp/server/handler.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"context"
66
"fmt"
77

8+
"github.com/stacklok/toolhive/pkg/config"
89
"github.com/stacklok/toolhive/pkg/registry"
910
"github.com/stacklok/toolhive/pkg/workloads"
1011
)
@@ -14,6 +15,7 @@ type Handler struct {
1415
ctx context.Context
1516
workloadManager workloads.Manager
1617
registryProvider registry.Provider
18+
configProvider config.Provider
1719
}
1820

1921
// NewHandler creates a new ToolHive handler
@@ -30,9 +32,13 @@ func NewHandler(ctx context.Context) (*Handler, error) {
3032
return nil, fmt.Errorf("failed to get registry provider: %w", err)
3133
}
3234

35+
// Create config provider
36+
configProvider := config.NewDefaultProvider()
37+
3338
return &Handler{
3439
ctx: ctx,
3540
workloadManager: workloadManager,
3641
registryProvider: registryProvider,
42+
configProvider: configProvider,
3743
}, nil
3844
}

pkg/mcp/server/handler_test.go

Lines changed: 77 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,16 @@ func TestParseRunServerArgs(t *testing.T) {
3131
"KEY1": "value1",
3232
"KEY2": "value2",
3333
},
34+
"secrets": []interface{}{
35+
map[string]interface{}{
36+
"name": "github-token",
37+
"target": "GITHUB_TOKEN",
38+
},
39+
map[string]interface{}{
40+
"name": "api-key",
41+
"target": "API_KEY",
42+
},
43+
},
3444
},
3545
},
3646
},
@@ -42,6 +52,10 @@ func TestParseRunServerArgs(t *testing.T) {
4252
"KEY1": "value1",
4353
"KEY2": "value2",
4454
},
55+
Secrets: []SecretMapping{
56+
{Name: "github-token", Target: "GITHUB_TOKEN"},
57+
{Name: "api-key", Target: "API_KEY"},
58+
},
4559
},
4660
wantErr: false,
4761
},
@@ -55,10 +69,11 @@ func TestParseRunServerArgs(t *testing.T) {
5569
},
5670
},
5771
expected: &runServerArgs{
58-
Server: "test-server",
59-
Name: "test-server", // Should default to server name
60-
Host: "127.0.0.1", // Should default to 127.0.0.1
61-
Env: nil,
72+
Server: "test-server",
73+
Name: "test-server", // Should default to server name
74+
Host: "127.0.0.1", // Should default to 127.0.0.1
75+
Env: nil,
76+
Secrets: nil,
6277
},
6378
wantErr: false,
6479
},
@@ -73,10 +88,11 @@ func TestParseRunServerArgs(t *testing.T) {
7388
},
7489
},
7590
expected: &runServerArgs{
76-
Server: "my-server",
77-
Name: "my-server",
78-
Host: "127.0.0.1",
79-
Env: nil,
91+
Server: "my-server",
92+
Name: "my-server",
93+
Host: "127.0.0.1",
94+
Env: nil,
95+
Secrets: nil,
8096
},
8197
wantErr: false,
8298
},
@@ -91,10 +107,11 @@ func TestParseRunServerArgs(t *testing.T) {
91107
},
92108
},
93109
expected: &runServerArgs{
94-
Server: "test-server",
95-
Name: "test-server",
96-
Host: "127.0.0.1",
97-
Env: nil,
110+
Server: "test-server",
111+
Name: "test-server",
112+
Host: "127.0.0.1",
113+
Env: nil,
114+
Secrets: nil,
98115
},
99116
wantErr: false,
100117
},
@@ -306,3 +323,51 @@ func TestBuildServerConfig(t *testing.T) {
306323
})
307324
}
308325
}
326+
327+
func TestPrepareSecrets(t *testing.T) {
328+
t.Parallel()
329+
tests := []struct {
330+
name string
331+
secrets []SecretMapping
332+
expected []string
333+
}{
334+
{
335+
name: "nil secrets",
336+
secrets: nil,
337+
expected: nil,
338+
},
339+
{
340+
name: "empty secrets",
341+
secrets: []SecretMapping{},
342+
expected: nil,
343+
},
344+
{
345+
name: "single secret",
346+
secrets: []SecretMapping{
347+
{Name: "github-token", Target: "GITHUB_TOKEN"},
348+
},
349+
expected: []string{"github-token,target=GITHUB_TOKEN"},
350+
},
351+
{
352+
name: "multiple secrets",
353+
secrets: []SecretMapping{
354+
{Name: "github-token", Target: "GITHUB_TOKEN"},
355+
{Name: "api-key", Target: "API_KEY"},
356+
{Name: "db-password", Target: "DATABASE_PASSWORD"},
357+
},
358+
expected: []string{
359+
"github-token,target=GITHUB_TOKEN",
360+
"api-key,target=API_KEY",
361+
"db-password,target=DATABASE_PASSWORD",
362+
},
363+
},
364+
}
365+
366+
for _, tt := range tests {
367+
t.Run(tt.name, func(t *testing.T) {
368+
t.Parallel()
369+
result := prepareSecrets(tt.secrets)
370+
assert.Equal(t, tt.expected, result)
371+
})
372+
}
373+
}

pkg/mcp/server/list_secrets.go

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
package server
2+
3+
import (
4+
"context"
5+
"fmt"
6+
7+
"github.com/mark3labs/mcp-go/mcp"
8+
9+
"github.com/stacklok/toolhive/pkg/secrets"
10+
)
11+
12+
// SecretInfo represents secret information returned by list
13+
type SecretInfo struct {
14+
Key string `json:"key"`
15+
// Description is populated by secrets providers that support it (e.g., 1Password
16+
// provides "Vault :: Item :: Field" descriptions). Will be empty for providers
17+
// that don't support descriptions (e.g., encrypted provider).
18+
Description string `json:"description,omitempty"`
19+
}
20+
21+
// ListSecretsResponse represents the response from listing secrets
22+
type ListSecretsResponse struct {
23+
Secrets []SecretInfo `json:"secrets"`
24+
}
25+
26+
// ListSecrets lists all available secrets.
27+
// The request parameter is required by the MCP tool handler interface but not used
28+
// by this handler since list_secrets takes no arguments.
29+
func (h *Handler) ListSecrets(ctx context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) {
30+
// Get the configuration to determine the secrets provider
31+
cfg := h.configProvider.GetConfig()
32+
33+
// Check if secrets setup has been completed
34+
if !cfg.Secrets.SetupCompleted {
35+
return mcp.NewToolResultError(
36+
"Secrets provider not configured. Please run 'thv secret setup' to configure a secrets provider first"), nil
37+
}
38+
39+
// Get the provider type
40+
providerType, err := cfg.Secrets.GetProviderType()
41+
if err != nil {
42+
return mcp.NewToolResultError(fmt.Sprintf("Failed to get secrets provider type: %v", err)), nil
43+
}
44+
45+
// Create the secrets provider
46+
secretsProvider, err := secrets.CreateSecretProvider(providerType)
47+
if err != nil {
48+
return mcp.NewToolResultError(fmt.Sprintf("Failed to create secrets provider: %v", err)), nil
49+
}
50+
51+
// List all secrets
52+
secretDescriptions, err := secretsProvider.ListSecrets(ctx)
53+
if err != nil {
54+
return mcp.NewToolResultError(fmt.Sprintf("Failed to list secrets: %v", err)), nil
55+
}
56+
57+
// Format results with structured data
58+
var results []SecretInfo
59+
for _, desc := range secretDescriptions {
60+
info := SecretInfo{
61+
Key: desc.Key,
62+
Description: desc.Description,
63+
}
64+
results = append(results, info)
65+
}
66+
67+
// Create structured response
68+
response := ListSecretsResponse{
69+
Secrets: results,
70+
}
71+
72+
return mcp.NewToolResultStructuredOnly(response), nil
73+
}
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
package server
2+
3+
import (
4+
"context"
5+
"testing"
6+
7+
"github.com/mark3labs/mcp-go/mcp"
8+
"github.com/stretchr/testify/assert"
9+
"go.uber.org/mock/gomock"
10+
11+
"github.com/stacklok/toolhive/pkg/config"
12+
configmocks "github.com/stacklok/toolhive/pkg/config/mocks"
13+
registrymocks "github.com/stacklok/toolhive/pkg/registry/mocks"
14+
workloadsmocks "github.com/stacklok/toolhive/pkg/workloads/mocks"
15+
)
16+
17+
func TestHandler_ListSecrets(t *testing.T) {
18+
t.Parallel()
19+
ctrl := gomock.NewController(t)
20+
t.Cleanup(func() { ctrl.Finish() })
21+
22+
tests := []struct {
23+
name string
24+
setupMocks func(*configmocks.MockProvider)
25+
wantErr bool
26+
checkResult func(*testing.T, *mcp.CallToolResult)
27+
}{
28+
{
29+
name: "secrets not setup",
30+
setupMocks: func(configProvider *configmocks.MockProvider) {
31+
// Mock config setup - not completed
32+
cfg := &config.Config{
33+
Secrets: config.Secrets{
34+
SetupCompleted: false,
35+
},
36+
}
37+
configProvider.EXPECT().GetConfig().Return(cfg).AnyTimes()
38+
},
39+
wantErr: false,
40+
checkResult: func(t *testing.T, result *mcp.CallToolResult) {
41+
t.Helper()
42+
assert.NotNil(t, result)
43+
assert.True(t, result.IsError)
44+
},
45+
},
46+
}
47+
48+
for _, tt := range tests {
49+
t.Run(tt.name, func(t *testing.T) {
50+
t.Parallel()
51+
52+
// Create mocks
53+
mockRegistry := registrymocks.NewMockProvider(ctrl)
54+
mockWorkloadManager := workloadsmocks.NewMockManager(ctrl)
55+
mockConfigProvider := configmocks.NewMockProvider(ctrl)
56+
57+
// Setup mocks
58+
if tt.setupMocks != nil {
59+
tt.setupMocks(mockConfigProvider)
60+
}
61+
62+
handler := &Handler{
63+
ctx: context.Background(),
64+
workloadManager: mockWorkloadManager,
65+
registryProvider: mockRegistry,
66+
configProvider: mockConfigProvider,
67+
}
68+
69+
request := mcp.CallToolRequest{
70+
Params: mcp.CallToolParams{
71+
Name: "list_secrets",
72+
Arguments: map[string]interface{}{},
73+
},
74+
}
75+
76+
result, err := handler.ListSecrets(context.Background(), request)
77+
78+
if tt.wantErr {
79+
assert.Error(t, err)
80+
} else {
81+
assert.NoError(t, err)
82+
if tt.checkResult != nil {
83+
tt.checkResult(t, result)
84+
}
85+
}
86+
})
87+
}
88+
}

pkg/mcp/server/run_server.go

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,22 @@ import (
1414
transporttypes "github.com/stacklok/toolhive/pkg/transport/types"
1515
)
1616

17+
// SecretMapping represents a secret name and its target environment variable.
18+
// Note: Description is not included because it's only relevant for listing/discovery
19+
// (see SecretInfo). When mapping secrets to a running server, only the name and target
20+
// environment variable are needed.
21+
type SecretMapping struct {
22+
Name string `json:"name"`
23+
Target string `json:"target"`
24+
}
25+
1726
// runServerArgs holds the arguments for running a server
1827
type runServerArgs struct {
19-
Server string `json:"server"`
20-
Name string `json:"name,omitempty"`
21-
Host string `json:"host,omitempty"`
22-
Env map[string]string `json:"env,omitempty"`
28+
Server string `json:"server"`
29+
Name string `json:"name,omitempty"`
30+
Host string `json:"host,omitempty"`
31+
Env map[string]string `json:"env,omitempty"`
32+
Secrets []SecretMapping `json:"secrets,omitempty"`
2333
}
2434

2535
// RunServer runs an MCP server
@@ -119,6 +129,12 @@ func buildServerConfig(
119129
// Prepare environment variables
120130
envVars := prepareEnvironmentVariables(imageMetadata, args.Env)
121131

132+
// Prepare secrets
133+
secrets := prepareSecrets(args.Secrets)
134+
if len(secrets) > 0 {
135+
opts = append(opts, runner.WithSecrets(secrets))
136+
}
137+
122138
// Build the configuration
123139
envVarValidator := &runner.DetachedEnvVarValidator{}
124140
return runner.NewRunConfigBuilder(ctx, imageMetadata, envVars, envVarValidator, opts...)
@@ -159,6 +175,21 @@ func prepareEnvironmentVariables(imageMetadata *registry.ImageMetadata, userEnv
159175
return envVarsMap
160176
}
161177

178+
// prepareSecrets converts SecretMapping array to the string format expected by the runner
179+
func prepareSecrets(secretMappings []SecretMapping) []string {
180+
if len(secretMappings) == 0 {
181+
return nil
182+
}
183+
184+
secrets := make([]string, len(secretMappings))
185+
for i, mapping := range secretMappings {
186+
// Convert to the format expected by runner: "secret_name,target=ENV_VAR_NAME"
187+
secrets[i] = fmt.Sprintf("%s,target=%s", mapping.Name, mapping.Target)
188+
}
189+
190+
return secrets
191+
}
192+
162193
// saveAndRunServer saves the configuration and runs the server
163194
func (h *Handler) saveAndRunServer(ctx context.Context, runConfig *runner.RunConfig, name string) error {
164195
// Save the run configuration state before starting

0 commit comments

Comments
 (0)