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
40 changes: 40 additions & 0 deletions cmd/thv/app/run_flags.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import (
"github.com/stacklok/toolhive/pkg/telemetry"
"github.com/stacklok/toolhive/pkg/transport"
"github.com/stacklok/toolhive/pkg/transport/types"
"github.com/stacklok/toolhive/pkg/webhook"
)

const (
Expand Down Expand Up @@ -136,6 +137,10 @@ type RunFlags struct {
// Runtime configuration
RuntimeImage string
RuntimeAddPackages []string

// WebhookConfigs is a list of paths to webhook configuration files.
// Each file may define validating and/or mutating webhooks.
WebhookConfigs []string
}

// AddRunFlags adds all the run flags to a command
Expand Down Expand Up @@ -278,6 +283,10 @@ func AddRunFlags(cmd *cobra.Command, config *RunFlags) {
cmd.Flags().StringVar(&config.EnvFile, "env-file", "", "Load environment variables from a single file")
cmd.Flags().StringVar(&config.EnvFileDir, "env-file-dir", "", "Load environment variables from all files in a directory")

// Webhook configuration flags
cmd.Flags().StringArrayVar(&config.WebhookConfigs, "webhook-config", nil,
"Path to webhook configuration file (can be specified multiple times to merge configs)")

// Ignore functionality flags
cmd.Flags().BoolVar(&config.IgnoreGlobally, "ignore-globally", true,
"Load global ignore patterns from ~/.config/toolhive/thvignore")
Expand Down Expand Up @@ -530,6 +539,25 @@ func loadToolsOverrideConfig(toolsOverridePath string) (map[string]runner.ToolOv
return *loadedToolsOverride, nil
}

// loadAndMergeWebhookConfigs loads, merges, and validates webhook configuration files.
// Each file may define validating and/or mutating webhooks. Later files override earlier
// ones for webhooks with the same name.
func loadAndMergeWebhookConfigs(paths []string) (*webhook.FileConfig, error) {
configs := make([]*webhook.FileConfig, 0, len(paths))
for _, path := range paths {
config, err := webhook.LoadConfig(path)
if err != nil {
return nil, err
}
configs = append(configs, config)
}
merged := webhook.MergeConfigs(configs...)
if err := webhook.ValidateConfig(merged); err != nil {
return nil, fmt.Errorf("invalid webhook configuration: %w", err)
}
return merged, nil
}

// configureRemoteHeaderOptions configures header forwarding options for remote servers
func configureRemoteHeaderOptions(runFlags *RunFlags) ([]runner.RunConfigBuilderOption, error) {
var opts []runner.RunConfigBuilderOption
Expand Down Expand Up @@ -671,6 +699,18 @@ func buildRunnerConfig(
}
opts = append(opts, runtimeOpts...)

// Load and merge webhook configurations
if len(runFlags.WebhookConfigs) > 0 {
whCfg, err := loadAndMergeWebhookConfigs(runFlags.WebhookConfigs)
if err != nil {
return nil, err
}
opts = append(opts,
runner.WithValidatingWebhooks(whCfg.Validating),
runner.WithMutatingWebhooks(whCfg.Mutating),
)
}

// Configure middleware and additional options
additionalOpts, err := configureMiddlewareAndOptions(runFlags, serverMetadata, toolsOverride, oidcConfig,
telemetryConfig, serverName, transportType, appConfig)
Expand Down
69 changes: 69 additions & 0 deletions cmd/thv/app/run_flags_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"path/filepath"
"strings"
"testing"
"time"

"github.com/spf13/cobra"
"github.com/stretchr/testify/assert"
Expand All @@ -17,6 +18,7 @@ import (
"github.com/stacklok/toolhive-core/logging"
regtypes "github.com/stacklok/toolhive-core/registry/types"
"github.com/stacklok/toolhive/pkg/config"
"github.com/stacklok/toolhive/pkg/webhook"
)

func boolPtr(b bool) *bool { return &b }
Expand Down Expand Up @@ -740,3 +742,70 @@ func TestResolveServerName(t *testing.T) {
})
}
}

