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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Makefile.operator
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,8 @@ help: ## Display this help message.

##@ Development

manifests: controller-gen ## Generate ClusterRole and CustomResourceDefinition objects.
$(CONTROLLER_GEN) rbac:roleName=mcp-runtime-operator-role crd webhook paths="./api/..." output:crd:artifacts:config=config/crd/bases
manifests: controller-gen ## Generate ClusterRole, CustomResourceDefinition, and webhook objects.
$(CONTROLLER_GEN) rbac:roleName=mcp-runtime-operator-role crd webhook paths="./api/..." output:crd:artifacts:config=config/crd/bases output:webhook:artifacts:config=config/webhook

generate: controller-gen ## Generate code containing DeepCopy, DeepCopyInto, and DeepCopyObject method implementations.
$(CONTROLLER_GEN) object:headerFile="hack/boilerplate.go.txt" paths="./api/..."
Expand Down
2 changes: 2 additions & 0 deletions api/v1alpha1/access_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ type MCPAccessGrantStatus struct {
// +kubebuilder:printcolumn:name="Trust",type="string",JSONPath=".spec.maxTrust"
// +kubebuilder:printcolumn:name="Disabled",type="boolean",JSONPath=".spec.disabled"
// +kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp"
// +kubebuilder:webhook:path=/validate-mcpruntime-org-v1alpha1-mcpaccessgrant,mutating=false,failurePolicy=fail,sideEffects=None,groups=mcpruntime.org,resources=mcpaccessgrants,verbs=create;update,versions=v1alpha1,name=vmcpaccessgrant.kb.io,admissionReviewVersions=v1,serviceName=mcp-runtime-operator-webhook-service,serviceNamespace=mcp-runtime,servicePort=443

// MCPAccessGrant grants a human or agent access to an MCPServer.
type MCPAccessGrant struct {
Expand Down Expand Up @@ -105,6 +106,7 @@ type MCPAgentSessionStatus struct {
// +kubebuilder:printcolumn:name="Revoked",type="boolean",JSONPath=".spec.revoked"
// +kubebuilder:printcolumn:name="Expires",type="string",JSONPath=".spec.expiresAt"
// +kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp"
// +kubebuilder:webhook:path=/validate-mcpruntime-org-v1alpha1-mcpagentsession,mutating=false,failurePolicy=fail,sideEffects=None,groups=mcpruntime.org,resources=mcpagentsessions,verbs=create;update,versions=v1alpha1,name=vmcpagentsession.kb.io,admissionReviewVersions=v1,serviceName=mcp-runtime-operator-webhook-service,serviceNamespace=mcp-runtime,servicePort=443

// MCPAgentSession stores consent and upstream token state for an agent session.
type MCPAgentSession struct {
Expand Down
2 changes: 2 additions & 0 deletions api/v1alpha1/mcpserver_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,8 @@ type MCPServerStatus struct {
// +kubebuilder:printcolumn:name="Gateway",type="boolean",JSONPath=".status.gatewayReady"
// +kubebuilder:printcolumn:name="Ready",type="boolean",JSONPath=".status.deploymentReady"
// +kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp"
// +kubebuilder:webhook:path=/mutate-mcpruntime-org-v1alpha1-mcpserver,mutating=true,failurePolicy=ignore,sideEffects=None,groups=mcpruntime.org,resources=mcpservers,verbs=create;update,versions=v1alpha1,name=mmcpserver.kb.io,admissionReviewVersions=v1,serviceName=mcp-runtime-operator-webhook-service,serviceNamespace=mcp-runtime,servicePort=443
// +kubebuilder:webhook:path=/validate-mcpruntime-org-v1alpha1-mcpserver,mutating=false,failurePolicy=fail,sideEffects=None,groups=mcpruntime.org,resources=mcpservers,verbs=create;update,versions=v1alpha1,name=vmcpserver.kb.io,admissionReviewVersions=v1,serviceName=mcp-runtime-operator-webhook-service,serviceNamespace=mcp-runtime,servicePort=443

// MCPServer is the Schema for the mcpservers API.
type MCPServer struct {
Expand Down
75 changes: 51 additions & 24 deletions api/v1alpha1/validation.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import (
"fmt"
"strconv"
"strings"
"time"

apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/runtime"
Expand All @@ -16,11 +15,10 @@ import (
)

var (
_ admission.Defaulter[*MCPServer] = mcpServerWebhook{}
_ admission.Validator[*MCPServer] = mcpServerWebhook{}
_ admission.Validator[*MCPAccessGrant] = mcpAccessGrantValidator{}
_ admission.Validator[*MCPAgentSession] = mcpAgentSessionValidator{}
nowFunc = time.Now
_ admission.Defaulter[*MCPServer] = mcpServerWebhook{}
_ admission.Validator[*MCPServer] = mcpServerWebhook{}
_ admission.Validator[*MCPAccessGrant] = mcpAccessGrantValidator{}
_ admission.Validator[*MCPAgentSession] = mcpAgentSessionValidator{}
)

const (
Expand Down Expand Up @@ -81,8 +79,23 @@ func gatewayEnabled(spec MCPServerSpec) bool {
return spec.Gateway != nil && spec.Gateway.Enabled
}

// +kubebuilder:webhook:path=/mutate-mcpruntime-org-v1alpha1-mcpserver,mutating=true,failurePolicy=fail,sideEffects=None,groups=mcpruntime.org,resources=mcpservers,verbs=create;update,versions=v1alpha1,name=mmcpserver.kb.io,admissionReviewVersions=v1
// MCPServerDefaultOptions holds operator-scoped values that the admission
// webhook can use while defaulting MCPServer objects.
type MCPServerDefaultOptions struct {
DefaultIngressHost string
DefaultAnalyticsIngestURL string
}

func (r *MCPServer) Default() {
r.DefaultWithOptions(MCPServerDefaultOptions{})
}

// DefaultWithOptions applies MCPServer defaults, including operator-configured
// fallbacks when the webhook is registered by the operator manager.
func (r *MCPServer) DefaultWithOptions(options MCPServerDefaultOptions) {
ingressHostUnset := strings.TrimSpace(r.Spec.IngressHost) == ""
publicPathPrefixUnset := strings.TrimSpace(r.Spec.PublicPathPrefix) == ""

if strings.TrimSpace(r.Spec.ImageTag) == "" && !imageHasTagOrDigest(strings.TrimSpace(r.Spec.Image)) {
r.Spec.ImageTag = defaultImageTag
}
Expand All @@ -105,6 +118,9 @@ func (r *MCPServer) Default() {
if strings.TrimSpace(r.Spec.IngressClass) == "" {
r.Spec.IngressClass = defaultIngressClass
}
if ingressHostUnset && publicPathPrefixUnset {
r.Spec.IngressHost = strings.TrimSpace(options.DefaultIngressHost)
}

if gatewayEnabled(r.Spec) {
if r.Spec.Auth == nil {
Expand Down Expand Up @@ -194,6 +210,9 @@ func (r *MCPServer) Default() {
if strings.TrimSpace(r.Spec.Analytics.EventType) == "" {
r.Spec.Analytics.EventType = defaultAnalyticsEventType
}
if strings.TrimSpace(r.Spec.Analytics.IngestURL) == "" {
r.Spec.Analytics.IngestURL = strings.TrimSpace(options.DefaultAnalyticsIngestURL)
}
}

if r.Spec.Rollout != nil {
Expand All @@ -209,18 +228,23 @@ func (r *MCPServer) Default() {
}
}

// +kubebuilder:webhook:path=/validate-mcpruntime-org-v1alpha1-mcpserver,mutating=false,failurePolicy=fail,sideEffects=None,groups=mcpruntime.org,resources=mcpservers,verbs=create;update,versions=v1alpha1,name=vmcpserver.kb.io,admissionReviewVersions=v1
func (r *MCPServer) SetupWebhookWithManager(mgr ctrl.Manager) error {
return r.SetupWebhookWithManagerWithOptions(mgr, MCPServerDefaultOptions{})
}

func (r *MCPServer) SetupWebhookWithManagerWithOptions(mgr ctrl.Manager, options MCPServerDefaultOptions) error {
return ctrl.NewWebhookManagedBy(mgr, r).
WithDefaulter(mcpServerWebhook{}).
WithValidator(mcpServerWebhook{}).
WithDefaulter(mcpServerWebhook{defaultOptions: options}).
WithValidator(mcpServerWebhook{defaultOptions: options}).
Complete()
}

type mcpServerWebhook struct{}
type mcpServerWebhook struct {
defaultOptions MCPServerDefaultOptions
}

func (mcpServerWebhook) Default(_ context.Context, obj *MCPServer) error {
obj.Default()
func (w mcpServerWebhook) Default(_ context.Context, obj *MCPServer) error {
obj.DefaultWithOptions(w.defaultOptions)
return nil
}

Expand Down Expand Up @@ -385,7 +409,6 @@ func validateRolloutValue(fieldPath *field.Path, value string) *field.Error {
return nil
}

// +kubebuilder:webhook:path=/validate-mcpruntime-org-v1alpha1-mcpaccessgrant,mutating=false,failurePolicy=fail,sideEffects=None,groups=mcpruntime.org,resources=mcpaccessgrants,verbs=create;update,versions=v1alpha1,name=vmcpaccessgrant.kb.io,admissionReviewVersions=v1
func (r *MCPAccessGrant) SetupWebhookWithManager(mgr ctrl.Manager) error {
return ctrl.NewWebhookManagedBy(mgr, r).
WithValidator(mcpAccessGrantValidator{}).
Expand All @@ -407,27 +430,36 @@ func (mcpAccessGrantValidator) ValidateDelete(_ context.Context, obj *MCPAccessG
}

func (r *MCPAccessGrant) ValidateCreate() (admission.Warnings, error) {
return nil, r.validate()
return r.wildcardSubjectWarnings(), r.validate()
}

func (r *MCPAccessGrant) ValidateUpdate(_ runtime.Object) (admission.Warnings, error) {
return nil, r.validate()
return r.wildcardSubjectWarnings(), r.validate()
}

func (r *MCPAccessGrant) ValidateDelete() (admission.Warnings, error) {
return nil, nil
}

func (r *MCPAccessGrant) wildcardSubjectWarnings() admission.Warnings {
subject := r.Spec.Subject
if strings.TrimSpace(subject.HumanID) != "" ||
strings.TrimSpace(subject.AgentID) != "" ||
strings.TrimSpace(subject.TeamID) != "" {
return nil
}
return admission.Warnings{
"MCPAccessGrant subject is empty (wildcard grant): the gateway matches any authenticated principal for the server; adapter session creation still requires subject alignment with the caller",
}
}

func (r *MCPAccessGrant) validate() error {
var allErrs field.ErrorList
specPath := field.NewPath("spec")

if strings.TrimSpace(r.Spec.ServerRef.Name) == "" {
allErrs = append(allErrs, field.Required(specPath.Child("serverRef", "name"), "serverRef.name is required"))
}
if strings.TrimSpace(r.Spec.Subject.HumanID) == "" && strings.TrimSpace(r.Spec.Subject.AgentID) == "" && strings.TrimSpace(r.Spec.Subject.TeamID) == "" {
allErrs = append(allErrs, field.Required(specPath.Child("subject"), "one of subject.humanID, subject.agentID, or subject.teamID is required"))
}
if err := validateTeamIDField(specPath.Child("subject", "teamID"), r.Spec.Subject.TeamID); err != nil {
allErrs = append(allErrs, err)
}
Expand Down Expand Up @@ -475,7 +507,6 @@ func (r *MCPAccessGrant) validate() error {
return apierrors.NewInvalid(schema.GroupKind{Group: GroupVersion.Group, Kind: "MCPAccessGrant"}, r.Name, allErrs)
}

// +kubebuilder:webhook:path=/validate-mcpruntime-org-v1alpha1-mcpagentsession,mutating=false,failurePolicy=fail,sideEffects=None,groups=mcpruntime.org,resources=mcpagentsessions,verbs=create;update,versions=v1alpha1,name=vmcpagentsession.kb.io,admissionReviewVersions=v1
func (r *MCPAgentSession) SetupWebhookWithManager(mgr ctrl.Manager) error {
return ctrl.NewWebhookManagedBy(mgr, r).
WithValidator(mcpAgentSessionValidator{}).
Expand Down Expand Up @@ -521,10 +552,6 @@ func (r *MCPAgentSession) validate() error {
if err := validateTeamIDField(specPath.Child("subject", "teamID"), r.Spec.Subject.TeamID); err != nil {
allErrs = append(allErrs, err)
}
now := nowFunc().UTC()
if r.Spec.ExpiresAt != nil && !r.Spec.ExpiresAt.Time.After(now) {
allErrs = append(allErrs, field.Invalid(specPath.Child("expiresAt"), r.Spec.ExpiresAt.Time.Format(time.RFC3339), "expiresAt must be in the future"))
}
if ref := r.Spec.UpstreamTokenSecretRef; ref != nil {
if strings.TrimSpace(ref.Name) == "" {
allErrs = append(allErrs, field.Required(specPath.Child("upstreamTokenSecretRef", "name"), "secret name is required"))
Expand Down
75 changes: 60 additions & 15 deletions api/v1alpha1/validation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,39 @@ func TestMCPAccessGrantValidateAllowsTeamOnlySubject(t *testing.T) {
}
}

func TestMCPAccessGrantValidateAllowsWildcardSubject(t *testing.T) {
grant := &MCPAccessGrant{
ObjectMeta: metav1.ObjectMeta{Name: "grant"},
Spec: MCPAccessGrantSpec{
ServerRef: ServerReference{Name: "payments"},
},
}

if err := grant.validate(); err != nil {
t.Fatalf("expected empty subject to remain a wildcard grant, got %v", err)
}
}

func TestMCPAccessGrantValidateCreateWarnsOnWildcardSubject(t *testing.T) {
grant := &MCPAccessGrant{
ObjectMeta: metav1.ObjectMeta{Name: "grant"},
Spec: MCPAccessGrantSpec{
ServerRef: ServerReference{Name: "payments"},
},
}

warnings, err := grant.ValidateCreate()
if err != nil {
t.Fatalf("expected wildcard grant to validate, got %v", err)
}
if len(warnings) != 1 {
t.Fatalf("expected one wildcard warning, got %d: %v", len(warnings), warnings)
}
if !strings.Contains(warnings[0], "wildcard grant") {
t.Fatalf("expected wildcard warning, got %q", warnings[0])
}
}

func TestMCPAccessGrantValidateRejectsWhitespaceTeamID(t *testing.T) {
grant := &MCPAccessGrant{
ObjectMeta: metav1.ObjectMeta{Name: "grant"},
Expand All @@ -80,30 +113,19 @@ func TestMCPAccessGrantValidateRejectsWhitespaceTeamID(t *testing.T) {
}
}

func TestMCPAgentSessionValidateUsesInjectedTimeSource(t *testing.T) {
fixedNow := time.Date(2026, time.March, 25, 12, 0, 0, 0, time.UTC)
originalNowFunc := nowFunc
nowFunc = func() time.Time { return fixedNow }
t.Cleanup(func() {
nowFunc = originalNowFunc
})

func TestMCPAgentSessionValidateAllowsExpiredSessionState(t *testing.T) {
session := &MCPAgentSession{
ObjectMeta: metav1.ObjectMeta{Name: "session"},
Spec: MCPAgentSessionSpec{
ServerRef: ServerReference{Name: "payments"},
Subject: SubjectRef{AgentID: "ops-agent"},
ConsentedTrust: TrustLevelMedium,
ExpiresAt: &metav1.Time{Time: fixedNow},
ExpiresAt: &metav1.Time{Time: time.Date(2026, time.March, 25, 12, 0, 0, 0, time.UTC)},
},
}

err := session.validate()
if err == nil {
t.Fatal("expected validation error for expired session")
}
if !strings.Contains(err.Error(), "expiresAt") {
t.Fatalf("expected expiresAt validation error, got %v", err)
if err := session.validate(); err != nil {
t.Fatalf("expired sessions should remain valid persisted state: %v", err)
}
}

Expand Down Expand Up @@ -179,6 +201,29 @@ func TestMCPServerDefault(t *testing.T) {
}
}

func TestMCPServerDefaultWithOptions(t *testing.T) {
server := &MCPServer{
ObjectMeta: metav1.ObjectMeta{Name: "test-server"},
Spec: MCPServerSpec{
Image: "example.com/mcp-server",
Gateway: &GatewayConfig{Enabled: true},
Analytics: &AnalyticsConfig{},
},
}

server.DefaultWithOptions(MCPServerDefaultOptions{
DefaultIngressHost: "mcp.example.com",
DefaultAnalyticsIngestURL: "http://mcp-sentinel-ingest.mcp-sentinel.svc.cluster.local:8081/events",
})

if server.Spec.IngressHost != "mcp.example.com" {
t.Fatalf("expected ingressHost default from options, got %q", server.Spec.IngressHost)
}
if server.Spec.Analytics == nil || server.Spec.Analytics.IngestURL != "http://mcp-sentinel-ingest.mcp-sentinel.svc.cluster.local:8081/events" {
t.Fatalf("expected analytics ingest URL default from options, got %#v", server.Spec.Analytics)
}
}

func TestMCPServerDefaultGatewayAuthTeamHeader(t *testing.T) {
server := &MCPServer{
ObjectMeta: metav1.ObjectMeta{Name: "test-server"},
Expand Down
15 changes: 15 additions & 0 deletions api/v1alpha1/zz_generated.deepcopy.go

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

9 changes: 8 additions & 1 deletion cmd/operator/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,10 +72,17 @@ func main() {
}

if webhooksEnabledFromEnv(os.Getenv) {
mcpServerWebhookOptions := mcpv1alpha1.MCPServerDefaultOptions{
DefaultIngressHost: os.Getenv("MCP_DEFAULT_INGRESS_HOST"),
DefaultAnalyticsIngestURL: analyticsIngestURLFromEnv(os.Getenv),
}
if err := (&mcpv1alpha1.MCPServer{}).SetupWebhookWithManagerWithOptions(mgr, mcpServerWebhookOptions); err != nil {
setupLog.Error(err, "unable to create webhook")
os.Exit(1)
}
for _, resource := range []interface {
SetupWebhookWithManager(ctrl.Manager) error
}{
&mcpv1alpha1.MCPServer{},
&mcpv1alpha1.MCPAccessGrant{},
&mcpv1alpha1.MCPAgentSession{},
} {
Expand Down
6 changes: 6 additions & 0 deletions config/webhook/kustomization.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# Standalone `kubectl apply -k config/webhook` does not inject clientConfig.caBundle.
# Use `mcp-runtime setup`, which reads ca.crt from the operator webhook TLS Secret,
# or manually patch Mutating/ValidatingWebhookConfiguration objects after apply.
resources:
- manifests.yaml
- service.yaml
Loading
Loading