Skip to content

Commit 7c304d4

Browse files
jhrozekJAORMX
andauthored
Add scopes field to MCPServer CRD for OIDC config (#2988)
Add Scopes field to InlineOIDCConfig in MCPServer, MCPRemoteProxy, and VirtualMCPServer CRDs. This allows operators to configure custom OAuth scopes that will be advertised in the well-known endpoint. Example usage in MCPServer: oidcConfig: type: inline inline: issuer: https://accounts.google.com scopes: - https://www.googleapis.com/auth/drive.readonly Related: #2783 Co-authored-by: Juan Antonio Osorio <[email protected]>
1 parent 6cfd20a commit 7c304d4

14 files changed

+277
-4
lines changed

cmd/thv-operator/api/v1alpha1/mcpserver_types.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -517,6 +517,11 @@ type InlineOIDCConfig struct {
517517
// +kubebuilder:default=false
518518
// +optional
519519
InsecureAllowHTTP bool `json:"insecureAllowHTTP"`
520+
521+
// Scopes is the list of OAuth scopes to advertise in the well-known endpoint (RFC 9728)
522+
// If empty, defaults to ["openid"]
523+
// +optional
524+
Scopes []string `json:"scopes,omitempty"`
520525
}
521526

522527
// AuthzConfigRef defines a reference to authorization configuration

cmd/thv-operator/api/v1alpha1/zz_generated.deepcopy.go

Lines changed: 5 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

cmd/thv-operator/pkg/controllerutil/oidc.go

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,6 @@ func AddOIDCConfigOptions(
3232
}
3333

3434
// Add OIDC config to options
35-
// Note: Scopes are passed as nil until the operator CRD supports them
3635
*options = append(*options, runner.WithOIDCConfig(
3736
oidcConfig.Issuer,
3837
oidcConfig.Audience,
@@ -45,7 +44,7 @@ func AddOIDCConfigOptions(
4544
oidcConfig.ResourceURL,
4645
oidcConfig.JWKSAllowPrivateIP,
4746
oidcConfig.InsecureAllowHTTP,
48-
nil, // Scopes - TODO: add operator CRD support
47+
oidcConfig.Scopes,
4948
))
5049

5150
return nil

cmd/thv-operator/pkg/oidc/resolver.go

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

910
corev1 "k8s.io/api/core/v1"
1011
"k8s.io/apimachinery/pkg/types"
@@ -35,6 +36,7 @@ type OIDCConfig struct { //nolint:revive // Keeping OIDCConfig name for backward
3536
ResourceURL string
3637
JWKSAllowPrivateIP bool
3738
InsecureAllowHTTP bool
39+
Scopes []string
3840
}
3941

4042
// OIDCConfigurable is an interface for resources that have OIDC configuration
@@ -194,6 +196,9 @@ func (r *resolver) resolveConfigMapConfig(
194196
config.InsecureAllowHTTP = true
195197
}
196198

199+
// Handle scopes as comma-separated values
200+
config.Scopes = parseCommaSeparatedList(getMapValue(configMap.Data, "scopes"))
201+
197202
return config, nil
198203
}
199204

@@ -225,6 +230,7 @@ func (*resolver) resolveInlineConfig(
225230
ResourceURL: resourceURL,
226231
JWKSAllowPrivateIP: config.JWKSAllowPrivateIP,
227232
InsecureAllowHTTP: config.InsecureAllowHTTP,
233+
Scopes: config.Scopes,
228234
}, nil
229235
}
230236

@@ -236,6 +242,28 @@ func getMapValue(data map[string]string, key string) string {
236242
return ""
237243
}
238244