func TestLoadAndMergeWebhookConfigs(t *testing.T) {
t.Parallel()

t.Run("merges files and applies default timeout", func(t *testing.T) {
t.Parallel()
dir := t.TempDir()

first := filepath.Join(dir, "first.yaml")
second := filepath.Join(dir, "second.json")

require.NoError(t, os.WriteFile(first, []byte(`
validating:
- name: policy
url: http://localhost/validate
failure_policy: ignore
tls_config:
insecure_skip_verify: true
mutating:
- name: mutate-a
url: http://localhost/mutate-a
timeout: 3s
failure_policy: ignore
tls_config:
insecure_skip_verify: true
`), 0600))
require.NoError(t, os.WriteFile(second, []byte(`{
"validating": [
{"name":"policy","url":"http://localhost/validate-v2","timeout":"5s","failure_policy":"ignore","tls_config":{"insecure_skip_verify":true}}
],
"mutating": [
{"name":"mutate-b","url":"http://localhost/mutate-b","failure_policy":"ignore","tls_config":{"insecure_skip_verify":true}}
]
}`), 0600))

cfg, err := loadAndMergeWebhookConfigs([]string{first, second})
require.NoError(t, err)

require.Len(t, cfg.Validating, 1)
assert.Equal(t, "http://localhost/validate-v2", cfg.Validating[0].URL)
assert.Equal(t, 5*time.Second, cfg.Validating[0].Timeout)

require.Len(t, cfg.Mutating, 2)
assert.Equal(t, "mutate-a", cfg.Mutating[0].Name)
assert.Equal(t, 3*time.Second, cfg.Mutating[0].Timeout)
assert.Equal(t, "mutate-b", cfg.Mutating[1].Name)
assert.Equal(t, webhook.DefaultTimeout, cfg.Mutating[1].Timeout)
})

t.Run("rejects invalid merged config", func(t *testing.T) {
t.Parallel()
dir := t.TempDir()
path := filepath.Join(dir, "invalid.yaml")

require.NoError(t, os.WriteFile(path, []byte(`
validating:
- name: bad
url: https://example.com/validate
timeout: 500ms
failure_policy: fail
`), 0600))

_, err := loadAndMergeWebhookConfigs([]string{path})
require.Error(t, err)
assert.Contains(t, err.Error(), "invalid webhook configuration")
})
}
1 change: 1 addition & 0 deletions docs/cli/thv_run.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

23 changes: 23 additions & 0 deletions docs/examples/webhooks.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
{
"validating": [
{
"name": "policy-check",
"url": "https://policy.example.com/validate",
"failure_policy": "fail",
"timeout": "5s",
"tls_config": {
"ca_bundle_path": "/etc/toolhive/pki/webhook-ca.crt"
}
}
],
"mutating": [
{
"name": "request-enricher",
"url": "https://enrichment.example.com/mutate",
"failure_policy": "ignore",
"tls_config": {
"insecure_skip_verify": true
}
}
]
}
15 changes: 15 additions & 0 deletions docs/examples/webhooks.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
validating:
- name: policy-check
url: https://policy.example.com/validate
failure_policy: fail
timeout: 5s
tls_config:
ca_bundle_path: /etc/toolhive/pki/webhook-ca.crt

mutating:
- name: request-enricher
url: https://enrichment.example.com/mutate
failure_policy: ignore
# Omitting timeout uses the default of 10s.
tls_config:
insecure_skip_verify: true
33 changes: 33 additions & 0 deletions docs/middleware.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,39 @@ The middleware chain consists of the following components:
10. **Header Forward Middleware**: Injects custom headers into requests to remote MCP servers (optional)
11. **Recovery Middleware**: Catches panics and returns HTTP 500 errors (always present)

## Dynamic webhook middleware

ToolHive supports dynamic webhook middleware for request mutation and validation. Webhooks are configured externally and loaded at runtime with `thv run --webhook-config <file>`.

Two webhook types are supported:

1. **Mutating webhooks**: Transform the parsed MCP request before later policy evaluation.
2. **Validating webhooks**: Approve or deny the request after mutation has completed.

When configured together, the effective order is:

1. Authentication
2. Token exchange and related auth middleware, when configured
3. MCP parsing
4. Mutating webhooks
5. Validating webhooks
6. Telemetry, authorization, and audit middleware

Multiple webhook definitions of the same type run in configuration order. When multiple `--webhook-config` files are provided, later files override earlier webhook definitions with the same `name`.

Configuration files may be written in YAML or JSON. Duration values such as `timeout` accept strings like `5s`, and omitted timeouts default to `10s`.

Example:

```bash
thv run postgres-mcp --webhook-config docs/examples/webhooks.yaml
```

Example config files:

- [`docs/examples/webhooks.yaml`](examples/webhooks.yaml)
- [`docs/examples/webhooks.json`](examples/webhooks.json)

## Architecture Diagram

