Skip to content

Commit 0b35d8d

Browse files
authored
add mcp ingress annotations (#1178)
## Summary Adds annotations for experimental model context protocol support in Pomerium. MCP support must be enabled with `runtime_flags: mcp: true` in the Pomerium global config. ## Related issues <!-- For example... Fixes #159 --> ## Checklist - [ ] reference any related issues - [ ] updated docs - [ ] updated unit tests - [ ] updated UPGRADING.md - [x] add appropriate tag (`improvement` / `bug` / etc) - [x] ready for review
1 parent 6584411 commit 0b35d8d

4 files changed

Lines changed: 493 additions & 8 deletions

File tree

Makefile

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,10 +128,12 @@ internal/ui/dist/index.js: internal/ui/node_modules
128128
@echo "==> $@"
129129
@cd internal/ui && yarn build
130130

131+
# run the controller locally (i.e. with docker-desktop)
132+
# that assumes that the CRDs and ingress class are already installed
131133
.PHONY: run
132134
run: generated
133135
@echo "==> $@"
134-
@go run $(GOTAGS) ./main.go
136+
@go run $(GOTAGS) ./main.go all-in-one --pomerium-config global --update-status-from-service=pomerium/pomerium-proxy
135137

136138
.PHONY: docker-build
137139
docker-build: build test ## Build docker image with the manager.

model/ingress_config.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,22 @@ const (
5252
CAKey = "ca.crt"
5353
// SSHPrivateKey is the ssh privatekey secret key
5454
SSHPrivateKey = "ssh-privatekey"
55+
// MCPServer indicates this route is an MCP server without any additional configuration
56+
MCPServer = "mcp_server"
57+
// MCPClient indicates this route is an MCP client without any additional configuration
58+
MCPClient = "mcp_client"
59+
// MCPServerMaxRequestBytes sets the maximum request body size for MCP server routes
60+
MCPServerMaxRequestBytes = "mcp_server_max_request_bytes"
61+
// MCPServerUpstreamOAuth2Secret references a secret containing OAuth2 configuration for MCP server upstream authentication
62+
MCPServerUpstreamOAuth2Secret = "mcp_server_upstream_oauth2_secret" //nolint: gosec
63+
// MCPServerUpstreamOAuth2TokenURL sets the OAuth2 token URL for MCP server upstream authentication
64+
MCPServerUpstreamOAuth2TokenURL = "mcp_server_upstream_oauth2_token_url" //nolint: gosec
65+
// MCPServerUpstreamOAuth2Scopes sets the OAuth2 scopes for MCP server upstream authentication
66+
MCPServerUpstreamOAuth2Scopes = "mcp_server_upstream_oauth2_scopes"
67+
// MCPServerUpstreamOAuth2ClientIDKey defines the key within the OAuth2 secret that contains the client ID
68+
MCPServerUpstreamOAuth2ClientIDKey = "client_id"
69+
// MCPServerUpstreamOAuth2ClientSecretKey defines the key within the OAuth2 secret that contains the client secret
70+
MCPServerUpstreamOAuth2ClientSecretKey = "client_secret"
5571
)
5672

5773
// SSHSecrets is a grouping of ssh-related secrets.

pomerium/ingress_annotations.go

Lines changed: 173 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package pomerium
33
import (
44
"encoding/base64"
55
"fmt"
6+
"strconv"
67
"strings"
78

89
envoy_config_cluster_v3 "github.com/envoyproxy/go-control-plane/envoy/config/cluster/v3"
@@ -72,6 +73,16 @@ var (
7273
model.KubernetesServiceAccountTokenSecret,
7374
model.SetRequestHeadersSecret,
7475
model.SetResponseHeadersSecret,
76+
model.MCPServerUpstreamOAuth2Secret,
77+
})
78+
mcpServerAnnotations = boolMap([]string{
79+
model.MCPServer,
80+
model.MCPServerMaxRequestBytes,
81+
model.MCPServerUpstreamOAuth2TokenURL,
82+
model.MCPServerUpstreamOAuth2Scopes,
83+
})
84+
mcpClientAnnotations = boolMap([]string{
85+
model.MCPClient,
7586
})
7687
handledElsewhere = boolMap([]string{
7788
model.PathRegex,
@@ -95,18 +106,20 @@ func boolMap(keys []string) map[string]bool {
95106
}
96107

97108
type keys struct {
98-
Base, Envoy, Policy, TLS, Etc, Secret map[string]string
109+
Base, Envoy, Policy, TLS, Etc, Secret, MCPServer, MCPClient map[string]string
99110
}
100111

101112
func removeKeyPrefix(src map[string]string, prefix string) (*keys, error) {
102113
prefix = fmt.Sprintf("%s/", prefix)
103114
kv := keys{
104-
Base: make(map[string]string),
105-
Envoy: make(map[string]string),
106-
Policy: make(map[string]string),
107-
TLS: make(map[string]string),
108-
Etc: make(map[string]string),
109-
Secret: make(map[string]string),
115+
Base: make(map[string]string),
116+
Envoy: make(map[string]string),
117+
Policy: make(map[string]string),
118+
TLS: make(map[string]string),
119+
Etc: make(map[string]string),
120+
Secret: make(map[string]string),
121+
MCPServer: make(map[string]string),
122+
MCPClient: make(map[string]string),
110123
}
111124

112125
for k, v := range src {
@@ -129,6 +142,8 @@ func removeKeyPrefix(src map[string]string, prefix string) (*keys, error) {
129142
{policyAnnotations, kv.Policy},
130143
{tlsAnnotations, kv.TLS},
131144
{secretAnnotations, kv.Secret},
145+
{mcpServerAnnotations, kv.MCPServer},
146+
{mcpClientAnnotations, kv.MCPClient},
132147
{handledElsewhere, kv.Etc},
133148
} {
134149
if m.keys[k] {
@@ -167,6 +182,9 @@ func applyAnnotations(
167182
if err = applySecretAnnotations(r, kv.Secret, ic.Secrets, ic.Ingress.Namespace); err != nil {
168183
return err
169184
}
185+
if err = applyMCPAnnotations(r, kv.MCPServer, kv.MCPClient); err != nil {
186+
return err
187+
}
170188
p := new(pomerium.Policy)
171189
r.Policies = []*pomerium.Policy{p}
172190
if err := unmarshalPolicyAnnotations(p, kv.Policy); err != nil {
@@ -285,6 +303,47 @@ func applySecretAnnotations(
285303
return nil
286304
},
287305
},
306+
model.MCPServerUpstreamOAuth2Secret: {
307+
corev1.SecretTypeOpaque,
308+
func(data map[string][]byte) error {
309+
clientID, hasClientID := data[model.MCPServerUpstreamOAuth2ClientIDKey]
310+
clientSecret, hasClientSecret := data[model.MCPServerUpstreamOAuth2ClientSecretKey]
311+
312+
if !hasClientID && !hasClientSecret {
313+
return fmt.Errorf("secret must have at least one of %s or %s keys",
314+
model.MCPServerUpstreamOAuth2ClientIDKey, model.MCPServerUpstreamOAuth2ClientSecretKey)
315+
}
316+
317+
if r.Mcp == nil {
318+
r.Mcp = &pomerium.MCP{
319+
Mode: &pomerium.MCP_Server{
320+
Server: &pomerium.MCPServer{},
321+
},
322+
}
323+
} else if r.Mcp.GetServer() == nil {
324+
if r.Mcp.GetClient() != nil {
325+
return fmt.Errorf("route is already configured as MCP client, cannot add OAuth2 secret")
326+
}
327+
r.Mcp.Mode = &pomerium.MCP_Server{
328+
Server: &pomerium.MCPServer{},
329+
}
330+
}
331+
332+
server := r.Mcp.GetServer()
333+
if server.UpstreamOauth2 == nil {
334+
server.UpstreamOauth2 = &pomerium.UpstreamOAuth2{}
335+
}
336+
337+
if hasClientID {
338+
server.UpstreamOauth2.ClientId = string(clientID)
339+
}
340+
if hasClientSecret {
341+
server.UpstreamOauth2.ClientSecret = string(clientSecret)
342+
}
343+
344+
return nil
345+
},
346+
},
288347
}
289348

290349
for key, secretName := range annotations {
@@ -310,6 +369,113 @@ func applySecretAnnotations(
310369
return nil
311370
}
312371

372+
func applyMCPAnnotations(r *pomerium.Route, serverKVs, clientKVs map[string]string) error {
373+
hasServer := len(serverKVs) > 0
374+
hasClient := len(clientKVs) > 0
375+
376+
if hasServer && hasClient {
377+
return fmt.Errorf("cannot specify both MCP server and client configurations")
378+
}
379+
380+
if hasServer {
381+
return applyMCPServerAnnotations(r, serverKVs)
382+
}
383+
384+
if hasClient {
385+
return applyMCPClientAnnotations(r, clientKVs)
386+
}
387+
388+
r.Mcp = nil
389+
return nil
390+
}
391+
392+
func applyMCPServerAnnotations(r *pomerium.Route, kvs map[string]string) error {
393+
// Initialize or get existing server config
394+
var serverConfig *pomerium.MCPServer
395+
if r.Mcp != nil && r.Mcp.GetServer() != nil {
396+
serverConfig = r.Mcp.GetServer()
397+
} else {
398+
serverConfig = &pomerium.MCPServer{}
399+
}
400+
401+
for k, v := range kvs {
402+
if v == "" && k != model.MCPServer {
403+
continue
404+
}
405+
switch k {
406+
case model.MCPServer:
407+
if v != "" && v != "true" {
408+
return fmt.Errorf("mcp_server annotation should be 'true' or omitted, got %q", v)
409+
}
410+
continue
411+
case model.MCPServerMaxRequestBytes:
412+
val, err := strconv.ParseUint(v, 10, 32)
413+
if err != nil {
414+
return fmt.Errorf("invalid max_request_bytes value %q: %w", v, err)
415+
}
416+
maxBytes := uint32(val)
417+
serverConfig.MaxRequestBytes = &maxBytes
418+
case model.MCPServerUpstreamOAuth2TokenURL:
419+
if serverConfig.UpstreamOauth2 == nil {
420+
serverConfig.UpstreamOauth2 = &pomerium.UpstreamOAuth2{}
421+
}
422+
if serverConfig.UpstreamOauth2.Oauth2Endpoint == nil {
423+
serverConfig.UpstreamOauth2.Oauth2Endpoint = &pomerium.OAuth2Endpoint{}
424+
}
425+
serverConfig.UpstreamOauth2.Oauth2Endpoint.TokenUrl = v
426+
case model.MCPServerUpstreamOAuth2Scopes:
427+
if serverConfig.UpstreamOauth2 == nil {
428+
serverConfig.UpstreamOauth2 = &pomerium.UpstreamOAuth2{}
429+
}
430+
serverConfig.UpstreamOauth2.Scopes = strings.Split(v, ",")
431+
for i, scope := range serverConfig.UpstreamOauth2.Scopes {
432+
serverConfig.UpstreamOauth2.Scopes[i] = strings.TrimSpace(scope)
433+
}
434+
default:
435+
return fmt.Errorf("unknown MCP server annotation %s", k)
436+
}
437+
}
438+
439+
// Only create new MCP structure if it doesn't exist
440+
if r.Mcp == nil {
441+
r.Mcp = &pomerium.MCP{
442+
Mode: &pomerium.MCP_Server{
443+
Server: serverConfig,
444+
},
445+
}
446+
} else if r.Mcp.GetServer() == nil {
447+
if r.Mcp.GetClient() != nil {
448+
return fmt.Errorf("route is already configured as MCP client, cannot add server configuration")
449+
}
450+
r.Mcp.Mode = &pomerium.MCP_Server{
451+
Server: serverConfig,
452+
}
453+
}
454+
return nil
455+
}
456+
457+
func applyMCPClientAnnotations(r *pomerium.Route, kvs map[string]string) error {
458+
for k, v := range kvs {
459+
switch k {
460+
case model.MCPClient:
461+
if v != "" && v != "true" {
462+
return fmt.Errorf("mcp_client annotation should be 'true' or omitted, got %q", v)
463+
}
464+
continue
465+
default:
466+
return fmt.Errorf("unknown MCP client annotation %s", k)
467+
}
468+
}
469+
470+
clientConfig := &pomerium.MCPClient{}
471+
r.Mcp = &pomerium.MCP{
472+
Mode: &pomerium.MCP_Client{
473+
Client: clientConfig,
474+
},
475+
}
476+
return nil
477+
}
478+
313479
func b64(secret *corev1.Secret, annotation, key string) (string, error) {
314480
data := secret.Data[key]
315481
if len(data) == 0 {

0 commit comments

Comments
 (0)