245+
// parseCommaSeparatedList parses a comma-separated string into a slice of strings.
246+
// It trims whitespace from each element and filters out empty strings.
247+
func parseCommaSeparatedList(value string) []string {
248+
if value == "" {
249+
return nil
250+
}
251+
252+
parts := strings.Split(value, ",")
253+
result := make([]string, 0, len(parts))
254+
for _, part := range parts {
255+
trimmed := strings.TrimSpace(part)
256+
if trimmed != "" {
257+
result = append(result, trimmed)
258+
}
259+
}
260+
261+
if len(result) == 0 {
262+
return nil
263+
}
264+
return result
265+
}
266+
239267
// createServiceURL creates a service URL from MCPServer details
240268
func createServiceURL(name, namespace string, port int32) string {
241269
if port == 0 {

cmd/thv-operator/pkg/oidc/resolver_test.go

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,114 @@ func TestResolve_ConfigMapType(t *testing.T) {
261261
InsecureAllowHTTP: true,
262262
},
263263
},
264+
{
265+
name: "configmap with scopes",
266+
mcpServer: &mcpv1alpha1.MCPServer{
267+
ObjectMeta: metav1.ObjectMeta{
268+
Name: "scopes-server",
269+
Namespace: "test-ns",
270+
},
271+
Spec: mcpv1alpha1.MCPServerSpec{
272+
Port: 8080,
273+
OIDCConfig: &mcpv1alpha1.OIDCConfigRef{
274+
Type: mcpv1alpha1.OIDCConfigTypeConfigMap,
275+
ConfigMap: &mcpv1alpha1.ConfigMapOIDCRef{
276+
Name: "scopes-config",
277+
},
278+
},
279+
},
280+
},
281+
configMap: &corev1.ConfigMap{
282+
ObjectMeta: metav1.ObjectMeta{
283+
Name: "scopes-config",
284+
Namespace: "test-ns",
285+
},
286+
Data: map[string]string{
287+
"issuer": "https://auth.example.com",
288+
"audience": "test-audience",
289+
"scopes": "https://www.googleapis.com/auth/drive.readonly,https://www.googleapis.com/auth/documents.readonly",
290+
},
291+
},
292+
expected: &OIDCConfig{
293+
Issuer: "https://auth.example.com",
294+
Audience: "test-audience",
295+
ResourceURL: "http://scopes-server.test-ns.svc.cluster.local:8080",
296+
Scopes: []string{
297+
"https://www.googleapis.com/auth/drive.readonly",
298+
"https://www.googleapis.com/auth/documents.readonly",
299+
},
300+
},
301+
},
302+
{
303+
name: "configmap with scopes containing whitespace",
304+
mcpServer: &mcpv1alpha1.MCPServer{
305+
ObjectMeta: metav1.ObjectMeta{
306+
Name: "whitespace-scopes-server",
307+
Namespace: "test-ns",
308+
},
309+
Spec: mcpv1alpha1.MCPServerSpec{
310+
Port: 8080,
311+
OIDCConfig: &mcpv1alpha1.OIDCConfigRef{
312+
Type: mcpv1alpha1.OIDCConfigTypeConfigMap,
313+
ConfigMap: &mcpv1alpha1.ConfigMapOIDCRef{
314+
Name: "whitespace-scopes-config",
315+
},
316+
},
317+
},
318+
},
319+
configMap: &corev1.ConfigMap{
320+
ObjectMeta: metav1.ObjectMeta{
321+
Name: "whitespace-scopes-config",
322+
Namespace: "test-ns",
323+
},
324+
Data: map[string]string{
325+
"issuer": "https://auth.example.com",
326+
"audience": "test-audience",
327+
"scopes": "scope1 , scope2, scope3 ",
328+
},
329+
},
330+
expected: &OIDCConfig{
331+
Issuer: "https://auth.example.com",
332+
Audience: "test-audience",
333+
ResourceURL: "http://whitespace-scopes-server.test-ns.svc.cluster.local:8080",
334+
Scopes: []string{"scope1", "scope2", "scope3"},
335+
},
336+
},
337+
{
338+
name: "configmap with empty scopes",
339+
mcpServer: &mcpv1alpha1.MCPServer{
340+
ObjectMeta: metav1.ObjectMeta{
341+
Name: "empty-scopes-server",
342+
Namespace: "test-ns",
343+
},
344+
Spec: mcpv1alpha1.MCPServerSpec{
345+
Port: 8080,
346+
OIDCConfig: &mcpv1alpha1.OIDCConfigRef{
347+
Type: mcpv1alpha1.OIDCConfigTypeConfigMap,
348+
ConfigMap: &mcpv1alpha1.ConfigMapOIDCRef{
349+
Name: "empty-scopes-config",
350+
},
351+
},
352+
},
353+
},
354+
configMap: &corev1.ConfigMap{
355+
ObjectMeta: metav1.ObjectMeta{
356+
Name: "empty-scopes-config",
357+
Namespace: "test-ns",
358+
},
359+
Data: map[string]string{
360+
"issuer": "https://auth.example.com",
361+
"audience": "test-audience",
362+
"scopes": "",
363+
},
364+
},
365+
expected: &OIDCConfig{
366+
Issuer: "https://auth.example.com",
367+
Audience: "test-audience",
368+
ResourceURL: "http://empty-scopes-server.test-ns.svc.cluster.local:8080",
369+
Scopes: nil,
370+
},
371+
},
264372
{
265373
name: "configmap not found",
266374
mcpServer: &mcpv1alpha1.MCPServer{
@@ -430,6 +538,32 @@ func TestResolve_InlineType(t *testing.T) {
430538
InsecureAllowHTTP: true,
431539
},
432540
},
541+
{
542+
name: "inline with scopes",
543+
mcpServer: &mcpv1alpha1.MCPServer{
544+
ObjectMeta: metav1.ObjectMeta{
545+
Name: "scopes-inline-server",
546+
Namespace: "test-ns",
547+
},
548+
Spec: mcpv1alpha1.MCPServerSpec{
549+
Port: 8080,
550+
OIDCConfig: &mcpv1alpha1.OIDCConfigRef{
551+
Type: mcpv1alpha1.OIDCConfigTypeInline,
552+
Inline: &mcpv1alpha1.InlineOIDCConfig{
553+
Issuer: "https://auth.example.com",
554+
Audience: "test-audience",
555+
Scopes: []string{"openid", "profile", "email"},
556+
},
557+
},
558+
},
559+
},
560+
expected: &OIDCConfig{
561+
Issuer: "https://auth.example.com",
562+
Audience: "test-audience",
563+
ResourceURL: "http://scopes-inline-server.test-ns.svc.cluster.local:8080",
564+
Scopes: []string{"openid", "profile", "email"},
565+
},
566+
},
433567
{
434568
name: "nil inline config returns nil",
435569
mcpServer: &mcpv1alpha1.MCPServer{
@@ -619,3 +753,62 @@ func TestResolve_InlineWithClientSecretRef(t *testing.T) {
619753
})
620754
}
621755
}
756+
757+
func TestParseCommaSeparatedList(t *testing.T) {
758+
t.Parallel()
759+
760+
tests := []struct {
761+
name string
762+
input string
763+
expected []string
764+
}{
765+
{
766+
name: "empty string",
767+
input: "",
768+
expected: nil,
769+
},
770+
{
771+
name: "single value",
772+
input: "scope1",
773+
expected: []string{"scope1"},
774+
},
775+
{
776+
name: "multiple values",
777+
input: "scope1,scope2,scope3",
778+
expected: []string{"scope1", "scope2", "scope3"},
779+
},
780+
{
781+
name: "values with whitespace",
782+
input: " scope1 , scope2 , scope3 ",
783+
expected: []string{"scope1", "scope2", "scope3"},
784+
},
785+
{
786+
name: "values with URLs",
787+
input: "https://www.googleapis.com/auth/drive.readonly,https://www.googleapis.com/auth/documents.readonly",
788+
expected: []string{"https://www.googleapis.com/auth/drive.readonly", "https://www.googleapis.com/auth/documents.readonly"},
789+
},
790+
{
791+
name: "empty values filtered out",
792+
input: "scope1,,scope2, ,scope3",
793+
expected: []string{"scope1", "scope2", "scope3"},
794+
},
795+
{
796+
name: "only commas and whitespace",
797+
input: ", , , ",
798+
expected: nil,
799+
},
800+
{
801+
name: "single whitespace-only value",
802+
input: " ",
803+
expected: nil,
804+
},
805+
}
806+
807+
for _, tt := range tests {
808+
t.Run(tt.name, func(t *testing.T) {
809+
t.Parallel()
810+
result := parseCommaSeparatedList(tt.input)
811+
assert.Equal(t, tt.expected, result)
812+
})
813+
}
814+
}

deploy/charts/operator-crds/Chart.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,5 @@ apiVersion: v2
22
name: toolhive-operator-crds
33
description: A Helm chart for installing the ToolHive Operator CRDs into Kubernetes.
44
type: application
5-
version: 0.0.80
5+
version: 0.0.81
66
appVersion: "0.0.1"

deploy/charts/operator-crds/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# ToolHive Operator CRDs Helm Chart
22

3-
![Version: 0.0.80](https://img.shields.io/badge/Version-0.0.80-informational?style=flat-square)
3+
![Version: 0.0.81](https://img.shields.io/badge/Version-0.0.81-informational?style=flat-square)
44
![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square)
55

66
A Helm chart for installing the ToolHive Operator CRDs into Kubernetes.

deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpremoteproxies.yaml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,13 @@ spec:
211211
ProtectedResourceAllowPrivateIP allows protected resource endpoint on private IP addresses
212212
Use with caution - only enable for trusted internal IDPs or testing
213213
type: boolean
214+
scopes:
215+
description: |-
216+
Scopes is the list of OAuth scopes to advertise in the well-known endpoint (RFC 9728)
217+
If empty, defaults to ["openid"]
218+
items:
219+
type: string
220+
type: array
214221
thvCABundlePath:
215222
description: |-
216223
ThvCABundlePath is the path to CA certificate bundle file for HTTPS requests

deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpservers.yaml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,13 @@ spec:
241241
ProtectedResourceAllowPrivateIP allows protected resource endpoint on private IP addresses
242242
Use with caution - only enable for trusted internal IDPs or testing
243243
type: boolean
244+
scopes:
245+
description: |-
246+
Scopes is the list of OAuth scopes to advertise in the well-known endpoint (RFC 9728)
247+
If empty, defaults to ["openid"]
248+
items:
249+
type: string
250+
type: array
244251
thvCABundlePath:
245252
description: |-
246253
ThvCABundlePath is the path to CA certificate bundle file for HTTPS requests

deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_virtualmcpservers.yaml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -537,6 +537,13 @@ spec:
537537
ProtectedResourceAllowPrivateIP allows protected resource endpoint on private IP addresses
538538
Use with caution - only enable for trusted internal IDPs or testing
539539
type: boolean
540+
scopes:
541+
description: |-
542+
Scopes is the list of OAuth scopes to advertise in the well-known endpoint (RFC 9728)
543+
If empty, defaults to ["openid"]
544+
items:
545+
type: string
546+
type: array
540547
thvCABundlePath:
541548
description: |-
542549
ThvCABundlePath is the path to CA certificate bundle file for HTTPS requests

0 commit comments

Comments
 (0)