```mermaid
Expand Down
37 changes: 37 additions & 0 deletions pkg/runner/config_builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import (
"github.com/stacklok/toolhive/pkg/transport"
"github.com/stacklok/toolhive/pkg/transport/types"
"github.com/stacklok/toolhive/pkg/usagemetrics"
"github.com/stacklok/toolhive/pkg/webhook"
)

// BuildContext defines the context in which the RunConfigBuilder is being used
Expand Down Expand Up @@ -265,6 +266,24 @@ func WithAuthzConfig(config *authz.Config) RunConfigBuilderOption {
}
}

// WithValidatingWebhooks sets the validating webhook configurations.
// These webhooks run after mutating webhooks and can accept or deny requests.
func WithValidatingWebhooks(webhooks []webhook.Config) RunConfigBuilderOption {
return func(b *runConfigBuilder) error {
b.config.ValidatingWebhooks = webhooks
return nil
}
}

// WithMutatingWebhooks sets the mutating webhook configurations.
// These webhooks run before validating webhooks and can transform requests.
func WithMutatingWebhooks(webhooks []webhook.Config) RunConfigBuilderOption {
return func(b *runConfigBuilder) error {
b.config.MutatingWebhooks = webhooks
return nil
}
}

// WithAuditConfigPath sets the audit config path
func WithAuditConfigPath(path string) RunConfigBuilderOption {
return func(b *runConfigBuilder) error {
Expand Down Expand Up @@ -591,6 +610,24 @@ func WithMiddlewareFromFlags(

// NOTE: AWS STS middleware is NOT added here because it is only configured
// through the operator path via PopulateMiddlewareConfigs(), not via CLI flags.
//
// NOTE: addCoreMiddlewares also injects usage metrics before webhook insertion here,
// which differs slightly from PopulateMiddlewareConfigs where usage metrics is added
// after webhooks. This is currently benign because usage metrics does not depend on
// webhook state, and the broader ordering TODO remains to unify these paths.

// Add Mutating webhooks before Validating webhooks
var err error
middlewareConfigs, err = addMutatingWebhookMiddleware(middlewareConfigs, b.config)
if err != nil {
return err
}

// Add Validating webhooks
middlewareConfigs, err = addValidatingWebhookMiddleware(middlewareConfigs, b.config)
if err != nil {
return err
}
Comment thread
JAORMX marked this conversation as resolved.

// Add optional middlewares
middlewareConfigs = addTelemetryMiddleware(middlewareConfigs, telemetryConfig, serverName, transportType)
Expand Down
36 changes: 36 additions & 0 deletions pkg/runner/config_builder_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"os"
"path/filepath"
"testing"
"time"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
Expand All @@ -23,6 +24,7 @@ import (
"github.com/stacklok/toolhive/pkg/mcp"
"github.com/stacklok/toolhive/pkg/networking"
"github.com/stacklok/toolhive/pkg/transport/types"
"github.com/stacklok/toolhive/pkg/webhook"
)

func TestRunConfigBuilder_Build_WithPermissionProfile(t *testing.T) {
Expand Down Expand Up @@ -547,6 +549,40 @@ func TestRunConfigBuilder_WithToolOverride(t *testing.T) {
}
}

func TestRunConfigBuilder_WithWebhookConfigs(t *testing.T) {
t.Parallel()

validating := []webhook.Config{
{
Name: "validate-a",
URL: "http://localhost/validate-a",
Timeout: webhook.DefaultTimeout,
FailurePolicy: webhook.FailurePolicyIgnore,
TLSConfig: &webhook.TLSConfig{InsecureSkipVerify: true},
},
}
mutating := []webhook.Config{
{
Name: "mutate-a",
URL: "http://localhost/mutate-a",
Timeout: 3 * time.Second,
FailurePolicy: webhook.FailurePolicyIgnore,
TLSConfig: &webhook.TLSConfig{InsecureSkipVerify: true},
},
}

builder := &runConfigBuilder{
config: &RunConfig{},
}

require.NoError(t, WithValidatingWebhooks(validating)(builder))
require.NoError(t, WithMutatingWebhooks(mutating)(builder))
require.Len(t, builder.config.ValidatingWebhooks, 1)
require.Len(t, builder.config.MutatingWebhooks, 1)
assert.Equal(t, validating, builder.config.ValidatingWebhooks)
assert.Equal(t, mutating, builder.config.MutatingWebhooks)
}

func TestRunConfigBuilder_ToolOverrideMutualExclusivity(t *testing.T) {
t.Parallel()

Expand Down
4 changes: 2 additions & 2 deletions pkg/runner/middleware_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -886,8 +886,8 @@ func TestPopulateMiddlewareConfigs_FullCoverage(t *testing.T) {
config.Transport = types.TransportTypeStdio

// Setup options to hit all branches
config.MutatingWebhooks = []webhook.Config{{Name: "m-hook", URL: "http://example.com/m"}}
config.ValidatingWebhooks = []webhook.Config{{Name: "v-hook", URL: "http://example.com/v"}}
config.MutatingWebhooks = []webhook.Config{{Name: "m-hook", URL: "http://example.com/m", Timeout: webhook.DefaultTimeout}}
config.ValidatingWebhooks = []webhook.Config{{Name: "v-hook", URL: "http://example.com/v", Timeout: webhook.DefaultTimeout}}

config.ToolsFilter = []string{"tool1"}
config.ToolsOverride = map[string]ToolOverride{"tool1": {Name: "newtool1"}}
Expand Down
4 changes: 2 additions & 2 deletions pkg/webhook/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,11 +41,11 @@ func TestNewClient(t *testing.T) {
expectError: false,
},
{
name: "valid config with zero timeout",
name: "valid config with minimum timeout",
config: Config{
Name: "test",
URL: "https://example.com/webhook",
Timeout: 0,
Timeout: MinTimeout,
FailurePolicy: FailurePolicyIgnore,
},
expectError: false,
Expand Down
Loading
Loading