diff --git a/Taskfile.yml b/Taskfile.yml index d223fd34f..f989ded91 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -86,7 +86,7 @@ tasks: platforms: [linux, darwin] # we have to use ldflags to avoid the LC_DYSYMTAB linker error. # https://github.com/stacklok/toolhive/issues/1687 - - go test -ldflags=-extldflags=-Wl,-w -json -race -coverprofile=coverage/coverage.out $(go list ./... | grep -v '/test/e2e' | grep -v '/cmd/thv-operator/test-integration') | gotestfmt -hide "all" + - go test -ldflags=-extldflags=-Wl,-w -json -race -coverpkg=./... -coverprofile=coverage/coverage.out $(go list ./... | grep -v '/test/e2e' | grep -v '/cmd/thv-operator/test-integration') | gotestfmt -hide "all" - go tool cover -func=coverage/coverage.out - echo "Generating HTML coverage report in coverage/coverage.html" - go tool cover -html=coverage/coverage.out -o coverage/coverage.html @@ -101,7 +101,7 @@ tasks: cmds: - cmd: cmd.exe /c mkdir coverage ignore_error: true # Windows has no mkdir -p, so just ignore error if it exists - - go test -race -coverprofile=coverage/coverage.out {{.DIR_LIST | catLines}} + - go test -race -coverpkg=./... -coverprofile=coverage/coverage.out {{.DIR_LIST | catLines}} - go tool cover -func=coverage/coverage.out - echo "Generating HTML coverage report in coverage/coverage.html" - go tool cover -html=coverage/coverage.out -o coverage/coverage.html diff --git a/cmd/thv-operator/controllers/mcpremoteproxy_runconfig_test.go b/cmd/thv-operator/controllers/mcpremoteproxy_runconfig_test.go index b1de05c40..f45982c23 100644 --- a/cmd/thv-operator/controllers/mcpremoteproxy_runconfig_test.go +++ b/cmd/thv-operator/controllers/mcpremoteproxy_runconfig_test.go @@ -30,6 +30,7 @@ import ( mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" "github.com/stacklok/toolhive/pkg/authz" + "github.com/stacklok/toolhive/pkg/authz/authorizers/cedar" "github.com/stacklok/toolhive/pkg/runner" transporttypes "github.com/stacklok/toolhive/pkg/transport/types" ) @@ -158,10 +159,12 @@ func TestCreateRunConfigFromMCPRemoteProxy(t *testing.T) { t.Helper() assert.Equal(t, "authz-proxy", config.Name) assert.NotNil(t, config.AuthzConfig) - assert.Equal(t, authz.ConfigTypeCedarV1, config.AuthzConfig.Type) - assert.NotNil(t, config.AuthzConfig.Cedar) - assert.Len(t, config.AuthzConfig.Cedar.Policies, 2) - assert.Contains(t, config.AuthzConfig.Cedar.Policies[0], "tools/list") + assert.Equal(t, authz.ConfigType(cedar.ConfigType), config.AuthzConfig.Type) + + cedarCfg, err := cedar.ExtractConfig(config.AuthzConfig) + require.NoError(t, err) + assert.Len(t, cedarCfg.Options.Policies, 2) + assert.Contains(t, cedarCfg.Options.Policies[0], "tools/list") }, }, { diff --git a/cmd/thv-operator/controllers/mcpserver_runconfig_test.go b/cmd/thv-operator/controllers/mcpserver_runconfig_test.go index 801fc2afc..eef021323 100644 --- a/cmd/thv-operator/controllers/mcpserver_runconfig_test.go +++ b/cmd/thv-operator/controllers/mcpserver_runconfig_test.go @@ -19,6 +19,7 @@ import ( ctrlutil "github.com/stacklok/toolhive/cmd/thv-operator/pkg/controllerutil" "github.com/stacklok/toolhive/cmd/thv-operator/pkg/runconfig/configmap/checksum" "github.com/stacklok/toolhive/pkg/authz" + "github.com/stacklok/toolhive/pkg/authz/authorizers/cedar" "github.com/stacklok/toolhive/pkg/container/kubernetes" "github.com/stacklok/toolhive/pkg/runner" transporttypes "github.com/stacklok/toolhive/pkg/transport/types" @@ -322,14 +323,15 @@ func TestCreateRunConfigFromMCPServer(t *testing.T) { // Verify authorization config is set assert.NotNil(t, config.AuthzConfig) assert.Equal(t, "v1", config.AuthzConfig.Version) - assert.Equal(t, authz.ConfigTypeCedarV1, config.AuthzConfig.Type) - assert.NotNil(t, config.AuthzConfig.Cedar) + assert.Equal(t, authz.ConfigType(cedar.ConfigType), config.AuthzConfig.Type) // Check Cedar-specific configuration - assert.Len(t, config.AuthzConfig.Cedar.Policies, 2) - assert.Contains(t, config.AuthzConfig.Cedar.Policies, `permit(principal, action == Action::"call_tool", resource == Tool::"weather");`) - assert.Contains(t, config.AuthzConfig.Cedar.Policies, `permit(principal, action == Action::"get_prompt", resource == Prompt::"greeting");`) - assert.Equal(t, `[{"uid": {"type": "User", "id": "user1"}, "attrs": {}}]`, config.AuthzConfig.Cedar.EntitiesJSON) + cedarCfg, err := cedar.ExtractConfig(config.AuthzConfig) + require.NoError(t, err) + assert.Len(t, cedarCfg.Options.Policies, 2) + assert.Contains(t, cedarCfg.Options.Policies, `permit(principal, action == Action::"call_tool", resource == Tool::"weather");`) + assert.Contains(t, cedarCfg.Options.Policies, `permit(principal, action == Action::"get_prompt", resource == Prompt::"greeting");`) + assert.Equal(t, `[{"uid": {"type": "User", "id": "user1"}, "attrs": {}}]`, cedarCfg.Options.EntitiesJSON) }, }, { @@ -359,11 +361,13 @@ func TestCreateRunConfigFromMCPServer(t *testing.T) { // For ConfigMap type, with new feature, authorization config is embedded in RunConfig require.NotNil(t, config.AuthzConfig) assert.Equal(t, "v1", config.AuthzConfig.Version) - assert.Equal(t, authz.ConfigTypeCedarV1, config.AuthzConfig.Type) - require.NotNil(t, config.AuthzConfig.Cedar) - assert.Len(t, config.AuthzConfig.Cedar.Policies, 1) - assert.Contains(t, config.AuthzConfig.Cedar.Policies[0], "call_tool") - assert.Equal(t, "[]", config.AuthzConfig.Cedar.EntitiesJSON) + assert.Equal(t, authz.ConfigType(cedar.ConfigType), config.AuthzConfig.Type) + + cedarCfg, err := cedar.ExtractConfig(config.AuthzConfig) + require.NoError(t, err) + assert.Len(t, cedarCfg.Options.Policies, 1) + assert.Contains(t, cedarCfg.Options.Policies[0], "call_tool") + assert.Equal(t, "[]", cedarCfg.Options.EntitiesJSON) }, }, { @@ -748,14 +752,15 @@ func TestEnsureRunConfigConfigMap(t *testing.T) { // Verify authorization configuration is properly serialized assert.NotNil(t, runConfig.AuthzConfig, "AuthzConfig should be present in runconfig.json") assert.Equal(t, "v1", runConfig.AuthzConfig.Version) - assert.Equal(t, authz.ConfigTypeCedarV1, runConfig.AuthzConfig.Type) - assert.NotNil(t, runConfig.AuthzConfig.Cedar) + assert.Equal(t, authz.ConfigType(cedar.ConfigType), runConfig.AuthzConfig.Type) // Check Cedar-specific configuration - assert.Len(t, runConfig.AuthzConfig.Cedar.Policies, 2) - assert.Contains(t, runConfig.AuthzConfig.Cedar.Policies, `permit(principal, action == Action::"call_tool", resource == Tool::"weather");`) - assert.Contains(t, runConfig.AuthzConfig.Cedar.Policies, `permit(principal, action == Action::"get_prompt", resource == Prompt::"greeting");`) - assert.Equal(t, `[{"uid": {"type": "User", "id": "user1"}, "attrs": {}}]`, runConfig.AuthzConfig.Cedar.EntitiesJSON) + cedarCfg, err := cedar.ExtractConfig(runConfig.AuthzConfig) + require.NoError(t, err) + assert.Len(t, cedarCfg.Options.Policies, 2) + assert.Contains(t, cedarCfg.Options.Policies, `permit(principal, action == Action::"call_tool", resource == Tool::"weather");`) + assert.Contains(t, cedarCfg.Options.Policies, `permit(principal, action == Action::"get_prompt", resource == Prompt::"greeting");`) + assert.Equal(t, `[{"uid": {"type": "User", "id": "user1"}, "attrs": {}}]`, cedarCfg.Options.EntitiesJSON) }, }, { @@ -968,12 +973,14 @@ func TestEnsureRunConfigConfigMap(t *testing.T) { require.NotNil(t, runConfig.AuthzConfig) assert.Equal(t, "v1", runConfig.AuthzConfig.Version) - assert.Equal(t, authz.ConfigTypeCedarV1, runConfig.AuthzConfig.Type) - require.NotNil(t, runConfig.AuthzConfig.Cedar) - assert.Len(t, runConfig.AuthzConfig.Cedar.Policies, 2) - assert.Contains(t, runConfig.AuthzConfig.Cedar.Policies, `permit(principal, action == Action::"call_tool", resource == Tool::"weather");`) - assert.Contains(t, runConfig.AuthzConfig.Cedar.Policies, `permit(principal, action == Action::"get_prompt", resource == Prompt::"greeting");`) - assert.Equal(t, `[{"uid": {"type": "User", "id": "user1"}, "attrs": {}}]`, runConfig.AuthzConfig.Cedar.EntitiesJSON) + assert.Equal(t, authz.ConfigType(cedar.ConfigType), runConfig.AuthzConfig.Type) + + cedarCfg, err := cedar.ExtractConfig(runConfig.AuthzConfig) + require.NoError(t, err) + assert.Len(t, cedarCfg.Options.Policies, 2) + assert.Contains(t, cedarCfg.Options.Policies, `permit(principal, action == Action::"call_tool", resource == Tool::"weather");`) + assert.Contains(t, cedarCfg.Options.Policies, `permit(principal, action == Action::"get_prompt", resource == Prompt::"greeting");`) + assert.Equal(t, `[{"uid": {"type": "User", "id": "user1"}, "attrs": {}}]`, cedarCfg.Options.EntitiesJSON) }) } diff --git a/cmd/thv-operator/pkg/controllerutil/authz.go b/cmd/thv-operator/pkg/controllerutil/authz.go index a9fd796f7..e85539262 100644 --- a/cmd/thv-operator/pkg/controllerutil/authz.go +++ b/cmd/thv-operator/pkg/controllerutil/authz.go @@ -17,6 +17,7 @@ import ( mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" "github.com/stacklok/toolhive/cmd/thv-operator/pkg/kubernetes/configmaps" "github.com/stacklok/toolhive/pkg/authz" + "github.com/stacklok/toolhive/pkg/authz/authorizers/cedar" "github.com/stacklok/toolhive/pkg/runner" ) @@ -162,6 +163,36 @@ func EnsureAuthzConfigMap( return nil } +func addAuthzInlineConfigOptions( + authzRef *mcpv1alpha1.AuthzConfigRef, + options *[]runner.RunConfigBuilderOption, +) error { + if authzRef.Inline == nil { + return fmt.Errorf("inline authz config type specified but inline config is nil") + } + + policies := authzRef.Inline.Policies + entitiesJSON := authzRef.Inline.EntitiesJSON + + // Create authorization config using the full config structure + // This maintains backwards compatibility with the v1.0 schema + authzCfg, err := authz.NewConfig(cedar.Config{ + Version: "v1", + Type: cedar.ConfigType, + Options: &cedar.ConfigOptions{ + Policies: policies, + EntitiesJSON: entitiesJSON, + }, + }) + if err != nil { + return fmt.Errorf("failed to create authz config: %w", err) + } + + // Add authorization config to options + *options = append(*options, runner.WithAuthzConfig(authzCfg)) + return nil +} + // AddAuthzConfigOptions adds authorization configuration options to builder options func AddAuthzConfigOptions( ctx context.Context, @@ -176,26 +207,7 @@ func AddAuthzConfigOptions( switch authzRef.Type { case mcpv1alpha1.AuthzConfigTypeInline: - if authzRef.Inline == nil { - return fmt.Errorf("inline authz config type specified but inline config is nil") - } - - policies := authzRef.Inline.Policies - entitiesJSON := authzRef.Inline.EntitiesJSON - - // Create authorization config - authzCfg := &authz.Config{ - Version: "v1", - Type: authz.ConfigTypeCedarV1, - Cedar: &authz.CedarConfig{ - Policies: policies, - EntitiesJSON: entitiesJSON, - }, - } - - // Add authorization config to options - *options = append(*options, runner.WithAuthzConfig(authzCfg)) - return nil + return addAuthzInlineConfigOptions(authzRef, options) case mcpv1alpha1.AuthzConfigTypeConfigMap: // Validate reference diff --git a/cmd/thv-operator/pkg/controllerutil/authz_test.go b/cmd/thv-operator/pkg/controllerutil/authz_test.go new file mode 100644 index 000000000..3b13715fd --- /dev/null +++ b/cmd/thv-operator/pkg/controllerutil/authz_test.go @@ -0,0 +1,684 @@ +package controllerutil + +import ( + "context" + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + "github.com/stacklok/toolhive/pkg/runner" +) + +func TestGenerateAuthzVolumeConfig(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + authzConfig *mcpv1alpha1.AuthzConfigRef + resourceName string + expectVolumeMount bool + expectVolume bool + expectedVolumeName string + expectedMountPath string + }{ + { + name: "Nil authz config", + authzConfig: nil, + resourceName: "test-resource", + expectVolumeMount: false, + expectVolume: false, + }, + { + name: "ConfigMap type with nil ConfigMap ref", + authzConfig: &mcpv1alpha1.AuthzConfigRef{ + Type: mcpv1alpha1.AuthzConfigTypeConfigMap, + ConfigMap: nil, + }, + resourceName: "test-resource", + expectVolumeMount: false, + expectVolume: false, + }, + { + name: "ConfigMap type with default key", + authzConfig: &mcpv1alpha1.AuthzConfigRef{ + Type: mcpv1alpha1.AuthzConfigTypeConfigMap, + ConfigMap: &mcpv1alpha1.ConfigMapAuthzRef{ + Name: "my-authz-config", + }, + }, + resourceName: "test-resource", + expectVolumeMount: true, + expectVolume: true, + expectedVolumeName: "authz-config", + expectedMountPath: "/etc/toolhive/authz", + }, + { + name: "ConfigMap type with custom key", + authzConfig: &mcpv1alpha1.AuthzConfigRef{ + Type: mcpv1alpha1.AuthzConfigTypeConfigMap, + ConfigMap: &mcpv1alpha1.ConfigMapAuthzRef{ + Name: "my-authz-config", + Key: "custom-authz.json", + }, + }, + resourceName: "test-resource", + expectVolumeMount: true, + expectVolume: true, + expectedVolumeName: "authz-config", + expectedMountPath: "/etc/toolhive/authz", + }, + { + name: "Inline type with nil inline config", + authzConfig: &mcpv1alpha1.AuthzConfigRef{ + Type: mcpv1alpha1.AuthzConfigTypeInline, + Inline: nil, + }, + resourceName: "test-resource", + expectVolumeMount: false, + expectVolume: false, + }, + { + name: "Inline type with valid config", + authzConfig: &mcpv1alpha1.AuthzConfigRef{ + Type: mcpv1alpha1.AuthzConfigTypeInline, + Inline: &mcpv1alpha1.InlineAuthzConfig{ + Policies: []string{`permit(principal, action, resource);`}, + }, + }, + resourceName: "test-resource", + expectVolumeMount: true, + expectVolume: true, + expectedVolumeName: "authz-config", + expectedMountPath: "/etc/toolhive/authz", + }, + { + name: "Unknown type returns nil", + authzConfig: &mcpv1alpha1.AuthzConfigRef{ + Type: "unknown", + }, + resourceName: "test-resource", + expectVolumeMount: false, + expectVolume: false, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + volumeMount, volume := GenerateAuthzVolumeConfig(tc.authzConfig, tc.resourceName) + + if tc.expectVolumeMount { + require.NotNil(t, volumeMount) + assert.Equal(t, tc.expectedVolumeName, volumeMount.Name) + assert.Equal(t, tc.expectedMountPath, volumeMount.MountPath) + assert.True(t, volumeMount.ReadOnly) + } else { + assert.Nil(t, volumeMount) + } + + if tc.expectVolume { + require.NotNil(t, volume) + assert.Equal(t, tc.expectedVolumeName, volume.Name) + } else { + assert.Nil(t, volume) + } + }) + } +} + +func TestGenerateAuthzVolumeConfigInlineConfigMapName(t *testing.T) { + t.Parallel() + + // Test that inline config generates the correct ConfigMap name + authzConfig := &mcpv1alpha1.AuthzConfigRef{ + Type: mcpv1alpha1.AuthzConfigTypeInline, + Inline: &mcpv1alpha1.InlineAuthzConfig{ + Policies: []string{`permit(principal, action, resource);`}, + }, + } + + _, volume := GenerateAuthzVolumeConfig(authzConfig, "my-server") + require.NotNil(t, volume) + require.NotNil(t, volume.ConfigMap) + assert.Equal(t, "my-server-authz-inline", volume.ConfigMap.Name) +} + +func TestEnsureAuthzConfigMap(t *testing.T) { + t.Parallel() + + scheme := runtime.NewScheme() + require.NoError(t, corev1.AddToScheme(scheme)) + require.NoError(t, mcpv1alpha1.AddToScheme(scheme)) + + t.Run("Nil authz config returns nil", func(t *testing.T) { + t.Parallel() + + client := fake.NewClientBuilder().WithScheme(scheme).Build() + err := EnsureAuthzConfigMap( + context.Background(), + client, + scheme, + &mcpv1alpha1.MCPServer{}, + "default", + "test-resource", + nil, + nil, + ) + assert.NoError(t, err) + }) + + t.Run("ConfigMap type returns nil", func(t *testing.T) { + t.Parallel() + + client := fake.NewClientBuilder().WithScheme(scheme).Build() + authzConfig := &mcpv1alpha1.AuthzConfigRef{ + Type: mcpv1alpha1.AuthzConfigTypeConfigMap, + ConfigMap: &mcpv1alpha1.ConfigMapAuthzRef{ + Name: "my-config", + }, + } + + err := EnsureAuthzConfigMap( + context.Background(), + client, + scheme, + &mcpv1alpha1.MCPServer{}, + "default", + "test-resource", + authzConfig, + nil, + ) + assert.NoError(t, err) + }) + + t.Run("Inline type without inline config returns nil", func(t *testing.T) { + t.Parallel() + + client := fake.NewClientBuilder().WithScheme(scheme).Build() + authzConfig := &mcpv1alpha1.AuthzConfigRef{ + Type: mcpv1alpha1.AuthzConfigTypeInline, + Inline: nil, + } + + err := EnsureAuthzConfigMap( + context.Background(), + client, + scheme, + &mcpv1alpha1.MCPServer{}, + "default", + "test-resource", + authzConfig, + nil, + ) + assert.NoError(t, err) + }) + + t.Run("Inline type creates ConfigMap", func(t *testing.T) { + t.Parallel() + + client := fake.NewClientBuilder().WithScheme(scheme).Build() + owner := &mcpv1alpha1.MCPServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-server", + Namespace: "default", + UID: "test-uid", + }, + } + authzConfig := &mcpv1alpha1.AuthzConfigRef{ + Type: mcpv1alpha1.AuthzConfigTypeInline, + Inline: &mcpv1alpha1.InlineAuthzConfig{ + Policies: []string{`permit(principal, action, resource);`}, + EntitiesJSON: `[]`, + }, + } + labels := map[string]string{ + "app": "test", + } + + err := EnsureAuthzConfigMap( + context.Background(), + client, + scheme, + owner, + "default", + "test-resource", + authzConfig, + labels, + ) + require.NoError(t, err) + + // Verify the ConfigMap was created + var cm corev1.ConfigMap + err = client.Get(context.Background(), getKey("default", "test-resource-authz-inline"), &cm) + require.NoError(t, err) + assert.Equal(t, "test", cm.Labels["app"]) + assert.Contains(t, cm.Data, DefaultAuthzKey) + + // Verify the ConfigMap data contains the correct structure + var data map[string]interface{} + err = json.Unmarshal([]byte(cm.Data[DefaultAuthzKey]), &data) + require.NoError(t, err) + assert.Equal(t, "1.0", data["version"]) + assert.Equal(t, "cedarv1", data["type"]) + }) + + t.Run("Inline type with empty EntitiesJSON defaults to empty array", func(t *testing.T) { + t.Parallel() + + client := fake.NewClientBuilder().WithScheme(scheme).Build() + owner := &mcpv1alpha1.MCPServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-server", + Namespace: "default", + UID: "test-uid-2", + }, + } + authzConfig := &mcpv1alpha1.AuthzConfigRef{ + Type: mcpv1alpha1.AuthzConfigTypeInline, + Inline: &mcpv1alpha1.InlineAuthzConfig{ + Policies: []string{`permit(principal, action, resource);`}, + // EntitiesJSON is empty + }, + } + + err := EnsureAuthzConfigMap( + context.Background(), + client, + scheme, + owner, + "default", + "test-resource-2", + authzConfig, + nil, + ) + require.NoError(t, err) + + // Verify the ConfigMap was created + var cm corev1.ConfigMap + err = client.Get(context.Background(), getKey("default", "test-resource-2-authz-inline"), &cm) + require.NoError(t, err) + + // Verify EntitiesJSON defaults to "[]" + var data map[string]interface{} + err = json.Unmarshal([]byte(cm.Data[DefaultAuthzKey]), &data) + require.NoError(t, err) + cedar, ok := data["cedar"].(map[string]interface{}) + require.True(t, ok) + assert.Equal(t, "[]", cedar["entities_json"]) + }) +} + +func TestAddAuthzConfigOptions(t *testing.T) { + t.Parallel() + + scheme := runtime.NewScheme() + require.NoError(t, corev1.AddToScheme(scheme)) + require.NoError(t, mcpv1alpha1.AddToScheme(scheme)) + + t.Run("Nil authz ref returns nil", func(t *testing.T) { + t.Parallel() + + var options []runner.RunConfigBuilderOption + err := AddAuthzConfigOptions( + context.Background(), + nil, + "default", + nil, + &options, + ) + assert.NoError(t, err) + assert.Empty(t, options) + }) + + t.Run("Inline type adds config", func(t *testing.T) { + t.Parallel() + + authzRef := &mcpv1alpha1.AuthzConfigRef{ + Type: mcpv1alpha1.AuthzConfigTypeInline, + Inline: &mcpv1alpha1.InlineAuthzConfig{ + Policies: []string{`permit(principal, action, resource);`}, + EntitiesJSON: `[]`, + }, + } + + var options []runner.RunConfigBuilderOption + err := AddAuthzConfigOptions( + context.Background(), + nil, + "default", + authzRef, + &options, + ) + require.NoError(t, err) + assert.Len(t, options, 1) + }) + + t.Run("Inline type with nil inline config returns error", func(t *testing.T) { + t.Parallel() + + authzRef := &mcpv1alpha1.AuthzConfigRef{ + Type: mcpv1alpha1.AuthzConfigTypeInline, + Inline: nil, + } + + var options []runner.RunConfigBuilderOption + err := AddAuthzConfigOptions( + context.Background(), + nil, + "default", + authzRef, + &options, + ) + assert.Error(t, err) + assert.Contains(t, err.Error(), "inline authz config type specified but inline config is nil") + }) + + t.Run("ConfigMap type with nil ConfigMap ref returns error", func(t *testing.T) { + t.Parallel() + + client := fake.NewClientBuilder().WithScheme(scheme).Build() + authzRef := &mcpv1alpha1.AuthzConfigRef{ + Type: mcpv1alpha1.AuthzConfigTypeConfigMap, + ConfigMap: nil, + } + + var options []runner.RunConfigBuilderOption + err := AddAuthzConfigOptions( + context.Background(), + client, + "default", + authzRef, + &options, + ) + assert.Error(t, err) + assert.Contains(t, err.Error(), "reference is missing name") + }) + + t.Run("ConfigMap type with empty name returns error", func(t *testing.T) { + t.Parallel() + + client := fake.NewClientBuilder().WithScheme(scheme).Build() + authzRef := &mcpv1alpha1.AuthzConfigRef{ + Type: mcpv1alpha1.AuthzConfigTypeConfigMap, + ConfigMap: &mcpv1alpha1.ConfigMapAuthzRef{ + Name: "", + }, + } + + var options []runner.RunConfigBuilderOption + err := AddAuthzConfigOptions( + context.Background(), + client, + "default", + authzRef, + &options, + ) + assert.Error(t, err) + assert.Contains(t, err.Error(), "reference is missing name") + }) + + t.Run("ConfigMap type with nil client returns error", func(t *testing.T) { + t.Parallel() + + authzRef := &mcpv1alpha1.AuthzConfigRef{ + Type: mcpv1alpha1.AuthzConfigTypeConfigMap, + ConfigMap: &mcpv1alpha1.ConfigMapAuthzRef{ + Name: "my-config", + }, + } + + var options []runner.RunConfigBuilderOption + err := AddAuthzConfigOptions( + context.Background(), + nil, + "default", + authzRef, + &options, + ) + assert.Error(t, err) + assert.Contains(t, err.Error(), "kubernetes client is not configured") + }) + + t.Run("ConfigMap type with non-existent ConfigMap returns error", func(t *testing.T) { + t.Parallel() + + client := fake.NewClientBuilder().WithScheme(scheme).Build() + authzRef := &mcpv1alpha1.AuthzConfigRef{ + Type: mcpv1alpha1.AuthzConfigTypeConfigMap, + ConfigMap: &mcpv1alpha1.ConfigMapAuthzRef{ + Name: "non-existent", + }, + } + + var options []runner.RunConfigBuilderOption + err := AddAuthzConfigOptions( + context.Background(), + client, + "default", + authzRef, + &options, + ) + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to get Authz ConfigMap") + }) + + t.Run("ConfigMap type with missing key returns error", func(t *testing.T) { + t.Parallel() + + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "authz-config", + Namespace: "default", + }, + Data: map[string]string{ + "other-key": "some data", + }, + } + client := fake.NewClientBuilder().WithScheme(scheme).WithObjects(cm).Build() + + authzRef := &mcpv1alpha1.AuthzConfigRef{ + Type: mcpv1alpha1.AuthzConfigTypeConfigMap, + ConfigMap: &mcpv1alpha1.ConfigMapAuthzRef{ + Name: "authz-config", + }, + } + + var options []runner.RunConfigBuilderOption + err := AddAuthzConfigOptions( + context.Background(), + client, + "default", + authzRef, + &options, + ) + assert.Error(t, err) + assert.Contains(t, err.Error(), "is missing key") + }) + + t.Run("ConfigMap type with empty value returns error", func(t *testing.T) { + t.Parallel() + + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "authz-config-empty", + Namespace: "default", + }, + Data: map[string]string{ + DefaultAuthzKey: " ", + }, + } + client := fake.NewClientBuilder().WithScheme(scheme).WithObjects(cm).Build() + + authzRef := &mcpv1alpha1.AuthzConfigRef{ + Type: mcpv1alpha1.AuthzConfigTypeConfigMap, + ConfigMap: &mcpv1alpha1.ConfigMapAuthzRef{ + Name: "authz-config-empty", + }, + } + + var options []runner.RunConfigBuilderOption + err := AddAuthzConfigOptions( + context.Background(), + client, + "default", + authzRef, + &options, + ) + assert.Error(t, err) + assert.Contains(t, err.Error(), "is empty") + }) + + t.Run("ConfigMap type with invalid config returns error", func(t *testing.T) { + t.Parallel() + + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "authz-config-invalid", + Namespace: "default", + }, + Data: map[string]string{ + DefaultAuthzKey: "not valid json or yaml", + }, + } + client := fake.NewClientBuilder().WithScheme(scheme).WithObjects(cm).Build() + + authzRef := &mcpv1alpha1.AuthzConfigRef{ + Type: mcpv1alpha1.AuthzConfigTypeConfigMap, + ConfigMap: &mcpv1alpha1.ConfigMapAuthzRef{ + Name: "authz-config-invalid", + }, + } + + var options []runner.RunConfigBuilderOption + err := AddAuthzConfigOptions( + context.Background(), + client, + "default", + authzRef, + &options, + ) + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to parse authz config") + }) + + t.Run("ConfigMap type with valid config adds option", func(t *testing.T) { + t.Parallel() + + validConfig := `{ + "version": "1.0", + "type": "cedarv1", + "cedar": { + "policies": ["permit(principal, action, resource);"], + "entities_json": "[]" + } + }` + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "authz-config-valid", + Namespace: "default", + }, + Data: map[string]string{ + DefaultAuthzKey: validConfig, + }, + } + client := fake.NewClientBuilder().WithScheme(scheme).WithObjects(cm).Build() + + authzRef := &mcpv1alpha1.AuthzConfigRef{ + Type: mcpv1alpha1.AuthzConfigTypeConfigMap, + ConfigMap: &mcpv1alpha1.ConfigMapAuthzRef{ + Name: "authz-config-valid", + }, + } + + var options []runner.RunConfigBuilderOption + err := AddAuthzConfigOptions( + context.Background(), + client, + "default", + authzRef, + &options, + ) + require.NoError(t, err) + assert.Len(t, options, 1) + }) + + t.Run("ConfigMap type with custom key", func(t *testing.T) { + t.Parallel() + + validConfig := `{ + "version": "1.0", + "type": "cedarv1", + "cedar": { + "policies": ["permit(principal, action, resource);"], + "entities_json": "[]" + } + }` + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "authz-config-custom-key", + Namespace: "default", + }, + Data: map[string]string{ + "custom.json": validConfig, + }, + } + client := fake.NewClientBuilder().WithScheme(scheme).WithObjects(cm).Build() + + authzRef := &mcpv1alpha1.AuthzConfigRef{ + Type: mcpv1alpha1.AuthzConfigTypeConfigMap, + ConfigMap: &mcpv1alpha1.ConfigMapAuthzRef{ + Name: "authz-config-custom-key", + Key: "custom.json", + }, + } + + var options []runner.RunConfigBuilderOption + err := AddAuthzConfigOptions( + context.Background(), + client, + "default", + authzRef, + &options, + ) + require.NoError(t, err) + assert.Len(t, options, 1) + }) + + t.Run("Unknown type returns error", func(t *testing.T) { + t.Parallel() + + authzRef := &mcpv1alpha1.AuthzConfigRef{ + Type: "unknown", + } + + var options []runner.RunConfigBuilderOption + err := AddAuthzConfigOptions( + context.Background(), + nil, + "default", + authzRef, + &options, + ) + assert.Error(t, err) + assert.Contains(t, err.Error(), "unknown authz config type") + }) +} + +// Helper function to create a NamespacedName key +func getKey(namespace, name string) struct { + Namespace string + Name string +} { + return struct { + Namespace string + Name string + }{Namespace: namespace, Name: name} +} diff --git a/cmd/thv-operator/test-integration/mcp-server/mcpserver_runconfig_integration_test.go b/cmd/thv-operator/test-integration/mcp-server/mcpserver_runconfig_integration_test.go index cf6cfb3fc..39e254cf5 100644 --- a/cmd/thv-operator/test-integration/mcp-server/mcpserver_runconfig_integration_test.go +++ b/cmd/thv-operator/test-integration/mcp-server/mcpserver_runconfig_integration_test.go @@ -16,6 +16,7 @@ import ( mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" "github.com/stacklok/toolhive/cmd/thv-operator/pkg/runconfig/configmap/checksum" "github.com/stacklok/toolhive/pkg/authz" + "github.com/stacklok/toolhive/pkg/authz/authorizers/cedar" "github.com/stacklok/toolhive/pkg/runner" transporttypes "github.com/stacklok/toolhive/pkg/transport/types" ) @@ -481,12 +482,14 @@ var _ = Describe("RunConfig ConfigMap Integration Tests", func() { // Verify authorization configuration Expect(runConfig.AuthzConfig).NotTo(BeNil()) Expect(runConfig.AuthzConfig.Version).To(Equal("v1")) - Expect(runConfig.AuthzConfig.Type).To(Equal(authz.ConfigTypeCedarV1)) - Expect(runConfig.AuthzConfig.Cedar).NotTo(BeNil()) - Expect(runConfig.AuthzConfig.Cedar.Policies).To(HaveLen(2)) - Expect(runConfig.AuthzConfig.Cedar.Policies[0]).To(ContainSubstring("call_tool")) - Expect(runConfig.AuthzConfig.Cedar.Policies[1]).To(ContainSubstring("get_prompt")) - Expect(runConfig.AuthzConfig.Cedar.EntitiesJSON).To(ContainSubstring("user1")) + Expect(runConfig.AuthzConfig.Type).To(Equal(authz.ConfigType(cedar.ConfigType))) + + cedarCfg, err := cedar.ExtractConfig(runConfig.AuthzConfig) + Expect(err).NotTo(HaveOccurred()) + Expect(cedarCfg.Options.Policies).To(HaveLen(2)) + Expect(cedarCfg.Options.Policies[0]).To(ContainSubstring("call_tool")) + Expect(cedarCfg.Options.Policies[1]).To(ContainSubstring("get_prompt")) + Expect(cedarCfg.Options.EntitiesJSON).To(ContainSubstring("user1")) }) It("Should handle deterministic ConfigMap generation", func() { @@ -795,26 +798,27 @@ var _ = Describe("RunConfig ConfigMap Integration Tests", func() { // Verify authorization configuration was embedded from external ConfigMap Expect(runConfig.AuthzConfig).NotTo(BeNil()) Expect(runConfig.AuthzConfig.Version).To(Equal("v1")) - Expect(runConfig.AuthzConfig.Type).To(Equal(authz.ConfigTypeCedarV1)) + Expect(runConfig.AuthzConfig.Type).To(Equal(authz.ConfigType(cedar.ConfigType))) // Verify Cedar configuration - Expect(runConfig.AuthzConfig.Cedar).NotTo(BeNil()) + cedarCfg, err := cedar.ExtractConfig(runConfig.AuthzConfig) + Expect(err).NotTo(HaveOccurred()) // Check policies are present - Expect(runConfig.AuthzConfig.Cedar.Policies).To(HaveLen(3)) - Expect(runConfig.AuthzConfig.Cedar.Policies[0]).To(ContainSubstring("call_tool")) - Expect(runConfig.AuthzConfig.Cedar.Policies[0]).To(ContainSubstring("weather")) - Expect(runConfig.AuthzConfig.Cedar.Policies[1]).To(ContainSubstring("get_prompt")) - Expect(runConfig.AuthzConfig.Cedar.Policies[1]).To(ContainSubstring("greeting")) - Expect(runConfig.AuthzConfig.Cedar.Policies[2]).To(ContainSubstring("forbid")) - Expect(runConfig.AuthzConfig.Cedar.Policies[2]).To(ContainSubstring("sensitive_data")) + Expect(cedarCfg.Options.Policies).To(HaveLen(3)) + Expect(cedarCfg.Options.Policies[0]).To(ContainSubstring("call_tool")) + Expect(cedarCfg.Options.Policies[0]).To(ContainSubstring("weather")) + Expect(cedarCfg.Options.Policies[1]).To(ContainSubstring("get_prompt")) + Expect(cedarCfg.Options.Policies[1]).To(ContainSubstring("greeting")) + Expect(cedarCfg.Options.Policies[2]).To(ContainSubstring("forbid")) + Expect(cedarCfg.Options.Policies[2]).To(ContainSubstring("sensitive_data")) // Verify entities are embedded - Expect(runConfig.AuthzConfig.Cedar.EntitiesJSON).NotTo(BeEmpty()) + Expect(cedarCfg.Options.EntitiesJSON).NotTo(BeEmpty()) // Parse entities to verify they're correctly embedded var entities []interface{} - err = json.Unmarshal([]byte(runConfig.AuthzConfig.Cedar.EntitiesJSON), &entities) + err = json.Unmarshal([]byte(cedarCfg.Options.EntitiesJSON), &entities) Expect(err).NotTo(HaveOccurred()) Expect(entities).To(HaveLen(2)) diff --git a/docs/authz.md b/docs/authz.md index 2f28656c8..2f8acda30 100644 --- a/docs/authz.md +++ b/docs/authz.md @@ -1,24 +1,41 @@ # Authorization framework This document describes the authorization framework for MCP servers managed by -ToolHive. The framework uses Cedar policies to authorize MCP operations based on -the client's identity and the requested operation. +ToolHive. The framework uses a pluggable architecture that allows different +authorization backends to be used based on configuration. ## Overview -ToolHive supports adding authorization to MCP servers it manages. This is -implemented using Cedar, a policy language developed by Amazon. The -authorization framework consists of the following components: +ToolHive supports adding authorization to MCP servers it manages through a +pluggable authorizer system. The framework is designed to be extensible, +allowing different authorization engines to be implemented and registered. -1. **Cedar authorizer**: A component that evaluates Cedar policies to determine - if a request is authorized. -2. **Authorization middleware**: An HTTP middleware that extracts information - from MCP requests and uses the Cedar Authorizer to authorize the request. -3. **Configuration**: A configuration file (JSON or YAML) that specifies the Cedar - policies and entities. +### Architecture -The framework integrates with the existing JWT authentication middleware to -provide a complete authentication and authorization solution. +The authorization framework consists of the following components: + +1. **Authorizer interface**: A common interface (`pkg/authz/authorizers/core.go`) + that all authorization backends must implement. +2. **AuthorizerFactory interface**: A factory interface for creating and + validating authorizer instances from configuration. +3. **Registry**: A global registry (`pkg/authz/authorizers/registry.go`) where + authorizer factories register themselves. +4. **Authorization middleware**: HTTP middleware that extracts information from + MCP requests and delegates authorization decisions to the configured + authorizer. +5. **Configuration**: A configuration file (JSON or YAML) that specifies which + authorizer to use and its settings. + +### Available authorizers + +Currently, ToolHive provides the following authorizer implementation: + +| Type | Description | +|------|-------------| +| `cedarv1` | Authorization using [Cedar](https://www.cedarpolicy.com/), a policy language developed by Amazon | + +The framework is designed to support additional authorizers in the future (e.g., +OPA, Casbin, or custom implementations). ## How it works @@ -29,8 +46,7 @@ occurs: request context. 2. The authorization middleware extracts information from the MCP request, including the feature, operation, and resource ID. -3. The Cedar authorizer evaluates the Cedar policies to determine if the request - is authorized. +3. The configured authorizer evaluates the request against its policies. 4. If the request is authorized, it is passed to the next handler. Otherwise, a 403 Forbidden response is returned. @@ -39,15 +55,47 @@ occurs: To set up authorization for an MCP server managed by ToolHive, follow these steps: -1. Create a Cedar authorization configuration file. +1. Create an authorization configuration file specifying the authorizer type. 2. Start the MCP server with the `--authz-config` flag pointing to your configuration file. -### Create an authorization configuration file +### Configuration file structure + +All authorization configuration files share a common structure: + +```yaml +version: "1.0" +type: "" +# Authorizer-specific configuration follows... +``` + +The common fields are: + +- `version`: The version of the configuration format (currently `"1.0"`). +- `type`: The type of authorizer to use (e.g., `cedarv1`). This determines which + registered authorizer factory handles the configuration. + +### Start an MCP server with authorization + +To start an MCP server with authorization, use the `--authz-config` flag: + +```bash +thv run --transport sse --name my-mcp-server --proxy-port 8080 --authz-config /path/to/authz-config.yaml my-mcp-server-image:latest -- my-mcp-server-args +``` + +--- + +## Cedar authorizer (`cedarv1`) + +Cedar is the default authorization backend provided by ToolHive. It uses the +Cedar policy language developed by Amazon to express fine-grained authorization +rules. + +### Cedar configuration Create a configuration file (JSON or YAML) with the following structure: -#### JSON Format +#### JSON format ```json { @@ -64,7 +112,7 @@ Create a configuration file (JSON or YAML) with the following structure: } ``` -#### YAML Format +#### YAML format ```yaml version: "1.0" @@ -77,35 +125,18 @@ cedar: entities_json: "[]" ``` -The configuration file has the following fields: +The Cedar-specific configuration fields are: -- `version`: The version of the configuration format. -- `type`: The type of authorization configuration. Currently, only `cedarv1` is - supported. - `cedar`: The Cedar-specific configuration. - `policies`: An array of Cedar policy strings. - `entities_json`: A JSON string representing Cedar entities. -### Start an MCP server with authorization - -To start an MCP server with authorization, use the `--authz-config` flag: - -```bash -thv run --transport sse --name my-mcp-server --proxy-port 8080 --authz-config /path/to/authz-config.json my-mcp-server-image:latest -- my-mcp-server-args -``` - -Or with a YAML configuration: - -```bash -thv run --transport sse --name my-mcp-server --proxy-port 8080 --authz-config /path/to/authz-config.yaml my-mcp-server-image:latest -- my-mcp-server-args -``` - -## Writing Cedar policies +### Writing Cedar policies Cedar is a powerful policy language that allows you to express complex authorization rules. Here's a guide to writing Cedar policies for MCP servers. -### Policy structure +#### Policy structure A Cedar policy has the following structure: @@ -120,7 +151,7 @@ permit|forbid(principal, action, resource) when { conditions }; - `conditions`: Optional conditions that must be satisfied for the policy to apply. -### MCP entities +#### MCP entities In the context of MCP servers, the following entities are used: @@ -151,11 +182,11 @@ In the context of MCP servers, the following entities are used: - `Resource::"data"`: The data resource - `FeatureType::"tool"`: The tool feature type (used for list operations) -### Example policies +#### Example policies Here are some example policies for common scenarios: -#### Allow a specific tool +##### Allow a specific tool ```plain permit(principal, action == Action::"call_tool", resource == Tool::"weather"); @@ -163,7 +194,7 @@ permit(principal, action == Action::"call_tool", resource == Tool::"weather"); This policy allows any client to call the weather tool. -#### Allow a specific prompt +##### Allow a specific prompt ```plain permit(principal, action == Action::"get_prompt", resource == Prompt::"greeting"); @@ -171,7 +202,7 @@ permit(principal, action == Action::"get_prompt", resource == Prompt::"greeting" This policy allows any client to get the greeting prompt. -#### Allow a specific resource +##### Allow a specific resource ```plain permit(principal, action == Action::"read_resource", resource == Resource::"data"); @@ -179,7 +210,7 @@ permit(principal, action == Action::"read_resource", resource == Resource::"data This policy allows any client to read the data resource. -#### List operations +##### List operations List operations (`tools/list`, `prompts/list`, `resources/list`) do not require explicit policies. They are always allowed but the response is automatically filtered based on the user's permissions @@ -196,7 +227,7 @@ permit(principal, action == Action::"call_tool", resource == Tool::"weather"); Then `tools/list` will only show the "weather" tool for that user. -#### Allow a specific client to call any tool +##### Allow a specific client to call any tool ```plain permit(principal == Client::"user123", action == Action::"call_tool", resource); @@ -204,7 +235,7 @@ permit(principal == Client::"user123", action == Action::"call_tool", resource); This policy allows the client with ID `user123` to call any tool. -#### Allow clients with a specific role to call any tool +##### Allow clients with a specific role to call any tool ```plain permit(principal, action == Action::"call_tool", resource) when { principal.claim_roles.contains("admin") }; @@ -213,7 +244,7 @@ permit(principal, action == Action::"call_tool", resource) when { principal.clai This policy allows any client with the `admin` role to call any tool. The `claim_roles` attribute is extracted from the JWT claims and added to the principal entity. -#### Allow clients to call tools based on arguments +##### Allow clients to call tools based on arguments ```plain permit(principal, action == Action::"call_tool", resource == Tool::"calculator") when { @@ -225,7 +256,7 @@ This policy allows any client to call the calculator tool, but only for the "add" and "subtract" operations. The `arg_operation` attribute is extracted from the tool arguments and added to the resource entity. -### Using JWT claims in policies +#### Using JWT claims in policies The authorization middleware automatically extracts JWT claims from the request context and adds them with a `claim_` prefix. For example, the `sub` claim becomes @@ -251,7 +282,7 @@ Both approaches work and can be used to make authorization decisions based on the client's identity. This policy allows only clients with the name "John Doe" to call the weather tool. -### Using tool arguments in policies +#### Using tool arguments in policies The authorization middleware also extracts tool arguments from the request and adds them with an `arg_` prefix. For example, the `location` argument becomes @@ -277,7 +308,7 @@ Both approaches work and can be used to make authorization decisions based on the specific parameters of the request. This policy allows any client to call the weather tool, but only for the locations "New York" and "London". -### Combining JWT claims and tool arguments +#### Combining JWT claims and tool arguments You can combine JWT claims and tool arguments in your policies to create more sophisticated authorization rules: @@ -290,9 +321,9 @@ permit(principal, action == Action::"call_tool", resource == Tool::"sensitive_da This policy allows clients with the "data_analyst" role to access the sensitive_data tool, but only if their clearance level (from JWT claims) is sufficient for the requested data level (from tool arguments). -## Advanced topics +### Advanced Cedar topics -### Entity attributes +#### Entity attributes Cedar entities can have attributes that can be used in policy conditions. The authorization middleware automatically adds JWT claims and tool arguments as @@ -325,7 +356,7 @@ This configuration defines a custom entity for the weather tool with an `owner` attribute set to `user123`. The policy allows clients to call tools only if they own them. -### Policy evaluation +#### Policy evaluation Cedar policies are evaluated in the following order: @@ -335,6 +366,85 @@ Cedar policies are evaluated in the following order: This means that `forbid` policies take precedence over `permit` policies. +--- + +## Implementing a custom authorizer + +The authorization framework is designed to be extensible. You can implement your +own authorizer by following these steps: + +### 1. Implement the Authorizer interface + +Create a type that implements the `Authorizer` interface defined in +`pkg/authz/authorizers/core.go`: + +```go +type Authorizer interface { + AuthorizeWithJWTClaims( + ctx context.Context, + feature MCPFeature, + operation MCPOperation, + resourceID string, + arguments map[string]interface{}, + ) (bool, error) +} +``` + +### 2. Implement the AuthorizerFactory interface + +Create a factory that implements the `AuthorizerFactory` interface defined in +`pkg/authz/authorizers/registry.go`: + +```go +type AuthorizerFactory interface { + // ValidateConfig validates the authorizer-specific configuration. + ValidateConfig(rawConfig json.RawMessage) error + + // CreateAuthorizer creates an Authorizer instance from the configuration. + CreateAuthorizer(rawConfig json.RawMessage) (Authorizer, error) +} +``` + +### 3. Register the factory + +Register your factory in an `init()` function so it's available when the package +is imported: + +```go +package myauthorizer + +import "github.com/stacklok/toolhive/pkg/authz/authorizers" + +const ConfigType = "myauthv1" + +func init() { + authorizers.Register(ConfigType, &Factory{}) +} + +type Factory struct{} + +func (*Factory) ValidateConfig(rawConfig json.RawMessage) error { + // Validate your configuration + return nil +} + +func (*Factory) CreateAuthorizer(rawConfig json.RawMessage) (authorizers.Authorizer, error) { + // Parse config and create your authorizer + return &MyAuthorizer{}, nil +} +``` + +### 4. Import the package + +Ensure your authorizer package is imported (typically via a blank import) so that +the `init()` function runs and registers the factory: + +```go +import _ "github.com/stacklok/toolhive/pkg/authz/authorizers/myauthorizer" +``` + +--- + ## Troubleshooting If you're having issues with authorization, here are some common problems and @@ -346,19 +456,28 @@ solutions: - Check that the principal, action, and resource in your policies match the actual values in the request. - Check that any conditions in your policies are satisfied by the request. -- Remember that Cedar uses a default deny policy, so if no policy explicitly - permits the request, it will be denied. +- Remember that most authorizers use a default deny policy, so if no policy + explicitly permits the request, it will be denied. ### JWT claims are not available in policies - Make sure that the JWT middleware is configured correctly and is running before the authorization middleware. - Check that the JWT token contains the expected claims. -- Remember that JWT claims are added to the Cedar context with a `claim_` - prefix. +- Remember that JWT claims are added with a `claim_` prefix (e.g., `claim_sub`, + `claim_roles`). ### Tool arguments are not available in policies - Check that the tool arguments are correctly specified in the request. -- Remember that tool arguments are added to the Cedar context with an `arg_` - prefix. +- Remember that tool arguments are added with an `arg_` prefix (e.g., + `arg_location`). + +### Unknown authorizer type + +- Ensure the authorizer package is imported (see "Implementing a custom + authorizer" above). +- Check that the `type` field in your configuration matches a registered + authorizer type exactly. +- Use `authorizers.RegisteredTypes()` to see which authorizer types are + available. diff --git a/docs/server/docs.go b/docs/server/docs.go index 502171046..0ebc3a66f 100644 --- a/docs/server/docs.go +++ b/docs/server/docs.go @@ -6,7 +6,7 @@ import "github.com/swaggo/swag/v2" const docTemplate = `{ "schemes": {{ marshal .Schemes }}, - "components": {"schemas":{"audit.Config":{"description":"AuditConfig contains the audit logging configuration","properties":{"component":{"description":"Component is the component name to use in audit events","type":"string"},"event_types":{"description":"EventTypes specifies which event types to audit. If empty, all events are audited.","items":{"type":"string"},"type":"array","uniqueItems":false},"exclude_event_types":{"description":"ExcludeEventTypes specifies which event types to exclude from auditing.\nThis takes precedence over EventTypes.","items":{"type":"string"},"type":"array","uniqueItems":false},"include_request_data":{"description":"IncludeRequestData determines whether to include request data in audit logs","type":"boolean"},"include_response_data":{"description":"IncludeResponseData determines whether to include response data in audit logs","type":"boolean"},"log_file":{"description":"LogFile specifies the file path for audit logs. If empty, logs to stdout.","type":"string"},"max_data_size":{"description":"MaxDataSize limits the size of request/response data included in audit logs (in bytes)","type":"integer"}},"type":"object"},"auth.TokenValidatorConfig":{"description":"OIDCConfig contains OIDC configuration","properties":{"allowPrivateIP":{"description":"AllowPrivateIP allows JWKS/OIDC endpoints on private IP addresses","type":"boolean"},"audience":{"description":"Audience is the expected audience for the token","type":"string"},"authTokenFile":{"description":"AuthTokenFile is the path to file containing bearer token for authentication","type":"string"},"cacertPath":{"description":"CACertPath is the path to the CA certificate bundle for HTTPS requests","type":"string"},"clientID":{"description":"ClientID is the OIDC client ID","type":"string"},"clientSecret":{"description":"ClientSecret is the optional OIDC client secret for introspection","type":"string"},"insecureAllowHTTP":{"description":"InsecureAllowHTTP allows HTTP (non-HTTPS) OIDC issuers for development/testing\nWARNING: This is insecure and should NEVER be used in production","type":"boolean"},"introspectionURL":{"description":"IntrospectionURL is the optional introspection endpoint for validating tokens","type":"string"},"issuer":{"description":"Issuer is the OIDC issuer URL (e.g., https://accounts.google.com)","type":"string"},"jwksurl":{"description":"JWKSURL is the URL to fetch the JWKS from","type":"string"},"resourceURL":{"description":"ResourceURL is the explicit resource URL for OAuth discovery (RFC 9728)","type":"string"},"scopes":{"description":"Scopes is the list of OAuth scopes to advertise in the well-known endpoint (RFC 9728)\nIf empty, defaults to [\"openid\"]","items":{"type":"string"},"type":"array"}},"type":"object"},"authz.CedarConfig":{"description":"Cedar is the Cedar-specific configuration.\nThis is only used when Type is ConfigTypeCedarV1.","properties":{"entities_json":{"description":"EntitiesJSON is the JSON string representing Cedar entities","type":"string"},"policies":{"description":"Policies is a list of Cedar policy strings","items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"authz.Config":{"description":"AuthzConfig contains the authorization configuration","properties":{"cedar":{"$ref":"#/components/schemas/authz.CedarConfig"},"type":{"$ref":"#/components/schemas/authz.ConfigType"},"version":{"description":"Version is the version of the configuration format.","type":"string"}},"type":"object"},"authz.ConfigType":{"description":"Type is the type of authorization configuration.","type":"string","x-enum-varnames":["ConfigTypeCedarV1"]},"client.MCPClient":{"type":"string","x-enum-varnames":["RooCode","Cline","Cursor","VSCodeInsider","VSCode","ClaudeCode","Windsurf","WindsurfJetBrains","AmpCli","AmpVSCode","AmpCursor","AmpVSCodeInsider","AmpWindsurf","LMStudio","Goose","Trae","Continue","OpenCode","Kiro","Antigravity","Zed"]},"client.MCPClientStatus":{"properties":{"client_type":{"description":"ClientType is the type of MCP client","type":"string","x-enum-varnames":["RooCode","Cline","Cursor","VSCodeInsider","VSCode","ClaudeCode","Windsurf","WindsurfJetBrains","AmpCli","AmpVSCode","AmpCursor","AmpVSCodeInsider","AmpWindsurf","LMStudio","Goose","Trae","Continue","OpenCode","Kiro","Antigravity","Zed"]},"installed":{"description":"Installed indicates whether the client is installed on the system","type":"boolean"},"registered":{"description":"Registered indicates whether the client is registered in the ToolHive configuration","type":"boolean"}},"type":"object"},"client.RegisteredClient":{"properties":{"groups":{"items":{"type":"string"},"type":"array","uniqueItems":false},"name":{"$ref":"#/components/schemas/client.MCPClient"}},"type":"object"},"core.Workload":{"properties":{"created_at":{"description":"CreatedAt is the timestamp when the workload was created.","type":"string"},"group":{"description":"Group is the name of the group this workload belongs to, if any.","type":"string"},"labels":{"additionalProperties":{"type":"string"},"description":"Labels are the container labels (excluding standard ToolHive labels)","type":"object"},"name":{"description":"Name is the name of the workload.\nIt is used as a unique identifier.","type":"string"},"package":{"description":"Package specifies the Workload Package used to create this Workload.","type":"string"},"port":{"description":"Port is the port on which the workload is exposed.\nThis is embedded in the URL.","type":"integer"},"proxy_mode":{"description":"ProxyMode is the proxy mode that clients should use to connect.\nFor stdio transports, this will be the proxy mode (sse or streamable-http).\nFor direct transports (sse/streamable-http), this will be the same as TransportType.","type":"string"},"remote":{"description":"Remote indicates whether this is a remote workload (true) or a container workload (false).","type":"boolean"},"status":{"$ref":"#/components/schemas/runtime.WorkloadStatus"},"status_context":{"description":"StatusContext provides additional context about the workload's status.\nThe exact meaning is determined by the status and the underlying runtime.","type":"string"},"tools":{"description":"ToolsFilter is the filter on tools applied to the workload.","items":{"type":"string"},"type":"array","uniqueItems":false},"transport_type":{"$ref":"#/components/schemas/types.TransportType"},"url":{"description":"URL is the URL of the workload exposed by the ToolHive proxy.","type":"string"}},"type":"object"},"groups.Group":{"properties":{"name":{"type":"string"},"registered_clients":{"items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"ignore.Config":{"description":"IgnoreConfig contains configuration for ignore processing","properties":{"loadGlobal":{"description":"Whether to load global ignore patterns","type":"boolean"},"printOverlays":{"description":"Whether to print resolved overlay paths for debugging","type":"boolean"}},"type":"object"},"permissions.InboundNetworkPermissions":{"description":"Inbound defines inbound network permissions","properties":{"allow_host":{"description":"AllowHost is a list of allowed hosts for inbound connections","items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"permissions.NetworkPermissions":{"description":"Network defines network permissions","properties":{"inbound":{"$ref":"#/components/schemas/permissions.InboundNetworkPermissions"},"mode":{"description":"Mode specifies the network mode for the container (e.g., \"host\", \"bridge\", \"none\")\nWhen empty, the default container runtime network mode is used","type":"string"},"outbound":{"$ref":"#/components/schemas/permissions.OutboundNetworkPermissions"}},"type":"object"},"permissions.OutboundNetworkPermissions":{"description":"Outbound defines outbound network permissions","properties":{"allow_host":{"description":"AllowHost is a list of allowed hosts","items":{"type":"string"},"type":"array","uniqueItems":false},"allow_port":{"description":"AllowPort is a list of allowed ports","items":{"type":"integer"},"type":"array","uniqueItems":false},"insecure_allow_all":{"description":"InsecureAllowAll allows all outbound network connections","type":"boolean"}},"type":"object"},"permissions.Profile":{"description":"PermissionProfile is the permission profile to use","properties":{"name":{"description":"Name is the name of the profile","type":"string"},"network":{"$ref":"#/components/schemas/permissions.NetworkPermissions"},"privileged":{"description":"Privileged indicates whether the container should run in privileged mode\nWhen true, the container has access to all host devices and capabilities\nUse with extreme caution as this removes most security isolation","type":"boolean"},"read":{"description":"Read is a list of mount declarations that the container can read from\nThese can be in the following formats:\n- A single path: The same path will be mounted from host to container\n- host-path:container-path: Different paths for host and container\n- resource-uri:container-path: Mount a resource identified by URI to a container path","items":{"type":"string"},"type":"array","uniqueItems":false},"write":{"description":"Write is a list of mount declarations that the container can write to\nThese follow the same format as Read mounts but with write permissions","items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"registry.EnvVar":{"properties":{"default":{"description":"Default is the value to use if the environment variable is not explicitly provided\nOnly used for non-required variables","type":"string"},"description":{"description":"Description is a human-readable explanation of the variable's purpose","type":"string"},"name":{"description":"Name is the environment variable name (e.g., API_KEY)","type":"string"},"required":{"description":"Required indicates whether this environment variable must be provided\nIf true and not provided via command line or secrets, the user will be prompted for a value","type":"boolean"},"secret":{"description":"Secret indicates whether this environment variable contains sensitive information\nIf true, the value will be stored as a secret rather than as a plain environment variable","type":"boolean"}},"type":"object"},"registry.Group":{"properties":{"description":{"description":"Description is a human-readable description of the group's purpose and functionality","type":"string"},"name":{"description":"Name is the identifier for the group, used when referencing the group in commands","type":"string"},"remote_servers":{"additionalProperties":{"$ref":"#/components/schemas/registry.RemoteServerMetadata"},"description":"RemoteServers is a map of server names to their corresponding remote server definitions within this group","type":"object"},"servers":{"additionalProperties":{"$ref":"#/components/schemas/registry.ImageMetadata"},"description":"Servers is a map of server names to their corresponding server definitions within this group","type":"object"}},"type":"object"},"registry.Header":{"properties":{"choices":{"description":"Choices provides a list of valid values for the header (optional)","items":{"type":"string"},"type":"array","uniqueItems":false},"default":{"description":"Default is the value to use if the header is not explicitly provided\nOnly used for non-required headers","type":"string"},"description":{"description":"Description is a human-readable explanation of the header's purpose","type":"string"},"name":{"description":"Name is the header name (e.g., X-API-Key, Authorization)","type":"string"},"required":{"description":"Required indicates whether this header must be provided\nIf true and not provided via command line or secrets, the user will be prompted for a value","type":"boolean"},"secret":{"description":"Secret indicates whether this header contains sensitive information\nIf true, the value will be stored as a secret rather than as plain text","type":"boolean"}},"type":"object"},"registry.ImageMetadata":{"description":"Container server details (if it's a container server)","properties":{"args":{"description":"Args are the default command-line arguments to pass to the MCP server container.\nThese arguments will be used only if no command-line arguments are provided by the user.\nIf the user provides arguments, they will override these defaults.","items":{"type":"string"},"type":"array","uniqueItems":false},"custom_metadata":{"additionalProperties":{},"description":"CustomMetadata allows for additional user-defined metadata","type":"object"},"description":{"description":"Description is a human-readable description of the server's purpose and functionality","type":"string"},"docker_tags":{"description":"DockerTags lists the available Docker tags for this server image","items":{"type":"string"},"type":"array","uniqueItems":false},"env_vars":{"description":"EnvVars defines environment variables that can be passed to the server","items":{"$ref":"#/components/schemas/registry.EnvVar"},"type":"array","uniqueItems":false},"image":{"description":"Image is the Docker image reference for the MCP server","type":"string"},"metadata":{"$ref":"#/components/schemas/registry.Metadata"},"name":{"description":"Name is the identifier for the MCP server, used when referencing the server in commands\nIf not provided, it will be auto-generated from the registry key","type":"string"},"permissions":{"$ref":"#/components/schemas/permissions.Profile"},"provenance":{"$ref":"#/components/schemas/registry.Provenance"},"repository_url":{"description":"RepositoryURL is the URL to the source code repository for the server","type":"string"},"status":{"description":"Status indicates whether the server is currently active or deprecated","type":"string"},"tags":{"description":"Tags are categorization labels for the server to aid in discovery and filtering","items":{"type":"string"},"type":"array","uniqueItems":false},"target_port":{"description":"TargetPort is the port for the container to expose (only applicable to SSE and Streamable HTTP transports)","type":"integer"},"tier":{"description":"Tier represents the tier classification level of the server, e.g., \"Official\" or \"Community\"","type":"string"},"tools":{"description":"Tools is a list of tool names provided by this MCP server","items":{"type":"string"},"type":"array","uniqueItems":false},"transport":{"description":"Transport defines the communication protocol for the server\nFor containers: stdio, sse, or streamable-http\nFor remote servers: sse or streamable-http (stdio not supported)","type":"string"}},"type":"object"},"registry.Metadata":{"description":"Metadata contains additional information about the server such as popularity metrics","properties":{"last_updated":{"description":"LastUpdated is the timestamp when the server was last updated, in RFC3339 format","type":"string"},"pulls":{"description":"Pulls indicates how many times the server image has been downloaded","type":"integer"},"stars":{"description":"Stars represents the popularity rating or number of stars for the server","type":"integer"}},"type":"object"},"registry.OAuthConfig":{"description":"OAuthConfig provides OAuth/OIDC configuration for authentication to the remote server\nUsed with the thv proxy command's --remote-auth flags","properties":{"authorize_url":{"description":"AuthorizeURL is the OAuth authorization endpoint URL\nUsed for non-OIDC OAuth flows when issuer is not provided","type":"string"},"callback_port":{"description":"CallbackPort is the specific port to use for the OAuth callback server\nIf not specified, a random available port will be used","type":"integer"},"client_id":{"description":"ClientID is the OAuth client ID for authentication","type":"string"},"issuer":{"description":"Issuer is the OAuth/OIDC issuer URL (e.g., https://accounts.google.com)\nUsed for OIDC discovery to find authorization and token endpoints","type":"string"},"oauth_params":{"additionalProperties":{"type":"string"},"description":"OAuthParams contains additional OAuth parameters to include in the authorization request\nThese are server-specific parameters like \"prompt\", \"response_mode\", etc.","type":"object"},"resource":{"description":"Resource is the OAuth 2.0 resource indicator (RFC 8707)","type":"string"},"scopes":{"description":"Scopes are the OAuth scopes to request\nIf not specified, defaults to [\"openid\", \"profile\", \"email\"] for OIDC","items":{"type":"string"},"type":"array","uniqueItems":false},"token_url":{"description":"TokenURL is the OAuth token endpoint URL\nUsed for non-OIDC OAuth flows when issuer is not provided","type":"string"},"use_pkce":{"description":"UsePKCE indicates whether to use PKCE for the OAuth flow\nDefaults to true for enhanced security","type":"boolean"}},"type":"object"},"registry.Provenance":{"description":"Provenance contains verification and signing metadata","properties":{"attestation":{"$ref":"#/components/schemas/registry.VerifiedAttestation"},"cert_issuer":{"type":"string"},"repository_ref":{"type":"string"},"repository_uri":{"type":"string"},"runner_environment":{"type":"string"},"signer_identity":{"type":"string"},"sigstore_url":{"type":"string"}},"type":"object"},"registry.Registry":{"description":"Full registry data","properties":{"groups":{"description":"Groups is a slice of group definitions containing related MCP servers","items":{"$ref":"#/components/schemas/registry.Group"},"type":"array","uniqueItems":false},"last_updated":{"description":"LastUpdated is the timestamp when the registry was last updated, in RFC3339 format","type":"string"},"remote_servers":{"additionalProperties":{"$ref":"#/components/schemas/registry.RemoteServerMetadata"},"description":"RemoteServers is a map of server names to their corresponding remote server definitions\nThese are MCP servers accessed via HTTP/HTTPS using the thv proxy command","type":"object"},"servers":{"additionalProperties":{"$ref":"#/components/schemas/registry.ImageMetadata"},"description":"Servers is a map of server names to their corresponding server definitions","type":"object"},"version":{"description":"Version is the schema version of the registry","type":"string"}},"type":"object"},"registry.RemoteServerMetadata":{"description":"Remote server details (if it's a remote server)","properties":{"custom_metadata":{"additionalProperties":{},"description":"CustomMetadata allows for additional user-defined metadata","type":"object"},"description":{"description":"Description is a human-readable description of the server's purpose and functionality","type":"string"},"env_vars":{"description":"EnvVars defines environment variables that can be passed to configure the client\nThese might be needed for client-side configuration when connecting to the remote server","items":{"$ref":"#/components/schemas/registry.EnvVar"},"type":"array","uniqueItems":false},"headers":{"description":"Headers defines HTTP headers that can be passed to the remote server for authentication\nThese are used with the thv proxy command's authentication features","items":{"$ref":"#/components/schemas/registry.Header"},"type":"array","uniqueItems":false},"metadata":{"$ref":"#/components/schemas/registry.Metadata"},"name":{"description":"Name is the identifier for the MCP server, used when referencing the server in commands\nIf not provided, it will be auto-generated from the registry key","type":"string"},"oauth_config":{"$ref":"#/components/schemas/registry.OAuthConfig"},"repository_url":{"description":"RepositoryURL is the URL to the source code repository for the server","type":"string"},"status":{"description":"Status indicates whether the server is currently active or deprecated","type":"string"},"tags":{"description":"Tags are categorization labels for the server to aid in discovery and filtering","items":{"type":"string"},"type":"array","uniqueItems":false},"tier":{"description":"Tier represents the tier classification level of the server, e.g., \"Official\" or \"Community\"","type":"string"},"tools":{"description":"Tools is a list of tool names provided by this MCP server","items":{"type":"string"},"type":"array","uniqueItems":false},"transport":{"description":"Transport defines the communication protocol for the server\nFor containers: stdio, sse, or streamable-http\nFor remote servers: sse or streamable-http (stdio not supported)","type":"string"},"url":{"description":"URL is the endpoint URL for the remote MCP server (e.g., https://api.example.com/mcp)","type":"string"}},"type":"object"},"registry.VerifiedAttestation":{"properties":{"predicate":{},"predicate_type":{"type":"string"}},"type":"object"},"remote.Config":{"description":"RemoteAuthConfig contains OAuth configuration for remote MCP servers","properties":{"authorize_url":{"type":"string"},"callback_port":{"type":"integer"},"client_id":{"type":"string"},"client_secret":{"type":"string"},"client_secret_file":{"type":"string"},"env_vars":{"description":"Environment variables for the client","items":{"$ref":"#/components/schemas/registry.EnvVar"},"type":"array","uniqueItems":false},"headers":{"description":"Headers for HTTP requests","items":{"$ref":"#/components/schemas/registry.Header"},"type":"array","uniqueItems":false},"issuer":{"description":"OAuth endpoint configuration (from registry)","type":"string"},"oauth_params":{"additionalProperties":{"type":"string"},"description":"OAuth parameters for server-specific customization","type":"object"},"resource":{"description":"Resource is the OAuth 2.0 resource indicator (RFC 8707).","type":"string"},"scopes":{"items":{"type":"string"},"type":"array","uniqueItems":false},"skip_browser":{"type":"boolean"},"timeout":{"example":"5m","type":"string"},"token_url":{"type":"string"},"use_pkce":{"type":"boolean"}},"type":"object"},"runner.RunConfig":{"properties":{"audit_config":{"$ref":"#/components/schemas/audit.Config"},"audit_config_path":{"description":"AuditConfigPath is the path to the audit configuration file","type":"string"},"authz_config":{"$ref":"#/components/schemas/authz.Config"},"authz_config_path":{"description":"AuthzConfigPath is the path to the authorization configuration file","type":"string"},"base_name":{"description":"BaseName is the base name used for the container (without prefixes)","type":"string"},"cmd_args":{"description":"CmdArgs are the arguments to pass to the container","items":{"type":"string"},"type":"array","uniqueItems":false},"container_labels":{"additionalProperties":{"type":"string"},"description":"ContainerLabels are the labels to apply to the container","type":"object"},"container_name":{"description":"ContainerName is the name of the container","type":"string"},"debug":{"description":"Debug indicates whether debug mode is enabled","type":"boolean"},"env_file_dir":{"description":"EnvFileDir is the directory path to load environment files from","type":"string"},"env_vars":{"additionalProperties":{"type":"string"},"description":"EnvVars are the parsed environment variables as key-value pairs","type":"object"},"group":{"description":"Group is the name of the group this workload belongs to, if any","type":"string"},"host":{"description":"Host is the host for the HTTP proxy","type":"string"},"ignore_config":{"$ref":"#/components/schemas/ignore.Config"},"image":{"description":"Image is the Docker image to run","type":"string"},"isolate_network":{"description":"IsolateNetwork indicates whether to isolate the network for the container","type":"boolean"},"jwks_auth_token_file":{"description":"JWKSAuthTokenFile is the path to file containing auth token for JWKS/OIDC requests","type":"string"},"k8s_pod_template_patch":{"description":"K8sPodTemplatePatch is a JSON string to patch the Kubernetes pod template\nOnly applicable when using Kubernetes runtime","type":"string"},"middleware_configs":{"description":"MiddlewareConfigs contains the list of middleware to apply to the transport\nand the configuration for each middleware.","items":{"$ref":"#/components/schemas/types.MiddlewareConfig"},"type":"array","uniqueItems":false},"name":{"description":"Name is the name of the MCP server","type":"string"},"oidc_config":{"$ref":"#/components/schemas/auth.TokenValidatorConfig"},"permission_profile":{"$ref":"#/components/schemas/permissions.Profile"},"permission_profile_name_or_path":{"description":"PermissionProfileNameOrPath is the name or path of the permission profile","type":"string"},"port":{"description":"Port is the port for the HTTP proxy to listen on (host port)","type":"integer"},"proxy_mode":{"$ref":"#/components/schemas/types.ProxyMode"},"remote_auth_config":{"$ref":"#/components/schemas/remote.Config"},"remote_url":{"description":"RemoteURL is the URL of the remote MCP server (if running remotely)","type":"string"},"schema_version":{"description":"SchemaVersion is the version of the RunConfig schema","type":"string"},"secrets":{"description":"Secrets are the secret parameters to pass to the container\nFormat: \"\u003csecret name\u003e,target=\u003ctarget environment variable\u003e\"","items":{"type":"string"},"type":"array","uniqueItems":false},"target_host":{"description":"TargetHost is the host to forward traffic to (only applicable to SSE transport)","type":"string"},"target_port":{"description":"TargetPort is the port for the container to expose (only applicable to SSE transport)","type":"integer"},"telemetry_config":{"$ref":"#/components/schemas/telemetry.Config"},"thv_ca_bundle":{"description":"ThvCABundle is the path to the CA certificate bundle for ToolHive HTTP operations","type":"string"},"token_exchange_config":{"$ref":"#/components/schemas/tokenexchange.Config"},"tools_filter":{"description":"ToolsFilter is the list of tools to filter","items":{"type":"string"},"type":"array","uniqueItems":false},"tools_override":{"additionalProperties":{"$ref":"#/components/schemas/runner.ToolOverride"},"description":"ToolsOverride is a map from an actual tool to its overridden name and/or description","type":"object"},"transport":{"description":"Transport is the transport mode (stdio, sse, or streamable-http)","type":"string","x-enum-varnames":["TransportTypeStdio","TransportTypeSSE","TransportTypeStreamableHTTP","TransportTypeInspector"]},"trust_proxy_headers":{"description":"TrustProxyHeaders indicates whether to trust X-Forwarded-* headers from reverse proxies","type":"boolean"},"volumes":{"description":"Volumes are the directory mounts to pass to the container\nFormat: \"host-path:container-path[:ro]\"","items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"runner.ToolOverride":{"properties":{"description":{"description":"Description is the redefined description of the tool","type":"string"},"name":{"description":"Name is the redefined name of the tool","type":"string"}},"type":"object"},"runtime.WorkloadStatus":{"description":"Status is the current status of the workload.","type":"string","x-enum-varnames":["WorkloadStatusRunning","WorkloadStatusStopped","WorkloadStatusError","WorkloadStatusStarting","WorkloadStatusStopping","WorkloadStatusUnhealthy","WorkloadStatusRemoving","WorkloadStatusUnknown","WorkloadStatusUnauthenticated"]},"secrets.SecretParameter":{"properties":{"name":{"type":"string"},"target":{"type":"string"}},"type":"object"},"telemetry.Config":{"description":"TelemetryConfig contains the OpenTelemetry configuration","properties":{"customAttributes":{"additionalProperties":{"type":"string"},"description":"CustomAttributes contains custom resource attributes to be added to all telemetry signals.\nThese are parsed from CLI flags (--otel-custom-attributes) or environment variables\n(OTEL_RESOURCE_ATTRIBUTES) as key=value pairs.\nWe use map[string]string for proper JSON serialization instead of []attribute.KeyValue\nwhich doesn't marshal/unmarshal correctly.","type":"object"},"enablePrometheusMetricsPath":{"description":"EnablePrometheusMetricsPath controls whether to expose Prometheus-style /metrics endpoint\nThe metrics are served on the main transport port at /metrics\nThis is separate from OTLP metrics which are sent to the Endpoint","type":"boolean"},"endpoint":{"description":"Endpoint is the OTLP endpoint URL","type":"string"},"environmentVariables":{"description":"EnvironmentVariables is a list of environment variable names that should be\nincluded in telemetry spans as attributes. Only variables in this list will\nbe read from the host machine and included in spans for observability.\nExample: []string{\"NODE_ENV\", \"DEPLOYMENT_ENV\", \"SERVICE_VERSION\"}","items":{"type":"string"},"type":"array","uniqueItems":false},"headers":{"additionalProperties":{"type":"string"},"description":"Headers contains authentication headers for the OTLP endpoint","type":"object"},"insecure":{"description":"Insecure indicates whether to use HTTP instead of HTTPS for the OTLP endpoint","type":"boolean"},"metricsEnabled":{"description":"MetricsEnabled controls whether OTLP metrics are enabled\nWhen false, OTLP metrics are not sent even if an endpoint is configured\nThis is independent of EnablePrometheusMetricsPath","type":"boolean"},"samplingRate":{"description":"SamplingRate is the trace sampling rate (0.0-1.0)\nOnly used when TracingEnabled is true","type":"number"},"serviceName":{"description":"ServiceName is the service name for telemetry","type":"string"},"serviceVersion":{"description":"ServiceVersion is the service version for telemetry","type":"string"},"tracingEnabled":{"description":"TracingEnabled controls whether distributed tracing is enabled\nWhen false, no tracer provider is created even if an endpoint is configured","type":"boolean"}},"type":"object"},"tokenexchange.Config":{"description":"TokenExchangeConfig contains token exchange configuration for external authentication","properties":{"audience":{"description":"Audience is the target audience for the exchanged token","type":"string"},"client_id":{"description":"ClientID is the OAuth 2.0 client identifier","type":"string"},"client_secret":{"description":"ClientSecret is the OAuth 2.0 client secret","type":"string"},"external_token_header_name":{"description":"ExternalTokenHeaderName is the name of the custom header to use when HeaderStrategy is \"custom\"","type":"string"},"header_strategy":{"description":"HeaderStrategy determines how to inject the token\nValid values: HeaderStrategyReplace (default), HeaderStrategyCustom","type":"string"},"scopes":{"description":"Scopes is the list of scopes to request for the exchanged token","items":{"type":"string"},"type":"array","uniqueItems":false},"subject_token_type":{"description":"SubjectTokenType specifies the type of the subject token being exchanged.\nCommon values: tokenTypeAccessToken (default), tokenTypeIDToken, tokenTypeJWT.\nIf empty, defaults to tokenTypeAccessToken.","type":"string"},"token_url":{"description":"TokenURL is the OAuth 2.0 token endpoint URL","type":"string"}},"type":"object"},"types.MiddlewareConfig":{"properties":{"parameters":{"description":"Parameters is a JSON object containing the middleware parameters.\nIt is stored as a raw message to allow flexible parameter types.","type":"object"},"type":{"description":"Type is a string representing the middleware type.","type":"string"}},"type":"object"},"types.ProxyMode":{"description":"ProxyMode is the proxy mode for stdio transport (\"sse\" or \"streamable-http\")\nNote: \"sse\" is deprecated; use \"streamable-http\" instead.","type":"string","x-enum-varnames":["ProxyModeSSE","ProxyModeStreamableHTTP"]},"types.TransportType":{"description":"TransportType is the type of transport used for this workload.","type":"string","x-enum-varnames":["TransportTypeStdio","TransportTypeSSE","TransportTypeStreamableHTTP","TransportTypeInspector"]},"v1.RegistryType":{"description":"Type of registry (file, url, or default)","type":"string","x-enum-varnames":["RegistryTypeFile","RegistryTypeURL","RegistryTypeAPI","RegistryTypeDefault"]},"v1.UpdateRegistryRequest":{"description":"Request containing registry configuration updates","properties":{"allow_private_ip":{"description":"Allow private IP addresses for registry URL or API URL","type":"boolean"},"api_url":{"description":"MCP Registry API URL","type":"string"},"local_path":{"description":"Local registry file path","type":"string"},"url":{"description":"Registry URL (for remote registries)","type":"string"}},"type":"object"},"v1.UpdateRegistryResponse":{"description":"Response containing update result","properties":{"message":{"description":"Status message","type":"string"},"type":{"description":"Registry type after update","type":"string"}},"type":"object"},"v1.bulkClientRequest":{"properties":{"groups":{"description":"Groups is the list of groups configured on the client.","items":{"type":"string"},"type":"array","uniqueItems":false},"names":{"description":"Names is the list of client names to operate on.","items":{"type":"string","x-enum-varnames":["RooCode","Cline","Cursor","VSCodeInsider","VSCode","ClaudeCode","Windsurf","WindsurfJetBrains","AmpCli","AmpVSCode","AmpCursor","AmpVSCodeInsider","AmpWindsurf","LMStudio","Goose","Trae","Continue","OpenCode","Kiro","Antigravity","Zed"]},"type":"array","uniqueItems":false}},"type":"object"},"v1.bulkOperationRequest":{"properties":{"group":{"description":"Group name to operate on (mutually exclusive with names)","type":"string"},"names":{"description":"Names of the workloads to operate on","items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"v1.clientStatusResponse":{"properties":{"clients":{"items":{"$ref":"#/components/schemas/client.MCPClientStatus"},"type":"array","uniqueItems":false}},"type":"object"},"v1.createClientRequest":{"properties":{"groups":{"description":"Groups is the list of groups configured on the client.","items":{"type":"string"},"type":"array","uniqueItems":false},"name":{"description":"Name is the type of the client to register.","type":"string","x-enum-varnames":["RooCode","Cline","Cursor","VSCodeInsider","VSCode","ClaudeCode","Windsurf","WindsurfJetBrains","AmpCli","AmpVSCode","AmpCursor","AmpVSCodeInsider","AmpWindsurf","LMStudio","Goose","Trae","Continue","OpenCode","Kiro","Antigravity","Zed"]}},"type":"object"},"v1.createClientResponse":{"properties":{"groups":{"description":"Groups is the list of groups configured on the client.","items":{"type":"string"},"type":"array","uniqueItems":false},"name":{"description":"Name is the type of the client that was registered.","type":"string","x-enum-varnames":["RooCode","Cline","Cursor","VSCodeInsider","VSCode","ClaudeCode","Windsurf","WindsurfJetBrains","AmpCli","AmpVSCode","AmpCursor","AmpVSCodeInsider","AmpWindsurf","LMStudio","Goose","Trae","Continue","OpenCode","Kiro","Antigravity","Zed"]}},"type":"object"},"v1.createGroupRequest":{"properties":{"name":{"description":"Name of the group to create","type":"string"}},"type":"object"},"v1.createGroupResponse":{"properties":{"name":{"description":"Name of the created group","type":"string"}},"type":"object"},"v1.createRequest":{"description":"Request to create a new workload","properties":{"authz_config":{"description":"Authorization configuration","type":"string"},"cmd_arguments":{"description":"Command arguments to pass to the container","items":{"type":"string"},"type":"array","uniqueItems":false},"env_vars":{"additionalProperties":{"type":"string"},"description":"Environment variables to set in the container","type":"object"},"group":{"description":"Group name this workload belongs to","type":"string"},"headers":{"items":{"$ref":"#/components/schemas/registry.Header"},"type":"array","uniqueItems":false},"host":{"description":"Host to bind to","type":"string"},"image":{"description":"Docker image to use","type":"string"},"name":{"description":"Name of the workload","type":"string"},"network_isolation":{"description":"Whether network isolation is turned on. This applies the rules in the permission profile.","type":"boolean"},"oauth_config":{"$ref":"#/components/schemas/v1.remoteOAuthConfig"},"oidc":{"$ref":"#/components/schemas/v1.oidcOptions"},"permission_profile":{"$ref":"#/components/schemas/permissions.Profile"},"proxy_mode":{"description":"Proxy mode to use","type":"string"},"proxy_port":{"description":"Port for the HTTP proxy to listen on","type":"integer"},"secrets":{"description":"Secret parameters to inject","items":{"$ref":"#/components/schemas/secrets.SecretParameter"},"type":"array","uniqueItems":false},"target_port":{"description":"Port to expose from the container","type":"integer"},"tools":{"description":"Tools filter","items":{"type":"string"},"type":"array","uniqueItems":false},"tools_override":{"additionalProperties":{"$ref":"#/components/schemas/v1.toolOverride"},"description":"Tools override","type":"object"},"transport":{"description":"Transport configuration","type":"string"},"trust_proxy_headers":{"description":"Whether to trust X-Forwarded-* headers from reverse proxies","type":"boolean"},"url":{"description":"Remote server specific fields","type":"string"},"volumes":{"description":"Volume mounts","items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"v1.createSecretRequest":{"description":"Request to create a new secret","properties":{"key":{"description":"Secret key name","type":"string"},"value":{"description":"Secret value","type":"string"}},"type":"object"},"v1.createSecretResponse":{"description":"Response after creating a secret","properties":{"key":{"description":"Secret key that was created","type":"string"},"message":{"description":"Success message","type":"string"}},"type":"object"},"v1.createWorkloadResponse":{"description":"Response after successfully creating a workload","properties":{"name":{"description":"Name of the created workload","type":"string"},"port":{"description":"Port the workload is listening on","type":"integer"}},"type":"object"},"v1.getRegistryResponse":{"description":"Response containing registry details","properties":{"last_updated":{"description":"Last updated timestamp","type":"string"},"name":{"description":"Name of the registry","type":"string"},"registry":{"$ref":"#/components/schemas/registry.Registry"},"server_count":{"description":"Number of servers in the registry","type":"integer"},"source":{"description":"Source of the registry (URL, file path, or empty string for built-in)","type":"string"},"type":{"description":"Type of registry (file, url, or default)","type":"string","x-enum-varnames":["RegistryTypeFile","RegistryTypeURL","RegistryTypeAPI","RegistryTypeDefault"]},"version":{"description":"Version of the registry schema","type":"string"}},"type":"object"},"v1.getSecretsProviderResponse":{"description":"Response containing secrets provider details","properties":{"capabilities":{"$ref":"#/components/schemas/v1.providerCapabilitiesResponse"},"name":{"description":"Name of the secrets provider","type":"string"},"provider_type":{"description":"Type of the secrets provider","type":"string"}},"type":"object"},"v1.getServerResponse":{"description":"Response containing server details","properties":{"is_remote":{"description":"Indicates if this is a remote server","type":"boolean"},"remote_server":{"$ref":"#/components/schemas/registry.RemoteServerMetadata"},"server":{"$ref":"#/components/schemas/registry.ImageMetadata"}},"type":"object"},"v1.groupListResponse":{"properties":{"groups":{"description":"List of groups","items":{"$ref":"#/components/schemas/groups.Group"},"type":"array","uniqueItems":false}},"type":"object"},"v1.listSecretsResponse":{"description":"Response containing a list of secret keys","properties":{"keys":{"description":"List of secret keys","items":{"$ref":"#/components/schemas/v1.secretKeyResponse"},"type":"array","uniqueItems":false}},"type":"object"},"v1.listServersResponse":{"description":"Response containing a list of servers","properties":{"remote_servers":{"description":"List of remote servers in the registry (if any)","items":{"$ref":"#/components/schemas/registry.RemoteServerMetadata"},"type":"array","uniqueItems":false},"servers":{"description":"List of container servers in the registry","items":{"$ref":"#/components/schemas/registry.ImageMetadata"},"type":"array","uniqueItems":false}},"type":"object"},"v1.oidcOptions":{"description":"OIDC configuration options","properties":{"audience":{"description":"Expected audience","type":"string"},"client_id":{"description":"OAuth2 client ID","type":"string"},"client_secret":{"description":"OAuth2 client secret","type":"string"},"introspection_url":{"description":"Token introspection URL for OIDC","type":"string"},"issuer":{"description":"OIDC issuer URL","type":"string"},"jwks_url":{"description":"JWKS URL for key verification","type":"string"},"scopes":{"description":"OAuth scopes to advertise in well-known endpoint (RFC 9728)","items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"v1.providerCapabilitiesResponse":{"description":"Capabilities of the secrets provider","properties":{"can_cleanup":{"description":"Whether the provider can cleanup all secrets","type":"boolean"},"can_delete":{"description":"Whether the provider can delete secrets","type":"boolean"},"can_list":{"description":"Whether the provider can list secrets","type":"boolean"},"can_read":{"description":"Whether the provider can read secrets","type":"boolean"},"can_write":{"description":"Whether the provider can write secrets","type":"boolean"}},"type":"object"},"v1.registryInfo":{"description":"Basic information about a registry","properties":{"last_updated":{"description":"Last updated timestamp","type":"string"},"name":{"description":"Name of the registry","type":"string"},"server_count":{"description":"Number of servers in the registry","type":"integer"},"source":{"description":"Source of the registry (URL, file path, or empty string for built-in)","type":"string"},"type":{"$ref":"#/components/schemas/v1.RegistryType"},"version":{"description":"Version of the registry schema","type":"string"}},"type":"object"},"v1.registryListResponse":{"description":"Response containing a list of registries","properties":{"registries":{"description":"List of registries","items":{"$ref":"#/components/schemas/v1.registryInfo"},"type":"array","uniqueItems":false}},"type":"object"},"v1.remoteOAuthConfig":{"description":"OAuth configuration for remote server authentication","properties":{"authorize_url":{"description":"OAuth authorization endpoint URL (alternative to issuer for non-OIDC OAuth)","type":"string"},"callback_port":{"description":"Specific port for OAuth callback server","type":"integer"},"client_id":{"description":"OAuth client ID for authentication","type":"string"},"client_secret":{"$ref":"#/components/schemas/secrets.SecretParameter"},"issuer":{"description":"OAuth/OIDC issuer URL (e.g., https://accounts.google.com)","type":"string"},"oauth_params":{"additionalProperties":{"type":"string"},"description":"Additional OAuth parameters for server-specific customization","type":"object"},"resource":{"description":"OAuth 2.0 resource indicator (RFC 8707)","type":"string"},"scopes":{"description":"OAuth scopes to request","items":{"type":"string"},"type":"array","uniqueItems":false},"skip_browser":{"description":"Whether to skip opening browser for OAuth flow (defaults to false)","type":"boolean"},"token_url":{"description":"OAuth token endpoint URL (alternative to issuer for non-OIDC OAuth)","type":"string"},"use_pkce":{"description":"Whether to use PKCE for the OAuth flow","type":"boolean"}},"type":"object"},"v1.secretKeyResponse":{"description":"Secret key information","properties":{"description":{"description":"Optional description of the secret","type":"string"},"key":{"description":"Secret key name","type":"string"}},"type":"object"},"v1.setupSecretsRequest":{"description":"Request to setup a secrets provider","properties":{"password":{"description":"Password for encrypted provider (optional, can be set via environment variable)\nTODO Review environment variable for this","type":"string"},"provider_type":{"description":"Type of the secrets provider (encrypted, 1password, none)","type":"string"}},"type":"object"},"v1.setupSecretsResponse":{"description":"Response after initializing a secrets provider","properties":{"message":{"description":"Success message","type":"string"},"provider_type":{"description":"Type of the secrets provider that was setup","type":"string"}},"type":"object"},"v1.toolOverride":{"description":"Tool override","properties":{"description":{"description":"Description of the tool","type":"string"},"name":{"description":"Name of the tool","type":"string"}},"type":"object"},"v1.updateRequest":{"description":"Request to update an existing workload (name cannot be changed)","properties":{"authz_config":{"description":"Authorization configuration","type":"string"},"cmd_arguments":{"description":"Command arguments to pass to the container","items":{"type":"string"},"type":"array","uniqueItems":false},"env_vars":{"additionalProperties":{"type":"string"},"description":"Environment variables to set in the container","type":"object"},"group":{"description":"Group name this workload belongs to","type":"string"},"headers":{"items":{"$ref":"#/components/schemas/registry.Header"},"type":"array","uniqueItems":false},"host":{"description":"Host to bind to","type":"string"},"image":{"description":"Docker image to use","type":"string"},"network_isolation":{"description":"Whether network isolation is turned on. This applies the rules in the permission profile.","type":"boolean"},"oauth_config":{"$ref":"#/components/schemas/v1.remoteOAuthConfig"},"oidc":{"$ref":"#/components/schemas/v1.oidcOptions"},"permission_profile":{"$ref":"#/components/schemas/permissions.Profile"},"proxy_mode":{"description":"Proxy mode to use","type":"string"},"proxy_port":{"description":"Port for the HTTP proxy to listen on","type":"integer"},"secrets":{"description":"Secret parameters to inject","items":{"$ref":"#/components/schemas/secrets.SecretParameter"},"type":"array","uniqueItems":false},"target_port":{"description":"Port to expose from the container","type":"integer"},"tools":{"description":"Tools filter","items":{"type":"string"},"type":"array","uniqueItems":false},"tools_override":{"additionalProperties":{"$ref":"#/components/schemas/v1.toolOverride"},"description":"Tools override","type":"object"},"transport":{"description":"Transport configuration","type":"string"},"trust_proxy_headers":{"description":"Whether to trust X-Forwarded-* headers from reverse proxies","type":"boolean"},"url":{"description":"Remote server specific fields","type":"string"},"volumes":{"description":"Volume mounts","items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"v1.updateSecretRequest":{"description":"Request to update an existing secret","properties":{"value":{"description":"New secret value","type":"string"}},"type":"object"},"v1.updateSecretResponse":{"description":"Response after updating a secret","properties":{"key":{"description":"Secret key that was updated","type":"string"},"message":{"description":"Success message","type":"string"}},"type":"object"},"v1.versionResponse":{"properties":{"version":{"type":"string"}},"type":"object"},"v1.workloadListResponse":{"description":"Response containing a list of workloads","properties":{"workloads":{"description":"List of container information for each workload","items":{"$ref":"#/components/schemas/core.Workload"},"type":"array","uniqueItems":false}},"type":"object"},"v1.workloadStatusResponse":{"description":"Response containing workload status information","properties":{"status":{"description":"Current status of the workload","type":"string","x-enum-varnames":["WorkloadStatusRunning","WorkloadStatusStopped","WorkloadStatusError","WorkloadStatusStarting","WorkloadStatusStopping","WorkloadStatusUnhealthy","WorkloadStatusRemoving","WorkloadStatusUnknown","WorkloadStatusUnauthenticated"]}},"type":"object"}}}, + "components": {"schemas":{"audit.Config":{"description":"AuditConfig contains the audit logging configuration","properties":{"component":{"description":"Component is the component name to use in audit events","type":"string"},"event_types":{"description":"EventTypes specifies which event types to audit. If empty, all events are audited.","items":{"type":"string"},"type":"array","uniqueItems":false},"exclude_event_types":{"description":"ExcludeEventTypes specifies which event types to exclude from auditing.\nThis takes precedence over EventTypes.","items":{"type":"string"},"type":"array","uniqueItems":false},"include_request_data":{"description":"IncludeRequestData determines whether to include request data in audit logs","type":"boolean"},"include_response_data":{"description":"IncludeResponseData determines whether to include response data in audit logs","type":"boolean"},"log_file":{"description":"LogFile specifies the file path for audit logs. If empty, logs to stdout.","type":"string"},"max_data_size":{"description":"MaxDataSize limits the size of request/response data included in audit logs (in bytes)","type":"integer"}},"type":"object"},"auth.TokenValidatorConfig":{"description":"OIDCConfig contains OIDC configuration","properties":{"allowPrivateIP":{"description":"AllowPrivateIP allows JWKS/OIDC endpoints on private IP addresses","type":"boolean"},"audience":{"description":"Audience is the expected audience for the token","type":"string"},"authTokenFile":{"description":"AuthTokenFile is the path to file containing bearer token for authentication","type":"string"},"cacertPath":{"description":"CACertPath is the path to the CA certificate bundle for HTTPS requests","type":"string"},"clientID":{"description":"ClientID is the OIDC client ID","type":"string"},"clientSecret":{"description":"ClientSecret is the optional OIDC client secret for introspection","type":"string"},"insecureAllowHTTP":{"description":"InsecureAllowHTTP allows HTTP (non-HTTPS) OIDC issuers for development/testing\nWARNING: This is insecure and should NEVER be used in production","type":"boolean"},"introspectionURL":{"description":"IntrospectionURL is the optional introspection endpoint for validating tokens","type":"string"},"issuer":{"description":"Issuer is the OIDC issuer URL (e.g., https://accounts.google.com)","type":"string"},"jwksurl":{"description":"JWKSURL is the URL to fetch the JWKS from","type":"string"},"resourceURL":{"description":"ResourceURL is the explicit resource URL for OAuth discovery (RFC 9728)","type":"string"},"scopes":{"description":"Scopes is the list of OAuth scopes to advertise in the well-known endpoint (RFC 9728)\nIf empty, defaults to [\"openid\"]","items":{"type":"string"},"type":"array"}},"type":"object"},"authz.Config":{"description":"AuthzConfig contains the authorization configuration","properties":{"type":{"description":"Type is the type of authorization configuration (e.g., \"cedarv1\").","type":"string"},"version":{"description":"Version is the version of the configuration format.","type":"string"}},"type":"object"},"client.MCPClient":{"type":"string","x-enum-varnames":["RooCode","Cline","Cursor","VSCodeInsider","VSCode","ClaudeCode","Windsurf","WindsurfJetBrains","AmpCli","AmpVSCode","AmpCursor","AmpVSCodeInsider","AmpWindsurf","LMStudio","Goose","Trae","Continue","OpenCode","Kiro","Antigravity","Zed"]},"client.MCPClientStatus":{"properties":{"client_type":{"description":"ClientType is the type of MCP client","type":"string","x-enum-varnames":["RooCode","Cline","Cursor","VSCodeInsider","VSCode","ClaudeCode","Windsurf","WindsurfJetBrains","AmpCli","AmpVSCode","AmpCursor","AmpVSCodeInsider","AmpWindsurf","LMStudio","Goose","Trae","Continue","OpenCode","Kiro","Antigravity","Zed"]},"installed":{"description":"Installed indicates whether the client is installed on the system","type":"boolean"},"registered":{"description":"Registered indicates whether the client is registered in the ToolHive configuration","type":"boolean"}},"type":"object"},"client.RegisteredClient":{"properties":{"groups":{"items":{"type":"string"},"type":"array","uniqueItems":false},"name":{"$ref":"#/components/schemas/client.MCPClient"}},"type":"object"},"core.Workload":{"properties":{"created_at":{"description":"CreatedAt is the timestamp when the workload was created.","type":"string"},"group":{"description":"Group is the name of the group this workload belongs to, if any.","type":"string"},"labels":{"additionalProperties":{"type":"string"},"description":"Labels are the container labels (excluding standard ToolHive labels)","type":"object"},"name":{"description":"Name is the name of the workload.\nIt is used as a unique identifier.","type":"string"},"package":{"description":"Package specifies the Workload Package used to create this Workload.","type":"string"},"port":{"description":"Port is the port on which the workload is exposed.\nThis is embedded in the URL.","type":"integer"},"proxy_mode":{"description":"ProxyMode is the proxy mode that clients should use to connect.\nFor stdio transports, this will be the proxy mode (sse or streamable-http).\nFor direct transports (sse/streamable-http), this will be the same as TransportType.","type":"string"},"remote":{"description":"Remote indicates whether this is a remote workload (true) or a container workload (false).","type":"boolean"},"status":{"$ref":"#/components/schemas/runtime.WorkloadStatus"},"status_context":{"description":"StatusContext provides additional context about the workload's status.\nThe exact meaning is determined by the status and the underlying runtime.","type":"string"},"tools":{"description":"ToolsFilter is the filter on tools applied to the workload.","items":{"type":"string"},"type":"array","uniqueItems":false},"transport_type":{"$ref":"#/components/schemas/types.TransportType"},"url":{"description":"URL is the URL of the workload exposed by the ToolHive proxy.","type":"string"}},"type":"object"},"groups.Group":{"properties":{"name":{"type":"string"},"registered_clients":{"items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"ignore.Config":{"description":"IgnoreConfig contains configuration for ignore processing","properties":{"loadGlobal":{"description":"Whether to load global ignore patterns","type":"boolean"},"printOverlays":{"description":"Whether to print resolved overlay paths for debugging","type":"boolean"}},"type":"object"},"permissions.InboundNetworkPermissions":{"description":"Inbound defines inbound network permissions","properties":{"allow_host":{"description":"AllowHost is a list of allowed hosts for inbound connections","items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"permissions.NetworkPermissions":{"description":"Network defines network permissions","properties":{"inbound":{"$ref":"#/components/schemas/permissions.InboundNetworkPermissions"},"mode":{"description":"Mode specifies the network mode for the container (e.g., \"host\", \"bridge\", \"none\")\nWhen empty, the default container runtime network mode is used","type":"string"},"outbound":{"$ref":"#/components/schemas/permissions.OutboundNetworkPermissions"}},"type":"object"},"permissions.OutboundNetworkPermissions":{"description":"Outbound defines outbound network permissions","properties":{"allow_host":{"description":"AllowHost is a list of allowed hosts","items":{"type":"string"},"type":"array","uniqueItems":false},"allow_port":{"description":"AllowPort is a list of allowed ports","items":{"type":"integer"},"type":"array","uniqueItems":false},"insecure_allow_all":{"description":"InsecureAllowAll allows all outbound network connections","type":"boolean"}},"type":"object"},"permissions.Profile":{"description":"PermissionProfile is the permission profile to use","properties":{"name":{"description":"Name is the name of the profile","type":"string"},"network":{"$ref":"#/components/schemas/permissions.NetworkPermissions"},"privileged":{"description":"Privileged indicates whether the container should run in privileged mode\nWhen true, the container has access to all host devices and capabilities\nUse with extreme caution as this removes most security isolation","type":"boolean"},"read":{"description":"Read is a list of mount declarations that the container can read from\nThese can be in the following formats:\n- A single path: The same path will be mounted from host to container\n- host-path:container-path: Different paths for host and container\n- resource-uri:container-path: Mount a resource identified by URI to a container path","items":{"type":"string"},"type":"array","uniqueItems":false},"write":{"description":"Write is a list of mount declarations that the container can write to\nThese follow the same format as Read mounts but with write permissions","items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"registry.EnvVar":{"properties":{"default":{"description":"Default is the value to use if the environment variable is not explicitly provided\nOnly used for non-required variables","type":"string"},"description":{"description":"Description is a human-readable explanation of the variable's purpose","type":"string"},"name":{"description":"Name is the environment variable name (e.g., API_KEY)","type":"string"},"required":{"description":"Required indicates whether this environment variable must be provided\nIf true and not provided via command line or secrets, the user will be prompted for a value","type":"boolean"},"secret":{"description":"Secret indicates whether this environment variable contains sensitive information\nIf true, the value will be stored as a secret rather than as a plain environment variable","type":"boolean"}},"type":"object"},"registry.Group":{"properties":{"description":{"description":"Description is a human-readable description of the group's purpose and functionality","type":"string"},"name":{"description":"Name is the identifier for the group, used when referencing the group in commands","type":"string"},"remote_servers":{"additionalProperties":{"$ref":"#/components/schemas/registry.RemoteServerMetadata"},"description":"RemoteServers is a map of server names to their corresponding remote server definitions within this group","type":"object"},"servers":{"additionalProperties":{"$ref":"#/components/schemas/registry.ImageMetadata"},"description":"Servers is a map of server names to their corresponding server definitions within this group","type":"object"}},"type":"object"},"registry.Header":{"properties":{"choices":{"description":"Choices provides a list of valid values for the header (optional)","items":{"type":"string"},"type":"array","uniqueItems":false},"default":{"description":"Default is the value to use if the header is not explicitly provided\nOnly used for non-required headers","type":"string"},"description":{"description":"Description is a human-readable explanation of the header's purpose","type":"string"},"name":{"description":"Name is the header name (e.g., X-API-Key, Authorization)","type":"string"},"required":{"description":"Required indicates whether this header must be provided\nIf true and not provided via command line or secrets, the user will be prompted for a value","type":"boolean"},"secret":{"description":"Secret indicates whether this header contains sensitive information\nIf true, the value will be stored as a secret rather than as plain text","type":"boolean"}},"type":"object"},"registry.ImageMetadata":{"description":"Container server details (if it's a container server)","properties":{"args":{"description":"Args are the default command-line arguments to pass to the MCP server container.\nThese arguments will be used only if no command-line arguments are provided by the user.\nIf the user provides arguments, they will override these defaults.","items":{"type":"string"},"type":"array","uniqueItems":false},"custom_metadata":{"additionalProperties":{},"description":"CustomMetadata allows for additional user-defined metadata","type":"object"},"description":{"description":"Description is a human-readable description of the server's purpose and functionality","type":"string"},"docker_tags":{"description":"DockerTags lists the available Docker tags for this server image","items":{"type":"string"},"type":"array","uniqueItems":false},"env_vars":{"description":"EnvVars defines environment variables that can be passed to the server","items":{"$ref":"#/components/schemas/registry.EnvVar"},"type":"array","uniqueItems":false},"image":{"description":"Image is the Docker image reference for the MCP server","type":"string"},"metadata":{"$ref":"#/components/schemas/registry.Metadata"},"name":{"description":"Name is the identifier for the MCP server, used when referencing the server in commands\nIf not provided, it will be auto-generated from the registry key","type":"string"},"permissions":{"$ref":"#/components/schemas/permissions.Profile"},"provenance":{"$ref":"#/components/schemas/registry.Provenance"},"repository_url":{"description":"RepositoryURL is the URL to the source code repository for the server","type":"string"},"status":{"description":"Status indicates whether the server is currently active or deprecated","type":"string"},"tags":{"description":"Tags are categorization labels for the server to aid in discovery and filtering","items":{"type":"string"},"type":"array","uniqueItems":false},"target_port":{"description":"TargetPort is the port for the container to expose (only applicable to SSE and Streamable HTTP transports)","type":"integer"},"tier":{"description":"Tier represents the tier classification level of the server, e.g., \"Official\" or \"Community\"","type":"string"},"tools":{"description":"Tools is a list of tool names provided by this MCP server","items":{"type":"string"},"type":"array","uniqueItems":false},"transport":{"description":"Transport defines the communication protocol for the server\nFor containers: stdio, sse, or streamable-http\nFor remote servers: sse or streamable-http (stdio not supported)","type":"string"}},"type":"object"},"registry.Metadata":{"description":"Metadata contains additional information about the server such as popularity metrics","properties":{"last_updated":{"description":"LastUpdated is the timestamp when the server was last updated, in RFC3339 format","type":"string"},"pulls":{"description":"Pulls indicates how many times the server image has been downloaded","type":"integer"},"stars":{"description":"Stars represents the popularity rating or number of stars for the server","type":"integer"}},"type":"object"},"registry.OAuthConfig":{"description":"OAuthConfig provides OAuth/OIDC configuration for authentication to the remote server\nUsed with the thv proxy command's --remote-auth flags","properties":{"authorize_url":{"description":"AuthorizeURL is the OAuth authorization endpoint URL\nUsed for non-OIDC OAuth flows when issuer is not provided","type":"string"},"callback_port":{"description":"CallbackPort is the specific port to use for the OAuth callback server\nIf not specified, a random available port will be used","type":"integer"},"client_id":{"description":"ClientID is the OAuth client ID for authentication","type":"string"},"issuer":{"description":"Issuer is the OAuth/OIDC issuer URL (e.g., https://accounts.google.com)\nUsed for OIDC discovery to find authorization and token endpoints","type":"string"},"oauth_params":{"additionalProperties":{"type":"string"},"description":"OAuthParams contains additional OAuth parameters to include in the authorization request\nThese are server-specific parameters like \"prompt\", \"response_mode\", etc.","type":"object"},"resource":{"description":"Resource is the OAuth 2.0 resource indicator (RFC 8707)","type":"string"},"scopes":{"description":"Scopes are the OAuth scopes to request\nIf not specified, defaults to [\"openid\", \"profile\", \"email\"] for OIDC","items":{"type":"string"},"type":"array","uniqueItems":false},"token_url":{"description":"TokenURL is the OAuth token endpoint URL\nUsed for non-OIDC OAuth flows when issuer is not provided","type":"string"},"use_pkce":{"description":"UsePKCE indicates whether to use PKCE for the OAuth flow\nDefaults to true for enhanced security","type":"boolean"}},"type":"object"},"registry.Provenance":{"description":"Provenance contains verification and signing metadata","properties":{"attestation":{"$ref":"#/components/schemas/registry.VerifiedAttestation"},"cert_issuer":{"type":"string"},"repository_ref":{"type":"string"},"repository_uri":{"type":"string"},"runner_environment":{"type":"string"},"signer_identity":{"type":"string"},"sigstore_url":{"type":"string"}},"type":"object"},"registry.Registry":{"description":"Full registry data","properties":{"groups":{"description":"Groups is a slice of group definitions containing related MCP servers","items":{"$ref":"#/components/schemas/registry.Group"},"type":"array","uniqueItems":false},"last_updated":{"description":"LastUpdated is the timestamp when the registry was last updated, in RFC3339 format","type":"string"},"remote_servers":{"additionalProperties":{"$ref":"#/components/schemas/registry.RemoteServerMetadata"},"description":"RemoteServers is a map of server names to their corresponding remote server definitions\nThese are MCP servers accessed via HTTP/HTTPS using the thv proxy command","type":"object"},"servers":{"additionalProperties":{"$ref":"#/components/schemas/registry.ImageMetadata"},"description":"Servers is a map of server names to their corresponding server definitions","type":"object"},"version":{"description":"Version is the schema version of the registry","type":"string"}},"type":"object"},"registry.RemoteServerMetadata":{"description":"Remote server details (if it's a remote server)","properties":{"custom_metadata":{"additionalProperties":{},"description":"CustomMetadata allows for additional user-defined metadata","type":"object"},"description":{"description":"Description is a human-readable description of the server's purpose and functionality","type":"string"},"env_vars":{"description":"EnvVars defines environment variables that can be passed to configure the client\nThese might be needed for client-side configuration when connecting to the remote server","items":{"$ref":"#/components/schemas/registry.EnvVar"},"type":"array","uniqueItems":false},"headers":{"description":"Headers defines HTTP headers that can be passed to the remote server for authentication\nThese are used with the thv proxy command's authentication features","items":{"$ref":"#/components/schemas/registry.Header"},"type":"array","uniqueItems":false},"metadata":{"$ref":"#/components/schemas/registry.Metadata"},"name":{"description":"Name is the identifier for the MCP server, used when referencing the server in commands\nIf not provided, it will be auto-generated from the registry key","type":"string"},"oauth_config":{"$ref":"#/components/schemas/registry.OAuthConfig"},"repository_url":{"description":"RepositoryURL is the URL to the source code repository for the server","type":"string"},"status":{"description":"Status indicates whether the server is currently active or deprecated","type":"string"},"tags":{"description":"Tags are categorization labels for the server to aid in discovery and filtering","items":{"type":"string"},"type":"array","uniqueItems":false},"tier":{"description":"Tier represents the tier classification level of the server, e.g., \"Official\" or \"Community\"","type":"string"},"tools":{"description":"Tools is a list of tool names provided by this MCP server","items":{"type":"string"},"type":"array","uniqueItems":false},"transport":{"description":"Transport defines the communication protocol for the server\nFor containers: stdio, sse, or streamable-http\nFor remote servers: sse or streamable-http (stdio not supported)","type":"string"},"url":{"description":"URL is the endpoint URL for the remote MCP server (e.g., https://api.example.com/mcp)","type":"string"}},"type":"object"},"registry.VerifiedAttestation":{"properties":{"predicate":{},"predicate_type":{"type":"string"}},"type":"object"},"remote.Config":{"description":"RemoteAuthConfig contains OAuth configuration for remote MCP servers","properties":{"authorize_url":{"type":"string"},"callback_port":{"type":"integer"},"client_id":{"type":"string"},"client_secret":{"type":"string"},"client_secret_file":{"type":"string"},"env_vars":{"description":"Environment variables for the client","items":{"$ref":"#/components/schemas/registry.EnvVar"},"type":"array","uniqueItems":false},"headers":{"description":"Headers for HTTP requests","items":{"$ref":"#/components/schemas/registry.Header"},"type":"array","uniqueItems":false},"issuer":{"description":"OAuth endpoint configuration (from registry)","type":"string"},"oauth_params":{"additionalProperties":{"type":"string"},"description":"OAuth parameters for server-specific customization","type":"object"},"resource":{"description":"Resource is the OAuth 2.0 resource indicator (RFC 8707).","type":"string"},"scopes":{"items":{"type":"string"},"type":"array","uniqueItems":false},"skip_browser":{"type":"boolean"},"timeout":{"example":"5m","type":"string"},"token_url":{"type":"string"},"use_pkce":{"type":"boolean"}},"type":"object"},"runner.RunConfig":{"properties":{"audit_config":{"$ref":"#/components/schemas/audit.Config"},"audit_config_path":{"description":"AuditConfigPath is the path to the audit configuration file","type":"string"},"authz_config":{"$ref":"#/components/schemas/authz.Config"},"authz_config_path":{"description":"AuthzConfigPath is the path to the authorization configuration file","type":"string"},"base_name":{"description":"BaseName is the base name used for the container (without prefixes)","type":"string"},"cmd_args":{"description":"CmdArgs are the arguments to pass to the container","items":{"type":"string"},"type":"array","uniqueItems":false},"container_labels":{"additionalProperties":{"type":"string"},"description":"ContainerLabels are the labels to apply to the container","type":"object"},"container_name":{"description":"ContainerName is the name of the container","type":"string"},"debug":{"description":"Debug indicates whether debug mode is enabled","type":"boolean"},"env_file_dir":{"description":"EnvFileDir is the directory path to load environment files from","type":"string"},"env_vars":{"additionalProperties":{"type":"string"},"description":"EnvVars are the parsed environment variables as key-value pairs","type":"object"},"group":{"description":"Group is the name of the group this workload belongs to, if any","type":"string"},"host":{"description":"Host is the host for the HTTP proxy","type":"string"},"ignore_config":{"$ref":"#/components/schemas/ignore.Config"},"image":{"description":"Image is the Docker image to run","type":"string"},"isolate_network":{"description":"IsolateNetwork indicates whether to isolate the network for the container","type":"boolean"},"jwks_auth_token_file":{"description":"JWKSAuthTokenFile is the path to file containing auth token for JWKS/OIDC requests","type":"string"},"k8s_pod_template_patch":{"description":"K8sPodTemplatePatch is a JSON string to patch the Kubernetes pod template\nOnly applicable when using Kubernetes runtime","type":"string"},"middleware_configs":{"description":"MiddlewareConfigs contains the list of middleware to apply to the transport\nand the configuration for each middleware.","items":{"$ref":"#/components/schemas/types.MiddlewareConfig"},"type":"array","uniqueItems":false},"name":{"description":"Name is the name of the MCP server","type":"string"},"oidc_config":{"$ref":"#/components/schemas/auth.TokenValidatorConfig"},"permission_profile":{"$ref":"#/components/schemas/permissions.Profile"},"permission_profile_name_or_path":{"description":"PermissionProfileNameOrPath is the name or path of the permission profile","type":"string"},"port":{"description":"Port is the port for the HTTP proxy to listen on (host port)","type":"integer"},"proxy_mode":{"$ref":"#/components/schemas/types.ProxyMode"},"remote_auth_config":{"$ref":"#/components/schemas/remote.Config"},"remote_url":{"description":"RemoteURL is the URL of the remote MCP server (if running remotely)","type":"string"},"schema_version":{"description":"SchemaVersion is the version of the RunConfig schema","type":"string"},"secrets":{"description":"Secrets are the secret parameters to pass to the container\nFormat: \"\u003csecret name\u003e,target=\u003ctarget environment variable\u003e\"","items":{"type":"string"},"type":"array","uniqueItems":false},"target_host":{"description":"TargetHost is the host to forward traffic to (only applicable to SSE transport)","type":"string"},"target_port":{"description":"TargetPort is the port for the container to expose (only applicable to SSE transport)","type":"integer"},"telemetry_config":{"$ref":"#/components/schemas/telemetry.Config"},"thv_ca_bundle":{"description":"ThvCABundle is the path to the CA certificate bundle for ToolHive HTTP operations","type":"string"},"token_exchange_config":{"$ref":"#/components/schemas/tokenexchange.Config"},"tools_filter":{"description":"ToolsFilter is the list of tools to filter","items":{"type":"string"},"type":"array","uniqueItems":false},"tools_override":{"additionalProperties":{"$ref":"#/components/schemas/runner.ToolOverride"},"description":"ToolsOverride is a map from an actual tool to its overridden name and/or description","type":"object"},"transport":{"description":"Transport is the transport mode (stdio, sse, or streamable-http)","type":"string","x-enum-varnames":["TransportTypeStdio","TransportTypeSSE","TransportTypeStreamableHTTP","TransportTypeInspector"]},"trust_proxy_headers":{"description":"TrustProxyHeaders indicates whether to trust X-Forwarded-* headers from reverse proxies","type":"boolean"},"volumes":{"description":"Volumes are the directory mounts to pass to the container\nFormat: \"host-path:container-path[:ro]\"","items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"runner.ToolOverride":{"properties":{"description":{"description":"Description is the redefined description of the tool","type":"string"},"name":{"description":"Name is the redefined name of the tool","type":"string"}},"type":"object"},"runtime.WorkloadStatus":{"description":"Status is the current status of the workload.","type":"string","x-enum-varnames":["WorkloadStatusRunning","WorkloadStatusStopped","WorkloadStatusError","WorkloadStatusStarting","WorkloadStatusStopping","WorkloadStatusUnhealthy","WorkloadStatusRemoving","WorkloadStatusUnknown","WorkloadStatusUnauthenticated"]},"secrets.SecretParameter":{"properties":{"name":{"type":"string"},"target":{"type":"string"}},"type":"object"},"telemetry.Config":{"description":"TelemetryConfig contains the OpenTelemetry configuration","properties":{"customAttributes":{"additionalProperties":{"type":"string"},"description":"CustomAttributes contains custom resource attributes to be added to all telemetry signals.\nThese are parsed from CLI flags (--otel-custom-attributes) or environment variables\n(OTEL_RESOURCE_ATTRIBUTES) as key=value pairs.\nWe use map[string]string for proper JSON serialization instead of []attribute.KeyValue\nwhich doesn't marshal/unmarshal correctly.","type":"object"},"enablePrometheusMetricsPath":{"description":"EnablePrometheusMetricsPath controls whether to expose Prometheus-style /metrics endpoint\nThe metrics are served on the main transport port at /metrics\nThis is separate from OTLP metrics which are sent to the Endpoint","type":"boolean"},"endpoint":{"description":"Endpoint is the OTLP endpoint URL","type":"string"},"environmentVariables":{"description":"EnvironmentVariables is a list of environment variable names that should be\nincluded in telemetry spans as attributes. Only variables in this list will\nbe read from the host machine and included in spans for observability.\nExample: []string{\"NODE_ENV\", \"DEPLOYMENT_ENV\", \"SERVICE_VERSION\"}","items":{"type":"string"},"type":"array","uniqueItems":false},"headers":{"additionalProperties":{"type":"string"},"description":"Headers contains authentication headers for the OTLP endpoint","type":"object"},"insecure":{"description":"Insecure indicates whether to use HTTP instead of HTTPS for the OTLP endpoint","type":"boolean"},"metricsEnabled":{"description":"MetricsEnabled controls whether OTLP metrics are enabled\nWhen false, OTLP metrics are not sent even if an endpoint is configured\nThis is independent of EnablePrometheusMetricsPath","type":"boolean"},"samplingRate":{"description":"SamplingRate is the trace sampling rate (0.0-1.0)\nOnly used when TracingEnabled is true","type":"number"},"serviceName":{"description":"ServiceName is the service name for telemetry","type":"string"},"serviceVersion":{"description":"ServiceVersion is the service version for telemetry","type":"string"},"tracingEnabled":{"description":"TracingEnabled controls whether distributed tracing is enabled\nWhen false, no tracer provider is created even if an endpoint is configured","type":"boolean"}},"type":"object"},"tokenexchange.Config":{"description":"TokenExchangeConfig contains token exchange configuration for external authentication","properties":{"audience":{"description":"Audience is the target audience for the exchanged token","type":"string"},"client_id":{"description":"ClientID is the OAuth 2.0 client identifier","type":"string"},"client_secret":{"description":"ClientSecret is the OAuth 2.0 client secret","type":"string"},"external_token_header_name":{"description":"ExternalTokenHeaderName is the name of the custom header to use when HeaderStrategy is \"custom\"","type":"string"},"header_strategy":{"description":"HeaderStrategy determines how to inject the token\nValid values: HeaderStrategyReplace (default), HeaderStrategyCustom","type":"string"},"scopes":{"description":"Scopes is the list of scopes to request for the exchanged token","items":{"type":"string"},"type":"array","uniqueItems":false},"subject_token_type":{"description":"SubjectTokenType specifies the type of the subject token being exchanged.\nCommon values: tokenTypeAccessToken (default), tokenTypeIDToken, tokenTypeJWT.\nIf empty, defaults to tokenTypeAccessToken.","type":"string"},"token_url":{"description":"TokenURL is the OAuth 2.0 token endpoint URL","type":"string"}},"type":"object"},"types.MiddlewareConfig":{"properties":{"parameters":{"description":"Parameters is a JSON object containing the middleware parameters.\nIt is stored as a raw message to allow flexible parameter types.","type":"object"},"type":{"description":"Type is a string representing the middleware type.","type":"string"}},"type":"object"},"types.ProxyMode":{"description":"ProxyMode is the proxy mode for stdio transport (\"sse\" or \"streamable-http\")\nNote: \"sse\" is deprecated; use \"streamable-http\" instead.","type":"string","x-enum-varnames":["ProxyModeSSE","ProxyModeStreamableHTTP"]},"types.TransportType":{"description":"TransportType is the type of transport used for this workload.","type":"string","x-enum-varnames":["TransportTypeStdio","TransportTypeSSE","TransportTypeStreamableHTTP","TransportTypeInspector"]},"v1.RegistryType":{"description":"Type of registry (file, url, or default)","type":"string","x-enum-varnames":["RegistryTypeFile","RegistryTypeURL","RegistryTypeAPI","RegistryTypeDefault"]},"v1.UpdateRegistryRequest":{"description":"Request containing registry configuration updates","properties":{"allow_private_ip":{"description":"Allow private IP addresses for registry URL or API URL","type":"boolean"},"api_url":{"description":"MCP Registry API URL","type":"string"},"local_path":{"description":"Local registry file path","type":"string"},"url":{"description":"Registry URL (for remote registries)","type":"string"}},"type":"object"},"v1.UpdateRegistryResponse":{"description":"Response containing update result","properties":{"message":{"description":"Status message","type":"string"},"type":{"description":"Registry type after update","type":"string"}},"type":"object"},"v1.bulkClientRequest":{"properties":{"groups":{"description":"Groups is the list of groups configured on the client.","items":{"type":"string"},"type":"array","uniqueItems":false},"names":{"description":"Names is the list of client names to operate on.","items":{"type":"string","x-enum-varnames":["RooCode","Cline","Cursor","VSCodeInsider","VSCode","ClaudeCode","Windsurf","WindsurfJetBrains","AmpCli","AmpVSCode","AmpCursor","AmpVSCodeInsider","AmpWindsurf","LMStudio","Goose","Trae","Continue","OpenCode","Kiro","Antigravity","Zed"]},"type":"array","uniqueItems":false}},"type":"object"},"v1.bulkOperationRequest":{"properties":{"group":{"description":"Group name to operate on (mutually exclusive with names)","type":"string"},"names":{"description":"Names of the workloads to operate on","items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"v1.clientStatusResponse":{"properties":{"clients":{"items":{"$ref":"#/components/schemas/client.MCPClientStatus"},"type":"array","uniqueItems":false}},"type":"object"},"v1.createClientRequest":{"properties":{"groups":{"description":"Groups is the list of groups configured on the client.","items":{"type":"string"},"type":"array","uniqueItems":false},"name":{"description":"Name is the type of the client to register.","type":"string","x-enum-varnames":["RooCode","Cline","Cursor","VSCodeInsider","VSCode","ClaudeCode","Windsurf","WindsurfJetBrains","AmpCli","AmpVSCode","AmpCursor","AmpVSCodeInsider","AmpWindsurf","LMStudio","Goose","Trae","Continue","OpenCode","Kiro","Antigravity","Zed"]}},"type":"object"},"v1.createClientResponse":{"properties":{"groups":{"description":"Groups is the list of groups configured on the client.","items":{"type":"string"},"type":"array","uniqueItems":false},"name":{"description":"Name is the type of the client that was registered.","type":"string","x-enum-varnames":["RooCode","Cline","Cursor","VSCodeInsider","VSCode","ClaudeCode","Windsurf","WindsurfJetBrains","AmpCli","AmpVSCode","AmpCursor","AmpVSCodeInsider","AmpWindsurf","LMStudio","Goose","Trae","Continue","OpenCode","Kiro","Antigravity","Zed"]}},"type":"object"},"v1.createGroupRequest":{"properties":{"name":{"description":"Name of the group to create","type":"string"}},"type":"object"},"v1.createGroupResponse":{"properties":{"name":{"description":"Name of the created group","type":"string"}},"type":"object"},"v1.createRequest":{"description":"Request to create a new workload","properties":{"authz_config":{"description":"Authorization configuration","type":"string"},"cmd_arguments":{"description":"Command arguments to pass to the container","items":{"type":"string"},"type":"array","uniqueItems":false},"env_vars":{"additionalProperties":{"type":"string"},"description":"Environment variables to set in the container","type":"object"},"group":{"description":"Group name this workload belongs to","type":"string"},"headers":{"items":{"$ref":"#/components/schemas/registry.Header"},"type":"array","uniqueItems":false},"host":{"description":"Host to bind to","type":"string"},"image":{"description":"Docker image to use","type":"string"},"name":{"description":"Name of the workload","type":"string"},"network_isolation":{"description":"Whether network isolation is turned on. This applies the rules in the permission profile.","type":"boolean"},"oauth_config":{"$ref":"#/components/schemas/v1.remoteOAuthConfig"},"oidc":{"$ref":"#/components/schemas/v1.oidcOptions"},"permission_profile":{"$ref":"#/components/schemas/permissions.Profile"},"proxy_mode":{"description":"Proxy mode to use","type":"string"},"proxy_port":{"description":"Port for the HTTP proxy to listen on","type":"integer"},"secrets":{"description":"Secret parameters to inject","items":{"$ref":"#/components/schemas/secrets.SecretParameter"},"type":"array","uniqueItems":false},"target_port":{"description":"Port to expose from the container","type":"integer"},"tools":{"description":"Tools filter","items":{"type":"string"},"type":"array","uniqueItems":false},"tools_override":{"additionalProperties":{"$ref":"#/components/schemas/v1.toolOverride"},"description":"Tools override","type":"object"},"transport":{"description":"Transport configuration","type":"string"},"trust_proxy_headers":{"description":"Whether to trust X-Forwarded-* headers from reverse proxies","type":"boolean"},"url":{"description":"Remote server specific fields","type":"string"},"volumes":{"description":"Volume mounts","items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"v1.createSecretRequest":{"description":"Request to create a new secret","properties":{"key":{"description":"Secret key name","type":"string"},"value":{"description":"Secret value","type":"string"}},"type":"object"},"v1.createSecretResponse":{"description":"Response after creating a secret","properties":{"key":{"description":"Secret key that was created","type":"string"},"message":{"description":"Success message","type":"string"}},"type":"object"},"v1.createWorkloadResponse":{"description":"Response after successfully creating a workload","properties":{"name":{"description":"Name of the created workload","type":"string"},"port":{"description":"Port the workload is listening on","type":"integer"}},"type":"object"},"v1.getRegistryResponse":{"description":"Response containing registry details","properties":{"last_updated":{"description":"Last updated timestamp","type":"string"},"name":{"description":"Name of the registry","type":"string"},"registry":{"$ref":"#/components/schemas/registry.Registry"},"server_count":{"description":"Number of servers in the registry","type":"integer"},"source":{"description":"Source of the registry (URL, file path, or empty string for built-in)","type":"string"},"type":{"description":"Type of registry (file, url, or default)","type":"string","x-enum-varnames":["RegistryTypeFile","RegistryTypeURL","RegistryTypeAPI","RegistryTypeDefault"]},"version":{"description":"Version of the registry schema","type":"string"}},"type":"object"},"v1.getSecretsProviderResponse":{"description":"Response containing secrets provider details","properties":{"capabilities":{"$ref":"#/components/schemas/v1.providerCapabilitiesResponse"},"name":{"description":"Name of the secrets provider","type":"string"},"provider_type":{"description":"Type of the secrets provider","type":"string"}},"type":"object"},"v1.getServerResponse":{"description":"Response containing server details","properties":{"is_remote":{"description":"Indicates if this is a remote server","type":"boolean"},"remote_server":{"$ref":"#/components/schemas/registry.RemoteServerMetadata"},"server":{"$ref":"#/components/schemas/registry.ImageMetadata"}},"type":"object"},"v1.groupListResponse":{"properties":{"groups":{"description":"List of groups","items":{"$ref":"#/components/schemas/groups.Group"},"type":"array","uniqueItems":false}},"type":"object"},"v1.listSecretsResponse":{"description":"Response containing a list of secret keys","properties":{"keys":{"description":"List of secret keys","items":{"$ref":"#/components/schemas/v1.secretKeyResponse"},"type":"array","uniqueItems":false}},"type":"object"},"v1.listServersResponse":{"description":"Response containing a list of servers","properties":{"remote_servers":{"description":"List of remote servers in the registry (if any)","items":{"$ref":"#/components/schemas/registry.RemoteServerMetadata"},"type":"array","uniqueItems":false},"servers":{"description":"List of container servers in the registry","items":{"$ref":"#/components/schemas/registry.ImageMetadata"},"type":"array","uniqueItems":false}},"type":"object"},"v1.oidcOptions":{"description":"OIDC configuration options","properties":{"audience":{"description":"Expected audience","type":"string"},"client_id":{"description":"OAuth2 client ID","type":"string"},"client_secret":{"description":"OAuth2 client secret","type":"string"},"introspection_url":{"description":"Token introspection URL for OIDC","type":"string"},"issuer":{"description":"OIDC issuer URL","type":"string"},"jwks_url":{"description":"JWKS URL for key verification","type":"string"},"scopes":{"description":"OAuth scopes to advertise in well-known endpoint (RFC 9728)","items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"v1.providerCapabilitiesResponse":{"description":"Capabilities of the secrets provider","properties":{"can_cleanup":{"description":"Whether the provider can cleanup all secrets","type":"boolean"},"can_delete":{"description":"Whether the provider can delete secrets","type":"boolean"},"can_list":{"description":"Whether the provider can list secrets","type":"boolean"},"can_read":{"description":"Whether the provider can read secrets","type":"boolean"},"can_write":{"description":"Whether the provider can write secrets","type":"boolean"}},"type":"object"},"v1.registryInfo":{"description":"Basic information about a registry","properties":{"last_updated":{"description":"Last updated timestamp","type":"string"},"name":{"description":"Name of the registry","type":"string"},"server_count":{"description":"Number of servers in the registry","type":"integer"},"source":{"description":"Source of the registry (URL, file path, or empty string for built-in)","type":"string"},"type":{"$ref":"#/components/schemas/v1.RegistryType"},"version":{"description":"Version of the registry schema","type":"string"}},"type":"object"},"v1.registryListResponse":{"description":"Response containing a list of registries","properties":{"registries":{"description":"List of registries","items":{"$ref":"#/components/schemas/v1.registryInfo"},"type":"array","uniqueItems":false}},"type":"object"},"v1.remoteOAuthConfig":{"description":"OAuth configuration for remote server authentication","properties":{"authorize_url":{"description":"OAuth authorization endpoint URL (alternative to issuer for non-OIDC OAuth)","type":"string"},"callback_port":{"description":"Specific port for OAuth callback server","type":"integer"},"client_id":{"description":"OAuth client ID for authentication","type":"string"},"client_secret":{"$ref":"#/components/schemas/secrets.SecretParameter"},"issuer":{"description":"OAuth/OIDC issuer URL (e.g., https://accounts.google.com)","type":"string"},"oauth_params":{"additionalProperties":{"type":"string"},"description":"Additional OAuth parameters for server-specific customization","type":"object"},"resource":{"description":"OAuth 2.0 resource indicator (RFC 8707)","type":"string"},"scopes":{"description":"OAuth scopes to request","items":{"type":"string"},"type":"array","uniqueItems":false},"skip_browser":{"description":"Whether to skip opening browser for OAuth flow (defaults to false)","type":"boolean"},"token_url":{"description":"OAuth token endpoint URL (alternative to issuer for non-OIDC OAuth)","type":"string"},"use_pkce":{"description":"Whether to use PKCE for the OAuth flow","type":"boolean"}},"type":"object"},"v1.secretKeyResponse":{"description":"Secret key information","properties":{"description":{"description":"Optional description of the secret","type":"string"},"key":{"description":"Secret key name","type":"string"}},"type":"object"},"v1.setupSecretsRequest":{"description":"Request to setup a secrets provider","properties":{"password":{"description":"Password for encrypted provider (optional, can be set via environment variable)\nTODO Review environment variable for this","type":"string"},"provider_type":{"description":"Type of the secrets provider (encrypted, 1password, none)","type":"string"}},"type":"object"},"v1.setupSecretsResponse":{"description":"Response after initializing a secrets provider","properties":{"message":{"description":"Success message","type":"string"},"provider_type":{"description":"Type of the secrets provider that was setup","type":"string"}},"type":"object"},"v1.toolOverride":{"description":"Tool override","properties":{"description":{"description":"Description of the tool","type":"string"},"name":{"description":"Name of the tool","type":"string"}},"type":"object"},"v1.updateRequest":{"description":"Request to update an existing workload (name cannot be changed)","properties":{"authz_config":{"description":"Authorization configuration","type":"string"},"cmd_arguments":{"description":"Command arguments to pass to the container","items":{"type":"string"},"type":"array","uniqueItems":false},"env_vars":{"additionalProperties":{"type":"string"},"description":"Environment variables to set in the container","type":"object"},"group":{"description":"Group name this workload belongs to","type":"string"},"headers":{"items":{"$ref":"#/components/schemas/registry.Header"},"type":"array","uniqueItems":false},"host":{"description":"Host to bind to","type":"string"},"image":{"description":"Docker image to use","type":"string"},"network_isolation":{"description":"Whether network isolation is turned on. This applies the rules in the permission profile.","type":"boolean"},"oauth_config":{"$ref":"#/components/schemas/v1.remoteOAuthConfig"},"oidc":{"$ref":"#/components/schemas/v1.oidcOptions"},"permission_profile":{"$ref":"#/components/schemas/permissions.Profile"},"proxy_mode":{"description":"Proxy mode to use","type":"string"},"proxy_port":{"description":"Port for the HTTP proxy to listen on","type":"integer"},"secrets":{"description":"Secret parameters to inject","items":{"$ref":"#/components/schemas/secrets.SecretParameter"},"type":"array","uniqueItems":false},"target_port":{"description":"Port to expose from the container","type":"integer"},"tools":{"description":"Tools filter","items":{"type":"string"},"type":"array","uniqueItems":false},"tools_override":{"additionalProperties":{"$ref":"#/components/schemas/v1.toolOverride"},"description":"Tools override","type":"object"},"transport":{"description":"Transport configuration","type":"string"},"trust_proxy_headers":{"description":"Whether to trust X-Forwarded-* headers from reverse proxies","type":"boolean"},"url":{"description":"Remote server specific fields","type":"string"},"volumes":{"description":"Volume mounts","items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"v1.updateSecretRequest":{"description":"Request to update an existing secret","properties":{"value":{"description":"New secret value","type":"string"}},"type":"object"},"v1.updateSecretResponse":{"description":"Response after updating a secret","properties":{"key":{"description":"Secret key that was updated","type":"string"},"message":{"description":"Success message","type":"string"}},"type":"object"},"v1.versionResponse":{"properties":{"version":{"type":"string"}},"type":"object"},"v1.workloadListResponse":{"description":"Response containing a list of workloads","properties":{"workloads":{"description":"List of container information for each workload","items":{"$ref":"#/components/schemas/core.Workload"},"type":"array","uniqueItems":false}},"type":"object"},"v1.workloadStatusResponse":{"description":"Response containing workload status information","properties":{"status":{"description":"Current status of the workload","type":"string","x-enum-varnames":["WorkloadStatusRunning","WorkloadStatusStopped","WorkloadStatusError","WorkloadStatusStarting","WorkloadStatusStopping","WorkloadStatusUnhealthy","WorkloadStatusRemoving","WorkloadStatusUnknown","WorkloadStatusUnauthenticated"]}},"type":"object"}}}, "info": {"description":"{{escape .Description}}","title":"{{.Title}}","version":"{{.Version}}"}, "externalDocs": {"description":"","url":""}, "paths": {"/api/openapi.json":{"get":{"description":"Returns the OpenAPI specification for the API","responses":{"200":{"content":{"application/json":{"schema":{"type":"object"}}},"description":"OpenAPI specification"}},"summary":"Get OpenAPI specification","tags":["system"]}},"/api/v1beta/clients":{"get":{"description":"List all registered clients in ToolHive","responses":{"200":{"content":{"application/json":{"schema":{"items":{"$ref":"#/components/schemas/client.RegisteredClient"},"type":"array"}}},"description":"OK"}},"summary":"List all clients","tags":["clients"]},"post":{"description":"Register a new client with ToolHive","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/v1.createClientRequest"}}},"description":"Client to register","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/v1.createClientResponse"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"Invalid request"}},"summary":"Register a new client","tags":["clients"]}},"/api/v1beta/clients/register":{"post":{"description":"Register multiple clients with ToolHive","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/v1.bulkClientRequest"}}},"description":"Clients to register","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"items":{"$ref":"#/components/schemas/v1.createClientResponse"},"type":"array"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"Invalid request"}},"summary":"Register multiple clients","tags":["clients"]}},"/api/v1beta/clients/unregister":{"post":{"description":"Unregister multiple clients from ToolHive","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/v1.bulkClientRequest"}}},"description":"Clients to unregister","required":true},"responses":{"204":{"description":"No Content"},"400":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"Invalid request"}},"summary":"Unregister multiple clients","tags":["clients"]}},"/api/v1beta/clients/{name}":{"delete":{"description":"Unregister a client from ToolHive","parameters":[{"description":"Client name to unregister","in":"path","name":"name","required":true,"schema":{"type":"string"}}],"responses":{"204":{"description":"No Content"},"400":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"Invalid request"}},"summary":"Unregister a client","tags":["clients"]}},"/api/v1beta/clients/{name}/groups/{group}":{"delete":{"description":"Unregister a client from a specific group in ToolHive","parameters":[{"description":"Client name to unregister","in":"path","name":"name","required":true,"schema":{"type":"string"}},{"description":"Group name to remove client from","in":"path","name":"group","required":true,"schema":{"type":"string"}}],"responses":{"204":{"description":"No Content"},"400":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"Invalid request"},"404":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"Client or group not found"}},"summary":"Unregister a client from a specific group","tags":["clients"]}},"/api/v1beta/discovery/clients":{"get":{"description":"List all clients compatible with ToolHive and their status","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/v1.clientStatusResponse"}}},"description":"OK"}},"summary":"List all clients status","tags":["discovery"]}},"/api/v1beta/groups":{"get":{"description":"Get a list of all groups","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/v1.groupListResponse"}}},"description":"OK"},"500":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"Internal Server Error"}},"summary":"List all groups","tags":["groups"]},"post":{"description":"Create a new group with the specified name","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/v1.createGroupRequest"}}},"description":"Group creation request","required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/v1.createGroupResponse"}}},"description":"Created"},"400":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"Bad Request"},"409":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"Conflict"},"500":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"Internal Server Error"}},"summary":"Create a new group","tags":["groups"]}},"/api/v1beta/groups/{name}":{"delete":{"description":"Delete a group by name.","parameters":[{"description":"Group name","in":"path","name":"name","required":true,"schema":{"type":"string"}},{"description":"Delete all workloads in the group (default: false, moves workloads to default group)","in":"query","name":"with-workloads","schema":{"type":"boolean"}}],"responses":{"204":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"No Content"},"404":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"Not Found"},"500":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"Internal Server Error"}},"summary":"Delete a group","tags":["groups"]},"get":{"description":"Get details of a specific group","parameters":[{"description":"Group name","in":"path","name":"name","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/groups.Group"}}},"description":"OK"},"404":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"Not Found"},"500":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"Internal Server Error"}},"summary":"Get group details","tags":["groups"]}},"/api/v1beta/registry":{"get":{"description":"Get a list of the current registries","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/v1.registryListResponse"}}},"description":"OK"}},"summary":"List registries","tags":["registry"]},"post":{"description":"Add a new registry","requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"501":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"Not Implemented"}},"summary":"Add a registry","tags":["registry"]}},"/api/v1beta/registry/{name}":{"delete":{"description":"Remove a specific registry","parameters":[{"description":"Registry name","in":"path","name":"name","required":true,"schema":{"type":"string"}}],"responses":{"204":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"No Content"},"404":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"Not Found"}},"summary":"Remove a registry","tags":["registry"]},"get":{"description":"Get details of a specific registry","parameters":[{"description":"Registry name","in":"path","name":"name","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/v1.getRegistryResponse"}}},"description":"OK"},"404":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"Not Found"}},"summary":"Get a registry","tags":["registry"]},"put":{"description":"Update registry URL or local path for the default registry","parameters":[{"description":"Registry name (must be 'default')","in":"path","name":"name","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/v1.UpdateRegistryRequest"}}},"description":"Registry configuration","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/v1.UpdateRegistryResponse"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"Bad Request"},"404":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"Not Found"}},"summary":"Update registry configuration","tags":["registry"]}},"/api/v1beta/registry/{name}/servers":{"get":{"description":"Get a list of servers in a specific registry","parameters":[{"description":"Registry name","in":"path","name":"name","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/v1.listServersResponse"}}},"description":"OK"},"404":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"Not Found"}},"summary":"List servers in a registry","tags":["registry"]}},"/api/v1beta/registry/{name}/servers/{serverName}":{"get":{"description":"Get details of a specific server in a registry","parameters":[{"description":"Registry name","in":"path","name":"name","required":true,"schema":{"type":"string"}},{"description":"ImageMetadata name","in":"path","name":"serverName","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/v1.getServerResponse"}}},"description":"OK"},"404":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"Not Found"}},"summary":"Get a server from a registry","tags":["registry"]}},"/api/v1beta/secrets":{"post":{"description":"Setup the secrets provider with the specified type and configuration.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/v1.setupSecretsRequest"}}},"description":"Setup secrets provider request","required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/v1.setupSecretsResponse"}}},"description":"Created"},"400":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"Bad Request"},"500":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"Internal Server Error"}},"summary":"Setup or reconfigure secrets provider","tags":["secrets"]}},"/api/v1beta/secrets/default":{"get":{"description":"Get details of the default secrets provider","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/v1.getSecretsProviderResponse"}}},"description":"OK"},"404":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"Not Found - Provider not setup"},"500":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"Internal Server Error"}},"summary":"Get secrets provider details","tags":["secrets"]}},"/api/v1beta/secrets/default/keys":{"get":{"description":"Get a list of all secret keys from the default provider","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/v1.listSecretsResponse"}}},"description":"OK"},"404":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"Not Found - Provider not setup"},"405":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"Method Not Allowed - Provider doesn't support listing"},"500":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"Internal Server Error"}},"summary":"List secrets","tags":["secrets"]},"post":{"description":"Create a new secret in the default provider (encrypted provider only)","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/v1.createSecretRequest"}}},"description":"Create secret request","required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/v1.createSecretResponse"}}},"description":"Created"},"400":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"Bad Request"},"404":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"Not Found - Provider not setup"},"405":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"Method Not Allowed - Provider doesn't support writing"},"409":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"Conflict - Secret already exists"},"500":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"Internal Server Error"}},"summary":"Create a new secret","tags":["secrets"]}},"/api/v1beta/secrets/default/keys/{key}":{"delete":{"description":"Delete a secret from the default provider (encrypted provider only)","parameters":[{"description":"Secret key","in":"path","name":"key","required":true,"schema":{"type":"string"}}],"responses":{"204":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"No Content"},"404":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"Not Found - Provider not setup or secret not found"},"405":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"Method Not Allowed - Provider doesn't support deletion"},"500":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"Internal Server Error"}},"summary":"Delete a secret","tags":["secrets"]},"put":{"description":"Update an existing secret in the default provider (encrypted provider only)","parameters":[{"description":"Secret key","in":"path","name":"key","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/v1.updateSecretRequest"}}},"description":"Update secret request","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/v1.updateSecretResponse"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"Bad Request"},"404":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"Not Found - Provider not setup or secret not found"},"405":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"Method Not Allowed - Provider doesn't support writing"},"500":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"Internal Server Error"}},"summary":"Update a secret","tags":["secrets"]}},"/api/v1beta/version":{"get":{"description":"Returns the current version of the server","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/v1.versionResponse"}}},"description":"OK"}},"summary":"Get server version","tags":["version"]}},"/api/v1beta/workloads":{"get":{"description":"Get a list of all running workloads, optionally filtered by group","parameters":[{"description":"List all workloads, including stopped ones","in":"query","name":"all","schema":{"type":"boolean"}},{"description":"Filter workloads by group name","in":"query","name":"group","schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/v1.workloadListResponse"}}},"description":"OK"},"404":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"Group not found"}},"summary":"List all workloads","tags":["workloads"]},"post":{"description":"Create and start a new workload","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/v1.createRequest"}}},"description":"Create workload request","required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/v1.createWorkloadResponse"}}},"description":"Created"},"400":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"Bad Request"},"409":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"Conflict"}},"summary":"Create a new workload","tags":["workloads"]}},"/api/v1beta/workloads/delete":{"post":{"description":"Delete multiple workloads by name or by group","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/v1.bulkOperationRequest"}}},"description":"Bulk delete request (names or group)","required":true},"responses":{"202":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"Accepted"},"400":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"Bad Request"}},"summary":"Delete workloads in bulk","tags":["workloads"]}},"/api/v1beta/workloads/restart":{"post":{"description":"Restart multiple workloads by name or by group","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/v1.bulkOperationRequest"}}},"description":"Bulk restart request (names or group)","required":true},"responses":{"202":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"Accepted"},"400":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"Bad Request"}},"summary":"Restart workloads in bulk","tags":["workloads"]}},"/api/v1beta/workloads/stop":{"post":{"description":"Stop multiple workloads by name or by group","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/v1.bulkOperationRequest"}}},"description":"Bulk stop request (names or group)","required":true},"responses":{"202":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"Accepted"},"400":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"Bad Request"}},"summary":"Stop workloads in bulk","tags":["workloads"]}},"/api/v1beta/workloads/{name}":{"delete":{"description":"Delete a workload","parameters":[{"description":"Workload name","in":"path","name":"name","required":true,"schema":{"type":"string"}}],"responses":{"202":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"Accepted"},"400":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"Bad Request"},"404":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"Not Found"}},"summary":"Delete a workload","tags":["workloads"]},"get":{"description":"Get details of a specific workload","parameters":[{"description":"Workload name","in":"path","name":"name","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/v1.createRequest"}}},"description":"OK"},"404":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"Not Found"}},"summary":"Get workload details","tags":["workloads"]}},"/api/v1beta/workloads/{name}/edit":{"post":{"description":"Update an existing workload configuration","parameters":[{"description":"Workload name","in":"path","name":"name","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/v1.updateRequest"}}},"description":"Update workload request","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/v1.createWorkloadResponse"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"Bad Request"},"404":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"Not Found"}},"summary":"Update workload","tags":["workloads"]}},"/api/v1beta/workloads/{name}/export":{"get":{"description":"Export a workload's run configuration as JSON","parameters":[{"description":"Workload name","in":"path","name":"name","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/runner.RunConfig"}}},"description":"OK"},"404":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"Not Found"}},"summary":"Export workload configuration","tags":["workloads"]}},"/api/v1beta/workloads/{name}/logs":{"get":{"description":"Retrieve at most 100 lines of logs for a specific workload by name.","parameters":[{"description":"Workload name","in":"path","name":"name","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"type":"string"}},"text/plain":{"schema":{"type":"string"}}},"description":"Logs for the specified workload"},"400":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"Invalid workload name"},"404":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"Not Found"}},"summary":"Get logs for a specific workload","tags":["logs"]}},"/api/v1beta/workloads/{name}/proxy-logs":{"get":{"description":"Retrieve proxy logs for a specific workload by name from the file system.","parameters":[{"description":"Workload name","in":"path","name":"name","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"type":"string"}},"text/plain":{"schema":{"type":"string"}}},"description":"Proxy logs for the specified workload"},"400":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"Invalid workload name"},"404":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"Proxy logs not found for workload"}},"summary":"Get proxy logs for a specific workload","tags":["logs"]}},"/api/v1beta/workloads/{name}/restart":{"post":{"description":"Restart a running workload","parameters":[{"description":"Workload name","in":"path","name":"name","required":true,"schema":{"type":"string"}}],"responses":{"202":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"Accepted"},"400":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"Bad Request"},"404":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"Not Found"}},"summary":"Restart a workload","tags":["workloads"]}},"/api/v1beta/workloads/{name}/status":{"get":{"description":"Get the current status of a specific workload","parameters":[{"description":"Workload name","in":"path","name":"name","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/v1.workloadStatusResponse"}}},"description":"OK"},"404":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"Not Found"}},"summary":"Get workload status","tags":["workloads"]}},"/api/v1beta/workloads/{name}/stop":{"post":{"description":"Stop a running workload","parameters":[{"description":"Workload name","in":"path","name":"name","required":true,"schema":{"type":"string"}}],"responses":{"202":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"Accepted"},"400":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"Bad Request"},"404":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"Not Found"}},"summary":"Stop a workload","tags":["workloads"]}},"/health":{"get":{"description":"Check if the API is healthy","responses":{"204":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"No Content"}},"summary":"Health check","tags":["system"]}}}, diff --git a/docs/server/swagger.json b/docs/server/swagger.json index 4f6025268..b289d07d5 100644 --- a/docs/server/swagger.json +++ b/docs/server/swagger.json @@ -1,5 +1,5 @@ { - "components": {"schemas":{"audit.Config":{"description":"AuditConfig contains the audit logging configuration","properties":{"component":{"description":"Component is the component name to use in audit events","type":"string"},"event_types":{"description":"EventTypes specifies which event types to audit. If empty, all events are audited.","items":{"type":"string"},"type":"array","uniqueItems":false},"exclude_event_types":{"description":"ExcludeEventTypes specifies which event types to exclude from auditing.\nThis takes precedence over EventTypes.","items":{"type":"string"},"type":"array","uniqueItems":false},"include_request_data":{"description":"IncludeRequestData determines whether to include request data in audit logs","type":"boolean"},"include_response_data":{"description":"IncludeResponseData determines whether to include response data in audit logs","type":"boolean"},"log_file":{"description":"LogFile specifies the file path for audit logs. If empty, logs to stdout.","type":"string"},"max_data_size":{"description":"MaxDataSize limits the size of request/response data included in audit logs (in bytes)","type":"integer"}},"type":"object"},"auth.TokenValidatorConfig":{"description":"OIDCConfig contains OIDC configuration","properties":{"allowPrivateIP":{"description":"AllowPrivateIP allows JWKS/OIDC endpoints on private IP addresses","type":"boolean"},"audience":{"description":"Audience is the expected audience for the token","type":"string"},"authTokenFile":{"description":"AuthTokenFile is the path to file containing bearer token for authentication","type":"string"},"cacertPath":{"description":"CACertPath is the path to the CA certificate bundle for HTTPS requests","type":"string"},"clientID":{"description":"ClientID is the OIDC client ID","type":"string"},"clientSecret":{"description":"ClientSecret is the optional OIDC client secret for introspection","type":"string"},"insecureAllowHTTP":{"description":"InsecureAllowHTTP allows HTTP (non-HTTPS) OIDC issuers for development/testing\nWARNING: This is insecure and should NEVER be used in production","type":"boolean"},"introspectionURL":{"description":"IntrospectionURL is the optional introspection endpoint for validating tokens","type":"string"},"issuer":{"description":"Issuer is the OIDC issuer URL (e.g., https://accounts.google.com)","type":"string"},"jwksurl":{"description":"JWKSURL is the URL to fetch the JWKS from","type":"string"},"resourceURL":{"description":"ResourceURL is the explicit resource URL for OAuth discovery (RFC 9728)","type":"string"},"scopes":{"description":"Scopes is the list of OAuth scopes to advertise in the well-known endpoint (RFC 9728)\nIf empty, defaults to [\"openid\"]","items":{"type":"string"},"type":"array"}},"type":"object"},"authz.CedarConfig":{"description":"Cedar is the Cedar-specific configuration.\nThis is only used when Type is ConfigTypeCedarV1.","properties":{"entities_json":{"description":"EntitiesJSON is the JSON string representing Cedar entities","type":"string"},"policies":{"description":"Policies is a list of Cedar policy strings","items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"authz.Config":{"description":"AuthzConfig contains the authorization configuration","properties":{"cedar":{"$ref":"#/components/schemas/authz.CedarConfig"},"type":{"$ref":"#/components/schemas/authz.ConfigType"},"version":{"description":"Version is the version of the configuration format.","type":"string"}},"type":"object"},"authz.ConfigType":{"description":"Type is the type of authorization configuration.","type":"string","x-enum-varnames":["ConfigTypeCedarV1"]},"client.MCPClient":{"type":"string","x-enum-varnames":["RooCode","Cline","Cursor","VSCodeInsider","VSCode","ClaudeCode","Windsurf","WindsurfJetBrains","AmpCli","AmpVSCode","AmpCursor","AmpVSCodeInsider","AmpWindsurf","LMStudio","Goose","Trae","Continue","OpenCode","Kiro","Antigravity","Zed"]},"client.MCPClientStatus":{"properties":{"client_type":{"description":"ClientType is the type of MCP client","type":"string","x-enum-varnames":["RooCode","Cline","Cursor","VSCodeInsider","VSCode","ClaudeCode","Windsurf","WindsurfJetBrains","AmpCli","AmpVSCode","AmpCursor","AmpVSCodeInsider","AmpWindsurf","LMStudio","Goose","Trae","Continue","OpenCode","Kiro","Antigravity","Zed"]},"installed":{"description":"Installed indicates whether the client is installed on the system","type":"boolean"},"registered":{"description":"Registered indicates whether the client is registered in the ToolHive configuration","type":"boolean"}},"type":"object"},"client.RegisteredClient":{"properties":{"groups":{"items":{"type":"string"},"type":"array","uniqueItems":false},"name":{"$ref":"#/components/schemas/client.MCPClient"}},"type":"object"},"core.Workload":{"properties":{"created_at":{"description":"CreatedAt is the timestamp when the workload was created.","type":"string"},"group":{"description":"Group is the name of the group this workload belongs to, if any.","type":"string"},"labels":{"additionalProperties":{"type":"string"},"description":"Labels are the container labels (excluding standard ToolHive labels)","type":"object"},"name":{"description":"Name is the name of the workload.\nIt is used as a unique identifier.","type":"string"},"package":{"description":"Package specifies the Workload Package used to create this Workload.","type":"string"},"port":{"description":"Port is the port on which the workload is exposed.\nThis is embedded in the URL.","type":"integer"},"proxy_mode":{"description":"ProxyMode is the proxy mode that clients should use to connect.\nFor stdio transports, this will be the proxy mode (sse or streamable-http).\nFor direct transports (sse/streamable-http), this will be the same as TransportType.","type":"string"},"remote":{"description":"Remote indicates whether this is a remote workload (true) or a container workload (false).","type":"boolean"},"status":{"$ref":"#/components/schemas/runtime.WorkloadStatus"},"status_context":{"description":"StatusContext provides additional context about the workload's status.\nThe exact meaning is determined by the status and the underlying runtime.","type":"string"},"tools":{"description":"ToolsFilter is the filter on tools applied to the workload.","items":{"type":"string"},"type":"array","uniqueItems":false},"transport_type":{"$ref":"#/components/schemas/types.TransportType"},"url":{"description":"URL is the URL of the workload exposed by the ToolHive proxy.","type":"string"}},"type":"object"},"groups.Group":{"properties":{"name":{"type":"string"},"registered_clients":{"items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"ignore.Config":{"description":"IgnoreConfig contains configuration for ignore processing","properties":{"loadGlobal":{"description":"Whether to load global ignore patterns","type":"boolean"},"printOverlays":{"description":"Whether to print resolved overlay paths for debugging","type":"boolean"}},"type":"object"},"permissions.InboundNetworkPermissions":{"description":"Inbound defines inbound network permissions","properties":{"allow_host":{"description":"AllowHost is a list of allowed hosts for inbound connections","items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"permissions.NetworkPermissions":{"description":"Network defines network permissions","properties":{"inbound":{"$ref":"#/components/schemas/permissions.InboundNetworkPermissions"},"mode":{"description":"Mode specifies the network mode for the container (e.g., \"host\", \"bridge\", \"none\")\nWhen empty, the default container runtime network mode is used","type":"string"},"outbound":{"$ref":"#/components/schemas/permissions.OutboundNetworkPermissions"}},"type":"object"},"permissions.OutboundNetworkPermissions":{"description":"Outbound defines outbound network permissions","properties":{"allow_host":{"description":"AllowHost is a list of allowed hosts","items":{"type":"string"},"type":"array","uniqueItems":false},"allow_port":{"description":"AllowPort is a list of allowed ports","items":{"type":"integer"},"type":"array","uniqueItems":false},"insecure_allow_all":{"description":"InsecureAllowAll allows all outbound network connections","type":"boolean"}},"type":"object"},"permissions.Profile":{"description":"PermissionProfile is the permission profile to use","properties":{"name":{"description":"Name is the name of the profile","type":"string"},"network":{"$ref":"#/components/schemas/permissions.NetworkPermissions"},"privileged":{"description":"Privileged indicates whether the container should run in privileged mode\nWhen true, the container has access to all host devices and capabilities\nUse with extreme caution as this removes most security isolation","type":"boolean"},"read":{"description":"Read is a list of mount declarations that the container can read from\nThese can be in the following formats:\n- A single path: The same path will be mounted from host to container\n- host-path:container-path: Different paths for host and container\n- resource-uri:container-path: Mount a resource identified by URI to a container path","items":{"type":"string"},"type":"array","uniqueItems":false},"write":{"description":"Write is a list of mount declarations that the container can write to\nThese follow the same format as Read mounts but with write permissions","items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"registry.EnvVar":{"properties":{"default":{"description":"Default is the value to use if the environment variable is not explicitly provided\nOnly used for non-required variables","type":"string"},"description":{"description":"Description is a human-readable explanation of the variable's purpose","type":"string"},"name":{"description":"Name is the environment variable name (e.g., API_KEY)","type":"string"},"required":{"description":"Required indicates whether this environment variable must be provided\nIf true and not provided via command line or secrets, the user will be prompted for a value","type":"boolean"},"secret":{"description":"Secret indicates whether this environment variable contains sensitive information\nIf true, the value will be stored as a secret rather than as a plain environment variable","type":"boolean"}},"type":"object"},"registry.Group":{"properties":{"description":{"description":"Description is a human-readable description of the group's purpose and functionality","type":"string"},"name":{"description":"Name is the identifier for the group, used when referencing the group in commands","type":"string"},"remote_servers":{"additionalProperties":{"$ref":"#/components/schemas/registry.RemoteServerMetadata"},"description":"RemoteServers is a map of server names to their corresponding remote server definitions within this group","type":"object"},"servers":{"additionalProperties":{"$ref":"#/components/schemas/registry.ImageMetadata"},"description":"Servers is a map of server names to their corresponding server definitions within this group","type":"object"}},"type":"object"},"registry.Header":{"properties":{"choices":{"description":"Choices provides a list of valid values for the header (optional)","items":{"type":"string"},"type":"array","uniqueItems":false},"default":{"description":"Default is the value to use if the header is not explicitly provided\nOnly used for non-required headers","type":"string"},"description":{"description":"Description is a human-readable explanation of the header's purpose","type":"string"},"name":{"description":"Name is the header name (e.g., X-API-Key, Authorization)","type":"string"},"required":{"description":"Required indicates whether this header must be provided\nIf true and not provided via command line or secrets, the user will be prompted for a value","type":"boolean"},"secret":{"description":"Secret indicates whether this header contains sensitive information\nIf true, the value will be stored as a secret rather than as plain text","type":"boolean"}},"type":"object"},"registry.ImageMetadata":{"description":"Container server details (if it's a container server)","properties":{"args":{"description":"Args are the default command-line arguments to pass to the MCP server container.\nThese arguments will be used only if no command-line arguments are provided by the user.\nIf the user provides arguments, they will override these defaults.","items":{"type":"string"},"type":"array","uniqueItems":false},"custom_metadata":{"additionalProperties":{},"description":"CustomMetadata allows for additional user-defined metadata","type":"object"},"description":{"description":"Description is a human-readable description of the server's purpose and functionality","type":"string"},"docker_tags":{"description":"DockerTags lists the available Docker tags for this server image","items":{"type":"string"},"type":"array","uniqueItems":false},"env_vars":{"description":"EnvVars defines environment variables that can be passed to the server","items":{"$ref":"#/components/schemas/registry.EnvVar"},"type":"array","uniqueItems":false},"image":{"description":"Image is the Docker image reference for the MCP server","type":"string"},"metadata":{"$ref":"#/components/schemas/registry.Metadata"},"name":{"description":"Name is the identifier for the MCP server, used when referencing the server in commands\nIf not provided, it will be auto-generated from the registry key","type":"string"},"permissions":{"$ref":"#/components/schemas/permissions.Profile"},"provenance":{"$ref":"#/components/schemas/registry.Provenance"},"repository_url":{"description":"RepositoryURL is the URL to the source code repository for the server","type":"string"},"status":{"description":"Status indicates whether the server is currently active or deprecated","type":"string"},"tags":{"description":"Tags are categorization labels for the server to aid in discovery and filtering","items":{"type":"string"},"type":"array","uniqueItems":false},"target_port":{"description":"TargetPort is the port for the container to expose (only applicable to SSE and Streamable HTTP transports)","type":"integer"},"tier":{"description":"Tier represents the tier classification level of the server, e.g., \"Official\" or \"Community\"","type":"string"},"tools":{"description":"Tools is a list of tool names provided by this MCP server","items":{"type":"string"},"type":"array","uniqueItems":false},"transport":{"description":"Transport defines the communication protocol for the server\nFor containers: stdio, sse, or streamable-http\nFor remote servers: sse or streamable-http (stdio not supported)","type":"string"}},"type":"object"},"registry.Metadata":{"description":"Metadata contains additional information about the server such as popularity metrics","properties":{"last_updated":{"description":"LastUpdated is the timestamp when the server was last updated, in RFC3339 format","type":"string"},"pulls":{"description":"Pulls indicates how many times the server image has been downloaded","type":"integer"},"stars":{"description":"Stars represents the popularity rating or number of stars for the server","type":"integer"}},"type":"object"},"registry.OAuthConfig":{"description":"OAuthConfig provides OAuth/OIDC configuration for authentication to the remote server\nUsed with the thv proxy command's --remote-auth flags","properties":{"authorize_url":{"description":"AuthorizeURL is the OAuth authorization endpoint URL\nUsed for non-OIDC OAuth flows when issuer is not provided","type":"string"},"callback_port":{"description":"CallbackPort is the specific port to use for the OAuth callback server\nIf not specified, a random available port will be used","type":"integer"},"client_id":{"description":"ClientID is the OAuth client ID for authentication","type":"string"},"issuer":{"description":"Issuer is the OAuth/OIDC issuer URL (e.g., https://accounts.google.com)\nUsed for OIDC discovery to find authorization and token endpoints","type":"string"},"oauth_params":{"additionalProperties":{"type":"string"},"description":"OAuthParams contains additional OAuth parameters to include in the authorization request\nThese are server-specific parameters like \"prompt\", \"response_mode\", etc.","type":"object"},"resource":{"description":"Resource is the OAuth 2.0 resource indicator (RFC 8707)","type":"string"},"scopes":{"description":"Scopes are the OAuth scopes to request\nIf not specified, defaults to [\"openid\", \"profile\", \"email\"] for OIDC","items":{"type":"string"},"type":"array","uniqueItems":false},"token_url":{"description":"TokenURL is the OAuth token endpoint URL\nUsed for non-OIDC OAuth flows when issuer is not provided","type":"string"},"use_pkce":{"description":"UsePKCE indicates whether to use PKCE for the OAuth flow\nDefaults to true for enhanced security","type":"boolean"}},"type":"object"},"registry.Provenance":{"description":"Provenance contains verification and signing metadata","properties":{"attestation":{"$ref":"#/components/schemas/registry.VerifiedAttestation"},"cert_issuer":{"type":"string"},"repository_ref":{"type":"string"},"repository_uri":{"type":"string"},"runner_environment":{"type":"string"},"signer_identity":{"type":"string"},"sigstore_url":{"type":"string"}},"type":"object"},"registry.Registry":{"description":"Full registry data","properties":{"groups":{"description":"Groups is a slice of group definitions containing related MCP servers","items":{"$ref":"#/components/schemas/registry.Group"},"type":"array","uniqueItems":false},"last_updated":{"description":"LastUpdated is the timestamp when the registry was last updated, in RFC3339 format","type":"string"},"remote_servers":{"additionalProperties":{"$ref":"#/components/schemas/registry.RemoteServerMetadata"},"description":"RemoteServers is a map of server names to their corresponding remote server definitions\nThese are MCP servers accessed via HTTP/HTTPS using the thv proxy command","type":"object"},"servers":{"additionalProperties":{"$ref":"#/components/schemas/registry.ImageMetadata"},"description":"Servers is a map of server names to their corresponding server definitions","type":"object"},"version":{"description":"Version is the schema version of the registry","type":"string"}},"type":"object"},"registry.RemoteServerMetadata":{"description":"Remote server details (if it's a remote server)","properties":{"custom_metadata":{"additionalProperties":{},"description":"CustomMetadata allows for additional user-defined metadata","type":"object"},"description":{"description":"Description is a human-readable description of the server's purpose and functionality","type":"string"},"env_vars":{"description":"EnvVars defines environment variables that can be passed to configure the client\nThese might be needed for client-side configuration when connecting to the remote server","items":{"$ref":"#/components/schemas/registry.EnvVar"},"type":"array","uniqueItems":false},"headers":{"description":"Headers defines HTTP headers that can be passed to the remote server for authentication\nThese are used with the thv proxy command's authentication features","items":{"$ref":"#/components/schemas/registry.Header"},"type":"array","uniqueItems":false},"metadata":{"$ref":"#/components/schemas/registry.Metadata"},"name":{"description":"Name is the identifier for the MCP server, used when referencing the server in commands\nIf not provided, it will be auto-generated from the registry key","type":"string"},"oauth_config":{"$ref":"#/components/schemas/registry.OAuthConfig"},"repository_url":{"description":"RepositoryURL is the URL to the source code repository for the server","type":"string"},"status":{"description":"Status indicates whether the server is currently active or deprecated","type":"string"},"tags":{"description":"Tags are categorization labels for the server to aid in discovery and filtering","items":{"type":"string"},"type":"array","uniqueItems":false},"tier":{"description":"Tier represents the tier classification level of the server, e.g., \"Official\" or \"Community\"","type":"string"},"tools":{"description":"Tools is a list of tool names provided by this MCP server","items":{"type":"string"},"type":"array","uniqueItems":false},"transport":{"description":"Transport defines the communication protocol for the server\nFor containers: stdio, sse, or streamable-http\nFor remote servers: sse or streamable-http (stdio not supported)","type":"string"},"url":{"description":"URL is the endpoint URL for the remote MCP server (e.g., https://api.example.com/mcp)","type":"string"}},"type":"object"},"registry.VerifiedAttestation":{"properties":{"predicate":{},"predicate_type":{"type":"string"}},"type":"object"},"remote.Config":{"description":"RemoteAuthConfig contains OAuth configuration for remote MCP servers","properties":{"authorize_url":{"type":"string"},"callback_port":{"type":"integer"},"client_id":{"type":"string"},"client_secret":{"type":"string"},"client_secret_file":{"type":"string"},"env_vars":{"description":"Environment variables for the client","items":{"$ref":"#/components/schemas/registry.EnvVar"},"type":"array","uniqueItems":false},"headers":{"description":"Headers for HTTP requests","items":{"$ref":"#/components/schemas/registry.Header"},"type":"array","uniqueItems":false},"issuer":{"description":"OAuth endpoint configuration (from registry)","type":"string"},"oauth_params":{"additionalProperties":{"type":"string"},"description":"OAuth parameters for server-specific customization","type":"object"},"resource":{"description":"Resource is the OAuth 2.0 resource indicator (RFC 8707).","type":"string"},"scopes":{"items":{"type":"string"},"type":"array","uniqueItems":false},"skip_browser":{"type":"boolean"},"timeout":{"example":"5m","type":"string"},"token_url":{"type":"string"},"use_pkce":{"type":"boolean"}},"type":"object"},"runner.RunConfig":{"properties":{"audit_config":{"$ref":"#/components/schemas/audit.Config"},"audit_config_path":{"description":"AuditConfigPath is the path to the audit configuration file","type":"string"},"authz_config":{"$ref":"#/components/schemas/authz.Config"},"authz_config_path":{"description":"AuthzConfigPath is the path to the authorization configuration file","type":"string"},"base_name":{"description":"BaseName is the base name used for the container (without prefixes)","type":"string"},"cmd_args":{"description":"CmdArgs are the arguments to pass to the container","items":{"type":"string"},"type":"array","uniqueItems":false},"container_labels":{"additionalProperties":{"type":"string"},"description":"ContainerLabels are the labels to apply to the container","type":"object"},"container_name":{"description":"ContainerName is the name of the container","type":"string"},"debug":{"description":"Debug indicates whether debug mode is enabled","type":"boolean"},"env_file_dir":{"description":"EnvFileDir is the directory path to load environment files from","type":"string"},"env_vars":{"additionalProperties":{"type":"string"},"description":"EnvVars are the parsed environment variables as key-value pairs","type":"object"},"group":{"description":"Group is the name of the group this workload belongs to, if any","type":"string"},"host":{"description":"Host is the host for the HTTP proxy","type":"string"},"ignore_config":{"$ref":"#/components/schemas/ignore.Config"},"image":{"description":"Image is the Docker image to run","type":"string"},"isolate_network":{"description":"IsolateNetwork indicates whether to isolate the network for the container","type":"boolean"},"jwks_auth_token_file":{"description":"JWKSAuthTokenFile is the path to file containing auth token for JWKS/OIDC requests","type":"string"},"k8s_pod_template_patch":{"description":"K8sPodTemplatePatch is a JSON string to patch the Kubernetes pod template\nOnly applicable when using Kubernetes runtime","type":"string"},"middleware_configs":{"description":"MiddlewareConfigs contains the list of middleware to apply to the transport\nand the configuration for each middleware.","items":{"$ref":"#/components/schemas/types.MiddlewareConfig"},"type":"array","uniqueItems":false},"name":{"description":"Name is the name of the MCP server","type":"string"},"oidc_config":{"$ref":"#/components/schemas/auth.TokenValidatorConfig"},"permission_profile":{"$ref":"#/components/schemas/permissions.Profile"},"permission_profile_name_or_path":{"description":"PermissionProfileNameOrPath is the name or path of the permission profile","type":"string"},"port":{"description":"Port is the port for the HTTP proxy to listen on (host port)","type":"integer"},"proxy_mode":{"$ref":"#/components/schemas/types.ProxyMode"},"remote_auth_config":{"$ref":"#/components/schemas/remote.Config"},"remote_url":{"description":"RemoteURL is the URL of the remote MCP server (if running remotely)","type":"string"},"schema_version":{"description":"SchemaVersion is the version of the RunConfig schema","type":"string"},"secrets":{"description":"Secrets are the secret parameters to pass to the container\nFormat: \"\u003csecret name\u003e,target=\u003ctarget environment variable\u003e\"","items":{"type":"string"},"type":"array","uniqueItems":false},"target_host":{"description":"TargetHost is the host to forward traffic to (only applicable to SSE transport)","type":"string"},"target_port":{"description":"TargetPort is the port for the container to expose (only applicable to SSE transport)","type":"integer"},"telemetry_config":{"$ref":"#/components/schemas/telemetry.Config"},"thv_ca_bundle":{"description":"ThvCABundle is the path to the CA certificate bundle for ToolHive HTTP operations","type":"string"},"token_exchange_config":{"$ref":"#/components/schemas/tokenexchange.Config"},"tools_filter":{"description":"ToolsFilter is the list of tools to filter","items":{"type":"string"},"type":"array","uniqueItems":false},"tools_override":{"additionalProperties":{"$ref":"#/components/schemas/runner.ToolOverride"},"description":"ToolsOverride is a map from an actual tool to its overridden name and/or description","type":"object"},"transport":{"description":"Transport is the transport mode (stdio, sse, or streamable-http)","type":"string","x-enum-varnames":["TransportTypeStdio","TransportTypeSSE","TransportTypeStreamableHTTP","TransportTypeInspector"]},"trust_proxy_headers":{"description":"TrustProxyHeaders indicates whether to trust X-Forwarded-* headers from reverse proxies","type":"boolean"},"volumes":{"description":"Volumes are the directory mounts to pass to the container\nFormat: \"host-path:container-path[:ro]\"","items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"runner.ToolOverride":{"properties":{"description":{"description":"Description is the redefined description of the tool","type":"string"},"name":{"description":"Name is the redefined name of the tool","type":"string"}},"type":"object"},"runtime.WorkloadStatus":{"description":"Status is the current status of the workload.","type":"string","x-enum-varnames":["WorkloadStatusRunning","WorkloadStatusStopped","WorkloadStatusError","WorkloadStatusStarting","WorkloadStatusStopping","WorkloadStatusUnhealthy","WorkloadStatusRemoving","WorkloadStatusUnknown","WorkloadStatusUnauthenticated"]},"secrets.SecretParameter":{"properties":{"name":{"type":"string"},"target":{"type":"string"}},"type":"object"},"telemetry.Config":{"description":"TelemetryConfig contains the OpenTelemetry configuration","properties":{"customAttributes":{"additionalProperties":{"type":"string"},"description":"CustomAttributes contains custom resource attributes to be added to all telemetry signals.\nThese are parsed from CLI flags (--otel-custom-attributes) or environment variables\n(OTEL_RESOURCE_ATTRIBUTES) as key=value pairs.\nWe use map[string]string for proper JSON serialization instead of []attribute.KeyValue\nwhich doesn't marshal/unmarshal correctly.","type":"object"},"enablePrometheusMetricsPath":{"description":"EnablePrometheusMetricsPath controls whether to expose Prometheus-style /metrics endpoint\nThe metrics are served on the main transport port at /metrics\nThis is separate from OTLP metrics which are sent to the Endpoint","type":"boolean"},"endpoint":{"description":"Endpoint is the OTLP endpoint URL","type":"string"},"environmentVariables":{"description":"EnvironmentVariables is a list of environment variable names that should be\nincluded in telemetry spans as attributes. Only variables in this list will\nbe read from the host machine and included in spans for observability.\nExample: []string{\"NODE_ENV\", \"DEPLOYMENT_ENV\", \"SERVICE_VERSION\"}","items":{"type":"string"},"type":"array","uniqueItems":false},"headers":{"additionalProperties":{"type":"string"},"description":"Headers contains authentication headers for the OTLP endpoint","type":"object"},"insecure":{"description":"Insecure indicates whether to use HTTP instead of HTTPS for the OTLP endpoint","type":"boolean"},"metricsEnabled":{"description":"MetricsEnabled controls whether OTLP metrics are enabled\nWhen false, OTLP metrics are not sent even if an endpoint is configured\nThis is independent of EnablePrometheusMetricsPath","type":"boolean"},"samplingRate":{"description":"SamplingRate is the trace sampling rate (0.0-1.0)\nOnly used when TracingEnabled is true","type":"number"},"serviceName":{"description":"ServiceName is the service name for telemetry","type":"string"},"serviceVersion":{"description":"ServiceVersion is the service version for telemetry","type":"string"},"tracingEnabled":{"description":"TracingEnabled controls whether distributed tracing is enabled\nWhen false, no tracer provider is created even if an endpoint is configured","type":"boolean"}},"type":"object"},"tokenexchange.Config":{"description":"TokenExchangeConfig contains token exchange configuration for external authentication","properties":{"audience":{"description":"Audience is the target audience for the exchanged token","type":"string"},"client_id":{"description":"ClientID is the OAuth 2.0 client identifier","type":"string"},"client_secret":{"description":"ClientSecret is the OAuth 2.0 client secret","type":"string"},"external_token_header_name":{"description":"ExternalTokenHeaderName is the name of the custom header to use when HeaderStrategy is \"custom\"","type":"string"},"header_strategy":{"description":"HeaderStrategy determines how to inject the token\nValid values: HeaderStrategyReplace (default), HeaderStrategyCustom","type":"string"},"scopes":{"description":"Scopes is the list of scopes to request for the exchanged token","items":{"type":"string"},"type":"array","uniqueItems":false},"subject_token_type":{"description":"SubjectTokenType specifies the type of the subject token being exchanged.\nCommon values: tokenTypeAccessToken (default), tokenTypeIDToken, tokenTypeJWT.\nIf empty, defaults to tokenTypeAccessToken.","type":"string"},"token_url":{"description":"TokenURL is the OAuth 2.0 token endpoint URL","type":"string"}},"type":"object"},"types.MiddlewareConfig":{"properties":{"parameters":{"description":"Parameters is a JSON object containing the middleware parameters.\nIt is stored as a raw message to allow flexible parameter types.","type":"object"},"type":{"description":"Type is a string representing the middleware type.","type":"string"}},"type":"object"},"types.ProxyMode":{"description":"ProxyMode is the proxy mode for stdio transport (\"sse\" or \"streamable-http\")\nNote: \"sse\" is deprecated; use \"streamable-http\" instead.","type":"string","x-enum-varnames":["ProxyModeSSE","ProxyModeStreamableHTTP"]},"types.TransportType":{"description":"TransportType is the type of transport used for this workload.","type":"string","x-enum-varnames":["TransportTypeStdio","TransportTypeSSE","TransportTypeStreamableHTTP","TransportTypeInspector"]},"v1.RegistryType":{"description":"Type of registry (file, url, or default)","type":"string","x-enum-varnames":["RegistryTypeFile","RegistryTypeURL","RegistryTypeAPI","RegistryTypeDefault"]},"v1.UpdateRegistryRequest":{"description":"Request containing registry configuration updates","properties":{"allow_private_ip":{"description":"Allow private IP addresses for registry URL or API URL","type":"boolean"},"api_url":{"description":"MCP Registry API URL","type":"string"},"local_path":{"description":"Local registry file path","type":"string"},"url":{"description":"Registry URL (for remote registries)","type":"string"}},"type":"object"},"v1.UpdateRegistryResponse":{"description":"Response containing update result","properties":{"message":{"description":"Status message","type":"string"},"type":{"description":"Registry type after update","type":"string"}},"type":"object"},"v1.bulkClientRequest":{"properties":{"groups":{"description":"Groups is the list of groups configured on the client.","items":{"type":"string"},"type":"array","uniqueItems":false},"names":{"description":"Names is the list of client names to operate on.","items":{"type":"string","x-enum-varnames":["RooCode","Cline","Cursor","VSCodeInsider","VSCode","ClaudeCode","Windsurf","WindsurfJetBrains","AmpCli","AmpVSCode","AmpCursor","AmpVSCodeInsider","AmpWindsurf","LMStudio","Goose","Trae","Continue","OpenCode","Kiro","Antigravity","Zed"]},"type":"array","uniqueItems":false}},"type":"object"},"v1.bulkOperationRequest":{"properties":{"group":{"description":"Group name to operate on (mutually exclusive with names)","type":"string"},"names":{"description":"Names of the workloads to operate on","items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"v1.clientStatusResponse":{"properties":{"clients":{"items":{"$ref":"#/components/schemas/client.MCPClientStatus"},"type":"array","uniqueItems":false}},"type":"object"},"v1.createClientRequest":{"properties":{"groups":{"description":"Groups is the list of groups configured on the client.","items":{"type":"string"},"type":"array","uniqueItems":false},"name":{"description":"Name is the type of the client to register.","type":"string","x-enum-varnames":["RooCode","Cline","Cursor","VSCodeInsider","VSCode","ClaudeCode","Windsurf","WindsurfJetBrains","AmpCli","AmpVSCode","AmpCursor","AmpVSCodeInsider","AmpWindsurf","LMStudio","Goose","Trae","Continue","OpenCode","Kiro","Antigravity","Zed"]}},"type":"object"},"v1.createClientResponse":{"properties":{"groups":{"description":"Groups is the list of groups configured on the client.","items":{"type":"string"},"type":"array","uniqueItems":false},"name":{"description":"Name is the type of the client that was registered.","type":"string","x-enum-varnames":["RooCode","Cline","Cursor","VSCodeInsider","VSCode","ClaudeCode","Windsurf","WindsurfJetBrains","AmpCli","AmpVSCode","AmpCursor","AmpVSCodeInsider","AmpWindsurf","LMStudio","Goose","Trae","Continue","OpenCode","Kiro","Antigravity","Zed"]}},"type":"object"},"v1.createGroupRequest":{"properties":{"name":{"description":"Name of the group to create","type":"string"}},"type":"object"},"v1.createGroupResponse":{"properties":{"name":{"description":"Name of the created group","type":"string"}},"type":"object"},"v1.createRequest":{"description":"Request to create a new workload","properties":{"authz_config":{"description":"Authorization configuration","type":"string"},"cmd_arguments":{"description":"Command arguments to pass to the container","items":{"type":"string"},"type":"array","uniqueItems":false},"env_vars":{"additionalProperties":{"type":"string"},"description":"Environment variables to set in the container","type":"object"},"group":{"description":"Group name this workload belongs to","type":"string"},"headers":{"items":{"$ref":"#/components/schemas/registry.Header"},"type":"array","uniqueItems":false},"host":{"description":"Host to bind to","type":"string"},"image":{"description":"Docker image to use","type":"string"},"name":{"description":"Name of the workload","type":"string"},"network_isolation":{"description":"Whether network isolation is turned on. This applies the rules in the permission profile.","type":"boolean"},"oauth_config":{"$ref":"#/components/schemas/v1.remoteOAuthConfig"},"oidc":{"$ref":"#/components/schemas/v1.oidcOptions"},"permission_profile":{"$ref":"#/components/schemas/permissions.Profile"},"proxy_mode":{"description":"Proxy mode to use","type":"string"},"proxy_port":{"description":"Port for the HTTP proxy to listen on","type":"integer"},"secrets":{"description":"Secret parameters to inject","items":{"$ref":"#/components/schemas/secrets.SecretParameter"},"type":"array","uniqueItems":false},"target_port":{"description":"Port to expose from the container","type":"integer"},"tools":{"description":"Tools filter","items":{"type":"string"},"type":"array","uniqueItems":false},"tools_override":{"additionalProperties":{"$ref":"#/components/schemas/v1.toolOverride"},"description":"Tools override","type":"object"},"transport":{"description":"Transport configuration","type":"string"},"trust_proxy_headers":{"description":"Whether to trust X-Forwarded-* headers from reverse proxies","type":"boolean"},"url":{"description":"Remote server specific fields","type":"string"},"volumes":{"description":"Volume mounts","items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"v1.createSecretRequest":{"description":"Request to create a new secret","properties":{"key":{"description":"Secret key name","type":"string"},"value":{"description":"Secret value","type":"string"}},"type":"object"},"v1.createSecretResponse":{"description":"Response after creating a secret","properties":{"key":{"description":"Secret key that was created","type":"string"},"message":{"description":"Success message","type":"string"}},"type":"object"},"v1.createWorkloadResponse":{"description":"Response after successfully creating a workload","properties":{"name":{"description":"Name of the created workload","type":"string"},"port":{"description":"Port the workload is listening on","type":"integer"}},"type":"object"},"v1.getRegistryResponse":{"description":"Response containing registry details","properties":{"last_updated":{"description":"Last updated timestamp","type":"string"},"name":{"description":"Name of the registry","type":"string"},"registry":{"$ref":"#/components/schemas/registry.Registry"},"server_count":{"description":"Number of servers in the registry","type":"integer"},"source":{"description":"Source of the registry (URL, file path, or empty string for built-in)","type":"string"},"type":{"description":"Type of registry (file, url, or default)","type":"string","x-enum-varnames":["RegistryTypeFile","RegistryTypeURL","RegistryTypeAPI","RegistryTypeDefault"]},"version":{"description":"Version of the registry schema","type":"string"}},"type":"object"},"v1.getSecretsProviderResponse":{"description":"Response containing secrets provider details","properties":{"capabilities":{"$ref":"#/components/schemas/v1.providerCapabilitiesResponse"},"name":{"description":"Name of the secrets provider","type":"string"},"provider_type":{"description":"Type of the secrets provider","type":"string"}},"type":"object"},"v1.getServerResponse":{"description":"Response containing server details","properties":{"is_remote":{"description":"Indicates if this is a remote server","type":"boolean"},"remote_server":{"$ref":"#/components/schemas/registry.RemoteServerMetadata"},"server":{"$ref":"#/components/schemas/registry.ImageMetadata"}},"type":"object"},"v1.groupListResponse":{"properties":{"groups":{"description":"List of groups","items":{"$ref":"#/components/schemas/groups.Group"},"type":"array","uniqueItems":false}},"type":"object"},"v1.listSecretsResponse":{"description":"Response containing a list of secret keys","properties":{"keys":{"description":"List of secret keys","items":{"$ref":"#/components/schemas/v1.secretKeyResponse"},"type":"array","uniqueItems":false}},"type":"object"},"v1.listServersResponse":{"description":"Response containing a list of servers","properties":{"remote_servers":{"description":"List of remote servers in the registry (if any)","items":{"$ref":"#/components/schemas/registry.RemoteServerMetadata"},"type":"array","uniqueItems":false},"servers":{"description":"List of container servers in the registry","items":{"$ref":"#/components/schemas/registry.ImageMetadata"},"type":"array","uniqueItems":false}},"type":"object"},"v1.oidcOptions":{"description":"OIDC configuration options","properties":{"audience":{"description":"Expected audience","type":"string"},"client_id":{"description":"OAuth2 client ID","type":"string"},"client_secret":{"description":"OAuth2 client secret","type":"string"},"introspection_url":{"description":"Token introspection URL for OIDC","type":"string"},"issuer":{"description":"OIDC issuer URL","type":"string"},"jwks_url":{"description":"JWKS URL for key verification","type":"string"},"scopes":{"description":"OAuth scopes to advertise in well-known endpoint (RFC 9728)","items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"v1.providerCapabilitiesResponse":{"description":"Capabilities of the secrets provider","properties":{"can_cleanup":{"description":"Whether the provider can cleanup all secrets","type":"boolean"},"can_delete":{"description":"Whether the provider can delete secrets","type":"boolean"},"can_list":{"description":"Whether the provider can list secrets","type":"boolean"},"can_read":{"description":"Whether the provider can read secrets","type":"boolean"},"can_write":{"description":"Whether the provider can write secrets","type":"boolean"}},"type":"object"},"v1.registryInfo":{"description":"Basic information about a registry","properties":{"last_updated":{"description":"Last updated timestamp","type":"string"},"name":{"description":"Name of the registry","type":"string"},"server_count":{"description":"Number of servers in the registry","type":"integer"},"source":{"description":"Source of the registry (URL, file path, or empty string for built-in)","type":"string"},"type":{"$ref":"#/components/schemas/v1.RegistryType"},"version":{"description":"Version of the registry schema","type":"string"}},"type":"object"},"v1.registryListResponse":{"description":"Response containing a list of registries","properties":{"registries":{"description":"List of registries","items":{"$ref":"#/components/schemas/v1.registryInfo"},"type":"array","uniqueItems":false}},"type":"object"},"v1.remoteOAuthConfig":{"description":"OAuth configuration for remote server authentication","properties":{"authorize_url":{"description":"OAuth authorization endpoint URL (alternative to issuer for non-OIDC OAuth)","type":"string"},"callback_port":{"description":"Specific port for OAuth callback server","type":"integer"},"client_id":{"description":"OAuth client ID for authentication","type":"string"},"client_secret":{"$ref":"#/components/schemas/secrets.SecretParameter"},"issuer":{"description":"OAuth/OIDC issuer URL (e.g., https://accounts.google.com)","type":"string"},"oauth_params":{"additionalProperties":{"type":"string"},"description":"Additional OAuth parameters for server-specific customization","type":"object"},"resource":{"description":"OAuth 2.0 resource indicator (RFC 8707)","type":"string"},"scopes":{"description":"OAuth scopes to request","items":{"type":"string"},"type":"array","uniqueItems":false},"skip_browser":{"description":"Whether to skip opening browser for OAuth flow (defaults to false)","type":"boolean"},"token_url":{"description":"OAuth token endpoint URL (alternative to issuer for non-OIDC OAuth)","type":"string"},"use_pkce":{"description":"Whether to use PKCE for the OAuth flow","type":"boolean"}},"type":"object"},"v1.secretKeyResponse":{"description":"Secret key information","properties":{"description":{"description":"Optional description of the secret","type":"string"},"key":{"description":"Secret key name","type":"string"}},"type":"object"},"v1.setupSecretsRequest":{"description":"Request to setup a secrets provider","properties":{"password":{"description":"Password for encrypted provider (optional, can be set via environment variable)\nTODO Review environment variable for this","type":"string"},"provider_type":{"description":"Type of the secrets provider (encrypted, 1password, none)","type":"string"}},"type":"object"},"v1.setupSecretsResponse":{"description":"Response after initializing a secrets provider","properties":{"message":{"description":"Success message","type":"string"},"provider_type":{"description":"Type of the secrets provider that was setup","type":"string"}},"type":"object"},"v1.toolOverride":{"description":"Tool override","properties":{"description":{"description":"Description of the tool","type":"string"},"name":{"description":"Name of the tool","type":"string"}},"type":"object"},"v1.updateRequest":{"description":"Request to update an existing workload (name cannot be changed)","properties":{"authz_config":{"description":"Authorization configuration","type":"string"},"cmd_arguments":{"description":"Command arguments to pass to the container","items":{"type":"string"},"type":"array","uniqueItems":false},"env_vars":{"additionalProperties":{"type":"string"},"description":"Environment variables to set in the container","type":"object"},"group":{"description":"Group name this workload belongs to","type":"string"},"headers":{"items":{"$ref":"#/components/schemas/registry.Header"},"type":"array","uniqueItems":false},"host":{"description":"Host to bind to","type":"string"},"image":{"description":"Docker image to use","type":"string"},"network_isolation":{"description":"Whether network isolation is turned on. This applies the rules in the permission profile.","type":"boolean"},"oauth_config":{"$ref":"#/components/schemas/v1.remoteOAuthConfig"},"oidc":{"$ref":"#/components/schemas/v1.oidcOptions"},"permission_profile":{"$ref":"#/components/schemas/permissions.Profile"},"proxy_mode":{"description":"Proxy mode to use","type":"string"},"proxy_port":{"description":"Port for the HTTP proxy to listen on","type":"integer"},"secrets":{"description":"Secret parameters to inject","items":{"$ref":"#/components/schemas/secrets.SecretParameter"},"type":"array","uniqueItems":false},"target_port":{"description":"Port to expose from the container","type":"integer"},"tools":{"description":"Tools filter","items":{"type":"string"},"type":"array","uniqueItems":false},"tools_override":{"additionalProperties":{"$ref":"#/components/schemas/v1.toolOverride"},"description":"Tools override","type":"object"},"transport":{"description":"Transport configuration","type":"string"},"trust_proxy_headers":{"description":"Whether to trust X-Forwarded-* headers from reverse proxies","type":"boolean"},"url":{"description":"Remote server specific fields","type":"string"},"volumes":{"description":"Volume mounts","items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"v1.updateSecretRequest":{"description":"Request to update an existing secret","properties":{"value":{"description":"New secret value","type":"string"}},"type":"object"},"v1.updateSecretResponse":{"description":"Response after updating a secret","properties":{"key":{"description":"Secret key that was updated","type":"string"},"message":{"description":"Success message","type":"string"}},"type":"object"},"v1.versionResponse":{"properties":{"version":{"type":"string"}},"type":"object"},"v1.workloadListResponse":{"description":"Response containing a list of workloads","properties":{"workloads":{"description":"List of container information for each workload","items":{"$ref":"#/components/schemas/core.Workload"},"type":"array","uniqueItems":false}},"type":"object"},"v1.workloadStatusResponse":{"description":"Response containing workload status information","properties":{"status":{"description":"Current status of the workload","type":"string","x-enum-varnames":["WorkloadStatusRunning","WorkloadStatusStopped","WorkloadStatusError","WorkloadStatusStarting","WorkloadStatusStopping","WorkloadStatusUnhealthy","WorkloadStatusRemoving","WorkloadStatusUnknown","WorkloadStatusUnauthenticated"]}},"type":"object"}}}, + "components": {"schemas":{"audit.Config":{"description":"AuditConfig contains the audit logging configuration","properties":{"component":{"description":"Component is the component name to use in audit events","type":"string"},"event_types":{"description":"EventTypes specifies which event types to audit. If empty, all events are audited.","items":{"type":"string"},"type":"array","uniqueItems":false},"exclude_event_types":{"description":"ExcludeEventTypes specifies which event types to exclude from auditing.\nThis takes precedence over EventTypes.","items":{"type":"string"},"type":"array","uniqueItems":false},"include_request_data":{"description":"IncludeRequestData determines whether to include request data in audit logs","type":"boolean"},"include_response_data":{"description":"IncludeResponseData determines whether to include response data in audit logs","type":"boolean"},"log_file":{"description":"LogFile specifies the file path for audit logs. If empty, logs to stdout.","type":"string"},"max_data_size":{"description":"MaxDataSize limits the size of request/response data included in audit logs (in bytes)","type":"integer"}},"type":"object"},"auth.TokenValidatorConfig":{"description":"OIDCConfig contains OIDC configuration","properties":{"allowPrivateIP":{"description":"AllowPrivateIP allows JWKS/OIDC endpoints on private IP addresses","type":"boolean"},"audience":{"description":"Audience is the expected audience for the token","type":"string"},"authTokenFile":{"description":"AuthTokenFile is the path to file containing bearer token for authentication","type":"string"},"cacertPath":{"description":"CACertPath is the path to the CA certificate bundle for HTTPS requests","type":"string"},"clientID":{"description":"ClientID is the OIDC client ID","type":"string"},"clientSecret":{"description":"ClientSecret is the optional OIDC client secret for introspection","type":"string"},"insecureAllowHTTP":{"description":"InsecureAllowHTTP allows HTTP (non-HTTPS) OIDC issuers for development/testing\nWARNING: This is insecure and should NEVER be used in production","type":"boolean"},"introspectionURL":{"description":"IntrospectionURL is the optional introspection endpoint for validating tokens","type":"string"},"issuer":{"description":"Issuer is the OIDC issuer URL (e.g., https://accounts.google.com)","type":"string"},"jwksurl":{"description":"JWKSURL is the URL to fetch the JWKS from","type":"string"},"resourceURL":{"description":"ResourceURL is the explicit resource URL for OAuth discovery (RFC 9728)","type":"string"},"scopes":{"description":"Scopes is the list of OAuth scopes to advertise in the well-known endpoint (RFC 9728)\nIf empty, defaults to [\"openid\"]","items":{"type":"string"},"type":"array"}},"type":"object"},"authz.Config":{"description":"AuthzConfig contains the authorization configuration","properties":{"type":{"description":"Type is the type of authorization configuration (e.g., \"cedarv1\").","type":"string"},"version":{"description":"Version is the version of the configuration format.","type":"string"}},"type":"object"},"client.MCPClient":{"type":"string","x-enum-varnames":["RooCode","Cline","Cursor","VSCodeInsider","VSCode","ClaudeCode","Windsurf","WindsurfJetBrains","AmpCli","AmpVSCode","AmpCursor","AmpVSCodeInsider","AmpWindsurf","LMStudio","Goose","Trae","Continue","OpenCode","Kiro","Antigravity","Zed"]},"client.MCPClientStatus":{"properties":{"client_type":{"description":"ClientType is the type of MCP client","type":"string","x-enum-varnames":["RooCode","Cline","Cursor","VSCodeInsider","VSCode","ClaudeCode","Windsurf","WindsurfJetBrains","AmpCli","AmpVSCode","AmpCursor","AmpVSCodeInsider","AmpWindsurf","LMStudio","Goose","Trae","Continue","OpenCode","Kiro","Antigravity","Zed"]},"installed":{"description":"Installed indicates whether the client is installed on the system","type":"boolean"},"registered":{"description":"Registered indicates whether the client is registered in the ToolHive configuration","type":"boolean"}},"type":"object"},"client.RegisteredClient":{"properties":{"groups":{"items":{"type":"string"},"type":"array","uniqueItems":false},"name":{"$ref":"#/components/schemas/client.MCPClient"}},"type":"object"},"core.Workload":{"properties":{"created_at":{"description":"CreatedAt is the timestamp when the workload was created.","type":"string"},"group":{"description":"Group is the name of the group this workload belongs to, if any.","type":"string"},"labels":{"additionalProperties":{"type":"string"},"description":"Labels are the container labels (excluding standard ToolHive labels)","type":"object"},"name":{"description":"Name is the name of the workload.\nIt is used as a unique identifier.","type":"string"},"package":{"description":"Package specifies the Workload Package used to create this Workload.","type":"string"},"port":{"description":"Port is the port on which the workload is exposed.\nThis is embedded in the URL.","type":"integer"},"proxy_mode":{"description":"ProxyMode is the proxy mode that clients should use to connect.\nFor stdio transports, this will be the proxy mode (sse or streamable-http).\nFor direct transports (sse/streamable-http), this will be the same as TransportType.","type":"string"},"remote":{"description":"Remote indicates whether this is a remote workload (true) or a container workload (false).","type":"boolean"},"status":{"$ref":"#/components/schemas/runtime.WorkloadStatus"},"status_context":{"description":"StatusContext provides additional context about the workload's status.\nThe exact meaning is determined by the status and the underlying runtime.","type":"string"},"tools":{"description":"ToolsFilter is the filter on tools applied to the workload.","items":{"type":"string"},"type":"array","uniqueItems":false},"transport_type":{"$ref":"#/components/schemas/types.TransportType"},"url":{"description":"URL is the URL of the workload exposed by the ToolHive proxy.","type":"string"}},"type":"object"},"groups.Group":{"properties":{"name":{"type":"string"},"registered_clients":{"items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"ignore.Config":{"description":"IgnoreConfig contains configuration for ignore processing","properties":{"loadGlobal":{"description":"Whether to load global ignore patterns","type":"boolean"},"printOverlays":{"description":"Whether to print resolved overlay paths for debugging","type":"boolean"}},"type":"object"},"permissions.InboundNetworkPermissions":{"description":"Inbound defines inbound network permissions","properties":{"allow_host":{"description":"AllowHost is a list of allowed hosts for inbound connections","items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"permissions.NetworkPermissions":{"description":"Network defines network permissions","properties":{"inbound":{"$ref":"#/components/schemas/permissions.InboundNetworkPermissions"},"mode":{"description":"Mode specifies the network mode for the container (e.g., \"host\", \"bridge\", \"none\")\nWhen empty, the default container runtime network mode is used","type":"string"},"outbound":{"$ref":"#/components/schemas/permissions.OutboundNetworkPermissions"}},"type":"object"},"permissions.OutboundNetworkPermissions":{"description":"Outbound defines outbound network permissions","properties":{"allow_host":{"description":"AllowHost is a list of allowed hosts","items":{"type":"string"},"type":"array","uniqueItems":false},"allow_port":{"description":"AllowPort is a list of allowed ports","items":{"type":"integer"},"type":"array","uniqueItems":false},"insecure_allow_all":{"description":"InsecureAllowAll allows all outbound network connections","type":"boolean"}},"type":"object"},"permissions.Profile":{"description":"PermissionProfile is the permission profile to use","properties":{"name":{"description":"Name is the name of the profile","type":"string"},"network":{"$ref":"#/components/schemas/permissions.NetworkPermissions"},"privileged":{"description":"Privileged indicates whether the container should run in privileged mode\nWhen true, the container has access to all host devices and capabilities\nUse with extreme caution as this removes most security isolation","type":"boolean"},"read":{"description":"Read is a list of mount declarations that the container can read from\nThese can be in the following formats:\n- A single path: The same path will be mounted from host to container\n- host-path:container-path: Different paths for host and container\n- resource-uri:container-path: Mount a resource identified by URI to a container path","items":{"type":"string"},"type":"array","uniqueItems":false},"write":{"description":"Write is a list of mount declarations that the container can write to\nThese follow the same format as Read mounts but with write permissions","items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"registry.EnvVar":{"properties":{"default":{"description":"Default is the value to use if the environment variable is not explicitly provided\nOnly used for non-required variables","type":"string"},"description":{"description":"Description is a human-readable explanation of the variable's purpose","type":"string"},"name":{"description":"Name is the environment variable name (e.g., API_KEY)","type":"string"},"required":{"description":"Required indicates whether this environment variable must be provided\nIf true and not provided via command line or secrets, the user will be prompted for a value","type":"boolean"},"secret":{"description":"Secret indicates whether this environment variable contains sensitive information\nIf true, the value will be stored as a secret rather than as a plain environment variable","type":"boolean"}},"type":"object"},"registry.Group":{"properties":{"description":{"description":"Description is a human-readable description of the group's purpose and functionality","type":"string"},"name":{"description":"Name is the identifier for the group, used when referencing the group in commands","type":"string"},"remote_servers":{"additionalProperties":{"$ref":"#/components/schemas/registry.RemoteServerMetadata"},"description":"RemoteServers is a map of server names to their corresponding remote server definitions within this group","type":"object"},"servers":{"additionalProperties":{"$ref":"#/components/schemas/registry.ImageMetadata"},"description":"Servers is a map of server names to their corresponding server definitions within this group","type":"object"}},"type":"object"},"registry.Header":{"properties":{"choices":{"description":"Choices provides a list of valid values for the header (optional)","items":{"type":"string"},"type":"array","uniqueItems":false},"default":{"description":"Default is the value to use if the header is not explicitly provided\nOnly used for non-required headers","type":"string"},"description":{"description":"Description is a human-readable explanation of the header's purpose","type":"string"},"name":{"description":"Name is the header name (e.g., X-API-Key, Authorization)","type":"string"},"required":{"description":"Required indicates whether this header must be provided\nIf true and not provided via command line or secrets, the user will be prompted for a value","type":"boolean"},"secret":{"description":"Secret indicates whether this header contains sensitive information\nIf true, the value will be stored as a secret rather than as plain text","type":"boolean"}},"type":"object"},"registry.ImageMetadata":{"description":"Container server details (if it's a container server)","properties":{"args":{"description":"Args are the default command-line arguments to pass to the MCP server container.\nThese arguments will be used only if no command-line arguments are provided by the user.\nIf the user provides arguments, they will override these defaults.","items":{"type":"string"},"type":"array","uniqueItems":false},"custom_metadata":{"additionalProperties":{},"description":"CustomMetadata allows for additional user-defined metadata","type":"object"},"description":{"description":"Description is a human-readable description of the server's purpose and functionality","type":"string"},"docker_tags":{"description":"DockerTags lists the available Docker tags for this server image","items":{"type":"string"},"type":"array","uniqueItems":false},"env_vars":{"description":"EnvVars defines environment variables that can be passed to the server","items":{"$ref":"#/components/schemas/registry.EnvVar"},"type":"array","uniqueItems":false},"image":{"description":"Image is the Docker image reference for the MCP server","type":"string"},"metadata":{"$ref":"#/components/schemas/registry.Metadata"},"name":{"description":"Name is the identifier for the MCP server, used when referencing the server in commands\nIf not provided, it will be auto-generated from the registry key","type":"string"},"permissions":{"$ref":"#/components/schemas/permissions.Profile"},"provenance":{"$ref":"#/components/schemas/registry.Provenance"},"repository_url":{"description":"RepositoryURL is the URL to the source code repository for the server","type":"string"},"status":{"description":"Status indicates whether the server is currently active or deprecated","type":"string"},"tags":{"description":"Tags are categorization labels for the server to aid in discovery and filtering","items":{"type":"string"},"type":"array","uniqueItems":false},"target_port":{"description":"TargetPort is the port for the container to expose (only applicable to SSE and Streamable HTTP transports)","type":"integer"},"tier":{"description":"Tier represents the tier classification level of the server, e.g., \"Official\" or \"Community\"","type":"string"},"tools":{"description":"Tools is a list of tool names provided by this MCP server","items":{"type":"string"},"type":"array","uniqueItems":false},"transport":{"description":"Transport defines the communication protocol for the server\nFor containers: stdio, sse, or streamable-http\nFor remote servers: sse or streamable-http (stdio not supported)","type":"string"}},"type":"object"},"registry.Metadata":{"description":"Metadata contains additional information about the server such as popularity metrics","properties":{"last_updated":{"description":"LastUpdated is the timestamp when the server was last updated, in RFC3339 format","type":"string"},"pulls":{"description":"Pulls indicates how many times the server image has been downloaded","type":"integer"},"stars":{"description":"Stars represents the popularity rating or number of stars for the server","type":"integer"}},"type":"object"},"registry.OAuthConfig":{"description":"OAuthConfig provides OAuth/OIDC configuration for authentication to the remote server\nUsed with the thv proxy command's --remote-auth flags","properties":{"authorize_url":{"description":"AuthorizeURL is the OAuth authorization endpoint URL\nUsed for non-OIDC OAuth flows when issuer is not provided","type":"string"},"callback_port":{"description":"CallbackPort is the specific port to use for the OAuth callback server\nIf not specified, a random available port will be used","type":"integer"},"client_id":{"description":"ClientID is the OAuth client ID for authentication","type":"string"},"issuer":{"description":"Issuer is the OAuth/OIDC issuer URL (e.g., https://accounts.google.com)\nUsed for OIDC discovery to find authorization and token endpoints","type":"string"},"oauth_params":{"additionalProperties":{"type":"string"},"description":"OAuthParams contains additional OAuth parameters to include in the authorization request\nThese are server-specific parameters like \"prompt\", \"response_mode\", etc.","type":"object"},"resource":{"description":"Resource is the OAuth 2.0 resource indicator (RFC 8707)","type":"string"},"scopes":{"description":"Scopes are the OAuth scopes to request\nIf not specified, defaults to [\"openid\", \"profile\", \"email\"] for OIDC","items":{"type":"string"},"type":"array","uniqueItems":false},"token_url":{"description":"TokenURL is the OAuth token endpoint URL\nUsed for non-OIDC OAuth flows when issuer is not provided","type":"string"},"use_pkce":{"description":"UsePKCE indicates whether to use PKCE for the OAuth flow\nDefaults to true for enhanced security","type":"boolean"}},"type":"object"},"registry.Provenance":{"description":"Provenance contains verification and signing metadata","properties":{"attestation":{"$ref":"#/components/schemas/registry.VerifiedAttestation"},"cert_issuer":{"type":"string"},"repository_ref":{"type":"string"},"repository_uri":{"type":"string"},"runner_environment":{"type":"string"},"signer_identity":{"type":"string"},"sigstore_url":{"type":"string"}},"type":"object"},"registry.Registry":{"description":"Full registry data","properties":{"groups":{"description":"Groups is a slice of group definitions containing related MCP servers","items":{"$ref":"#/components/schemas/registry.Group"},"type":"array","uniqueItems":false},"last_updated":{"description":"LastUpdated is the timestamp when the registry was last updated, in RFC3339 format","type":"string"},"remote_servers":{"additionalProperties":{"$ref":"#/components/schemas/registry.RemoteServerMetadata"},"description":"RemoteServers is a map of server names to their corresponding remote server definitions\nThese are MCP servers accessed via HTTP/HTTPS using the thv proxy command","type":"object"},"servers":{"additionalProperties":{"$ref":"#/components/schemas/registry.ImageMetadata"},"description":"Servers is a map of server names to their corresponding server definitions","type":"object"},"version":{"description":"Version is the schema version of the registry","type":"string"}},"type":"object"},"registry.RemoteServerMetadata":{"description":"Remote server details (if it's a remote server)","properties":{"custom_metadata":{"additionalProperties":{},"description":"CustomMetadata allows for additional user-defined metadata","type":"object"},"description":{"description":"Description is a human-readable description of the server's purpose and functionality","type":"string"},"env_vars":{"description":"EnvVars defines environment variables that can be passed to configure the client\nThese might be needed for client-side configuration when connecting to the remote server","items":{"$ref":"#/components/schemas/registry.EnvVar"},"type":"array","uniqueItems":false},"headers":{"description":"Headers defines HTTP headers that can be passed to the remote server for authentication\nThese are used with the thv proxy command's authentication features","items":{"$ref":"#/components/schemas/registry.Header"},"type":"array","uniqueItems":false},"metadata":{"$ref":"#/components/schemas/registry.Metadata"},"name":{"description":"Name is the identifier for the MCP server, used when referencing the server in commands\nIf not provided, it will be auto-generated from the registry key","type":"string"},"oauth_config":{"$ref":"#/components/schemas/registry.OAuthConfig"},"repository_url":{"description":"RepositoryURL is the URL to the source code repository for the server","type":"string"},"status":{"description":"Status indicates whether the server is currently active or deprecated","type":"string"},"tags":{"description":"Tags are categorization labels for the server to aid in discovery and filtering","items":{"type":"string"},"type":"array","uniqueItems":false},"tier":{"description":"Tier represents the tier classification level of the server, e.g., \"Official\" or \"Community\"","type":"string"},"tools":{"description":"Tools is a list of tool names provided by this MCP server","items":{"type":"string"},"type":"array","uniqueItems":false},"transport":{"description":"Transport defines the communication protocol for the server\nFor containers: stdio, sse, or streamable-http\nFor remote servers: sse or streamable-http (stdio not supported)","type":"string"},"url":{"description":"URL is the endpoint URL for the remote MCP server (e.g., https://api.example.com/mcp)","type":"string"}},"type":"object"},"registry.VerifiedAttestation":{"properties":{"predicate":{},"predicate_type":{"type":"string"}},"type":"object"},"remote.Config":{"description":"RemoteAuthConfig contains OAuth configuration for remote MCP servers","properties":{"authorize_url":{"type":"string"},"callback_port":{"type":"integer"},"client_id":{"type":"string"},"client_secret":{"type":"string"},"client_secret_file":{"type":"string"},"env_vars":{"description":"Environment variables for the client","items":{"$ref":"#/components/schemas/registry.EnvVar"},"type":"array","uniqueItems":false},"headers":{"description":"Headers for HTTP requests","items":{"$ref":"#/components/schemas/registry.Header"},"type":"array","uniqueItems":false},"issuer":{"description":"OAuth endpoint configuration (from registry)","type":"string"},"oauth_params":{"additionalProperties":{"type":"string"},"description":"OAuth parameters for server-specific customization","type":"object"},"resource":{"description":"Resource is the OAuth 2.0 resource indicator (RFC 8707).","type":"string"},"scopes":{"items":{"type":"string"},"type":"array","uniqueItems":false},"skip_browser":{"type":"boolean"},"timeout":{"example":"5m","type":"string"},"token_url":{"type":"string"},"use_pkce":{"type":"boolean"}},"type":"object"},"runner.RunConfig":{"properties":{"audit_config":{"$ref":"#/components/schemas/audit.Config"},"audit_config_path":{"description":"AuditConfigPath is the path to the audit configuration file","type":"string"},"authz_config":{"$ref":"#/components/schemas/authz.Config"},"authz_config_path":{"description":"AuthzConfigPath is the path to the authorization configuration file","type":"string"},"base_name":{"description":"BaseName is the base name used for the container (without prefixes)","type":"string"},"cmd_args":{"description":"CmdArgs are the arguments to pass to the container","items":{"type":"string"},"type":"array","uniqueItems":false},"container_labels":{"additionalProperties":{"type":"string"},"description":"ContainerLabels are the labels to apply to the container","type":"object"},"container_name":{"description":"ContainerName is the name of the container","type":"string"},"debug":{"description":"Debug indicates whether debug mode is enabled","type":"boolean"},"env_file_dir":{"description":"EnvFileDir is the directory path to load environment files from","type":"string"},"env_vars":{"additionalProperties":{"type":"string"},"description":"EnvVars are the parsed environment variables as key-value pairs","type":"object"},"group":{"description":"Group is the name of the group this workload belongs to, if any","type":"string"},"host":{"description":"Host is the host for the HTTP proxy","type":"string"},"ignore_config":{"$ref":"#/components/schemas/ignore.Config"},"image":{"description":"Image is the Docker image to run","type":"string"},"isolate_network":{"description":"IsolateNetwork indicates whether to isolate the network for the container","type":"boolean"},"jwks_auth_token_file":{"description":"JWKSAuthTokenFile is the path to file containing auth token for JWKS/OIDC requests","type":"string"},"k8s_pod_template_patch":{"description":"K8sPodTemplatePatch is a JSON string to patch the Kubernetes pod template\nOnly applicable when using Kubernetes runtime","type":"string"},"middleware_configs":{"description":"MiddlewareConfigs contains the list of middleware to apply to the transport\nand the configuration for each middleware.","items":{"$ref":"#/components/schemas/types.MiddlewareConfig"},"type":"array","uniqueItems":false},"name":{"description":"Name is the name of the MCP server","type":"string"},"oidc_config":{"$ref":"#/components/schemas/auth.TokenValidatorConfig"},"permission_profile":{"$ref":"#/components/schemas/permissions.Profile"},"permission_profile_name_or_path":{"description":"PermissionProfileNameOrPath is the name or path of the permission profile","type":"string"},"port":{"description":"Port is the port for the HTTP proxy to listen on (host port)","type":"integer"},"proxy_mode":{"$ref":"#/components/schemas/types.ProxyMode"},"remote_auth_config":{"$ref":"#/components/schemas/remote.Config"},"remote_url":{"description":"RemoteURL is the URL of the remote MCP server (if running remotely)","type":"string"},"schema_version":{"description":"SchemaVersion is the version of the RunConfig schema","type":"string"},"secrets":{"description":"Secrets are the secret parameters to pass to the container\nFormat: \"\u003csecret name\u003e,target=\u003ctarget environment variable\u003e\"","items":{"type":"string"},"type":"array","uniqueItems":false},"target_host":{"description":"TargetHost is the host to forward traffic to (only applicable to SSE transport)","type":"string"},"target_port":{"description":"TargetPort is the port for the container to expose (only applicable to SSE transport)","type":"integer"},"telemetry_config":{"$ref":"#/components/schemas/telemetry.Config"},"thv_ca_bundle":{"description":"ThvCABundle is the path to the CA certificate bundle for ToolHive HTTP operations","type":"string"},"token_exchange_config":{"$ref":"#/components/schemas/tokenexchange.Config"},"tools_filter":{"description":"ToolsFilter is the list of tools to filter","items":{"type":"string"},"type":"array","uniqueItems":false},"tools_override":{"additionalProperties":{"$ref":"#/components/schemas/runner.ToolOverride"},"description":"ToolsOverride is a map from an actual tool to its overridden name and/or description","type":"object"},"transport":{"description":"Transport is the transport mode (stdio, sse, or streamable-http)","type":"string","x-enum-varnames":["TransportTypeStdio","TransportTypeSSE","TransportTypeStreamableHTTP","TransportTypeInspector"]},"trust_proxy_headers":{"description":"TrustProxyHeaders indicates whether to trust X-Forwarded-* headers from reverse proxies","type":"boolean"},"volumes":{"description":"Volumes are the directory mounts to pass to the container\nFormat: \"host-path:container-path[:ro]\"","items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"runner.ToolOverride":{"properties":{"description":{"description":"Description is the redefined description of the tool","type":"string"},"name":{"description":"Name is the redefined name of the tool","type":"string"}},"type":"object"},"runtime.WorkloadStatus":{"description":"Status is the current status of the workload.","type":"string","x-enum-varnames":["WorkloadStatusRunning","WorkloadStatusStopped","WorkloadStatusError","WorkloadStatusStarting","WorkloadStatusStopping","WorkloadStatusUnhealthy","WorkloadStatusRemoving","WorkloadStatusUnknown","WorkloadStatusUnauthenticated"]},"secrets.SecretParameter":{"properties":{"name":{"type":"string"},"target":{"type":"string"}},"type":"object"},"telemetry.Config":{"description":"TelemetryConfig contains the OpenTelemetry configuration","properties":{"customAttributes":{"additionalProperties":{"type":"string"},"description":"CustomAttributes contains custom resource attributes to be added to all telemetry signals.\nThese are parsed from CLI flags (--otel-custom-attributes) or environment variables\n(OTEL_RESOURCE_ATTRIBUTES) as key=value pairs.\nWe use map[string]string for proper JSON serialization instead of []attribute.KeyValue\nwhich doesn't marshal/unmarshal correctly.","type":"object"},"enablePrometheusMetricsPath":{"description":"EnablePrometheusMetricsPath controls whether to expose Prometheus-style /metrics endpoint\nThe metrics are served on the main transport port at /metrics\nThis is separate from OTLP metrics which are sent to the Endpoint","type":"boolean"},"endpoint":{"description":"Endpoint is the OTLP endpoint URL","type":"string"},"environmentVariables":{"description":"EnvironmentVariables is a list of environment variable names that should be\nincluded in telemetry spans as attributes. Only variables in this list will\nbe read from the host machine and included in spans for observability.\nExample: []string{\"NODE_ENV\", \"DEPLOYMENT_ENV\", \"SERVICE_VERSION\"}","items":{"type":"string"},"type":"array","uniqueItems":false},"headers":{"additionalProperties":{"type":"string"},"description":"Headers contains authentication headers for the OTLP endpoint","type":"object"},"insecure":{"description":"Insecure indicates whether to use HTTP instead of HTTPS for the OTLP endpoint","type":"boolean"},"metricsEnabled":{"description":"MetricsEnabled controls whether OTLP metrics are enabled\nWhen false, OTLP metrics are not sent even if an endpoint is configured\nThis is independent of EnablePrometheusMetricsPath","type":"boolean"},"samplingRate":{"description":"SamplingRate is the trace sampling rate (0.0-1.0)\nOnly used when TracingEnabled is true","type":"number"},"serviceName":{"description":"ServiceName is the service name for telemetry","type":"string"},"serviceVersion":{"description":"ServiceVersion is the service version for telemetry","type":"string"},"tracingEnabled":{"description":"TracingEnabled controls whether distributed tracing is enabled\nWhen false, no tracer provider is created even if an endpoint is configured","type":"boolean"}},"type":"object"},"tokenexchange.Config":{"description":"TokenExchangeConfig contains token exchange configuration for external authentication","properties":{"audience":{"description":"Audience is the target audience for the exchanged token","type":"string"},"client_id":{"description":"ClientID is the OAuth 2.0 client identifier","type":"string"},"client_secret":{"description":"ClientSecret is the OAuth 2.0 client secret","type":"string"},"external_token_header_name":{"description":"ExternalTokenHeaderName is the name of the custom header to use when HeaderStrategy is \"custom\"","type":"string"},"header_strategy":{"description":"HeaderStrategy determines how to inject the token\nValid values: HeaderStrategyReplace (default), HeaderStrategyCustom","type":"string"},"scopes":{"description":"Scopes is the list of scopes to request for the exchanged token","items":{"type":"string"},"type":"array","uniqueItems":false},"subject_token_type":{"description":"SubjectTokenType specifies the type of the subject token being exchanged.\nCommon values: tokenTypeAccessToken (default), tokenTypeIDToken, tokenTypeJWT.\nIf empty, defaults to tokenTypeAccessToken.","type":"string"},"token_url":{"description":"TokenURL is the OAuth 2.0 token endpoint URL","type":"string"}},"type":"object"},"types.MiddlewareConfig":{"properties":{"parameters":{"description":"Parameters is a JSON object containing the middleware parameters.\nIt is stored as a raw message to allow flexible parameter types.","type":"object"},"type":{"description":"Type is a string representing the middleware type.","type":"string"}},"type":"object"},"types.ProxyMode":{"description":"ProxyMode is the proxy mode for stdio transport (\"sse\" or \"streamable-http\")\nNote: \"sse\" is deprecated; use \"streamable-http\" instead.","type":"string","x-enum-varnames":["ProxyModeSSE","ProxyModeStreamableHTTP"]},"types.TransportType":{"description":"TransportType is the type of transport used for this workload.","type":"string","x-enum-varnames":["TransportTypeStdio","TransportTypeSSE","TransportTypeStreamableHTTP","TransportTypeInspector"]},"v1.RegistryType":{"description":"Type of registry (file, url, or default)","type":"string","x-enum-varnames":["RegistryTypeFile","RegistryTypeURL","RegistryTypeAPI","RegistryTypeDefault"]},"v1.UpdateRegistryRequest":{"description":"Request containing registry configuration updates","properties":{"allow_private_ip":{"description":"Allow private IP addresses for registry URL or API URL","type":"boolean"},"api_url":{"description":"MCP Registry API URL","type":"string"},"local_path":{"description":"Local registry file path","type":"string"},"url":{"description":"Registry URL (for remote registries)","type":"string"}},"type":"object"},"v1.UpdateRegistryResponse":{"description":"Response containing update result","properties":{"message":{"description":"Status message","type":"string"},"type":{"description":"Registry type after update","type":"string"}},"type":"object"},"v1.bulkClientRequest":{"properties":{"groups":{"description":"Groups is the list of groups configured on the client.","items":{"type":"string"},"type":"array","uniqueItems":false},"names":{"description":"Names is the list of client names to operate on.","items":{"type":"string","x-enum-varnames":["RooCode","Cline","Cursor","VSCodeInsider","VSCode","ClaudeCode","Windsurf","WindsurfJetBrains","AmpCli","AmpVSCode","AmpCursor","AmpVSCodeInsider","AmpWindsurf","LMStudio","Goose","Trae","Continue","OpenCode","Kiro","Antigravity","Zed"]},"type":"array","uniqueItems":false}},"type":"object"},"v1.bulkOperationRequest":{"properties":{"group":{"description":"Group name to operate on (mutually exclusive with names)","type":"string"},"names":{"description":"Names of the workloads to operate on","items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"v1.clientStatusResponse":{"properties":{"clients":{"items":{"$ref":"#/components/schemas/client.MCPClientStatus"},"type":"array","uniqueItems":false}},"type":"object"},"v1.createClientRequest":{"properties":{"groups":{"description":"Groups is the list of groups configured on the client.","items":{"type":"string"},"type":"array","uniqueItems":false},"name":{"description":"Name is the type of the client to register.","type":"string","x-enum-varnames":["RooCode","Cline","Cursor","VSCodeInsider","VSCode","ClaudeCode","Windsurf","WindsurfJetBrains","AmpCli","AmpVSCode","AmpCursor","AmpVSCodeInsider","AmpWindsurf","LMStudio","Goose","Trae","Continue","OpenCode","Kiro","Antigravity","Zed"]}},"type":"object"},"v1.createClientResponse":{"properties":{"groups":{"description":"Groups is the list of groups configured on the client.","items":{"type":"string"},"type":"array","uniqueItems":false},"name":{"description":"Name is the type of the client that was registered.","type":"string","x-enum-varnames":["RooCode","Cline","Cursor","VSCodeInsider","VSCode","ClaudeCode","Windsurf","WindsurfJetBrains","AmpCli","AmpVSCode","AmpCursor","AmpVSCodeInsider","AmpWindsurf","LMStudio","Goose","Trae","Continue","OpenCode","Kiro","Antigravity","Zed"]}},"type":"object"},"v1.createGroupRequest":{"properties":{"name":{"description":"Name of the group to create","type":"string"}},"type":"object"},"v1.createGroupResponse":{"properties":{"name":{"description":"Name of the created group","type":"string"}},"type":"object"},"v1.createRequest":{"description":"Request to create a new workload","properties":{"authz_config":{"description":"Authorization configuration","type":"string"},"cmd_arguments":{"description":"Command arguments to pass to the container","items":{"type":"string"},"type":"array","uniqueItems":false},"env_vars":{"additionalProperties":{"type":"string"},"description":"Environment variables to set in the container","type":"object"},"group":{"description":"Group name this workload belongs to","type":"string"},"headers":{"items":{"$ref":"#/components/schemas/registry.Header"},"type":"array","uniqueItems":false},"host":{"description":"Host to bind to","type":"string"},"image":{"description":"Docker image to use","type":"string"},"name":{"description":"Name of the workload","type":"string"},"network_isolation":{"description":"Whether network isolation is turned on. This applies the rules in the permission profile.","type":"boolean"},"oauth_config":{"$ref":"#/components/schemas/v1.remoteOAuthConfig"},"oidc":{"$ref":"#/components/schemas/v1.oidcOptions"},"permission_profile":{"$ref":"#/components/schemas/permissions.Profile"},"proxy_mode":{"description":"Proxy mode to use","type":"string"},"proxy_port":{"description":"Port for the HTTP proxy to listen on","type":"integer"},"secrets":{"description":"Secret parameters to inject","items":{"$ref":"#/components/schemas/secrets.SecretParameter"},"type":"array","uniqueItems":false},"target_port":{"description":"Port to expose from the container","type":"integer"},"tools":{"description":"Tools filter","items":{"type":"string"},"type":"array","uniqueItems":false},"tools_override":{"additionalProperties":{"$ref":"#/components/schemas/v1.toolOverride"},"description":"Tools override","type":"object"},"transport":{"description":"Transport configuration","type":"string"},"trust_proxy_headers":{"description":"Whether to trust X-Forwarded-* headers from reverse proxies","type":"boolean"},"url":{"description":"Remote server specific fields","type":"string"},"volumes":{"description":"Volume mounts","items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"v1.createSecretRequest":{"description":"Request to create a new secret","properties":{"key":{"description":"Secret key name","type":"string"},"value":{"description":"Secret value","type":"string"}},"type":"object"},"v1.createSecretResponse":{"description":"Response after creating a secret","properties":{"key":{"description":"Secret key that was created","type":"string"},"message":{"description":"Success message","type":"string"}},"type":"object"},"v1.createWorkloadResponse":{"description":"Response after successfully creating a workload","properties":{"name":{"description":"Name of the created workload","type":"string"},"port":{"description":"Port the workload is listening on","type":"integer"}},"type":"object"},"v1.getRegistryResponse":{"description":"Response containing registry details","properties":{"last_updated":{"description":"Last updated timestamp","type":"string"},"name":{"description":"Name of the registry","type":"string"},"registry":{"$ref":"#/components/schemas/registry.Registry"},"server_count":{"description":"Number of servers in the registry","type":"integer"},"source":{"description":"Source of the registry (URL, file path, or empty string for built-in)","type":"string"},"type":{"description":"Type of registry (file, url, or default)","type":"string","x-enum-varnames":["RegistryTypeFile","RegistryTypeURL","RegistryTypeAPI","RegistryTypeDefault"]},"version":{"description":"Version of the registry schema","type":"string"}},"type":"object"},"v1.getSecretsProviderResponse":{"description":"Response containing secrets provider details","properties":{"capabilities":{"$ref":"#/components/schemas/v1.providerCapabilitiesResponse"},"name":{"description":"Name of the secrets provider","type":"string"},"provider_type":{"description":"Type of the secrets provider","type":"string"}},"type":"object"},"v1.getServerResponse":{"description":"Response containing server details","properties":{"is_remote":{"description":"Indicates if this is a remote server","type":"boolean"},"remote_server":{"$ref":"#/components/schemas/registry.RemoteServerMetadata"},"server":{"$ref":"#/components/schemas/registry.ImageMetadata"}},"type":"object"},"v1.groupListResponse":{"properties":{"groups":{"description":"List of groups","items":{"$ref":"#/components/schemas/groups.Group"},"type":"array","uniqueItems":false}},"type":"object"},"v1.listSecretsResponse":{"description":"Response containing a list of secret keys","properties":{"keys":{"description":"List of secret keys","items":{"$ref":"#/components/schemas/v1.secretKeyResponse"},"type":"array","uniqueItems":false}},"type":"object"},"v1.listServersResponse":{"description":"Response containing a list of servers","properties":{"remote_servers":{"description":"List of remote servers in the registry (if any)","items":{"$ref":"#/components/schemas/registry.RemoteServerMetadata"},"type":"array","uniqueItems":false},"servers":{"description":"List of container servers in the registry","items":{"$ref":"#/components/schemas/registry.ImageMetadata"},"type":"array","uniqueItems":false}},"type":"object"},"v1.oidcOptions":{"description":"OIDC configuration options","properties":{"audience":{"description":"Expected audience","type":"string"},"client_id":{"description":"OAuth2 client ID","type":"string"},"client_secret":{"description":"OAuth2 client secret","type":"string"},"introspection_url":{"description":"Token introspection URL for OIDC","type":"string"},"issuer":{"description":"OIDC issuer URL","type":"string"},"jwks_url":{"description":"JWKS URL for key verification","type":"string"},"scopes":{"description":"OAuth scopes to advertise in well-known endpoint (RFC 9728)","items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"v1.providerCapabilitiesResponse":{"description":"Capabilities of the secrets provider","properties":{"can_cleanup":{"description":"Whether the provider can cleanup all secrets","type":"boolean"},"can_delete":{"description":"Whether the provider can delete secrets","type":"boolean"},"can_list":{"description":"Whether the provider can list secrets","type":"boolean"},"can_read":{"description":"Whether the provider can read secrets","type":"boolean"},"can_write":{"description":"Whether the provider can write secrets","type":"boolean"}},"type":"object"},"v1.registryInfo":{"description":"Basic information about a registry","properties":{"last_updated":{"description":"Last updated timestamp","type":"string"},"name":{"description":"Name of the registry","type":"string"},"server_count":{"description":"Number of servers in the registry","type":"integer"},"source":{"description":"Source of the registry (URL, file path, or empty string for built-in)","type":"string"},"type":{"$ref":"#/components/schemas/v1.RegistryType"},"version":{"description":"Version of the registry schema","type":"string"}},"type":"object"},"v1.registryListResponse":{"description":"Response containing a list of registries","properties":{"registries":{"description":"List of registries","items":{"$ref":"#/components/schemas/v1.registryInfo"},"type":"array","uniqueItems":false}},"type":"object"},"v1.remoteOAuthConfig":{"description":"OAuth configuration for remote server authentication","properties":{"authorize_url":{"description":"OAuth authorization endpoint URL (alternative to issuer for non-OIDC OAuth)","type":"string"},"callback_port":{"description":"Specific port for OAuth callback server","type":"integer"},"client_id":{"description":"OAuth client ID for authentication","type":"string"},"client_secret":{"$ref":"#/components/schemas/secrets.SecretParameter"},"issuer":{"description":"OAuth/OIDC issuer URL (e.g., https://accounts.google.com)","type":"string"},"oauth_params":{"additionalProperties":{"type":"string"},"description":"Additional OAuth parameters for server-specific customization","type":"object"},"resource":{"description":"OAuth 2.0 resource indicator (RFC 8707)","type":"string"},"scopes":{"description":"OAuth scopes to request","items":{"type":"string"},"type":"array","uniqueItems":false},"skip_browser":{"description":"Whether to skip opening browser for OAuth flow (defaults to false)","type":"boolean"},"token_url":{"description":"OAuth token endpoint URL (alternative to issuer for non-OIDC OAuth)","type":"string"},"use_pkce":{"description":"Whether to use PKCE for the OAuth flow","type":"boolean"}},"type":"object"},"v1.secretKeyResponse":{"description":"Secret key information","properties":{"description":{"description":"Optional description of the secret","type":"string"},"key":{"description":"Secret key name","type":"string"}},"type":"object"},"v1.setupSecretsRequest":{"description":"Request to setup a secrets provider","properties":{"password":{"description":"Password for encrypted provider (optional, can be set via environment variable)\nTODO Review environment variable for this","type":"string"},"provider_type":{"description":"Type of the secrets provider (encrypted, 1password, none)","type":"string"}},"type":"object"},"v1.setupSecretsResponse":{"description":"Response after initializing a secrets provider","properties":{"message":{"description":"Success message","type":"string"},"provider_type":{"description":"Type of the secrets provider that was setup","type":"string"}},"type":"object"},"v1.toolOverride":{"description":"Tool override","properties":{"description":{"description":"Description of the tool","type":"string"},"name":{"description":"Name of the tool","type":"string"}},"type":"object"},"v1.updateRequest":{"description":"Request to update an existing workload (name cannot be changed)","properties":{"authz_config":{"description":"Authorization configuration","type":"string"},"cmd_arguments":{"description":"Command arguments to pass to the container","items":{"type":"string"},"type":"array","uniqueItems":false},"env_vars":{"additionalProperties":{"type":"string"},"description":"Environment variables to set in the container","type":"object"},"group":{"description":"Group name this workload belongs to","type":"string"},"headers":{"items":{"$ref":"#/components/schemas/registry.Header"},"type":"array","uniqueItems":false},"host":{"description":"Host to bind to","type":"string"},"image":{"description":"Docker image to use","type":"string"},"network_isolation":{"description":"Whether network isolation is turned on. This applies the rules in the permission profile.","type":"boolean"},"oauth_config":{"$ref":"#/components/schemas/v1.remoteOAuthConfig"},"oidc":{"$ref":"#/components/schemas/v1.oidcOptions"},"permission_profile":{"$ref":"#/components/schemas/permissions.Profile"},"proxy_mode":{"description":"Proxy mode to use","type":"string"},"proxy_port":{"description":"Port for the HTTP proxy to listen on","type":"integer"},"secrets":{"description":"Secret parameters to inject","items":{"$ref":"#/components/schemas/secrets.SecretParameter"},"type":"array","uniqueItems":false},"target_port":{"description":"Port to expose from the container","type":"integer"},"tools":{"description":"Tools filter","items":{"type":"string"},"type":"array","uniqueItems":false},"tools_override":{"additionalProperties":{"$ref":"#/components/schemas/v1.toolOverride"},"description":"Tools override","type":"object"},"transport":{"description":"Transport configuration","type":"string"},"trust_proxy_headers":{"description":"Whether to trust X-Forwarded-* headers from reverse proxies","type":"boolean"},"url":{"description":"Remote server specific fields","type":"string"},"volumes":{"description":"Volume mounts","items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"v1.updateSecretRequest":{"description":"Request to update an existing secret","properties":{"value":{"description":"New secret value","type":"string"}},"type":"object"},"v1.updateSecretResponse":{"description":"Response after updating a secret","properties":{"key":{"description":"Secret key that was updated","type":"string"},"message":{"description":"Success message","type":"string"}},"type":"object"},"v1.versionResponse":{"properties":{"version":{"type":"string"}},"type":"object"},"v1.workloadListResponse":{"description":"Response containing a list of workloads","properties":{"workloads":{"description":"List of container information for each workload","items":{"$ref":"#/components/schemas/core.Workload"},"type":"array","uniqueItems":false}},"type":"object"},"v1.workloadStatusResponse":{"description":"Response containing workload status information","properties":{"status":{"description":"Current status of the workload","type":"string","x-enum-varnames":["WorkloadStatusRunning","WorkloadStatusStopped","WorkloadStatusError","WorkloadStatusStarting","WorkloadStatusStopping","WorkloadStatusUnhealthy","WorkloadStatusRemoving","WorkloadStatusUnknown","WorkloadStatusUnauthenticated"]}},"type":"object"}}}, "info": {"description":"This is the ToolHive API server.","title":"ToolHive API","version":"1.0"}, "externalDocs": {"description":"","url":""}, "paths": {"/api/openapi.json":{"get":{"description":"Returns the OpenAPI specification for the API","responses":{"200":{"content":{"application/json":{"schema":{"type":"object"}}},"description":"OpenAPI specification"}},"summary":"Get OpenAPI specification","tags":["system"]}},"/api/v1beta/clients":{"get":{"description":"List all registered clients in ToolHive","responses":{"200":{"content":{"application/json":{"schema":{"items":{"$ref":"#/components/schemas/client.RegisteredClient"},"type":"array"}}},"description":"OK"}},"summary":"List all clients","tags":["clients"]},"post":{"description":"Register a new client with ToolHive","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/v1.createClientRequest"}}},"description":"Client to register","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/v1.createClientResponse"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"Invalid request"}},"summary":"Register a new client","tags":["clients"]}},"/api/v1beta/clients/register":{"post":{"description":"Register multiple clients with ToolHive","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/v1.bulkClientRequest"}}},"description":"Clients to register","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"items":{"$ref":"#/components/schemas/v1.createClientResponse"},"type":"array"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"Invalid request"}},"summary":"Register multiple clients","tags":["clients"]}},"/api/v1beta/clients/unregister":{"post":{"description":"Unregister multiple clients from ToolHive","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/v1.bulkClientRequest"}}},"description":"Clients to unregister","required":true},"responses":{"204":{"description":"No Content"},"400":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"Invalid request"}},"summary":"Unregister multiple clients","tags":["clients"]}},"/api/v1beta/clients/{name}":{"delete":{"description":"Unregister a client from ToolHive","parameters":[{"description":"Client name to unregister","in":"path","name":"name","required":true,"schema":{"type":"string"}}],"responses":{"204":{"description":"No Content"},"400":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"Invalid request"}},"summary":"Unregister a client","tags":["clients"]}},"/api/v1beta/clients/{name}/groups/{group}":{"delete":{"description":"Unregister a client from a specific group in ToolHive","parameters":[{"description":"Client name to unregister","in":"path","name":"name","required":true,"schema":{"type":"string"}},{"description":"Group name to remove client from","in":"path","name":"group","required":true,"schema":{"type":"string"}}],"responses":{"204":{"description":"No Content"},"400":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"Invalid request"},"404":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"Client or group not found"}},"summary":"Unregister a client from a specific group","tags":["clients"]}},"/api/v1beta/discovery/clients":{"get":{"description":"List all clients compatible with ToolHive and their status","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/v1.clientStatusResponse"}}},"description":"OK"}},"summary":"List all clients status","tags":["discovery"]}},"/api/v1beta/groups":{"get":{"description":"Get a list of all groups","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/v1.groupListResponse"}}},"description":"OK"},"500":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"Internal Server Error"}},"summary":"List all groups","tags":["groups"]},"post":{"description":"Create a new group with the specified name","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/v1.createGroupRequest"}}},"description":"Group creation request","required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/v1.createGroupResponse"}}},"description":"Created"},"400":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"Bad Request"},"409":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"Conflict"},"500":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"Internal Server Error"}},"summary":"Create a new group","tags":["groups"]}},"/api/v1beta/groups/{name}":{"delete":{"description":"Delete a group by name.","parameters":[{"description":"Group name","in":"path","name":"name","required":true,"schema":{"type":"string"}},{"description":"Delete all workloads in the group (default: false, moves workloads to default group)","in":"query","name":"with-workloads","schema":{"type":"boolean"}}],"responses":{"204":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"No Content"},"404":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"Not Found"},"500":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"Internal Server Error"}},"summary":"Delete a group","tags":["groups"]},"get":{"description":"Get details of a specific group","parameters":[{"description":"Group name","in":"path","name":"name","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/groups.Group"}}},"description":"OK"},"404":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"Not Found"},"500":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"Internal Server Error"}},"summary":"Get group details","tags":["groups"]}},"/api/v1beta/registry":{"get":{"description":"Get a list of the current registries","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/v1.registryListResponse"}}},"description":"OK"}},"summary":"List registries","tags":["registry"]},"post":{"description":"Add a new registry","requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"501":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"Not Implemented"}},"summary":"Add a registry","tags":["registry"]}},"/api/v1beta/registry/{name}":{"delete":{"description":"Remove a specific registry","parameters":[{"description":"Registry name","in":"path","name":"name","required":true,"schema":{"type":"string"}}],"responses":{"204":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"No Content"},"404":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"Not Found"}},"summary":"Remove a registry","tags":["registry"]},"get":{"description":"Get details of a specific registry","parameters":[{"description":"Registry name","in":"path","name":"name","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/v1.getRegistryResponse"}}},"description":"OK"},"404":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"Not Found"}},"summary":"Get a registry","tags":["registry"]},"put":{"description":"Update registry URL or local path for the default registry","parameters":[{"description":"Registry name (must be 'default')","in":"path","name":"name","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/v1.UpdateRegistryRequest"}}},"description":"Registry configuration","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/v1.UpdateRegistryResponse"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"Bad Request"},"404":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"Not Found"}},"summary":"Update registry configuration","tags":["registry"]}},"/api/v1beta/registry/{name}/servers":{"get":{"description":"Get a list of servers in a specific registry","parameters":[{"description":"Registry name","in":"path","name":"name","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/v1.listServersResponse"}}},"description":"OK"},"404":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"Not Found"}},"summary":"List servers in a registry","tags":["registry"]}},"/api/v1beta/registry/{name}/servers/{serverName}":{"get":{"description":"Get details of a specific server in a registry","parameters":[{"description":"Registry name","in":"path","name":"name","required":true,"schema":{"type":"string"}},{"description":"ImageMetadata name","in":"path","name":"serverName","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/v1.getServerResponse"}}},"description":"OK"},"404":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"Not Found"}},"summary":"Get a server from a registry","tags":["registry"]}},"/api/v1beta/secrets":{"post":{"description":"Setup the secrets provider with the specified type and configuration.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/v1.setupSecretsRequest"}}},"description":"Setup secrets provider request","required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/v1.setupSecretsResponse"}}},"description":"Created"},"400":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"Bad Request"},"500":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"Internal Server Error"}},"summary":"Setup or reconfigure secrets provider","tags":["secrets"]}},"/api/v1beta/secrets/default":{"get":{"description":"Get details of the default secrets provider","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/v1.getSecretsProviderResponse"}}},"description":"OK"},"404":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"Not Found - Provider not setup"},"500":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"Internal Server Error"}},"summary":"Get secrets provider details","tags":["secrets"]}},"/api/v1beta/secrets/default/keys":{"get":{"description":"Get a list of all secret keys from the default provider","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/v1.listSecretsResponse"}}},"description":"OK"},"404":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"Not Found - Provider not setup"},"405":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"Method Not Allowed - Provider doesn't support listing"},"500":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"Internal Server Error"}},"summary":"List secrets","tags":["secrets"]},"post":{"description":"Create a new secret in the default provider (encrypted provider only)","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/v1.createSecretRequest"}}},"description":"Create secret request","required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/v1.createSecretResponse"}}},"description":"Created"},"400":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"Bad Request"},"404":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"Not Found - Provider not setup"},"405":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"Method Not Allowed - Provider doesn't support writing"},"409":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"Conflict - Secret already exists"},"500":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"Internal Server Error"}},"summary":"Create a new secret","tags":["secrets"]}},"/api/v1beta/secrets/default/keys/{key}":{"delete":{"description":"Delete a secret from the default provider (encrypted provider only)","parameters":[{"description":"Secret key","in":"path","name":"key","required":true,"schema":{"type":"string"}}],"responses":{"204":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"No Content"},"404":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"Not Found - Provider not setup or secret not found"},"405":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"Method Not Allowed - Provider doesn't support deletion"},"500":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"Internal Server Error"}},"summary":"Delete a secret","tags":["secrets"]},"put":{"description":"Update an existing secret in the default provider (encrypted provider only)","parameters":[{"description":"Secret key","in":"path","name":"key","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/v1.updateSecretRequest"}}},"description":"Update secret request","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/v1.updateSecretResponse"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"Bad Request"},"404":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"Not Found - Provider not setup or secret not found"},"405":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"Method Not Allowed - Provider doesn't support writing"},"500":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"Internal Server Error"}},"summary":"Update a secret","tags":["secrets"]}},"/api/v1beta/version":{"get":{"description":"Returns the current version of the server","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/v1.versionResponse"}}},"description":"OK"}},"summary":"Get server version","tags":["version"]}},"/api/v1beta/workloads":{"get":{"description":"Get a list of all running workloads, optionally filtered by group","parameters":[{"description":"List all workloads, including stopped ones","in":"query","name":"all","schema":{"type":"boolean"}},{"description":"Filter workloads by group name","in":"query","name":"group","schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/v1.workloadListResponse"}}},"description":"OK"},"404":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"Group not found"}},"summary":"List all workloads","tags":["workloads"]},"post":{"description":"Create and start a new workload","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/v1.createRequest"}}},"description":"Create workload request","required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/v1.createWorkloadResponse"}}},"description":"Created"},"400":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"Bad Request"},"409":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"Conflict"}},"summary":"Create a new workload","tags":["workloads"]}},"/api/v1beta/workloads/delete":{"post":{"description":"Delete multiple workloads by name or by group","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/v1.bulkOperationRequest"}}},"description":"Bulk delete request (names or group)","required":true},"responses":{"202":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"Accepted"},"400":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"Bad Request"}},"summary":"Delete workloads in bulk","tags":["workloads"]}},"/api/v1beta/workloads/restart":{"post":{"description":"Restart multiple workloads by name or by group","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/v1.bulkOperationRequest"}}},"description":"Bulk restart request (names or group)","required":true},"responses":{"202":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"Accepted"},"400":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"Bad Request"}},"summary":"Restart workloads in bulk","tags":["workloads"]}},"/api/v1beta/workloads/stop":{"post":{"description":"Stop multiple workloads by name or by group","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/v1.bulkOperationRequest"}}},"description":"Bulk stop request (names or group)","required":true},"responses":{"202":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"Accepted"},"400":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"Bad Request"}},"summary":"Stop workloads in bulk","tags":["workloads"]}},"/api/v1beta/workloads/{name}":{"delete":{"description":"Delete a workload","parameters":[{"description":"Workload name","in":"path","name":"name","required":true,"schema":{"type":"string"}}],"responses":{"202":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"Accepted"},"400":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"Bad Request"},"404":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"Not Found"}},"summary":"Delete a workload","tags":["workloads"]},"get":{"description":"Get details of a specific workload","parameters":[{"description":"Workload name","in":"path","name":"name","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/v1.createRequest"}}},"description":"OK"},"404":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"Not Found"}},"summary":"Get workload details","tags":["workloads"]}},"/api/v1beta/workloads/{name}/edit":{"post":{"description":"Update an existing workload configuration","parameters":[{"description":"Workload name","in":"path","name":"name","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/v1.updateRequest"}}},"description":"Update workload request","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/v1.createWorkloadResponse"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"Bad Request"},"404":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"Not Found"}},"summary":"Update workload","tags":["workloads"]}},"/api/v1beta/workloads/{name}/export":{"get":{"description":"Export a workload's run configuration as JSON","parameters":[{"description":"Workload name","in":"path","name":"name","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/runner.RunConfig"}}},"description":"OK"},"404":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"Not Found"}},"summary":"Export workload configuration","tags":["workloads"]}},"/api/v1beta/workloads/{name}/logs":{"get":{"description":"Retrieve at most 100 lines of logs for a specific workload by name.","parameters":[{"description":"Workload name","in":"path","name":"name","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"type":"string"}},"text/plain":{"schema":{"type":"string"}}},"description":"Logs for the specified workload"},"400":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"Invalid workload name"},"404":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"Not Found"}},"summary":"Get logs for a specific workload","tags":["logs"]}},"/api/v1beta/workloads/{name}/proxy-logs":{"get":{"description":"Retrieve proxy logs for a specific workload by name from the file system.","parameters":[{"description":"Workload name","in":"path","name":"name","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"type":"string"}},"text/plain":{"schema":{"type":"string"}}},"description":"Proxy logs for the specified workload"},"400":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"Invalid workload name"},"404":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"Proxy logs not found for workload"}},"summary":"Get proxy logs for a specific workload","tags":["logs"]}},"/api/v1beta/workloads/{name}/restart":{"post":{"description":"Restart a running workload","parameters":[{"description":"Workload name","in":"path","name":"name","required":true,"schema":{"type":"string"}}],"responses":{"202":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"Accepted"},"400":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"Bad Request"},"404":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"Not Found"}},"summary":"Restart a workload","tags":["workloads"]}},"/api/v1beta/workloads/{name}/status":{"get":{"description":"Get the current status of a specific workload","parameters":[{"description":"Workload name","in":"path","name":"name","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/v1.workloadStatusResponse"}}},"description":"OK"},"404":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"Not Found"}},"summary":"Get workload status","tags":["workloads"]}},"/api/v1beta/workloads/{name}/stop":{"post":{"description":"Stop a running workload","parameters":[{"description":"Workload name","in":"path","name":"name","required":true,"schema":{"type":"string"}}],"responses":{"202":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"Accepted"},"400":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"Bad Request"},"404":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"Not Found"}},"summary":"Stop a workload","tags":["workloads"]}},"/health":{"get":{"description":"Check if the API is healthy","responses":{"204":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"No Content"}},"summary":"Health check","tags":["system"]}}}, diff --git a/docs/server/swagger.yaml b/docs/server/swagger.yaml index a54483cbe..b5aca4ef8 100644 --- a/docs/server/swagger.yaml +++ b/docs/server/swagger.yaml @@ -88,37 +88,16 @@ components: type: string type: array type: object - authz.CedarConfig: - description: |- - Cedar is the Cedar-specific configuration. - This is only used when Type is ConfigTypeCedarV1. - properties: - entities_json: - description: EntitiesJSON is the JSON string representing Cedar entities - type: string - policies: - description: Policies is a list of Cedar policy strings - items: - type: string - type: array - uniqueItems: false - type: object authz.Config: description: AuthzConfig contains the authorization configuration properties: - cedar: - $ref: '#/components/schemas/authz.CedarConfig' type: - $ref: '#/components/schemas/authz.ConfigType' + description: Type is the type of authorization configuration (e.g., "cedarv1"). + type: string version: description: Version is the version of the configuration format. type: string type: object - authz.ConfigType: - description: Type is the type of authorization configuration. - type: string - x-enum-varnames: - - ConfigTypeCedarV1 client.MCPClient: type: string x-enum-varnames: diff --git a/pkg/authz/cedar.go b/pkg/authz/authorizers/cedar/core.go similarity index 76% rename from pkg/authz/cedar.go rename to pkg/authz/authorizers/cedar/core.go index 4847549ac..a09a9ef61 100644 --- a/pkg/authz/cedar.go +++ b/pkg/authz/authorizers/cedar/core.go @@ -1,5 +1,5 @@ -// Package authz provides authorization utilities using Cedar policies. -package authz +// Package cedar provides authorization utilities using Cedar policies. +package cedar import ( "context" @@ -13,9 +13,88 @@ import ( "github.com/golang-jwt/jwt/v5" "github.com/stacklok/toolhive/pkg/auth" + "github.com/stacklok/toolhive/pkg/authz/authorizers" "github.com/stacklok/toolhive/pkg/logger" ) +// ConfigType is the configuration type identifier for Cedar authorization. +const ConfigType = "cedarv1" + +func init() { + // Register the Cedar authorizer factory with the authorizers registry. + authorizers.Register(ConfigType, &Factory{}) +} + +// Config represents the complete authorization configuration file structure +// for Cedar authorization. This includes the common version/type fields plus +// the Cedar-specific "cedar" field. This maintains backwards compatibility +// with the v1.0 configuration schema. +type Config struct { + Version string `json:"version"` + Type string `json:"type"` + Options *ConfigOptions `json:"cedar"` +} + +// ExtractConfig extracts the Cedar configuration from an authorizers.Config. +// This is useful for tests and other code that needs to inspect the Cedar configuration +// after it has been loaded into the generic Config structure. +// To access the Cedar-specific options (policies, entities), use the returned Config's Cedar field. +func ExtractConfig(authzConfig *authorizers.Config) (*Config, error) { + if authzConfig == nil { + return nil, fmt.Errorf("config is nil") + } + rawConfig := authzConfig.RawConfig() + if len(rawConfig) == 0 { + return nil, fmt.Errorf("config has no raw data") + } + + var config Config + if err := json.Unmarshal(rawConfig, &config); err != nil { + return nil, fmt.Errorf("failed to unmarshal config: %w", err) + } + if config.Options == nil { + return nil, fmt.Errorf("cedar config is nil") + } + return &config, nil +} + +// Factory implements the authorizers.AuthorizerFactory interface for Cedar. +type Factory struct{} + +// ValidateConfig validates the Cedar-specific configuration. +// It receives the full raw config and extracts the Cedar-specific portion. +func (*Factory) ValidateConfig(rawConfig json.RawMessage) error { + var config Config + if err := json.Unmarshal(rawConfig, &config); err != nil { + return fmt.Errorf("failed to parse configuration: %w", err) + } + + if config.Options == nil { + return fmt.Errorf("cedar configuration is required (missing 'cedar' field)") + } + + if len(config.Options.Policies) == 0 { + return fmt.Errorf("at least one policy is required for Cedar authorization") + } + + return nil +} + +// CreateAuthorizer creates a Cedar Authorizer from the configuration. +// It receives the full raw config and extracts the Cedar-specific portion. +func (*Factory) CreateAuthorizer(rawConfig json.RawMessage) (authorizers.Authorizer, error) { + var config Config + if err := json.Unmarshal(rawConfig, &config); err != nil { + return nil, fmt.Errorf("failed to parse configuration: %w", err) + } + + if config.Options == nil { + return nil, fmt.Errorf("cedar configuration is required (missing 'cedar' field)") + } + + return NewCedarAuthorizer(*config.Options) +} + // Common errors for Cedar authorization var ( ErrNoPolicies = errors.New("no policies loaded") @@ -30,43 +109,8 @@ var ( // ClientIDContextKey is the key used to store client ID in the context. type ClientIDContextKey struct{} -// MCPFeature represents an MCP feature type. -// In the MCP protocol, there are three main features: -// - Tools: Allow models to call functions in external systems -// - Prompts: Provide structured templates for interacting with language models -// - Resources: Share data that provides context to language models -type MCPFeature string - -const ( - // MCPFeatureTool represents the MCP tool feature. - MCPFeatureTool MCPFeature = "tool" - // MCPFeaturePrompt represents the MCP prompt feature. - MCPFeaturePrompt MCPFeature = "prompt" - // MCPFeatureResource represents the MCP resource feature. - MCPFeatureResource MCPFeature = "resource" -) - -// MCPOperation represents an operation on an MCP feature. -// Each feature supports different operations: -// - List: Get a list of available items (tools, prompts, resources) -// - Get: Get a specific prompt -// - Call: Call a specific tool -// - Read: Read a specific resource -type MCPOperation string - -const ( - // MCPOperationList represents a list operation. - MCPOperationList MCPOperation = "list" - // MCPOperationGet represents a get operation. - MCPOperationGet MCPOperation = "get" - // MCPOperationCall represents a call operation. - MCPOperationCall MCPOperation = "call" - // MCPOperationRead represents a read operation. - MCPOperationRead MCPOperation = "read" -) - -// CedarAuthorizer authorizes MCP operations using Cedar policies. -type CedarAuthorizer struct { +// Authorizer authorizes MCP operations using Cedar policies. +type Authorizer struct { // Cedar policy set policySet *cedar.PolicySet // Cedar entities @@ -77,28 +121,29 @@ type CedarAuthorizer struct { mu sync.RWMutex } -// CedarAuthorizerConfig contains configuration for the Cedar authorizer. -type CedarAuthorizerConfig struct { +// ConfigOptions represents the Cedar-specific authorization configuration options. +type ConfigOptions struct { // Policies is a list of Cedar policy strings - Policies []string + Policies []string `json:"policies" yaml:"policies"` + // EntitiesJSON is the JSON string representing Cedar entities - EntitiesJSON string + EntitiesJSON string `json:"entities_json" yaml:"entities_json"` } // NewCedarAuthorizer creates a new Cedar authorizer. -func NewCedarAuthorizer(config CedarAuthorizerConfig) (*CedarAuthorizer, error) { - authorizer := &CedarAuthorizer{ +func NewCedarAuthorizer(options ConfigOptions) (authorizers.Authorizer, error) { + authorizer := &Authorizer{ policySet: cedar.NewPolicySet(), entities: cedar.EntityMap{}, entityFactory: NewEntityFactory(), } // Load policies - if len(config.Policies) == 0 { + if len(options.Policies) == 0 { return nil, ErrNoPolicies } - for i, policyStr := range config.Policies { + for i, policyStr := range options.Policies { var policy cedar.Policy if err := policy.UnmarshalCedar([]byte(policyStr)); err != nil { return nil, fmt.Errorf("failed to parse policy %d: %w", i, err) @@ -109,8 +154,8 @@ func NewCedarAuthorizer(config CedarAuthorizerConfig) (*CedarAuthorizer, error) } // Load entities if provided - if config.EntitiesJSON != "" { - if err := json.Unmarshal([]byte(config.EntitiesJSON), &authorizer.entities); err != nil { + if options.EntitiesJSON != "" { + if err := json.Unmarshal([]byte(options.EntitiesJSON), &authorizer.entities); err != nil { return nil, fmt.Errorf("failed to parse entities JSON: %w", err) } } @@ -119,7 +164,7 @@ func NewCedarAuthorizer(config CedarAuthorizerConfig) (*CedarAuthorizer, error) } // UpdatePolicies updates the Cedar policies. -func (a *CedarAuthorizer) UpdatePolicies(policies []string) error { +func (a *Authorizer) UpdatePolicies(policies []string) error { a.mu.Lock() defer a.mu.Unlock() @@ -144,7 +189,7 @@ func (a *CedarAuthorizer) UpdatePolicies(policies []string) error { } // UpdateEntities updates the Cedar entities. -func (a *CedarAuthorizer) UpdateEntities(entitiesJSON string) error { +func (a *Authorizer) UpdateEntities(entitiesJSON string) error { a.mu.Lock() defer a.mu.Unlock() @@ -158,7 +203,7 @@ func (a *CedarAuthorizer) UpdateEntities(entitiesJSON string) error { } // AddEntity adds or updates an entity in the authorizer's entity store. -func (a *CedarAuthorizer) AddEntity(entity cedar.Entity) { +func (a *Authorizer) AddEntity(entity cedar.Entity) { a.mu.Lock() defer a.mu.Unlock() @@ -166,7 +211,7 @@ func (a *CedarAuthorizer) AddEntity(entity cedar.Entity) { } // RemoveEntity removes an entity from the authorizer's entity store. -func (a *CedarAuthorizer) RemoveEntity(uid cedar.EntityUID) { +func (a *Authorizer) RemoveEntity(uid cedar.EntityUID) { a.mu.Lock() defer a.mu.Unlock() @@ -174,7 +219,7 @@ func (a *CedarAuthorizer) RemoveEntity(uid cedar.EntityUID) { } // GetEntity retrieves an entity from the authorizer's entity store. -func (a *CedarAuthorizer) GetEntity(uid cedar.EntityUID) (cedar.Entity, bool) { +func (a *Authorizer) GetEntity(uid cedar.EntityUID) (cedar.Entity, bool) { a.mu.RLock() defer a.mu.RUnlock() @@ -183,7 +228,7 @@ func (a *CedarAuthorizer) GetEntity(uid cedar.EntityUID) (cedar.Entity, bool) { } // GetEntityFactory returns the entity factory associated with this authorizer. -func (a *CedarAuthorizer) GetEntityFactory() *EntityFactory { +func (a *Authorizer) GetEntityFactory() *EntityFactory { return a.entityFactory } @@ -195,7 +240,7 @@ func (a *CedarAuthorizer) GetEntityFactory() *EntityFactory { // - resource: The object being accessed (e.g., "Tool::weather") // - context: Additional information about the request // - entities: Optional Cedar entity map with attributes -func (a *CedarAuthorizer) IsAuthorized( +func (a *Authorizer) IsAuthorized( principal, action, resource string, contextMap map[string]interface{}, entities ...cedar.EntityMap, @@ -341,7 +386,7 @@ func mergeContexts(contextMaps ...map[string]interface{}) map[string]interface{} // authorizeToolCall authorizes a tool call operation. // This method is used when a client tries to call a specific tool. // It checks if the client is authorized to call the tool with the given context. -func (a *CedarAuthorizer) authorizeToolCall( +func (a *Authorizer) authorizeToolCall( clientID, toolName string, claimsMap map[string]interface{}, attrsMap map[string]interface{}, @@ -377,7 +422,7 @@ func (a *CedarAuthorizer) authorizeToolCall( // authorizePromptGet authorizes a prompt get operation. // This method is used when a client tries to get a specific prompt. // It checks if the client is authorized to access the prompt with the given context. -func (a *CedarAuthorizer) authorizePromptGet( +func (a *Authorizer) authorizePromptGet( clientID, promptName string, claimsMap map[string]interface{}, attrsMap map[string]interface{}, @@ -413,7 +458,7 @@ func (a *CedarAuthorizer) authorizePromptGet( // authorizeResourceRead authorizes a resource read operation. // This method is used when a client tries to read a specific resource. // It checks if the client is authorized to read the resource. -func (a *CedarAuthorizer) authorizeResourceRead( +func (a *Authorizer) authorizeResourceRead( clientID, resourceURI string, claimsMap map[string]interface{}, attrsMap map[string]interface{}, @@ -451,9 +496,9 @@ func (a *CedarAuthorizer) authorizeResourceRead( // authorizeFeatureList authorizes a list operation for a feature. // This method is used when a client tries to list available tools, prompts, or resources. // It checks if the client is authorized to list the specified feature type. -func (a *CedarAuthorizer) authorizeFeatureList( +func (a *Authorizer) authorizeFeatureList( clientID string, - feature MCPFeature, + feature authorizers.MCPFeature, claimsMap map[string]interface{}, attrsMap map[string]interface{}, ) (bool, error) { @@ -521,10 +566,10 @@ func sanitizeURIForCedar(uri string) string { // 3. Includes the JWT claims in the Cedar context // 4. Creates entities with appropriate attributes // 5. Authorizes the operation using the client ID and claims -func (a *CedarAuthorizer) AuthorizeWithJWTClaims( +func (a *Authorizer) AuthorizeWithJWTClaims( ctx context.Context, - feature MCPFeature, - operation MCPOperation, + feature authorizers.MCPFeature, + operation authorizers.MCPOperation, resourceID string, arguments map[string]interface{}, ) (bool, error) { @@ -547,19 +592,19 @@ func (a *CedarAuthorizer) AuthorizeWithJWTClaims( // Authorize based on the feature and operation switch { - case feature == MCPFeatureTool && operation == MCPOperationCall: + case feature == authorizers.MCPFeatureTool && operation == authorizers.MCPOperationCall: // Use the authorizeToolCall function for tool call operations return a.authorizeToolCall(clientID, resourceID, processedClaims, processedArgs) - case feature == MCPFeaturePrompt && operation == MCPOperationGet: + case feature == authorizers.MCPFeaturePrompt && operation == authorizers.MCPOperationGet: // Use the authorizePromptGet function for prompt get operations return a.authorizePromptGet(clientID, resourceID, processedClaims, processedArgs) - case feature == MCPFeatureResource && operation == MCPOperationRead: + case feature == authorizers.MCPFeatureResource && operation == authorizers.MCPOperationRead: // Use the authorizeResourceRead function for resource read operations return a.authorizeResourceRead(clientID, resourceID, processedClaims, processedArgs) - case operation == MCPOperationList: + case operation == authorizers.MCPOperationList: // Use the authorizeFeatureList function for list operations return a.authorizeFeatureList(clientID, feature, processedClaims, processedArgs) diff --git a/pkg/authz/authorizers/cedar/core_test.go b/pkg/authz/authorizers/cedar/core_test.go new file mode 100644 index 000000000..b9406338a --- /dev/null +++ b/pkg/authz/authorizers/cedar/core_test.go @@ -0,0 +1,954 @@ +package cedar + +import ( + "context" + "testing" + + "github.com/golang-jwt/jwt/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/stacklok/toolhive/pkg/auth" + "github.com/stacklok/toolhive/pkg/authz/authorizers" + "github.com/stacklok/toolhive/pkg/logger" +) + +// TestNewCedarAuthorizer tests the creation of a new Cedar authorizer with different configurations. +func TestNewCedarAuthorizer(t *testing.T) { + t.Parallel() + + // Initialize logger for tests + logger.Initialize() + // Test cases + testCases := []struct { + name string + policies []string + entitiesJSON string + expectError bool + errorType error + }{ + { + name: "Valid policy and empty entities", + policies: []string{`permit(principal, action, resource);`}, + entitiesJSON: `[]`, + expectError: false, + }, + { + name: "Multiple valid policies", + policies: []string{`permit(principal, action, resource);`, `forbid(principal, action, resource);`}, + entitiesJSON: `[]`, + expectError: false, + }, + { + name: "Invalid policy", + policies: []string{`invalid policy syntax`}, + entitiesJSON: `[]`, + expectError: true, + }, + { + name: "No policies", + policies: []string{}, + entitiesJSON: `[]`, + expectError: true, + errorType: ErrNoPolicies, + }, + { + name: "Invalid entities JSON", + policies: []string{`permit(principal, action, resource);`}, + entitiesJSON: `invalid json`, + expectError: true, + }, + { + name: "Valid policy and valid entities", + policies: []string{`permit(principal, action, resource);`}, + entitiesJSON: `[{"uid": {"type": "User", "id": "alice"}, "attrs": {}, "parents": []}]`, + expectError: false, + }, + } + + // Run test cases + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + // Create a Cedar authorizer + authorizer, err := NewCedarAuthorizer(ConfigOptions{ + Policies: tc.policies, + EntitiesJSON: tc.entitiesJSON, + }) + + // Check error expectations + if tc.expectError { + assert.Error(t, err, "Expected an error but got none") + if tc.errorType != nil { + assert.ErrorIs(t, err, tc.errorType, "Expected error type %v but got %v", tc.errorType, err) + } + assert.Nil(t, authorizer, "Expected nil authorizer when error occurs") + } else { + assert.NoError(t, err, "Unexpected error: %v", err) + require.NotNil(t, authorizer, "Cedar authorizer is nil") + } + }) + } +} + +// TestAuthorizeWithJWTClaims tests the AuthorizeWithJWTClaims function with different roles in claims. +func TestAuthorizeWithJWTClaims(t *testing.T) { + t.Parallel() + // Test cases + testCases := []struct { + name string + policy string + claims jwt.MapClaims + feature authorizers.MCPFeature + operation authorizers.MCPOperation + resourceID string + arguments map[string]interface{} + expectAuthorized bool + }{ + { + name: "User with correct name can call weather tool", + policy: ` + permit( + principal, + action == Action::"call_tool", + resource == Tool::"weather" + ) + when { + context.claim_name == "John Doe" + }; + `, + claims: jwt.MapClaims{ + "sub": "user123", + "name": "John Doe", + "roles": []string{"user", "reader"}, + }, + feature: authorizers.MCPFeatureTool, + operation: authorizers.MCPOperationCall, + resourceID: "weather", + arguments: nil, + expectAuthorized: true, + }, + { + name: "User with incorrect name cannot call weather tool", + policy: ` + permit( + principal, + action == Action::"call_tool", + resource == Tool::"weather" + ) + when { + context.claim_name == "John Doe" + }; + `, + claims: jwt.MapClaims{ + "sub": "user123", + "name": "Jane Smith", + "roles": []string{"user", "reader"}, + }, + feature: authorizers.MCPFeatureTool, + operation: authorizers.MCPOperationCall, + resourceID: "weather", + arguments: nil, + expectAuthorized: false, + }, + { + name: "Admin user can call any tool", + policy: ` + permit( + principal, + action == Action::"call_tool", + resource + ) + when { + context.claim_role == "admin" + }; + `, + claims: map[string]interface{}{ + "sub": "admin123", + "name": "Admin User", + "role": "admin", + }, + feature: authorizers.MCPFeatureTool, + operation: authorizers.MCPOperationCall, + resourceID: "any_tool", + arguments: nil, + expectAuthorized: true, + }, + { + name: "User with specific argument value can call tool", + policy: ` + permit( + principal, + action == Action::"call_tool", + resource == Tool::"calculator" + ) + when { + context.arg_operation == "add" && context.arg_value1 == 5 + }; + `, + claims: map[string]interface{}{ + "sub": "user123", + "name": "John Doe", + }, + feature: authorizers.MCPFeatureTool, + operation: authorizers.MCPOperationCall, + resourceID: "calculator", + arguments: map[string]interface{}{ + "operation": "add", + "value1": 5, + "value2": 10, + }, + expectAuthorized: true, + }, + { + name: "User with specific role in array can access resource", + policy: ` + permit( + principal, + action == Action::"read_resource", + resource == Resource::"sensitive_data" + ) + when { + context.claim_groups.contains("editor") + }; + `, + claims: jwt.MapClaims{ + "sub": "user123", + "name": "John Doe", + "groups": []string{"reader", "editor", "viewer"}, + }, + feature: authorizers.MCPFeatureResource, + operation: authorizers.MCPOperationRead, + resourceID: "sensitive_data", + arguments: nil, + expectAuthorized: true, + }, + { + name: "User can get prompt", + policy: ` + permit( + principal, + action == Action::"get_prompt", + resource == Prompt::"greeting" + ); + `, + claims: jwt.MapClaims{ + "sub": "user123", + "name": "John Doe", + "role": "user", + }, + feature: authorizers.MCPFeaturePrompt, + operation: authorizers.MCPOperationGet, + resourceID: "greeting", + arguments: nil, + expectAuthorized: true, + }, + { + name: "User can list tools", + policy: ` + permit( + principal, + action == Action::"list_tools", + resource == FeatureType::"tool" + ); + `, + claims: jwt.MapClaims{ + "sub": "user123", + "name": "John Doe", + "role": "user", + }, + feature: authorizers.MCPFeatureTool, + operation: authorizers.MCPOperationList, + resourceID: "", + arguments: nil, + expectAuthorized: true, + }, + { + name: "User can list prompts", + policy: ` + permit( + principal, + action == Action::"list_prompts", + resource == FeatureType::"prompt" + ); + `, + claims: jwt.MapClaims{ + "sub": "user123", + "name": "John Doe", + "role": "user", + }, + feature: authorizers.MCPFeaturePrompt, + operation: authorizers.MCPOperationList, + resourceID: "", + arguments: nil, + expectAuthorized: true, + }, + { + name: "User can list resources", + policy: ` + permit( + principal, + action == Action::"list_resources", + resource == FeatureType::"resource" + ); + `, + claims: jwt.MapClaims{ + "sub": "user123", + "name": "John Doe", + "role": "user", + }, + feature: authorizers.MCPFeatureResource, + operation: authorizers.MCPOperationList, + resourceID: "", + arguments: nil, + expectAuthorized: true, + }, + } + + // Run test cases + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + // Create a context + ctx := context.Background() + + // Create a Cedar authorizer + authorizer, err := NewCedarAuthorizer(ConfigOptions{ + Policies: []string{tc.policy}, + EntitiesJSON: `[]`, + }) + require.NoError(t, err, "Failed to create Cedar authorizer") + + // Create a context with JWT claims + identity := &auth.Identity{Subject: "test-user", Claims: tc.claims} + claimsCtx := auth.WithIdentity(ctx, identity) + + // Test authorization + authorized, err := authorizer.AuthorizeWithJWTClaims(claimsCtx, tc.feature, tc.operation, tc.resourceID, tc.arguments) + assert.NoError(t, err, "Authorization error") + assert.Equal(t, tc.expectAuthorized, authorized, "Authorization result does not match expectation") + }) + } +} + +// TestAuthorizeWithJWTClaimsErrors tests error cases for AuthorizeWithJWTClaims. +func TestAuthorizeWithJWTClaimsErrors(t *testing.T) { + t.Parallel() + // Create a context + ctx := context.Background() + + // Create a Cedar authorizer + authorizer, err := NewCedarAuthorizer(ConfigOptions{ + Policies: []string{`permit(principal, action, resource);`}, + EntitiesJSON: `[]`, + }) + require.NoError(t, err, "Failed to create Cedar authorizer") + + // Test cases + testCases := []struct { + name string + setupCtx func(context.Context) context.Context + feature authorizers.MCPFeature + operation authorizers.MCPOperation + resourceID string + arguments map[string]interface{} + expectError bool + errorType error + }{ + { + name: "Missing claims in context", + setupCtx: func(ctx context.Context) context.Context { + // Don't add claims to the context + return ctx + }, + feature: authorizers.MCPFeatureTool, + operation: authorizers.MCPOperationCall, + resourceID: "weather", + arguments: nil, + expectError: true, + errorType: ErrMissingPrincipal, + }, + { + name: "Missing sub claim", + setupCtx: func(ctx context.Context) context.Context { + // Add claims without sub + claims := jwt.MapClaims{ + "name": "John Doe", + "role": "user", + } + identity := &auth.Identity{Subject: "", Claims: claims} + return auth.WithIdentity(ctx, identity) + }, + feature: authorizers.MCPFeatureTool, + operation: authorizers.MCPOperationCall, + resourceID: "weather", + arguments: nil, + expectError: true, + errorType: ErrMissingPrincipal, + }, + { + name: "Empty sub claim", + setupCtx: func(ctx context.Context) context.Context { + // Add claims with empty sub + claims := jwt.MapClaims{ + "sub": "", + "name": "John Doe", + "role": "user", + } + identity := &auth.Identity{Subject: "", Claims: claims} + return auth.WithIdentity(ctx, identity) + }, + feature: authorizers.MCPFeatureTool, + operation: authorizers.MCPOperationCall, + resourceID: "weather", + arguments: nil, + expectError: true, + errorType: ErrMissingPrincipal, + }, + { + name: "Unsupported feature/operation combination", + setupCtx: func(ctx context.Context) context.Context { + // Add valid claims + claims := jwt.MapClaims{ + "sub": "user123", + "name": "John Doe", + "role": "user", + } + identity := &auth.Identity{Subject: "user123", Claims: claims} + return auth.WithIdentity(ctx, identity) + }, + feature: "invalid_feature", + operation: "invalid_operation", + resourceID: "resource", + arguments: nil, + expectError: true, + }, + } + + // Run test cases + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + // Setup context + testCtx := tc.setupCtx(ctx) + + // Test authorization + _, err := authorizer.AuthorizeWithJWTClaims(testCtx, tc.feature, tc.operation, tc.resourceID, tc.arguments) + assert.Error(t, err, "Expected an error") + if tc.errorType != nil { + assert.ErrorIs(t, err, tc.errorType, "Expected error type %v but got %v", tc.errorType, err) + } + }) + } +} + +// TestExtractConfig tests the ExtractConfig function +func TestExtractConfig(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + config *authorizers.Config + expectError bool + errorMsg string + }{ + { + name: "Nil config", + config: nil, + expectError: true, + errorMsg: "config is nil", + }, + { + name: "Empty raw config", + config: &authorizers.Config{ + Version: "1.0", + Type: ConfigType, + }, + expectError: true, + errorMsg: "config has no raw data", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + config, err := ExtractConfig(tc.config) + + if tc.expectError { + assert.Error(t, err) + if tc.errorMsg != "" { + assert.Contains(t, err.Error(), tc.errorMsg) + } + assert.Nil(t, config) + return + } + + require.NoError(t, err) + require.NotNil(t, config) + }) + } +} + +// TestExtractConfigValid tests ExtractConfig with a valid config +func TestExtractConfigValid(t *testing.T) { + t.Parallel() + + // Create a valid Cedar config + cedarConfig := Config{ + Version: "1.0", + Type: ConfigType, + Options: &ConfigOptions{ + Policies: []string{`permit(principal, action, resource);`}, + EntitiesJSON: "[]", + }, + } + + // Create an authorizers.Config from it + authzConfig, err := authorizers.NewConfig(cedarConfig) + require.NoError(t, err) + + // Extract the Cedar config + extracted, err := ExtractConfig(authzConfig) + require.NoError(t, err) + require.NotNil(t, extracted) + require.NotNil(t, extracted.Options) + assert.Equal(t, cedarConfig.Version, extracted.Version) + assert.Equal(t, cedarConfig.Type, extracted.Type) + assert.Equal(t, cedarConfig.Options.Policies, extracted.Options.Policies) +} + +// TestExtractConfigMissingCedarField tests ExtractConfig with missing cedar field +func TestExtractConfigMissingCedarField(t *testing.T) { + t.Parallel() + + // Create a config without the cedar field + authzConfig, err := authorizers.NewConfig(map[string]interface{}{ + "version": "1.0", + "type": ConfigType, + // No "cedar" field + }) + require.NoError(t, err) + + // Extract should fail + _, err = ExtractConfig(authzConfig) + assert.Error(t, err) + assert.Contains(t, err.Error(), "cedar config is nil") +} + +// TestFactoryValidateConfig tests the Factory.ValidateConfig method +func TestFactoryValidateConfig(t *testing.T) { + t.Parallel() + + factory := &Factory{} + + testCases := []struct { + name string + rawConfig string + expectError bool + errorMsg string + }{ + { + name: "Invalid JSON", + rawConfig: `{"invalid`, + expectError: true, + errorMsg: "failed to parse configuration", + }, + { + name: "Missing cedar field", + rawConfig: `{"version":"1.0","type":"cedarv1"}`, + expectError: true, + errorMsg: "cedar configuration is required", + }, + { + name: "Empty policies", + rawConfig: `{"version":"1.0","type":"cedarv1","cedar":{"policies":[]}}`, + expectError: true, + errorMsg: "at least one policy is required", + }, + { + name: "Valid config", + rawConfig: `{"version":"1.0","type":"cedarv1","cedar":{"policies":["permit(principal, action, resource);"]}}`, + expectError: false, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + err := factory.ValidateConfig([]byte(tc.rawConfig)) + + if tc.expectError { + assert.Error(t, err) + if tc.errorMsg != "" { + assert.Contains(t, err.Error(), tc.errorMsg) + } + return + } + + assert.NoError(t, err) + }) + } +} + +// TestFactoryCreateAuthorizer tests the Factory.CreateAuthorizer method +func TestFactoryCreateAuthorizer(t *testing.T) { + t.Parallel() + + factory := &Factory{} + + testCases := []struct { + name string + rawConfig string + expectError bool + errorMsg string + }{ + { + name: "Invalid JSON", + rawConfig: `{"invalid`, + expectError: true, + errorMsg: "failed to parse configuration", + }, + { + name: "Missing cedar field", + rawConfig: `{"version":"1.0","type":"cedarv1"}`, + expectError: true, + errorMsg: "cedar configuration is required", + }, + { + name: "Valid config", + rawConfig: `{"version":"1.0","type":"cedarv1","cedar":{"policies":["permit(principal, action, resource);"]}}`, + expectError: false, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + authorizer, err := factory.CreateAuthorizer([]byte(tc.rawConfig)) + + if tc.expectError { + assert.Error(t, err) + if tc.errorMsg != "" { + assert.Contains(t, err.Error(), tc.errorMsg) + } + assert.Nil(t, authorizer) + return + } + + require.NoError(t, err) + require.NotNil(t, authorizer) + }) + } +} + +//nolint:paralleltest,tparallel // Subtests cannot be parallelized as they modify shared authorizer state +func TestUpdatePolicies(t *testing.T) { + t.Parallel() + + // Create a Cedar authorizer + authorizer, err := NewCedarAuthorizer(ConfigOptions{ + Policies: []string{`permit(principal, action, resource);`}, + EntitiesJSON: `[]`, + }) + require.NoError(t, err) + + // Cast to concrete type to access UpdatePolicies + cedarAuthorizer, ok := authorizer.(*Authorizer) + require.True(t, ok) + + testCases := []struct { + name string + policies []string + expectError bool + errorType error + }{ + { + name: "Empty policies", + policies: []string{}, + expectError: true, + errorType: ErrNoPolicies, + }, + { + name: "Invalid policy", + policies: []string{`invalid policy syntax`}, + expectError: true, + }, + { + name: "Valid policy", + policies: []string{`forbid(principal, action, resource);`}, + expectError: false, + }, + { + name: "Multiple valid policies", + policies: []string{`permit(principal, action, resource);`, `forbid(principal == Client::"evil", action, resource);`}, + expectError: false, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + err := cedarAuthorizer.UpdatePolicies(tc.policies) + + if tc.expectError { + assert.Error(t, err) + if tc.errorType != nil { + assert.ErrorIs(t, err, tc.errorType) + } + return + } + + assert.NoError(t, err) + }) + } +} + +//nolint:paralleltest,tparallel // Subtests cannot be parallelized as they modify shared authorizer state +func TestUpdateEntities(t *testing.T) { + t.Parallel() + + // Create a Cedar authorizer + authorizer, err := NewCedarAuthorizer(ConfigOptions{ + Policies: []string{`permit(principal, action, resource);`}, + EntitiesJSON: `[]`, + }) + require.NoError(t, err) + + // Cast to concrete type to access UpdateEntities + cedarAuthorizer, ok := authorizer.(*Authorizer) + require.True(t, ok) + + testCases := []struct { + name string + entitiesJSON string + expectError bool + }{ + { + name: "Invalid JSON", + entitiesJSON: `invalid`, + expectError: true, + }, + { + name: "Empty array", + entitiesJSON: `[]`, + expectError: false, + }, + { + name: "Valid entities", + entitiesJSON: `[{"uid": {"type": "User", "id": "alice"}, "attrs": {}, "parents": []}]`, + expectError: false, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + err := cedarAuthorizer.UpdateEntities(tc.entitiesJSON) + + if tc.expectError { + assert.Error(t, err) + return + } + + assert.NoError(t, err) + }) + } +} + +// TestEntityOperations tests AddEntity, RemoveEntity, and GetEntity methods +func TestEntityOperations(t *testing.T) { + t.Parallel() + + // Create a Cedar authorizer + authorizer, err := NewCedarAuthorizer(ConfigOptions{ + Policies: []string{`permit(principal, action, resource);`}, + EntitiesJSON: `[]`, + }) + require.NoError(t, err) + + // Cast to concrete type to access entity methods + cedarAuthorizer, ok := authorizer.(*Authorizer) + require.True(t, ok) + + // Get entity factory + factory := cedarAuthorizer.GetEntityFactory() + require.NotNil(t, factory) + + // Create a test entity using the factory + uid, entity := factory.CreatePrincipalEntity("Client", "testuser", map[string]interface{}{ + "name": "Test User", + }) + + // Add entity + cedarAuthorizer.AddEntity(entity) + + // Get entity + retrieved, found := cedarAuthorizer.GetEntity(uid) + assert.True(t, found) + assert.Equal(t, uid, retrieved.UID) + + // Remove entity + cedarAuthorizer.RemoveEntity(uid) + + // Verify entity is removed + _, found = cedarAuthorizer.GetEntity(uid) + assert.False(t, found) +} + +// TestGetEntityNotFound tests GetEntity for a non-existent entity +func TestGetEntityNotFound(t *testing.T) { + t.Parallel() + + // Create a Cedar authorizer + authorizer, err := NewCedarAuthorizer(ConfigOptions{ + Policies: []string{`permit(principal, action, resource);`}, + EntitiesJSON: `[]`, + }) + require.NoError(t, err) + + // Cast to concrete type + cedarAuthorizer, ok := authorizer.(*Authorizer) + require.True(t, ok) + + // Create a UID that doesn't exist + factory := cedarAuthorizer.GetEntityFactory() + uid, _ := factory.CreatePrincipalEntity("Client", "nonexistent", nil) + + // Try to get it + _, found := cedarAuthorizer.GetEntity(uid) + assert.False(t, found) +} + +// TestIsAuthorizedErrors tests error cases for IsAuthorized +func TestIsAuthorizedErrors(t *testing.T) { + t.Parallel() + + // Create a Cedar authorizer + authorizer, err := NewCedarAuthorizer(ConfigOptions{ + Policies: []string{`permit(principal, action, resource);`}, + EntitiesJSON: `[]`, + }) + require.NoError(t, err) + + // Cast to concrete type + cedarAuthorizer, ok := authorizer.(*Authorizer) + require.True(t, ok) + + testCases := []struct { + name string + principal string + action string + resource string + expectError bool + errorType error + }{ + { + name: "Empty principal", + principal: "", + action: "Action::test", + resource: "Resource::test", + expectError: true, + errorType: ErrMissingPrincipal, + }, + { + name: "Empty action", + principal: "Client::test", + action: "", + resource: "Resource::test", + expectError: true, + errorType: ErrMissingAction, + }, + { + name: "Empty resource", + principal: "Client::test", + action: "Action::test", + resource: "", + expectError: true, + errorType: ErrMissingResource, + }, + { + name: "Invalid principal format", + principal: "invalid", + action: "Action::test", + resource: "Resource::test", + expectError: true, + }, + { + name: "Invalid action format", + principal: "Client::test", + action: "invalid", + resource: "Resource::test", + expectError: true, + }, + { + name: "Invalid resource format", + principal: "Client::test", + action: "Action::test", + resource: "invalid", + expectError: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + _, err := cedarAuthorizer.IsAuthorized(tc.principal, tc.action, tc.resource, nil) + + if tc.expectError { + assert.Error(t, err) + if tc.errorType != nil { + assert.ErrorIs(t, err, tc.errorType) + } + return + } + + assert.NoError(t, err) + }) + } +} + +// TestIsAuthorizedWithEntities tests IsAuthorized with custom entities +func TestIsAuthorizedWithEntities(t *testing.T) { + t.Parallel() + + // Create a Cedar authorizer with a policy that checks entity attributes + authorizer, err := NewCedarAuthorizer(ConfigOptions{ + Policies: []string{` + permit( + principal, + action == Action::"call_tool", + resource + ); + `}, + EntitiesJSON: `[]`, + }) + require.NoError(t, err) + + // Cast to concrete type + cedarAuthorizer, ok := authorizer.(*Authorizer) + require.True(t, ok) + + // Get factory and create entities + factory := cedarAuthorizer.GetEntityFactory() + entities, err := factory.CreateEntitiesForRequest( + "Client::testuser", + "Action::call_tool", + "Tool::weather", + map[string]interface{}{"name": "Test User"}, + map[string]interface{}{"name": "weather"}, + ) + require.NoError(t, err) + + // Test authorization with custom entities + authorized, err := cedarAuthorizer.IsAuthorized( + "Client::testuser", + "Action::call_tool", + "Tool::weather", + map[string]interface{}{}, + entities, + ) + assert.NoError(t, err) + assert.True(t, authorized) +} diff --git a/pkg/authz/cedar_entity.go b/pkg/authz/authorizers/cedar/entity.go similarity index 98% rename from pkg/authz/cedar_entity.go rename to pkg/authz/authorizers/cedar/entity.go index adef7aa74..ddcfcf558 100644 --- a/pkg/authz/cedar_entity.go +++ b/pkg/authz/authorizers/cedar/entity.go @@ -1,5 +1,5 @@ -// Package authz provides authorization utilities using Cedar policies. -package authz +// Package cedar provides authorization utilities using Cedar policies. +package cedar import ( cedar "github.com/cedar-policy/cedar-go" diff --git a/pkg/authz/cedar_entities_test.go b/pkg/authz/authorizers/cedar/entity_test.go similarity index 99% rename from pkg/authz/cedar_entities_test.go rename to pkg/authz/authorizers/cedar/entity_test.go index 7c8ad81fa..6ceb5bf32 100644 --- a/pkg/authz/cedar_entities_test.go +++ b/pkg/authz/authorizers/cedar/entity_test.go @@ -1,4 +1,4 @@ -package authz +package cedar import ( "testing" diff --git a/pkg/authz/cedar_record_test.go b/pkg/authz/authorizers/cedar/record_test.go similarity index 99% rename from pkg/authz/cedar_record_test.go rename to pkg/authz/authorizers/cedar/record_test.go index aa914f2eb..349313741 100644 --- a/pkg/authz/cedar_record_test.go +++ b/pkg/authz/authorizers/cedar/record_test.go @@ -1,4 +1,4 @@ -package authz +package cedar import ( "math" diff --git a/pkg/authz/authorizers/config.go b/pkg/authz/authorizers/config.go new file mode 100644 index 000000000..9e40122a4 --- /dev/null +++ b/pkg/authz/authorizers/config.go @@ -0,0 +1,175 @@ +// Package authorizers provides the authorization framework and abstractions for ToolHive. +// It defines interfaces for authorization decisions and configuration handling. +package authorizers + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + + "sigs.k8s.io/yaml" +) + +// ConfigType represents the type of authorization configuration. +type ConfigType string + +// Config represents the authorization configuration. +// This struct contains the common fields (version/type) needed to identify +// which authorizer factory to use. The full raw configuration is preserved +// so that each authorizer implementation can parse it with domain-specific +// knowledge (e.g., Cedar configs have a "cedar" field at the top level). +type Config struct { + // Version is the version of the configuration format. + Version string `json:"version" yaml:"version"` + + // Type is the type of authorization configuration (e.g., "cedarv1"). + Type ConfigType `json:"type" yaml:"type"` + + // rawConfig stores the original raw configuration bytes for re-parsing + // by the authorizer factory with domain-specific knowledge. + rawConfig json.RawMessage +} + +// UnmarshalJSON implements custom JSON unmarshaling that preserves the raw config +// while extracting the version and type fields. +func (c *Config) UnmarshalJSON(data []byte) error { + // First, extract just version and type + var header struct { + Version string `json:"version"` + Type ConfigType `json:"type"` + } + if err := json.Unmarshal(data, &header); err != nil { + return err + } + + c.Version = header.Version + c.Type = header.Type + c.rawConfig = data + + return nil +} + +// MarshalJSON implements custom JSON marshaling. +// If we have the original raw config, use that to preserve all fields. +// Otherwise, just marshal version and type. +func (c *Config) MarshalJSON() ([]byte, error) { + if len(c.rawConfig) > 0 { + return c.rawConfig, nil + } + + // Fallback: just marshal version and type + type alias struct { + Version string `json:"version"` + Type ConfigType `json:"type"` + } + return json.Marshal(&alias{ + Version: c.Version, + Type: c.Type, + }) +} + +// RawConfig returns the raw configuration bytes for the authorizer factory +// to parse with domain-specific knowledge. +func (c *Config) RawConfig() json.RawMessage { + return c.rawConfig +} + +// LoadConfig loads the authorization configuration from a file. +// It supports both JSON and YAML formats, detected by file extension. +func LoadConfig(path string) (*Config, error) { + // Validate and clean the path to prevent directory traversal attacks + cleanPath := filepath.Clean(path) + if strings.Contains(cleanPath, "..") { + return nil, fmt.Errorf("path contains directory traversal elements: %s", path) + } + + // Read the file + data, err := os.ReadFile(cleanPath) + if err != nil { + return nil, fmt.Errorf("failed to read authorization configuration file: %w", err) + } + + // Determine the file format based on extension + var config Config + ext := strings.ToLower(filepath.Ext(cleanPath)) + + // Parse the file based on its format + switch ext { + case ".yaml", ".yml": + // Parse YAML - first convert to JSON for consistent handling + jsonData, err := yaml.YAMLToJSON(data) + if err != nil { + return nil, fmt.Errorf("failed to parse YAML authorization configuration file: %w", err) + } + if err := json.Unmarshal(jsonData, &config); err != nil { + return nil, fmt.Errorf("failed to parse authorization configuration: %w", err) + } + case ".json", "": + // Parse JSON (default if no extension) + if err := json.Unmarshal(data, &config); err != nil { + return nil, fmt.Errorf("failed to parse JSON authorization configuration file: %w", err) + } + default: + return nil, fmt.Errorf("unsupported file format: %s (supported formats: .json, .yaml, .yml)", ext) + } + + // Validate the configuration + if err := config.Validate(); err != nil { + return nil, err + } + + return &config, nil +} + +// Validate validates the authorization configuration. +func (c *Config) Validate() error { + // Check if the version is provided + if c.Version == "" { + return fmt.Errorf("version is required") + } + + // Check if the type is provided + if c.Type == "" { + return fmt.Errorf("type is required") + } + + // Get the factory for this config type + factory := GetFactory(string(c.Type)) + if factory == nil { + return fmt.Errorf("unsupported configuration type: %s (registered types: %v)", + c.Type, RegisteredTypes()) + } + + // Check if we have raw config to validate + if len(c.rawConfig) == 0 { + return fmt.Errorf("configuration data is required for type %s", c.Type) + } + + // Delegate validation to the authorizer factory, passing the full raw config + if err := factory.ValidateConfig(c.rawConfig); err != nil { + return fmt.Errorf("invalid %s configuration: %w", c.Type, err) + } + + return nil +} + +// NewConfig creates a new Config from a full configuration structure. +// The fullConfig parameter should be the complete configuration including +// version, type, and authorizer-specific fields (e.g., "cedar" field for Cedar configs). +// This maintains backwards compatibility with the v1.0 configuration schema. +func NewConfig(fullConfig interface{}) (*Config, error) { + rawConfig, err := json.Marshal(fullConfig) + if err != nil { + return nil, fmt.Errorf("failed to marshal configuration: %w", err) + } + + // Parse the raw config to extract version and type + var config Config + if err := json.Unmarshal(rawConfig, &config); err != nil { + return nil, fmt.Errorf("failed to parse configuration: %w", err) + } + + return &config, nil +} diff --git a/pkg/authz/authorizers/config_test.go b/pkg/authz/authorizers/config_test.go new file mode 100644 index 000000000..8228ab686 --- /dev/null +++ b/pkg/authz/authorizers/config_test.go @@ -0,0 +1,451 @@ +package authorizers + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// testConfigType is a test configuration type registered for testing +const testConfigType = "test-config-type" + +// testFactory is a simple test factory for config tests +type testFactory struct{} + +func (*testFactory) ValidateConfig(rawConfig json.RawMessage) error { + var config struct { + TestField string `json:"test_field"` + } + return json.Unmarshal(rawConfig, &config) +} + +func (*testFactory) CreateAuthorizer(_ json.RawMessage) (Authorizer, error) { + return &testAuthorizer{}, nil +} + +type testAuthorizer struct{} + +func (*testAuthorizer) AuthorizeWithJWTClaims( + _ context.Context, + _ MCPFeature, + _ MCPOperation, + _ string, + _ map[string]interface{}, +) (bool, error) { + return true, nil +} + +func init() { + // Register a test factory type for config tests + if !IsRegistered(testConfigType) { + Register(testConfigType, &testFactory{}) + } +} + +func TestConfigUnmarshalJSON(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + input string + expectedVersion string + expectedType ConfigType + expectError bool + }{ + { + name: "Valid configuration", + input: `{"version": "1.0", "type": "test-config-type", "test_field": "value"}`, + expectedVersion: "1.0", + expectedType: testConfigType, + expectError: false, + }, + { + name: "Minimal configuration", + input: `{"version": "2.0", "type": "customtype"}`, + expectedVersion: "2.0", + expectedType: "customtype", + expectError: false, + }, + { + name: "Invalid JSON", + input: `{"version": "1.0", "type":`, + expectError: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + var config Config + err := json.Unmarshal([]byte(tc.input), &config) + + if tc.expectError { + assert.Error(t, err) + return + } + + require.NoError(t, err) + assert.Equal(t, tc.expectedVersion, config.Version) + assert.Equal(t, tc.expectedType, config.Type) + // Verify raw config is preserved + assert.NotEmpty(t, config.rawConfig) + }) + } +} + +func TestConfigMarshalJSON(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + config Config + expectError bool + }{ + { + name: "Config with raw config", + config: Config{ + Version: "1.0", + Type: testConfigType, + rawConfig: json.RawMessage(`{"version":"1.0","type":"test-config-type","test_field":"value"}`), + }, + expectError: false, + }, + { + name: "Config without raw config (fallback)", + config: Config{ + Version: "1.0", + Type: testConfigType, + }, + expectError: false, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + data, err := json.Marshal(&tc.config) + + if tc.expectError { + assert.Error(t, err) + return + } + + require.NoError(t, err) + assert.NotEmpty(t, data) + + // Verify we can unmarshal the result + var result map[string]interface{} + err = json.Unmarshal(data, &result) + require.NoError(t, err) + assert.Equal(t, tc.config.Version, result["version"]) + assert.Equal(t, string(tc.config.Type), result["type"]) + }) + } +} + +func TestConfigRawConfig(t *testing.T) { + t.Parallel() + + rawData := json.RawMessage(`{"version":"1.0","type":"test-config-type"}`) + config := Config{ + Version: "1.0", + Type: testConfigType, + rawConfig: rawData, + } + + assert.Equal(t, rawData, config.RawConfig()) +} + +func TestLoadConfig(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + filename string + content string + expectError bool + errorMsg string + }{ + { + name: "Valid JSON config", + filename: "config.json", + content: `{"version": "1.0", "type": "test-config-type", "test_field": "value"}`, + }, + { + name: "Valid YAML config", + filename: "config.yaml", + content: `version: "1.0" +type: test-config-type +test_field: value`, + }, + { + name: "Valid YML config", + filename: "config.yml", + content: `version: "1.0" +type: test-config-type +test_field: value`, + }, + { + name: "Invalid JSON", + filename: "invalid.json", + content: `{"version": "1.0"`, + expectError: true, + errorMsg: "failed to parse JSON", + }, + { + name: "Invalid YAML", + filename: "invalid.yaml", + content: "version: [invalid", + expectError: true, + errorMsg: "failed to parse YAML", + }, + { + name: "Unsupported extension", + filename: "config.txt", + content: `{"version": "1.0", "type": "test-config-type"}`, + expectError: true, + errorMsg: "unsupported file format", + }, + { + name: "Missing version", + filename: "missing_version.json", + content: `{"type": "test-config-type", "test_field": "value"}`, + expectError: true, + errorMsg: "version is required", + }, + { + name: "Missing type", + filename: "missing_type.json", + content: `{"version": "1.0", "test_field": "value"}`, + expectError: true, + errorMsg: "type is required", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // Create temp directory + tmpDir, err := os.MkdirTemp("", "authz-config-test") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + // Write test file + filePath := filepath.Join(tmpDir, tc.filename) + err = os.WriteFile(filePath, []byte(tc.content), 0600) + require.NoError(t, err) + + // Load config + config, err := LoadConfig(filePath) + + if tc.expectError { + assert.Error(t, err) + if tc.errorMsg != "" { + assert.Contains(t, err.Error(), tc.errorMsg) + } + return + } + + require.NoError(t, err) + require.NotNil(t, config) + assert.Equal(t, "1.0", config.Version) + assert.Equal(t, ConfigType(testConfigType), config.Type) + }) + } +} + +func TestLoadConfigPathTraversal(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + path string + expectError bool + errorMsg string + }{ + { + name: "Directory traversal", + path: "../../../etc/passwd", + expectError: true, + errorMsg: "directory traversal", + }, + { + name: "Multiple traversals", + path: "../../../../../../etc/passwd", + expectError: true, + errorMsg: "directory traversal", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + _, err := LoadConfig(tc.path) + + if tc.expectError { + assert.Error(t, err) + if tc.errorMsg != "" { + assert.Contains(t, err.Error(), tc.errorMsg) + } + } + }) + } +} + +func TestLoadConfigNonExistentFile(t *testing.T) { + t.Parallel() + + _, err := LoadConfig("/nonexistent/path/config.json") + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to read authorization configuration file") +} + +func TestConfigValidate(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + config Config + expectError bool + errorMsg string + }{ + { + name: "Missing version", + config: Config{ + Type: testConfigType, + rawConfig: json.RawMessage(`{"type":"test-config-type"}`), + }, + expectError: true, + errorMsg: "version is required", + }, + { + name: "Missing type", + config: Config{ + Version: "1.0", + rawConfig: json.RawMessage(`{"version":"1.0"}`), + }, + expectError: true, + errorMsg: "type is required", + }, + { + name: "Unsupported type", + config: Config{ + Version: "1.0", + Type: "unsupported", + rawConfig: json.RawMessage(`{"version":"1.0","type":"unsupported"}`), + }, + expectError: true, + errorMsg: "unsupported configuration type", + }, + { + name: "Missing raw config", + config: Config{ + Version: "1.0", + Type: testConfigType, + // No rawConfig + }, + expectError: true, + errorMsg: "configuration data is required", + }, + { + name: "Valid config", + config: Config{ + Version: "1.0", + Type: testConfigType, + rawConfig: json.RawMessage(`{"version":"1.0","type":"test-config-type","test_field":"value"}`), + }, + expectError: false, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + err := tc.config.Validate() + + if tc.expectError { + assert.Error(t, err) + if tc.errorMsg != "" { + assert.Contains(t, err.Error(), tc.errorMsg) + } + return + } + + assert.NoError(t, err) + }) + } +} + +func TestNewConfig(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + fullConfig interface{} + expectError bool + expectedVersion string + expectedType ConfigType + }{ + { + name: "Map config", + fullConfig: map[string]interface{}{ + "version": "1.0", + "type": testConfigType, + "test_field": "value", + }, + expectedVersion: "1.0", + expectedType: testConfigType, + }, + { + name: "Struct config", + fullConfig: struct { + Version string `json:"version"` + Type string `json:"type"` + }{ + Version: "2.0", + Type: "testtype", + }, + expectedVersion: "2.0", + expectedType: "testtype", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + config, err := NewConfig(tc.fullConfig) + + if tc.expectError { + assert.Error(t, err) + return + } + + require.NoError(t, err) + require.NotNil(t, config) + assert.Equal(t, tc.expectedVersion, config.Version) + assert.Equal(t, tc.expectedType, config.Type) + assert.NotEmpty(t, config.RawConfig()) + }) + } +} + +func TestNewConfigWithInvalidInput(t *testing.T) { + t.Parallel() + + // Test with something that can't be marshaled to JSON + // Using a channel, which cannot be marshaled + _, err := NewConfig(make(chan int)) + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to marshal configuration") +} diff --git a/pkg/authz/authorizers/core.go b/pkg/authz/authorizers/core.go new file mode 100644 index 000000000..e63a0520e --- /dev/null +++ b/pkg/authz/authorizers/core.go @@ -0,0 +1,53 @@ +package authorizers + +import ( + "context" +) + +// MCPFeature represents an MCP feature type. +// In the MCP protocol, there are three main features: +// - Tools: Allow models to call functions in external systems +// - Prompts: Provide structured templates for interacting with language models +// - Resources: Share data that provides context to language models +type MCPFeature string + +const ( + // MCPFeatureTool represents the MCP tool feature. + MCPFeatureTool MCPFeature = "tool" + // MCPFeaturePrompt represents the MCP prompt feature. + MCPFeaturePrompt MCPFeature = "prompt" + // MCPFeatureResource represents the MCP resource feature. + MCPFeatureResource MCPFeature = "resource" +) + +// MCPOperation represents an operation on an MCP feature. +// Each feature supports different operations: +// - List: Get a list of available items (tools, prompts, resources) +// - Get: Get a specific prompt +// - Call: Call a specific tool +// - Read: Read a specific resource +type MCPOperation string + +const ( + // MCPOperationList represents a list operation. + MCPOperationList MCPOperation = "list" + // MCPOperationGet represents a get operation. + MCPOperationGet MCPOperation = "get" + // MCPOperationCall represents a call operation. + MCPOperationCall MCPOperation = "call" + // MCPOperationRead represents a read operation. + MCPOperationRead MCPOperation = "read" +) + +// Authorizer defines the interface for making authorization decisions. +// Implementations of this interface evaluate whether a given operation on an MCP feature +// should be permitted, based on JWT claims and the specific resource being accessed. +type Authorizer interface { + AuthorizeWithJWTClaims( + ctx context.Context, + feature MCPFeature, + operation MCPOperation, + resourceID string, + arguments map[string]interface{}, + ) (bool, error) +} diff --git a/pkg/authz/authorizers/registry.go b/pkg/authz/authorizers/registry.go new file mode 100644 index 000000000..e28b08db2 --- /dev/null +++ b/pkg/authz/authorizers/registry.go @@ -0,0 +1,70 @@ +package authorizers + +import ( + "encoding/json" + "fmt" + "sync" +) + +// AuthorizerFactory is the interface that authorizer implementations must satisfy +// to register themselves with the authorizers registry. Each authorizer type +// (e.g., Cedar, OPA) implements this interface to provide validation and +// instantiation of authorizers from their specific configuration format. +type AuthorizerFactory interface { + // ValidateConfig validates the authorizer-specific configuration. + // The rawConfig is the JSON-encoded authorizer configuration. + ValidateConfig(rawConfig json.RawMessage) error + + // CreateAuthorizer creates an Authorizer instance from the configuration. + // The rawConfig is the JSON-encoded authorizer configuration. + CreateAuthorizer(rawConfig json.RawMessage) (Authorizer, error) +} + +// registry holds the registered authorizer factories, keyed by config type. +var ( + registryMu sync.RWMutex + registry = make(map[string]AuthorizerFactory) +) + +// Register registers an AuthorizerFactory for the given config type. +// This is typically called from an init() function in the authorizer package. +// It panics if a factory is already registered for the given type. +func Register(configType string, factory AuthorizerFactory) { + registryMu.Lock() + defer registryMu.Unlock() + + if _, exists := registry[configType]; exists { + panic(fmt.Sprintf("authorizer factory already registered for type: %s", configType)) + } + registry[configType] = factory +} + +// GetFactory returns the AuthorizerFactory for the given config type. +// Returns nil if no factory is registered for the type. +func GetFactory(configType string) AuthorizerFactory { + registryMu.RLock() + defer registryMu.RUnlock() + + return registry[configType] +} + +// IsRegistered returns true if a factory is registered for the given config type. +func IsRegistered(configType string) bool { + registryMu.RLock() + defer registryMu.RUnlock() + + _, exists := registry[configType] + return exists +} + +// RegisteredTypes returns a list of all registered config types. +func RegisteredTypes() []string { + registryMu.RLock() + defer registryMu.RUnlock() + + types := make([]string, 0, len(registry)) + for t := range registry { + types = append(types, t) + } + return types +} diff --git a/pkg/authz/authorizers/registry_test.go b/pkg/authz/authorizers/registry_test.go new file mode 100644 index 000000000..c4d48d818 --- /dev/null +++ b/pkg/authz/authorizers/registry_test.go @@ -0,0 +1,124 @@ +package authorizers + +import ( + "context" + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" +) + +// mockFactory is a test implementation of AuthorizerFactory +type mockFactory struct { + validateErr error + createErr error + authorizer Authorizer +} + +func (f *mockFactory) ValidateConfig(_ json.RawMessage) error { + return f.validateErr +} + +func (f *mockFactory) CreateAuthorizer(_ json.RawMessage) (Authorizer, error) { + if f.createErr != nil { + return nil, f.createErr + } + return f.authorizer, nil +} + +// mockAuthorizer is a test implementation of Authorizer +type mockAuthorizer struct{} + +func (*mockAuthorizer) AuthorizeWithJWTClaims( + _ context.Context, + _ MCPFeature, + _ MCPOperation, + _ string, + _ map[string]interface{}, +) (bool, error) { + return true, nil +} + +func TestGetFactory(t *testing.T) { + t.Parallel() + + // Test getting a non-existent factory + factory := GetFactory("nonexistent") + assert.Nil(t, factory, "Expected nil for non-existent factory") +} + +func TestIsRegistered(t *testing.T) { + t.Parallel() + + // Test non-existent type + assert.False(t, IsRegistered("nonexistent"), "Expected false for non-existent type") +} + +func TestRegisteredTypes(t *testing.T) { + t.Parallel() + + // RegisteredTypes should return a list (even if empty) + types := RegisteredTypes() + assert.NotNil(t, types, "Expected non-nil list of types") +} + +//nolint:paralleltest // This test modifies global registry state and cannot be parallelized +func TestRegisterNewType(t *testing.T) { + // Register a new type that doesn't exist + testType := "test-authorizer-type-unique" + + // First verify it's not registered (might already be from a previous test run, skip if so) + if IsRegistered(testType) { + t.Skip("Type already registered from previous test run") + } + + // Register the new type + mockFactory := &mockFactory{ + authorizer: &mockAuthorizer{}, + } + Register(testType, mockFactory) + + // Verify it's now registered + assert.True(t, IsRegistered(testType), "Type should be registered after Register") + + // Verify we can get the factory + factory := GetFactory(testType) + assert.NotNil(t, factory, "Factory should be retrievable") + assert.Equal(t, mockFactory, factory, "Factory should match what was registered") + + // Verify it appears in RegisteredTypes + types := RegisteredTypes() + found := false + for _, typ := range types { + if typ == testType { + found = true + break + } + } + assert.True(t, found, "Expected %s to be in registered types", testType) +} + +//nolint:paralleltest // This test modifies global registry state and cannot be parallelized +func TestRegisterPanicsOnDuplicate(t *testing.T) { + // Register a unique type for this test + testType := "test-authorizer-type-duplicate-check" + + // Skip if already registered from a previous test run + if IsRegistered(testType) { + // Type already exists, directly test the panic case + assert.Panics(t, func() { + Register(testType, &mockFactory{}) + }, "Expected panic when registering duplicate factory") + return + } + + // First register a new type + Register(testType, &mockFactory{ + authorizer: &mockAuthorizer{}, + }) + + // Trying to register it again should panic + assert.Panics(t, func() { + Register(testType, &mockFactory{}) + }, "Expected panic when registering duplicate factory") +} diff --git a/pkg/authz/cedar_test.go b/pkg/authz/cedar_test.go deleted file mode 100644 index 214ab4fa3..000000000 --- a/pkg/authz/cedar_test.go +++ /dev/null @@ -1,444 +0,0 @@ -package authz - -import ( - "context" - "testing" - - "github.com/golang-jwt/jwt/v5" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/stacklok/toolhive/pkg/auth" - "github.com/stacklok/toolhive/pkg/logger" -) - -// TestNewCedarAuthorizer tests the creation of a new Cedar authorizer with different configurations. -func TestNewCedarAuthorizer(t *testing.T) { - t.Parallel() - - // Initialize logger for tests - logger.Initialize() - // Test cases - testCases := []struct { - name string - policies []string - entitiesJSON string - expectError bool - errorType error - }{ - { - name: "Valid policy and empty entities", - policies: []string{`permit(principal, action, resource);`}, - entitiesJSON: `[]`, - expectError: false, - }, - { - name: "Multiple valid policies", - policies: []string{`permit(principal, action, resource);`, `forbid(principal, action, resource);`}, - entitiesJSON: `[]`, - expectError: false, - }, - { - name: "Invalid policy", - policies: []string{`invalid policy syntax`}, - entitiesJSON: `[]`, - expectError: true, - }, - { - name: "No policies", - policies: []string{}, - entitiesJSON: `[]`, - expectError: true, - errorType: ErrNoPolicies, - }, - { - name: "Invalid entities JSON", - policies: []string{`permit(principal, action, resource);`}, - entitiesJSON: `invalid json`, - expectError: true, - }, - { - name: "Valid policy and valid entities", - policies: []string{`permit(principal, action, resource);`}, - entitiesJSON: `[{"uid": {"type": "User", "id": "alice"}, "attrs": {}, "parents": []}]`, - expectError: false, - }, - } - - // Run test cases - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - // Create a Cedar authorizer - authorizer, err := NewCedarAuthorizer(CedarAuthorizerConfig{ - Policies: tc.policies, - EntitiesJSON: tc.entitiesJSON, - }) - - // Check error expectations - if tc.expectError { - assert.Error(t, err, "Expected an error but got none") - if tc.errorType != nil { - assert.ErrorIs(t, err, tc.errorType, "Expected error type %v but got %v", tc.errorType, err) - } - assert.Nil(t, authorizer, "Expected nil authorizer when error occurs") - } else { - assert.NoError(t, err, "Unexpected error: %v", err) - require.NotNil(t, authorizer, "Cedar authorizer is nil") - assert.NotNil(t, authorizer.policySet, "Cedar policy set is nil") - assert.NotNil(t, authorizer.entities, "Cedar entities map is nil") - } - }) - } -} - -// TestAuthorizeWithJWTClaims tests the AuthorizeWithJWTClaims function with different roles in claims. -func TestAuthorizeWithJWTClaims(t *testing.T) { - t.Parallel() - // Test cases - testCases := []struct { - name string - policy string - claims jwt.MapClaims - feature MCPFeature - operation MCPOperation - resourceID string - arguments map[string]interface{} - expectAuthorized bool - }{ - { - name: "User with correct name can call weather tool", - policy: ` - permit( - principal, - action == Action::"call_tool", - resource == Tool::"weather" - ) - when { - context.claim_name == "John Doe" - }; - `, - claims: jwt.MapClaims{ - "sub": "user123", - "name": "John Doe", - "roles": []string{"user", "reader"}, - }, - feature: MCPFeatureTool, - operation: MCPOperationCall, - resourceID: "weather", - arguments: nil, - expectAuthorized: true, - }, - { - name: "User with incorrect name cannot call weather tool", - policy: ` - permit( - principal, - action == Action::"call_tool", - resource == Tool::"weather" - ) - when { - context.claim_name == "John Doe" - }; - `, - claims: jwt.MapClaims{ - "sub": "user123", - "name": "Jane Smith", - "roles": []string{"user", "reader"}, - }, - feature: MCPFeatureTool, - operation: MCPOperationCall, - resourceID: "weather", - arguments: nil, - expectAuthorized: false, - }, - { - name: "Admin user can call any tool", - policy: ` - permit( - principal, - action == Action::"call_tool", - resource - ) - when { - context.claim_role == "admin" - }; - `, - claims: map[string]interface{}{ - "sub": "admin123", - "name": "Admin User", - "role": "admin", - }, - feature: MCPFeatureTool, - operation: MCPOperationCall, - resourceID: "any_tool", - arguments: nil, - expectAuthorized: true, - }, - { - name: "User with specific argument value can call tool", - policy: ` - permit( - principal, - action == Action::"call_tool", - resource == Tool::"calculator" - ) - when { - context.arg_operation == "add" && context.arg_value1 == 5 - }; - `, - claims: map[string]interface{}{ - "sub": "user123", - "name": "John Doe", - }, - feature: MCPFeatureTool, - operation: MCPOperationCall, - resourceID: "calculator", - arguments: map[string]interface{}{ - "operation": "add", - "value1": 5, - "value2": 10, - }, - expectAuthorized: true, - }, - { - name: "User with specific role in array can access resource", - policy: ` - permit( - principal, - action == Action::"read_resource", - resource == Resource::"sensitive_data" - ) - when { - context.claim_groups.contains("editor") - }; - `, - claims: jwt.MapClaims{ - "sub": "user123", - "name": "John Doe", - "groups": []string{"reader", "editor", "viewer"}, - }, - feature: MCPFeatureResource, - operation: MCPOperationRead, - resourceID: "sensitive_data", - arguments: nil, - expectAuthorized: true, - }, - { - name: "User can get prompt", - policy: ` - permit( - principal, - action == Action::"get_prompt", - resource == Prompt::"greeting" - ); - `, - claims: jwt.MapClaims{ - "sub": "user123", - "name": "John Doe", - "role": "user", - }, - feature: MCPFeaturePrompt, - operation: MCPOperationGet, - resourceID: "greeting", - arguments: nil, - expectAuthorized: true, - }, - { - name: "User can list tools", - policy: ` - permit( - principal, - action == Action::"list_tools", - resource == FeatureType::"tool" - ); - `, - claims: jwt.MapClaims{ - "sub": "user123", - "name": "John Doe", - "role": "user", - }, - feature: MCPFeatureTool, - operation: MCPOperationList, - resourceID: "", - arguments: nil, - expectAuthorized: true, - }, - { - name: "User can list prompts", - policy: ` - permit( - principal, - action == Action::"list_prompts", - resource == FeatureType::"prompt" - ); - `, - claims: jwt.MapClaims{ - "sub": "user123", - "name": "John Doe", - "role": "user", - }, - feature: MCPFeaturePrompt, - operation: MCPOperationList, - resourceID: "", - arguments: nil, - expectAuthorized: true, - }, - { - name: "User can list resources", - policy: ` - permit( - principal, - action == Action::"list_resources", - resource == FeatureType::"resource" - ); - `, - claims: jwt.MapClaims{ - "sub": "user123", - "name": "John Doe", - "role": "user", - }, - feature: MCPFeatureResource, - operation: MCPOperationList, - resourceID: "", - arguments: nil, - expectAuthorized: true, - }, - } - - // Run test cases - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - // Create a context - ctx := context.Background() - - // Create a Cedar authorizer - authorizer, err := NewCedarAuthorizer(CedarAuthorizerConfig{ - Policies: []string{tc.policy}, - EntitiesJSON: `[]`, - }) - require.NoError(t, err, "Failed to create Cedar authorizer") - - // Create a context with JWT claims - identity := &auth.Identity{Subject: "test-user", Claims: tc.claims} - claimsCtx := auth.WithIdentity(ctx, identity) - - // Test authorization - authorized, err := authorizer.AuthorizeWithJWTClaims(claimsCtx, tc.feature, tc.operation, tc.resourceID, tc.arguments) - assert.NoError(t, err, "Authorization error") - assert.Equal(t, tc.expectAuthorized, authorized, "Authorization result does not match expectation") - }) - } -} - -// TestAuthorizeWithJWTClaimsErrors tests error cases for AuthorizeWithJWTClaims. -func TestAuthorizeWithJWTClaimsErrors(t *testing.T) { - t.Parallel() - // Create a context - ctx := context.Background() - - // Create a Cedar authorizer - authorizer, err := NewCedarAuthorizer(CedarAuthorizerConfig{ - Policies: []string{`permit(principal, action, resource);`}, - EntitiesJSON: `[]`, - }) - require.NoError(t, err, "Failed to create Cedar authorizer") - - // Test cases - testCases := []struct { - name string - setupCtx func(context.Context) context.Context - feature MCPFeature - operation MCPOperation - resourceID string - arguments map[string]interface{} - expectError bool - errorType error - }{ - { - name: "Missing claims in context", - setupCtx: func(ctx context.Context) context.Context { - // Don't add claims to the context - return ctx - }, - feature: MCPFeatureTool, - operation: MCPOperationCall, - resourceID: "weather", - arguments: nil, - expectError: true, - errorType: ErrMissingPrincipal, - }, - { - name: "Missing sub claim", - setupCtx: func(ctx context.Context) context.Context { - // Add claims without sub - claims := jwt.MapClaims{ - "name": "John Doe", - "role": "user", - } - identity := &auth.Identity{Subject: "", Claims: claims} - return auth.WithIdentity(ctx, identity) - }, - feature: MCPFeatureTool, - operation: MCPOperationCall, - resourceID: "weather", - arguments: nil, - expectError: true, - errorType: ErrMissingPrincipal, - }, - { - name: "Empty sub claim", - setupCtx: func(ctx context.Context) context.Context { - // Add claims with empty sub - claims := jwt.MapClaims{ - "sub": "", - "name": "John Doe", - "role": "user", - } - identity := &auth.Identity{Subject: "", Claims: claims} - return auth.WithIdentity(ctx, identity) - }, - feature: MCPFeatureTool, - operation: MCPOperationCall, - resourceID: "weather", - arguments: nil, - expectError: true, - errorType: ErrMissingPrincipal, - }, - { - name: "Unsupported feature/operation combination", - setupCtx: func(ctx context.Context) context.Context { - // Add valid claims - claims := jwt.MapClaims{ - "sub": "user123", - "name": "John Doe", - "role": "user", - } - identity := &auth.Identity{Subject: "user123", Claims: claims} - return auth.WithIdentity(ctx, identity) - }, - feature: "invalid_feature", - operation: "invalid_operation", - resourceID: "resource", - arguments: nil, - expectError: true, - }, - } - - // Run test cases - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - // Setup context - testCtx := tc.setupCtx(ctx) - - // Test authorization - _, err := authorizer.AuthorizeWithJWTClaims(testCtx, tc.feature, tc.operation, tc.resourceID, tc.arguments) - assert.Error(t, err, "Expected an error") - if tc.errorType != nil { - assert.ErrorIs(t, err, tc.errorType, "Expected error type %v but got %v", tc.errorType, err) - } - }) - } -} diff --git a/pkg/authz/config.go b/pkg/authz/config.go index c025d2f0f..10551d59f 100644 --- a/pkg/authz/config.go +++ b/pkg/authz/config.go @@ -1,146 +1,48 @@ -// Package authz provides authorization utilities using Cedar policies. +// Package authz provides authorization utilities for MCP servers. +// It supports a pluggable authorizer architecture where different authorization +// backends (e.g., Cedar, OPA) can be registered and used based on configuration. package authz import ( - "encoding/json" "fmt" "net/http" - "os" - "path/filepath" - "strings" - - "sigs.k8s.io/yaml" + "github.com/stacklok/toolhive/pkg/authz/authorizers" "github.com/stacklok/toolhive/pkg/transport/types" ) -// ConfigType represents the type of authorization configuration. -type ConfigType string - -const ( - // ConfigTypeCedarV1 represents the Cedar v1 authorization configuration. - ConfigTypeCedarV1 ConfigType = "cedarv1" -) +// ConfigType is an alias for authorizers.ConfigType for backward compatibility. +type ConfigType = authorizers.ConfigType -// Config represents the authorization configuration. -type Config struct { - // Version is the version of the configuration format. - Version string `json:"version" yaml:"version"` +// Config is an alias for authorizers.Config for backward compatibility. +type Config = authorizers.Config - // Type is the type of authorization configuration. - Type ConfigType `json:"type" yaml:"type"` +// LoadConfig is an alias for authorizers.LoadConfig for backward compatibility. +var LoadConfig = authorizers.LoadConfig - // Cedar is the Cedar-specific configuration. - // This is only used when Type is ConfigTypeCedarV1. - Cedar *CedarConfig `json:"cedar,omitempty" yaml:"cedar,omitempty"` -} - -// CedarConfig represents the Cedar-specific authorization configuration. -type CedarConfig struct { - // Policies is a list of Cedar policy strings - Policies []string `json:"policies" yaml:"policies"` - - // EntitiesJSON is the JSON string representing Cedar entities - EntitiesJSON string `json:"entities_json" yaml:"entities_json"` -} +// NewConfig is an alias for authorizers.NewConfig for backward compatibility. +var NewConfig = authorizers.NewConfig -// LoadConfig loads the authorization configuration from a file. -// It supports both JSON and YAML formats, detected by file extension. -func LoadConfig(path string) (*Config, error) { - // Validate and clean the path to prevent directory traversal attacks - cleanPath := filepath.Clean(path) - if strings.Contains(cleanPath, "..") { - return nil, fmt.Errorf("path contains directory traversal elements: %s", path) +// CreateMiddlewareFromConfig creates an HTTP middleware from the configuration. +func CreateMiddlewareFromConfig(c *Config, _ string) (types.MiddlewareFunction, error) { + // Get the factory for this config type + factory := authorizers.GetFactory(string(c.Type)) + if factory == nil { + return nil, fmt.Errorf("unsupported configuration type: %s", c.Type) } - // Read the file - data, err := os.ReadFile(cleanPath) + // Create the authorizer using the factory, passing the full raw config + authz, err := factory.CreateAuthorizer(c.RawConfig()) if err != nil { - return nil, fmt.Errorf("failed to read authorization configuration file: %w", err) - } - - // Determine the file format based on extension - var config Config - ext := strings.ToLower(filepath.Ext(cleanPath)) - - // Parse the file based on its format - switch ext { - case ".yaml", ".yml": - // Parse YAML - if err := yaml.Unmarshal(data, &config); err != nil { - return nil, fmt.Errorf("failed to parse YAML authorization configuration file: %w", err) - } - case ".json", "": - // Parse JSON (default if no extension) - if err := json.Unmarshal(data, &config); err != nil { - return nil, fmt.Errorf("failed to parse JSON authorization configuration file: %w", err) - } - default: - return nil, fmt.Errorf("unsupported file format: %s (supported formats: .json, .yaml, .yml)", ext) - } - - // Validate the configuration - if err := config.Validate(); err != nil { - return nil, err + return nil, fmt.Errorf("failed to create %s authorizer: %w", c.Type, err) } - return &config, nil -} - -// Validate validates the authorization configuration. -func (c *Config) Validate() error { - // Check if the version is provided - if c.Version == "" { - return fmt.Errorf("version is required") - } - - // Check if the type is provided - if c.Type == "" { - return fmt.Errorf("type is required") - } - - // Validate based on the type - switch c.Type { - case ConfigTypeCedarV1: - // Check if the Cedar configuration is provided - if c.Cedar == nil { - return fmt.Errorf("cedar configuration is required for type %s", c.Type) - } - - // Check if policies are provided - if len(c.Cedar.Policies) == 0 { - return fmt.Errorf("at least one policy is required for type %s", c.Type) - } - default: - return fmt.Errorf("unsupported configuration type: %s", c.Type) - } - - return nil -} - -// CreateMiddleware creates an HTTP middleware from the configuration. -func (c *Config) CreateMiddleware() (types.MiddlewareFunction, error) { - // Create the appropriate middleware based on the configuration type - switch c.Type { - case ConfigTypeCedarV1: - // Create the Cedar authorizer - authorizer, err := NewCedarAuthorizer(CedarAuthorizerConfig{ - Policies: c.Cedar.Policies, - EntitiesJSON: c.Cedar.EntitiesJSON, - }) - if err != nil { - return nil, fmt.Errorf("failed to create Cedar authorizer: %w", err) - } - - // Return the Cedar middleware - return authorizer.Middleware, nil - default: - return nil, fmt.Errorf("unsupported configuration type: %s", c.Type) - } + // Return the middleware + return func(handler http.Handler) http.Handler { return Middleware(authz, handler) }, nil } // GetMiddlewareFromFile loads the authorization configuration from a file and creates an HTTP middleware. -func GetMiddlewareFromFile(path string) (func(http.Handler) http.Handler, error) { +func GetMiddlewareFromFile(serverName, path string) (func(http.Handler) http.Handler, error) { // Load the configuration config, err := LoadConfig(path) if err != nil { @@ -148,5 +50,5 @@ func GetMiddlewareFromFile(path string) (func(http.Handler) http.Handler, error) } // Create the middleware - return config.CreateMiddleware() + return CreateMiddlewareFromConfig(config, serverName) } diff --git a/pkg/authz/config_test.go b/pkg/authz/config_test.go index fdf12e448..3bd66460d 100644 --- a/pkg/authz/config_test.go +++ b/pkg/authz/config_test.go @@ -12,9 +12,18 @@ import ( "github.com/stretchr/testify/require" "github.com/stacklok/toolhive/pkg/auth" + "github.com/stacklok/toolhive/pkg/authz/authorizers/cedar" mcpparser "github.com/stacklok/toolhive/pkg/mcp" ) +// mustNewConfig creates a new Config from a cedar.Config or fails the test. +func mustNewConfig(t *testing.T, fullConfig interface{}) *Config { + t.Helper() + config, err := NewConfig(fullConfig) + require.NoError(t, err, "Failed to create config") + return config +} + func TestLoadConfig(t *testing.T) { t.Parallel() // Create a temporary file with a valid configuration @@ -22,17 +31,16 @@ func TestLoadConfig(t *testing.T) { require.NoError(t, err, "Failed to create temporary file") defer os.Remove(tempFile.Name()) - // Create a valid configuration - config := Config{ + // Create a valid configuration using the v1.0 schema + cedarConfig := cedar.Config{ Version: "1.0", - Type: ConfigTypeCedarV1, - Cedar: &CedarConfig{ - Policies: []string{ - `permit(principal, action == Action::"call_tool", resource == Tool::"weather");`, - }, + Type: cedar.ConfigType, + Options: &cedar.ConfigOptions{ + Policies: []string{`permit(principal, action == Action::"call_tool", resource == Tool::"weather");`}, EntitiesJSON: "[]", }, } + config := mustNewConfig(t, cedarConfig) // Marshal the configuration to JSON configJSON, err := json.MarshalIndent(config, "", " ") @@ -50,8 +58,56 @@ func TestLoadConfig(t *testing.T) { // Check if the loaded configuration matches the original configuration assert.Equal(t, config.Version, loadedConfig.Version, "Version does not match") assert.Equal(t, config.Type, loadedConfig.Type, "Type does not match") - assert.Equal(t, config.Cedar.Policies, loadedConfig.Cedar.Policies, "Policies do not match") - assert.Equal(t, config.Cedar.EntitiesJSON, loadedConfig.Cedar.EntitiesJSON, "EntitiesJSON does not match") + + // Verify the raw config can be parsed back to the cedar config structure + var loadedCedarConfig cedar.Config + err = json.Unmarshal(loadedConfig.RawConfig(), &loadedCedarConfig) + require.NoError(t, err, "Failed to unmarshal loaded config") + require.NotNil(t, loadedCedarConfig.Options, "Cedar config should not be nil") + assert.Equal(t, cedarConfig.Options.Policies, loadedCedarConfig.Options.Policies, "Policies do not match") + assert.Equal(t, cedarConfig.Options.EntitiesJSON, loadedCedarConfig.Options.EntitiesJSON, "EntitiesJSON does not match") +} + +func TestLoadConfigLegacyFormat(t *testing.T) { + t.Parallel() + // Create a temporary file with a legacy configuration format (v1.0 schema) + tempFile, err := os.CreateTemp("", "authz-config-legacy-*.json") + require.NoError(t, err, "Failed to create temporary file") + defer os.Remove(tempFile.Name()) + + // Create a v1.0 configuration with "cedar" field - this IS the supported format + legacyConfig := map[string]interface{}{ + "version": "1.0", + "type": "cedarv1", + "cedar": map[string]interface{}{ + "policies": []string{`permit(principal, action == Action::"call_tool", resource == Tool::"weather");`}, + "entities_json": "[]", + }, + } + + // Marshal the configuration to JSON + configJSON, err := json.MarshalIndent(legacyConfig, "", " ") + require.NoError(t, err, "Failed to marshal configuration to JSON") + + // Write the configuration to the temporary file + _, err = tempFile.Write(configJSON) + require.NoError(t, err, "Failed to write configuration to temporary file") + tempFile.Close() + + // Load the configuration from the temporary file + loadedConfig, err := LoadConfig(tempFile.Name()) + require.NoError(t, err, "Failed to load configuration from file") + + // Check if the loaded configuration has the expected values + assert.Equal(t, "1.0", loadedConfig.Version, "Version does not match") + assert.Equal(t, ConfigType("cedarv1"), loadedConfig.Type, "Type does not match") + + // Verify the raw config can be parsed with Cedar's config + var loadedCedarConfig cedar.Config + err = json.Unmarshal(loadedConfig.RawConfig(), &loadedCedarConfig) + require.NoError(t, err, "Failed to unmarshal loaded config") + require.NotNil(t, loadedCedarConfig.Options, "Cedar config should not be nil") + assert.Equal(t, []string{`permit(principal, action == Action::"call_tool", resource == Tool::"weather");`}, loadedCedarConfig.Options.Policies) } func TestLoadConfigPathTraversal(t *testing.T) { @@ -115,76 +171,65 @@ func TestValidateConfig(t *testing.T) { }{ { name: "Valid configuration", - config: &Config{ + config: mustNewConfig(t, cedar.Config{ Version: "1.0", - Type: ConfigTypeCedarV1, - Cedar: &CedarConfig{ - Policies: []string{ - `permit(principal, action == Action::"call_tool", resource == Tool::"weather");`, - }, + Type: cedar.ConfigType, + Options: &cedar.ConfigOptions{ + Policies: []string{`permit(principal, action == Action::"call_tool", resource == Tool::"weather");`}, EntitiesJSON: "[]", }, - }, + }), expectError: false, }, { name: "Missing version", - config: &Config{ - Type: ConfigTypeCedarV1, - Cedar: &CedarConfig{ - Policies: []string{ - `permit(principal, action == Action::"call_tool", resource == Tool::"weather");`, - }, + config: mustNewConfig(t, cedar.Config{ + Type: cedar.ConfigType, + Options: &cedar.ConfigOptions{ + Policies: []string{`permit(principal, action == Action::"call_tool", resource == Tool::"weather");`}, EntitiesJSON: "[]", }, - }, + }), expectError: true, }, { name: "Missing type", - config: &Config{ + config: mustNewConfig(t, cedar.Config{ Version: "1.0", - Cedar: &CedarConfig{ - Policies: []string{ - `permit(principal, action == Action::"call_tool", resource == Tool::"weather");`, - }, + Options: &cedar.ConfigOptions{ + Policies: []string{`permit(principal, action == Action::"call_tool", resource == Tool::"weather");`}, EntitiesJSON: "[]", }, - }, + }), expectError: true, }, { name: "Unsupported type", - config: &Config{ - Version: "1.0", - Type: "unsupported", - Cedar: &CedarConfig{ - Policies: []string{ - `permit(principal, action == Action::"call_tool", resource == Tool::"weather");`, - }, - EntitiesJSON: "[]", - }, - }, + config: mustNewConfig(t, map[string]interface{}{ + "version": "1.0", + "type": "unsupported", + }), expectError: true, }, { name: "Missing Cedar configuration", - config: &Config{ - Version: "1.0", - Type: ConfigTypeCedarV1, - }, + config: mustNewConfig(t, map[string]interface{}{ + "version": "1.0", + "type": cedar.ConfigType, + // No "cedar" field + }), expectError: true, }, { name: "Empty policies", - config: &Config{ + config: mustNewConfig(t, cedar.Config{ Version: "1.0", - Type: ConfigTypeCedarV1, - Cedar: &CedarConfig{ + Type: cedar.ConfigType, + Options: &cedar.ConfigOptions{ Policies: []string{}, EntitiesJSON: "[]", }, - }, + }), expectError: true, }, } @@ -204,20 +249,18 @@ func TestValidateConfig(t *testing.T) { func TestCreateMiddleware(t *testing.T) { t.Parallel() - // Create a valid configuration - config := &Config{ + // Create a valid configuration using the v1.0 schema + config := mustNewConfig(t, cedar.Config{ Version: "1.0", - Type: ConfigTypeCedarV1, - Cedar: &CedarConfig{ - Policies: []string{ - `permit(principal, action == Action::"call_tool", resource == Tool::"weather");`, - }, + Type: cedar.ConfigType, + Options: &cedar.ConfigOptions{ + Policies: []string{`permit(principal, action == Action::"call_tool", resource == Tool::"weather");`}, EntitiesJSON: "[]", }, - } + }) // Create the middleware - middleware, err := config.CreateMiddleware() + middleware, err := CreateMiddlewareFromConfig(config, "testmodule") require.NoError(t, err, "Failed to create middleware") require.NotNil(t, middleware, "Middleware is nil") @@ -262,3 +305,133 @@ func TestCreateMiddleware(t *testing.T) { // Check the response assert.Equal(t, http.StatusOK, rr.Code, "Response status code does not match expected") } + +func TestNewConfig(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + fullConfig interface{} + expectError bool + expectedType ConfigType + }{ + { + name: "Valid Cedar config", + fullConfig: cedar.Config{ + Version: "1.0", + Type: cedar.ConfigType, + Options: &cedar.ConfigOptions{ + Policies: []string{`permit(principal, action, resource);`}, + EntitiesJSON: "[]", + }, + }, + expectError: false, + expectedType: ConfigType(cedar.ConfigType), + }, + { + name: "Config as map", + fullConfig: map[string]interface{}{ + "version": "1.0", + "type": cedar.ConfigType, + "cedar": map[string]interface{}{ + "policies": []string{`permit(principal, action, resource);`}, + "entities_json": "[]", + }, + }, + expectError: false, + expectedType: ConfigType(cedar.ConfigType), + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + config, err := NewConfig(tc.fullConfig) + if tc.expectError { + assert.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tc.expectedType, config.Type) + assert.NotEmpty(t, config.RawConfig()) + }) + } +} + +func TestGetMiddlewareFromFile(t *testing.T) { + t.Parallel() + + t.Run("Valid config file", func(t *testing.T) { + t.Parallel() + + // Create a temporary file with a valid configuration + tempFile, err := os.CreateTemp("", "authz-middleware-*.json") + require.NoError(t, err) + defer os.Remove(tempFile.Name()) + + // Create a valid configuration using the v1.0 schema + cedarConfig := cedar.Config{ + Version: "1.0", + Type: cedar.ConfigType, + Options: &cedar.ConfigOptions{ + Policies: []string{`permit(principal, action == Action::"call_tool", resource == Tool::"weather");`}, + EntitiesJSON: "[]", + }, + } + config := mustNewConfig(t, cedarConfig) + + // Marshal and write the configuration + configJSON, err := json.Marshal(config) + require.NoError(t, err) + _, err = tempFile.Write(configJSON) + require.NoError(t, err) + tempFile.Close() + + // Get middleware from file + middleware, err := GetMiddlewareFromFile("testserver", tempFile.Name()) + require.NoError(t, err) + require.NotNil(t, middleware) + }) + + t.Run("Non-existent file", func(t *testing.T) { + t.Parallel() + + _, err := GetMiddlewareFromFile("testserver", "/nonexistent/path/config.json") + assert.Error(t, err) + }) + + t.Run("Invalid config file", func(t *testing.T) { + t.Parallel() + + // Create a temporary file with invalid configuration + tempFile, err := os.CreateTemp("", "authz-middleware-invalid-*.json") + require.NoError(t, err) + defer os.Remove(tempFile.Name()) + + // Write invalid JSON + _, err = tempFile.WriteString(`{"invalid": "config"}`) + require.NoError(t, err) + tempFile.Close() + + // Get middleware from file should fail + _, err = GetMiddlewareFromFile("testserver", tempFile.Name()) + assert.Error(t, err) + }) +} + +func TestCreateMiddlewareFromConfigErrors(t *testing.T) { + t.Parallel() + + t.Run("Unsupported config type", func(t *testing.T) { + t.Parallel() + + config := &Config{ + Version: "1.0", + Type: "unsupported-type", + } + + _, err := CreateMiddlewareFromConfig(config, "testserver") + assert.Error(t, err) + assert.Contains(t, err.Error(), "unsupported configuration type") + }) +} diff --git a/pkg/authz/integration_test.go b/pkg/authz/integration_test.go index ade1703f9..a7b5a7983 100644 --- a/pkg/authz/integration_test.go +++ b/pkg/authz/integration_test.go @@ -14,6 +14,7 @@ import ( "golang.org/x/exp/jsonrpc2" "github.com/stacklok/toolhive/pkg/auth" + "github.com/stacklok/toolhive/pkg/authz/authorizers/cedar" "github.com/stacklok/toolhive/pkg/logger" mcpparser "github.com/stacklok/toolhive/pkg/mcp" ) @@ -26,7 +27,7 @@ func TestIntegrationListFiltering(t *testing.T) { // Initialize logger for tests logger.Initialize() // Create a realistic Cedar authorizer with role-based policies - authorizer, err := NewCedarAuthorizer(CedarAuthorizerConfig{ + authorizer, err := cedar.NewCedarAuthorizer(cedar.ConfigOptions{ Policies: []string{ // Basic users can only access weather and news tools `permit(principal, action == Action::"call_tool", resource == Tool::"weather") when { principal.claim_role == "user" };`, @@ -256,7 +257,7 @@ func TestIntegrationListFiltering(t *testing.T) { }) // Apply the middleware chain: MCP parsing first, then authorization - middleware := mcpparser.ParsingMiddleware(authorizer.Middleware(mockHandler)) + middleware := mcpparser.ParsingMiddleware(Middleware(authorizer, mockHandler)) // Execute the request through the middleware middleware.ServeHTTP(rr, req) @@ -328,7 +329,7 @@ func TestIntegrationListFiltering(t *testing.T) { func TestIntegrationNonListOperations(t *testing.T) { t.Parallel() // Create a Cedar authorizer with specific permissions - authorizer, err := NewCedarAuthorizer(CedarAuthorizerConfig{ + authorizer, err := cedar.NewCedarAuthorizer(cedar.ConfigOptions{ Policies: []string{ `permit(principal, action == Action::"call_tool", resource == Tool::"weather") when { principal.claim_role == "user" };`, `permit(principal, action == Action::"call_tool", resource) when { principal.claim_role == "admin" };`, @@ -425,7 +426,7 @@ func TestIntegrationNonListOperations(t *testing.T) { }) // Apply the middleware chain: MCP parsing first, then authorization - middleware := mcpparser.ParsingMiddleware(authorizer.Middleware(mockHandler)) + middleware := mcpparser.ParsingMiddleware(Middleware(authorizer, mockHandler)) // Execute the request through the middleware middleware.ServeHTTP(rr, req) diff --git a/pkg/authz/middleware.go b/pkg/authz/middleware.go index 64bce5b4b..087bd21f5 100644 --- a/pkg/authz/middleware.go +++ b/pkg/authz/middleware.go @@ -1,4 +1,6 @@ -// Package authz provides authorization utilities using Cedar policies. +// Package authz provides authorization utilities for MCP servers. +// It supports a pluggable authorizer architecture where different authorization +// backends (e.g., Cedar, OPA) can be registered and used based on configuration. package authz import ( @@ -9,6 +11,7 @@ import ( "golang.org/x/exp/jsonrpc2" + "github.com/stacklok/toolhive/pkg/authz/authorizers" "github.com/stacklok/toolhive/pkg/logger" "github.com/stacklok/toolhive/pkg/mcp" "github.com/stacklok/toolhive/pkg/transport/ssecommon" @@ -17,16 +20,16 @@ import ( // MCPMethodToFeatureOperation maps MCP method names to feature and operation pairs. var MCPMethodToFeatureOperation = map[string]struct { - Feature MCPFeature - Operation MCPOperation + Feature authorizers.MCPFeature + Operation authorizers.MCPOperation }{ - "tools/call": {Feature: MCPFeatureTool, Operation: MCPOperationCall}, - "tools/list": {Feature: MCPFeatureTool, Operation: MCPOperationList}, - "prompts/get": {Feature: MCPFeaturePrompt, Operation: MCPOperationGet}, - "prompts/list": {Feature: MCPFeaturePrompt, Operation: MCPOperationList}, - "resources/read": {Feature: MCPFeatureResource, Operation: MCPOperationRead}, - "resources/list": {Feature: MCPFeatureResource, Operation: MCPOperationList}, - "features/list": {Feature: "", Operation: MCPOperationList}, + "tools/call": {Feature: authorizers.MCPFeatureTool, Operation: authorizers.MCPOperationCall}, + "tools/list": {Feature: authorizers.MCPFeatureTool, Operation: authorizers.MCPOperationList}, + "prompts/get": {Feature: authorizers.MCPFeaturePrompt, Operation: authorizers.MCPOperationGet}, + "prompts/list": {Feature: authorizers.MCPFeaturePrompt, Operation: authorizers.MCPOperationList}, + "resources/read": {Feature: authorizers.MCPFeatureResource, Operation: authorizers.MCPOperationRead}, + "resources/list": {Feature: authorizers.MCPFeatureResource, Operation: authorizers.MCPOperationList}, + "features/list": {Feature: "", Operation: authorizers.MCPOperationList}, "ping": {Feature: "", Operation: ""}, // Always allowed "progress/update": {Feature: "", Operation: ""}, // Always allowed "initialize": {Feature: "", Operation: ""}, // Always allowed @@ -110,34 +113,18 @@ func handleUnauthorized(w http.ResponseWriter, msgID interface{}, err error) { } } -// Middleware creates an HTTP middleware that authorizes MCP requests using Cedar policies. +// Middleware creates an HTTP middleware that authorizes MCP requests. // This middleware extracts the MCP message from the request, determines the feature, -// operation, and resource ID, and authorizes the request using Cedar policies. +// operation, and resource ID, and authorizes the request using the configured authorizer. // // For list operations (tools/list, prompts/list, resources/list), the middleware allows // the request to proceed but intercepts the response to filter out items that the user // is not authorized to access based on the corresponding call/get/read policies. // -// Example usage: -// -// // Create a Cedar authorizer with a policy that covers all tools and resources -// cedarAuthorizer, _ := authz.NewCedarAuthorizer(authz.CedarAuthorizerConfig{ -// Policies: []string{ -// `permit(principal, action == Action::"call_tool", resource == Tool::"weather");`, -// `permit(principal, action == Action::"get_prompt", resource == Prompt::"greeting");`, -// `permit(principal, action == Action::"read_resource", resource == Resource::"data");`, -// }, -// }) -// -// // Create a transport with the middleware -// middlewares := []types.Middleware{ -// jwtValidator.Middleware, // JWT middleware should be applied first -// cedarAuthorizer.Middleware, // Cedar middleware is applied second -// } -// -// proxy := httpsse.NewHTTPSSEProxy(8080, "my-container", middlewares...) -// proxy.Start(context.Background()) -func (a *CedarAuthorizer) Middleware(next http.Handler) http.Handler { +// The authorizer parameter should implement the authorizers.Authorizer interface, +// which can be created using authz.CreateMiddlewareFromConfig() or directly +// from an authorizer package (e.g., cedar.NewCedarAuthorizer()). +func Middleware(a authorizers.Authorizer, next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Check if we should skip authorization before checking parsed data if shouldSkipInitialAuthorization(r) { @@ -169,7 +156,8 @@ func (a *CedarAuthorizer) Middleware(next http.Handler) http.Handler { } // Handle list operations differently - allow them through but filter the response - if featureOp.Operation == MCPOperationList { + if featureOp.Operation == authorizers.MCPOperationList { + // Create a response filtering writer to intercept and filter the response filteringWriter := NewResponseFilteringWriter(w, a, r, parsedRequest.Method) @@ -257,7 +245,7 @@ func CreateMiddleware(config *types.MiddlewareConfig, runner types.MiddlewareRun return fmt.Errorf("either config_data or config_path is required for authorization middleware") } - middleware, err := authzConfig.CreateMiddleware() + middleware, err := CreateMiddlewareFromConfig(authzConfig, runner.GetConfig().GetName()) if err != nil { return fmt.Errorf("failed to create authorization middleware: %w", err) } diff --git a/pkg/authz/middleware_test.go b/pkg/authz/middleware_test.go index 3893bb7dd..a72bac1e0 100644 --- a/pkg/authz/middleware_test.go +++ b/pkg/authz/middleware_test.go @@ -16,6 +16,7 @@ import ( "golang.org/x/exp/jsonrpc2" "github.com/stacklok/toolhive/pkg/auth" + "github.com/stacklok/toolhive/pkg/authz/authorizers/cedar" "github.com/stacklok/toolhive/pkg/logger" mcpparser "github.com/stacklok/toolhive/pkg/mcp" "github.com/stacklok/toolhive/pkg/transport/types" @@ -30,7 +31,7 @@ func TestMiddleware(t *testing.T) { logger.Initialize() // Create a Cedar authorizer - authorizer, err := NewCedarAuthorizer(CedarAuthorizerConfig{ + authorizer, err := cedar.NewCedarAuthorizer(cedar.ConfigOptions{ Policies: []string{ `permit(principal, action == Action::"call_tool", resource == Tool::"weather");`, `permit(principal, action == Action::"get_prompt", resource == Prompt::"greeting");`, @@ -239,7 +240,7 @@ func TestMiddleware(t *testing.T) { }) // Apply the middleware chain: MCP parsing first, then authorization - middleware := mcpparser.ParsingMiddleware(authorizer.Middleware(handler)) + middleware := mcpparser.ParsingMiddleware(Middleware(authorizer, handler)) // Serve the request middleware.ServeHTTP(rr, req) @@ -255,7 +256,7 @@ func TestMiddleware(t *testing.T) { func TestMiddlewareWithGETRequest(t *testing.T) { t.Parallel() // Create a Cedar authorizer - authorizer, err := NewCedarAuthorizer(CedarAuthorizerConfig{ + authorizer, err := cedar.NewCedarAuthorizer(cedar.ConfigOptions{ Policies: []string{ `permit(principal, action == Action::"call_tool", resource == Tool::"weather");`, }, @@ -271,7 +272,7 @@ func TestMiddlewareWithGETRequest(t *testing.T) { }) // Apply the middleware chain: MCP parsing first, then authorization - middleware := mcpparser.ParsingMiddleware(authorizer.Middleware(handler)) + middleware := mcpparser.ParsingMiddleware(Middleware(authorizer, handler)) // Create a GET request req, err := http.NewRequest(http.MethodGet, "/messages", nil) @@ -297,17 +298,17 @@ func TestFactoryCreateMiddleware(t *testing.T) { t.Run("create middleware with config data", func(t *testing.T) { t.Parallel() - // Create config data - configData := &Config{ + // Create config data using the new API + configData := mustNewConfig(t, cedar.Config{ Version: "1.0", - Type: ConfigTypeCedarV1, - Cedar: &CedarConfig{ + Type: cedar.ConfigType, + Options: &cedar.ConfigOptions{ Policies: []string{ `permit(principal, action == Action::"call_tool", resource == Tool::"weather");`, }, EntitiesJSON: "[]", }, - } + }) // Create middleware parameters with ConfigData params := FactoryMiddlewareParams{ @@ -318,10 +319,13 @@ func TestFactoryCreateMiddleware(t *testing.T) { middlewareConfig, err := types.NewMiddlewareConfig(MiddlewareType, params) require.NoError(t, err) - // Create mock runner + // Create mock runner and config ctrl := gomock.NewController(t) defer ctrl.Finish() + mockConfig := mocks.NewMockRunnerConfig(ctrl) + mockConfig.EXPECT().GetName().Return("test-server").AnyTimes() mockRunner := mocks.NewMockMiddlewareRunner(ctrl) + mockRunner.EXPECT().GetConfig().Return(mockConfig).AnyTimes() mockRunner.EXPECT().AddMiddleware(gomock.Any(), gomock.Any()).Times(1) // Test CreateMiddleware @@ -332,17 +336,17 @@ func TestFactoryCreateMiddleware(t *testing.T) { t.Run("create middleware with config path (backwards compatibility)", func(t *testing.T) { t.Parallel() - // Create a temporary config file - configData := &Config{ + // Create a temporary config file using the new API + configData := mustNewConfig(t, cedar.Config{ Version: "1.0", - Type: ConfigTypeCedarV1, - Cedar: &CedarConfig{ + Type: cedar.ConfigType, + Options: &cedar.ConfigOptions{ Policies: []string{ `permit(principal, action == Action::"call_tool", resource == Tool::"weather");`, }, EntitiesJSON: "[]", }, - } + }) tmpFile, err := os.CreateTemp("", "authz_config_*.json") require.NoError(t, err) @@ -364,10 +368,13 @@ func TestFactoryCreateMiddleware(t *testing.T) { middlewareConfig, err := types.NewMiddlewareConfig(MiddlewareType, params) require.NoError(t, err) - // Create mock runner + // Create mock runner and config ctrl := gomock.NewController(t) defer ctrl.Finish() + mockConfig := mocks.NewMockRunnerConfig(ctrl) + mockConfig.EXPECT().GetName().Return("test-server").AnyTimes() mockRunner := mocks.NewMockMiddlewareRunner(ctrl) + mockRunner.EXPECT().GetConfig().Return(mockConfig).AnyTimes() mockRunner.EXPECT().AddMiddleware(gomock.Any(), gomock.Any()).Times(1) // Test CreateMiddleware @@ -378,17 +385,17 @@ func TestFactoryCreateMiddleware(t *testing.T) { t.Run("config data takes precedence over config path", func(t *testing.T) { t.Parallel() - // Create config data - configData := &Config{ + // Create config data using the new API + configData := mustNewConfig(t, cedar.Config{ Version: "1.0", - Type: ConfigTypeCedarV1, - Cedar: &CedarConfig{ + Type: cedar.ConfigType, + Options: &cedar.ConfigOptions{ Policies: []string{ `permit(principal, action == Action::"call_tool", resource == Tool::"weather");`, }, EntitiesJSON: "[]", }, - } + }) // Create middleware parameters with both ConfigData and ConfigPath // ConfigData should take precedence, so ConfigPath can be invalid @@ -401,10 +408,13 @@ func TestFactoryCreateMiddleware(t *testing.T) { middlewareConfig, err := types.NewMiddlewareConfig(MiddlewareType, params) require.NoError(t, err) - // Create mock runner + // Create mock runner and config ctrl := gomock.NewController(t) defer ctrl.Finish() + mockConfig := mocks.NewMockRunnerConfig(ctrl) + mockConfig.EXPECT().GetName().Return("test-server").AnyTimes() mockRunner := mocks.NewMockMiddlewareRunner(ctrl) + mockRunner.EXPECT().GetConfig().Return(mockConfig).AnyTimes() mockRunner.EXPECT().AddMiddleware(gomock.Any(), gomock.Any()).Times(1) // Test CreateMiddleware - should succeed even with invalid path because ConfigData takes precedence @@ -475,10 +485,13 @@ func TestFactoryCreateMiddleware(t *testing.T) { middlewareConfig, err := types.NewMiddlewareConfig(MiddlewareType, params) require.NoError(t, err) - // Create mock runner + // Create mock runner and config (GetConfig is called before validation) ctrl := gomock.NewController(t) defer ctrl.Finish() + mockConfig := mocks.NewMockRunnerConfig(ctrl) + mockConfig.EXPECT().GetName().Return("test-server").AnyTimes() mockRunner := mocks.NewMockMiddlewareRunner(ctrl) + mockRunner.EXPECT().GetConfig().Return(mockConfig).AnyTimes() // Should not call AddMiddleware since creation should fail // Test CreateMiddleware - should fail @@ -612,8 +625,8 @@ func TestMiddlewareToolsListTestkit(t *testing.T) { t.Parallel() // Create a Cedar authorizer - authorizer, err := NewCedarAuthorizer( - CedarAuthorizerConfig{ + authorizer, err := cedar.NewCedarAuthorizer( + cedar.ConfigOptions{ Policies: tc.policies, EntitiesJSON: `[]`, }, @@ -639,7 +652,7 @@ func TestMiddlewareToolsListTestkit(t *testing.T) { }) }, mcpparser.ParsingMiddleware, - authorizer.Middleware, + func(h http.Handler) http.Handler { return Middleware(authorizer, h) }, )) server, client, err := testkit.NewStreamableTestServer(opts...) require.NoError(t, err) @@ -782,8 +795,8 @@ func TestMiddlewareToolsCallTestkit(t *testing.T) { t.Parallel() // Create a Cedar authorizer - authorizer, err := NewCedarAuthorizer( - CedarAuthorizerConfig{ + authorizer, err := cedar.NewCedarAuthorizer( + cedar.ConfigOptions{ Policies: tc.policies, EntitiesJSON: `[]`, }, @@ -809,7 +822,7 @@ func TestMiddlewareToolsCallTestkit(t *testing.T) { }) }, mcpparser.ParsingMiddleware, - authorizer.Middleware, + func(h http.Handler) http.Handler { return Middleware(authorizer, h) }, )) server, client, err := testkit.NewStreamableTestServer(opts...) require.NoError(t, err) @@ -843,3 +856,77 @@ func TestMiddlewareToolsCallTestkit(t *testing.T) { }) } } + +// TestConvertToJSONRPC2ID tests the convertToJSONRPC2ID function with various ID types +func TestConvertToJSONRPC2ID(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + input interface{} + expectError bool + }{ + { + name: "nil ID", + input: nil, + expectError: false, + }, + { + name: "string ID", + input: "test-id", + expectError: false, + }, + { + name: "int ID", + input: 42, + expectError: false, + }, + { + name: "int64 ID", + input: int64(123456789), + expectError: false, + }, + { + name: "float64 ID (JSON number)", + input: float64(99.0), + expectError: false, + }, + { + name: "unsupported type (slice)", + input: []string{"invalid"}, + expectError: true, + }, + { + name: "unsupported type (map)", + input: map[string]string{"key": "value"}, + expectError: true, + }, + { + name: "unsupported type (struct)", + input: struct{ Name string }{Name: "test"}, + expectError: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + result, err := convertToJSONRPC2ID(tc.input) + + if tc.expectError { + assert.Error(t, err) + assert.Contains(t, err.Error(), "unsupported ID type") + } else { + assert.NoError(t, err) + // For nil input, we expect an empty ID + if tc.input == nil { + assert.Equal(t, jsonrpc2.ID{}, result) + } else { + // For other valid inputs, we just verify no error + assert.NotNil(t, result) + } + } + }) + } +} diff --git a/pkg/authz/response_filter.go b/pkg/authz/response_filter.go index 37d6d1405..eb74ee630 100644 --- a/pkg/authz/response_filter.go +++ b/pkg/authz/response_filter.go @@ -1,4 +1,4 @@ -// Package authz provides authorization utilities using Cedar policies. +// Package authz provides authorization utilities for MCP servers. package authz import ( @@ -11,6 +11,8 @@ import ( "github.com/mark3labs/mcp-go/mcp" "golang.org/x/exp/jsonrpc2" + + "github.com/stacklok/toolhive/pkg/authz/authorizers" ) var errBug = errors.New("there's a bug") @@ -18,7 +20,7 @@ var errBug = errors.New("there's a bug") // ResponseFilteringWriter wraps an http.ResponseWriter to intercept and filter responses type ResponseFilteringWriter struct { http.ResponseWriter - authorizer *CedarAuthorizer + authorizer authorizers.Authorizer request *http.Request method string buffer *bytes.Buffer @@ -27,7 +29,7 @@ type ResponseFilteringWriter struct { // NewResponseFilteringWriter creates a new response filtering writer func NewResponseFilteringWriter( - w http.ResponseWriter, authorizer *CedarAuthorizer, r *http.Request, method string, + w http.ResponseWriter, authorizer authorizers.Authorizer, r *http.Request, method string, ) *ResponseFilteringWriter { return &ResponseFilteringWriter{ ResponseWriter: w, @@ -261,8 +263,8 @@ func (rfw *ResponseFilteringWriter) filterToolsResponse(response *jsonrpc2.Respo // Check if the user is authorized to call this tool authorized, err := rfw.authorizer.AuthorizeWithJWTClaims( rfw.request.Context(), - MCPFeatureTool, - MCPOperationCall, + authorizers.MCPFeatureTool, + authorizers.MCPOperationCall, tool.Name, nil, // No arguments for the authorization check ) @@ -313,8 +315,8 @@ func (rfw *ResponseFilteringWriter) filterPromptsResponse(response *jsonrpc2.Res // Check if the user is authorized to get this prompt authorized, err := rfw.authorizer.AuthorizeWithJWTClaims( rfw.request.Context(), - MCPFeaturePrompt, - MCPOperationGet, + authorizers.MCPFeaturePrompt, + authorizers.MCPOperationGet, prompt.Name, nil, // No arguments for the authorization check ) @@ -365,8 +367,8 @@ func (rfw *ResponseFilteringWriter) filterResourcesResponse(response *jsonrpc2.R // Check if the user is authorized to read this resource authorized, err := rfw.authorizer.AuthorizeWithJWTClaims( rfw.request.Context(), - MCPFeatureResource, - MCPOperationRead, + authorizers.MCPFeatureResource, + authorizers.MCPOperationRead, resource.URI, nil, // No arguments for the authorization check ) diff --git a/pkg/authz/response_filter_test.go b/pkg/authz/response_filter_test.go index 5b18e3492..02fdb2b25 100644 --- a/pkg/authz/response_filter_test.go +++ b/pkg/authz/response_filter_test.go @@ -13,6 +13,7 @@ import ( "golang.org/x/exp/jsonrpc2" "github.com/stacklok/toolhive/pkg/auth" + "github.com/stacklok/toolhive/pkg/authz/authorizers/cedar" "github.com/stacklok/toolhive/pkg/logger" ) @@ -23,7 +24,7 @@ func TestResponseFilteringWriter(t *testing.T) { logger.Initialize() // Create a Cedar authorizer with specific tool permissions - authorizer, err := NewCedarAuthorizer(CedarAuthorizerConfig{ + authorizer, err := cedar.NewCedarAuthorizer(cedar.ConfigOptions{ Policies: []string{ `permit(principal, action == Action::"call_tool", resource == Tool::"weather");`, `permit(principal, action == Action::"get_prompt", resource == Prompt::"greeting");`, @@ -218,7 +219,7 @@ func TestResponseFilteringWriter(t *testing.T) { func TestResponseFilteringWriter_NonListOperations(t *testing.T) { t.Parallel() // Create a Cedar authorizer - authorizer, err := NewCedarAuthorizer(CedarAuthorizerConfig{ + authorizer, err := cedar.NewCedarAuthorizer(cedar.ConfigOptions{ Policies: []string{ `permit(principal, action == Action::"call_tool", resource == Tool::"weather");`, }, @@ -267,7 +268,7 @@ func TestResponseFilteringWriter_NonListOperations(t *testing.T) { func TestResponseFilteringWriter_ErrorResponse(t *testing.T) { t.Parallel() // Create a Cedar authorizer - authorizer, err := NewCedarAuthorizer(CedarAuthorizerConfig{ + authorizer, err := cedar.NewCedarAuthorizer(cedar.ConfigOptions{ Policies: []string{ `permit(principal, action == Action::"call_tool", resource == Tool::"weather");`, }, diff --git a/pkg/export/k8s.go b/pkg/export/k8s.go index 9caa0ca40..3d98eb56b 100644 --- a/pkg/export/k8s.go +++ b/pkg/export/k8s.go @@ -2,6 +2,7 @@ package export import ( + "encoding/json" "fmt" "io" "strings" @@ -10,6 +11,7 @@ import ( "sigs.k8s.io/yaml" v1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + "github.com/stacklok/toolhive/pkg/authz/authorizers/cedar" "github.com/stacklok/toolhive/pkg/runner" "github.com/stacklok/toolhive/pkg/transport/types" ) @@ -136,16 +138,21 @@ func runConfigToMCPServer(config *runner.RunConfig) (*v1alpha1.MCPServer, error) } // Convert authz config - if config.AuthzConfig != nil && config.AuthzConfig.Cedar != nil && len(config.AuthzConfig.Cedar.Policies) > 0 { - mcpServer.Spec.AuthzConfig = &v1alpha1.AuthzConfigRef{ - Type: v1alpha1.AuthzConfigTypeInline, - Inline: &v1alpha1.InlineAuthzConfig{ - Policies: config.AuthzConfig.Cedar.Policies, - }, - } + if config.AuthzConfig != nil && len(config.AuthzConfig.RawConfig()) > 0 { + // Extract Cedar config from the config (v1.0 schema has cedar field at top level) + var cedarConfig cedar.Config + if err := json.Unmarshal(config.AuthzConfig.RawConfig(), &cedarConfig); err == nil && + cedarConfig.Options != nil && len(cedarConfig.Options.Policies) > 0 { + mcpServer.Spec.AuthzConfig = &v1alpha1.AuthzConfigRef{ + Type: v1alpha1.AuthzConfigTypeInline, + Inline: &v1alpha1.InlineAuthzConfig{ + Policies: cedarConfig.Options.Policies, + }, + } - if config.AuthzConfig.Cedar.EntitiesJSON != "" { - mcpServer.Spec.AuthzConfig.Inline.EntitiesJSON = config.AuthzConfig.Cedar.EntitiesJSON + if cedarConfig.Options.EntitiesJSON != "" { + mcpServer.Spec.AuthzConfig.Inline.EntitiesJSON = cedarConfig.Options.EntitiesJSON + } } } diff --git a/pkg/export/k8s_test.go b/pkg/export/k8s_test.go index eea51cc79..6d22c6e10 100644 --- a/pkg/export/k8s_test.go +++ b/pkg/export/k8s_test.go @@ -13,12 +13,25 @@ import ( "github.com/stacklok/toolhive/pkg/audit" "github.com/stacklok/toolhive/pkg/auth" "github.com/stacklok/toolhive/pkg/authz" + "github.com/stacklok/toolhive/pkg/authz/authorizers/cedar" "github.com/stacklok/toolhive/pkg/permissions" "github.com/stacklok/toolhive/pkg/runner" "github.com/stacklok/toolhive/pkg/telemetry" "github.com/stacklok/toolhive/pkg/transport/types" ) +// mustNewAuthzConfig creates a new authz.Config or fails the test. +func mustNewAuthzConfig(t *testing.T, cedarOpts cedar.ConfigOptions) *authz.Config { + t.Helper() + config, err := authz.NewConfig(cedar.Config{ + Version: "1.0", + Type: cedar.ConfigType, + Options: &cedarOpts, + }) + require.NoError(t, err, "Failed to create authz config") + return config +} + func TestWriteK8sManifest(t *testing.T) { t.Parallel() @@ -164,15 +177,12 @@ func TestWriteK8sManifest(t *testing.T) { Name: "test", BaseName: "test", Transport: types.TransportTypeStdio, - AuthzConfig: &authz.Config{ - Type: authz.ConfigTypeCedarV1, - Cedar: &authz.CedarConfig{ - Policies: []string{ - "permit(principal, action, resource);", - }, - EntitiesJSON: "[]", + AuthzConfig: mustNewAuthzConfig(t, cedar.ConfigOptions{ + Policies: []string{ + "permit(principal, action, resource);", }, - }, + EntitiesJSON: "[]", + }), }, validateFn: func(t *testing.T, mcpServer *v1alpha1.MCPServer) { t.Helper() diff --git a/pkg/runner/runner.go b/pkg/runner/runner.go index 5638db0ce..b9d97654f 100644 --- a/pkg/runner/runner.go +++ b/pkg/runner/runner.go @@ -112,6 +112,11 @@ func (r *Runner) GetConfig() types.RunnerConfig { return r.Config } +// GetName returns the name of the mcp-service from the runner config (implements types.RunnerConfig) +func (c *RunConfig) GetName() string { + return c.Name +} + // GetPort returns the port from the runner config (implements types.RunnerConfig) func (c *RunConfig) GetPort() int { return c.Port diff --git a/pkg/runner/runner_test.go b/pkg/runner/runner_test.go new file mode 100644 index 000000000..c8a5e322e --- /dev/null +++ b/pkg/runner/runner_test.go @@ -0,0 +1,351 @@ +package runner + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + + rt "github.com/stacklok/toolhive/pkg/container/runtime" + "github.com/stacklok/toolhive/pkg/transport/types" + statusesmocks "github.com/stacklok/toolhive/pkg/workloads/statuses/mocks" +) + +const testServerName = "test-server" + +func TestNewRunner(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockStatusManager := statusesmocks.NewMockStatusManager(ctrl) + + runConfig := NewRunConfig() + runConfig.Name = testServerName + runConfig.Port = 8080 + + runner := NewRunner(runConfig, mockStatusManager) + + require.NotNil(t, runner) + assert.Equal(t, runConfig, runner.Config) + assert.NotNil(t, runner.supportedMiddleware) +} + +func TestRunner_AddMiddleware(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockStatusManager := statusesmocks.NewMockStatusManager(ctrl) + + runConfig := NewRunConfig() + runner := NewRunner(runConfig, mockStatusManager) + + // Create a mock middleware + mockMiddleware := &mockMiddlewareImpl{ + handler: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }), + } + + runner.AddMiddleware("test-middleware", mockMiddleware) + + assert.Len(t, runner.middlewares, 1) + assert.Len(t, runner.namedMiddlewares, 1) + assert.Equal(t, "test-middleware", runner.namedMiddlewares[0].Name) +} + +func TestRunner_SetAuthInfoHandler(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockStatusManager := statusesmocks.NewMockStatusManager(ctrl) + + runConfig := NewRunConfig() + runner := NewRunner(runConfig, mockStatusManager) + + handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }) + + runner.SetAuthInfoHandler(handler) + + assert.NotNil(t, runner.authInfoHandler) +} + +func TestRunner_SetPrometheusHandler(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockStatusManager := statusesmocks.NewMockStatusManager(ctrl) + + runConfig := NewRunConfig() + runner := NewRunner(runConfig, mockStatusManager) + + handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }) + + runner.SetPrometheusHandler(handler) + + assert.NotNil(t, runner.prometheusHandler) +} + +func TestRunner_GetConfig(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockStatusManager := statusesmocks.NewMockStatusManager(ctrl) + + runConfig := NewRunConfig() + runConfig.Name = testServerName + runConfig.Port = 9090 + + runner := NewRunner(runConfig, mockStatusManager) + + config := runner.GetConfig() + + require.NotNil(t, config) + assert.Equal(t, testServerName, config.GetName()) + assert.Equal(t, 9090, config.GetPort()) +} + +func TestRunConfig_GetName(t *testing.T) { + t.Parallel() + + runConfig := NewRunConfig() + runConfig.Name = "my-server" + + assert.Equal(t, "my-server", runConfig.GetName()) +} + +func TestRunConfig_GetPort(t *testing.T) { + t.Parallel() + + runConfig := NewRunConfig() + runConfig.Port = 12345 + + assert.Equal(t, 12345, runConfig.GetPort()) +} + +func TestRunner_Cleanup(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockStatusManager := statusesmocks.NewMockStatusManager(ctrl) + + runConfig := NewRunConfig() + runner := NewRunner(runConfig, mockStatusManager) + + // Add a mock middleware that closes successfully + mockMiddleware := &mockMiddlewareImpl{ + handler: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }), + closeErr: nil, + } + runner.middlewares = append(runner.middlewares, mockMiddleware) + + // Set up monitoring cancel function + ctx, cancel := context.WithCancel(context.Background()) + runner.monitoringCtx = ctx + runner.monitoringCancel = cancel + + err := runner.Cleanup(context.Background()) + assert.NoError(t, err) + + // Verify monitoring was cancelled + assert.Nil(t, runner.monitoringCancel) +} + +func TestRunner_CleanupWithMiddlewareError(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockStatusManager := statusesmocks.NewMockStatusManager(ctrl) + + runConfig := NewRunConfig() + runner := NewRunner(runConfig, mockStatusManager) + + // Add a mock middleware that returns an error on close + mockMiddleware := &mockMiddlewareImpl{ + handler: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }), + closeErr: assert.AnError, + } + runner.middlewares = append(runner.middlewares, mockMiddleware) + + err := runner.Cleanup(context.Background()) + assert.Error(t, err) +} + +func TestStatusManagerAdapter_SetWorkloadStatus(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockStatusManager := statusesmocks.NewMockStatusManager(ctrl) + mockStatusManager.EXPECT(). + SetWorkloadStatus(gomock.Any(), "test-workload", rt.WorkloadStatusRunning, "test reason"). + Return(nil) + + adapter := &statusManagerAdapter{sm: mockStatusManager} + + err := adapter.SetWorkloadStatus( + context.Background(), + "test-workload", + rt.WorkloadStatusRunning, + "test reason", + ) + assert.NoError(t, err) +} + +func TestWaitForInitializeSuccess(t *testing.T) { + t.Parallel() + + t.Run("Streamable HTTP success", func(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost { + w.WriteHeader(http.StatusOK) + return + } + w.WriteHeader(http.StatusMethodNotAllowed) + })) + defer server.Close() + + ctx := context.Background() + err := waitForInitializeSuccess(ctx, server.URL, "streamable-http", 5*time.Second) + assert.NoError(t, err) + }) + + t.Run("Streamable success (alias)", func(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost { + w.WriteHeader(http.StatusOK) + return + } + w.WriteHeader(http.StatusMethodNotAllowed) + })) + defer server.Close() + + ctx := context.Background() + err := waitForInitializeSuccess(ctx, server.URL, "streamable", 5*time.Second) + assert.NoError(t, err) + }) + + t.Run("SSE success", func(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + w.WriteHeader(http.StatusOK) + return + } + w.WriteHeader(http.StatusMethodNotAllowed) + })) + defer server.Close() + + ctx := context.Background() + err := waitForInitializeSuccess(ctx, server.URL+"#container-name", "sse", 5*time.Second) + assert.NoError(t, err) + }) + + t.Run("Unknown transport skips check", func(t *testing.T) { + t.Parallel() + + ctx := context.Background() + err := waitForInitializeSuccess(ctx, "http://localhost:9999", "unknown-transport", 5*time.Second) + assert.NoError(t, err) + }) + + t.Run("Timeout on server not ready", func(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusServiceUnavailable) + })) + defer server.Close() + + ctx := context.Background() + err := waitForInitializeSuccess(ctx, server.URL, "streamable-http", 500*time.Millisecond) + assert.Error(t, err) + assert.Contains(t, err.Error(), "initialize not successful") + }) + + t.Run("Context cancelled", func(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusServiceUnavailable) + })) + defer server.Close() + + ctx, cancel := context.WithCancel(context.Background()) + cancel() // Cancel immediately + + err := waitForInitializeSuccess(ctx, server.URL, "streamable-http", 5*time.Second) + assert.Error(t, err) + assert.Contains(t, err.Error(), "context cancelled") + }) +} + +func TestHandleRemoteAuthentication(t *testing.T) { + t.Parallel() + + t.Run("Nil remote auth config returns nil", func(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + t.Cleanup(func() { ctrl.Finish() }) + + mockStatusManager := statusesmocks.NewMockStatusManager(ctrl) + + runConfig := NewRunConfig() + runConfig.RemoteAuthConfig = nil + + runner := NewRunner(runConfig, mockStatusManager) + + tokenSource, err := runner.handleRemoteAuthentication(context.Background()) + assert.NoError(t, err) + assert.Nil(t, tokenSource) + }) +} + +// mockMiddlewareImpl is a mock implementation of the types.Middleware interface +type mockMiddlewareImpl struct { + handler http.Handler + closeErr error +} + +func (m *mockMiddlewareImpl) Handler() types.MiddlewareFunction { + return func(_ http.Handler) http.Handler { + return m.handler + } +} + +func (m *mockMiddlewareImpl) Close() error { + return m.closeErr +} diff --git a/pkg/transport/types/mocks/mock_transport.go b/pkg/transport/types/mocks/mock_transport.go index c6cca4756..f2cee0304 100644 --- a/pkg/transport/types/mocks/mock_transport.go +++ b/pkg/transport/types/mocks/mock_transport.go @@ -169,6 +169,20 @@ func (m *MockRunnerConfig) EXPECT() *MockRunnerConfigMockRecorder { return m.recorder } +// GetName mocks base method. +func (m *MockRunnerConfig) GetName() string { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetName") + ret0, _ := ret[0].(string) + return ret0 +} + +// GetName indicates an expected call of GetName. +func (mr *MockRunnerConfigMockRecorder) GetName() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetName", reflect.TypeOf((*MockRunnerConfig)(nil).GetName)) +} + // GetPort mocks base method. func (m *MockRunnerConfig) GetPort() int { m.ctrl.T.Helper() diff --git a/pkg/transport/types/transport.go b/pkg/transport/types/transport.go index 9eaaebaf3..55c88ae63 100644 --- a/pkg/transport/types/transport.go +++ b/pkg/transport/types/transport.go @@ -75,6 +75,7 @@ type MiddlewareRunner interface { // RunnerConfig defines the config interface needed by middleware to access runner configuration type RunnerConfig interface { + GetName() string GetPort() int }