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
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@
package controllers

import (
"fmt"
"strings"

. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"

Expand Down Expand Up @@ -53,4 +56,83 @@ var _ = Describe("CEL Validation for embedding provider on VirtualMCPServer",
err := k8sClient.Create(ctx, vmcp)
Expect(err).NotTo(HaveOccurred())
})

It("should reject embeddingHeaders with the tei provider", func() {
vmcp := newVirtualMCPServerWithOptimizer("vmcp-headers-tei",
&vmcpconfig.OptimizerConfig{
EmbeddingProvider: "tei",
EmbeddingService: "http://embeddings.example:8080",
EmbeddingHeaders: map[string]vmcpconfig.EmbeddingHeaderValue{"x-cache-key": "toolhive-optimizer"},
})
err := k8sClient.Create(ctx, vmcp)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring(
"embeddingHeaders is only supported when embeddingProvider is 'openai'"))
})

It("should reject embeddingHeaders when the provider is defaulted to tei", func() {
vmcp := newVirtualMCPServerWithOptimizer("vmcp-headers-default",
&vmcpconfig.OptimizerConfig{
EmbeddingService: "http://embeddings.example:8080",
EmbeddingHeaders: map[string]vmcpconfig.EmbeddingHeaderValue{"x-cache-key": "toolhive-optimizer"},
})
err := k8sClient.Create(ctx, vmcp)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring(
"embeddingHeaders is only supported when embeddingProvider is 'openai'"))
})

It("should accept embeddingHeaders with the openai provider", func() {
vmcp := newVirtualMCPServerWithOptimizer("vmcp-headers-openai",
&vmcpconfig.OptimizerConfig{
EmbeddingProvider: "openai",
EmbeddingService: "http://gateway.example:8080",
EmbeddingModel: "text-embedding-3-small",
EmbeddingHeaders: map[string]vmcpconfig.EmbeddingHeaderValue{"x-cache-key": "toolhive-optimizer"},
})
err := k8sClient.Create(ctx, vmcp)
Expect(err).NotTo(HaveOccurred())
})

It("should reject reserved names, invalid names, and unsafe values in embeddingHeaders", func() {
for i, tc := range []struct {
headers map[string]vmcpconfig.EmbeddingHeaderValue
want string
}{
{map[string]vmcpconfig.EmbeddingHeaderValue{"Authorization": "Bearer x"}, "must not include Authorization or Content-Type"},
{map[string]vmcpconfig.EmbeddingHeaderValue{"authorization": "Bearer x"}, "must not include Authorization or Content-Type"},
{map[string]vmcpconfig.EmbeddingHeaderValue{"Content-Type": "application/json"}, "must not include Authorization or Content-Type"},
{map[string]vmcpconfig.EmbeddingHeaderValue{"content-type": "application/json"}, "must not include Authorization or Content-Type"},
{map[string]vmcpconfig.EmbeddingHeaderValue{"": "value"}, "names must be valid HTTP header names"},
{map[string]vmcpconfig.EmbeddingHeaderValue{"x bad": "value"}, "names must be valid HTTP header names"},
{map[string]vmcpconfig.EmbeddingHeaderValue{"x-cache-key": ""}, "should be at least 1 chars long"},
{map[string]vmcpconfig.EmbeddingHeaderValue{"x-cache-key": "a\r\nb"}, "should match"},
{map[string]vmcpconfig.EmbeddingHeaderValue{
"x-cache-key": vmcpconfig.EmbeddingHeaderValue(strings.Repeat("a", 8193)),
}, "8192"},
} {
vmcp := newVirtualMCPServerWithOptimizer(fmt.Sprintf("vmcp-headers-reject-%d", i),
&vmcpconfig.OptimizerConfig{
EmbeddingProvider: "openai",
EmbeddingService: "http://gateway.example:8080",
EmbeddingModel: "text-embedding-3-small",
EmbeddingHeaders: tc.headers,
})
err := k8sClient.Create(ctx, vmcp)
Expect(err).To(HaveOccurred(), "headers %v should be rejected", tc.headers)
Expect(err.Error()).To(ContainSubstring(tc.want))
}
})

