Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions pkg/mcp/server/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"context"
"fmt"

"github.com/stacklok/toolhive/pkg/config"
"github.com/stacklok/toolhive/pkg/registry"
"github.com/stacklok/toolhive/pkg/workloads"
)
Expand All @@ -14,6 +15,7 @@ type Handler struct {
ctx context.Context
workloadManager workloads.Manager
registryProvider registry.Provider
configProvider config.Provider
}

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

// Create config provider
configProvider := config.NewDefaultProvider()

return &Handler{
ctx: ctx,
workloadManager: workloadManager,
registryProvider: registryProvider,
configProvider: configProvider,
}, nil
}
89 changes: 77 additions & 12 deletions pkg/mcp/server/handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,16 @@ func TestParseRunServerArgs(t *testing.T) {
"KEY1": "value1",
"KEY2": "value2",
},
"secrets": []interface{}{
map[string]interface{}{
"name": "github-token",
"target": "GITHUB_TOKEN",
},
map[string]interface{}{
"name": "api-key",
"target": "API_KEY",
},
},
},
},
},
Expand All @@ -42,6 +52,10 @@ func TestParseRunServerArgs(t *testing.T) {
"KEY1": "value1",
"KEY2": "value2",
},
Secrets: []SecretMapping{
{Name: "github-token", Target: "GITHUB_TOKEN"},
{Name: "api-key", Target: "API_KEY"},
},
},
wantErr: false,
},
Expand All @@ -55,10 +69,11 @@ func TestParseRunServerArgs(t *testing.T) {
},
},
expected: &runServerArgs{
Server: "test-server",
Name: "test-server", // Should default to server name
Host: "127.0.0.1", // Should default to 127.0.0.1
Env: nil,
Server: "test-server",
Name: "test-server", // Should default to server name
Host: "127.0.0.1", // Should default to 127.0.0.1
Env: nil,
Secrets: nil,
},
wantErr: false,
},
Expand All @@ -73,10 +88,11 @@ func TestParseRunServerArgs(t *testing.T) {
},
},
expected: &runServerArgs{
Server: "my-server",
Name: "my-server",
Host: "127.0.0.1",
Env: nil,
Server: "my-server",
Name: "my-server",
Host: "127.0.0.1",
Env: nil,
Secrets: nil,
},
wantErr: false,
},
Expand All @@ -91,10 +107,11 @@ func TestParseRunServerArgs(t *testing.T) {
},
},
expected: &runServerArgs{
Server: "test-server",
Name: "test-server",
Host: "127.0.0.1",
Env: nil,
Server: "test-server",
Name: "test-server",
Host: "127.0.0.1",
Env: nil,
Secrets: nil,
},
wantErr: false,
},
Expand Down Expand Up @@ -306,3 +323,51 @@ func TestBuildServerConfig(t *testing.T) {
})
}
}

func TestPrepareSecrets(t *testing.T) {
t.Parallel()
tests := []struct {
name string
secrets []SecretMapping
expected []string
}{
{
name: "nil secrets",
secrets: nil,
expected: nil,
},
{
name: "empty secrets",
secrets: []SecretMapping{},
expected: nil,
},
{
name: "single secret",
secrets: []SecretMapping{
{Name: "github-token", Target: "GITHUB_TOKEN"},
},
expected: []string{"github-token,target=GITHUB_TOKEN"},
},
{
name: "multiple secrets",
secrets: []SecretMapping{
{Name: "github-token", Target: "GITHUB_TOKEN"},
{Name: "api-key", Target: "API_KEY"},
{Name: "db-password", Target: "DATABASE_PASSWORD"},
},
expected: []string{
"github-token,target=GITHUB_TOKEN",
"api-key,target=API_KEY",
"db-password,target=DATABASE_PASSWORD",
},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
result := prepareSecrets(tt.secrets)
assert.Equal(t, tt.expected, result)
})
}
}
73 changes: 73 additions & 0 deletions pkg/mcp/server/list_secrets.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package server

import (
"context"
"fmt"

"github.com/mark3labs/mcp-go/mcp"

"github.com/stacklok/toolhive/pkg/secrets"
)

// SecretInfo represents secret information returned by list
type SecretInfo struct {
Key string `json:"key"`
// Description is populated by secrets providers that support it (e.g., 1Password
// provides "Vault :: Item :: Field" descriptions). Will be empty for providers
// that don't support descriptions (e.g., encrypted provider).
Description string `json:"description,omitempty"`
}

// ListSecretsResponse represents the response from listing secrets
type ListSecretsResponse struct {
Secrets []SecretInfo `json:"secrets"`
}

// ListSecrets lists all available secrets.
// The request parameter is required by the MCP tool handler interface but not used
// by this handler since list_secrets takes no arguments.
func (h *Handler) ListSecrets(ctx context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) {
// Get the configuration to determine the secrets provider
cfg := h.configProvider.GetConfig()

// Check if secrets setup has been completed
if !cfg.Secrets.SetupCompleted {
return mcp.NewToolResultError(
"Secrets provider not configured. Please run 'thv secret setup' to configure a secrets provider first"), nil
}

// Get the provider type
providerType, err := cfg.Secrets.GetProviderType()
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("Failed to get secrets provider type: %v", err)), nil
}

// Create the secrets provider
secretsProvider, err := secrets.CreateSecretProvider(providerType)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("Failed to create secrets provider: %v", err)), nil
}

// List all secrets
secretDescriptions, err := secretsProvider.ListSecrets(ctx)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("Failed to list secrets: %v", err)), nil
}

// Format results with structured data
var results []SecretInfo
for _, desc := range secretDescriptions {
info := SecretInfo{
Key: desc.Key,
Description: desc.Description,
}
results = append(results, info)
}

// Create structured response
response := ListSecretsResponse{
Secrets: results,
}

return mcp.NewToolResultStructuredOnly(response), nil
}
88 changes: 88 additions & 0 deletions pkg/mcp/server/list_secrets_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package server

import (
"context"
"testing"

"github.com/mark3labs/mcp-go/mcp"
"github.com/stretchr/testify/assert"
"go.uber.org/mock/gomock"

"github.com/stacklok/toolhive/pkg/config"
configmocks "github.com/stacklok/toolhive/pkg/config/mocks"
registrymocks "github.com/stacklok/toolhive/pkg/registry/mocks"
workloadsmocks "github.com/stacklok/toolhive/pkg/workloads/mocks"
)

func TestHandler_ListSecrets(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
t.Cleanup(func() { ctrl.Finish() })

tests := []struct {
name string
setupMocks func(*configmocks.MockProvider)
wantErr bool
checkResult func(*testing.T, *mcp.CallToolResult)
}{
{
name: "secrets not setup",
setupMocks: func(configProvider *configmocks.MockProvider) {
// Mock config setup - not completed
cfg := &config.Config{
Secrets: config.Secrets{
SetupCompleted: false,
},
}
configProvider.EXPECT().GetConfig().Return(cfg).AnyTimes()
},
wantErr: false,
checkResult: func(t *testing.T, result *mcp.CallToolResult) {
t.Helper()
assert.NotNil(t, result)
assert.True(t, result.IsError)
},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()

// Create mocks
mockRegistry := registrymocks.NewMockProvider(ctrl)
mockWorkloadManager := workloadsmocks.NewMockManager(ctrl)
mockConfigProvider := configmocks.NewMockProvider(ctrl)

// Setup mocks
if tt.setupMocks != nil {
tt.setupMocks(mockConfigProvider)
}

handler := &Handler{
ctx: context.Background(),
workloadManager: mockWorkloadManager,
registryProvider: mockRegistry,
configProvider: mockConfigProvider,
}

request := mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: "list_secrets",
Arguments: map[string]interface{}{},
},
}

result, err := handler.ListSecrets(context.Background(), request)

if tt.wantErr {
assert.Error(t, err)
} else {
assert.NoError(t, err)
if tt.checkResult != nil {
tt.checkResult(t, result)
}
}
})
}
}
39 changes: 35 additions & 4 deletions pkg/mcp/server/run_server.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,22 @@ import (
transporttypes "github.com/stacklok/toolhive/pkg/transport/types"
)

// SecretMapping represents a secret name and its target environment variable.
// Note: Description is not included because it's only relevant for listing/discovery
// (see SecretInfo). When mapping secrets to a running server, only the name and target
// environment variable are needed.
type SecretMapping struct {
Name string `json:"name"`
Target string `json:"target"`
}

// runServerArgs holds the arguments for running a server
type runServerArgs struct {
Server string `json:"server"`
Name string `json:"name,omitempty"`
Host string `json:"host,omitempty"`
Env map[string]string `json:"env,omitempty"`
Server string `json:"server"`
Name string `json:"name,omitempty"`
Host string `json:"host,omitempty"`
Env map[string]string `json:"env,omitempty"`
Secrets []SecretMapping `json:"secrets,omitempty"`
}

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

// Prepare secrets
secrets := prepareSecrets(args.Secrets)
if len(secrets) > 0 {
opts = append(opts, runner.WithSecrets(secrets))
}

// Build the configuration
envVarValidator := &runner.DetachedEnvVarValidator{}
return runner.NewRunConfigBuilder(ctx, imageMetadata, envVars, envVarValidator, opts...)
Expand Down Expand Up @@ -159,6 +175,21 @@ func prepareEnvironmentVariables(imageMetadata *registry.ImageMetadata, userEnv
return envVarsMap
}

// prepareSecrets converts SecretMapping array to the string format expected by the runner
func prepareSecrets(secretMappings []SecretMapping) []string {
if len(secretMappings) == 0 {
return nil
}

secrets := make([]string, len(secretMappings))
for i, mapping := range secretMappings {
// Convert to the format expected by runner: "secret_name,target=ENV_VAR_NAME"
secrets[i] = fmt.Sprintf("%s,target=%s", mapping.Name, mapping.Target)
}

return secrets
}

// saveAndRunServer saves the configuration and runs the server
func (h *Handler) saveAndRunServer(ctx context.Context, runConfig *runner.RunConfig, name string) error {
// Save the run configuration state before starting
Expand Down
Loading