It("should accept uncommon but valid RFC token characters in header names", func() {
vmcp := newVirtualMCPServerWithOptimizer("vmcp-headers-token-chars",
&vmcpconfig.OptimizerConfig{
EmbeddingProvider: "openai",
EmbeddingService: "http://gateway.example:8080",
EmbeddingModel: "text-embedding-3-small",
EmbeddingHeaders: map[string]vmcpconfig.EmbeddingHeaderValue{"x-key'name`x": "value"},
})
err := k8sClient.Create(ctx, vmcp)
Expect(err).NotTo(HaveOccurred())
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -1810,6 +1810,22 @@ spec:
instead of all backend tools directly. This reduces token usage by allowing
LLMs to discover relevant tools on demand rather than receiving all tool definitions.
properties:
embeddingHeaders:
additionalProperties:
description: |-
EmbeddingHeaderValue is a custom embedding request header value: 1 to 8192
characters with no control characters other than tab.
maxLength: 8192
minLength: 1
pattern: ^[^\x00-\x08\x0A-\x1F\x7F]*$
type: string
description: |-
EmbeddingHeaders holds additional HTTP headers sent with every embedding
request. Only supported when EmbeddingProvider is "openai". Values are
stored in plain text and must not contain secrets; Authorization
(derived from OPENAI_API_KEY) and Content-Type cannot be set.
maxProperties: 32
type: object
embeddingModel:
description: |-
EmbeddingModel is the model name requested from the embedding service
Expand Down Expand Up @@ -1887,6 +1903,16 @@ spec:
pattern: ^([0-9]*[.])?[0-9]+$
type: string
type: object
x-kubernetes-validations:
- message: embeddingHeaders is only supported when embeddingProvider
is 'openai'
rule: '!has(self.embeddingHeaders) || (has(self.embeddingProvider)
&& self.embeddingProvider == ''openai'')'
- message: embeddingHeaders names must be valid HTTP header names
and must not include Authorization or Content-Type
rule: '!has(self.embeddingHeaders) || self.embeddingHeaders.all(k,
k.matches(''^[!#$%&\\x27*+.^_\\x60|~0-9A-Za-z-]+$'') && !(k.lowerAscii()
in [''authorization'', ''content-type'']))'
outgoingAuth:
description: |-
OutgoingAuth configures how the virtual MCP server authenticates to backends.
Expand Down Expand Up @@ -5216,6 +5242,22 @@ spec:
instead of all backend tools directly. This reduces token usage by allowing
LLMs to discover relevant tools on demand rather than receiving all tool definitions.
properties:
embeddingHeaders:
additionalProperties:
description: |-
EmbeddingHeaderValue is a custom embedding request header value: 1 to 8192
characters with no control characters other than tab.
maxLength: 8192
minLength: 1
pattern: ^[^\x00-\x08\x0A-\x1F\x7F]*$
type: string
description: |-
EmbeddingHeaders holds additional HTTP headers sent with every embedding
request. Only supported when EmbeddingProvider is "openai". Values are
stored in plain text and must not contain secrets; Authorization
(derived from OPENAI_API_KEY) and Content-Type cannot be set.
maxProperties: 32
type: object
embeddingModel:
description: |-
EmbeddingModel is the model name requested from the embedding service
Expand Down Expand Up @@ -5293,6 +5335,16 @@ spec:
pattern: ^([0-9]*[.])?[0-9]+$
type: string
type: object
x-kubernetes-validations:
- message: embeddingHeaders is only supported when embeddingProvider
is 'openai'
rule: '!has(self.embeddingHeaders) || (has(self.embeddingProvider)
&& self.embeddingProvider == ''openai'')'
- message: embeddingHeaders names must be valid HTTP header names
and must not include Authorization or Content-Type
rule: '!has(self.embeddingHeaders) || self.embeddingHeaders.all(k,
k.matches(''^[!#$%&\\x27*+.^_\\x60|~0-9A-Za-z-]+$'') && !(k.lowerAscii()
in [''authorization'', ''content-type'']))'
outgoingAuth:
description: |-
OutgoingAuth configures how the virtual MCP server authenticates to backends.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1813,6 +1813,22 @@ spec:
instead of all backend tools directly. This reduces token usage by allowing
LLMs to discover relevant tools on demand rather than receiving all tool definitions.
properties:
embeddingHeaders:
additionalProperties:
description: |-
EmbeddingHeaderValue is a custom embedding request header value: 1 to 8192
characters with no control characters other than tab.
maxLength: 8192
minLength: 1
pattern: ^[^\x00-\x08\x0A-\x1F\x7F]*$
type: string
description: |-
EmbeddingHeaders holds additional HTTP headers sent with every embedding
request. Only supported when EmbeddingProvider is "openai". Values are
stored in plain text and must not contain secrets; Authorization
(derived from OPENAI_API_KEY) and Content-Type cannot be set.
maxProperties: 32
type: object
embeddingModel:
description: |-
EmbeddingModel is the model name requested from the embedding service
Expand Down Expand Up @@ -1890,6 +1906,16 @@ spec:
pattern: ^([0-9]*[.])?[0-9]+$
type: string
type: object
x-kubernetes-validations:
- message: embeddingHeaders is only supported when embeddingProvider
is 'openai'
rule: '!has(self.embeddingHeaders) || (has(self.embeddingProvider)
&& self.embeddingProvider == ''openai'')'
- message: embeddingHeaders names must be valid HTTP header names
and must not include Authorization or Content-Type
rule: '!has(self.embeddingHeaders) || self.embeddingHeaders.all(k,
k.matches(''^[!#$%&\\x27*+.^_\\x60|~0-9A-Za-z-]+$'') && !(k.lowerAscii()
in [''authorization'', ''content-type'']))'
outgoingAuth:
description: |-
OutgoingAuth configures how the virtual MCP server authenticates to backends.
Expand Down Expand Up @@ -5219,6 +5245,22 @@ spec:
instead of all backend tools directly. This reduces token usage by allowing
LLMs to discover relevant tools on demand rather than receiving all tool definitions.
properties:
embeddingHeaders:
additionalProperties:
description: |-
EmbeddingHeaderValue is a custom embedding request header value: 1 to 8192
characters with no control characters other than tab.
maxLength: 8192
minLength: 1
pattern: ^[^\x00-\x08\x0A-\x1F\x7F]*$
type: string
description: |-
EmbeddingHeaders holds additional HTTP headers sent with every embedding
request. Only supported when EmbeddingProvider is "openai". Values are
stored in plain text and must not contain secrets; Authorization
(derived from OPENAI_API_KEY) and Content-Type cannot be set.
maxProperties: 32
type: object
embeddingModel:
description: |-
EmbeddingModel is the model name requested from the embedding service
Expand Down Expand Up @@ -5296,6 +5338,16 @@ spec:
pattern: ^([0-9]*[.])?[0-9]+$
type: string
type: object
x-kubernetes-validations:
- message: embeddingHeaders is only supported when embeddingProvider
is 'openai'
rule: '!has(self.embeddingHeaders) || (has(self.embeddingProvider)
&& self.embeddingProvider == ''openai'')'
- message: embeddingHeaders names must be valid HTTP header names
and must not include Authorization or Content-Type
rule: '!has(self.embeddingHeaders) || self.embeddingHeaders.all(k,
k.matches(''^[!#$%&\\x27*+.^_\\x60|~0-9A-Za-z-]+$'') && !(k.lowerAscii()
in [''authorization'', ''content-type'']))'
outgoingAuth:
description: |-
OutgoingAuth configures how the virtual MCP server authenticates to backends.
Expand Down
3 changes: 3 additions & 0 deletions docs/operator/crd-api.md

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

4 changes: 4 additions & 0 deletions examples/operator/virtual-mcps/vmcp_optimizer_openai.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,10 @@ spec:
# Model requested from the service (required for the openai provider).
embeddingModel: text-embedding-3-small
embeddingServiceTimeout: 15s
# Optional extra headers sent with every embedding request (e.g. a
# gateway cache-scoping key). Plain text — never put secrets here.
embeddingHeaders:
x-cache-key: toolhive-optimizer

incomingAuth:
type: anonymous
Expand Down
Loading
Loading